diff --git a/.dockerignore b/.dockerignore index e4995fe58..99976ae65 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,6 @@ .git +.github +.vscode node_modules npm-debug.log readme* @@ -8,3 +10,5 @@ Start.bat cloudflared.exe access.log /data +/cache +.DS_Store diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 53be9963f..92a02412a 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -1,5 +1,5 @@ name: Bug Report 🐛 -description: Report something that's not working the intended way. Support requests for external programs (reverse proxies, 3rd party servers, other peoples' forks) will be refused! +description: Report something that's not working the intended way. Support requests for external programs (reverse proxies, 3rd party servers, other peoples' forks) will be refused! Please use English only. title: '[BUG] ' labels: ['🐛 Bug'] body: diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index bbb97465e..088a8acf3 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -1,5 +1,5 @@ name: Feature Request ✨ -description: Suggest an idea for future development of this project +description: Suggest an idea for future development of this project. Please use English only. title: '[FEATURE_REQUEST] <title>' labels: ['🦄 Feature Request'] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..608850799 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,5 @@ +<!-- Put X in the box below to confirm --> + +## Checklist: + +- [ ] I have read the [Contributing guidelines](https://github.com/SillyTavern/SillyTavern/blob/release/CONTRIBUTING.md). diff --git a/.gitignore b/.gitignore index fbef3d33f..4be6774fb 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ public/css/user.css /plugins/ /data /default/scaffold +public/scripts/extensions/third-party diff --git a/.npmignore b/.npmignore index 10082773f..150ad23aa 100644 --- a/.npmignore +++ b/.npmignore @@ -5,4 +5,9 @@ node_modules/ secrets.json /dist /backups/ +/data +/cache access.log +.github +.vscode +.git diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..84253f028 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# How to contribute to SillyTavern + +## Setting up the dev environment + +1. Required software: git and node. +2. Recommended editor: Visual Studio Code. +3. You can also use GitHub Codespaces which sets up everything for you. + +## Getting the code ready + +1. Register a GitHub account. +2. Fork this repository under your account. +3. Clone the fork onto your machine. +4. Open the cloned repository in the code editor. +5. Create a git branch (recommended). +6. Make your changes and test them locally. +7. Commit the changes and push the branch to the remote repo. +8. Go to GitHub, and open a pull request, targeting the upstream branch. + +## Contribution guidelines + +1. Our standards are pretty low, but make sure the code is not too ugly: + - Run VS Code's autoformat when you're done. + - Check with ESLint by running `npm run lint`, then fix the errors. + - Use common sense and follow existing naming conventions. +2. Create pull requests for the staging branch, 99% of contributions should go there. That way people could test your code before the next stable release. +3. You can still send a pull request for release in the following scenarios: + - Updating README. + - Updating GitHub Actions. + - Hotfixing a critical bug. +4. Project maintainers will test and can change your code before merging. +5. Mind the license. Your contributions will be licensed under the GNU Affero General Public License. If you don't know what that implies, consult your lawyer. diff --git a/default/config.yaml b/default/config.yaml index 0b679f7c2..9c8c02de2 100644 --- a/default/config.yaml +++ b/default/config.yaml @@ -48,6 +48,8 @@ allowKeysExposure: false skipContentCheck: false # Disable automatic chats backup disableChatBackup: false +# Number of backups to keep for each chat and settings file +numberOfBackups: 50 # Allowed hosts for card downloads whitelistImportDomains: - localhost diff --git a/default/content/index.json b/default/content/index.json index 82cce2384..de5316875 100644 --- a/default/content/index.json +++ b/default/content/index.json @@ -11,6 +11,18 @@ "filename": "themes/Cappuccino.json", "type": "theme" }, + { + "filename": "themes/Celestial Macaron.json", + "type": "theme" + }, + { + "filename": "themes/Dark V 1.0.json", + "type": "theme" + }, + { + "filename": "themes/Azure.json", + "type": "theme" + }, { "filename": "backgrounds/__transparent.png", "type": "background" @@ -666,5 +678,13 @@ { "filename": "presets/context/ChatML-Names.json", "type": "context" + }, + { + "filename": "presets/context/Gemma 2.json", + "type": "context" + }, + { + "filename": "presets/instruct/Gemma 2.json", + "type": "instruct" } ] diff --git a/default/content/presets/context/Gemma 2.json b/default/content/presets/context/Gemma 2.json new file mode 100644 index 000000000..7e8dba55b --- /dev/null +++ b/default/content/presets/context/Gemma 2.json @@ -0,0 +1,12 @@ +{ + "story_string": "<start_of_turn>user\n{{#if system}}{{system}}\n{{/if}}{{#if wiBefore}}{{wiBefore}}\n{{/if}}{{#if description}}{{description}}\n{{/if}}{{#if personality}}{{char}}'s personality: {{personality}}\n{{/if}}{{#if scenario}}Scenario: {{scenario}}\n{{/if}}{{#if wiAfter}}{{wiAfter}}\n{{/if}}{{#if persona}}{{persona}}\n{{/if}}{{trim}}<end_of_turn>", + "example_separator": "", + "chat_start": "", + "use_stop_strings": false, + "allow_jailbreak": false, + "always_force_name2": true, + "trim_sentences": false, + "include_newline": false, + "single_line": false, + "name": "Gemma 2" +} \ No newline at end of file diff --git a/default/content/presets/instruct/Gemma 2.json b/default/content/presets/instruct/Gemma 2.json new file mode 100644 index 000000000..a399b8e89 --- /dev/null +++ b/default/content/presets/instruct/Gemma 2.json @@ -0,0 +1,24 @@ +{ + "system_prompt": "Continue writing this story and portray characters realistically.", + "input_sequence": "<start_of_turn>user", + "output_sequence": "<start_of_turn>model", + "last_output_sequence": "", + "system_sequence": "<start_of_turn>system", + "stop_sequence": "<end_of_turn>", + "wrap": true, + "macro": true, + "names": false, + "names_force_groups": false, + "activation_regex": "", + "system_sequence_prefix": "", + "system_sequence_suffix": "", + "first_output_sequence": "", + "skip_examples": false, + "output_suffix": "<end_of_turn>\n", + "input_suffix": "<end_of_turn>\n", + "system_suffix": "<end_of_turn>\n", + "user_alignment_message": "", + "system_same_as_user": true, + "last_system_sequence": "", + "name": "Gemma 2" +} diff --git a/default/content/settings.json b/default/content/settings.json index c04b88bfb..f558c769f 100644 --- a/default/content/settings.json +++ b/default/content/settings.json @@ -13,6 +13,7 @@ }, "world_info_depth": 2, "world_info_budget": 25, + "world_info_include_names": true, "world_info_recursive": true, "world_info_overflow_alert": false, "world_info_case_sensitive": false, diff --git a/default/content/themes/Azure.json b/default/content/themes/Azure.json new file mode 100644 index 000000000..676ae668c --- /dev/null +++ b/default/content/themes/Azure.json @@ -0,0 +1,35 @@ +{ + "name": "Azure", + "blur_strength": 11, + "main_text_color": "rgba(171, 198, 223, 1)", + "italics_text_color": "rgba(255, 255, 255, 1)", + "underline_text_color": "rgba(188, 231, 207, 1)", + "quote_text_color": "rgba(111, 133, 253, 1)", + "blur_tint_color": "rgba(23, 30, 33, 0.61)", + "chat_tint_color": "rgba(23, 23, 23, 0)", + "user_mes_blur_tint_color": "rgba(0, 28, 174, 0.2)", + "bot_mes_blur_tint_color": "rgba(0, 13, 57, 0.22)", + "shadow_color": "rgba(0, 0, 0, 1)", + "shadow_width": 5, + "border_color": "rgba(0, 0, 0, 0.5)", + "font_scale": 1, + "fast_ui_mode": false, + "waifuMode": false, + "avatar_style": 1, + "chat_display": 1, + "noShadows": false, + "chat_width": 50, + "timer_enabled": true, + "timestamps_enabled": true, + "timestamp_model_icon": false, + "mesIDDisplay_enabled": true, + "message_token_count_enabled": false, + "expand_message_actions": false, + "enableZenSliders": false, + "enableLabMode": false, + "hotswap_enabled": true, + "custom_css": "", + "bogus_folders": false, + "reduced_motion": false, + "compact_input_area": false +} diff --git a/default/content/themes/Celestial Macaron.json b/default/content/themes/Celestial Macaron.json new file mode 100644 index 000000000..db76bf6e4 --- /dev/null +++ b/default/content/themes/Celestial Macaron.json @@ -0,0 +1,37 @@ +{ + "name": "Celestial Macaron", + "blur_strength": 10, + "main_text_color": "rgba(229, 175, 162, 1)", + "italics_text_color": "rgba(146, 147, 161, 1)", + "underline_text_color": "rgba(157, 215, 198, 1)", + "quote_text_color": "rgba(197, 202, 206, 1)", + "blur_tint_color": "rgba(23, 36, 55, 0.9)", + "chat_tint_color": "rgba(18, 26, 40, 0.9)", + "user_mes_blur_tint_color": "rgba(51, 67, 90, 0.7)", + "bot_mes_blur_tint_color": "rgba(23, 36, 55, 0.75)", + "shadow_color": "rgba(0, 0, 0, 0.3)", + "shadow_width": 1, + "border_color": "rgba(60, 74, 110, 0.93)", + "font_scale": 1, + "fast_ui_mode": false, + "waifuMode": false, + "avatar_style": 0, + "chat_display": 1, + "noShadows": true, + "chat_width": 58, + "timer_enabled": true, + "timestamps_enabled": true, + "timestamp_model_icon": false, + "mesIDDisplay_enabled": true, + "hideChatAvatars_enabled": false, + "message_token_count_enabled": true, + "expand_message_actions": true, + "enableZenSliders": false, + "enableLabMode": false, + "hotswap_enabled": true, + "custom_css": "", + "bogus_folders": true, + "zoomed_avatar_magnification": false, + "reduced_motion": false, + "compact_input_area": true +} \ No newline at end of file diff --git a/default/content/themes/Dark V 1.0.json b/default/content/themes/Dark V 1.0.json new file mode 100644 index 000000000..5ee03cdee --- /dev/null +++ b/default/content/themes/Dark V 1.0.json @@ -0,0 +1,37 @@ +{ + "name": "Dark V 1.0", + "blur_strength": 13, + "main_text_color": "rgba(207, 207, 197, 1)", + "italics_text_color": "rgba(145, 145, 145, 1)", + "underline_text_color": "rgba(145, 145, 145, 1)", + "quote_text_color": "rgba(198, 193, 151, 1)", + "blur_tint_color": "rgba(29, 33, 40, 0.9)", + "chat_tint_color": "rgba(29, 33, 40, 0.9)", + "user_mes_blur_tint_color": "rgba(29, 33, 40, 0.9)", + "bot_mes_blur_tint_color": "rgba(29, 33, 40, 0.9)", + "shadow_color": "rgba(0, 0, 0, 0.9)", + "shadow_width": 2, + "border_color": "rgba(0, 0, 0, 1)", + "font_scale": 1, + "fast_ui_mode": false, + "waifuMode": false, + "avatar_style": 0, + "chat_display": 0, + "noShadows": false, + "chat_width": 55, + "timer_enabled": false, + "timestamps_enabled": false, + "timestamp_model_icon": false, + "mesIDDisplay_enabled": false, + "hideChatAvatars_enabled": false, + "message_token_count_enabled": false, + "expand_message_actions": false, + "enableZenSliders": false, + "enableLabMode": false, + "hotswap_enabled": true, + "custom_css": "", + "bogus_folders": true, + "zoomed_avatar_magnification": true, + "reduced_motion": true, + "compact_input_area": false +} \ No newline at end of file diff --git a/jsconfig.json b/jsconfig.json index e7691789d..a48606f1f 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -15,6 +15,11 @@ "**/node_modules/*", "public/lib", "backups/*", - "data/*" + "data/*", + "**/dist/*", + "dist/*", + "cache/*", + "src/tokenizers/*", + "docker/*", ] } diff --git a/package-lock.json b/package-lock.json index fc3c67537..ae2d0a3ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sillytavern", - "version": "1.12.1", + "version": "1.12.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sillytavern", - "version": "1.12.1", + "version": "1.12.3", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/package.json b/package.json index d8496ad7a..3dd6007e7 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "type": "git", "url": "https://github.com/SillyTavern/SillyTavern.git" }, - "version": "1.12.1", + "version": "1.12.3", "scripts": { "start": "node server.js", "start:no-csrf": "node server.js --disableCsrf", diff --git a/public/css/animations.css b/public/css/animations.css index 7ce3c8f0e..85452d03a 100644 --- a/public/css/animations.css +++ b/public/css/animations.css @@ -43,6 +43,10 @@ transform: scaleY(1); } + 66% { + transform: scaleY(1); + } + 100% { opacity: 0; transform: scaleY(0); diff --git a/public/css/loader.css b/public/css/loader.css index 3536f8689..dea0eb5a3 100644 --- a/public/css/loader.css +++ b/public/css/loader.css @@ -1,4 +1,4 @@ -#loader, #preloader { +#preloader { position: fixed; margin: 0; padding: 0; @@ -10,9 +10,9 @@ width: 100svw; height: 100svh; background-color: var(--SmartThemeBlurTintColor); + color: var(--SmartThemeBodyColor); /*for some reason the full screen blur does not work on iOS*/ backdrop-filter: blur(30px); - color: var(--SmartThemeBodyColor); opacity: 1; } diff --git a/public/css/popup-safari-fix.css b/public/css/popup-safari-fix.css new file mode 100644 index 000000000..6838e3352 --- /dev/null +++ b/public/css/popup-safari-fix.css @@ -0,0 +1,11 @@ +/* iPhone copium land */ +body.safari .popup .popup-body:has(.maximized_textarea), +body.safari .popup.large_dialogue_popup .popup-body { + height: 100%; +} + +body.safari .popup .popup-body { + height: fit-content; + max-height: 90vh; + max-height: 90svh; +} diff --git a/public/css/popup.css b/public/css/popup.css index 045bfc2e5..f05006749 100644 --- a/public/css/popup.css +++ b/public/css/popup.css @@ -1,3 +1,5 @@ +@import url('./popup-safari-fix.css'); + dialog { color: var(--SmartThemeBodyColor); } @@ -14,6 +16,10 @@ dialog { display: flex; flex-direction: column; + max-height: calc(100svh - 2em); + max-width: calc(100svw - 2em); + min-height: fit-content; + /* Overflow visible so elements (like toasts) can appear outside of the dialog. '.popup-body' is hiding overflow for the real content. */ overflow: visible; @@ -21,8 +27,17 @@ dialog { backface-visibility: hidden; transform: translateZ(0); -webkit-font-smoothing: subpixel-antialiased; + + /* Variables setup */ + --popup-animation-speed: var(--animation-duration-slow); } +/** Popup styles applied to the main popup */ +.popup--animation-fast { --popup-animation-speed: var(--animation-duration); } +.popup--animation-slow { --popup-animation-speed: var(--animation-duration-slow); } +.popup--animation-none { --popup-animation-speed: 0ms; } + +/* Styling of main popup elements */ .popup .popup-body { display: flex; flex-direction: column; @@ -54,11 +69,11 @@ dialog { /* Opening animation */ .popup[opening] { - animation: pop-in var(--animation-duration-slow) ease-in-out; + animation: pop-in var(--popup-animation-speed) ease-in-out; } .popup[opening]::backdrop { - animation: fade-in var(--animation-duration-slow) ease-in-out; + animation: fade-in var(--popup-animation-speed) ease-in-out; } /* Open state of the dialog */ @@ -72,13 +87,18 @@ dialog { background-color: var(--black30a); } +body.no-blur .popup[open]::backdrop { + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + /* Closing animation */ .popup[closing] { - animation: pop-out var(--animation-duration-slow) ease-in-out; + animation: pop-out var(--popup-animation-speed) ease-in-out; } .popup[closing]::backdrop { - animation: fade-out var(--animation-duration-slow) ease-in-out; + animation: fade-out var(--popup-animation-speed) ease-in-out; } .popup #toast-container { @@ -92,6 +112,24 @@ dialog { text-align: left; } +.popup-crop-wrap { + margin: 10px auto; + max-height: 75vh; + max-height: 75svh; + max-width: 100%; +} + +.popup-crop-wrap img { + max-width: 100%; + /* This rule is very important, please do not ignore this! */ +} + +.popup-inputs { + margin-top: 10px; + font-size: smaller; + opacity: 0.7; +} + .popup-input { margin-top: 10px; } @@ -143,3 +181,4 @@ dialog { /* Fix weird animation issue with font-scaling during popup open */ backface-visibility: hidden; } + diff --git a/public/css/promptmanager.css b/public/css/promptmanager.css index 178682998..5370a98e8 100644 --- a/public/css/promptmanager.css +++ b/public/css/promptmanager.css @@ -233,7 +233,6 @@ } #completion_prompt_manager .completion_prompt_manager_footer a { - padding: 0.75em; font-size: 12px; } diff --git a/public/css/rm-groups.css b/public/css/rm-groups.css index e7cbb2c95..499f59873 100644 --- a/public/css/rm-groups.css +++ b/public/css/rm-groups.css @@ -68,9 +68,9 @@ margin-top: 0.25rem; margin-bottom: 0.5rem; border: 1px solid var(--SmartThemeBorderColor); - ; border-radius: 10px; background-color: var(--black30a); + padding: 2px; } #rm_group_buttons_expander { diff --git a/public/img/01ai.svg b/public/img/01ai.svg new file mode 100644 index 000000000..317a5da59 --- /dev/null +++ b/public/img/01ai.svg @@ -0,0 +1,59 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + width="363.44339" + height="375.68854" + viewBox="0 0 363.44339 375.68854" + version="1.1" + id="svg2" + sodipodi:docname="Yi_logo_icon_dark.svg" + inkscape:version="1.3 (0e150ed, 2023-07-21)" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"> + <defs + id="defs2" /> + <sodipodi:namedview + id="namedview2" + pagecolor="#ffffff" + bordercolor="#000000" + borderopacity="0.25" + inkscape:showpageshadow="2" + inkscape:pageopacity="0.0" + inkscape:pagecheckerboard="0" + inkscape:deskcolor="#d1d1d1" + inkscape:zoom="1.1073359" + inkscape:cx="192.35355" + inkscape:cy="196.86889" + inkscape:window-width="1512" + inkscape:window-height="857" + inkscape:window-x="0" + inkscape:window-y="38" + inkscape:window-maximized="1" + inkscape:current-layer="svg2" /> + <rect + x="287.14771" + y="224.04056" + width="42.3862" + height="151.64799" + rx="21.1931" + id="rect1" /> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="m 299.41969,17.362538 c -8.916,-7.5830004 -22.291,-6.503 -29.874,2.414 l -118.432,139.253002 c -3.056,3.593 -4.705,7.911 -5.001,12.281 -0.166,1.069 -0.252,2.164 -0.252,3.279 v 178.022 c 0,11.705 9.488,21.193 21.193,21.193 11.705,0 21.193,-9.488 21.193,-21.193 v -171.819 l 113.587,-133.556002 c 7.583,-8.916 6.502,-22.291 -2.414,-29.874 z" + id="path1" /> + <rect + x="-18.236605" + y="8.6596518" + width="42.3862" + height="174.745" + rx="21.1931" + transform="rotate(-39.3441)" + id="rect2" /> + <circle + cx="337.54071" + cy="163.28656" + r="25.9027" + id="circle2" /> +</svg> diff --git a/public/img/featherless.svg b/public/img/featherless.svg new file mode 100644 index 000000000..b386387f4 --- /dev/null +++ b/public/img/featherless.svg @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + class="logo" + width="36" + height="30.9767" + viewBox="0 0 36 30.9767" + version="1.1" + id="svg2" + sodipodi:docname="featherless.svg" + inkscape:version="1.3 (0e150ed, 2023-07-21)" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"> + <defs + id="defs2" /> + <sodipodi:namedview + id="namedview2" + pagecolor="#ffffff" + bordercolor="#000000" + borderopacity="0.25" + inkscape:showpageshadow="2" + inkscape:pageopacity="0.0" + inkscape:pagecheckerboard="0" + inkscape:deskcolor="#d1d1d1" + inkscape:zoom="4.0920245" + inkscape:cx="75.268366" + inkscape:cy="15.151424" + inkscape:window-width="1512" + inkscape:window-height="857" + inkscape:window-x="0" + inkscape:window-y="38" + inkscape:window-maximized="1" + inkscape:current-layer="svg2" /> + <path + d="M 34.0866,1.68482 C 32.2902,0.5825 29.863,0 27.0672,0 22.7842,0 18.0653,1.35865 13.8276,3.72206 L 13.7979,3.71083 c 0,0 -0.0042,0.02261 -0.0065,0.0334 C 12.5086,4.4617 11.2656,5.2629 10.0981,6.15731 3.22112,11.4248 1.29519,17.6748 2.92004,21.0156 1.14142,24.0728 0.0457,27.2332 0,30.9767 3.41949,24.421 5.4719,19.108 16.6146,10.1637 13.4309,10.8501 7.9281,14.1057 4.2271,19.0459 3.87793,16.156 6.1477,11.4895 11.2033,7.6174 11.8435,7.127 12.5092,6.66864 13.1886,6.23374 12.6577,7.8934 12.8269,7.4806 11.7254,9.8076 c 1.6289,-1.551 2.7014,-2.5081 4.3096,-5.16615 2.088,-1.03181 4.2598,-1.80301 6.4132,-2.2691 -0.3563,1.18836 -1.0345,3.20231 -1.9527,4.79455 0,0 2.3303,-0.50255 4.2563,-0.38902 -1.0523,1.16802 -1.9991,2.43152 -2.9592,3.72332 -1.3149,1.7684 -2.6742,3.5971 -4.4148,5.2993 -0.2095,0.2049 -0.4098,0.3907 -0.6129,0.5825 -2.6747,-0.2576 -4.4414,0.7485 -6.0966,2.5259 1.3054,-0.6123 3.059,-1.1165 4.1583,-0.813 -2.0258,1.662 -5.216,3.8529 -7.8373,3.6725 -0.4971,0.7611 -0.5285,0.7844 -1.0749,1.7038 4.252,1.0648 9.5926,-3.2817 12.7354,-6.3561 1.8428,-1.803 3.2466,-3.6904 4.6036,-5.5149 2.7947,-3.7585 5.2082,-7.0038 10.5619,-8.2388 L 36,2.85877 Z" + class="logo-mark" + id="path1"/> +</svg> diff --git a/public/img/huggingface.svg b/public/img/huggingface.svg new file mode 100644 index 000000000..3acb82a27 --- /dev/null +++ b/public/img/huggingface.svg @@ -0,0 +1,40 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + width="88.001465mm" + height="81.280983mm" + version="1.1" + id="svg9" + sodipodi:docname="huggingface.svg" + inkscape:version="1.3 (0e150ed, 2023-07-21)" + viewBox="0 0 88.001465 81.280983" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"> + <defs + id="defs9" /> + <sodipodi:namedview + id="namedview9" + pagecolor="#ffffff" + bordercolor="#000000" + borderopacity="0.25" + inkscape:showpageshadow="2" + inkscape:pageopacity="0.0" + inkscape:pagecheckerboard="0" + inkscape:deskcolor="#d1d1d1" + inkscape:zoom="0.68605868" + inkscape:cx="424.16197" + inkscape:cy="154.50573" + inkscape:window-width="1512" + inkscape:window-height="857" + inkscape:window-x="0" + inkscape:window-y="38" + inkscape:window-maximized="1" + inkscape:current-layer="svg9" + inkscape:clip-to-page="false" + inkscape:document-units="mm" /> + <path + id="path2-9" + style="display:inline;" + d="M 40.855186,0.10840487 A 38.75,38.75 0 0 0 5.0016702,38.750983 a 38.75,38.75 0 0 0 1.7871095,11.589844 7.1,7.1 0 0 1 1.871094,0.291015 5.97,5.97 0 0 1 1.330078,-3.761718 c 0.02089,-0.02502 0.04515,-0.04576 0.06641,-0.07031 a 34.75,34.75 0 0 1 -1.0547201,-8.048831 34.750014,34.750014 0 0 1 69.5000291,0 34.75,34.75 0 0 1 -0.957032,7.630859 c 0.163358,0.152193 0.321565,0.31255 0.466797,0.488282 a 5.97,5.97 0 0 1 1.330078,3.761718 7.1,7.1 0 0 1 1.337891,-0.207031 A 38.75,38.75 0 0 0 82.501671,38.750983 38.75,38.75 0 0 0 40.855186,0.10840487 Z M 48.015342,73.165045 a 34.75,34.75 0 0 1 -8.044921,0.03906 c -0.396448,0.901178 -0.898324,1.811009 -1.529297,2.736328 -0.233308,0.342701 -0.489288,0.664577 -0.75586,0.974609 a 38.75,38.75 0 0 0 12.574219,-0.06641 c -0.245421,-0.290144 -0.482504,-0.589875 -0.699219,-0.908203 -0.639915,-0.938432 -1.14623,-1.86177 -1.544922,-2.775391 z M 73.940733,45.000983 c 1.62,0 3.07,0.66 4.07,1.87 a 5.97,5.97 0 0 1 1.33,3.76 7.1,7.1 0 0 1 1.95,-0.3 c 1.55,0 2.95,0.59 3.94,1.66 a 5.8,5.8 0 0 1 0.8,7 5.3,5.3 0 0 1 1.78,2.82 c 0.24,0.9 0.48,2.8 -0.8,4.74 a 5.22,5.22 0 0 1 0.37,5.02 c -1.02,2.32 -3.57,4.14 -8.51,6.1 -3.08,1.22 -5.9,2 -5.92,2.01 a 44.33,44.33 0 0 1 -10.93,1.6 c -5.86,0 -10.05,-1.8 -12.46,-5.34 -3.88,-5.69 -3.33,-10.9 1.7,-15.92 2.78,-2.78 4.63,-6.87 5.01,-7.77 0.78,-2.66 2.83,-5.62 6.24,-5.62 a 5.7,5.7 0 0 1 4.6,2.46 c 1,-1.26 1.98,-2.25 2.87,-2.82 a 7.4,7.4 0 0 1 3.96,-1.27 z m 0,4 c -0.51,0 -1.13,0.22 -1.82,0.65 -2.13,1.36 -6.25,8.43 -7.76,11.18 a 2.43,2.43 0 0 1 -2.14,1.31 c -1.54,0 -2.75,-1.53 -0.14,-3.48 3.91,-2.93 2.54,-7.72 0.67,-8.01 a 1.54,1.54 0 0 0 -0.24,-0.02 c -1.7,0 -2.45,2.93 -2.45,2.93 0,0 -2.2,5.52 -5.97,9.3 -3.78,3.77 -3.98,6.8 -1.22,10.83 1.87,2.75 5.47,3.58 9.15,3.58 3.82,0 7.73,-0.9 9.93,-1.46 0.1,-0.03 13.45,-3.8 11.76,-7 -0.29,-0.54 -0.75,-0.76 -1.34,-0.76 -2.38,0 -6.71,3.54 -8.57,3.54 -0.42,0 -0.71,-0.17 -0.83,-0.6 -0.8,-2.85 12.05,-4.05 10.97,-8.17 -0.19,-0.73 -0.7,-1.02 -1.44,-1.02 -3.14,0 -10.2,5.53 -11.68,5.53 -0.1,0 -0.19,-0.03 -0.23,-0.1 -0.74,-1.2 -0.34,-2.04 4.88,-5.2 5.23,-3.16 8.9,-5.06 6.8,-7.33 -0.23,-0.26 -0.57,-0.38 -0.98,-0.38 -3.18,0 -10.67,6.82 -10.67,6.82 0,0 -2.02,2.1 -3.24,2.1 a 0.74,0.74 0 0 1 -0.68,-0.38 c -0.87,-1.46 8.05,-8.22 8.55,-11.01 0.34,-1.9 -0.24,-2.85 -1.31,-2.85 z m -6.69,-15 a 3.25,3.25 0 1 0 0,-6.5 3.25,3.25 0 0 0 0,6.5 z m -46.5,0 a 3.25,3.25 0 1 0 0,-6.5 3.25,3.25 0 0 0 0,6.5 z m -6.69,11 c -1.62,0 -3.06,0.66 -4.0700003,1.87 a 5.97,5.97 0 0 0 -1.33,3.76 7.1,7.1 0 0 0 -1.94,-0.3 c -1.55,0 -2.95,0.59 -3.94,1.66 a 5.8,5.8 0 0 0 -0.8,7 5.3,5.3 0 0 0 -1.79000004,2.82 c -0.24,0.9 -0.48,2.8 0.8,4.74 a 5.22,5.22 0 0 0 -0.37,5.02 c 1.02000004,2.32 3.57000004,4.14 8.52000004,6.1 3.0700003,1.22 5.8900003,2 5.9100003,2.01 a 44.33,44.33 0 0 0 10.93,1.6 c 5.86,0 10.05,-1.8 12.46,-5.34 3.88,-5.69 3.33,-10.9 -1.7,-15.92 -2.77,-2.78 -4.62,-6.87 -5,-7.77 -0.78,-2.66 -2.84,-5.62 -6.25,-5.62 a 5.7,5.7 0 0 0 -4.6,2.46 c -1,-1.26 -1.98,-2.25 -2.86,-2.82 a 7.4,7.4 0 0 0 -3.97,-1.27 z m 0,4 c 0.51,0 1.14,0.22 1.82,0.65 2.14,1.36 6.25,8.43 7.76,11.18 0.5,0.92 1.37,1.31 2.14,1.31 1.55,0 2.75,-1.53 0.15,-3.48 -3.92,-2.93 -2.55,-7.72 -0.68,-8.01 0.08,-0.02 0.17,-0.02 0.24,-0.02 1.7,0 2.45,2.93 2.45,2.93 0,0 2.2,5.52 5.98,9.3 3.77,3.77 3.97,6.8 1.22,10.83 -1.88,2.75 -5.47,3.58 -9.16,3.58 -3.81,0 -7.73,-0.9 -9.92,-1.46 -0.11,-0.03 -13.4500003,-3.8 -11.7600003,-7 0.28,-0.54 0.75,-0.76 1.34,-0.76 2.38,0 6.7000003,3.54 8.5700003,3.54 0.41,0 0.7,-0.17 0.83,-0.6 0.79,-2.85 -12.0600003,-4.05 -10.9800003,-8.17 0.2,-0.73 0.71,-1.02 1.44,-1.02 3.14,0 10.2000003,5.53 11.6800003,5.53 0.11,0 0.2,-0.03 0.24,-0.1 0.74,-1.2 0.33,-2.04 -4.9,-5.2 -5.2100003,-3.16 -8.8800003,-5.06 -6.8000003,-7.33 0.24,-0.26 0.58,-0.38 1,-0.38 3.17,0 10.6600003,6.82 10.6600003,6.82 0,0 2.02,2.1 3.25,2.1 0.28,0 0.52,-0.1 0.68,-0.38 0.86,-1.46 -8.06,-8.22 -8.56,-11.01 -0.34,-1.9 0.24,-2.85 1.31,-2.85 z m 21.91,2 a 8.7,8.7 0 0 1 5.3,-4.49 c 0.4,-0.12 0.81,0.57 1.24,1.28 0.4,0.68 0.82,1.37 1.24,1.37 0.45,0 0.9,-0.68 1.33,-1.35 0.45,-0.7 0.89,-1.38 1.32,-1.25 a 8.61,8.61 0 0 1 5,4.17 c 3.73,-2.94 5.1,-7.74 5.1,-10.7 0,-2.34 -1.57,-1.6 -4.09,-0.36 l -0.14,0.07 c -2.31,1.15 -5.39,2.67 -8.77,2.67 -3.38,0 -6.45,-1.52 -8.77,-2.67 -2.6,-1.29 -4.23,-2.1 -4.23,0.29 0,3.05 1.46,8.06 5.47,10.97 z m 19.07,-21.7 c 1.28,0.44 1.78,3.06 3.07,2.38 a 5,5 0 1 0 -6.76,-2.07 c 0.61,1.15 2.55,-0.72 3.7,-0.32 z m -23.55,0 c -1.28,0.44 -1.79,3.06 -3.07,2.38 a 5,5 0 1 1 6.76,-2.07 c -0.61,1.15 -2.56,-0.72 -3.7,-0.32 z" /> +</svg> diff --git a/public/img/manual.svg b/public/img/manual.svg new file mode 100644 index 000000000..e313ab59c --- /dev/null +++ b/public/img/manual.svg @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> +<path d="M13.3252 3.05011L8.66765 20.4323L10.5995 20.9499L15.257 3.56775L13.3252 3.05011Z" /> +<path d="M7.61222 18.3608L8.97161 16.9124L8.9711 16.8933L3.87681 12.1121L8.66724 7.00798L7.20892 5.63928L1.0498 12.2017L7.61222 18.3608Z" /> +<path d="M16.3883 18.3608L15.0289 16.9124L15.0294 16.8933L20.1237 12.1121L15.3333 7.00798L16.7916 5.63928L22.9507 12.2017L16.3883 18.3608Z" /> +</svg> diff --git a/public/index.html b/public/index.html index 3b619ccaf..5b9f5b455 100644 --- a/public/index.html +++ b/public/index.html @@ -423,7 +423,7 @@ </span> </div> </div> - <div class="range-block" data-source="openai,claude,windowai,openrouter,ai21,scale,makersuite,mistralai,custom,cohere,perplexity,groq"> + <div class="range-block" data-source="openai,claude,windowai,openrouter,ai21,scale,makersuite,mistralai,custom,cohere,perplexity,groq,01ai"> <div class="range-block-title" data-i18n="Temperature"> Temperature </div> @@ -488,7 +488,7 @@ </div> </div> </div> - <div data-newbie-hidden class="range-block" data-source="openai,claude,openrouter,ai21,scale,makersuite,mistralai,custom,cohere,perplexity,groq"> + <div data-newbie-hidden class="range-block" data-source="openai,claude,openrouter,ai21,scale,makersuite,mistralai,custom,cohere,perplexity,groq,01ai"> <div class="range-block-title" data-i18n="Top P"> Top P </div> @@ -1477,7 +1477,7 @@ </label> </div> </div> - <div data-tg-type="mancer, ooba, koboldcpp, vllm, aphrodite, llamacpp, ollama, infermaticai" data-newbie-hidden class="flex-container flexFlowColumn alignitemscenter flexBasis48p flexGrow flexShrink gap0"> + <div data-tg-type="mancer, ooba, koboldcpp, vllm, aphrodite, llamacpp, ollama, infermaticai, huggingface" data-newbie-hidden class="flex-container flexFlowColumn alignitemscenter flexBasis48p flexGrow flexShrink gap0"> <small data-i18n="Seed" class="textAlignCenter">Seed</small> <input type="number" id="seed_textgenerationwebui" class="text_pole textAlignCenter" min="-1" value="-1" maxlength="100" /> </div> @@ -1969,7 +1969,7 @@ <small data-i18n="Example: http://127.0.0.1:5000/api ">Example: http://127.0.0.1:5000/api </small> <input id="api_url_text" name="api_url" class="text_pole" placeholder="http://127.0.0.1:5000/api" maxlength="500" value="" autocomplete="off" data-server-history="kobold"> <div id="koboldcpp_hint" class="neutral_warning displayNone"> - We have a dedicated KoboldCpp support under Text Completion ⇒ KoboldCpp. + KoboldCpp works better when you select the Text Completion API and then KoboldCpp as a type! </div> <div class="flex-container"> <div id="api_button" class="api_button menu_button" type="submit" data-i18n="Connect" data-server-connect="kobold">Connect</div> @@ -2029,6 +2029,8 @@ <option value="ooba" data-i18n="Default (completions compatible)">Default [OpenAI /completions compatible: oobabooga, LM Studio, etc.]</option> <option value="aphrodite">Aphrodite</option> <option value="dreamgen">DreamGen</option> + <option value="featherless">Featherless</option> + <option value="huggingface">HuggingFace (Inference Endpoint)</option> <option value="infermaticai">InfermaticAI</option> <option value="koboldcpp">KoboldCpp</option> <option value="llamacpp">llama.cpp</option> @@ -2182,6 +2184,27 @@ </div> <input id="custom_model_textgenerationwebui" class="text_pole wide100p" maxlength="500" placeholder="Custom model (optional)" data-i18n="[placeholder]Custom model (optional)" type="text"> </div> + <div data-tg-type="featherless" class="flex-container flexFlowColumn"> + <div class="flex-container flexFlowColumn"> + <a href="https://featherless.ai/models/" target="_blank" rel="noopener noreferrer"> + featherless.ai + </a> + </div> + <h4 data-i18n="API key (optional)">API key</h4> + <div class="flex-container"> + <input id="api_key_featherless" name="api_key_featherless" class="text_pole flex1 wide100p" maxlength="500" size="35" type="text" autocomplete="off"> + <div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_featherless"> + </div> + </div> + <div data-for="api_key_featherless" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page."> + For privacy reasons, your API key will be hidden after you reload the page. + </div> + <select id="featherless_model"> + <option data-i18n="-- Connect to the API --"> + -- Connect to the API -- + </option> + </select> + </div> <div data-tg-type="vllm"> <div class="flex-container flexFlowColumn"> <a href="https://github.com/vllm-project/vllm" target="_blank" data-i18n="vllm-project/vllm"> @@ -2211,6 +2234,22 @@ </select> </div> </div> + <div data-tg-type="huggingface"> + <h4 data-i18n="HuggingFace Token">HuggingFace Token</h4> + <div class="flex-container"> + <input id="api_key_huggingface" name="api_key_huggingface" class="text_pole flex1 wide100p" maxlength="500" size="35" type="text" autocomplete="off"> + <div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_huggingface"> + </div> + </div> + <div data-for="api_key_huggingface" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page."> + For privacy reasons, your API key will be hidden after you reload the page. + </div> + <div class="flex1"> + <h4 data-i18n="Endpoint URL">Endpoint URL</h4> + <small data-i18n="Example: https://****.endpoints.huggingface.cloud">Example: https://****.endpoints.huggingface.cloud</small> + <input id="huggingface_api_url_text" class="text_pole wide100p" maxlength="500" value="" autocomplete="off" data-server-history="huggingface"> + </div> + </div> <div data-tg-type="aphrodite"> <div class="flex-container flexFlowColumn"> @@ -2332,7 +2371,7 @@ </div> </div> <div class="flex-container"> - <div id="api_button_textgenerationwebui" class="api_button menu_button" type="submit" data-i18n="Connect" data-server-connect="ooba_blocking,vllm,aphrodite,tabby,koboldcpp,ollama,llamacpp">Connect</div> + <div id="api_button_textgenerationwebui" class="api_button menu_button" type="submit" data-i18n="Connect" data-server-connect="ooba_blocking,vllm,aphrodite,tabby,koboldcpp,ollama,llamacpp,huggingface">Connect</div> <div data-tg-type="openrouter" class="menu_button menu_button_icon openrouter_authorize" title="Get your OpenRouter API token using OAuth flow. You will be redirected to openrouter.ai" data-i18n="Authorize;[title]Get your OpenRouter API token using OAuth flow. You will be redirected to openrouter.ai">Authorize</div> <div class="api_loading menu_button" data-i18n="Cancel">Cancel</div> </div> @@ -2360,6 +2399,7 @@ <option value="custom" data-i18n="Custom (OpenAI-compatible)">Custom (OpenAI-compatible)</option> </optgroup> <optgroup> + <option value="01ai">01.AI (Yi)</option> <option value="ai21">AI21</option> <option value="claude">Claude</option> <option value="cohere">Cohere</option> @@ -2890,6 +2930,23 @@ <option value="claude">Claude</option> </select> </form> + <div id="01ai_form" data-source="01ai"> + <h4> + <a data-i18n="01.AI API Key" href="https://platform.01.ai/" target="_blank" rel="noopener noreferrer"> + 01.AI API Key + </a> + </h4> + <div class="flex-container"> + <input id="api_key_01ai" name="api_key_01ai" class="text_pole flex1" maxlength="500" value="" type="text" autocomplete="off"> + <div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_01ai"></div> + </div> + <div data-for="api_key_01ai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page."> + For privacy reasons, your API key will be hidden after you reload the page. + </div> + <h4 data-i18n="01.AI Model">01.AI Model</h4> + <select id="model_01ai_select"> + </select> + </div> <div class="flex-container flex"> <div id="api_button_openai" class="api_button menu_button menu_button_icon" type="submit" data-i18n="Connect">Connect</div> <div class="api_loading menu_button" data-i18n="Cancel">Cancel</div> @@ -3485,6 +3542,12 @@ </div> </div> <div id="wiCheckboxes" class="flex1 flex-container flexFlowColumn"> + <label title="Include names with each message into the context for scanning" data-i18n="[title]Include names with each message into the context for scanning" class="checkbox_label flex1"> + <input id="world_info_include_names" type="checkbox" /> + <small data-i18n="Include Names" class="whitespacenowrap flex1"> + Include Names + </small> + </label> <label title="Entries can activate other entries by mentioning their keywords" data-i18n="[title]Entries can activate other entries by mentioning their keywords" class="checkbox_label flex1"> <input id="world_info_recursive" type="checkbox" /> <small data-i18n="Recursive Scan" class="whitespacenowrap flex1"> @@ -3749,95 +3812,6 @@ <input class="neo-range-slider" type="range" id="shadow_width" name="shadow_width" min="0" max="5" step="1"> <input class="neo-range-input" type="number" min="0" max="5" step="1" data-for="shadow_width" id="shadow_width_counter"> </div> - - - - <!-- <div data-newbie-hidden class="range-block"> - <label for="compact_input_area" class="range-block-title"> - <span data-i18n="Chat Width (PC)"> - Chat Width - </span> - <i class="fa-solid fa-desktop"></i> - </label> - <div class="range-block-range-and-counter"> - <div class="range-block-range"> - <input id="chat_width_slider" class="wide100p" type="range" min="25" max="100" step="1" value="50"> - </div> - <div class="range-block-counter"> - <input type="number" min="25" max="100" step="1" value="50" data-for="chat_width_slider" id="chat_width_slider_counter"> - </div> - </div> - </div> --> - - <!-- <div id="font-scale-block" class="range-block"> - <div class="range-block-title" data-i18n="Font Scale"> - Font Scale - </div> - <div class="range-block-range-and-counter"> - <div class="range-block-range"> - <input type="range" id="font_scale" name="font_scale" min="0.8" max="1.2" step="0.01"> - </div> - <div class="range-block-counter"> - <input type="number" min="0.8" max="1.2" step="0.01" data-for="font_scale" id="font_scale_counter"> - </div> - </div> - </div> --> - <!-- <div id="blur-strength-block" class="range-block"> - <div class="range-block-title" data-i18n="Blur Strength"> - Blur Strength - </div> - <div class="range-block-range-and-counter"> - <div class="range-block-range"> - <input type="range" id="blur_strength" name="blur_strength" min="0" max="30" step="1"> - </div> - <div class="range-block-counter"> - <input type="number" min="0" max="30" step="1" data-for="blur_strength" id="blur_strength_counter"> - </div> - </div> - </div> --> - <!-- <div id="shadow-width-block" class="range-block"> - <div class="range-block-title" data-i18n="Text Shadow Width"> - Text Shadow Width - </div> - <div class="range-block-range-and-counter"> - <div class="range-block-range"> - <input type="range" id="shadow_width" name="shadow_width" min="0" max="5" step="1"> - </div> - <div class="range-block-counter"> - <input type="number" min="0" max="5" step="1" data-for="shadow_width" id="shadow_width_counter"> - </div> - </div> - </div> --> - <!-- <div id="chat-truncation-block" class="range-block"> - <label for="compact_input_area" class="range-block-title"> - <span data-i18n="Chat Truncation"> - Chat Truncation - </span> - <small data-i18n="(0 = unlimited)">(0 = unlimited)</small> - </label> - <div class="range-block-range-and-counter"> - <div class="range-block-range"> - <input type="range" id="chat_truncation" name="chat_truncation" min="0" max="1000" step="25"> - </div> - <div class="range-block-counter"> - <input type="number" min="0" max="1000" step="1" data-for="chat_truncation" id="chat_truncation_counter"> - </div> - </div> - </div> --> - <!-- <div id="streaming-fps" class="range-block"> - <div class="range-block-title" data-i18n="Streaming FPS"> - Streaming FPS - </div> - <div class="range-block-range-and-counter"> - <div class="range-block-range"> - <input type="range" id="streaming_fps" name="streaming_fps" min="5" max="100" step="5"> - </div> - <div class="range-block-counter"> - <input type="number" min="5" max="100" step="1" data-for="streaming_fps" id="streaming_fps_counter"> - </div> - </div> - </div> --> - </div> <hr> <div name="themeToggles"> @@ -3935,8 +3909,8 @@ <label for="tag_import_setting"><small data-i18n="Import Card Tags">Import Card Tags</small></label> <select id="tag_import_setting" class="widthNatural flex1 margin0"> <option data-i18n="Ask" value="1">Ask</option> - <option data-i18n="None" value="2">None</option> - <option data-i18n="All" value="3">All</option> + <option data-i18n="tag_import_none" value="2">None</option> + <option data-i18n="tag_import_all" value="3">All</option> <option data-i18n="Existing" value="4">Existing</option> </select> </div> @@ -4173,6 +4147,10 @@ <input id="request_token_probabilities" type="checkbox" /> <small data-i18n="Request token probabilities">Request token probabilities</small> </label> + <label data-newbie-hidden class="checkbox_label" for="show_group_chat_queue" title="In group chat, highlight the character(s) that are currently queued to generate responses and the order in which they will respond." data-i18n="[title]In group chat, highlight the character(s) that are currently queued to generate responses and the order in which they will respond."> + <input id="show_group_chat_queue" type="checkbox" /> + <small data-i18n="Show group chat queue">Show group chat queue</small> + </label> <div data-newbie-hidden class="inline-drawer wide100p flexFlowColumn"> <div class="inline-drawer-toggle inline-drawer-header userSettingsInnerExpandable" title="Automatically reject and re-generate AI message based on configurable criteria." data-i18n="[title]Automatically reject and re-generate AI message based on configurable criteria"> <b><span data-i18n="Auto-swipe">Auto-swipe</span></b> @@ -4828,7 +4806,7 @@ <input multiple type="file" id="character_import_file" accept=".json, image/png, .yaml, .yml, .charx" name="avatar"> <input id="character_import_file_type" name="file_type" class="text_pole" maxlength="999" size="2" value="" autocomplete="off"> </form> - <input type="file" id="character_replace_file" accept="image/png" name="replace_avatar" hidden> + <input type="file" id="character_replace_file" accept=".json, image/png, .yaml, .yml, .charx" name="replace_avatar" hidden> </div> <div name="Character List Panel" id="rm_characters_block" class="right_menu"> <div id="charListFixedTop"> @@ -4883,6 +4861,7 @@ <img class="popup-crop-image" src=""> </div> <textarea class="popup-input text_pole result-control" rows="1" data-result="1" data-result-event="submit"></textarea> + <div class="popup-inputs"></div> <div class="popup-controls"> <div class="popup-button-ok menu_button result-control" data-result="1" data-i18n="Delete">Delete</div> <div class="popup-button-cancel menu_button result-control" data-result="0" data-i18n="Cancel">Cancel</div> @@ -5425,7 +5404,7 @@ </div> </div> <div class="flex-container wide100p flexGap10"> - <div class="flex3 flex-container flexFlowColumn flexNoGap"> + <div class="flex4 flex-container flexFlowColumn flexNoGap"> <div class="flex-container justifySpaceBetween"> <small for="group"> <span data-i18n="Inclusion Group">Inclusion Group</span> @@ -5447,7 +5426,7 @@ <input type="text" class="text_pole margin0" name="group" rows="1" data-i18n="[placeholder]Only one entry with the same label will be activated" placeholder="Only one entry with the same label will be activated"> </div> </div> - <div class="flex1 flex-container flexFlowColumn flexNoGap" data-i18n="[title]A relative likelihood of entry activation within the group" title="A relative likelihood of entry activation within the group"> + <div class="flex2 flex-container flexFlowColumn flexNoGap" data-i18n="[title]A relative likelihood of entry activation within the group" title="A relative likelihood of entry activation within the group"> <div class="flex-container justifySpaceBetween marginBot5"> <small for="groupWeight" data-i18n="Group Weight"> Group Weight @@ -5483,6 +5462,19 @@ <input class="text_pole margin0" name="cooldown" type="number" placeholder="No cooldown" min="0" max="999999"> </div> </div> + <div class="flex2 flex-container flexFlowColumn flexNoGap" data-i18n="[title]Entries with a delay can't be activated until there are N messages present in the chat." title="Entries with a delay can't be activated until there are N messages present in the chat."> + <div class="flex-container justifySpaceBetween marginBot5"> + <small class="flex-container alignItemsBaseline" for="delay" data-i18n="Delay"> + <span data-i18n="Delay"> + Delay + </span> + <i class="fa-solid fa-comments fa-xs"></i> + </small> + </div> + <div class="range-block-range"> + <input class="text_pole margin0" name="delay" type="number" placeholder="No delay" min="0" max="999999"> + </div> + </div> </div> <div class="flex-container wide100p flexGap10"> <div class="flex4 flex-container flexFlowColumn flexNoGap"> @@ -5614,10 +5606,10 @@ <label for="completion_prompt_manager_popup_entry_form_injection_position"> <span data-i18n="prompt_manager_position">Position</span> </label> - <div class="text_muted" data-i18n="Injection position. Next to other prompts (relative) or in-chat (absolute).">Injection position. Next to other prompts (relative) or in-chat (absolute).</div> + <div class="text_muted" data-i18n="Injection position. Relative (to other prompts in prompt manager) or In-chat @ Depth.">Injection position. Relative (to other prompts in prompt manager) or In-chat @ Depth.</div> <select id="completion_prompt_manager_popup_entry_form_injection_position" class="text_pole" name="injection_position"> <option data-i18n="prompt_manager_relative" value="0">Relative</option> - <option data-i18n="prompt_manager_absolute" value="1">Absolute</option> + <option data-i18n="prompt_manager_in_chat" value="1">In-chat</option> </select> </div> <div id="completion_prompt_manager_depth_block" class="completion_prompt_manager_popup_entry_form_control flex1"> @@ -5704,10 +5696,11 @@ <div class="mes_edit_cancel menu_button fa-solid fa-xmark" title="Cancel" data-i18n="[title]Cancel"></div> </div> </div> - <div class=mes_text></div> + <div class="mes_text"></div> <div class="mes_img_container"> <div class="mes_img_controls"> <div title="Enlarge" class="right_menu_button fa-lg fa-solid fa-magnifying-glass mes_img_enlarge" data-i18n="[title]Enlarge"></div> + <div title="Caption" class="right_menu_button fa-lg fa-solid fa-envelope-open-text mes_img_caption" data-i18n="[title]Caption"></div> <div title="Delete" class="right_menu_button fa-lg fa-solid fa-trash-can mes_img_delete" data-i18n="[title]Delete"></div> </div> <img class="mes_img" src="" /> @@ -5813,6 +5806,7 @@ <div class="tags tags_inline"></div> </div> <input class="ch_fav" value="" hidden /> + <div class="queue_position"></div> <div class="group_member_icon"> <div title="Temporarily disable automatic replies from this character" data-action="disable" class="right_menu_button fa-solid fa-lg fa-comment-slash" data-i18n="[title]Temporarily disable automatic replies from this character"></div> <div title="Enable automatic replies from this character" data-action="enable" class="right_menu_button fa-solid fa-lg fa-comment-slash" data-i18n="[title]Enable automatic replies from this character"></div> diff --git a/public/locales/ar-sa.json b/public/locales/ar-sa.json index d722b70c0..a227372b2 100644 --- a/public/locales/ar-sa.json +++ b/public/locales/ar-sa.json @@ -1023,7 +1023,6 @@ "prompt_manager_position": "موضع", "Injection position. Next to other prompts (relative) or in-chat (absolute).": "موضع الحقن. بجوار المطالبات الأخرى (نسبية) أو داخل الدردشة (مطلقة).", "prompt_manager_relative": "نسبي", - "prompt_manager_absolute": "مطلق", "prompt_manager_depth": "عمق", "Injection depth. 0 = after the last message, 1 = before the last message, etc.": "عمق الحقن. 0 = بعد الرسالة الأخيرة، 1 = قبل الرسالة الأخيرة، الخ.", "Prompt": "موضوع", diff --git a/public/locales/de-de.json b/public/locales/de-de.json index 4551322da..fcadcbfbc 100644 --- a/public/locales/de-de.json +++ b/public/locales/de-de.json @@ -1023,7 +1023,6 @@ "prompt_manager_position": "Position", "Injection position. Next to other prompts (relative) or in-chat (absolute).": "Injektionsposition. Neben anderen Eingabeaufforderungen (relativ) oder im Chat (absolut).", "prompt_manager_relative": "Relativ", - "prompt_manager_absolute": "Absolut", "prompt_manager_depth": "Tiefe", "Injection depth. 0 = after the last message, 1 = before the last message, etc.": "Injektionstiefe. 0 = nach der letzten Nachricht, 1 = vor der letzten Nachricht usw.", "Prompt": "Aufforderung", diff --git a/public/locales/es-es.json b/public/locales/es-es.json index d4b54fa7a..36cc002bc 100644 --- a/public/locales/es-es.json +++ b/public/locales/es-es.json @@ -1023,7 +1023,6 @@ "prompt_manager_position": "Posición", "Injection position. Next to other prompts (relative) or in-chat (absolute).": "Posición de inyección. Junto a otras indicaciones (relativa) o en el chat (absoluta).", "prompt_manager_relative": "Relativo", - "prompt_manager_absolute": "Absoluto", "prompt_manager_depth": "Profundidad", "Injection depth. 0 = after the last message, 1 = before the last message, etc.": "Profundidad de inyección. 0 = después del último mensaje, 1 = antes del último mensaje, etc.", "Prompt": "Indicar", diff --git a/public/locales/fr-fr.json b/public/locales/fr-fr.json index 84f502e8c..1b30fda0e 100644 --- a/public/locales/fr-fr.json +++ b/public/locales/fr-fr.json @@ -1023,7 +1023,6 @@ "prompt_manager_position": "Position", "Injection position. Next to other prompts (relative) or in-chat (absolute).": "Position d'injection. À côté d’autres invites (relatives) ou dans le chat (absolues).", "prompt_manager_relative": "Relatif", - "prompt_manager_absolute": "Absolu", "prompt_manager_depth": "Profondeur", "Injection depth. 0 = after the last message, 1 = before the last message, etc.": "Profondeur d'injection. 0 = après le dernier message, 1 = avant le dernier message, etc.", "Prompt": "Inciter", diff --git a/public/locales/is-is.json b/public/locales/is-is.json index 1d5587e0c..45e5a3095 100644 --- a/public/locales/is-is.json +++ b/public/locales/is-is.json @@ -1023,7 +1023,6 @@ "prompt_manager_position": "Staða", "Injection position. Next to other prompts (relative) or in-chat (absolute).": "Inndælingarstaða. Við hliðina á öðrum leiðbeiningum (afstætt) eða í spjalli (algert).", "prompt_manager_relative": "Aðstandandi", - "prompt_manager_absolute": "Algjört", "prompt_manager_depth": "Dýpt", "Injection depth. 0 = after the last message, 1 = before the last message, etc.": "Inndælingardýpt. 0 = eftir síðustu skilaboð, 1 = fyrir síðustu skilaboð o.s.frv.", "Prompt": "Ábending", diff --git a/public/locales/it-it.json b/public/locales/it-it.json index 18f0a3fa9..ce0f9f03a 100644 --- a/public/locales/it-it.json +++ b/public/locales/it-it.json @@ -1023,7 +1023,6 @@ "prompt_manager_position": "Posizione", "Injection position. Next to other prompts (relative) or in-chat (absolute).": "Posizione di iniezione. Accanto ad altri suggerimenti (relativo) o in chat (assoluto).", "prompt_manager_relative": "Parente", - "prompt_manager_absolute": "Assoluto", "prompt_manager_depth": "Profondità", "Injection depth. 0 = after the last message, 1 = before the last message, etc.": "Profondità di iniezione. 0 = dopo l'ultimo messaggio, 1 = prima dell'ultimo messaggio, ecc.", "Prompt": "Prompt", diff --git a/public/locales/ja-jp.json b/public/locales/ja-jp.json index 0af4081ee..2e18796f4 100644 --- a/public/locales/ja-jp.json +++ b/public/locales/ja-jp.json @@ -1,6 +1,6 @@ { "Favorite": "お気に入り", - "Tag": "鬼ごっこ", + "Tag": "タグ", "Duplicate": "重複", "Persona": "ペルソナ", "Delete": "削除", @@ -29,13 +29,13 @@ "Text Adventure": "テキストアドベンチャー", "response legth(tokens)": "応答の長さ(トークン数)", "Streaming": "ストリーミング", - "Streaming_desc": "生成された応答をビット単位で表示します。", + "Streaming_desc": "生成された応答を逐次表示します。", "context size(tokens)": "コンテキストのサイズ(トークン数)", "unlocked": "ロック解除", "Only enable this if your model supports context sizes greater than 4096 tokens": "モデルが4096トークンを超えるコンテキストサイズをサポートしている場合にのみ有効にします", "Max prompt cost:": "最大プロンプトコスト:", - "Display the response bit by bit as it is generated.": "生成されるたびに、応答をビットごとに表示します。", - "When this is off, responses will be displayed all at once when they are complete.": "この機能がオフの場合、応答は完全になるとすぐにすべて一度に表示されます。", + "Display the response bit by bit as it is generated.": "生成されるたびに、応答を逐次表示します。", + "When this is off, responses will be displayed all at once when they are complete.": "この機能がオフの場合、応答は完全に生成されたときに一度ですべて表示されます。", "Temperature": "温度", "rep.pen": "繰り返しペナルティ", "Rep. Pen. Range.": "繰り返しペナルティの範囲", @@ -46,10 +46,10 @@ "Phrase Repetition Penalty": "フレーズの繰り返しペナルティ", "Off": "オフ", "Very light": "非常に軽い", - "Light": "ライト", - "Medium": "ミディアム", - "Aggressive": "攻撃的", - "Very aggressive": "非常に攻撃的", + "Light": "軽め", + "Medium": "中程度", + "Aggressive": "強め", + "Very aggressive": "非常に強い", "Unlocked Context Size": "ロック解除されたコンテキストサイズ", "Unrestricted maximum value for the context slider": "コンテキストスライダーの制限なしの最大値", "Context Size (tokens)": "コンテキストサイズ(トークン数)", @@ -132,7 +132,7 @@ "CFG Scale": "CFGスケール", "Negative Prompt": "ネガティブプロンプト", "Add text here that would make the AI generate things you don't want in your outputs.": "出力に望ましくないものを生成させるAIを作成するテキストをここに追加します。", - "Used if CFG Scale is unset globally, per chat or character": "CFGスケールがグローバル、チャットごと、または文字ごとに設定されていない場合に使用されます", + "Used if CFG Scale is unset globally, per chat or character": "CFGスケールがグローバル、チャットごと、またはキャラクターごとに設定されていない場合に使用されます", "Mirostat Tau": "Mirostat Tau", "Mirostat LR": "ミロスタットLR", "Min Length": "最小長", @@ -214,7 +214,7 @@ "Sampler Priority": "サンプラー優先度", "Ooba only. Determines the order of samplers.": "Oobaのみ。サンプラーの順序を決定します。", "Character Names Behavior": "キャラクター名の動作", - "Helps the model to associate messages with characters.": "モデルがメッセージを文字に関連付けるのに役立ちます。", + "Helps the model to associate messages with characters.": "モデルがメッセージをキャラクターに関連付けるのに役立ちます。", "None": "なし", "character_names_none": "グループと過去のペルソナを除きます。それ以外の場合は、プロンプトに名前を必ず入力してください。", "Don't add character names.": "キャラクター名を追加しないでください。", @@ -222,7 +222,7 @@ "character_names_completion": "制限事項: ラテン英数字とアンダースコアのみ。すべてのソースで機能するわけではありません。特に、Claude、MistralAI、Google では機能しません。", "Add character names to completion objects.": "完了オブジェクトにキャラクター名を追加します。", "Message Content": "メッセージ内容", - "Prepend character names to message contents.": "メッセージの内容の先頭に文字名を追加します。", + "Prepend character names to message contents.": "メッセージの内容の先頭にキャラクター名を追加します。", "Continue Postfix": "ポストフィックスの継続", "The next chunk of the continued message will be appended using this as a separator.": "継続メッセージの次のチャンクは、これを区切り文字として使用して追加されます。", "Space": "空間", @@ -270,9 +270,9 @@ "Text Completion": "テキスト補完", "Chat Completion": "チャット完了", "NovelAI": "NovelAI", - "KoboldAI Horde": "KoboldAIホルド", + "KoboldAI Horde": "KoboldAI Horde", "KoboldAI": "KoboldAI", - "Avoid sending sensitive information to the Horde.": "ホルドに機密情報を送信しないでください。", + "Avoid sending sensitive information to the Horde.": "Hordeに機密情報を送信しないでください。", "Review the Privacy statement": "プライバシー声明を確認する", "Register a Horde account for faster queue times": "キュー待ち時間を短縮するためにHordeアカウントを登録する", "Learn how to contribute your idle GPU cycles to the Horde": "アイドルのGPUサイクルをホルドに貢献する方法を学びます", @@ -633,7 +633,7 @@ "Tags_as_Folders_desc": "最近の変更: タグは、タグ管理メニューでフォルダーとしてマークされて初めてフォルダーとして表示されます。ここをクリックして表示します。", "Character Handling": "キャラクター処理", "If set in the advanced character definitions, this field will be displayed in the characters list.": "高度なキャラクター定義で設定されている場合、このフィールドがキャラクターリストに表示されます。", - "Char List Subheader": "文字リストサブヘッダー", + "Char List Subheader": "キャラクターリストサブヘッダー", "Character Version": "キャラクターバージョン", "Created by": "作成者", "Use fuzzy matching, and search characters in the list by all data fields, not just by a name substring": "曖昧な一致を使用し、名前の部分文字列ではなく、すべてのデータフィールドでリスト内のキャラクターを検索する", @@ -729,8 +729,8 @@ "Automatically hide details": "詳細を自動的に非表示にする", "Determines how entries are found for autocomplete.": "オートコンプリートのエントリの検索方法を決定します。", "Autocomplete Matching": "マッチング", - "Starts with": "始まりは", - "Includes": "含まれるもの", + "Starts with": "前方一致", + "Includes": "部分一致", "Fuzzy": "ファジー", "Sets the style of the autocomplete.": "オートコンプリートのスタイルを設定します。", "Autocomplete Style": "スタイル", @@ -755,8 +755,8 @@ "Auto-select": "自動選択", "System Backgrounds": "システムの背景", "Chat Backgrounds": "チャットの背景", - "bg_chat_hint_1": "チャットの背景は、", - "bg_chat_hint_2": "拡張子がここに表示されます。", + "bg_chat_hint_1": "", + "bg_chat_hint_2": "拡張機能で生成したチャットの背景はここに表示されます。", "Extensions": "拡張機能", "Notify on extension updates": "拡張機能の更新時に通知", "Manage extensions": "拡張機能を管理", @@ -770,9 +770,9 @@ "How do I use this?": "これをどのように使用しますか?", "Click for stats!": "統計をクリック!", "Usage Stats": "使用状況統計", - "Backup your personas to a file": "キャラクタをファイルにバックアップします", + "Backup your personas to a file": "キャラクターをファイルにバックアップします", "Backup": "バックアップ", - "Restore your personas from a file": "ファイルからキャラクタを復元します", + "Restore your personas from a file": "ファイルからキャラクターを復元します", "Restore": "復元", "Create a dummy persona": "ダミーのペルソナを作成", "Create": "作成", @@ -839,7 +839,7 @@ "Describe your character's physical and mental traits here.": "ここにキャラクターの身体的および精神的特徴を説明します。", "First message": "最初のメッセージ", "Click to set additional greeting messages": "追加の挨拶メッセージを設定するにはクリック", - "Alt. Greetings": "挨拶", + "Alt. Greetings": "他の挨拶", "This will be the first message from the character that starts every chat.": "これはすべてのチャットを開始するキャラクターからの最初のメッセージになります。", "Group Controls": "グループコントロール", "Chat Name (Optional)": "チャット名(任意)", @@ -889,7 +889,7 @@ "popup-button-yes": "はい", "popup-button-no": "いいえ", "popup-button-cancel": "キャンセル", - "popup-button-import": "輸入", + "popup-button-import": "インポート", "Advanced Defininitions": "高度な定義", "Prompt Overrides": "プロンプトのオーバーライド", "(For Chat Completion and Instruct Mode)": "(チャット補完と指示モード用)", @@ -937,7 +937,7 @@ "Type here...": "ここに入力...", "Chat Lorebook": "チャットロアブック", "Chat Lorebook for": "チャットロアブック", - "chat_world_template_txt": "選択したワールド情報はこのチャットにバインドされます。AI の返信を生成する際、\nグローバルおよびキャラクターの伝承書のエントリと結合されます。", + "chat_world_template_txt": "選択したワールド情報はこのチャットにバインドされます。AI の返信を生成する際、\nグローバルおよびキャラクターのロアブックのエントリと結合されます。", "Select a World Info file for": "次のためにワールド情報ファイルを選択", "Primary Lorebook": "プライマリロアブック", "A selected World Info will be bound to this character as its own Lorebook.": "選択したワールド情報は、このキャラクターにその独自のロアブックとしてバインドされます。", @@ -1023,7 +1023,6 @@ "prompt_manager_position": "位置", "Injection position. Next to other prompts (relative) or in-chat (absolute).": "挿入位置。他のプロンプトの隣 (相対) またはチャット内 (絶対)。", "prompt_manager_relative": "相対的", - "prompt_manager_absolute": "絶対", "prompt_manager_depth": "深さ", "Injection depth. 0 = after the last message, 1 = before the last message, etc.": "注入の深さ。0 = 最後のメッセージの後、1 = 最後のメッセージの前など。", "Prompt": "プロンプト", @@ -1065,9 +1064,9 @@ "Change it later in the 'User Settings' panel.": "後で「ユーザー設定」パネルで変更します。", "Enable simple UI mode": "シンプルUIモードを有効にする", "Looking for AI characters?": "AIキャラクターをお探しですか?", - "onboarding_import": "輸入", + "onboarding_import": "インポート", "from supported sources or view": "サポートされているソースからまたは表示", - "Sample characters": "サンプル文字", + "Sample characters": "サンプルキャラクター", "Your Persona": "あなたのペルソナ", "Before you get started, you must select a persona name.": "始める前に、ペルソナ名を選択する必要があります。", "welcome_message_part_8": "これはいつでも変更可能です。", @@ -1081,7 +1080,7 @@ "View character card": "キャラクターカードを表示", "Remove from group": "グループから削除", "Add to group": "グループに追加", - "Alternate Greetings": "代わりの挨拶", + "Alternate Greetings": "挨拶のバリエーション", "Alternate_Greetings_desc": "これらは、新しいチャットを開始するときに最初のメッセージにスワイプとして表示されます。\nグループのメンバーは、そのうちの 1 つを選択して会話を開始できます。", "Alternate Greetings Hint": "ボタンをクリックして始めましょう!", "(This will be the first message from the character that starts every chat)": "(これはすべてのチャットを開始するキャラクターからの最初のメッセージになります)", @@ -1118,11 +1117,11 @@ "Will be used as the default CFG options for every chat unless overridden.": "上書きされない限り、すべてのチャットのデフォルトの CFG オプションとして使用されます。", "CFG Prompt Cascading": "CFG プロンプト カスケード", "Combine positive/negative prompts from other boxes.": "他のボックスからの肯定的/否定的なプロンプトを組み合わせます。", - "For example, ticking the chat, global, and character boxes combine all negative prompts into a comma-separated string.": "たとえば、チャット、グローバル、および文字のボックスにチェックを入れると、すべての否定プロンプトがコンマ区切りの文字列に結合されます。", + "For example, ticking the chat, global, and character boxes combine all negative prompts into a comma-separated string.": "たとえば、チャット、グローバル、およびキャラクターのボックスにチェックを入れると、すべてのネガティブプロンプトがコンマ区切りの文字列に結合されます。", "Always Include": "常に含めます", "Chat Negatives": "チャットのネガティブ", - "Character Negatives": "性格のマイナス面", - "Global Negatives": "世界的なマイナス", + "Character Negatives": "キャラクターのネガティブ", + "Global Negatives": "グローバルネガティブ", "Custom Separator:": "カスタムセパレーター:", "Insertion Depth:": "挿入深さ:", "Token Probabilities": "トークン確率", @@ -1236,7 +1235,7 @@ "ext_regex_title": "正規表現", "ext_regex_new_global_script": "+ グローバル", "ext_regex_new_scoped_script": "+ スコープ付き", - "ext_regex_import_script": "輸入", + "ext_regex_import_script": "インポート", "ext_regex_global_scripts": "グローバルスクリプト", "ext_regex_global_scripts_desc": "すべてのキャラクターで使用可能。ローカル設定に保存されます。", "ext_regex_scoped_scripts": "スコープ付きスクリプト", @@ -1301,8 +1300,8 @@ "Authentication (optional)": "認証(オプション)", "Example: username:password": "例: ユーザー名:パスワード", "Important:": "重要:", - "sd_auto_auth_warning_1": "SD Web UIを実行する", - "sd_auto_auth_warning_2": "フラグ! サーバーは SillyTavern ホスト マシンからアクセスできる必要があります。", + "sd_auto_auth_warning_1": "SD Web UIを", + "sd_auto_auth_warning_2": "フラグを指定して実行してください! サーバーは SillyTavern ホスト マシンからアクセスできる必要があります。", "sd_drawthings_url": "例: {{drawthings_url}}", "sd_drawthings_auth_txt": "UI で HTTP API スイッチを有効にして DrawThings アプリを実行します。サーバーは SillyTavern ホスト マシンからアクセスできる必要があります。", "sd_vlad_url": "例: {{vlad_url}}", @@ -1326,36 +1325,36 @@ "Enhance": "強化する", "Refine": "リファイン", "Decrisper": "デクリスパー", - "Sampling steps": "サンプリング手順 ()", - "Width": "幅 ()", - "Height": "身長 ()", - "Resolution": "解決", + "Sampling steps": "サンプリングステップ数", + "Width": "幅", + "Height": "高さ", + "Resolution": "解像度", "Model": "モデル", "Sampling method": "サンプリング方法", "Karras (not all samplers supported)": "Karras (すべてのサンプラーがサポートされているわけではありません)", "SMEA versions of samplers are modified to perform better at high resolution.": "SMEA バージョンのサンプラーは、高解像度でより優れたパフォーマンスを発揮するように変更されています。", - "SMEA": "中小企業庁", + "SMEA": "SMEA", "DYN variants of SMEA samplers often lead to more varied output, but may fail at very high resolutions.": "SMEA サンプラーの DYN バリアントは、多くの場合、より多様な出力をもたらしますが、非常に高い解像度では失敗する可能性があります。", "DYN": "ダイナミック", - "Scheduler": "スケジューラ", - "Restore Faces": "顔を復元する", - "Hires. Fix": "雇用。修正", + "Scheduler": "スケジューラー", + "Restore Faces": "顔の修復", + "Hires. Fix": "高解像度補助", "Upscaler": "アップスケーラー", - "Upscale by": "高級化", + "Upscale by": "アップスケール倍率", "Denoising strength": "ノイズ除去の強さ", - "Hires steps (2nd pass)": "採用手順(2回目のパス)", - "Preset for prompt prefix and negative prompt": "プロンプトプレフィックスと否定プロンプトのプリセット", + "Hires steps (2nd pass)": "高解像度でのステップ数", + "Preset for prompt prefix and negative prompt": "プロンプトプレフィックスとネガティブプロンプトのプリセット", "Style": "スタイル", "Save style": "スタイルを保存", "Delete style": "スタイルを削除", - "Common prompt prefix": "一般的なプロンプトプレフィックス", + "Common prompt prefix": "共通のプロンプトプレフィックス", "sd_prompt_prefix_placeholder": "生成されたプロンプトを挿入する場所を指定するには、{prompt}を使用します。", - "Negative common prompt prefix": "否定の共通プロンプト接頭辞", - "Character-specific prompt prefix": "文字固有のプロンプトプレフィックス", + "Negative common prompt prefix": "共通のネガティブプロンプトプレフィックス", + "Character-specific prompt prefix": "キャラクター固有のプロンプトプレフィックス", "Won't be used in groups.": "グループでは使用されません。", - "sd_character_prompt_placeholder": "現在選択されているキャラクターを説明する任意の特性。共通のプロンプト プレフィックスの後に追加されます。\n例: 女性、緑の目、茶色の髪、ピンクのシャツ", - "Character-specific negative prompt prefix": "文字固有の否定プロンプト接頭辞", - "sd_character_negative_prompt_placeholder": "選択したキャラクターに表示されるべきではない特性。否定の共通プロンプト接頭辞の後に追加されます。\n例: ジュエリー、靴、メガネ", + "sd_character_prompt_placeholder": "現在選択されているキャラクターを説明する特徴。共通のプロンプトプレフィックスの後に追加されます。\n例: 女性、緑の目、茶色の髪、ピンクのシャツ", + "Character-specific negative prompt prefix": "キャラクター固有のネガティブプロンプトプレフィックス", + "sd_character_negative_prompt_placeholder": "選択したキャラクターに表示されるべきではない特徴。共通のネガティブプロンプトプレフィックスの後に追加されます。\n例: ジュエリー、靴、メガネ", "Shareable": "共有可能", "Image Prompt Templates": "画像プロンプトテンプレート", "Vectors Model Warning": "チャットの途中でモデルを変更する場合は、ベクトルを消去することをお勧めします。そうしないと、標準以下の結果になります。", @@ -1380,22 +1379,22 @@ "Warning:": "警告:", "This action is irreversible.": "この操作は元に戻せません。", "Type the user's handle below to confirm:": "確認するには、以下のユーザーのハンドルを入力してください。", - "Import Characters": "文字をインポートする", + "Import Characters": "キャラクターをインポートする", "Enter the URL of the content to import": "インポートするコンテンツの URL を入力します", "Supported sources:": "サポートされているソース:", - "char_import_1": "チャブキャラクター(直接リンクまたはID)", + "char_import_1": "Chub キャラクター (直接リンクまたはID)", "char_import_example": "例:", - "char_import_2": "チャブの伝承集 (直接リンクまたは ID)", + "char_import_2": "Chub ロアブック (直接リンクまたは ID)", "char_import_3": "JanitorAI キャラクター (直接リンクまたは UUID)", "char_import_4": "Pygmalion.chat キャラクター (直接リンクまたは UUID)", "char_import_5": "AICharacterCard.com キャラクター (直接リンクまたは ID)", "char_import_6": "直接PNGリンク(参照", "char_import_7": "許可されたホストの場合)", "char_import_8": "RisuRealm キャラクター (直接リンク)", - "Supports importing multiple characters.": "複数の文字のインポートをサポートします。", + "Supports importing multiple characters.": "複数のキャラクターのインポートをサポートします。", "Write each URL or ID into a new line.": "各 URL または ID を新しい行に入力します。", - "Export for character": "文字のエクスポート", - "Export prompts for this character, including their order.": "この文字のプロンプトを順序も含めてエクスポートします。", + "Export for character": "キャラクターのエクスポート", + "Export prompts for this character, including their order.": "このキャラクターのプロンプトを順序も含めてエクスポートします。", "Export all": "すべてをエクスポート", "Export all your prompts to a file": "すべてのプロンプトをファイルにエクスポートする", "Insert prompt": "プロンプトを挿入", diff --git a/public/locales/ko-kr.json b/public/locales/ko-kr.json index 1c56ff3cc..cf04e38ae 100644 --- a/public/locales/ko-kr.json +++ b/public/locales/ko-kr.json @@ -1023,7 +1023,6 @@ "prompt_manager_position": "위치", "Injection position. Next to other prompts (relative) or in-chat (absolute).": "주입 위치. 다른 프롬프트 옆(상대적) 또는 채팅 내(절대적).", "prompt_manager_relative": "상대적인", - "prompt_manager_absolute": "순수한", "prompt_manager_depth": "깊이", "Injection depth. 0 = after the last message, 1 = before the last message, etc.": "주입 깊이. 0 = 마지막 메시지 뒤, 1 = 마지막 메시지 앞 등", "Prompt": "프롬프트", diff --git a/public/locales/nl-nl.json b/public/locales/nl-nl.json index f102ee93e..256eda091 100644 --- a/public/locales/nl-nl.json +++ b/public/locales/nl-nl.json @@ -1023,7 +1023,6 @@ "prompt_manager_position": "Positie", "Injection position. Next to other prompts (relative) or in-chat (absolute).": "Injectiepositie. Naast andere prompts (relatief) of in-chat (absoluut).", "prompt_manager_relative": "Familielid", - "prompt_manager_absolute": "Absoluut", "prompt_manager_depth": "Diepte", "Injection depth. 0 = after the last message, 1 = before the last message, etc.": "Injectiediepte. 0 = na het laatste bericht, 1 = voor het laatste bericht, etc.", "Prompt": "Prompt", diff --git a/public/locales/pt-pt.json b/public/locales/pt-pt.json index caf918890..77bbdbafd 100644 --- a/public/locales/pt-pt.json +++ b/public/locales/pt-pt.json @@ -1023,7 +1023,6 @@ "prompt_manager_position": "Posição", "Injection position. Next to other prompts (relative) or in-chat (absolute).": "Posição de injeção. Ao lado de outras solicitações (relativas) ou no chat (absolutas).", "prompt_manager_relative": "Relativo", - "prompt_manager_absolute": "Absoluto", "prompt_manager_depth": "Profundidade", "Injection depth. 0 = after the last message, 1 = before the last message, etc.": "Profundidade de injeção. 0 = após a última mensagem, 1 = antes da última mensagem, etc.", "Prompt": "Prompt", diff --git a/public/locales/ru-ru.json b/public/locales/ru-ru.json index 878433ca0..02d19207a 100644 --- a/public/locales/ru-ru.json +++ b/public/locales/ru-ru.json @@ -163,9 +163,9 @@ "View hidden API keys": "Посмотреть скрытые API-ключи", "Advanced Formatting": "Расширенное форматирование", "Context Template": "Шаблон контекста", - "Replace Macro in Custom Stopping Strings": "Заменить макросы в пользовательских стоп-строках", + "Replace Macro in Custom Stopping Strings": "Заменять макросы в пользовательских стоп-строках", "Story String": "Строка истории", - "Example Separator": "Пример разделителя", + "Example Separator": "Разделитель примеров сообщений", "Chat Start": "Начало чата", "Activation Regex": "Regex для активации", "Instruct Mode": "Режим Instruct", @@ -187,7 +187,7 @@ "Misc. Settings": "Доп. настройки", "Auto-Continue": "Авто-продолжение", "Collapse Consecutive Newlines": "Сворачивать последовательные новые строки", - "Allow for Chat Completion APIs": "Разрешить для API Chat Completion", + "Allow for Chat Completion APIs": "Разрешить для Chat Completion API", "Target length (tokens)": "Целевая длина (в токенах)", "World Info": "Информация о мире", "Scan Depth": "Глубина сканирования", @@ -377,7 +377,7 @@ "Log prompts to console": "Выводить промпты в консоль", "Never resize avatars": "Не менять размер аватарок", "Show avatar filenames": "Показывать названия файлов аватарок", - "Import Card Tags": "Импорт тегов карточки", + "Import Card Tags": "Импортировать теги карточки", "Confirm message deletion": "Подтверждение удаления сообщений", "Spoiler Free Mode": "Режим без спойлеров", "Auto-swipe": "Автоматические свайпы", @@ -1025,7 +1025,6 @@ "prompt_manager_position": "Точка инжекта", "Injection position. Next to other prompts (relative) or in-chat (absolute).": "Как рассчитывать позицию для инжекта. Она может располагаться по отношению к другим промптам (относительная) либо по отношению к чату (абсолютная).", "prompt_manager_relative": "Относительная", - "prompt_manager_absolute": "Абсолютная", "prompt_manager_depth": "Глубина", "Injection depth. 0 = after the last message, 1 = before the last message, etc.": "Глубина вставки. 0 = после последнего сообщения, 1 = перед последним сообщением, и т.д.", "The prompt to be sent.": "Отправляемый ИИ промпт.", @@ -1274,7 +1273,7 @@ "openrouter_force_instruct": "This option is outdated and will be removed in the future. To use instruct formatting, please switch to OpenRouter under Text Completion API instead.", "Force_Instruct_Mode_formatting_Description": "If both Instruct Mode and this are enabled, the prompt will be formatted by SillyTavern using the current\n advanced formatting settings (except instruct System Prompt). If disabled, the prompt will be formatted by OpenRouter.", "Clear your cookie": "Clear your cookie", - "Add Chat Start and Example Separator to a list of stopping strings.": "Add Chat Start and Example Separator to a list of stopping strings.", + "Add Chat Start and Example Separator to a list of stopping strings.": "Использовать Начало чата и Разделитель примеров сообщений в качестве стоп-строк.", "context_allow_jailbreak": "Если в карточке есть джейлбрейк И ПРИ ЭТОМ включена опция \"Приоритет джейлбрейку из карточки персонажа\", то этот джейлбрейк добавляется в конец промпта.\nНЕ РЕКОМЕНДУЕТСЯ ДЛЯ МОДЕЛЕЙ TEXT COMPLETION, МОЖЕТ ПОРТИТЬ ВЫХОДНОЙ ТЕКСТ.", "Context Order": "Context Order", "Summary": "Summary", @@ -1636,5 +1635,10 @@ "Change Password": "Сменить пароль", "Reset Code:": "Reset Code:", "Click _space": "Нажмите ", - "Alternate Greeting #": "Вариант #" + "Alternate Greeting #": "Вариант #", + "Defines on importing cards which action should be chosen for importing its listed tags. 'Ask' will always display the dialog.": "Выберите, какие действия следует предпринять по отношению к тегам импортируемой карточки. При выборе опции \"Спрашивать\" вы будете решать это индивидуально для каждой карточки.", + "Ask": "Спрашивать", + "tag_import_all": "Все", + "Existing": "Только существующие", + "tag_import_none": "Не импортировать" } diff --git a/public/locales/uk-ua.json b/public/locales/uk-ua.json index ec9c933fa..bd2ae43f1 100644 --- a/public/locales/uk-ua.json +++ b/public/locales/uk-ua.json @@ -1023,7 +1023,6 @@ "prompt_manager_position": "Позиція", "Injection position. Next to other prompts (relative) or in-chat (absolute).": "Позиція ін'єкції. Поруч з іншими підказками (відносні) або в чаті (абсолютні).", "prompt_manager_relative": "Відносна", - "prompt_manager_absolute": "Абсолютний", "prompt_manager_depth": "Глибина", "Injection depth. 0 = after the last message, 1 = before the last message, etc.": "Глибина ін'єкції. 0 = після останнього повідомлення, 1 = перед останнім повідомленням тощо.", "Prompt": "Запит", diff --git a/public/locales/vi-vn.json b/public/locales/vi-vn.json index bfcfb4dd3..d7399ea45 100644 --- a/public/locales/vi-vn.json +++ b/public/locales/vi-vn.json @@ -1023,7 +1023,6 @@ "prompt_manager_position": "Chức vụ", "Injection position. Next to other prompts (relative) or in-chat (absolute).": "Vị trí tiêm. Bên cạnh các lời nhắc khác (tương đối) hoặc trong trò chuyện (tuyệt đối).", "prompt_manager_relative": "Liên quan đến", - "prompt_manager_absolute": "tuyệt đối", "prompt_manager_depth": "Chiều sâu", "Injection depth. 0 = after the last message, 1 = before the last message, etc.": "Độ sâu phun. 0 = sau tin nhắn cuối cùng, 1 = trước tin nhắn cuối cùng, v.v.", "Prompt": "Đề xuất", diff --git a/public/locales/zh-cn.json b/public/locales/zh-cn.json index a7f7ae597..1e236dcf6 100644 --- a/public/locales/zh-cn.json +++ b/public/locales/zh-cn.json @@ -217,6 +217,7 @@ "Character Names Behavior": "角色名称行为", "Helps the model to associate messages with characters.": "有助于模型将消息与角色关联起来。", "None": "无", + "tag_import_none": "无", "character_names_none": "群聊和过去的角色除外。否则,请确保在提示词中提供了姓名。", "Don't add character names.": "不添加角色名称。", "Completion": "补全对象", @@ -333,6 +334,9 @@ "vLLM API key": "vLLM API 密钥", "Example: 127.0.0.1:8000": "例如:http://127.0.0.1:8000", "vLLM Model": "vLLM 模型", + "HuggingFace Token": "HuggingFace 代币", + "Endpoint URL": "端点 URL", + "Example: https://****.endpoints.huggingface.cloud": "例如:https://****.endpoints.huggingface.cloud", "PygmalionAI/aphrodite-engine": "PygmalionAI/aphrodite-engine(用于OpenAI API的包装器)", "Aphrodite API key": "Aphrodite API 密钥", "Aphrodite Model": "Aphrodite 模型", @@ -418,6 +422,8 @@ "Prompt Post-Processing": "提示词后处理", "Applies additional processing to the prompt before sending it to the API.": "在将提示词发送到 API 之前对其进行额外处理。", "prompt_post_processing_none": "未选择", + "01.AI API Key": "01.AI API密钥", + "01.AI Model": "01.AI模型", "Additional Parameters": "附加参数", "Verifies your API connection by sending a short test message. Be aware that you'll be credited for it!": "通过发送简短的测试消息验证您的API连接。请注意,您将因此而消耗额度!", "Test Message": "发送测试消息", @@ -653,7 +659,7 @@ "Defines on importing cards which action should be chosen for importing its listed tags. 'Ask' will always display the dialog.": "定义在导入卡片时应选择哪种操作来导入其列出的标签。“询问”将始终显示对话框。", "Import Card Tags": "导入卡片标签", "Ask": "询问", - "All": "全部", + "tag_import_all": "全部", "Existing": "现存的", "Use fuzzy matching, and search characters in the list by all data fields, not just by a name substring": "使用模糊匹配,在列表中通过所有数据字段搜索角色,而不仅仅是名称子字符串", "Advanced Character Search": "高级角色搜索", @@ -1032,6 +1038,8 @@ "Sticky": "粘性", "Entries with a cooldown can't be activated N messages after being triggered.": "具有冷却时间的条目在触发后 N 条消息内无法被激活。", "Cooldown": "冷却", + "Entries with a delay can't be activated until there are N messages present in the chat.": "直到聊天中出现 N 条消息时,延迟的条目才能被激活。", + "Delay": "延迟", "Filter to Character(s)": "应用到角色", "Character Exclusion": "反选角色", "-- Characters not found --": "-- 未找到角色 --", @@ -1048,7 +1056,6 @@ "prompt_manager_position": "位置", "Injection position. Next to other prompts (relative) or in-chat (absolute).": "注入位置。其他提示词旁边(相对)或在聊天中(绝对)。", "prompt_manager_relative": "相对", - "prompt_manager_absolute": "绝对", "prompt_manager_depth": "深度", "Injection depth. 0 = after the last message, 1 = before the last message, etc.": "注入深度。0 = 在最后一条消息之后,1 = 在最后一条消息之前,等等。", "Prompt": "提示词", @@ -1076,6 +1083,7 @@ "Move message up": "将消息上移", "Move message down": "将消息下移", "Enlarge": "放大", + "Caption": "标题", "Welcome to SillyTavern!": "欢迎来到 SillyTavern!", "welcome_message_part_1": "阅读", "welcome_message_part_2": "官方文档", @@ -1112,10 +1120,6 @@ "alternate_greetings_hint_2": "按钮即可开始!", "Alternate Greeting #": "额外问候语 #", "(This will be the first message from the character that starts every chat)": "(这将是角色在每次聊天开始时发送的第一条消息)", - "Forbid Media Override explanation": "当前角色/群组在聊天中使用外部媒体的能力。", - "Forbid Media Override subtitle": "媒体:图像、视频、音频。外部:不在本地服务器上托管。", - "Always forbidden": "始终禁止", - "Always allowed": "始终允许", "View contents": "查看内容", "Remove the file": "删除文件", "Unique to this chat": "此聊天独有", @@ -1239,6 +1243,7 @@ "Message Template": "消息模板", "(use _space": "(使用", "macro)": "宏指令)", + "Automatically caption images": "自动为图像添加标题", "Edit captions before saving": "保存前编辑标题", "Character Expressions": "角色表情", "Translate text to English before classification": "分类之前将文本翻译成英文", @@ -1578,6 +1583,10 @@ "Warning:": "警告:", "This action is irreversible.": "此操作不可逆。", "Type the user's handle below to confirm:": "在下面输入用户的名称以确认:", + "Forbid Media Override explanation": "当前角色/群组在聊天中使用外部媒体的能力。", + "Forbid Media Override subtitle": "媒体:图像、视频、音频。外部:不在本地服务器上托管。", + "Always forbidden": "始终禁止", + "Always allowed": "始终允许", "help_format_1": "文本格式化命令:", "help_format_2": "*文本*", "help_format_3": "显示为", diff --git a/public/locales/zh-tw.json b/public/locales/zh-tw.json index cb5cb0965..e8418ef56 100644 --- a/public/locales/zh-tw.json +++ b/public/locales/zh-tw.json @@ -1025,7 +1025,6 @@ "prompt_manager_position": "位置", "Injection position. Next to other prompts (relative) or in-chat (absolute).": "注入位置。與其他提示詞相鄰(相對位置)或在聊天中(絕對位置)。", "prompt_manager_relative": "相對位置", - "prompt_manager_absolute": "絕對位置", "prompt_manager_depth": "深度", "Injection depth. 0 = after the last message, 1 = before the last message, etc.": "注入深度。0 = 在最後一條訊息之後,1 = 在最後一條訊息之前,以此類推。", "Prompt": "提示詞", diff --git a/public/script.js b/public/script.js index 67335b544..79eae6303 100644 --- a/public/script.js +++ b/public/script.js @@ -22,7 +22,7 @@ import { parseTabbyLogprobs, } from './scripts/textgen-settings.js'; -const { MANCER, TOGETHERAI, OOBA, VLLM, APHRODITE, TABBY, OLLAMA, INFERMATICAI, DREAMGEN, OPENROUTER } = textgen_types; +const { MANCER, TOGETHERAI, OOBA, VLLM, APHRODITE, TABBY, OLLAMA, INFERMATICAI, DREAMGEN, OPENROUTER, FEATHERLESS } = textgen_types; import { world_info, @@ -35,6 +35,7 @@ import { setWorldInfoButtonClass, importWorldInfo, wi_anchor_position, + world_info_include_names, } from './scripts/world-info.js'; import { @@ -43,7 +44,6 @@ import { saveGroupChat, getGroups, generateGroupWrapper, - deleteGroup, is_group_generating, resetSelectedGroup, select_group_chats, @@ -159,7 +159,7 @@ import { import { debounce_timeout } from './scripts/constants.js'; import { ModuleWorkerWrapper, doDailyExtensionUpdatesCheck, extension_settings, getContext, loadExtensionSettings, renderExtensionTemplate, renderExtensionTemplateAsync, runGenerationInterceptors, saveMetadataDebounced, writeExtensionField } from './scripts/extensions.js'; -import { COMMENT_NAME_DEFAULT, executeSlashCommands, executeSlashCommandsOnChatInput, getSlashCommandsHelp, isExecutingCommandsFromChatInput, pauseScriptExecution, processChatSlashCommands, registerSlashCommand, stopScriptExecution } from './scripts/slash-commands.js'; +import { COMMENT_NAME_DEFAULT, executeSlashCommands, executeSlashCommandsOnChatInput, getSlashCommandsHelp, initDefaultSlashCommands, isExecutingCommandsFromChatInput, pauseScriptExecution, processChatSlashCommands, registerSlashCommand, stopScriptExecution } from './scripts/slash-commands.js'; import { tag_map, tags, @@ -209,7 +209,7 @@ import { instruct_presets, selectContextPreset, } from './scripts/instruct-mode.js'; -import { initLocales } from './scripts/i18n.js'; +import { initLocales, t, translate } from './scripts/i18n.js'; import { getFriendlyTokenizerName, getTokenCount, getTokenCountAsync, getTokenizerModel, initTokenizers, saveTokenCache } from './scripts/tokenizers.js'; import { user_avatar, @@ -223,10 +223,10 @@ import { import { getBackgrounds, initBackgrounds, loadBackgroundSettings, background_settings } from './scripts/backgrounds.js'; import { hideLoader, showLoader } from './scripts/loader.js'; import { BulkEditOverlay, CharacterContextMenu } from './scripts/BulkEditOverlay.js'; -import { loadMancerModels, loadOllamaModels, loadTogetherAIModels, loadInfermaticAIModels, loadOpenRouterModels, loadVllmModels, loadAphroditeModels, loadDreamGenModels } from './scripts/textgen-models.js'; +import { loadFeatherlessModels, loadMancerModels, loadOllamaModels, loadTogetherAIModels, loadInfermaticAIModels, loadOpenRouterModels, loadVllmModels, loadAphroditeModels, loadDreamGenModels } from './scripts/textgen-models.js'; import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, decodeStyleTags, encodeStyleTags, isExternalMediaAllowed, getCurrentEntityId } from './scripts/chats.js'; import { initPresetManager } from './scripts/preset-manager.js'; -import { evaluateMacros } from './scripts/macros.js'; +import { MacrosParser, evaluateMacros } from './scripts/macros.js'; import { currentUser, setUserControls } from './scripts/user.js'; import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup, fixToastrForDialogs } from './scripts/popup.js'; import { renderTemplate, renderTemplateAsync } from './scripts/templates.js'; @@ -266,8 +266,6 @@ await new Promise((resolve) => { }); showLoader(); -// Yoink preloader entirely; it only exists to cover up unstyled content while loading JS -document.getElementById('preloader').remove(); // Configure toast library: toastr.options.escapeHtml = true; // Prevent raw HTML inserts @@ -408,6 +406,7 @@ export const event_types = { MESSAGE_EDITED: 'message_edited', MESSAGE_DELETED: 'message_deleted', MESSAGE_UPDATED: 'message_updated', + MESSAGE_FILE_EMBEDDED: 'message_file_embedded', IMPERSONATE_READY: 'impersonate_ready', CHAT_CHANGED: 'chat_id_changed', GENERATION_STARTED: 'generation_started', @@ -454,6 +453,7 @@ export const event_types = { OPEN_CHARACTER_LIBRARY: 'open_character_library', LLM_FUNCTION_TOOL_REGISTER: 'llm_function_tool_register', LLM_FUNCTION_TOOL_CALL: 'llm_function_tool_call', + ONLINE_STATUS_CHANGED: 'online_status_changed', }; export const eventSource = new EventEmitter(); @@ -521,6 +521,7 @@ const chatElement = $('#chat'); let dialogueResolve = null; let dialogueCloseStop = false; export let chat_metadata = {}; +/** @type {StreamingProcessor} */ export let streamingProcessor = null; let crop_data = undefined; let is_delete_mode = false; @@ -838,6 +839,7 @@ export let main_api;// = "kobold"; //novel settings export let novelai_settings; export let novelai_setting_names; +/** @type {AbortController} */ let abortController; //css @@ -910,6 +912,7 @@ async function firstLoadInit() { initKeyboard(); initDynamicStyles(); initTags(); + initDefaultSlashCommands(); await getUserAvatars(true, user_avatar); await getCharacters(); await getBackgrounds(); @@ -1050,10 +1053,10 @@ export async function clearItemizedPrompts() { async function getStatusHorde() { try { const hordeStatus = await checkHordeStatus(); - online_status = hordeStatus ? 'Connected' : 'no_connection'; + setOnlineStatus(hordeStatus ? 'Connected' : 'no_connection'); } catch { - online_status = 'no_connection'; + setOnlineStatus('no_connection'); } return resultCheckStatus(); @@ -1064,7 +1067,7 @@ async function getStatusKobold() { if (!endpoint) { console.warn('No endpoint for status check'); - online_status = 'no_connection'; + setOnlineStatus('no_connection'); return resultCheckStatus(); } @@ -1081,7 +1084,7 @@ async function getStatusKobold() { const data = await response.json(); - online_status = data?.model ?? 'no_connection'; + setOnlineStatus(data?.model ?? 'no_connection'); if (!data.koboldUnitedVersion) { throw new Error('Missing mandatory Kobold version in data:', data); @@ -1099,7 +1102,7 @@ async function getStatusKobold() { } } catch (err) { console.error('Error getting status', err); - online_status = 'no_connection'; + setOnlineStatus('no_connection'); } return resultCheckStatus(); @@ -1112,12 +1115,12 @@ async function getStatusTextgen() { if (!endpoint) { console.warn('No endpoint for status check'); - online_status = 'no_connection'; + setOnlineStatus('no_connection'); return resultCheckStatus(); } if (textgen_settings.type == OOBA && textgen_settings.bypass_status_check) { - online_status = 'Status check bypassed'; + setOnlineStatus('Status check bypassed'); return resultCheckStatus(); } @@ -1137,34 +1140,37 @@ async function getStatusTextgen() { if (textgen_settings.type === MANCER) { loadMancerModels(data?.data); - online_status = textgen_settings.mancer_model; + setOnlineStatus(textgen_settings.mancer_model); } else if (textgen_settings.type === TOGETHERAI) { loadTogetherAIModels(data?.data); - online_status = textgen_settings.togetherai_model; + setOnlineStatus(textgen_settings.togetherai_model); } else if (textgen_settings.type === OLLAMA) { loadOllamaModels(data?.data); - online_status = textgen_settings.ollama_model || 'Connected'; + setOnlineStatus(textgen_settings.ollama_model || 'Connected'); } else if (textgen_settings.type === INFERMATICAI) { loadInfermaticAIModels(data?.data); - online_status = textgen_settings.infermaticai_model; + setOnlineStatus(textgen_settings.infermaticai_model); } else if (textgen_settings.type === DREAMGEN) { loadDreamGenModels(data?.data); - online_status = textgen_settings.dreamgen_model; + setOnlineStatus(textgen_settings.dreamgen_model); } else if (textgen_settings.type === OPENROUTER) { loadOpenRouterModels(data?.data); - online_status = textgen_settings.openrouter_model; + setOnlineStatus(textgen_settings.openrouter_model); } else if (textgen_settings.type === VLLM) { loadVllmModels(data?.data); - online_status = textgen_settings.vllm_model; + setOnlineStatus(textgen_settings.vllm_model); } else if (textgen_settings.type === APHRODITE) { loadAphroditeModels(data?.data); - online_status = textgen_settings.aphrodite_model; + setOnlineStatus(textgen_settings.aphrodite_model); + } else if (textgen_settings.type === FEATHERLESS) { + loadFeatherlessModels(data?.data); + setOnlineStatus(textgen_settings.featherless_model); } else { - online_status = data?.result; + setOnlineStatus(data?.result); } if (!online_status) { - online_status = 'no_connection'; + setOnlineStatus('no_connection'); } // Determine instruct mode preset @@ -1176,7 +1182,7 @@ async function getStatusTextgen() { } } catch (err) { console.error('Error getting status', err); - online_status = 'no_connection'; + setOnlineStatus('no_connection'); } return resultCheckStatus(); @@ -1190,9 +1196,9 @@ async function getStatusNovel() { throw new Error('Could not load subscription data'); } - online_status = getNovelTier(); + setOnlineStatus(getNovelTier()); } catch { - online_status = 'no_connection'; + setOnlineStatus('no_connection'); } resultCheckStatus(); @@ -2073,14 +2079,23 @@ export function updateMessageBlock(messageId, message) { appendMediaToMessage(message, messageElement); } -export function appendMediaToMessage(mes, messageElement) { +/** + * Appends image or file to the message element. + * @param {object} mes Message object + * @param {JQuery<HTMLElement>} messageElement Message element + * @param {boolean} [adjustScroll=true] Whether to adjust the scroll position after appending the media + */ +export function appendMediaToMessage(mes, messageElement, adjustScroll = true) { // Add image to message if (mes.extra?.image) { const chatHeight = $('#chat').prop('scrollHeight'); const image = messageElement.find('.mes_img'); const text = messageElement.find('.mes_text'); const isInline = !!mes.extra?.inline_image; - image.on('load', function () { + image.off('load').on('load', function () { + if (!adjustScroll) { + return; + } const scrollPosition = $('#chat').scrollTop(); const newChatHeight = $('#chat').prop('scrollHeight'); const diff = newChatHeight - chatHeight; @@ -3399,6 +3414,10 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro let regexedMessage = getRegexedString(message, regexType, options); regexedMessage = await appendFileContent(chatItem, regexedMessage); + if (chatItem?.extra?.append_title && chatItem?.extra?.title) { + regexedMessage = `${regexedMessage}\n\n${chatItem.extra.title}`; + } + return { ...chatItem, mes: regexedMessage, @@ -3482,7 +3501,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro // Add WI to prompt (and also inject WI to AN value via hijack) // Make quiet prompt available for WIAN setExtensionPrompt('QUIET_PROMPT', quiet_prompt || '', extension_prompt_types.IN_PROMPT, 0, true); - const chatForWI = coreChat.map(x => `${x.name}: ${x.mes}`).reverse(); + const chatForWI = coreChat.map(x => world_info_include_names ? `${x.name}: ${x.mes}` : x.mes).reverse(); const { worldInfoString, worldInfoBefore, worldInfoAfter, worldInfoExamples, worldInfoDepth } = await getWorldInfoPrompt(chatForWI, this_max_context, dryRun); setExtensionPrompt('QUIET_PROMPT', '', extension_prompt_types.IN_PROMPT, 0, true); @@ -4364,6 +4383,25 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro } } +/** + * Stops the generation and any streaming if it is currently running. + */ +export function stopGeneration() { + let stopped = false; + if (streamingProcessor) { + streamingProcessor.onStopStreaming(); + streamingProcessor = null; + stopped = true; + } + if (abortController) { + abortController.abort('Clicked stop button'); + hideStopButton(); + stopped = true; + } + eventSource.emit(event_types.GENERATION_STOPPED); + return stopped; +} + /** * Injects extension prompts into chat messages. * @param {object[]} messages Array of chat messages @@ -5526,9 +5564,17 @@ export function setCharacterName(value) { name2 = value; } +/** + * Sets the API connection status of the application + * @param {string|'no_connection'} value Connection status value + */ export function setOnlineStatus(value) { + const previousStatus = online_status; online_status = value; displayOnlineStatus(); + if (previousStatus !== online_status) { + eventSource.emitAndWait(event_types.ONLINE_STATUS_CHANGED, online_status); + } } export function setEditedMessageId(value) { @@ -5618,7 +5664,7 @@ export async function renameCharacter(name = null, { silent = false, renameChats } } catch (error) { - // Reloading to prevent data corruption + // Reloading to prevent data corruption if (!silent) await callPopup('Something went wrong. The page will be reloaded.', 'text'); else toastr.error('Something went wrong. The page will be reloaded.', 'Rename Character'); @@ -5766,6 +5812,7 @@ export async function saveChat(chat_name, withMetadata, mesId) { contentType: 'application/json', success: function (data) { }, error: function (jqXHR, exception) { + toastr.error('Check the server connection and reload the page to prevent data loss.', 'Chat could not be saved'); console.log(exception); console.log(jqXHR); }, @@ -6096,7 +6143,7 @@ export function changeMainAPI() { } main_api = selectedVal; - online_status = 'no_connection'; + setOnlineStatus('no_connection'); if (main_api == 'openai' && oai_settings.chat_completion_source == chat_completion_sources.WINDOWAI) { $('#api_button_openai').trigger('click'); @@ -6129,7 +6176,7 @@ async function doOnboarding(avatarId) { template.find('input[name="enable_simple_mode"]').on('input', function () { simpleUiMode = $(this).is(':checked'); }); - let userName = await callGenericPopup(template, POPUP_TYPE.INPUT, currentUser?.name || name1, { rows: 2 }); + let userName = await callGenericPopup(template, POPUP_TYPE.INPUT, currentUser?.name || name1, { rows: 2, wide: true, large: true }); if (userName) { userName = userName.replace('\n', ' '); @@ -7138,7 +7185,8 @@ function onScenarioOverrideRemoveClick() { * @param {string} inputValue - Value to set the input to. * @param {PopupOptions} options - Options for the popup. * @typedef {{okButton?: string, rows?: number, wide?: boolean, wider?: boolean, large?: boolean, allowHorizontalScrolling?: boolean, allowVerticalScrolling?: boolean, cropAspect?: number }} PopupOptions - Options for the popup. - * @returns + * @returns {Promise<any>} A promise that resolves when the popup is closed. + * @deprecated Use `callGenericPopup` instead. */ export function callPopup(text, type, inputValue = '', { okButton, rows, wide, wider, large, allowHorizontalScrolling, allowVerticalScrolling, cropAspect } = {}) { function getOkButtonText() { @@ -7769,6 +7817,7 @@ window['SillyTavern'].getContext = function () { eventTypes: event_types, addOneMessage: addOneMessage, generate: Generate, + stopGeneration: stopGeneration, getTokenCount: getTokenCount, extensionPrompts: extension_prompts, setExtensionPrompt: setExtensionPrompt, @@ -7790,6 +7839,8 @@ window['SillyTavern'].getContext = function () { * @deprecated Handlebars for extensions are no longer supported. */ registerHelper: () => { }, + registerMacro: MacrosParser.registerMacro.bind(MacrosParser), + unregisterMacro: MacrosParser.unregisterMacro.bind(MacrosParser), registedDebugFunction: registerDebugFunction, /** * @deprecated Use renderExtensionTemplateAsync instead. @@ -7799,6 +7850,8 @@ window['SillyTavern'].getContext = function () { registerDataBankScraper: ScraperManager.registerDataBankScraper, callPopup: callPopup, callGenericPopup: callGenericPopup, + showLoader: showLoader, + hideLoader: hideLoader, mainApi: main_api, extensionSettings: extension_settings, ModuleWorkerWrapper: ModuleWorkerWrapper, @@ -7810,6 +7863,8 @@ window['SillyTavern'].getContext = function () { messageFormatting: messageFormatting, shouldSendOnEnter: shouldSendOnEnter, isMobile: isMobile, + t: t, + translate: translate, tags: tags, tagMap: tag_map, menuType: menu_type, @@ -7818,6 +7873,8 @@ window['SillyTavern'].getContext = function () { * @deprecated Legacy snake-case naming, compatibility with old extensions */ event_types: event_types, + POPUP_TYPE: POPUP_TYPE, + POPUP_RESULT: POPUP_RESULT, }; }; @@ -7867,7 +7924,7 @@ function swipe_left() { // when we swipe left..but no generation. } $(this).parent().children('.mes_block').transition({ x: swipe_range, - duration: swipe_duration, + duration: animation_duration > 0 ? swipe_duration : 0, easing: animation_easing, queue: false, complete: async function () { @@ -7910,7 +7967,7 @@ function swipe_left() { // when we swipe left..but no generation. complete: function () { $(this).parent().children('.mes_block').transition({ x: '0px', - duration: swipe_duration, + duration: animation_duration > 0 ? swipe_duration : 0, easing: animation_easing, queue: false, complete: async function () { @@ -7925,7 +7982,7 @@ function swipe_left() { // when we swipe left..but no generation. $(this).parent().children('.avatar').transition({ x: swipe_range, - duration: swipe_duration, + duration: animation_duration > 0 ? swipe_duration : 0, easing: animation_easing, queue: false, complete: function () { @@ -7937,7 +7994,7 @@ function swipe_left() { // when we swipe left..but no generation. complete: function () { $(this).parent().children('.avatar').transition({ x: '0px', - duration: swipe_duration, + duration: animation_duration > 0 ? swipe_duration : 0, easing: animation_easing, queue: false, complete: function () { @@ -8044,7 +8101,7 @@ const swipe_right = () => { this_mes_div.children('.swipe_left').css('display', 'flex'); this_mes_div.children('.mes_block').transition({ // this moves the div back and forth x: '-' + swipe_range, - duration: swipe_duration, + duration: animation_duration > 0 ? swipe_duration : 0, easing: animation_easing, queue: false, complete: async function () { @@ -8103,7 +8160,7 @@ const swipe_right = () => { complete: function () { this_mes_div.children('.mes_block').transition({ x: '0px', - duration: swipe_duration, + duration: animation_duration > 0 ? swipe_duration : 0, easing: animation_easing, queue: false, complete: async function () { @@ -8126,7 +8183,7 @@ const swipe_right = () => { }); this_mes_div.children('.avatar').transition({ // moves avatar along with swipe x: '-' + swipe_range, - duration: swipe_duration, + duration: animation_duration > 0 ? swipe_duration : 0, easing: animation_easing, queue: false, complete: function () { @@ -8138,7 +8195,7 @@ const swipe_right = () => { complete: function () { this_mes_div.children('.avatar').transition({ x: '0px', - duration: swipe_duration, + duration: animation_duration > 0 ? swipe_duration : 0, easing: animation_easing, queue: false, complete: function () { @@ -8279,6 +8336,11 @@ const CONNECT_API_MAP = { button: '#api_button_openai', source: chat_completion_sources.GROQ, }, + '01ai': { + selected: 'openai', + button: '#api_button_openai', + source: chat_completion_sources.ZEROONEAI, + }, 'infermaticai': { selected: 'textgenerationwebui', button: '#api_button_textgenerationwebui', @@ -8294,6 +8356,11 @@ const CONNECT_API_MAP = { button: '#api_button_textgenerationwebui', type: textgen_types.OPENROUTER, }, + 'huggingface': { + selected: 'textgenerationwebui', + button: '#api_button_textgenerationwebui', + type: textgen_types.HUGGINGFACE, + }, }; async function selectContextCallback(_, name) { @@ -8408,10 +8475,10 @@ async function connectAPISlash(_, text) { /** * Imports supported files dropped into the app window. * @param {File[]} files Array of files to process - * @param {boolean?} preserveFileNames Whether to preserve original file names + * @param {Map<File, string>} [data] Extra data to pass to the import function * @returns {Promise<void>} */ -export async function processDroppedFiles(files, preserveFileNames = false) { +export async function processDroppedFiles(files, data = new Map()) { const allowedMimeTypes = [ 'application/json', 'image/png', @@ -8428,7 +8495,8 @@ export async function processDroppedFiles(files, preserveFileNames = false) { for (const file of files) { const extension = file.name.split('.').pop().toLowerCase(); if (allowedMimeTypes.includes(file.type) || allowedExtensions.includes(extension)) { - await importCharacter(file, preserveFileNames); + const preservedName = data instanceof Map && data.get(file); + await importCharacter(file, preservedName); } else { toastr.warning('Unsupported file type: ' + file.name); } @@ -8438,10 +8506,10 @@ export async function processDroppedFiles(files, preserveFileNames = false) { /** * Imports a character from a file. * @param {File} file File to import - * @param {boolean?} preserveFileName Whether to preserve original file name + * @param {string?} preserveFileName Whether to preserve original file name * @returns {Promise<void>} */ -async function importCharacter(file, preserveFileName = false) { +async function importCharacter(file, preserveFileName = '') { if (is_group_generating || is_send_press) { toastr.error('Cannot import characters while generating. Stop the request and try again.', 'Import aborted'); throw new Error('Cannot import character while generating'); @@ -8457,7 +8525,7 @@ async function importCharacter(file, preserveFileName = false) { const formData = new FormData(); formData.append('avatar', file); formData.append('file_type', format); - formData.append('preserve_file_name', String(preserveFileName)); + if (preserveFileName) formData.append('preserved_name', preserveFileName); const data = await jQuery.ajax({ type: 'POST', @@ -8541,6 +8609,39 @@ async function doImpersonate(args, prompt) { return ''; } +export async function doNewChat({ deleteCurrentChat = false } = {}) { + //Make a new chat for selected character + if ((!selected_group && this_chid == undefined) || menu_type == 'create') { + return; + } + + //Fix it; New chat doesn't create while open create character menu + await clearChat(); + chat.length = 0; + + chat_file_for_del = getCurrentChatDetails()?.sessionName; + + // Make it easier to find in backups + if (deleteCurrentChat) { + await saveChatConditional(); + } + + if (selected_group) { + await createNewGroupChat(selected_group); + if (deleteCurrentChat) await deleteGroupChat(selected_group, chat_file_for_del); + } + else { + //RossAscends: added character name to new chat filenames and replaced Date.now() with humanizedDateTime; + chat_metadata = {}; + characters[this_chid].chat = `${name2} - ${humanizedDateTime()}`; + $('#selected_chat_pole').val(characters[this_chid].chat); + await getChat(); + await createOrEditCharacter(new CustomEvent('newChat')); + if (deleteCurrentChat) await delChat(chat_file_for_del + '.jsonl'); + } + +} + async function doDeleteChat() { await displayPastChats(); let currentChatDeleteButton = $('.select_chat_block[highlight=\'true\']').parent().find('.PastChat_cross'); @@ -8649,13 +8750,11 @@ function doCloseChat() { * it proceeds to delete character from UI and saves settings. * In case of error during the fetch request, it logs the error details. * - * @param {string} popup_type - The type of popup currently active. * @param {string} this_chid - The character ID to be deleted. * @param {boolean} delete_chats - Whether to delete chats or not. */ -export async function handleDeleteCharacter(popup_type, this_chid, delete_chats) { - if (popup_type !== 'del_ch' || - !characters[this_chid]) { +export async function handleDeleteCharacter(this_chid, delete_chats) { + if (!characters[this_chid]) { return; } @@ -8701,6 +8800,8 @@ export async function deleteCharacter(characterKey, { deleteChats = true } = {}) await eventSource.emit(event_types.CHAT_DELETED, name); } } + + eventSource.emit(event_types.CHARACTER_DELETED, { id: this_chid, character: characters[this_chid] }); } /** @@ -9095,14 +9196,26 @@ jQuery(async function () { chooseBogusFolder($(this), tagId); }); - $(document).on('input', '.edit_textarea', function () { - scroll_holder = $('#chat').scrollTop(); - $(this).height(0).height(this.scrollHeight); + /** + * Sets the scroll height of the edit textarea to fit the content. + * @param {HTMLTextAreaElement} e Textarea element to auto-fit + */ + function autoFitEditTextArea(e) { + scroll_holder = chatElement[0].scrollTop; + e.style.height = '0'; + e.style.height = `${e.scrollHeight + 4}px`; is_use_scroll_holder = true; + } + const autoFitEditTextAreaDebounced = debounce(autoFitEditTextArea, debounce_timeout.short); + document.addEventListener('input', e => { + if (e.target instanceof HTMLTextAreaElement && e.target.classList.contains('edit_textarea')) { + const immediately = e.target.scrollHeight > e.target.offsetHeight || e.target.value === ''; + immediately ? autoFitEditTextArea(e.target) : autoFitEditTextAreaDebounced(e.target); + } }); - $('#chat').on('scroll', function () { + document.getElementById('chat').addEventListener('scroll', function () { if (is_use_scroll_holder) { - $('#chat').scrollTop(scroll_holder); + this.scrollTop = scroll_holder; is_use_scroll_holder = false; } }); @@ -9202,46 +9315,9 @@ jQuery(async function () { }, 2000); } } - if (popup_type == 'del_ch') { - const deleteChats = !!$('#del_char_checkbox').prop('checked'); - eventSource.emit(event_types.CHARACTER_DELETED, { id: this_chid, character: characters[this_chid] }); - await handleDeleteCharacter(popup_type, this_chid, deleteChats); - } if (popup_type == 'alternate_greeting' && menu_type !== 'create') { createOrEditCharacter(); } - //Make a new chat for selected character - if ( - popup_type == 'new_chat' && - (selected_group || this_chid !== undefined) && - menu_type != 'create' - ) { - //Fix it; New chat doesn't create while open create character menu - await clearChat(); - chat.length = 0; - - chat_file_for_del = getCurrentChatDetails()?.sessionName; - const isDelChatCheckbox = document.getElementById('del_chat_checkbox')?.checked; - - // Make it easier to find in backups - if (isDelChatCheckbox) { - await saveChatConditional(); - } - - if (selected_group) { - await createNewGroupChat(selected_group); - if (isDelChatCheckbox) await deleteGroupChat(selected_group, chat_file_for_del); - } - else { - //RossAscends: added character name to new chat filenames and replaced Date.now() with humanizedDateTime; - chat_metadata = {}; - characters[this_chid].chat = `${name2} - ${humanizedDateTime()}`; - $('#selected_chat_pole').val(characters[this_chid].chat); - await getChat(); - await createOrEditCharacter(new CustomEvent('newChat')); - if (isDelChatCheckbox) await delChat(chat_file_for_del + '.jsonl'); - } - } if (dialogueResolve) { if (popup_type == 'input') { @@ -9287,15 +9363,27 @@ jQuery(async function () { $('#form_create').submit(createOrEditCharacter); - $('#delete_button').on('click', function () { - callPopup(` - <h3>Delete the character?</h3> - <b>THIS IS PERMANENT!<br><br> + $('#delete_button').on('click', async function () { + if (!this_chid) { + toastr.warning('No character selected.'); + return; + } + + let deleteChats = false; + + const confirm = await Popup.show.confirm('Delete the character?', ` + <b>THIS IS PERMANENT!<br><br> <label for="del_char_checkbox" class="checkbox_label justifyCenter"> <input type="checkbox" id="del_char_checkbox" /> <small>Also delete the chat files</small> - </label><br></b>`, 'del_ch', '', - ); + </label></b>`, { + onClose: () => deleteChats = !!$('#del_char_checkbox').prop('checked'), + }); + if (!confirm) { + return; + } + + await deleteCharacter(characters[this_chid].avatar, { deleteChats: deleteChats }); }); //////// OPTIMIZED ALL CHAR CREATION/EDITING TEXTAREA LISTENERS /////////////// @@ -9446,6 +9534,8 @@ jQuery(async function () { { id: 'api_key_openrouter-tg', secret: SECRET_KEYS.OPENROUTER }, { id: 'api_key_koboldcpp', secret: SECRET_KEYS.KOBOLDCPP }, { id: 'api_key_llamacpp', secret: SECRET_KEYS.LLAMACPP }, + { id: 'api_key_featherless', secret: SECRET_KEYS.FEATHERLESS }, + { id: 'api_key_huggingface', secret: SECRET_KEYS.HUGGINGFACE }, ]; for (const key of keys) { @@ -9557,14 +9647,20 @@ jQuery(async function () { else if (id == 'option_start_new_chat') { if ((selected_group || this_chid !== undefined) && !is_send_press) { - callPopup(` - <h3>Start new chat?</h3><br> + let deleteCurrentChat = false; + const result = await Popup.show.confirm('Start new chat?', ` <label for="del_chat_checkbox" class="checkbox_label justifyCenter" title="If necessary, you can later restore this chat file from the /backups folder"> <input type="checkbox" id="del_chat_checkbox" /> <small>Also delete the current chat file</small> - </label><br> - `, 'new_chat', ''); + </label>`, { + onClose: () => deleteCurrentChat = !!$('#del_chat_checkbox').prop('checked'), + }); + if (!result) { + return; + } + + await doNewChat({ deleteCurrentChat: deleteCurrentChat }); } } @@ -9651,14 +9747,8 @@ jQuery(async function () { }); $('#newChatFromManageScreenButton').on('click', function () { - setTimeout(() => { - $('#option_start_new_chat').trigger('click'); - }, 1); - setTimeout(() => { - $('#dialogue_popup_ok').trigger('click'); - }, 1); + doNewChat({ deleteCurrentChat: false }); $('#select_chat_cross').trigger('click'); - }); ////////////////////////////////////////////////////////////////////////////////////////////// @@ -10278,15 +10368,7 @@ jQuery(async function () { }); $(document).on('click', '.mes_stop', function () { - if (streamingProcessor) { - streamingProcessor.onStopStreaming(); - streamingProcessor = null; - } - if (abortController) { - abortController.abort('Clicked stop button'); - hideStopButton(); - } - eventSource.emit(event_types.GENERATION_STOPPED); + stopGeneration(); }); $(document).on('click', '#form_sheld .stscript_continue', function () { @@ -10574,10 +10656,12 @@ jQuery(async function () { } try { - const cloneFile = new File([file], characters[this_chid].avatar, { type: file.type }); const chatFile = characters[this_chid]['chat']; - await processDroppedFiles([cloneFile], true); + const data = new Map(); + data.set(file, characters[this_chid].avatar); + await processDroppedFiles([file], data); await openCharacterChat(chatFile); + await fetch(getThumbnailUrl('avatar', characters[this_chid].avatar), { cache: 'no-cache' }); } catch { toastr.error('Failed to replace the character card.', 'Something went wrong'); } @@ -10773,3 +10857,4 @@ jQuery(async function () { initCustomSelectedSamplers(); }); + diff --git a/public/scripts/RossAscends-mods.js b/public/scripts/RossAscends-mods.js index b2d97ff97..dceba9063 100644 --- a/public/scripts/RossAscends-mods.js +++ b/public/scripts/RossAscends-mods.js @@ -360,6 +360,7 @@ function RA_autoconnect(PrevApi) { || (textgen_settings.type === textgen_types.INFERMATICAI && secret_state[SECRET_KEYS.INFERMATICAI]) || (textgen_settings.type === textgen_types.DREAMGEN && secret_state[SECRET_KEYS.DREAMGEN]) || (textgen_settings.type === textgen_types.OPENROUTER && secret_state[SECRET_KEYS.OPENROUTER]) + || (textgen_settings.type === textgen_types.FEATHERLESS && secret_state[SECRET_KEYS.FEATHERLESS]) ) { $('#api_button_textgenerationwebui').trigger('click'); } @@ -379,6 +380,7 @@ function RA_autoconnect(PrevApi) { || (secret_state[SECRET_KEYS.COHERE] && oai_settings.chat_completion_source == chat_completion_sources.COHERE) || (secret_state[SECRET_KEYS.PERPLEXITY] && oai_settings.chat_completion_source == chat_completion_sources.PERPLEXITY) || (secret_state[SECRET_KEYS.GROQ] && oai_settings.chat_completion_source == chat_completion_sources.GROQ) + || (secret_state[SECRET_KEYS.ZEROONEAI] && oai_settings.chat_completion_source == chat_completion_sources.ZEROONEAI) || (isValidUrl(oai_settings.custom_url) && oai_settings.chat_completion_source == chat_completion_sources.CUSTOM) ) { $('#api_button_openai').trigger('click'); @@ -476,8 +478,8 @@ export function dragElement(elmnt) { } const style = getComputedStyle(target); - height = parseInt(style.height) - width = parseInt(style.width) + height = parseInt(style.height); + width = parseInt(style.width); top = parseInt(style.top); left = parseInt(style.left); right = parseInt(style.right); @@ -723,6 +725,16 @@ export function initRossMods() { RA_autoconnect(); } + const userAgent = getParsedUA(); + console.debug('User Agent', userAgent); + const isMobileSafari = /iPad|iPhone|iPod/.test(navigator.platform) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); + const isDesktopSafari = userAgent?.browser?.name === 'Safari' && userAgent?.platform?.type === 'desktop'; + const isIOS = userAgent?.os?.name === 'iOS'; + + if (isIOS || isMobileSafari || isDesktopSafari) { + document.body.classList.add('safari'); + } + $('#main_api').change(function () { var PrevAPI = main_api; setTimeout(() => RA_autoconnect(PrevAPI), 100); @@ -926,8 +938,8 @@ export function initRossMods() { return false; } - $(document).on('keydown', function (event) { - processHotkeys(event.originalEvent); + $(document).on('keydown', async function (event) { + await processHotkeys(event.originalEvent); }); const hotkeyTargets = { @@ -939,7 +951,7 @@ export function initRossMods() { /** * @param {KeyboardEvent} event */ - function processHotkeys(event) { + async function processHotkeys(event) { //Enter to send when send_textarea in focus if (document.activeElement == hotkeyTargets['send_textarea']) { const sendOnEnter = shouldSendOnEnter(); @@ -1003,21 +1015,17 @@ export function initRossMods() { if (skipConfirm) { doRegenerate(); } else { - Popup.show.confirm('Regenerate Message', ` - <span>Are you sure you want to regenerate the latest message?</span> - <label class="checkbox_label justifyCenter marginTop10" for="regenerateWithCtrlEnter"> - <input type="checkbox" id="regenerateWithCtrlEnter"> - Don't ask again - </label>`, { - onClose: (popup) => { - if (!popup.result) { - return; - } - const regenerateWithCtrlEnter = $('#regenerateWithCtrlEnter').prop('checked'); - SaveLocal(skipConfirmKey, regenerateWithCtrlEnter); - doRegenerate(); - }, - }) + let regenerateWithCtrlEnter = false; + const result = await Popup.show.confirm('Regenerate Message', 'Are you sure you want to regenerate the latest message?', { + customInputs: [{ id: 'regenerateWithCtrlEnter', label: 'Don\'t ask again' }], + onClose: (popup) => regenerateWithCtrlEnter = popup.inputResults.get('regenerateWithCtrlEnter') ?? false, + }); + if (!result) { + return; + } + + SaveLocal(skipConfirmKey, regenerateWithCtrlEnter); + doRegenerate(); } return; } else { diff --git a/public/scripts/bookmarks.js b/public/scripts/bookmarks.js index f099ba047..14ba977f8 100644 --- a/public/scripts/bookmarks.js +++ b/public/scripts/bookmarks.js @@ -24,6 +24,7 @@ import { saveGroupBookmarkChat, selected_group, } from './group-chats.js'; +import { Popup } from './popup.js'; import { createTagMapFromList } from './tags.js'; import { @@ -239,8 +240,7 @@ async function convertSoloToGroupChat() { return; } - const confirm = await callPopup('Are you sure you want to convert this chat to a group chat?', 'confirm'); - + const confirm = await Popup.show.confirm('Convert to group chat', 'Are you sure you want to convert this chat to a group chat?<br />This cannot be reverted.'); if (!confirm) { return; } @@ -336,6 +336,7 @@ async function convertSoloToGroupChat() { if (!createChatResponse.ok) { console.error('Group chat creation unsuccessful'); + toastr.error('Group chat creation unsuccessful'); return; } diff --git a/public/scripts/chats.js b/public/scripts/chats.js index 2402dd2db..6d1e8d694 100644 --- a/public/scripts/chats.js +++ b/public/scripts/chats.js @@ -185,18 +185,19 @@ export async function populateFileAttachment(message, inputId = 'file_form_input const file = fileInput.files[0]; if (!file) return; + const slug = getStringHash(file.name); + const fileNamePrefix = `${Date.now()}_${slug}`; const fileBase64 = await getBase64Async(file); let base64Data = fileBase64.split(',')[1]; // If file is image if (file.type.startsWith('image/')) { const extension = file.type.split('/')[1]; - const imageUrl = await saveBase64AsFile(base64Data, name2, file.name, extension); + const imageUrl = await saveBase64AsFile(base64Data, name2, fileNamePrefix, extension); message.extra.image = imageUrl; message.extra.inline_image = true; } else { - const slug = getStringHash(file.name); - const uniqueFileName = `${Date.now()}_${slug}.txt`; + const uniqueFileName = `${fileNamePrefix}.txt`; if (isConvertible(file.type)) { try { @@ -319,12 +320,10 @@ export function hasPendingFileAttachment() { /** * Displays file information in the message sending form. + * @param {File} file File object * @returns {Promise<void>} */ -async function onFileAttach() { - const fileInput = document.getElementById('file_form_input'); - if (!(fileInput instanceof HTMLInputElement)) return; - const file = fileInput.files[0]; +async function onFileAttach(file) { if (!file) return; const isValid = await validateFile(file); @@ -419,6 +418,7 @@ function embedMessageFile(messageId, messageBlock) { } await populateFileAttachment(message, 'embed_file_input'); + await eventSource.emit(event_types.MESSAGE_FILE_EMBEDDED, messageId); appendMediaToMessage(message, messageBlock); await saveChatConditional(); } @@ -616,6 +616,8 @@ async function deleteMessageImage() { const message = chat[mesId]; delete message.extra.image; delete message.extra.inline_image; + delete message.extra.title; + delete message.extra.append_title; mesBlock.find('.mes_img_container').removeClass('img_extra'); mesBlock.find('.mes_img').attr('src', ''); await saveChatConditional(); @@ -1119,7 +1121,7 @@ async function openAttachmentManager() { const cleanupFn = await renderButtons(); await verifyAttachments(); await renderAttachments(); - await callGenericPopup(template, POPUP_TYPE.TEXT, '', { wide: true, large: true, okButton: 'Close' }); + await callGenericPopup(template, POPUP_TYPE.TEXT, '', { wide: true, large: true, okButton: 'Close', allowVerticalScrolling: true }); cleanupFn(); dragDropHandler.destroy(); @@ -1428,7 +1430,7 @@ jQuery(function () { wrapper.classList.add('flexFlowColumn', 'justifyCenter', 'alignitemscenter'); const textarea = document.createElement('textarea'); textarea.value = String(bro.val()); - textarea.classList.add('height100p', 'wide100p'); + textarea.classList.add('height100p', 'wide100p', 'maximized_textarea'); bro.hasClass('monospace') && textarea.classList.add('monospace'); textarea.addEventListener('input', function () { bro.val(textarea.value).trigger('input'); @@ -1503,8 +1505,34 @@ jQuery(function () { $(document).on('click', '.mes_img_enlarge', enlargeMessageImage); $(document).on('click', '.mes_img_delete', deleteMessageImage); - $('#file_form_input').on('change', onFileAttach); + $('#file_form_input').on('change', async () => { + const fileInput = document.getElementById('file_form_input'); + if (!(fileInput instanceof HTMLInputElement)) return; + const file = fileInput.files[0]; + await onFileAttach(file); + }); $('#file_form').on('reset', function () { $('#file_form').addClass('displayNone'); }); + + document.getElementById('send_textarea').addEventListener('paste', async function (event) { + if (event.clipboardData.files.length === 0) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const fileInput = document.getElementById('file_form_input'); + if (!(fileInput instanceof HTMLInputElement)) return; + + // Workaround for Firefox: Use a DataTransfer object to indirectly set fileInput.files + const dataTransfer = new DataTransfer(); + for (let i = 0; i < event.clipboardData.files.length; i++) { + dataTransfer.items.add(event.clipboardData.files[i]); + } + + fileInput.files = dataTransfer.files; + await onFileAttach(fileInput.files[0]); + }); }); diff --git a/public/scripts/constants.js b/public/scripts/constants.js index c70b3af63..f95a8e146 100644 --- a/public/scripts/constants.js +++ b/public/scripts/constants.js @@ -5,6 +5,8 @@ export const debounce_timeout = { /** [100 ms] For ultra-fast responses, typically for keypresses or executions that might happen multiple times in a loop or recursion. */ quick: 100, + /** [200 ms] Slightly slower than quick, but still very responsive. */ + short: 200, /** [300 ms] Default time for general use, good balance between responsiveness and performance. */ standard: 300, /** [1.000 ms] For situations where the function triggers more intensive tasks. */ diff --git a/public/scripts/dynamic-styles.js b/public/scripts/dynamic-styles.js index b70c33e6b..dd5462501 100644 --- a/public/scripts/dynamic-styles.js +++ b/public/scripts/dynamic-styles.js @@ -154,7 +154,7 @@ export function initDynamicStyles() { // Process all stylesheets on initial load Array.from(document.styleSheets).forEach(sheet => { try { - applyDynamicFocusStyles(sheet, { fromExtension: sheet.href.toLowerCase().includes('scripts/extensions') }); + applyDynamicFocusStyles(sheet, { fromExtension: sheet.href?.toLowerCase().includes('scripts/extensions') == true }); } catch (e) { console.warn('Failed to process stylesheet on initial load:', e); } diff --git a/public/scripts/extensions.js b/public/scripts/extensions.js index 3ecf7c893..57e512ea8 100644 --- a/public/scripts/extensions.js +++ b/public/scripts/extensions.js @@ -374,7 +374,7 @@ async function addExtensionsButtonAndMenu() { $('html').on('click', function (e) { const clickTarget = $(e.target); - const noCloseTargets = ['#sd_gen', '#extensionsMenuButton']; + const noCloseTargets = ['#sd_gen', '#extensionsMenuButton', '#roll_dice']; if (dropdown.is(':visible') && !noCloseTargets.some(id => clickTarget.closest(id).length > 0)) { $(dropdown).fadeOut(animation_duration); } @@ -641,9 +641,16 @@ async function showExtensionsDetails() { action: async () => { requiresReload = true; await autoUpdateExtensions(true); - popup.complete(POPUP_RESULT.AFFIRMATIVE); + await popup.complete(POPUP_RESULT.AFFIRMATIVE); }, }; + + // If we are updating an extension, the "old" popup is still active. We should close that. + const oldPopup = Popup.util.popups.find(popup => popup.content.querySelector('.extensions_info')); + if (oldPopup) { + await oldPopup.complete(POPUP_RESULT.CANCELLED); + } + const popup = new Popup(`<div class="extensions_info">${html}</div>`, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: true, large: true, customButtons: [updateAllButton], allowVerticalScrolling: true }); popupPromise = popup.show(); } catch (error) { diff --git a/public/scripts/extensions/caption/index.js b/public/scripts/extensions/caption/index.js index e06449f88..b47f73a2f 100644 --- a/public/scripts/extensions/caption/index.js +++ b/public/scripts/extensions/caption/index.js @@ -1,6 +1,6 @@ import { ensureImageFormatSupported, getBase64Async, isTrueBoolean, saveBase64AsFile } from '../../utils.js'; import { getContext, getApiUrl, doExtrasFetch, extension_settings, modules, renderExtensionTemplateAsync } from '../../extensions.js'; -import { callPopup, getRequestHeaders, saveSettingsDebounced, substituteParamsExtended } from '../../../script.js'; +import { appendMediaToMessage, callPopup, eventSource, event_types, getRequestHeaders, saveChatConditional, saveSettingsDebounced, substituteParamsExtended } from '../../../script.js'; import { getMessageTimeStamp } from '../../RossAscends-mods.js'; import { SECRET_KEYS, secret_state } from '../../secrets.js'; import { getMultimodalCaption } from '../shared.js'; @@ -84,12 +84,11 @@ async function setSpinnerIcon() { } /** - * Sends a captioned message to the chat. - * @param {string} caption Caption text - * @param {string} image Image URL + * Wraps a caption with a message template. + * @param {string} caption Raw caption + * @returns {Promise<string>} Wrapped caption */ -async function sendCaptionedMessage(caption, image) { - const context = getContext(); +async function wrapCaptionTemplate(caption) { let template = extension_settings.caption.template || TEMPLATE_DEFAULT; if (!/{{caption}}/i.test(template)) { @@ -101,7 +100,7 @@ async function sendCaptionedMessage(caption, image) { if (extension_settings.caption.refine_mode) { messageText = await callPopup( - '<h3>Review and edit the generated message:</h3>Press "Cancel" to abort the caption sending.', + '<h3>Review and edit the generated caption:</h3>Press "Cancel" to abort the caption sending.', 'input', messageText, { rows: 5, okButton: 'Send' }); @@ -111,6 +110,55 @@ async function sendCaptionedMessage(caption, image) { } } + return messageText; +} + +/** + * Appends caption to an existing message. + * @param {Object} data Message data + * @returns {Promise<void>} + */ +async function captionExistingMessage(data) { + if (!(data?.extra?.image)) { + return; + } + + const imageData = await fetch(data.extra.image); + const blob = await imageData.blob(); + const type = imageData.headers.get('Content-Type'); + const file = new File([blob], 'image.png', { type }); + const caption = await getCaptionForFile(file, null, true); + + if (!caption) { + console.warn('Failed to generate a caption for the image.'); + return; + } + + const wrappedCaption = await wrapCaptionTemplate(caption); + + const messageText = String(data.mes).trim(); + + if (!messageText) { + data.extra.inline_image = false; + data.mes = wrappedCaption; + data.extra.title = wrappedCaption; + } + else { + data.extra.inline_image = true; + data.extra.append_title = true; + data.extra.title = wrappedCaption; + } +} + +/** + * Sends a captioned message to the chat. + * @param {string} caption Caption text + * @param {string} image Image URL + */ +async function sendCaptionedMessage(caption, image) { + const messageText = await wrapCaptionTemplate(caption); + + const context = getContext(); const message = { name: context.name1, is_user: true, @@ -356,6 +404,7 @@ jQuery(async function () { (extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'llamacpp' && textgenerationwebui_settings.server_urls[textgen_types.LLAMACPP]) || (extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'ooba' && textgenerationwebui_settings.server_urls[textgen_types.OOBA]) || (extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'koboldcpp' && textgenerationwebui_settings.server_urls[textgen_types.KOBOLDCPP]) || + (extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'vllm' && textgenerationwebui_settings.server_urls[textgen_types.VLLM]) || (extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'custom') || extension_settings.caption.source === 'local' || extension_settings.caption.source === 'horde'; @@ -408,7 +457,7 @@ jQuery(async function () { }); } async function addSettings() { - const html = await renderExtensionTemplateAsync('caption', 'settings'); + const html = await renderExtensionTemplateAsync('caption', 'settings', { TEMPLATE_DEFAULT, PROMPT_DEFAULT }); $('#caption_container').append(html); } @@ -422,6 +471,7 @@ jQuery(async function () { $('#caption_refine_mode').prop('checked', !!(extension_settings.caption.refine_mode)); $('#caption_allow_reverse_proxy').prop('checked', !!(extension_settings.caption.allow_reverse_proxy)); $('#caption_prompt_ask').prop('checked', !!(extension_settings.caption.prompt_ask)); + $('#caption_auto_mode').prop('checked', !!(extension_settings.caption.auto_mode)); $('#caption_source').val(extension_settings.caption.source); $('#caption_prompt').val(extension_settings.caption.prompt); $('#caption_template').val(extension_settings.caption.template); @@ -447,6 +497,41 @@ jQuery(async function () { extension_settings.caption.prompt_ask = $('#caption_prompt_ask').prop('checked'); saveSettingsDebounced(); }); + $('#caption_auto_mode').on('input', () => { + extension_settings.caption.auto_mode = !!$('#caption_auto_mode').prop('checked'); + saveSettingsDebounced(); + }); + + const onMessageEvent = async (index) => { + if (!extension_settings.caption.auto_mode) { + return; + } + + const data = getContext().chat[index]; + await captionExistingMessage(data); + }; + + eventSource.on(event_types.MESSAGE_SENT, onMessageEvent); + eventSource.on(event_types.MESSAGE_FILE_EMBEDDED, onMessageEvent); + + $(document).on('click', '.mes_img_caption', async function () { + const animationClass = 'fa-fade'; + const messageBlock = $(this).closest('.mes'); + const messageImg = messageBlock.find('.mes_img'); + if (messageImg.hasClass(animationClass)) return; + messageImg.addClass(animationClass); + try { + const index = Number(messageBlock.attr('mesid')); + const data = getContext().chat[index]; + await captionExistingMessage(data); + appendMediaToMessage(data, messageBlock, false); + await saveChatConditional(); + } catch(e) { + console.error('Message image recaption failed', e); + } finally { + messageImg.removeClass(animationClass); + } + }); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'caption', callback: captionCommandCallback, @@ -482,4 +567,6 @@ jQuery(async function () { </div> `, })); + + document.body.classList.add('caption'); }); diff --git a/public/scripts/extensions/caption/settings.html b/public/scripts/extensions/caption/settings.html index 3e23cfbd5..ccbdd67c0 100644 --- a/public/scripts/extensions/caption/settings.html +++ b/public/scripts/extensions/caption/settings.html @@ -26,6 +26,7 @@ <option value="openai">OpenAI</option> <option value="openrouter">OpenRouter</option> <option value="ooba" data-i18n="Text Generation WebUI (oobabooga)">Text Generation WebUI (oobabooga)</option> + <option value="vllm">vLLM</option> </select> </div> <div class="flex1 flex-container flexFlowColumn flexNoGap"> @@ -66,6 +67,7 @@ <option data-type="llamacpp" value="llamacpp_current" data-i18n="currently_loaded">[Currently loaded]</option> <option data-type="ooba" value="ooba_current" data-i18n="currently_loaded">[Currently loaded]</option> <option data-type="koboldcpp" value="koboldcpp_current" data-i18n="currently_loaded">[Currently loaded]</option> + <option data-type="vllm" value="vllm_current" data-i18n="currently_selected">[Currently selected]</option> <option data-type="custom" value="custom_current" data-i18n="currently_selected">[Currently selected]</option> </select> </div> @@ -82,14 +84,19 @@ </div> <div id="caption_prompt_block"> <label for="caption_prompt" data-i18n="Caption Prompt">Caption Prompt</label> - <textarea id="caption_prompt" class="text_pole" rows="1" placeholder="< Use default >">${PROMPT_DEFAULT}</textarea> + <textarea id="caption_prompt" class="text_pole" rows="1" placeholder="< Use default >">{{PROMPT_DEFAULT}}</textarea> <label class="checkbox_label margin-bot-10px" for="caption_prompt_ask" title="Ask for a custom prompt every time an image is captioned."> <input id="caption_prompt_ask" type="checkbox" class="checkbox"> <span data-i18n="Ask every time">Ask every time</span> </label> </div> <label for="caption_template"><span data-i18n="Message Template">Message Template</span> <small><span data-i18n="(use _space">(use </span> <code>{{caption}}</code> <span data-i18n="macro)">macro)</span></small></label> - <textarea id="caption_template" class="text_pole" rows="2" placeholder="< Use default >">${TEMPLATE_DEFAULT}</textarea> + <textarea id="caption_template" class="text_pole" rows="2" placeholder="< Use default >">{{TEMPLATE_DEFAULT}}</textarea> + <label class="checkbox_label" for="caption_auto_mode"> + <input id="caption_auto_mode" type="checkbox" class="checkbox"> + <span data-i18n="Automatically caption images">Automatically caption images</span> + <i class="fa-solid fa-info-circle" title="Automatically caption images when they are pasted into the chat or attached to messages."></i> + </label> <label class="checkbox_label margin-bot-10px" for="caption_refine_mode"> <input id="caption_refine_mode" type="checkbox" class="checkbox"> <span data-i18n="Edit captions before saving">Edit captions before saving</span> diff --git a/public/scripts/extensions/memory/index.js b/public/scripts/extensions/memory/index.js index 34b3caeb2..efd6f5fdd 100644 --- a/public/scripts/extensions/memory/index.js +++ b/public/scripts/extensions/memory/index.js @@ -447,7 +447,7 @@ async function summarizeCallback(args, text) { } const source = args.source || extension_settings.memory.source; - const prompt = substituteParamsExtended((resolveVariable(args.prompt) || extension_settings.memory.prompt), { words: extension_settings.memory.promptWords }); + const prompt = substituteParamsExtended((args.prompt || extension_settings.memory.prompt), { words: extension_settings.memory.promptWords }); try { switch (source) { @@ -923,10 +923,8 @@ jQuery(async function () { SlashCommandNamedArgument.fromProps({ name: 'prompt', description: 'prompt to use for summarization', - typeList: [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.VARIABLE_NAME], + typeList: [ARGUMENT_TYPE.STRING], defaultValue: '', - enumProvider: commonEnumProviders.variables('all'), - forceEnum: false, }), ], unnamedArgumentList: [ diff --git a/public/scripts/extensions/quick-reply/src/QuickReplySet.js b/public/scripts/extensions/quick-reply/src/QuickReplySet.js index 106084081..7004c75c6 100644 --- a/public/scripts/extensions/quick-reply/src/QuickReplySet.js +++ b/public/scripts/extensions/quick-reply/src/QuickReplySet.js @@ -231,6 +231,7 @@ export class QuickReplySet { this.rerender(); } else { warn(`Failed to save Quick Reply Set: ${this.name}`); + console.error('QR could not be saved', response); } } diff --git a/public/scripts/extensions/regex/index.js b/public/scripts/extensions/regex/index.js index 72ce4a7fe..d038ffef7 100644 --- a/public/scripts/extensions/regex/index.js +++ b/public/scripts/extensions/regex/index.js @@ -349,7 +349,7 @@ function migrateSettings() { /** * /regex slash command callback - * @param {object} args Named arguments + * @param {{name: string}} args Named arguments * @param {string} value Unnamed argument * @returns {string} The regexed string */ @@ -359,11 +359,11 @@ function runRegexCallback(args, value) { return value; } - const scriptName = String(resolveVariable(args.name)); + const scriptName = args.name; const scripts = getRegexScripts(); for (const script of scripts) { - if (String(script.scriptName).toLowerCase() === String(scriptName).toLowerCase()) { + if (script.scriptName.toLowerCase() === scriptName.toLowerCase()) { if (script.disabled) { toastr.warning(`Regex script "${scriptName}" is disabled.`); return value; @@ -588,7 +588,7 @@ jQuery(async () => { SlashCommandNamedArgument.fromProps({ name: 'name', description: 'script name', - typeList: [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.VARIABLE_NAME], + typeList: [ARGUMENT_TYPE.STRING], isRequired: true, enumProvider: localEnumProviders.regexScripts, }), diff --git a/public/scripts/extensions/shared.js b/public/scripts/extensions/shared.js index 4a4390318..3e671c3af 100644 --- a/public/scripts/extensions/shared.js +++ b/public/scripts/extensions/shared.js @@ -34,6 +34,7 @@ export async function getMultimodalCaption(base64Img, prompt) { const isCustom = extension_settings.caption.multimodal_api === 'custom'; const isOoba = extension_settings.caption.multimodal_api === 'ooba'; const isKoboldCpp = extension_settings.caption.multimodal_api === 'koboldcpp'; + const isVllm = extension_settings.caption.multimodal_api === 'vllm'; const base64Bytes = base64Img.length * 0.75; const compressionLimit = 2 * 1024 * 1024; if ((['google', 'openrouter'].includes(extension_settings.caption.multimodal_api) && base64Bytes > compressionLimit) || isOoba || isKoboldCpp) { @@ -65,6 +66,14 @@ export async function getMultimodalCaption(base64Img, prompt) { requestBody.server_url = textgenerationwebui_settings.server_urls[textgen_types.OLLAMA]; } + if (isVllm) { + if (extension_settings.caption.multimodal_model === 'vllm_current') { + requestBody.model = textgenerationwebui_settings.vllm_model; + } + + requestBody.server_url = textgenerationwebui_settings.server_urls[textgen_types.VLLM]; + } + if (isLlamaCpp) { requestBody.server_url = textgenerationwebui_settings.server_urls[textgen_types.LLAMACPP]; } @@ -151,6 +160,14 @@ function throwIfInvalidModel(useReverseProxy) { throw new Error('KoboldCpp server URL is not set.'); } + if (extension_settings.caption.multimodal_api === 'vllm' && !textgenerationwebui_settings.server_urls[textgen_types.VLLM]) { + throw new Error('vLLM server URL is not set.'); + } + + if (extension_settings.caption.multimodal_api === 'vllm' && extension_settings.caption.multimodal_model === 'vllm_current' && !textgenerationwebui_settings.vllm_model) { + throw new Error('vLLM model is not set.'); + } + if (extension_settings.caption.multimodal_api === 'custom' && !oai_settings.custom_url) { throw new Error('Custom API URL is not set.'); } diff --git a/public/scripts/extensions/stable-diffusion/index.js b/public/scripts/extensions/stable-diffusion/index.js index 0ec751bec..902b3ad1c 100644 --- a/public/scripts/extensions/stable-diffusion/index.js +++ b/public/scripts/extensions/stable-diffusion/index.js @@ -3,7 +3,6 @@ import { systemUserName, hideSwipeButtons, showSwipeButtons, - callPopup, getRequestHeaders, event_types, eventSource, @@ -23,16 +22,15 @@ import { getApiUrl, getContext, extension_settings, doExtrasFetch, modules, rend import { selected_group } from '../../group-chats.js'; import { stringFormat, initScrollHeight, resetScrollHeight, getCharaFilename, saveBase64AsFile, getBase64Async, delay, isTrueBoolean, debounce } from '../../utils.js'; import { getMessageTimeStamp, humanizedDateTime } from '../../RossAscends-mods.js'; -import { SECRET_KEYS, secret_state } from '../../secrets.js'; +import { SECRET_KEYS, secret_state, writeSecret } from '../../secrets.js'; import { getNovelUnlimitedImageGeneration, getNovelAnlas, loadNovelSubscriptionData } from '../../nai-settings.js'; import { getMultimodalCaption } from '../shared.js'; import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; import { SlashCommand } from '../../slash-commands/SlashCommand.js'; import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js'; -import { resolveVariable } from '../../variables.js'; import { debounce_timeout } from '../../constants.js'; -import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js'; +import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js'; export { MODULE_NAME }; const MODULE_NAME = 'sd'; @@ -51,6 +49,7 @@ const sources = { togetherai: 'togetherai', drawthings: 'drawthings', pollinations: 'pollinations', + stability: 'stability', }; const initiators = { @@ -173,6 +172,8 @@ const defaultStyles = [ }, ]; +const placeholderVae = 'Automatic'; + const defaultSettings = { source: sources.extras, @@ -284,6 +285,9 @@ const defaultSettings = { wand_visible: false, command_visible: false, interactive_visible: false, + + // Stability AI settings + stability_style_preset: 'anime', }; const writePromptFieldsDebounced = debounce(writePromptFields, debounce_timeout.relaxed); @@ -446,6 +450,7 @@ async function loadSettings() { $('#sd_wand_visible').prop('checked', extension_settings.sd.wand_visible); $('#sd_command_visible').prop('checked', extension_settings.sd.command_visible); $('#sd_interactive_visible').prop('checked', extension_settings.sd.interactive_visible); + $('#sd_stability_style_preset').val(extension_settings.sd.stability_style_preset); for (const style of extension_settings.sd.styles) { const option = document.createElement('option'); @@ -572,7 +577,7 @@ async function onDeleteStyleClick() { return; } - const confirmed = await callPopup(`Are you sure you want to delete the style "${selectedStyle}"?`, 'confirm', '', { okButton: 'Delete' }); + const confirmed = await callGenericPopup(`Are you sure you want to delete the style "${selectedStyle}"?`, POPUP_TYPE.CONFIRM, '', { okButton: 'Delete', cancelButton: 'Cancel' }); if (!confirmed) { return; @@ -601,7 +606,7 @@ async function onDeleteStyleClick() { } async function onSaveStyleClick() { - const userInput = await callPopup('Enter style name:', 'input', '', { okButton: 'Save' }); + const userInput = await callGenericPopup('Enter style name:', POPUP_TYPE.INPUT); if (!userInput) { return; @@ -670,10 +675,10 @@ async function refinePrompt(prompt, allowExpand, isNegative = false) { if (extension_settings.sd.refine_mode) { const text = isNegative ? '<h3>Review and edit the <i>negative</i> prompt:</h3>' : '<h3>Review and edit the prompt:</h3>'; - const refinedPrompt = await callPopup(text + 'Press "Cancel" to abort the image generation.', 'input', prompt.trim(), { rows: 5, okButton: 'Continue' }); + const refinedPrompt = await callGenericPopup(text + 'Press "Cancel" to abort the image generation.', POPUP_TYPE.INPUT, prompt.trim(), { rows: 5, okButton: 'Continue' }); if (refinedPrompt) { - return refinedPrompt; + return String(refinedPrompt); } else { throw new Error('Generation aborted by user.'); } @@ -928,6 +933,7 @@ async function onSourceChange() { extension_settings.sd.model = null; extension_settings.sd.sampler = null; extension_settings.sd.scheduler = null; + extension_settings.sd.vae = null; toggleSourceControls(); saveSettingsDebounced(); await loadSettingOptions(); @@ -1086,6 +1092,26 @@ function onComfyWorkflowChange() { extension_settings.sd.comfy_workflow = $('#sd_comfy_workflow').find(':selected').val(); saveSettingsDebounced(); } + +async function onStabilityKeyClick() { + const popupText = 'Stability AI API Key:'; + const key = await callGenericPopup(popupText, POPUP_TYPE.INPUT); + + if (!key) { + return; + } + + await writeSecret(SECRET_KEYS.STABILITY, String(key)); + + toastr.success('API Key saved'); + await loadSettingOptions(); +} + +function onStabilityStylePresetChange() { + extension_settings.sd.stability_style_preset = String($('#sd_stability_style_preset').val()); + saveSettingsDebounced(); +} + async function changeComfyWorkflow(_, name) { name = name.replace(/(\.json)?$/i, '.json'); if ($(`#sd_comfy_workflow > [value="${name}"]`).length > 0) { @@ -1195,7 +1221,7 @@ async function onModelChange() { extension_settings.sd.model = $('#sd_model').find(':selected').val(); saveSettingsDebounced(); - const cloudSources = [sources.horde, sources.novel, sources.openai, sources.togetherai, sources.pollinations]; + const cloudSources = [sources.horde, sources.novel, sources.openai, sources.togetherai, sources.pollinations, sources.stability]; if (cloudSources.includes(extension_settings.sd.source)) { return; @@ -1404,6 +1430,9 @@ async function loadSamplers() { case sources.pollinations: samplers = ['N/A']; break; + case sources.stability: + samplers = ['N/A']; + break; } for (const sampler of samplers) { @@ -1587,6 +1616,9 @@ async function loadModels() { case sources.pollinations: models = await loadPollinationsModels(); break; + case sources.stability: + models = await loadStabilityModels(); + break; } for (const model of models) { @@ -1603,6 +1635,16 @@ async function loadModels() { } } +async function loadStabilityModels() { + $('#sd_stability_key').toggleClass('success', !!secret_state[SECRET_KEYS.STABILITY]); + + return [ + { value: 'stable-image-ultra', text: 'Stable Image Ultra' }, + { value: 'stable-image-core', text: 'Stable Image Core' }, + { value: 'stable-diffusion-3', text: 'Stable Diffusion 3' }, + ]; +} + async function loadPollinationsModels() { return [ { @@ -1934,6 +1976,9 @@ async function loadSchedulers() { case sources.comfy: schedulers = await loadComfySchedulers(); break; + case sources.stability: + schedulers = ['N/A']; + break; } for (const scheduler of schedulers) { @@ -1984,7 +2029,7 @@ async function loadVaes() { vaes = ['N/A']; break; case sources.auto: - vaes = ['N/A']; + vaes = await loadAutoVaes(); break; case sources.novel: vaes = ['N/A']; @@ -2007,6 +2052,9 @@ async function loadVaes() { case sources.comfy: vaes = await loadComfyVaes(); break; + case sources.stability: + vaes = ['N/A']; + break; } for (const vae of vaes) { @@ -2016,6 +2064,35 @@ async function loadVaes() { option.selected = vae === extension_settings.sd.vae; $('#sd_vae').append(option); } + + if (!extension_settings.sd.vae && vaes.length > 0 && vaes[0] !== 'N/A') { + extension_settings.sd.vae = vaes[0]; + $('#sd_vae').val(extension_settings.sd.vae).trigger('change'); + } +} + +async function loadAutoVaes() { + if (!extension_settings.sd.auto_url) { + return ['N/A']; + } + + try { + const result = await fetch('/api/sd/vaes', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify(getSdRequestBody()), + }); + + if (!result.ok) { + throw new Error('SD WebUI returned an error.'); + } + + const data = await result.json(); + Array.isArray(data) && data.unshift(placeholderVae); + return data; + } catch (error) { + return ['N/A']; + } } async function loadComfyVaes() { @@ -2206,7 +2283,7 @@ async function generatePicture(initiator, args, trigger, message, callback) { } const dimensions = setTypeSpecificDimensions(generationType); - let negativePromptPrefix = resolveVariable(args?.negative) || ''; + let negativePromptPrefix = args?.negative || ''; let imagePath = ''; try { @@ -2487,6 +2564,9 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP case sources.pollinations: result = await generatePollinationsImage(prefixedPrompt, negativePrompt); break; + case sources.stability: + result = await generateStabilityImage(prefixedPrompt, negativePrompt); + break; } if (!result.data) { @@ -2510,6 +2590,12 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP return base64Image; } +/** + * Generates an image using the TogetherAI API. + * @param {string} prompt - The main instruction used to guide the image generation. + * @param {string} negativePrompt - The instruction used to restrict the image generation. + * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete. + */ async function generateTogetherAIImage(prompt, negativePrompt) { const result = await fetch('/api/sd/together/generate', { method: 'POST', @@ -2534,6 +2620,12 @@ async function generateTogetherAIImage(prompt, negativePrompt) { } } +/** + * Generates an image using the Pollinations API. + * @param {string} prompt - The main instruction used to guide the image generation. + * @param {string} negativePrompt - The instruction used to restrict the image generation. + * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete. + */ async function generatePollinationsImage(prompt, negativePrompt) { const result = await fetch('/api/sd/pollinations/generate', { method: 'POST', @@ -2602,6 +2694,84 @@ async function generateExtrasImage(prompt, negativePrompt) { } } +/** + * Gets an aspect ratio for Stability that is the closest to the given width and height. + * @param {number} width Target width + * @param {number} height Target height + * @returns {string} Closest aspect ratio as a string + */ +function getClosestAspectRatio(width, height) { + const aspectRatios = { + '16:9': 16 / 9, + '1:1': 1, + '21:9': 21 / 9, + '2:3': 2 / 3, + '3:2': 3 / 2, + '4:5': 4 / 5, + '5:4': 5 / 4, + '9:16': 9 / 16, + '9:21': 9 / 21, + }; + + const aspectRatio = width / height; + + let closestAspectRatio = Object.keys(aspectRatios)[0]; + let minDiff = Math.abs(aspectRatio - aspectRatios[closestAspectRatio]); + + for (const key in aspectRatios) { + const diff = Math.abs(aspectRatio - aspectRatios[key]); + if (diff < minDiff) { + minDiff = diff; + closestAspectRatio = key; + } + } + + return closestAspectRatio; +} + +/** + * Generates an image using Stability AI. + * @param {string} prompt - The main instruction used to guide the image generation. + * @param {string} negativePrompt - The instruction used to restrict the image generation. + * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete. + */ +async function generateStabilityImage(prompt, negativePrompt) { + const IMAGE_FORMAT = 'png'; + const PROMPT_LIMIT = 10000; + + try { + const response = await fetch('/api/sd/stability/generate', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ + model: extension_settings.sd.model, + payload: { + prompt: prompt.slice(0, PROMPT_LIMIT), + negative_prompt: negativePrompt.slice(0, PROMPT_LIMIT), + aspect_ratio: getClosestAspectRatio(extension_settings.sd.width, extension_settings.sd.height), + seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined, + style_preset: extension_settings.sd.stability_style_preset, + output_format: IMAGE_FORMAT, + }, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const base64Image = await response.text(); + + return { + format: IMAGE_FORMAT, + data: base64Image, + }; + } catch (error) { + console.error('Error generating image with Stability AI:', error); + throw error; + } +} + /** * Generates a "horde" image using the provided prompt and configuration settings. * @@ -2648,6 +2818,7 @@ async function generateHordeImage(prompt, negativePrompt) { * @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete. */ async function generateAutoImage(prompt, negativePrompt) { + const isValidVae = extension_settings.sd.vae && !['N/A', placeholderVae].includes(extension_settings.sd.vae); const result = await fetch('/api/sd/generate', { method: 'POST', headers: getRequestHeaders(), @@ -2671,6 +2842,7 @@ async function generateAutoImage(prompt, negativePrompt) { // For AUTO1111 override_settings: { CLIP_stop_at_last_layers: extension_settings.sd.clip_skip, + sd_vae: isValidVae ? extension_settings.sd.vae : undefined, }, override_settings_restore_afterwards: true, // For SD.Next @@ -2918,25 +3090,25 @@ async function generateComfyImage(prompt, negativePrompt) { const text = await workflowResponse.text(); toastr.error(`Failed to load workflow.\n\n${text}`); } - let workflow = (await workflowResponse.json()).replace('"%prompt%"', JSON.stringify(prompt)); - workflow = workflow.replace('"%negative_prompt%"', JSON.stringify(negativePrompt)); + let workflow = (await workflowResponse.json()).replaceAll('"%prompt%"', JSON.stringify(prompt)); + workflow = workflow.replaceAll('"%negative_prompt%"', JSON.stringify(negativePrompt)); const seed = extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : Math.round(Math.random() * Number.MAX_SAFE_INTEGER); workflow = workflow.replaceAll('"%seed%"', JSON.stringify(seed)); placeholders.forEach(ph => { - workflow = workflow.replace(`"%${ph}%"`, JSON.stringify(extension_settings.sd[ph])); + workflow = workflow.replaceAll(`"%${ph}%"`, JSON.stringify(extension_settings.sd[ph])); }); (extension_settings.sd.comfy_placeholders ?? []).forEach(ph => { - workflow = workflow.replace(`"%${ph.find}%"`, JSON.stringify(substituteParams(ph.replace))); + workflow = workflow.replaceAll(`"%${ph.find}%"`, JSON.stringify(substituteParams(ph.replace))); }); if (/%user_avatar%/gi.test(workflow)) { const response = await fetch(getUserAvatarUrl()); if (response.ok) { const avatarBlob = await response.blob(); const avatarBase64 = await getBase64Async(avatarBlob); - workflow = workflow.replace('"%user_avatar%"', JSON.stringify(avatarBase64)); + workflow = workflow.replaceAll('"%user_avatar%"', JSON.stringify(avatarBase64)); } else { - workflow = workflow.replace('"%user_avatar%"', JSON.stringify(PNG_PIXEL)); + workflow = workflow.replaceAll('"%user_avatar%"', JSON.stringify(PNG_PIXEL)); } } if (/%char_avatar%/gi.test(workflow)) { @@ -2944,9 +3116,9 @@ async function generateComfyImage(prompt, negativePrompt) { if (response.ok) { const avatarBlob = await response.blob(); const avatarBase64 = await getBase64Async(avatarBlob); - workflow = workflow.replace('"%char_avatar%"', JSON.stringify(avatarBase64)); + workflow = workflow.replaceAll('"%char_avatar%"', JSON.stringify(avatarBase64)); } else { - workflow = workflow.replace('"%char_avatar%"', JSON.stringify(PNG_PIXEL)); + workflow = workflow.replaceAll('"%char_avatar%"', JSON.stringify(PNG_PIXEL)); } } console.log(`{ @@ -2978,7 +3150,12 @@ async function onComfyOpenWorkflowEditorClick() { }), })).json(); const editorHtml = $(await $.get('scripts/extensions/stable-diffusion/comfyWorkflowEditor.html')); - const popupResult = callPopup(editorHtml, 'confirm', undefined, { okButton: 'Save', wide: true, large: true, rows: 1 }); + const saveValue = (/** @type {Popup} */ _popup) => { + workflow = $('#sd_comfy_workflow_editor_workflow').val().toString(); + return true; + }; + const popup = new Popup(editorHtml, POPUP_TYPE.CONFIRM, '', { okButton: 'Save', cancelButton: 'Cancel', wide: true, large: true, onClosing: saveValue }); + const popupResult = popup.show(); const checkPlaceholders = () => { workflow = $('#sd_comfy_workflow_editor_workflow').val().toString(); $('.sd_comfy_workflow_editor_placeholder_list > li[data-placeholder]').each(function (idx) { @@ -3047,7 +3224,7 @@ async function onComfyOpenWorkflowEditorClick() { headers: getRequestHeaders(), body: JSON.stringify({ file_name: extension_settings.sd.comfy_workflow, - workflow: $('#sd_comfy_workflow_editor_workflow').val().toString(), + workflow: workflow, }), }); if (!response.ok) { @@ -3058,7 +3235,7 @@ async function onComfyOpenWorkflowEditorClick() { } async function onComfyNewWorkflowClick() { - let name = await callPopup('<h3>Workflow name:</h3>', 'input'); + let name = await callGenericPopup('Workflow name:', POPUP_TYPE.INPUT); if (!name) { return; } @@ -3085,7 +3262,7 @@ async function onComfyNewWorkflowClick() { } async function onComfyDeleteWorkflowClick() { - const confirm = await callPopup('Delete the workflow? This action is irreversible.', 'confirm'); + const confirm = await callGenericPopup('Delete the workflow? This action is irreversible.', POPUP_TYPE.CONFIRM, '', { okButton: 'Delete', cancelButton: 'Cancel' }); if (!confirm) { return; } @@ -3230,6 +3407,8 @@ function isValidState() { return secret_state[SECRET_KEYS.TOGETHERAI]; case sources.pollinations: return true; + case sources.stability: + return secret_state[SECRET_KEYS.STABILITY]; } } @@ -3356,8 +3535,7 @@ jQuery(async () => { SlashCommandNamedArgument.fromProps({ name: 'negative', description: 'negative prompt prefix', - typeList: [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.VARIABLE_NAME], - enumProvider: commonEnumProviders.variables('all'), + typeList: [ARGUMENT_TYPE.STRING], }), ], unnamedArgumentList: [ @@ -3458,6 +3636,8 @@ jQuery(async () => { $('#sd_command_visible').on('input', onCommandVisibleInput); $('#sd_interactive_visible').on('input', onInteractiveVisibleInput); $('#sd_swap_dimensions').on('click', onSwapDimensionsClick); + $('#sd_stability_key').on('click', onStabilityKeyClick); + $('#sd_stability_style_preset').on('change', onStabilityStylePresetChange); $('.sd_settings .inline-drawer-toggle').on('click', function () { initScrollHeight($('#sd_prompt_prefix')); diff --git a/public/scripts/extensions/stable-diffusion/settings.html b/public/scripts/extensions/stable-diffusion/settings.html index 7546cc030..33317f695 100644 --- a/public/scripts/extensions/stable-diffusion/settings.html +++ b/public/scripts/extensions/stable-diffusion/settings.html @@ -44,6 +44,7 @@ <option value="openai">OpenAI (DALL-E)</option> <option value="pollinations">Pollinations</option> <option value="vlad">SD.Next (vladmandic)</option> + <option value="stability">Stability AI</option> <option value="auto">Stable Diffusion Web UI (AUTOMATIC1111)</option> <option value="horde">Stable Horde</option> <option value="togetherai">TogetherAI</option> @@ -189,21 +190,59 @@ </label> </div> </div> + <div data-sd-source="stability"> + <div class="flex-container flexnowrap alignItemsBaseline marginBot5"> + <strong class="flex1" data-i18n="API Key">API Key</strong> + <div id="sd_stability_key" class="menu_button menu_button_icon"> + <i class="fa-fw fa-solid fa-key"></i> + <span data-i18n="Click to set">Click to set</span> + </div> + </div> + <div class="marginBot5"> + <i data-i18n="You can find your API key in the Stability AI dashboard."> + You can find your API key in the Stability AI dashboard. + </i> + </div> + <div class="flex-container"> + <div class="flex1"> + <label for="sd_stability_style_preset" data-i18n="Style Preset">Style Preset</label> + <select id="sd_stability_style_preset"> + <option value="anime">Anime</option> + <option value="3d-model">3D Model</option> + <option value="analog-film">Analog Film</option> + <option value="cinematic">Cinematic</option> + <option value="comic-book">Comic Book</option> + <option value="digital-art">Digital Art</option> + <option value="enhance">Enhance</option> + <option value="fantasy-art">Fantasy Art</option> + <option value="isometric">Isometric</option> + <option value="line-art">Line Art</option> + <option value="low-poly">Low Poly</option> + <option value="modeling-compound">Modeling Compound</option> + <option value="neon-punk">Neon Punk</option> + <option value="origami">Origami</option> + <option value="photographic">Photographic</option> + <option value="pixel-art">Pixel Art</option> + <option value="tile-texture">Tile Texture</option> + </select> + </div> + </div> + </div> <div class="flex-container"> <div class="flex1"> <label for="sd_model" data-i18n="Model">Model</label> <select id="sd_model"></select> </div> - <div class="flex1" data-sd-source="comfy"> + <div class="flex1" data-sd-source="comfy,auto"> <label for="sd_vae">VAE</label> <select id="sd_vae"></select> </div> </div> <div class="flex-container"> - <div class="flex1"> + <div class="flex1" data-sd-source="extras,horde,auto,drawthings,novel,vlad,comfy"> <label for="sd_sampler" data-i18n="Sampling method">Sampling method</label> <select id="sd_sampler"></select> </div> @@ -339,7 +378,7 @@ </label> </div> - <div data-sd-source="novel,togetherai,pollinations,comfy,drawthings,vlad,auto,horde,extras" class="marginTop5"> + <div data-sd-source="novel,togetherai,pollinations,comfy,drawthings,vlad,auto,horde,extras,stability" class="marginTop5"> <label for="sd_seed"> <span data-i18n="Seed">Seed</span> <small data-i18n="(-1 for random)">(-1 for random)</small> diff --git a/public/scripts/extensions/tts/azure.js b/public/scripts/extensions/tts/azure.js index 6ff49d9c9..88d3f4771 100644 --- a/public/scripts/extensions/tts/azure.js +++ b/public/scripts/extensions/tts/azure.js @@ -1,4 +1,5 @@ -import { callPopup, getRequestHeaders } from '../../../script.js'; +import { getRequestHeaders } from '../../../script.js'; +import { POPUP_TYPE, callGenericPopup } from '../../popup.js'; import { SECRET_KEYS, findSecret, secret_state, writeSecret } from '../../secrets.js'; import { getPreviewString, saveTtsProviderSettings } from './index.js'; export { AzureTtsProvider }; @@ -69,13 +70,13 @@ class AzureTtsProvider { const popupText = 'Azure TTS API Key'; const savedKey = secret_state[SECRET_KEYS.AZURE_TTS] ? await findSecret(SECRET_KEYS.AZURE_TTS) : ''; - const key = await callPopup(popupText, 'input', savedKey); + const key = await callGenericPopup(popupText, POPUP_TYPE.INPUT, savedKey); if (key == false || key == '') { return; } - await writeSecret(SECRET_KEYS.AZURE_TTS, key); + await writeSecret(SECRET_KEYS.AZURE_TTS, String(key)); toastr.success('API Key saved'); $('#azure_tts_key').addClass('success'); diff --git a/public/scripts/extensions/tts/coqui.js b/public/scripts/extensions/tts/coqui.js index b16fa2cf9..a4c1cecf3 100644 --- a/public/scripts/extensions/tts/coqui.js +++ b/public/scripts/extensions/tts/coqui.js @@ -5,8 +5,8 @@ TODO: */ import { doExtrasFetch, extension_settings, getApiUrl, modules } from '../../extensions.js'; -import { callPopup } from '../../../script.js'; import { initVoiceMap } from './index.js'; +import { POPUP_TYPE, callGenericPopup } from '../../popup.js'; export { CoquiTtsProvider }; @@ -246,7 +246,7 @@ class CoquiTtsProvider { } // Ask user for voiceId name to save voice - const voiceName = await callPopup('<h3>Name of Coqui voice to add to voice select dropdown:</h3>', 'input'); + const voiceName = await callGenericPopup('Name of Coqui voice to add to voice select dropdown:', POPUP_TYPE.INPUT); const model_origin = $('#coqui_model_origin').val(); const model_language = $('#coqui_api_language').val(); diff --git a/public/scripts/extensions/tts/index.js b/public/scripts/extensions/tts/index.js index 13d3718ed..9f88fc5d1 100644 --- a/public/scripts/extensions/tts/index.js +++ b/public/scripts/extensions/tts/index.js @@ -1,4 +1,4 @@ -import { cancelTtsPlay, eventSource, event_types, isStreamingEnabled, name2, saveSettingsDebounced, substituteParams } from '../../../script.js'; +import { cancelTtsPlay, eventSource, event_types, getCurrentChatId, isStreamingEnabled, name2, saveSettingsDebounced, substituteParams } from '../../../script.js'; import { ModuleWorkerWrapper, doExtrasFetch, extension_settings, getApiUrl, getContext, modules, renderExtensionTemplateAsync } from '../../extensions.js'; import { delay, escapeRegex, getBase64Async, getStringHash, onlyUnique } from '../../utils.js'; import { EdgeTtsProvider } from './edge.js'; @@ -10,6 +10,7 @@ import { NovelTtsProvider } from './novel.js'; import { power_user } from '../../power-user.js'; import { OpenAITtsProvider } from './openai.js'; import { XTTSTtsProvider } from './xtts.js'; +import { VITSTtsProvider } from './vits.js'; import { GSVITtsProvider } from './gsvi.js'; import { SBVits2TtsProvider } from './sbvits2.js'; import { AllTalkTtsProvider } from './alltalk.js'; @@ -34,6 +35,7 @@ let lastMessage = null; let lastMessageHash = null; let periodicMessageGenerationTimer = null; let lastPositionOfParagraphEnd = -1; +let currentInitVoiceMapPromise = null; const DEFAULT_VOICE_MARKER = '[Default Voice]'; const DISABLED_VOICE_MARKER = 'disabled'; @@ -83,6 +85,7 @@ const ttsProviders = { ElevenLabs: ElevenLabsTtsProvider, Silero: SileroTtsProvider, XTTSv2: XTTSTtsProvider, + VITS: VITSTtsProvider, GSVI: GSVITtsProvider, SBVits2: SBVits2TtsProvider, System: SystemTtsProvider, @@ -1008,9 +1011,39 @@ class VoiceMapEntry { /** * Init voiceMapEntries for character select list. + * If an initialization is already in progress, it returns the existing Promise instead of starting a new one. * @param {boolean} unrestricted - If true, will include all characters in voiceMapEntries, even if they are not in the current chat. + * @returns {Promise} A promise that resolves when the initialization is complete. */ export async function initVoiceMap(unrestricted = false) { + // Preventing parallel execution + if (currentInitVoiceMapPromise) { + return currentInitVoiceMapPromise; + } + + currentInitVoiceMapPromise = (async () => { + const initialChatId = getCurrentChatId(); + try { + await initVoiceMapInternal(unrestricted); + } finally { + currentInitVoiceMapPromise = null; + } + const currentChatId = getCurrentChatId(); + + if (initialChatId !== currentChatId) { + // Chat changed during initialization, reinitialize + await initVoiceMap(unrestricted); + } + })(); + + return currentInitVoiceMapPromise; +} + +/** + * Init voiceMapEntries for character select list. + * @param {boolean} unrestricted - If true, will include all characters in voiceMapEntries, even if they are not in the current chat. + */ +async function initVoiceMapInternal(unrestricted) { // Gate initialization if not enabled or TTS Provider not ready. Prevents error popups. const enabled = $('#tts_enabled').is(':checked'); if (!enabled) { diff --git a/public/scripts/extensions/tts/novel.js b/public/scripts/extensions/tts/novel.js index 175bdd07a..6bd08e4fa 100644 --- a/public/scripts/extensions/tts/novel.js +++ b/public/scripts/extensions/tts/novel.js @@ -1,4 +1,5 @@ -import { getRequestHeaders, callPopup } from '../../../script.js'; +import { getRequestHeaders } from '../../../script.js'; +import { POPUP_TYPE, callGenericPopup } from '../../popup.js'; import { splitRecursive } from '../../utils.js'; import { getPreviewString, saveTtsProviderSettings } from './index.js'; import { initVoiceMap } from './index.js'; @@ -56,7 +57,7 @@ class NovelTtsProvider { // Add a new Novel custom voice to provider async addCustomVoice() { - const voiceName = await callPopup('<h3>Custom Voice name:</h3>', 'input'); + const voiceName = await callGenericPopup('Custom Voice name:', POPUP_TYPE.INPUT); this.settings.customVoices.push(voiceName); this.populateCustomVoices(); initVoiceMap(); // Update TTS extension voiceMap diff --git a/public/scripts/extensions/tts/vits.js b/public/scripts/extensions/tts/vits.js new file mode 100644 index 000000000..4cfa72953 --- /dev/null +++ b/public/scripts/extensions/tts/vits.js @@ -0,0 +1,404 @@ +import { getPreviewString, saveTtsProviderSettings } from './index.js'; + +export { VITSTtsProvider }; + +class VITSTtsProvider { + //########// + // Config // + //########// + + settings; + ready = false; + voices = []; + separator = '. '; + audioElement = document.createElement('audio'); + + /** + * Perform any text processing before passing to TTS engine. + * @param {string} text Input text + * @returns {string} Processed text + */ + processText(text) { + return text; + } + + audioFormats = ['wav', 'ogg', 'silk', 'mp3', 'flac']; + + languageLabels = { + 'Auto': 'auto', + 'Chinese': 'zh', + 'English': 'en', + 'Japanese': 'ja', + 'Korean': 'ko', + }; + + langKey2LangCode = { + 'zh': 'zh-CN', + 'en': 'en-US', + 'ja': 'ja-JP', + 'ko': 'ko-KR', + }; + + modelTypes = { + VITS: 'VITS', + W2V2_VITS: 'W2V2-VITS', + BERT_VITS2: 'BERT-VITS2', + }; + + defaultSettings = { + provider_endpoint: 'http://localhost:23456', + format: 'wav', + lang: 'auto', + length: 1.0, + noise: 0.33, + noisew: 0.4, + segment_size: 50, + streaming: false, + dim_emotion: 0, + sdp_ratio: 0.2, + emotion: 0, + text_prompt: '', + style_text: '', + style_weight: 1, + }; + + get settingsHtml() { + let html = ` + <label for="vits_lang">Text Language</label> + <select id="vits_lang">`; + + for (let language in this.languageLabels) { + if (this.languageLabels[language] == this.settings?.lang) { + html += `<option value="${this.languageLabels[language]}" selected="selected">${language}</option>`; + continue; + } + html += `<option value="${this.languageLabels[language]}">${language}</option>`; + } + + html += ` + </select> + <label>VITS / W2V2-VITS / Bert-VITS2 Settings:</label><br/> + <label for="vits_endpoint">Provider Endpoint:</label> + <input id="vits_endpoint" type="text" class="text_pole" maxlength="250" value="${this.defaultSettings.provider_endpoint}"/> + <span>Use <a target="_blank" href="https://github.com/Artrajz/vits-simple-api">vits-simple-api</a>.</span><br/> + + <label for="vits_format">Audio format:</label> + <select id="vits_format">`; + + for (let format of this.audioFormats) { + if (format == this.settings?.format) { + html += `<option value="${format}" selected="selected">${format}</option>`; + continue; + } + html += `<option value="${format}">${format}</option>`; + } + + html += ` + </select> + <label for="vits_length">Audio length: <span id="vits_length_output">${this.defaultSettings.length}</span></label> + <input id="vits_length" type="range" value="${this.defaultSettings.length}" min="0.0" max="5" step="0.01" /> + + <label for="vits_noise">Noise: <span id="vits_noise_output">${this.defaultSettings.noise}</span></label> + <input id="vits_noise" type="range" value="${this.defaultSettings.noise}" min="0.1" max="2" step="0.01" /> + + <label for="vits_noisew">SDP noise: <span id="vits_noisew_output">${this.defaultSettings.noisew}</span></label> + <input id="vits_noisew" type="range" value="${this.defaultSettings.noisew}" min="0.1" max="2" step="0.01" /> + + <label for="vits_segment_size">Segment Size: <span id="vits_segment_size_output">${this.defaultSettings.segment_size}</span></label> + <input id="vits_segment_size" type="range" value="${this.defaultSettings.segment_size}" min="0" max="1000" step="1" /> + + <label for="vits_streaming" class="checkbox_label"> + <input id="vits_streaming" type="checkbox" /> + <span>Streaming</span> + </label> + + <label>W2V2-VITS Settings:</label><br/> + <label for="vits_dim_emotion">Dimensional emotion:</label> + <input id="vits_dim_emotion" type="number" class="text_pole" min="0" max="5457" step="1" value="${this.defaultSettings.dim_emotion}"/> + + <label>BERT-VITS2 Settings:</label><br/> + <label for="vits_sdp_ratio">sdp_ratio: <span id="vits_sdp_ratio_output">${this.defaultSettings.sdp_ratio}</span></label> + <input id="vits_sdp_ratio" type="range" value="${this.defaultSettings.sdp_ratio}" min="0.0" max="1" step="0.01" /> + + <label for="vits_emotion">emotion: <span id="vits_emotion_output">${this.defaultSettings.emotion}</span></label> + <input id="vits_emotion" type="range" value="${this.defaultSettings.emotion}" min="0" max="9" step="1" /> + + <label for="vits_text_prompt">Text Prompt:</label> + <input id="vits_text_prompt" type="text" class="text_pole" maxlength="512" value="${this.defaultSettings.text_prompt}"/> + + <label for="vits_style_text">Style text:</label> + <input id="vits_style_text" type="text" class="text_pole" maxlength="512" value="${this.defaultSettings.style_text}"/> + + <label for="vits_style_weight">Style weight <span id="vits_style_weight_output">${this.defaultSettings.style_weight}</span></label> + <input id="vits_style_weight" type="range" value="${this.defaultSettings.style_weight}" min="0" max="1" step="0.01" /> + `; + + return html; + } + + onSettingsChange() { + // Used when provider settings are updated from UI + this.settings.provider_endpoint = $('#vits_endpoint').val(); + this.settings.lang = $('#vits_lang').val(); + this.settings.format = $('#vits_format').val(); + this.settings.dim_emotion = $('#vits_dim_emotion').val(); + this.settings.text_prompt = $('#vits_text_prompt').val(); + this.settings.style_text = $('#vits_style_text').val(); + + // Update the default TTS settings based on input fields + this.settings.length = $('#vits_length').val(); + this.settings.noise = $('#vits_noise').val(); + this.settings.noisew = $('#vits_noisew').val(); + this.settings.segment_size = $('#vits_segment_size').val(); + this.settings.streaming = $('#vits_streaming').is(':checked'); + this.settings.sdp_ratio = $('#vits_sdp_ratio').val(); + this.settings.emotion = $('#vits_emotion').val(); + this.settings.style_weight = $('#vits_style_weight').val(); + + // Update the UI to reflect changes + $('#vits_length_output').text(this.settings.length); + $('#vits_noise_output').text(this.settings.noise); + $('#vits_noisew_output').text(this.settings.noisew); + $('#vits_segment_size_output').text(this.settings.segment_size); + $('#vits_sdp_ratio_output').text(this.settings.sdp_ratio); + $('#vits_emotion_output').text(this.settings.emotion); + $('#vits_style_weight_output').text(this.settings.style_weight); + + saveTtsProviderSettings(); + this.changeTTSSettings(); + } + + async loadSettings(settings) { + // Pupulate Provider UI given input settings + if (Object.keys(settings).length == 0) { + console.info('Using default TTS Provider settings'); + } + + // Only accept keys defined in defaultSettings + this.settings = this.defaultSettings; + + for (const key in settings) { + if (key in this.settings) { + this.settings[key] = settings[key]; + } else { + console.debug(`Ignoring non-user-configurable setting: ${key}`); + } + } + + // Set initial values from the settings + $('#vits_endpoint').val(this.settings.provider_endpoint); + $('#vits_lang').val(this.settings.lang); + $('#vits_format').val(this.settings.format); + $('#vits_length').val(this.settings.length); + $('#vits_noise').val(this.settings.noise); + $('#vits_noisew').val(this.settings.noisew); + $('#vits_segment_size').val(this.settings.segment_size); + $('#vits_streaming').prop('checked', this.settings.streaming); + $('#vits_dim_emotion').val(this.settings.dim_emotion); + $('#vits_sdp_ratio').val(this.settings.sdp_ratio); + $('#vits_emotion').val(this.settings.emotion); + $('#vits_text_prompt').val(this.settings.text_prompt); + $('#vits_style_text').val(this.settings.style_text); + $('#vits_style_weight').val(this.settings.style_weight); + + // Update the UI to reflect changes + $('#vits_length_output').text(this.settings.length); + $('#vits_noise_output').text(this.settings.noise); + $('#vits_noisew_output').text(this.settings.noisew); + $('#vits_segment_size_output').text(this.settings.segment_size); + $('#vits_sdp_ratio_output').text(this.settings.sdp_ratio); + $('#vits_emotion_output').text(this.settings.emotion); + $('#vits_style_weight_output').text(this.settings.style_weight); + + // Register input/change event listeners to update settings on user interaction + $('#vits_endpoint').on('input', () => { this.onSettingsChange(); }); + $('#vits_lang').on('change', () => { this.onSettingsChange(); }); + $('#vits_format').on('change', () => { this.onSettingsChange(); }); + $('#vits_length').on('change', () => { this.onSettingsChange(); }); + $('#vits_noise').on('change', () => { this.onSettingsChange(); }); + $('#vits_noisew').on('change', () => { this.onSettingsChange(); }); + $('#vits_segment_size').on('change', () => { this.onSettingsChange(); }); + $('#vits_streaming').on('change', () => { this.onSettingsChange(); }); + $('#vits_dim_emotion').on('change', () => { this.onSettingsChange(); }); + $('#vits_sdp_ratio').on('change', () => { this.onSettingsChange(); }); + $('#vits_emotion').on('change', () => { this.onSettingsChange(); }); + $('#vits_text_prompt').on('change', () => { this.onSettingsChange(); }); + $('#vits_style_text').on('change', () => { this.onSettingsChange(); }); + $('#vits_style_weight').on('change', () => { this.onSettingsChange(); }); + + await this.checkReady(); + + console.info('VITS: Settings loaded'); + } + + // Perform a simple readiness check by trying to fetch voiceIds + async checkReady() { + await Promise.allSettled([this.fetchTtsVoiceObjects(), this.changeTTSSettings()]); + } + + async onRefreshClick() { + return; + } + + //#################// + // TTS Interfaces // + //#################// + + async getVoice(voiceName) { + if (this.voices.length == 0) { + this.voices = await this.fetchTtsVoiceObjects(); + } + const match = this.voices.filter( + v => v.name == voiceName, + )[0]; + if (!match) { + throw `TTS Voice name ${voiceName} not found`; + } + return match; + } + + async getVoiceById(voiceId) { + if (this.voices.length == 0) { + this.voices = await this.fetchTtsVoiceObjects(); + } + const match = this.voices.filter( + v => v.voice_id == voiceId, + )[0]; + if (!match) { + throw `TTS Voice id ${voiceId} not found`; + } + return match; + } + + async generateTts(text, voiceId) { + const response = await this.fetchTtsGeneration(text, voiceId); + return response; + } + + //###########// + // API CALLS // + //###########// + async fetchTtsVoiceObjects() { + const response = await fetch(`${this.settings.provider_endpoint}/voice/speakers`); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${await response.json()}`); + } + const jsonData = await response.json(); + const voices = []; + + const addVoices = (modelType) => { + jsonData[modelType].forEach(voice => { + voices.push({ + name: `[${modelType}] ${voice.name} (${voice.lang})`, + voice_id: `${modelType}&${voice.id}`, + preview_url: false, + lang: voice.lang, + }); + }); + }; + for (const key in this.modelTypes) { + addVoices(this.modelTypes[key]); + } + + this.voices = voices; // Assign to the class property + return voices; // Also return this list + } + + // Each time a parameter is changed, we change the configuration + async changeTTSSettings() { + } + + /** + * Fetch TTS generation from the API. + * @param {string} inputText Text to generate TTS for + * @param {string} voiceId Voice ID to use (model_type&speaker_id)) + * @returns {Promise<Response|string>} Fetch response + */ + async fetchTtsGeneration(inputText, voiceId, lang = null, forceNoStreaming = false) { + console.info(`Generating new TTS for voice_id ${voiceId}`); + + const streaming = !forceNoStreaming && this.settings.streaming; + const [model_type, speaker_id] = voiceId.split('&'); + const params = new URLSearchParams(); + params.append('text', inputText); + params.append('id', speaker_id); + if (streaming) { + params.append('streaming', streaming); + // Streaming response only supports MP3 + } + else { + params.append('format', this.settings.format); + } + params.append('lang', lang ?? this.settings.lang); + params.append('length', this.settings.length); + params.append('noise', this.settings.noise); + params.append('noisew', this.settings.noisew); + params.append('segment_size', this.settings.segment_size); + + if (model_type == this.modelTypes.W2V2_VITS) { + params.append('emotion', this.settings.dim_emotion); + } + else if (model_type == this.modelTypes.BERT_VITS2) { + params.append('sdp_ratio', this.settings.sdp_ratio); + params.append('emotion', this.settings.emotion); + if (this.settings.text_prompt) { + params.append('text_prompt', this.settings.text_prompt); + } + if (this.settings.style_text) { + params.append('style_text', this.settings.style_text); + params.append('style_weight', this.settings.style_weight); + } + } + + const url = `${this.settings.provider_endpoint}/voice/${model_type.toLowerCase()}`; + + if (streaming) { + return url + `?${params.toString()}`; + } + + const response = await fetch( + url, + { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params, + }, + ); + if (!response.ok) { + toastr.error(response.statusText, 'TTS Generation Failed'); + throw new Error(`HTTP ${response.status}: ${await response.text()}`); + } + return response; + } + + /** + * Preview TTS for a given voice ID. + * @param {string} id Voice ID + */ + async previewTtsVoice(id) { + this.audioElement.pause(); + this.audioElement.currentTime = 0; + const voice = await this.getVoiceById(id); + const lang = voice.lang.includes(this.settings.lang) ? this.settings.lang : voice.lang[0]; + + let lang_code = this.langKey2LangCode[lang]; + const text = getPreviewString(lang_code); + const response = await this.fetchTtsGeneration(text, id, lang, true); + if (typeof response != 'string') { + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${await response.text()}`); + } + const audio = await response.blob(); + const url = URL.createObjectURL(audio); + this.audioElement.src = url; + this.audioElement.play(); + } + } + + // Interface not used + async fetchTtsFromHistory(history_item_id) { + return Promise.resolve(history_item_id); + } +} diff --git a/public/scripts/group-chats.js b/public/scripts/group-chats.js index fdc91c1df..83f0d0a99 100644 --- a/public/scripts/group-chats.js +++ b/public/scripts/group-chats.js @@ -70,6 +70,7 @@ import { animation_duration, depth_prompt_role_default, shouldAutoContinue, + this_chid, } from '../script.js'; import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect } from './tags.js'; import { FILTER_TYPES, FilterHelper } from './filters.js'; @@ -120,6 +121,8 @@ const DEFAULT_AUTO_MODE_DELAY = 5; export const groupCandidatesFilter = new FilterHelper(debounce(printGroupCandidates, debounce_timeout.quick)); let autoModeWorker = null; const saveGroupDebounced = debounce(async (group, reload) => await _save(group, reload), debounce_timeout.relaxed); +/** @type {Map<string, number>} */ +let groupChatQueueOrder = new Map(); function setAutoModeWorker() { clearInterval(autoModeWorker); @@ -178,8 +181,37 @@ async function loadGroupChat(chatId) { return []; } +async function validateGroup(group) { + if (!group) return; + + // Validate that all members exist as characters + let dirty = false; + group.members = group.members.filter(member => { + const character = characters.find(x => x.avatar === member || x.name === member); + if (!character) { + const msg = `Warning: Listed member ${member} does not exist as a character. It will be removed from the group.`; + toastr.warning(msg, 'Group Validation'); + console.warn(msg); + dirty = true; + } + return character; + }); + + if (dirty) { + await editGroup(group.id, true, false); + } +} + export async function getGroupChat(groupId, reload = false) { const group = groups.find((x) => x.id === groupId); + if (!group) { + console.warn('Group not found', groupId); + return; + } + + // Run validation before any loading + validateGroup(group); + const chat_id = group.chat_id; const data = await loadGroupChat(chat_id); let freshChat = false; @@ -197,7 +229,6 @@ export async function getGroupChat(groupId, reload = false) { if (group && Array.isArray(group.members)) { for (let member of group.members) { const character = characters.find(x => x.avatar === member || x.name === member); - if (!character) { continue; } @@ -219,10 +250,8 @@ export async function getGroupChat(groupId, reload = false) { freshChat = true; } - if (group) { - let metadata = group.chat_metadata ?? {}; - updateChatMetadata(metadata, true); - } + let metadata = group.chat_metadata ?? {}; + updateChatMetadata(metadata, true); if (reload) { select_group_chats(groupId, true); @@ -492,7 +521,13 @@ async function saveGroupChat(groupId, shouldSaveGroup) { body: JSON.stringify({ id: chat_id, chat: [...chat] }), }); - if (shouldSaveGroup && response.ok) { + if (!response.ok) { + toastr.error('Check the server connection and reload the page to prevent data loss.', 'Group Chat could not be saved'); + console.error('Group chat could not be saved', response); + return; + } + + if (shouldSaveGroup) { await editGroup(groupId, false, false); } } @@ -546,9 +581,11 @@ export async function renameGroupMember(oldAvatar, newAvatar, newName) { body: JSON.stringify({ id: chatId, chat: [...messages] }), }); - if (saveChatResponse.ok) { - console.log(`Renamed character ${newName} in group chat: ${chatId}`); + if (!saveChatResponse.ok) { + throw new Error('Group member could not be renamed'); } + + console.log(`Renamed character ${newName} in group chat: ${chatId}`); } } } @@ -821,9 +858,15 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) { const bias = getBiasStrings(userInput, type); await sendMessageAsUser(userInput, bias.messageBias); await saveChatConditional(); - $('#send_textarea').val('')[0].dispatchEvent(new Event('input', { bubbles:true })); + $('#send_textarea').val('')[0].dispatchEvent(new Event('input', { bubbles: true })); } + groupChatQueueOrder = new Map(); + if (power_user.show_group_chat_queue) { + for (let i = 0; i < activatedMembers.length; ++i) { + groupChatQueueOrder.set(characters[activatedMembers[i]].avatar, i + 1); + } + } // now the real generation begins: cycle through every activated character for (const chId of activatedMembers) { throwIfAborted(); @@ -831,6 +874,9 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) { const generateType = type == 'swipe' || type == 'impersonate' || type == 'quiet' || type == 'continue' ? type : 'group_chat'; setCharacterId(chId); setCharacterName(characters[chId].name); + if (power_user.show_group_chat_queue) { + printGroupMembers(); + } await eventSource.emit(event_types.GROUP_MEMBER_DRAFTED, chId); if (type !== 'swipe' && type !== 'impersonate' && !isStreamingEnabled()) { @@ -851,6 +897,10 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) { messageChunk = textResult?.messageChunk; } } + if (power_user.show_group_chat_queue) { + groupChatQueueOrder.delete(characters[chId].avatar); + groupChatQueueOrder.forEach((value, key, map) => map.set(key, value - 1)); + } } } finally { typingIndicator.hide(); @@ -858,6 +908,10 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) { is_group_generating = false; setSendButtonState(false); setCharacterId(undefined); + if (power_user.show_group_chat_queue) { + groupChatQueueOrder = new Map(); + printGroupMembers(); + } setCharacterName(''); activateSendButtons(); showSwipeButtons(); @@ -972,6 +1026,7 @@ function activateNaturalOrder(members, input, lastMessage, allowSelfResponses, i } } + const chattyMembers = []; // activation by talkativeness (in shuffled order, except banned) const shuffledMembers = shuffle([...members]); for (let member of shuffledMembers) { @@ -982,26 +1037,30 @@ function activateNaturalOrder(members, input, lastMessage, allowSelfResponses, i } const rollValue = Math.random(); - let talkativeness = Number(character.talkativeness); - talkativeness = Number.isNaN(talkativeness) + const talkativeness = isNaN(character.talkativeness) ? talkativeness_default - : talkativeness; + : Number(character.talkativeness); if (talkativeness >= rollValue) { activatedMembers.push(member); } + if (talkativeness > 0) { + chattyMembers.push(member); + } } // pick 1 at random if no one was activated let retries = 0; - while (activatedMembers.length === 0 && ++retries <= members.length) { - const randomIndex = Math.floor(Math.random() * members.length); - const character = characters.find((x) => x.avatar === members[randomIndex]); + // try to limit the selected random character to those with talkativeness > 0 + const randomPool = chattyMembers.length > 0 ? chattyMembers : members; + while (activatedMembers.length === 0 && ++retries <= randomPool.length) { + const randomIndex = Math.floor(Math.random() * randomPool.length); + const character = characters.find((x) => x.avatar === randomPool[randomIndex]); if (!character) { continue; } - activatedMembers.push(members[randomIndex]); + activatedMembers.push(randomPool[randomIndex]); } // de-duplicate array of character avatars @@ -1279,6 +1338,14 @@ function getGroupCharacterBlock(character) { template.attr('chid', characters.indexOf(character)); template.find('.ch_fav').val(isFav); template.toggleClass('is_fav', isFav); + + let queuePosition = groupChatQueueOrder.get(character.avatar); + if (queuePosition) { + template.find('.queue_position').text(queuePosition); + template.toggleClass('is_queued', queuePosition > 1); + template.toggleClass('is_active', queuePosition === 1); + } + template.toggleClass('disabled', isGroupMemberDisabled(character.avatar)); // Display inline tags @@ -1568,7 +1635,10 @@ export async function openGroupById(groupId) { } if (!is_send_press && !is_group_generating) { + select_group_chats(groupId); + if (selected_group !== groupId) { + groupChatQueueOrder = new Map(); await clearChat(); cancelTtsPlay(); selected_group = groupId; @@ -1579,8 +1649,6 @@ export async function openGroupById(groupId) { chat.length = 0; await getGroupChat(groupId); } - - select_group_chats(groupId); } } @@ -1828,11 +1896,16 @@ export async function saveGroupBookmarkChat(groupId, name, metadata, mesId) { await editGroup(groupId, true, false); - await fetch('/api/chats/group/save', { + const response = await fetch('/api/chats/group/save', { method: 'POST', headers: getRequestHeaders(), body: JSON.stringify({ id: name, chat: [...trimmed_chat] }), }); + + if (!response.ok) { + toastr.error('Check the server connection and reload the page to prevent data loss.', 'Group chat could not be saved'); + console.error('Group chat could not be saved', response); + } } function onSendTextareaInput() { diff --git a/public/scripts/i18n.js b/public/scripts/i18n.js index df86a3c26..d1ed2f3ea 100644 --- a/public/scripts/i18n.js +++ b/public/scripts/i18n.js @@ -9,6 +9,71 @@ const langs = await fetch('/locales/lang.json').then(response => response.json() // eslint-disable-next-line prefer-const var localeData = await getLocaleData(localeFile); +/** + * An observer that will check if any new i18n elements are added to the document + * @type {MutationObserver} + */ +const observer = new MutationObserver(mutations => { + mutations.forEach(mutation => { + mutation.addedNodes.forEach(node => { + if (node.nodeType === Node.ELEMENT_NODE && node instanceof Element) { + if (node.hasAttribute('data-i18n')) { + translateElement(node); + } + node.querySelectorAll('[data-i18n]').forEach(element => { + translateElement(element); + }); + } + }); + if (mutation.attributeName === 'data-i18n' && mutation.target instanceof Element) { + translateElement(mutation.target); + } + }); +}); + +/** + * Translates a template string with named arguments + * + * Uses the template literal with all values replaced by index placeholder for translation key. + * + * @example + * ```js + * toastr.warn(t`Tag ${tagName} not found.`); + * ``` + * Should be translated in the translation files as: + * ``` + * Tag ${0} not found. -> Tag ${0} nicht gefunden. + * ``` + * + * @param {TemplateStringsArray} strings - Template strings array + * @param {...any} values - Values for placeholders in the template string + * @returns {string} Translated and formatted string + */ +export function t(strings, ...values) { + let str = strings.reduce((result, string, i) => result + string + (values[i] !== undefined ? `\${${i}}` : ''), ''); + let translatedStr = translate(str); + + // Replace indexed placeholders with actual values + return translatedStr.replace(/\$\{(\d+)\}/g, (match, index) => values[index]); +} + +/** + * Translates a given key or text + * + * If the translation is based on a key, that one is used to find a possible translation in the translation file. + * The original text still has to be provided, as that is the default value being returned if no translation is found. + * + * For in-code text translation on a format string, using the template literal `t` is preferred. + * + * @param {string} text - The text to translate + * @param {string?} key - The key to use for translation. If not provided, text is used as the key. + * @returns {string} - The translated text + */ +export function translate(text, key = null) { + const translationKey = key || text; + return localeData?.[translationKey] || text; +} + /** * Fetches the locale data for the given language. * @param {string} language Language code @@ -40,6 +105,29 @@ function findLang(language) { return supportedLang; } +/** + * Translates a given element based on its data-i18n attribute. + * @param {Element} element The element to translate + */ +function translateElement(element) { + const keys = element.getAttribute('data-i18n').split(';'); // Multi-key entries are ; delimited + for (const key of keys) { + const attributeMatch = key.match(/\[(\S+)\](.+)/); // [attribute]key + if (attributeMatch) { // attribute-tagged key + const localizedValue = localeData?.[attributeMatch[2]]; + if (localizedValue || localizedValue === '') { + element.setAttribute(attributeMatch[1], localizedValue); + } + } else { // No attribute tag, treat as 'text' + const localizedValue = localeData?.[key]; + if (localizedValue || localizedValue === '') { + element.textContent = localizedValue; + } + } + } +} + + async function getMissingTranslations() { const missingData = []; @@ -103,22 +191,7 @@ export function applyLocale(root = document) { //find all the elements with `data-i18n` attribute $root.find('[data-i18n]').each(function () { - //read the translation from the language data - const keys = $(this).data('i18n').split(';'); // Multi-key entries are ; delimited - for (const key of keys) { - const attributeMatch = key.match(/\[(\S+)\](.+)/); // [attribute]key - if (attributeMatch) { // attribute-tagged key - const localizedValue = localeData?.[attributeMatch[2]]; - if (localizedValue || localizedValue == '') { - $(this).attr(attributeMatch[1], localizedValue); - } - } else { // No attribute tag, treat as 'text' - const localizedValue = localeData?.[key]; - if (localizedValue || localizedValue == '') { - $(this).text(localizedValue); - } - } - } + translateElement(this); }); if (root !== document) { @@ -126,7 +199,6 @@ export function applyLocale(root = document) { } } - function addLanguagesToDropdown() { const uiLanguageSelects = $('#ui_language_select, #onboarding_ui_language_select'); for (const langObj of langs) { // Set the value to the language code @@ -159,6 +231,13 @@ export function initLocales() { location.reload(); }); + observer.observe(document, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['data-i18n'], + }); + registerDebugFunction('getMissingTranslations', 'Get missing translations', 'Detects missing localization data in the current locale and dumps the data into the browser console. If the current locale is English, searches all other locales.', getMissingTranslations); registerDebugFunction('applyLocale', 'Apply locale', 'Reapplies the currently selected locale to the page.', applyLocale); } diff --git a/public/scripts/loader.js b/public/scripts/loader.js index 8c4fd223c..2df5f6edf 100644 --- a/public/scripts/loader.js +++ b/public/scripts/loader.js @@ -1,25 +1,55 @@ -const ELEMENT_ID = 'loader'; +import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js'; + +/** @type {Popup} */ +let loaderPopup; + +let preloaderYoinked = false; export function showLoader() { - const container = $('<div></div>').attr('id', ELEMENT_ID); - const loader = $('<div></div>').attr('id', 'load-spinner').addClass('fa-solid fa-gear fa-spin fa-3x'); - container.append(loader); - $('body').append(container); + // Two loaders don't make sense. Don't await, we can overlay the old loader while it closes + if (loaderPopup) loaderPopup.complete(POPUP_RESULT.CANCELLED); + + loaderPopup = new Popup(` + <div id="loader"> + <div id="load-spinner" class="fa-solid fa-gear fa-spin fa-3x"></div> + </div>`, POPUP_TYPE.DISPLAY, null, { transparent: true, animation: 'none' }); + + // No close button, loaders are not closable + loaderPopup.closeButton.style.display = 'none'; + + loaderPopup.show(); } export async function hideLoader() { - //Sets up a 2-step animation. Spinner blurs/fades out, and then the loader shadow does the same. - $('#load-spinner').on('transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd', function () { - $(`#${ELEMENT_ID}`) - //only fade out the spinner and replace with login screen - .animate({ opacity: 0 }, 300, function () { - $(`#${ELEMENT_ID}`).remove(); + if (!loaderPopup) { + console.warn('There is no loader showing to hide'); + return Promise.resolve(); + } + + return new Promise((resolve) => { + // Spinner blurs/fades out + $('#load-spinner').on('transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd', function () { + $('#loader').remove(); + // Yoink preloader entirely; it only exists to cover up unstyled content while loading JS + // If it's present, we remove it once and then it's gone. + yoinkPreloader(); + + loaderPopup.complete(POPUP_RESULT.AFFIRMATIVE).then(() => { + loaderPopup = null; + resolve(); + }); + }); + + $('#load-spinner') + .css({ + 'filter': 'blur(15px)', + 'opacity': '0', }); }); - - $('#load-spinner') - .css({ - 'filter': 'blur(15px)', - 'opacity': '0', - }); +} + +function yoinkPreloader() { + if (preloaderYoinked) return; + document.getElementById('preloader').remove(); + preloaderYoinked = true; } diff --git a/public/scripts/macros.js b/public/scripts/macros.js index 2b4ea17a7..aae0fad17 100644 --- a/public/scripts/macros.js +++ b/public/scripts/macros.js @@ -1,5 +1,5 @@ import { chat, chat_metadata, main_api, getMaxContextSize, getCurrentChatId, substituteParams } from '../script.js'; -import { timestampToMoment, isDigitsOnly, getStringHash } from './utils.js'; +import { timestampToMoment, isDigitsOnly, getStringHash, escapeRegex, uuidv4 } from './utils.js'; import { textgenerationwebui_banned_in_macros } from './textgen-settings.js'; import { replaceInstructMacros } from './instruct-mode.js'; import { replaceVariableMacros } from './variables.js'; @@ -13,6 +13,132 @@ Handlebars.registerHelper('helperMissing', function () { return substituteParams(`{{${macroName}}}`); }); +/** + * @typedef {Object<string, *>} EnvObject + * @typedef {(nonce: string) => string} MacroFunction + */ + +export class MacrosParser { + /** + * A map of registered macros. + * @type {Map<string, string|MacroFunction>} + */ + static #macros = new Map(); + + /** + * Registers a global macro that can be used anywhere where substitution is allowed. + * @param {string} key Macro name (key) + * @param {string|MacroFunction} value A string or a function that returns a string + */ + static registerMacro(key, value) { + if (typeof key !== 'string') { + throw new Error('Macro key must be a string'); + } + + // Allowing surrounding whitespace would just create more confusion... + key = key.trim(); + + if (!key) { + throw new Error('Macro key must not be empty or whitespace only'); + } + + if (key.startsWith('{{') || key.endsWith('}}')) { + throw new Error('Macro key must not include the surrounding braces'); + } + + if (typeof value !== 'string' && typeof value !== 'function') { + console.warn(`Macro value for "${key}" will be converted to a string`); + value = this.sanitizeMacroValue(value); + } + + if (this.#macros.has(key)) { + console.warn(`Macro ${key} is already registered`); + } + + this.#macros.set(key, value); + } + + /** + * Unregisters a global macro with the given key + * + * @param {string} key Macro name (key) + */ + static unregisterMacro(key) { + if (typeof key !== 'string') { + throw new Error('Macro key must be a string'); + } + + // Allowing surrounding whitespace would just create more confusion... + key = key.trim(); + + if (!key) { + throw new Error('Macro key must not be empty or whitespace only'); + } + + const deleted = this.#macros.delete(key); + + if (!deleted) { + console.warn(`Macro ${key} was not registered`); + } + } + + /** + * Populate the env object with macro values from the current context. + * @param {EnvObject} env Env object for the current evaluation context + * @returns {void} + */ + static populateEnv(env) { + if (!env || typeof env !== 'object') { + console.warn('Env object is not provided'); + return; + } + + // No macros are registered + if (this.#macros.size === 0) { + return; + } + + for (const [key, value] of this.#macros) { + env[key] = value; + } + } + + /** + * Performs a type-check on the macro value and returns a sanitized version of it. + * @param {any} value Value returned by a macro + * @returns {string} Sanitized value + */ + static sanitizeMacroValue(value) { + if (typeof value === 'string') { + return value; + } + + if (value === null || value === undefined) { + return ''; + } + + if (value instanceof Promise) { + console.warn('Promises are not supported as macro values'); + return ''; + } + + if (typeof value === 'function') { + console.warn('Functions are not supported as macro values'); + return ''; + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (typeof value === 'object') { + return JSON.stringify(value); + } + + return String(value); + } +} + /** * Gets a hashed id of the current chat from the metadata. * If no metadata exists, creates a new hash and saves it. @@ -128,7 +254,7 @@ function getCurrentSwipeId() { // For swipe macro, we are accepting using the message that is currently being swiped const mid = getLastMessageId({ exclude_swipe_in_propress: false }); const swipeId = chat[mid]?.swipe_id; - return swipeId ? swipeId + 1 : null; + return swipeId !== null ? swipeId + 1 : null; } /** @@ -275,7 +401,7 @@ function timeDiffReplace(input) { const time2 = moment(matchPart2); const timeDifference = moment.duration(time1.diff(time2)); - return timeDifference.humanize(); + return timeDifference.humanize(true); }); return output; @@ -284,7 +410,7 @@ function timeDiffReplace(input) { /** * Substitutes {{macro}} parameters in a string. * @param {string} content - The string to substitute parameters in. - * @param {Object<string, *>} env - Map of macro names to the values they'll be substituted with. If the param + * @param {EnvObject} env - Map of macro names to the values they'll be substituted with. If the param * values are functions, those functions will be called and their return values are used. * @returns {string} The string with substituted parameters. */ @@ -311,16 +437,23 @@ export function evaluateMacros(content, env) { content = replaceInstructMacros(content, env); content = replaceVariableMacros(content); content = content.replace(/{{newline}}/gi, '\n'); - content = content.replace(/\n*{{trim}}\n*/gi, ''); + content = content.replace(/(?:\r?\n)*{{trim}}(?:\r?\n)*/gi, ''); content = content.replace(/{{noop}}/gi, ''); content = content.replace(/{{input}}/gi, () => String($('#send_textarea').val())); + // Add all registered macros to the env object + const nonce = uuidv4(); + MacrosParser.populateEnv(env); + // Substitute passed-in variables for (const varName in env) { if (!Object.hasOwn(env, varName)) continue; - const param = env[varName]; - content = content.replace(new RegExp(`{{${varName}}}`, 'gi'), param); + content = content.replace(new RegExp(`{{${escapeRegex(varName)}}}`, 'gi'), () => { + const param = env[varName]; + const value = MacrosParser.sanitizeMacroValue(typeof param === 'function' ? param(nonce) : param); + return value; + }); } content = content.replace(/{{maxPrompt}}/gi, () => String(getMaxContextSize())); diff --git a/public/scripts/openai.js b/public/scripts/openai.js index 37551dd34..ee48db290 100644 --- a/public/scripts/openai.js +++ b/public/scripts/openai.js @@ -181,6 +181,7 @@ export const chat_completion_sources = { COHERE: 'cohere', PERPLEXITY: 'perplexity', GROQ: 'groq', + ZEROONEAI: '01ai', }; const character_names_behavior = { @@ -251,6 +252,7 @@ const default_settings = { cohere_model: 'command-r', perplexity_model: 'llama-3-70b-instruct', groq_model: 'llama3-70b-8192', + zerooneai_model: 'yi-large', custom_model: '', custom_url: '', custom_include_body: '', @@ -329,6 +331,7 @@ const oai_settings = { cohere_model: 'command-r', perplexity_model: 'llama-3-70b-instruct', groq_model: 'llama3-70b-8192', + zerooneai_model: 'yi-large', custom_model: '', custom_url: '', custom_include_body: '', @@ -686,7 +689,7 @@ function formatWorldInfo(value) { return ''; } - if (!oai_settings.wi_format) { + if (!oai_settings.wi_format.trim()) { return value; } @@ -1470,6 +1473,8 @@ function getChatCompletionModel() { return oai_settings.perplexity_model; case chat_completion_sources.GROQ: return oai_settings.groq_model; + case chat_completion_sources.ZEROONEAI: + return oai_settings.zerooneai_model; default: throw new Error(`Unknown chat completion source: ${oai_settings.chat_completion_source}`); } @@ -1566,6 +1571,23 @@ function saveModelList(data) { $('#model_custom_select').val(model_list[0].id).trigger('change'); } } + + if (oai_settings.chat_completion_source == chat_completion_sources.ZEROONEAI) { + $('#model_01ai_select').empty(); + model_list.forEach((model) => { + $('#model_01ai_select').append( + $('<option>', { + value: model.id, + text: model.id, + })); + }); + + if (!oai_settings.zerooneai_model && model_list.length > 0) { + oai_settings.zerooneai_model = model_list[0].id; + } + + $('#model_01ai_select').val(oai_settings.zerooneai_model).trigger('change'); + } } function appendOpenRouterOptions(model_list, groupModels = false, sort = false) { @@ -1697,6 +1719,7 @@ async function sendOpenAIRequest(type, messages, signal) { const isCohere = oai_settings.chat_completion_source == chat_completion_sources.COHERE; const isPerplexity = oai_settings.chat_completion_source == chat_completion_sources.PERPLEXITY; const isGroq = oai_settings.chat_completion_source == chat_completion_sources.GROQ; + const is01AI = oai_settings.chat_completion_source == chat_completion_sources.ZEROONEAI; const isTextCompletion = (isOAI && textCompletionModels.includes(oai_settings.openai_model)) || (isOpenRouter && oai_settings.openrouter_force_instruct && power_user.instruct.enabled); const isQuiet = type === 'quiet'; const isImpersonate = type === 'impersonate'; @@ -1863,6 +1886,17 @@ async function sendOpenAIRequest(type, messages, signal) { delete generate_data.n; } + // https://platform.01.ai/docs#request-body + if (is01AI) { + delete generate_data.logprobs; + delete generate_data.logit_bias; + delete generate_data.top_logprobs; + delete generate_data.n; + delete generate_data.frequency_penalty; + delete generate_data.presence_penalty; + delete generate_data.stop; + } + if ((isOAI || isOpenRouter || isMistral || isCustom || isCohere) && oai_settings.seed >= 0) { generate_data['seed'] = oai_settings.seed; } @@ -2912,6 +2946,7 @@ function loadOpenAISettings(data, settings) { oai_settings.cohere_model = settings.cohere_model ?? default_settings.cohere_model; oai_settings.perplexity_model = settings.perplexity_model ?? default_settings.perplexity_model; oai_settings.groq_model = settings.groq_model ?? default_settings.groq_model; + oai_settings.zerooneai_model = settings.zerooneai_model ?? default_settings.zerooneai_model; oai_settings.custom_model = settings.custom_model ?? default_settings.custom_model; oai_settings.custom_url = settings.custom_url ?? default_settings.custom_url; oai_settings.custom_include_body = settings.custom_include_body ?? default_settings.custom_include_body; @@ -2988,6 +3023,7 @@ function loadOpenAISettings(data, settings) { $(`#model_perplexity_select option[value="${oai_settings.perplexity_model}"`).attr('selected', true); $('#model_groq_select').val(oai_settings.groq_model); $(`#model_groq_select option[value="${oai_settings.groq_model}"`).attr('selected', true); + $('#model_01ai_select').val(oai_settings.zerooneai_model); $('#custom_model_id').val(oai_settings.custom_model); $('#custom_api_url_text').val(oai_settings.custom_url); $('#openai_max_context').val(oai_settings.openai_max_context); @@ -3240,6 +3276,7 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) { cohere_model: settings.cohere_model, perplexity_model: settings.perplexity_model, groq_model: settings.groq_model, + zerooneai_model: settings.zerooneai_model, custom_model: settings.custom_model, custom_url: settings.custom_url, custom_include_body: settings.custom_include_body, @@ -3328,6 +3365,7 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) { } } else { toastr.error('Failed to save preset'); + throw new Error('Failed to save preset'); } } @@ -3640,6 +3678,7 @@ function onSettingsPresetChange() { cohere_model: ['#model_cohere_select', 'cohere_model', false], perplexity_model: ['#model_perplexity_select', 'perplexity_model', false], groq_model: ['#model_groq_select', 'groq_model', false], + zerooneai_model: ['#model_01ai_select', 'zerooneai_model', false], custom_model: ['#custom_model_id', 'custom_model', false], custom_url: ['#custom_api_url_text', 'custom_url', false], custom_include_body: ['#custom_include_body', 'custom_include_body', false], @@ -3882,6 +3921,11 @@ async function onModelChange() { oai_settings.groq_model = value; } + if ($(this).is('#model_01ai_select')) { + console.log('01.AI model changed to', value); + oai_settings.zerooneai_model = value; + } + if (value && $(this).is('#model_custom_select')) { console.log('Custom model changed to', value); oai_settings.custom_model = value; @@ -3997,7 +4041,9 @@ async function onModelChange() { } if (oai_settings.chat_completion_source === chat_completion_sources.MISTRALAI) { - if (oai_settings.mistralai_model.includes('mixtral-8x22b')) { + if (oai_settings.max_context_unlocked) { + $('#openai_max_context').attr('max', unlocked_max); + } else if (oai_settings.mistralai_model.includes('mixtral-8x22b')) { $('#openai_max_context').attr('max', max_64k); } else { $('#openai_max_context').attr('max', max_32k); @@ -4120,6 +4166,20 @@ async function onModelChange() { $('#temp_openai').attr('max', oai_max_temp).val(oai_settings.temp_openai).trigger('input'); } + if (oai_settings.chat_completion_source === chat_completion_sources.ZEROONEAI) { + if (oai_settings.max_context_unlocked) { + $('#openai_max_context').attr('max', unlocked_max); + } else { + $('#openai_max_context').attr('max', max_16k); + } + + oai_settings.openai_max_context = Math.min(oai_settings.openai_max_context, Number($('#openai_max_context').attr('max'))); + $('#openai_max_context').val(oai_settings.openai_max_context).trigger('input'); + + oai_settings.temp_openai = Math.min(oai_max_temp, oai_settings.temp_openai); + $('#temp_openai').attr('max', oai_max_temp).val(oai_settings.temp_openai).trigger('input'); + } + $('#openai_max_context_counter').attr('max', Number($('#openai_max_context').attr('max'))); saveSettingsDebounced(); @@ -4314,6 +4374,19 @@ async function onConnectButtonClick(e) { } } + if (oai_settings.chat_completion_source == chat_completion_sources.ZEROONEAI) { + const api_key_01ai = String($('#api_key_01ai').val()).trim(); + + if (api_key_01ai.length) { + await writeSecret(SECRET_KEYS.ZEROONEAI, api_key_01ai); + } + + if (!secret_state[SECRET_KEYS.ZEROONEAI]) { + console.log('No secret key saved for 01.AI'); + return; + } + } + startStatusLoading(); saveSettingsDebounced(); await getStatusOpen(); @@ -4358,6 +4431,9 @@ function toggleChatCompletionForms() { else if (oai_settings.chat_completion_source == chat_completion_sources.GROQ) { $('#model_groq_select').trigger('change'); } + else if (oai_settings.chat_completion_source == chat_completion_sources.ZEROONEAI) { + $('#model_01ai_select').trigger('change'); + } else if (oai_settings.chat_completion_source == chat_completion_sources.CUSTOM) { $('#model_custom_select').trigger('change'); } @@ -5062,6 +5138,7 @@ $(document).ready(async function () { $('#model_cohere_select').on('change', onModelChange); $('#model_perplexity_select').on('change', onModelChange); $('#model_groq_select').on('change', onModelChange); + $('#model_01ai_select').on('change', onModelChange); $('#model_custom_select').on('change', onModelChange); $('#settings_preset_openai').on('change', onSettingsPresetChange); $('#new_oai_preset').on('click', onNewPresetClick); diff --git a/public/scripts/personas.js b/public/scripts/personas.js index 0ea2d5486..3ed28b95e 100644 --- a/public/scripts/personas.js +++ b/public/scripts/personas.js @@ -590,7 +590,37 @@ function selectCurrentPersona() { } } -async function lockUserNameToChat() { +/** + * Checks if the persona is locked for the current chat. + * @returns {boolean} Whether the persona is locked + */ +function isPersonaLocked() { + return !!chat_metadata['persona']; +} + +/** + * Locks or unlocks the persona for the current chat. + * @param {boolean} state Desired lock state + * @returns {Promise<void>} + */ +export async function setPersonaLockState(state) { + return state ? await lockPersona() : await unlockPersona(); +} + +/** + * Toggle the persona lock state for the current chat. + * @returns {Promise<void>} + */ +export async function togglePersonaLock() { + return isPersonaLocked() + ? await unlockPersona() + : await lockPersona(); +} + +/** + * Unlock the persona for the current chat. + */ +async function unlockPersona() { if (chat_metadata['persona']) { console.log(`Unlocking persona for this chat ${chat_metadata['persona']}`); delete chat_metadata['persona']; @@ -599,9 +629,13 @@ async function lockUserNameToChat() { toastr.info('User persona is now unlocked for this chat. Click the "Lock" again to revert.', 'Persona unlocked'); } updateUserLockIcon(); - return; } +} +/** + * Lock the persona for the current chat. + */ +async function lockPersona() { if (!(user_avatar in power_user.personas)) { console.log(`Creating a new persona ${user_avatar}`); if (power_user.persona_show_notifications) { @@ -625,6 +659,7 @@ async function lockUserNameToChat() { updateUserLockIcon(); } + async function deleteUserAvatar(e) { e?.stopPropagation(); const avatarId = $(this).closest('.avatar-container').find('.avatar').attr('imgfile'); @@ -973,7 +1008,7 @@ export function initPersonas() { $(document).on('click', '.bind_user_name', bindUserNameToPersona); $(document).on('click', '.set_default_persona', setDefaultPersona); $(document).on('click', '.delete_avatar', deleteUserAvatar); - $('#lock_user_name').on('click', lockUserNameToChat); + $('#lock_user_name').on('click', togglePersonaLock); $('#create_dummy_persona').on('click', createDummyPersona); $('#persona_description').on('input', onPersonaDescriptionInput); $('#persona_description_position').on('input', onPersonaDescriptionPositionInput); diff --git a/public/scripts/popup.js b/public/scripts/popup.js index ddd7dd5d0..944af3ae7 100644 --- a/public/scripts/popup.js +++ b/public/scripts/popup.js @@ -1,3 +1,4 @@ +import { shouldSendOnEnter } from './RossAscends-mods.js'; import { power_user } from './power-user.js'; import { removeFromArray, runAfterAnimation, uuidv4 } from './utils.js'; @@ -35,8 +36,10 @@ export const POPUP_RESULT = { * @property {boolean?} [transparent=false] - Whether to display the popup in transparent mode (no background, border, shadow or anything, only its content) * @property {boolean?} [allowHorizontalScrolling=false] - Whether to allow horizontal scrolling in the popup * @property {boolean?} [allowVerticalScrolling=false] - Whether to allow vertical scrolling in the popup + * @property {'slow'|'fast'|'none'?} [animation='slow'] - Animation speed for the popup (opening, closing, ...) * @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`. * @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. + * @property {CustomPopupInput[]?} [customInputs=null] - Custom inputs to add to the popup. The display below the content and the input box, one by one. * @property {(popup: Popup) => boolean?} [onClosing=null] - Handler called before the popup closes, return `false` to cancel the close * @property {(popup: Popup) => void?} [onClose=null] - Handler called after the popup closes, but before the DOM is cleaned up * @property {number?} [cropAspect=null] - Aspect ratio for the crop popup @@ -52,6 +55,14 @@ export const POPUP_RESULT = { * @property {boolean?} [appendAtEnd] - Whether to append the button to the end of the popup - by default it will be prepended */ +/** + * @typedef {object} CustomPopupInput + * @property {string} id - The id for the html element + * @property {string} label - The label text for the input + * @property {string?} [tooltip=null] - Optional tooltip icon displayed behind the label + * @property {boolean?} [defaultState=false] - The default state when opening the popup (false if not set) + */ + /** * @typedef {object} ShowPopupHelper * Local implementation of the helper functionality to show several popups. @@ -78,8 +89,8 @@ const showPopupHelper = { /** * Asynchronously displays a confirmation popup with the given header and text, returning the clicked result button value. * - * @param {string} header - The header text for the popup. - * @param {string} text - The main text for the popup. + * @param {string?} header - The header text for the popup. + * @param {string?} text - The main text for the popup. * @param {PopupOptions} [popupOptions={}] - Options for the popup. * @return {Promise<POPUP_RESULT>} A Promise that resolves with the result of the user's interaction. */ @@ -89,38 +100,41 @@ const showPopupHelper = { const result = await popup.show(); if (typeof result === 'string' || typeof result === 'boolean') throw new Error(`Invalid popup result. CONFIRM popups only support numbers, or null. Result: ${result}`); return result; - } + }, }; export class Popup { - /** @type {POPUP_TYPE} */ type; + /** @readonly @type {POPUP_TYPE} */ type; - /** @type {string} */ id; + /** @readonly @type {string} */ id; - /** @type {HTMLDialogElement} */ dlg; - /** @type {HTMLElement} */ body; - /** @type {HTMLElement} */ content; - /** @type {HTMLTextAreaElement} */ input; - /** @type {HTMLElement} */ controls; - /** @type {HTMLElement} */ okButton; - /** @type {HTMLElement} */ cancelButton; - /** @type {HTMLElement} */ closeButton; - /** @type {HTMLElement} */ cropWrap; - /** @type {HTMLImageElement} */ cropImage; - /** @type {POPUP_RESULT|number?} */ defaultResult; - /** @type {CustomPopupButton[]|string[]?} */ customButtons; + /** @readonly @type {HTMLDialogElement} */ dlg; + /** @readonly @type {HTMLDivElement} */ body; + /** @readonly @type {HTMLDivElement} */ content; + /** @readonly @type {HTMLTextAreaElement} */ mainInput; + /** @readonly @type {HTMLDivElement} */ inputControls; + /** @readonly @type {HTMLDivElement} */ buttonControls; + /** @readonly @type {HTMLDivElement} */ okButton; + /** @readonly @type {HTMLDivElement} */ cancelButton; + /** @readonly @type {HTMLDivElement} */ closeButton; + /** @readonly @type {HTMLDivElement} */ cropWrap; + /** @readonly @type {HTMLImageElement} */ cropImage; + /** @readonly @type {POPUP_RESULT|number?} */ defaultResult; + /** @readonly @type {CustomPopupButton[]|string[]?} */ customButtons; + /** @readonly @type {CustomPopupInput[]} */ customInputs; /** @type {(popup: Popup) => boolean?} */ onClosing; /** @type {(popup: Popup) => void?} */ onClose; /** @type {POPUP_RESULT|number} */ result; /** @type {any} */ value; + /** @type {Map<string,boolean>?} */ inputResults; /** @type {any} */ cropData; /** @type {HTMLElement} */ lastFocus; - /** @type {Promise<any>} */ promise; - /** @type {(result: any) => any} */ resolver; + /** @type {Promise<any>} */ #promise; + /** @type {(result: any) => any} */ #resolver; /** * Constructs a new Popup object with the given text content, type, inputValue, and options @@ -130,7 +144,7 @@ export class Popup { * @param {string} [inputValue=''] - The initial value of the input field * @param {PopupOptions} [options={}] - Additional options for the popup */ - constructor(content, type, inputValue = '', { okButton = null, cancelButton = null, rows = 1, wide = false, wider = false, large = false, transparent = false, allowHorizontalScrolling = false, allowVerticalScrolling = false, defaultResult = POPUP_RESULT.AFFIRMATIVE, customButtons = null, onClosing = null, onClose = null, cropAspect = null, cropImage = null } = {}) { + constructor(content, type, inputValue = '', { okButton = null, cancelButton = null, rows = 1, wide = false, wider = false, large = false, transparent = false, allowHorizontalScrolling = false, allowVerticalScrolling = false, animation = 'fast', defaultResult = POPUP_RESULT.AFFIRMATIVE, customButtons = null, customInputs = null, onClosing = null, onClose = null, cropAspect = null, cropImage = null } = {}) { Popup.util.popups.push(this); // Make this popup uniquely identifiable @@ -147,8 +161,9 @@ export class Popup { this.dlg = template.content.cloneNode(true).querySelector('.popup'); this.body = this.dlg.querySelector('.popup-body'); this.content = this.dlg.querySelector('.popup-content'); - this.input = this.dlg.querySelector('.popup-input'); - this.controls = this.dlg.querySelector('.popup-controls'); + this.mainInput = this.dlg.querySelector('.popup-input'); + this.inputControls = this.dlg.querySelector('.popup-inputs'); + this.buttonControls = this.dlg.querySelector('.popup-controls'); this.okButton = this.dlg.querySelector('.popup-button-ok'); this.cancelButton = this.dlg.querySelector('.popup-button-cancel'); this.closeButton = this.dlg.querySelector('.popup-button-close'); @@ -162,10 +177,13 @@ export class Popup { if (transparent) this.dlg.classList.add('transparent_dialogue_popup'); if (allowHorizontalScrolling) this.dlg.classList.add('horizontal_scrolling_dialogue_popup'); if (allowVerticalScrolling) this.dlg.classList.add('vertical_scrolling_dialogue_popup'); + if (animation) this.dlg.classList.add('popup--animation-' + animation); // If custom button captions are provided, we set them beforehand this.okButton.textContent = typeof okButton === 'string' ? okButton : 'OK'; + this.okButton.dataset.i18n = this.okButton.textContent; this.cancelButton.textContent = typeof cancelButton === 'string' ? cancelButton : template.getAttribute('popup-button-cancel'); + this.cancelButton.dataset.i18n = this.cancelButton.textContent; this.defaultResult = defaultResult; this.customButtons = customButtons; @@ -178,12 +196,13 @@ export class Popup { buttonElement.classList.add(...(button.classes ?? [])); buttonElement.dataset.result = String(button.result ?? undefined); buttonElement.textContent = button.text; + buttonElement.dataset.i18n = buttonElement.textContent; buttonElement.tabIndex = 0; if (button.appendAtEnd) { - this.controls.appendChild(buttonElement); + this.buttonControls.appendChild(buttonElement); } else { - this.controls.insertBefore(buttonElement, this.okButton); + this.buttonControls.insertBefore(buttonElement, this.okButton); } if (typeof button.action === 'function') { @@ -191,13 +210,45 @@ export class Popup { } }); + this.customInputs = customInputs; + this.customInputs?.forEach(input => { + if (!input.id || !(typeof input.id === 'string')) { + console.warn('Given custom input does not have a valid id set'); + return; + } + + const label = document.createElement('label'); + label.classList.add('checkbox_label', 'justifyCenter'); + label.setAttribute('for', input.id); + const inputElement = document.createElement('input'); + inputElement.type = 'checkbox'; + inputElement.id = input.id; + inputElement.checked = input.defaultState ?? false; + label.appendChild(inputElement); + const labelText = document.createElement('span'); + labelText.innerText = input.label; + labelText.dataset.i18n = input.label; + label.appendChild(labelText); + + if (input.tooltip) { + const tooltip = document.createElement('div'); + tooltip.classList.add('fa-solid', 'fa-circle-info', 'opacity50p'); + tooltip.title = input.tooltip; + tooltip.dataset.i18n = '[title]' + input.tooltip; + label.appendChild(tooltip); + } + + this.inputControls.appendChild(label); + }); + // Set the default button class - const defaultButton = this.controls.querySelector(`[data-result="${this.defaultResult}"]`); + const defaultButton = this.buttonControls.querySelector(`[data-result="${this.defaultResult}"]`); if (defaultButton) defaultButton.classList.add('menu_button_default'); // Styling differences depending on the popup type // General styling for all types first, that might be overriden for specific types below - this.input.style.display = 'none'; + this.mainInput.style.display = 'none'; + this.inputControls.style.display = customInputs ? 'block' : 'none'; this.closeButton.style.display = 'none'; this.cropWrap.style.display = 'none'; @@ -212,12 +263,12 @@ export class Popup { break; } case POPUP_TYPE.INPUT: { - this.input.style.display = 'block'; + this.mainInput.style.display = 'block'; if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-save'); break; } case POPUP_TYPE.DISPLAY: { - this.controls.style.display = 'none'; + this.buttonControls.style.display = 'none'; this.closeButton.style.display = 'block'; break; } @@ -243,8 +294,8 @@ export class Popup { } } - this.input.value = inputValue; - this.input.rows = rows ?? 1; + this.mainInput.value = inputValue; + this.mainInput.rows = rows ?? 1; this.content.innerHTML = ''; if (content instanceof jQuery) { @@ -270,20 +321,20 @@ export class Popup { if (String(undefined) === String(resultControl.dataset.result)) return; if (isNaN(result)) throw new Error('Invalid result control. Result must be a number. ' + resultControl.dataset.result); const type = resultControl.dataset.resultEvent || 'click'; - resultControl.addEventListener(type, () => this.complete(result)); + resultControl.addEventListener(type, async () => await this.complete(result)); }); // Bind dialog listeners manually, so we can be sure context is preserved - const cancelListener = (evt) => { - this.complete(POPUP_RESULT.CANCELLED); + const cancelListener = async (evt) => { evt.preventDefault(); evt.stopPropagation(); + await this.complete(POPUP_RESULT.CANCELLED); window.removeEventListener('cancel', cancelListenerBound); }; const cancelListenerBound = cancelListener.bind(this); this.dlg.addEventListener('cancel', cancelListenerBound); - const keyListener = (evt) => { + const keyListener = async (evt) => { switch (evt.key) { case 'Enter': { // CTRL+Enter counts as a closing action, but all other modifiers (ALT, SHIFT) should not trigger this @@ -294,15 +345,23 @@ export class Popup { if (this.dlg != document.activeElement?.closest('.popup')) return; - // Check if the current focus is a result control. Only should we apply the compelete action + // Check if the current focus is a result control. Only should we apply the complete action const resultControl = document.activeElement?.closest('.result-control'); if (!resultControl) return; - const result = Number(document.activeElement.getAttribute('data-result') ?? this.defaultResult); - this.complete(result); + // Check if we are inside an input type text or a textarea field and send on enter is disabled + const textarea = document.activeElement?.closest('textarea'); + if (textarea instanceof HTMLTextAreaElement && !shouldSendOnEnter()) + return; + const input = document.activeElement?.closest('input[type="text"]'); + if (input instanceof HTMLInputElement && !shouldSendOnEnter()) + return; + evt.preventDefault(); evt.stopPropagation(); + const result = Number(document.activeElement.getAttribute('data-result') ?? this.defaultResult); + await this.complete(result); window.removeEventListener('keydown', keyListenerBound); break; @@ -335,10 +394,10 @@ export class Popup { this.dlg.removeAttribute('opening'); }); - this.promise = new Promise((resolve) => { - this.resolver = resolve; + this.#promise = new Promise((resolve) => { + this.#resolver = resolve; }); - return this.promise; + return this.#promise; } setAutoFocus({ applyAutoFocus = false } = {}) { @@ -352,12 +411,12 @@ export class Popup { if (!control) { switch (this.type) { case POPUP_TYPE.INPUT: { - control = this.input; + control = this.mainInput; break; } default: // Select default button - control = this.controls.querySelector(`[data-result="${this.defaultResult}"]`); + control = this.buttonControls.querySelector(`[data-result="${this.defaultResult}"]`); break; } } @@ -382,14 +441,16 @@ export class Popup { * - All other will return the result value as provided as `POPUP_RESULT` or a custom number value * * @param {POPUP_RESULT|number} result - The result of the popup (either an existing `POPUP_RESULT` or a custom result value) + * + * @returns {Promise<string|number|boolean?>} A promise that resolves with the value of the popup when it is completed. */ - complete(result) { + async complete(result) { // In all cases besides INPUT the popup value should be the result /** @type {POPUP_RESULT|number|boolean|string?} */ let value = result; // Input type have special results, so the input can be accessed directly without the need to save the popup and access both result and value if (this.type === POPUP_TYPE.INPUT) { - if (result >= POPUP_RESULT.AFFIRMATIVE) value = this.input.value; + if (result >= POPUP_RESULT.AFFIRMATIVE) value = this.mainInput.value; else if (result === POPUP_RESULT.NEGATIVE) value = false; else if (result === POPUP_RESULT.CANCELLED) value = null; else value = false; // Might a custom negative value? @@ -402,6 +463,14 @@ export class Popup { : null; } + if (this.customInputs?.length) { + this.inputResults = new Map(this.customInputs.map(input => { + /** @type {HTMLInputElement} */ + const inputControl = this.dlg.querySelector(`#${input.id}`); + return [inputControl.id, inputControl.checked]; + })); + } + this.value = value; this.result = result; @@ -410,15 +479,16 @@ export class Popup { if (!shouldClose) return; } - Popup.util.lastResult = { value, result }; - this.hide(); + Popup.util.lastResult = { value, result, inputResults: this.inputResults }; + this.#hide(); + + return this.#promise; } /** * Hides the popup, using the internal resolver to return the value to the original show promise - * @private */ - hide() { + #hide() { // We close the dialog, first running the animation this.dlg.setAttribute('closing', ''); @@ -451,9 +521,9 @@ export class Popup { else popup.setAutoFocus(); } } - }); - this.resolver(this.value); + this.#resolver(this.value); + }); } /** @@ -467,10 +537,10 @@ export class Popup { * Contains the list of all currently open popups, and it'll remember the result of the last closed popup. */ static util = { - /** @type {Popup[]} Remember all popups */ + /** @readonly @type {Popup[]} Remember all popups */ popups: [], - /** @type {{value: any, result: POPUP_RESULT|number?}?} Last popup result */ + /** @type {{value: any, result: POPUP_RESULT|number?, inputResults: Map<string, boolean>?}?} Last popup result */ lastResult: null, /** @returns {boolean} Checks if any modal popup dialog is open */ @@ -491,9 +561,17 @@ export class Popup { } class PopupUtils { + /** + * Builds popup content with header and text below + * + * @param {string} header - The header to be added to the text + * @param {string} text - The main text content + */ static BuildTextWithHeader(header, text) { - return ` - <h3>${header}</h1> + if (!header) { + return text; + } + return `<h3>${header}</h3> ${text}`; } } @@ -555,8 +633,13 @@ export function fixToastrForDialogs() { // Now another case is if we only have one popup and that is currently closing. In that case the toastr container exists, // but we don't have an open dialog to move it into. It's just inside the existing one that will be gone in milliseconds. // To prevent new toasts from being showing up in there and then vanish in an instant, - // we move the toastr back to the main body + // we move the toastr back to the main body, or delete if its empty if (!dlg && isAlreadyPresent) { - document.body.appendChild(toastContainer); + if (!toastContainer.childNodes.length) { + toastContainer.remove(); + } else { + document.body.appendChild(toastContainer); + toastContainer.classList.add('toast-top-center'); + } } } diff --git a/public/scripts/power-user.js b/public/scripts/power-user.js index 654e479b2..e484d2d99 100644 --- a/public/scripts/power-user.js +++ b/public/scripts/power-user.js @@ -22,6 +22,7 @@ import { setActiveGroup, setActiveCharacter, entitiesFilter, + doNewChat, } from '../script.js'; import { isMobile, initMovingUI, favsToHotswap } from './RossAscends-mods.js'; import { @@ -39,11 +40,11 @@ import { tokenizers } from './tokenizers.js'; import { BIAS_CACHE } from './logit-bias.js'; import { renderTemplateAsync } from './templates.js'; -import { countOccurrences, debounce, delay, download, getFileText, isOdd, onlyUnique, resetScrollHeight, shuffle, sortMoments, stringToRange, timestampToMoment } from './utils.js'; +import { countOccurrences, debounce, delay, download, getFileText, getStringHash, isOdd, isTrueBoolean, onlyUnique, resetScrollHeight, shuffle, sortMoments, stringToRange, timestampToMoment } from './utils.js'; import { FILTER_TYPES } from './filters.js'; import { PARSER_FLAG, SlashCommandParser } from './slash-commands/SlashCommandParser.js'; import { SlashCommand } from './slash-commands/SlashCommand.js'; -import { ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js'; +import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js'; import { AUTOCOMPLETE_WIDTH } from './autocomplete/AutoComplete.js'; import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js'; import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js'; @@ -178,6 +179,7 @@ let power_user = { send_on_enter: send_on_enter_options.AUTO, console_log_prompts: false, request_token_probabilities: false, + show_group_chat_queue: false, render_formulas: false, allow_name1_display: false, allow_name2_display: false, @@ -334,6 +336,8 @@ const storage_keys = { compact_input_area: 'compact_input_area', auto_connect_legacy: 'AutoConnectEnabled', auto_load_chat_legacy: 'AutoLoadChatEnabled', + + storyStringValidationCache: 'StoryStringValidationCache', }; const contextControls = [ @@ -1598,6 +1602,7 @@ function loadPowerUserSettings(settings, data) { $('#console_log_prompts').prop('checked', power_user.console_log_prompts); $('#request_token_probabilities').prop('checked', power_user.request_token_probabilities); + $('#show_group_chat_queue').prop('checked', power_user.show_group_chat_queue); $('#auto_fix_generated_markdown').prop('checked', power_user.auto_fix_generated_markdown); $('#auto_scroll_chat_to_bottom').prop('checked', power_user.auto_scroll_chat_to_bottom); $('#bogus_folders').prop('checked', power_user.bogus_folders); @@ -2104,6 +2109,9 @@ export function fuzzySearchGroups(searchValue) { */ export function renderStoryString(params) { try { + // Validate and log possible warnings/errors + validateStoryString(power_user.context.story_string, params); + // compile the story string template into a function, with no HTML escaping const compiledTemplate = Handlebars.compile(power_user.context.story_string, { noEscape: true }); @@ -2131,6 +2139,55 @@ export function renderStoryString(params) { } } +/** + * Validate the story string for possible warnings or issues + * + * @param {string} storyString - The story string + * @param {Object} params - The story string parameters + */ +function validateStoryString(storyString, params) { + /** @type {{hashCache: {[hash: string]: {fieldsWarned: {[key: string]: boolean}}}}} */ + const cache = JSON.parse(localStorage.getItem(storage_keys.storyStringValidationCache)) ?? { hashCache: {} }; + + const hash = getStringHash(storyString); + + // Initialize the cache for the current hash if it doesn't exist + if (!cache.hashCache[hash]) { + cache.hashCache[hash] = { fieldsWarned: {} }; + } + + const currentCache = cache.hashCache[hash]; + const fieldsToWarn = []; + + function validateMissingField(field, fallbackLegacyField = null) { + const contains = storyString.includes(`{{${field}}}`) || (!!fallbackLegacyField && storyString.includes(`{{${fallbackLegacyField}}}`)); + if (!contains && params[field]) { + const wasLogged = currentCache.fieldsWarned[field]; + if (!wasLogged) { + fieldsToWarn.push(field); + currentCache.fieldsWarned[field] = true; + } + console.warn(`The story string does not contain {{${field}}}, but it would contain content:\n`, params[field]); + } + } + + validateMissingField('description'); + validateMissingField('personality'); + validateMissingField('persona'); + validateMissingField('scenario'); + validateMissingField('system'); + validateMissingField('wiBefore', 'loreBefore'); + validateMissingField('wiAfter', 'loreAfter'); + + if (fieldsToWarn.length > 0) { + const fieldsList = fieldsToWarn.map(field => `{{${field}}}`).join(', '); + toastr.warning(`The story string does not contain the following fields, but they would contain content: ${fieldsList}`, 'Story String Validation'); + } + + localStorage.setItem(storage_keys.storyStringValidationCache, JSON.stringify(cache)); +} + + const sortFunc = (a, b) => power_user.sort_order == 'asc' ? compareFunc(a, b) : compareFunc(b, a); const compareFunc = (first, second) => { const a = first[power_user.sort_field]; @@ -2315,26 +2372,30 @@ async function saveTheme(name = undefined, theme = undefined) { body: JSON.stringify(theme), }); - if (response.ok) { - const themeIndex = themes.findIndex(x => x.name == name); - - if (themeIndex == -1) { - themes.push(theme); - const option = document.createElement('option'); - option.selected = true; - option.value = name; - option.innerText = name; - $('#themes').append(option); - } - else { - themes[themeIndex] = theme; - $(`#themes option[value="${name}"]`).attr('selected', true); - } - - power_user.theme = name; - saveSettingsDebounced(); + if (!response.ok) { + toastr.error('Check the server connection and reload the page to prevent data loss.', 'Theme could not be saved'); + console.error('Theme could not be saved', response); + throw new Error('Theme could not be saved'); } + const themeIndex = themes.findIndex(x => x.name == name); + + if (themeIndex == -1) { + themes.push(theme); + const option = document.createElement('option'); + option.selected = true; + option.value = name; + option.innerText = name; + $('#themes').append(option); + } + else { + themes[themeIndex] = theme; + $(`#themes option[value="${name}"]`).attr('selected', true); + } + + power_user.theme = name; + saveSettingsDebounced(); + return theme; } @@ -2400,12 +2461,14 @@ function getNewTheme(parsed) { } async function saveMovingUI() { - const name = await callGenericPopup('Enter a name for the MovingUI Preset:', POPUP_TYPE.INPUT); + const popupResult = await callGenericPopup('Enter a name for the MovingUI Preset:', POPUP_TYPE.INPUT); - if (!name) { + if (!popupResult) { return; } + const name = String(popupResult); + const movingUIPreset = { name, movingUIState: power_user.movingUIState, @@ -2437,7 +2500,8 @@ async function saveMovingUI() { power_user.movingUIPreset = name; saveSettingsDebounced(); } else { - toastr.warning('failed to save MovingUI state.'); + toastr.error('Failed to save MovingUI state.'); + console.error('MovingUI could not be saved', response); } } @@ -2530,14 +2594,6 @@ async function resetMovablePanels(type) { }); } -async function doNewChat() { - $('#option_start_new_chat').trigger('click'); - await delay(1); - $('#dialogue_popup_ok').trigger('click'); - await delay(1); - return ''; -} - /** * Finds the ID of the tag with the given name. * @param {string} name @@ -3513,6 +3569,11 @@ $(document).ready(() => { saveSettingsDebounced(); }); + $('#show_group_chat_queue').on('input', function () { + power_user.show_group_chat_queue = !!$(this).prop('checked'); + saveSettingsDebounced(); + }); + $('#auto_scroll_chat_to_bottom').on('input', function () { power_user.auto_scroll_chat_to_bottom = !!$(this).prop('checked'); saveSettingsDebounced(); @@ -3926,7 +3987,20 @@ $(document).ready(() => { })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'newchat', - callback: doNewChat, + /** @type {(args: { delete: string?}, string) => Promise<''>} */ + callback: async (args, _) => { + await doNewChat({ deleteCurrentChat: isTrueBoolean(args.delete) }); + return ''; + }, + namedArgumentList: [ + SlashCommandNamedArgument.fromProps({ + name: 'delete', + description: 'delete the current chat', + typeList: [ARGUMENT_TYPE.BOOLEAN], + defaultValue: 'false', + enumList: commonEnumProviders.boolean('trueFalse')(), + }), + ], helpString: 'Start a new chat with the current character', })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ diff --git a/public/scripts/preset-manager.js b/public/scripts/preset-manager.js index 9043e8a91..90fe232ba 100644 --- a/public/scripts/preset-manager.js +++ b/public/scripts/preset-manager.js @@ -182,17 +182,19 @@ class PresetManager { async savePreset(name, settings) { const preset = settings ?? this.getPresetSettings(name); - const res = await fetch('/api/presets/save', { + const response = await fetch('/api/presets/save', { method: 'POST', headers: getRequestHeaders(), body: JSON.stringify({ preset, name, apiId: this.apiId }), }); - if (!res.ok) { - toastr.error('Failed to save preset'); + if (!response.ok) { + toastr.error('Check the server connection and reload the page to prevent data loss.', 'Preset could not be saved'); + console.error('Preset could not be saved', response); + throw new Error('Preset could not be saved'); } - const data = await res.json(); + const data = await response.json(); name = data.name; this.updateList(name, preset); @@ -327,6 +329,7 @@ class PresetManager { 'infermaticai_model', 'dreamgen_model', 'openrouter_model', + 'featherless_model', 'max_tokens_second', 'openrouter_providers', ]; diff --git a/public/scripts/secrets.js b/public/scripts/secrets.js index cb4477a78..308e3c722 100644 --- a/public/scripts/secrets.js +++ b/public/scripts/secrets.js @@ -28,6 +28,10 @@ export const SECRET_KEYS = { PERPLEXITY: 'api_key_perplexity', GROQ: 'api_key_groq', AZURE_TTS: 'api_key_azure_tts', + FEATHERLESS: 'api_key_featherless', + ZEROONEAI: 'api_key_01ai', + HUGGINGFACE: 'api_key_huggingface', + STABILITY: 'api_key_stability', }; const INPUT_MAP = { @@ -56,6 +60,9 @@ const INPUT_MAP = { [SECRET_KEYS.COHERE]: '#api_key_cohere', [SECRET_KEYS.PERPLEXITY]: '#api_key_perplexity', [SECRET_KEYS.GROQ]: '#api_key_groq', + [SECRET_KEYS.FEATHERLESS]: '#api_key_featherless', + [SECRET_KEYS.ZEROONEAI]: '#api_key_01ai', + [SECRET_KEYS.HUGGINGFACE]: '#api_key_huggingface', }; async function clearSecret() { diff --git a/public/scripts/slash-commands.js b/public/scripts/slash-commands.js index 8f3a1baa7..bbd8934ba 100644 --- a/public/scripts/slash-commands.js +++ b/public/scripts/slash-commands.js @@ -33,27 +33,27 @@ import { setCharacterName, setExtensionPrompt, setUserName, + stopGeneration, substituteParams, system_avatar, system_message_types, this_chid, } from '../script.js'; -import { PARSER_FLAG, SlashCommandParser } from './slash-commands/SlashCommandParser.js'; +import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; import { SlashCommandParserError } from './slash-commands/SlashCommandParserError.js'; import { getMessageTimeStamp } from './RossAscends-mods.js'; import { hideChatMessageRange } from './chats.js'; -import { extension_settings, getContext, saveMetadataDebounced } from './extensions.js'; +import { getContext, saveMetadataDebounced } from './extensions.js'; import { getRegexedString, regex_placement } from './extensions/regex/engine.js'; -import { findGroupMemberId, getGroupMembers, groups, is_group_generating, openGroupById, resetSelectedGroup, saveGroupChat, selected_group } from './group-chats.js'; +import { findGroupMemberId, groups, is_group_generating, openGroupById, resetSelectedGroup, saveGroupChat, selected_group } from './group-chats.js'; import { chat_completion_sources, oai_settings, setupChatCompletionPromptManager } from './openai.js'; -import { autoSelectPersona, retriggerFirstMessageOnEmptyChat, user_avatar } from './personas.js'; +import { autoSelectPersona, retriggerFirstMessageOnEmptyChat, setPersonaLockState, togglePersonaLock, user_avatar } from './personas.js'; import { addEphemeralStoppingString, chat_styles, flushEphemeralStoppingStrings, power_user } from './power-user.js'; import { textgen_types, textgenerationwebui_settings } from './textgen-settings.js'; import { decodeTextTokens, getFriendlyTokenizerName, getTextTokens, getTokenCountAsync } from './tokenizers.js'; import { debounce, delay, isFalseBoolean, isTrueBoolean, stringToRange, trimToEndSentence, trimToStartSentence, waitUntilCondition } from './utils.js'; import { registerVariableCommands, resolveVariable } from './variables.js'; import { background_settings } from './backgrounds.js'; -import { SlashCommandScope } from './slash-commands/SlashCommandScope.js'; import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js'; import { SlashCommandClosureResult } from './slash-commands/SlashCommandClosureResult.js'; import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js'; @@ -75,69 +75,79 @@ export const parser = new SlashCommandParser(); const registerSlashCommand = SlashCommandParser.addCommand.bind(SlashCommandParser); const getSlashCommandsHelp = parser.getHelpString.bind(parser); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: '?', - callback: helpCommandCallback, - aliases: ['help'], - unnamedArgumentList: [SlashCommandArgument.fromProps({ - description: 'help topic', - typeList: [ARGUMENT_TYPE.STRING], - enumList: [ - new SlashCommandEnumValue('slash', 'slash commands (STscript)', enumTypes.command, '/'), - new SlashCommandEnumValue('macros', '{{macros}} (text replacement)', enumTypes.macro, enumIcons.macro), - new SlashCommandEnumValue('format', 'chat/text formatting', enumTypes.name, '★'), - new SlashCommandEnumValue('hotkeys', 'keyboard shortcuts', enumTypes.enum, '⏎'), +export function initDefaultSlashCommands() { + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: '?', + callback: helpCommandCallback, + aliases: ['help'], + unnamedArgumentList: [SlashCommandArgument.fromProps({ + description: 'help topic', + typeList: [ARGUMENT_TYPE.STRING], + enumList: [ + new SlashCommandEnumValue('slash', 'slash commands (STscript)', enumTypes.command, '/'), + new SlashCommandEnumValue('macros', '{{macros}} (text replacement)', enumTypes.macro, enumIcons.macro), + new SlashCommandEnumValue('format', 'chat/text formatting', enumTypes.name, '★'), + new SlashCommandEnumValue('hotkeys', 'keyboard shortcuts', enumTypes.enum, '⏎'), + ], + })], + helpString: 'Get help on macros, chat formatting and commands.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'persona', + callback: setNameCallback, + namedArgumentList: [ + new SlashCommandNamedArgument( + 'mode', 'The mode for persona selection. ("lookup" = search for existing persona, "temp" = create a temporary name, set a temporary name, "all" = allow both in the same command)', + [ARGUMENT_TYPE.STRING], false, false, 'all', ['lookup', 'temp', 'all'], + ), ], - })], - helpString: 'Get help on macros, chat formatting and commands.', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'persona', - callback: setNameCallback, - namedArgumentList: [ - new SlashCommandNamedArgument( - 'mode', 'The mode for persona selection. ("lookup" = search for existing persona, "temp" = create a temporary name, set a temporary name, "all" = allow both in the same command)', - [ARGUMENT_TYPE.STRING], false, false, 'all', ['lookup', 'temp', 'all'], - ), - ], - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ - description: 'persona name', - typeList: [ARGUMENT_TYPE.STRING], - isRequired: true, - enumProvider: commonEnumProviders.personas, - }), - ], - helpString: 'Selects the given persona with its name and avatar (by name or avatar url). If no matching persona exists, applies a temporary name.', - aliases: ['name'], -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'sync', - callback: syncCallback, - helpString: 'Syncs the user persona in user-attributed messages in the current chat.', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'lock', - callback: bindCallback, - aliases: ['bind'], - helpString: 'Locks/unlocks a persona (name and avatar) to the current chat', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'bg', - callback: setBackgroundCallback, - aliases: ['background'], - returns: 'the current background', - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ - description: 'filename', - typeList: [ARGUMENT_TYPE.STRING], - isRequired: true, - enumProvider: () => [...document.querySelectorAll('.bg_example')] - .map(it => new SlashCommandEnumValue(it.getAttribute('bgfile'))) - .filter(it => it.value?.length), - }), - ], - helpString: ` + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'persona name', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: true, + enumProvider: commonEnumProviders.personas, + }), + ], + helpString: 'Selects the given persona with its name and avatar (by name or avatar url). If no matching persona exists, applies a temporary name.', + aliases: ['name'], + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'sync', + callback: syncCallback, + helpString: 'Syncs the user persona in user-attributed messages in the current chat.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'lock', + callback: lockPersonaCallback, + aliases: ['bind'], + helpString: 'Locks/unlocks a persona (name and avatar) to the current chat', + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'state', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: true, + defaultValue: 'toggle', + enumProvider: commonEnumProviders.boolean('onOffToggle'), + }), + ], + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'bg', + callback: setBackgroundCallback, + aliases: ['background'], + returns: 'the current background', + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'filename', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: true, + enumProvider: () => [...document.querySelectorAll('.bg_example')] + .map(it => new SlashCommandEnumValue(it.getAttribute('bgfile'))) + .filter(it => it.value?.length), + }), + ], + helpString: ` <div> Sets a background according to the provided filename. Partial names allowed. </div> @@ -150,35 +160,35 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ </ul> </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'sendas', - callback: sendMessageAs, - namedArgumentList: [ - SlashCommandNamedArgument.fromProps({ - name: 'name', - description: 'Character name', - typeList: [ARGUMENT_TYPE.STRING], - isRequired: true, - enumProvider: commonEnumProviders.characters('character'), - forceEnum: false, - }), - new SlashCommandNamedArgument( - 'compact', 'Use compact layout', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false', - ), - SlashCommandNamedArgument.fromProps({ - name: 'at', - description: 'position to insert the message', - typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.VARIABLE_NAME], - enumProvider: commonEnumProviders.messages({ allowIdAfter: true, allowVars: true }), - }), - ], - unnamedArgumentList: [ - new SlashCommandArgument( - 'text', [ARGUMENT_TYPE.STRING], true, - ), - ], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'sendas', + callback: sendMessageAs, + namedArgumentList: [ + SlashCommandNamedArgument.fromProps({ + name: 'name', + description: 'Character name', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: true, + enumProvider: commonEnumProviders.characters('character'), + forceEnum: false, + }), + new SlashCommandNamedArgument( + 'compact', 'Use compact layout', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false', + ), + SlashCommandNamedArgument.fromProps({ + name: 'at', + description: 'position to insert the message (index-based, corresponding to message id). If not set, the message will be inserted at the end of the chat.\nNegative values are accepted and will work similarly to how \'depth\' usually works. For example, -1 will insert the message right before the last message in chat.', + typeList: [ARGUMENT_TYPE.NUMBER], + enumProvider: commonEnumProviders.messages({ allowIdAfter: true }), + }), + ], + unnamedArgumentList: [ + new SlashCommandArgument( + 'text', [ARGUMENT_TYPE.STRING], true, + ), + ], + helpString: ` <div> Sends a message as a specific character. Uses the character avatar if it exists in the characters list. </div> @@ -195,33 +205,33 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ If "compact" is set to true, the message is sent using a compact layout. </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'sys', - callback: sendNarratorMessage, - aliases: ['nar'], - namedArgumentList: [ - new SlashCommandNamedArgument( - 'compact', - 'compact layout', - [ARGUMENT_TYPE.BOOLEAN], - false, - false, - 'false', - ), - SlashCommandNamedArgument.fromProps({ - name: 'at', - description: 'position to insert the message', - typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.VARIABLE_NAME], - enumProvider: commonEnumProviders.messages({ allowIdAfter: true, allowVars: true }), - }), - ], - unnamedArgumentList: [ - new SlashCommandArgument( - 'text', [ARGUMENT_TYPE.STRING], true, - ), - ], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'sys', + callback: sendNarratorMessage, + aliases: ['nar'], + namedArgumentList: [ + new SlashCommandNamedArgument( + 'compact', + 'compact layout', + [ARGUMENT_TYPE.BOOLEAN], + false, + false, + 'false', + ), + SlashCommandNamedArgument.fromProps({ + name: 'at', + description: 'position to insert the message (index-based, corresponding to message id). If not set, the message will be inserted at the end of the chat.\nNegative values are accepted and will work similarly to how \'depth\' usually works. For example, -1 will insert the message right before the last message in chat.', + typeList: [ARGUMENT_TYPE.NUMBER], + enumProvider: commonEnumProviders.messages({ allowIdAfter: true }), + }), + ], + unnamedArgumentList: [ + new SlashCommandArgument( + 'text', [ARGUMENT_TYPE.STRING], true, + ), + ], + helpString: ` <div> Sends a message as a system narrator. </div> @@ -240,44 +250,44 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ </ul> </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'sysname', - callback: setNarratorName, - unnamedArgumentList: [ - new SlashCommandArgument( - 'name', [ARGUMENT_TYPE.STRING], false, - ), - ], - helpString: 'Sets a name for future system narrator messages in this chat (display only). Default: System. Leave empty to reset.', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'comment', - callback: sendCommentMessage, - namedArgumentList: [ - new SlashCommandNamedArgument( - 'compact', - 'Whether to use a compact layout', - [ARGUMENT_TYPE.BOOLEAN], - false, - false, - 'false', - ), - SlashCommandNamedArgument.fromProps({ - name: 'at', - description: 'position to insert the message', - typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.VARIABLE_NAME], - enumProvider: commonEnumProviders.messages({ allowIdAfter: true, allowVars: true }), - }), - ], - unnamedArgumentList: [ - new SlashCommandArgument( - 'text', - [ARGUMENT_TYPE.STRING], - true, - ), - ], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'sysname', + callback: setNarratorName, + unnamedArgumentList: [ + new SlashCommandArgument( + 'name', [ARGUMENT_TYPE.STRING], false, + ), + ], + helpString: 'Sets a name for future system narrator messages in this chat (display only). Default: System. Leave empty to reset.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'comment', + callback: sendCommentMessage, + namedArgumentList: [ + new SlashCommandNamedArgument( + 'compact', + 'Whether to use a compact layout', + [ARGUMENT_TYPE.BOOLEAN], + false, + false, + 'false', + ), + SlashCommandNamedArgument.fromProps({ + name: 'at', + description: 'position to insert the message (index-based, corresponding to message id). If not set, the message will be inserted at the end of the chat.\nNegative values are accepted and will work similarly to how \'depth\' usually works. For example, -1 will insert the message right before the last message in chat.', + typeList: [ARGUMENT_TYPE.NUMBER], + enumProvider: commonEnumProviders.messages({ allowIdAfter: true }), + }), + ], + unnamedArgumentList: [ + new SlashCommandArgument( + 'text', + [ARGUMENT_TYPE.STRING], + true, + ), + ], + helpString: ` <div> Adds a note/comment message not part of the chat. </div> @@ -296,35 +306,35 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ </ul> </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'single', - callback: setStoryModeCallback, - aliases: ['story'], - helpString: 'Sets the message style to single document mode without names or avatars visible.', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'bubble', - callback: setBubbleModeCallback, - aliases: ['bubbles'], - helpString: 'Sets the message style to bubble chat mode.', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'flat', - callback: setFlatModeCallback, - aliases: ['default'], - helpString: 'Sets the message style to flat chat mode.', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'continue', - callback: continueChatCallback, - aliases: ['cont'], - unnamedArgumentList: [ - new SlashCommandArgument( - 'prompt', [ARGUMENT_TYPE.STRING], false, - ), - ], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'single', + callback: setStoryModeCallback, + aliases: ['story'], + helpString: 'Sets the message style to single document mode without names or avatars visible.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'bubble', + callback: setBubbleModeCallback, + aliases: ['bubbles'], + helpString: 'Sets the message style to bubble chat mode.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'flat', + callback: setFlatModeCallback, + aliases: ['default'], + helpString: 'Sets the message style to flat chat mode.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'continue', + callback: continueChatCallback, + aliases: ['cont'], + unnamedArgumentList: [ + new SlashCommandArgument( + 'prompt', [ARGUMENT_TYPE.STRING], false, + ), + ], + helpString: ` <div> Continues the last message in the chat, with an optional additional prompt. </div> @@ -342,87 +352,87 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ </ul> </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'go', - callback: goToCharacterCallback, - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ - description: 'name', - typeList: [ARGUMENT_TYPE.STRING], - isRequired: true, - enumProvider: commonEnumProviders.characters('all'), - }), - ], - helpString: 'Opens up a chat with the character or group by its name', - aliases: ['char'], -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'rename-char', - /** @param {{silent: string, chats: string}} options @param {string} name */ - callback: async ({ silent = 'true', chats = null }, name) => { - const renamed = await renameCharacter(name, { silent: isTrueBoolean(silent), renameChats: chats !== null ? isTrueBoolean(chats) : null }); - return String(renamed); - }, - returns: 'true/false - Whether the rename was successful', - namedArgumentList: [ - new SlashCommandNamedArgument( - 'silent', 'Hide any blocking popups. (if false, the name is optional. If not supplied, a popup asking for it will appear)', [ARGUMENT_TYPE.BOOLEAN], false, false, 'true', - ), - new SlashCommandNamedArgument( - 'chats', 'Rename char in all previous chats', [ARGUMENT_TYPE.BOOLEAN], false, false, '<null>', - ), - ], - unnamedArgumentList: [ - new SlashCommandArgument( - 'new char name', [ARGUMENT_TYPE.STRING], true, - ), - ], - helpString: 'Renames the current character.', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'sysgen', - callback: generateSystemMessage, - unnamedArgumentList: [ - new SlashCommandArgument( - 'prompt', [ARGUMENT_TYPE.STRING], true, - ), - ], - helpString: 'Generates a system message using a specified prompt.', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'ask', - callback: askCharacter, - namedArgumentList: [ - SlashCommandNamedArgument.fromProps({ - name: 'name', - description: 'character name', - typeList: [ARGUMENT_TYPE.STRING], - isRequired: true, - enumProvider: commonEnumProviders.characters('character'), - }), - ], - unnamedArgumentList: [ - new SlashCommandArgument( - 'prompt', [ARGUMENT_TYPE.STRING], true, false, - ), - ], - helpString: 'Asks a specified character card a prompt. Character name must be provided in a named argument.', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'delname', - callback: deleteMessagesByNameCallback, - namedArgumentList: [], - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ - description: 'name', - typeList: [ARGUMENT_TYPE.STRING], - isRequired: true, - enumProvider: commonEnumProviders.characters('character'), - }), - ], - aliases: ['cancel'], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'go', + callback: goToCharacterCallback, + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'name', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: true, + enumProvider: commonEnumProviders.characters('all'), + }), + ], + helpString: 'Opens up a chat with the character or group by its name', + aliases: ['char'], + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'rename-char', + /** @param {{silent: string, chats: string}} options @param {string} name */ + callback: async ({ silent = 'true', chats = null }, name) => { + const renamed = await renameCharacter(name, { silent: isTrueBoolean(silent), renameChats: chats !== null ? isTrueBoolean(chats) : null }); + return String(renamed); + }, + returns: 'true/false - Whether the rename was successful', + namedArgumentList: [ + new SlashCommandNamedArgument( + 'silent', 'Hide any blocking popups. (if false, the name is optional. If not supplied, a popup asking for it will appear)', [ARGUMENT_TYPE.BOOLEAN], false, false, 'true', + ), + new SlashCommandNamedArgument( + 'chats', 'Rename char in all previous chats', [ARGUMENT_TYPE.BOOLEAN], false, false, '<null>', + ), + ], + unnamedArgumentList: [ + new SlashCommandArgument( + 'new char name', [ARGUMENT_TYPE.STRING], true, + ), + ], + helpString: 'Renames the current character.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'sysgen', + callback: generateSystemMessage, + unnamedArgumentList: [ + new SlashCommandArgument( + 'prompt', [ARGUMENT_TYPE.STRING], true, + ), + ], + helpString: 'Generates a system message using a specified prompt.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'ask', + callback: askCharacter, + namedArgumentList: [ + SlashCommandNamedArgument.fromProps({ + name: 'name', + description: 'character name', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: true, + enumProvider: commonEnumProviders.characters('character'), + }), + ], + unnamedArgumentList: [ + new SlashCommandArgument( + 'prompt', [ARGUMENT_TYPE.STRING], true, false, + ), + ], + helpString: 'Asks a specified character card a prompt. Character name must be provided in a named argument.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'delname', + callback: deleteMessagesByNameCallback, + namedArgumentList: [], + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'name', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: true, + enumProvider: commonEnumProviders.characters('character'), + }), + ], + aliases: ['cancel'], + helpString: ` <div> Deletes all messages attributed to a specified name. </div> @@ -435,44 +445,41 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ </ul> </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'send', - callback: sendUserMessageCallback, - namedArgumentList: [ - new SlashCommandNamedArgument( - 'compact', - 'whether to use a compact layout', - [ARGUMENT_TYPE.BOOLEAN], - false, - false, - 'false', - ), - SlashCommandNamedArgument.fromProps({ - name: 'at', - description: 'position to insert the message', - typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.VARIABLE_NAME], - enumProvider: commonEnumProviders.messages({ allowIdAfter: true, allowVars: true }), - }), - SlashCommandNamedArgument.fromProps({ - name: 'name', - description: 'display name', - typeList: [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.VARIABLE_NAME], - defaultValue: '{{user}}', - enumProvider: () => [ - ...commonEnumProviders.characters('character')(), - ...commonEnumProviders.variables('all')().map(x => { x.description = 'Variable'; return x; }), - ], - }), - ], - unnamedArgumentList: [ - new SlashCommandArgument( - 'text', - [ARGUMENT_TYPE.STRING], - true, - ), - ], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'send', + callback: sendUserMessageCallback, + namedArgumentList: [ + new SlashCommandNamedArgument( + 'compact', + 'whether to use a compact layout', + [ARGUMENT_TYPE.BOOLEAN], + false, + false, + 'false', + ), + SlashCommandNamedArgument.fromProps({ + name: 'at', + description: 'position to insert the message (index-based, corresponding to message id). If not set, the message will be inserted at the end of the chat.\nNegative values are accepted and will work similarly to how \'depth\' usually works. For example, -1 will insert the message right before the last message in chat.', + typeList: [ARGUMENT_TYPE.NUMBER], + enumProvider: commonEnumProviders.messages({ allowIdAfter: true }), + }), + SlashCommandNamedArgument.fromProps({ + name: 'name', + description: 'display name', + typeList: [ARGUMENT_TYPE.STRING], + defaultValue: '{{user}}', + enumProvider: commonEnumProviders.personas, + }), + ], + unnamedArgumentList: [ + new SlashCommandArgument( + 'text', + [ARGUMENT_TYPE.STRING], + true, + ), + ], + helpString: ` <div> Adds a user message to the chat log without triggering a generation. </div> @@ -494,29 +501,29 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ </ul> </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'trigger', - callback: triggerGenerationCallback, - namedArgumentList: [ - new SlashCommandNamedArgument( - 'await', - 'Whether to await for the triggered generation before continuing', - [ARGUMENT_TYPE.BOOLEAN], - false, - false, - 'false', - ), - ], - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ - description: 'group member index (starts with 0) or name', - typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], - isRequired: false, - enumProvider: commonEnumProviders.groupMembers(), - }), - ], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'trigger', + callback: triggerGenerationCallback, + namedArgumentList: [ + new SlashCommandNamedArgument( + 'await', + 'Whether to await for the triggered generation before continuing', + [ARGUMENT_TYPE.BOOLEAN], + false, + false, + 'false', + ), + ], + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'group member index (starts with 0) or name', + typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], + isRequired: false, + enumProvider: commonEnumProviders.groupMembers(), + }), + ], + helpString: ` <div> Triggers a message generation. If in group, can trigger a message for the specified group member index or name. </div> @@ -524,74 +531,74 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ If <code>await=true</code> named argument is passed, the command will await for the triggered generation before continuing. </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'hide', - callback: hideMessageCallback, - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ - description: 'message index (starts with 0) or range', - typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.RANGE], - isRequired: true, - enumProvider: commonEnumProviders.messages(), - }), - ], - helpString: 'Hides a chat message from the prompt.', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'unhide', - callback: unhideMessageCallback, - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ - description: 'message index (starts with 0) or range', - typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.RANGE], - isRequired: true, - enumProvider: commonEnumProviders.messages(), - }), - ], - helpString: 'Unhides a message from the prompt.', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'member-disable', - callback: disableGroupMemberCallback, - aliases: ['disable', 'disablemember', 'memberdisable'], - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ - description: 'member index (starts with 0) or name', - typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], - isRequired: true, - enumProvider: commonEnumProviders.groupMembers(), - }), - ], - helpString: 'Disables a group member from being drafted for replies.', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'member-enable', - aliases: ['enable', 'enablemember', 'memberenable'], - callback: enableGroupMemberCallback, - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ - description: 'member index (starts with 0) or name', - typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], - isRequired: true, - enumProvider: commonEnumProviders.groupMembers(), - }), - ], - helpString: 'Enables a group member to be drafted for replies.', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'member-add', - callback: addGroupMemberCallback, - aliases: ['addmember', 'memberadd'], - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ - description: 'character name', - typeList: [ARGUMENT_TYPE.STRING], - isRequired: true, - enumProvider: () => selected_group ? commonEnumProviders.characters('character')() : [], - }), - ], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'hide', + callback: hideMessageCallback, + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'message index (starts with 0) or range', + typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.RANGE], + isRequired: true, + enumProvider: commonEnumProviders.messages(), + }), + ], + helpString: 'Hides a chat message from the prompt.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'unhide', + callback: unhideMessageCallback, + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'message index (starts with 0) or range', + typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.RANGE], + isRequired: true, + enumProvider: commonEnumProviders.messages(), + }), + ], + helpString: 'Unhides a message from the prompt.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'member-disable', + callback: disableGroupMemberCallback, + aliases: ['disable', 'disablemember', 'memberdisable'], + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'member index (starts with 0) or name', + typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], + isRequired: true, + enumProvider: commonEnumProviders.groupMembers(), + }), + ], + helpString: 'Disables a group member from being drafted for replies.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'member-enable', + aliases: ['enable', 'enablemember', 'memberenable'], + callback: enableGroupMemberCallback, + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'member index (starts with 0) or name', + typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], + isRequired: true, + enumProvider: commonEnumProviders.groupMembers(), + }), + ], + helpString: 'Enables a group member to be drafted for replies.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'member-add', + callback: addGroupMemberCallback, + aliases: ['addmember', 'memberadd'], + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'character name', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: true, + enumProvider: () => selected_group ? commonEnumProviders.characters('character')() : [], + }), + ], + helpString: ` <div> Adds a new group member to the group chat. </div> @@ -604,20 +611,20 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ </ul> </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'member-remove', - callback: removeGroupMemberCallback, - aliases: ['removemember', 'memberremove'], - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ - description: 'member index (starts with 0) or name', - typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], - isRequired: true, - enumProvider: commonEnumProviders.groupMembers(), - }), - ], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'member-remove', + callback: removeGroupMemberCallback, + aliases: ['removemember', 'memberremove'], + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'member index (starts with 0) or name', + typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], + isRequired: true, + enumProvider: commonEnumProviders.groupMembers(), + }), + ], + helpString: ` <div> Removes a group member from the group chat. </div> @@ -631,47 +638,47 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ </ul> </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'member-up', - callback: moveGroupMemberUpCallback, - aliases: ['upmember', 'memberup'], - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ - description: 'member index (starts with 0) or name', - typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], - isRequired: true, - enumProvider: commonEnumProviders.groupMembers(), - }), - ], - helpString: 'Moves a group member up in the group chat list.', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'member-down', - callback: moveGroupMemberDownCallback, - aliases: ['downmember', 'memberdown'], - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ - description: 'member index (starts with 0) or name', - typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], - isRequired: true, - enumProvider: commonEnumProviders.groupMembers(), - }), - ], - helpString: 'Moves a group member down in the group chat list.', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'peek', - callback: peekCallback, - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ - description: 'member index (starts with 0) or name', - typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], - isRequired: true, - enumProvider: commonEnumProviders.groupMembers(), - }), - ], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'member-up', + callback: moveGroupMemberUpCallback, + aliases: ['upmember', 'memberup'], + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'member index (starts with 0) or name', + typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], + isRequired: true, + enumProvider: commonEnumProviders.groupMembers(), + }), + ], + helpString: 'Moves a group member up in the group chat list.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'member-down', + callback: moveGroupMemberDownCallback, + aliases: ['downmember', 'memberdown'], + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'member index (starts with 0) or name', + typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], + isRequired: true, + enumProvider: commonEnumProviders.groupMembers(), + }), + ], + helpString: 'Moves a group member down in the group chat list.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'peek', + callback: peekCallback, + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'member index (starts with 0) or name', + typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], + isRequired: true, + enumProvider: commonEnumProviders.groupMembers(), + }), + ], + helpString: ` <div> Shows a group member character card without switching chats. </div> @@ -685,22 +692,22 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ </ul> </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'delswipe', - callback: deleteSwipeCallback, - aliases: ['swipedel'], - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ - description: '1-based swipe id', - typeList: [ARGUMENT_TYPE.NUMBER], - isRequired: true, - enumProvider: () => Array.isArray(chat[chat.length - 1]?.swipes) ? - chat[chat.length - 1].swipes.map((/** @type {string} */ swipe, /** @type {number} */ i) => new SlashCommandEnumValue(String(i + 1), swipe, enumTypes.enum, enumIcons.message)) - : [], - }), - ], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'delswipe', + callback: deleteSwipeCallback, + aliases: ['swipedel'], + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: '1-based swipe id', + typeList: [ARGUMENT_TYPE.NUMBER], + isRequired: true, + enumProvider: () => Array.isArray(chat[chat.length - 1]?.swipes) ? + chat[chat.length - 1].swipes.map((/** @type {string} */ swipe, /** @type {number} */ i) => new SlashCommandEnumValue(String(i + 1), swipe, enumTypes.enum, enumIcons.message)) + : [], + }), + ], + helpString: ` <div> Deletes a swipe from the last chat message. If swipe id is not provided, it deletes the current swipe. </div> @@ -718,34 +725,60 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ </ul> </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'echo', - callback: echoCallback, - returns: 'the text', - namedArgumentList: [ - new SlashCommandNamedArgument( - 'title', 'title of the toast message', [ARGUMENT_TYPE.STRING], false, - ), - SlashCommandNamedArgument.fromProps({ - name: 'severity', - description: 'severity level of the toast message', - typeList: [ARGUMENT_TYPE.STRING], - defaultValue: 'info', - enumProvider: () => [ - new SlashCommandEnumValue('info', 'info', enumTypes.macro, 'ℹ️'), - new SlashCommandEnumValue('warning', 'warning', enumTypes.enum, '⚠️'), - new SlashCommandEnumValue('error', 'error', enumTypes.enum, '❗'), - new SlashCommandEnumValue('success', 'success', enumTypes.enum, '✅'), - ], - }), - ], - unnamedArgumentList: [ - new SlashCommandArgument( - 'text', [ARGUMENT_TYPE.STRING], true, - ), - ], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'echo', + callback: echoCallback, + returns: 'the text', + namedArgumentList: [ + new SlashCommandNamedArgument( + 'title', 'title of the toast message', [ARGUMENT_TYPE.STRING], false, + ), + SlashCommandNamedArgument.fromProps({ + name: 'severity', + description: 'severity level of the toast message', + typeList: [ARGUMENT_TYPE.STRING], + defaultValue: 'info', + enumProvider: () => [ + new SlashCommandEnumValue('info', 'info', enumTypes.macro, 'ℹ️'), + new SlashCommandEnumValue('warning', 'warning', enumTypes.enum, '⚠️'), + new SlashCommandEnumValue('error', 'error', enumTypes.enum, '❗'), + new SlashCommandEnumValue('success', 'success', enumTypes.enum, '✅'), + ], + }), + SlashCommandNamedArgument.fromProps({ + name: 'timeout', + description: 'time in milliseconds to display the toast message. Set this and \'extendedTimeout\' to 0 to show indefinitely until dismissed.', + typeList: [ARGUMENT_TYPE.NUMBER], + defaultValue: `${toastr.options.timeOut}`, + }), + SlashCommandNamedArgument.fromProps({ + name: 'extendedTimeout', + description: 'time in milliseconds to display the toast message. Set this and \'timeout\' to 0 to show indefinitely until dismissed.', + typeList: [ARGUMENT_TYPE.NUMBER], + defaultValue: `${toastr.options.extendedTimeOut}`, + }), + SlashCommandNamedArgument.fromProps({ + name: 'preventDuplicates', + description: 'prevent duplicate toasts with the same message from being displayed.', + typeList: [ARGUMENT_TYPE.BOOLEAN], + defaultValue: 'false', + enumList: commonEnumProviders.boolean('trueFalse')(), + }), + SlashCommandNamedArgument.fromProps({ + name: 'awaitDismissal', + description: 'wait for the toast to be dismissed before continuing.', + typeList: [ARGUMENT_TYPE.BOOLEAN], + defaultValue: 'false', + enumList: commonEnumProviders.boolean('trueFalse')(), + }), + ], + unnamedArgumentList: [ + new SlashCommandArgument( + 'text', [ARGUMENT_TYPE.STRING], true, + ), + ], + helpString: ` <div> Echoes the provided text to a toast message. Useful for pipes debugging. </div> @@ -758,42 +791,42 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ </ul> </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'gen', - callback: generateCallback, - returns: 'generated text', - namedArgumentList: [ - new SlashCommandNamedArgument( - 'lock', 'lock user input during generation', [ARGUMENT_TYPE.BOOLEAN], false, false, null, commonEnumProviders.boolean('onOff')(), - ), - SlashCommandNamedArgument.fromProps({ - name: 'name', - description: 'in-prompt name for instruct mode', - typeList: [ARGUMENT_TYPE.STRING], - defaultValue: 'System', - enumProvider: () => [...commonEnumProviders.characters('character')(), new SlashCommandEnumValue('System', null, enumTypes.enum, enumIcons.assistant)], - forceEnum: false, - }), - new SlashCommandNamedArgument( - 'length', 'API response length in tokens', [ARGUMENT_TYPE.NUMBER], false, - ), - SlashCommandNamedArgument.fromProps({ - name: 'as', - description: 'role of the output prompt', - typeList: [ARGUMENT_TYPE.STRING], - enumList: [ - new SlashCommandEnumValue('system', null, enumTypes.enum, enumIcons.assistant), - new SlashCommandEnumValue('char', null, enumTypes.enum, enumIcons.character), - ], - }), - ], - unnamedArgumentList: [ - new SlashCommandArgument( - 'prompt', [ARGUMENT_TYPE.STRING], true, - ), - ], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'gen', + callback: generateCallback, + returns: 'generated text', + namedArgumentList: [ + new SlashCommandNamedArgument( + 'lock', 'lock user input during generation', [ARGUMENT_TYPE.BOOLEAN], false, false, null, commonEnumProviders.boolean('onOff')(), + ), + SlashCommandNamedArgument.fromProps({ + name: 'name', + description: 'in-prompt name for instruct mode', + typeList: [ARGUMENT_TYPE.STRING], + defaultValue: 'System', + enumProvider: () => [...commonEnumProviders.characters('character')(), new SlashCommandEnumValue('System', null, enumTypes.enum, enumIcons.assistant)], + forceEnum: false, + }), + new SlashCommandNamedArgument( + 'length', 'API response length in tokens', [ARGUMENT_TYPE.NUMBER], false, + ), + SlashCommandNamedArgument.fromProps({ + name: 'as', + description: 'role of the output prompt', + typeList: [ARGUMENT_TYPE.STRING], + enumList: [ + new SlashCommandEnumValue('system', null, enumTypes.enum, enumIcons.assistant), + new SlashCommandEnumValue('char', null, enumTypes.enum, enumIcons.character), + ], + }), + ], + unnamedArgumentList: [ + new SlashCommandArgument( + 'prompt', [ARGUMENT_TYPE.STRING], true, + ), + ], + helpString: ` <div> Generates text using the provided prompt and passes it to the next command through the pipe, optionally locking user input while generating and allowing to configure the in-prompt name for instruct mode (default = "System"). </div> @@ -801,43 +834,43 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ "as" argument controls the role of the output prompt: system (default) or char. If "length" argument is provided as a number in tokens, allows to temporarily override an API response length. </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'genraw', - callback: generateRawCallback, - returns: 'generated text', - namedArgumentList: [ - new SlashCommandNamedArgument( - 'lock', 'lock user input during generation', [ARGUMENT_TYPE.BOOLEAN], false, false, null, commonEnumProviders.boolean('onOff')(), - ), - new SlashCommandNamedArgument( - 'instruct', 'use instruct mode', [ARGUMENT_TYPE.BOOLEAN], false, false, 'on', commonEnumProviders.boolean('onOff')(), - ), - new SlashCommandNamedArgument( - 'stop', 'one-time custom stop strings', [ARGUMENT_TYPE.LIST], false, - ), - SlashCommandNamedArgument.fromProps({ - name: 'as', - description: 'role of the output prompt', - typeList: [ARGUMENT_TYPE.STRING], - enumList: [ - new SlashCommandEnumValue('system', null, enumTypes.enum, enumIcons.assistant), - new SlashCommandEnumValue('char', null, enumTypes.enum, enumIcons.character), - ], - }), - new SlashCommandNamedArgument( - 'system', 'system prompt at the start', [ARGUMENT_TYPE.STRING], false, - ), - new SlashCommandNamedArgument( - 'length', 'API response length in tokens', [ARGUMENT_TYPE.NUMBER], false, - ), - ], - unnamedArgumentList: [ - new SlashCommandArgument( - 'prompt', [ARGUMENT_TYPE.STRING], true, - ), - ], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'genraw', + callback: generateRawCallback, + returns: 'generated text', + namedArgumentList: [ + new SlashCommandNamedArgument( + 'lock', 'lock user input during generation', [ARGUMENT_TYPE.BOOLEAN], false, false, null, commonEnumProviders.boolean('onOff')(), + ), + new SlashCommandNamedArgument( + 'instruct', 'use instruct mode', [ARGUMENT_TYPE.BOOLEAN], false, false, 'on', commonEnumProviders.boolean('onOff')(), + ), + new SlashCommandNamedArgument( + 'stop', 'one-time custom stop strings', [ARGUMENT_TYPE.LIST], false, + ), + SlashCommandNamedArgument.fromProps({ + name: 'as', + description: 'role of the output prompt', + typeList: [ARGUMENT_TYPE.STRING], + enumList: [ + new SlashCommandEnumValue('system', null, enumTypes.enum, enumIcons.assistant), + new SlashCommandEnumValue('char', null, enumTypes.enum, enumIcons.character), + ], + }), + new SlashCommandNamedArgument( + 'system', 'system prompt at the start', [ARGUMENT_TYPE.STRING], false, + ), + new SlashCommandNamedArgument( + 'length', 'API response length in tokens', [ARGUMENT_TYPE.NUMBER], false, + ), + ], + unnamedArgumentList: [ + new SlashCommandArgument( + 'prompt', [ARGUMENT_TYPE.STRING], true, + ), + ], + helpString: ` <div> Generates text using the provided prompt and passes it to the next command through the pipe, optionally locking user input while generating. Does not include chat history or character card. </div> @@ -854,55 +887,73 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ If "length" argument is provided as a number in tokens, allows to temporarily override an API response length. </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'addswipe', - callback: addSwipeCallback, - aliases: ['swipeadd'], - unnamedArgumentList: [ - new SlashCommandArgument( - 'text', [ARGUMENT_TYPE.STRING], true, - ), - ], - helpString: 'Adds a swipe to the last chat message.', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'abort', - callback: abortCallback, - namedArgumentList: [ - SlashCommandNamedArgument.fromProps({ - name: 'quiet', - description: 'Whether to suppress the toast message notifying about the /abort call.', - typeList: [ARGUMENT_TYPE.BOOLEAN], - defaultValue: 'true', - }), - ], - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ - description: 'The reason for aborting command execution. Shown when quiet=false', - typeList: [ARGUMENT_TYPE.STRING], - }), - ], - helpString: 'Aborts the slash command batch execution.', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'fuzzy', - callback: fuzzyCallback, - returns: 'first matching item', - namedArgumentList: [ - new SlashCommandNamedArgument( - 'list', 'list of items to match against', [ARGUMENT_TYPE.LIST], true, - ), - new SlashCommandNamedArgument( - 'threshold', 'fuzzy match threshold (0.0 to 1.0)', [ARGUMENT_TYPE.NUMBER], false, false, '0.4', - ), - ], - unnamedArgumentList: [ - new SlashCommandArgument( - 'text to search', [ARGUMENT_TYPE.STRING], true, - ), - ], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'addswipe', + callback: addSwipeCallback, + aliases: ['swipeadd'], + unnamedArgumentList: [ + new SlashCommandArgument( + 'text', [ARGUMENT_TYPE.STRING], true, + ), + ], + helpString: 'Adds a swipe to the last chat message.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'stop', + callback: () => { + const stopped = stopGeneration(); + return String(stopped); + }, + returns: 'true/false, whether the generation was running and got stopped', + helpString: ` + <div> + Stops the generation and any streaming if it is currently running. + </div> + <div> + Note: This command cannot be executed from the chat input, as sending any message or script from there is blocked during generation. + But it can be executed via automations or QR scripts/buttons. + </div> + `, + aliases: ['generate-stop'], + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'abort', + callback: abortCallback, + namedArgumentList: [ + SlashCommandNamedArgument.fromProps({ + name: 'quiet', + description: 'Whether to suppress the toast message notifying about the /abort call.', + typeList: [ARGUMENT_TYPE.BOOLEAN], + defaultValue: 'true', + }), + ], + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'The reason for aborting command execution. Shown when quiet=false', + typeList: [ARGUMENT_TYPE.STRING], + }), + ], + helpString: 'Aborts the slash command batch execution.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'fuzzy', + callback: fuzzyCallback, + returns: 'first matching item', + namedArgumentList: [ + new SlashCommandNamedArgument( + 'list', 'list of items to match against', [ARGUMENT_TYPE.LIST], true, + ), + new SlashCommandNamedArgument( + 'threshold', 'fuzzy match threshold (0.0 to 1.0)', [ARGUMENT_TYPE.NUMBER], false, false, '0.4', + ), + ], + unnamedArgumentList: [ + new SlashCommandArgument( + 'text to search', [ARGUMENT_TYPE.STRING], true, + ), + ], + helpString: ` <div> Performs a fuzzy match of each item in the <code>list</code> against the <code>text to search</code>. If any item matches, then its name is returned. If no item matches the text, no value is returned. @@ -924,23 +975,23 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ </ul> </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'pass', - callback: (_, arg) => { - // We do not support arrays of closures. Arrays of strings will be send as JSON - if (Array.isArray(arg) && arg.some(x => x instanceof SlashCommandClosure)) throw new Error('Command /pass does not support multiple closures'); - if (Array.isArray(arg)) return JSON.stringify(arg); - return arg; - }, - returns: 'the provided value', - unnamedArgumentList: [ - new SlashCommandArgument( - 'text', [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.BOOLEAN, ARGUMENT_TYPE.LIST, ARGUMENT_TYPE.DICTIONARY, ARGUMENT_TYPE.CLOSURE], true, - ), - ], - aliases: ['return'], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'pass', + callback: (_, arg) => { + // We do not support arrays of closures. Arrays of strings will be send as JSON + if (Array.isArray(arg) && arg.some(x => x instanceof SlashCommandClosure)) throw new Error('Command /pass does not support multiple closures'); + if (Array.isArray(arg)) return JSON.stringify(arg); + return arg; + }, + returns: 'the provided value', + unnamedArgumentList: [ + new SlashCommandArgument( + 'text', [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.BOOLEAN, ARGUMENT_TYPE.LIST, ARGUMENT_TYPE.DICTIONARY, ARGUMENT_TYPE.CLOSURE], true, + ), + ], + aliases: ['return'], + helpString: ` <div> <pre><span class="monospace">/pass (text)</span> – passes the text to the next command through the pipe.</pre> </div> @@ -951,17 +1002,17 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ </ul> </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'delay', - callback: delayCallback, - aliases: ['wait', 'sleep'], - unnamedArgumentList: [ - new SlashCommandArgument( - 'milliseconds', [ARGUMENT_TYPE.NUMBER], true, - ), - ], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'delay', + callback: delayCallback, + aliases: ['wait', 'sleep'], + unnamedArgumentList: [ + new SlashCommandArgument( + 'milliseconds', [ARGUMENT_TYPE.NUMBER], true, + ), + ], + helpString: ` <div> Delays the next command in the pipe by the specified number of milliseconds. </div> @@ -974,101 +1025,101 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ </ul> </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'input', - aliases: ['prompt'], - callback: inputCallback, - returns: 'user input', - namedArgumentList: [ - new SlashCommandNamedArgument( - 'default', 'default value of the input field', [ARGUMENT_TYPE.STRING], false, false, '"string"', - ), - new SlashCommandNamedArgument( - 'large', 'show large input field', [ARGUMENT_TYPE.BOOLEAN], false, false, 'off', commonEnumProviders.boolean('onOff')(), - ), - new SlashCommandNamedArgument( - 'wide', 'show wide input field', [ARGUMENT_TYPE.BOOLEAN], false, false, 'off', commonEnumProviders.boolean('onOff')(), - ), - new SlashCommandNamedArgument( - 'okButton', 'text for the ok button', [ARGUMENT_TYPE.STRING], false, - ), - new SlashCommandNamedArgument( - 'rows', 'number of rows for the input field', [ARGUMENT_TYPE.NUMBER], false, - ), - ], - unnamedArgumentList: [ - new SlashCommandArgument( - 'text to display', [ARGUMENT_TYPE.STRING], false, - ), - ], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'input', + aliases: ['prompt'], + callback: inputCallback, + returns: 'user input', + namedArgumentList: [ + new SlashCommandNamedArgument( + 'default', 'default value of the input field', [ARGUMENT_TYPE.STRING], false, false, '"string"', + ), + new SlashCommandNamedArgument( + 'large', 'show large input field', [ARGUMENT_TYPE.BOOLEAN], false, false, 'off', commonEnumProviders.boolean('onOff')(), + ), + new SlashCommandNamedArgument( + 'wide', 'show wide input field', [ARGUMENT_TYPE.BOOLEAN], false, false, 'off', commonEnumProviders.boolean('onOff')(), + ), + new SlashCommandNamedArgument( + 'okButton', 'text for the ok button', [ARGUMENT_TYPE.STRING], false, + ), + new SlashCommandNamedArgument( + 'rows', 'number of rows for the input field', [ARGUMENT_TYPE.NUMBER], false, + ), + ], + unnamedArgumentList: [ + new SlashCommandArgument( + 'text to display', [ARGUMENT_TYPE.STRING], false, + ), + ], + helpString: ` <div> Shows a popup with the provided text and an input field. The <code>default</code> argument is the default value of the input field, and the text argument is the text to display. </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'run', - aliases: ['call', 'exec'], - callback: runCallback, - returns: 'result of the executed closure of QR', - namedArgumentList: [ - new SlashCommandNamedArgument( - 'args', 'named arguments', [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.BOOLEAN, ARGUMENT_TYPE.LIST, ARGUMENT_TYPE.DICTIONARY], false, true, - ), - ], - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ - description: 'scoped variable or qr label', - typeList: [ARGUMENT_TYPE.VARIABLE_NAME, ARGUMENT_TYPE.STRING], - isRequired: true, - enumProvider: () => [ - ...commonEnumProviders.variables('scope')(), - ...(typeof window['qrEnumProviderExecutables'] === 'function') ? window['qrEnumProviderExecutables']() : [], - ], - }), - ], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'run', + aliases: ['call', 'exec'], + callback: runCallback, + returns: 'result of the executed closure of QR', + namedArgumentList: [ + new SlashCommandNamedArgument( + 'args', 'named arguments', [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.BOOLEAN, ARGUMENT_TYPE.LIST, ARGUMENT_TYPE.DICTIONARY], false, true, + ), + ], + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'scoped variable or qr label', + typeList: [ARGUMENT_TYPE.VARIABLE_NAME, ARGUMENT_TYPE.STRING], + isRequired: true, + enumProvider: () => [ + ...commonEnumProviders.variables('scope')(), + ...(typeof window['qrEnumProviderExecutables'] === 'function') ? window['qrEnumProviderExecutables']() : [], + ], + }), + ], + helpString: ` <div> Runs a closure from a scoped variable, or a Quick Reply with the specified name from a currently active preset or from another preset. Named arguments can be referenced in a QR with <code>{{arg::key}}</code>. </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'messages', - callback: getMessagesCallback, - aliases: ['message'], - namedArgumentList: [ - new SlashCommandNamedArgument( - 'names', 'show message author names', [ARGUMENT_TYPE.BOOLEAN], false, false, 'off', commonEnumProviders.boolean('onOff')(), - ), - new SlashCommandNamedArgument( - 'hidden', 'include hidden messages', [ARGUMENT_TYPE.BOOLEAN], false, false, 'on', commonEnumProviders.boolean('onOff')(), - ), - SlashCommandNamedArgument.fromProps({ - name: 'role', - description: 'filter messages by role', - typeList: [ARGUMENT_TYPE.STRING], - enumList: [ - new SlashCommandEnumValue('system', null, enumTypes.enum, enumIcons.system), - new SlashCommandEnumValue('assistant', null, enumTypes.enum, enumIcons.assistant), - new SlashCommandEnumValue('user', null, enumTypes.enum, enumIcons.user), - ], - }), - ], - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ - description: 'message index (starts with 0) or range', - typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.RANGE], - isRequired: true, - enumProvider: commonEnumProviders.messages(), - }), - ], - returns: 'the specified message or range of messages as a string', - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'messages', + callback: getMessagesCallback, + aliases: ['message'], + namedArgumentList: [ + new SlashCommandNamedArgument( + 'names', 'show message author names', [ARGUMENT_TYPE.BOOLEAN], false, false, 'off', commonEnumProviders.boolean('onOff')(), + ), + new SlashCommandNamedArgument( + 'hidden', 'include hidden messages', [ARGUMENT_TYPE.BOOLEAN], false, false, 'on', commonEnumProviders.boolean('onOff')(), + ), + SlashCommandNamedArgument.fromProps({ + name: 'role', + description: 'filter messages by role', + typeList: [ARGUMENT_TYPE.STRING], + enumList: [ + new SlashCommandEnumValue('system', null, enumTypes.enum, enumIcons.system), + new SlashCommandEnumValue('assistant', null, enumTypes.enum, enumIcons.assistant), + new SlashCommandEnumValue('user', null, enumTypes.enum, enumIcons.user), + ], + }), + ], + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'message index (starts with 0) or range', + typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.RANGE], + isRequired: true, + enumProvider: commonEnumProviders.messages(), + }), + ], + returns: 'the specified message or range of messages as a string', + helpString: ` <div> Returns the specified message or range of messages as a string. </div> @@ -1092,16 +1143,16 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ </ul> </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'setinput', - callback: setInputCallback, - unnamedArgumentList: [ - new SlashCommandArgument( - 'text', [ARGUMENT_TYPE.STRING], true, - ), - ], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'setinput', + callback: setInputCallback, + unnamedArgumentList: [ + new SlashCommandArgument( + 'text', [ARGUMENT_TYPE.STRING], true, + ), + ], + helpString: ` <div> Sets the user input to the specified text and passes it to the next command through the pipe. </div> @@ -1114,28 +1165,28 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ </ul> </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'popup', - callback: popupCallback, - returns: 'popup text', - namedArgumentList: [ - new SlashCommandNamedArgument( - 'large', 'show large popup', [ARGUMENT_TYPE.BOOLEAN], false, false, null, commonEnumProviders.boolean('onOff')(), - ), - new SlashCommandNamedArgument( - 'wide', 'show wide popup', [ARGUMENT_TYPE.BOOLEAN], false, false, null, commonEnumProviders.boolean('onOff')(), - ), - new SlashCommandNamedArgument( - 'okButton', 'text for the OK button', [ARGUMENT_TYPE.STRING], false, - ), - ], - unnamedArgumentList: [ - new SlashCommandArgument( - 'text', [ARGUMENT_TYPE.STRING], true, - ), - ], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'popup', + callback: popupCallback, + returns: 'popup text', + namedArgumentList: [ + new SlashCommandNamedArgument( + 'large', 'show large popup', [ARGUMENT_TYPE.BOOLEAN], false, false, null, commonEnumProviders.boolean('onOff')(), + ), + new SlashCommandNamedArgument( + 'wide', 'show wide popup', [ARGUMENT_TYPE.BOOLEAN], false, false, null, commonEnumProviders.boolean('onOff')(), + ), + new SlashCommandNamedArgument( + 'okButton', 'text for the OK button', [ARGUMENT_TYPE.STRING], false, + ), + ], + unnamedArgumentList: [ + new SlashCommandArgument( + 'text', [ARGUMENT_TYPE.STRING], true, + ), + ], + helpString: ` <div> Shows a blocking popup with the specified text and buttons. Returns the popup text. @@ -1149,22 +1200,22 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ </ul> </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'buttons', - callback: buttonsCallback, - returns: 'clicked button label', - namedArgumentList: [ - new SlashCommandNamedArgument( - 'labels', 'button labels', [ARGUMENT_TYPE.LIST], true, - ), - ], - unnamedArgumentList: [ - new SlashCommandArgument( - 'text', [ARGUMENT_TYPE.STRING], true, - ), - ], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'buttons', + callback: buttonsCallback, + returns: 'clicked button label', + namedArgumentList: [ + new SlashCommandNamedArgument( + 'labels', 'button labels', [ARGUMENT_TYPE.LIST], true, + ), + ], + unnamedArgumentList: [ + new SlashCommandArgument( + 'text', [ARGUMENT_TYPE.STRING], true, + ), + ], + helpString: ` <div> Shows a blocking popup with the specified text and buttons. Returns the clicked button label into the pipe or empty string if canceled. @@ -1178,32 +1229,32 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ </ul> </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'trimtokens', - callback: trimTokensCallback, - returns: 'trimmed text', - namedArgumentList: [ - new SlashCommandNamedArgument( - 'limit', 'number of tokens to keep', [ARGUMENT_TYPE.NUMBER], true, - ), - SlashCommandNamedArgument.fromProps({ - name: 'direction', - description: 'trim direction', - typeList: [ARGUMENT_TYPE.STRING], - isRequired: true, - enumList: [ - new SlashCommandEnumValue('start', null, enumTypes.enum, '⏪'), - new SlashCommandEnumValue('end', null, enumTypes.enum, '⏩'), - ], - }), - ], - unnamedArgumentList: [ - new SlashCommandArgument( - 'text', [ARGUMENT_TYPE.STRING], false, - ), - ], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'trimtokens', + callback: trimTokensCallback, + returns: 'trimmed text', + namedArgumentList: [ + new SlashCommandNamedArgument( + 'limit', 'number of tokens to keep', [ARGUMENT_TYPE.NUMBER], true, + ), + SlashCommandNamedArgument.fromProps({ + name: 'direction', + description: 'trim direction', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: true, + enumList: [ + new SlashCommandEnumValue('start', null, enumTypes.enum, '⏪'), + new SlashCommandEnumValue('end', null, enumTypes.enum, '⏩'), + ], + }), + ], + unnamedArgumentList: [ + new SlashCommandArgument( + 'text', [ARGUMENT_TYPE.STRING], false, + ), + ], + helpString: ` <div> Trims the start or end of text to the specified number of tokens. </div> @@ -1216,17 +1267,17 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ </ul> </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'trimstart', - callback: trimStartCallback, - returns: 'trimmed text', - unnamedArgumentList: [ - new SlashCommandArgument( - 'text', [ARGUMENT_TYPE.STRING], true, - ), - ], - helpString: ` + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'trimstart', + callback: trimStartCallback, + returns: 'trimmed text', + unnamedArgumentList: [ + new SlashCommandArgument( + 'text', [ARGUMENT_TYPE.STRING], true, + ), + ], + helpString: ` <div> Trims the text to the start of the first full sentence. </div> @@ -1239,155 +1290,149 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ </ul> </div> `, -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'trimend', - callback: trimEndCallback, - returns: 'trimmed text', - unnamedArgumentList: [ - new SlashCommandArgument( - 'text', [ARGUMENT_TYPE.STRING], true, - ), - ], - helpString: 'Trims the text to the end of the last full sentence.', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'inject', - callback: injectCallback, - namedArgumentList: [ - SlashCommandNamedArgument.fromProps({ - name: 'id', - description: 'injection ID or variable name pointing to ID', - typeList: [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.VARIABLE_NAME], - isRequired: true, + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'trimend', + callback: trimEndCallback, + returns: 'trimmed text', + unnamedArgumentList: [ + new SlashCommandArgument( + 'text', [ARGUMENT_TYPE.STRING], true, + ), + ], + helpString: 'Trims the text to the end of the last full sentence.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'inject', + callback: injectCallback, + namedArgumentList: [ + SlashCommandNamedArgument.fromProps({ + name: 'id', + description: 'injection ID or variable name pointing to ID', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: true, + enumProvider: commonEnumProviders.injects, + }), + new SlashCommandNamedArgument( + 'position', 'injection position', [ARGUMENT_TYPE.STRING], false, false, 'after', ['before', 'after', 'chat'], + ), + new SlashCommandNamedArgument( + 'depth', 'injection depth', [ARGUMENT_TYPE.NUMBER], false, false, '4', + ), + new SlashCommandNamedArgument( + 'scan', 'include injection content into World Info scans', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false', + ), + SlashCommandNamedArgument.fromProps({ + name: 'role', + description: 'role for in-chat injections', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: false, + enumList: [ + new SlashCommandEnumValue('system', null, enumTypes.enum, enumIcons.system), + new SlashCommandEnumValue('assistant', null, enumTypes.enum, enumIcons.assistant), + new SlashCommandEnumValue('user', null, enumTypes.enum, enumIcons.user), + ], + }), + new SlashCommandNamedArgument( + 'ephemeral', 'remove injection after generation', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false', + ), + ], + unnamedArgumentList: [ + new SlashCommandArgument( + 'text', [ARGUMENT_TYPE.STRING], false, + ), + ], + helpString: 'Injects a text into the LLM prompt for the current chat. Requires a unique injection ID. Positions: "before" main prompt, "after" main prompt, in-"chat" (default: after). Depth: injection depth for the prompt (default: 4). Role: role for in-chat injections (default: system). Scan: include injection content into World Info scans (default: false).', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'listinjects', + callback: listInjectsCallback, + helpString: 'Lists all script injections for the current chat.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'flushinject', + aliases: ['flushinjects'], + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'injection ID or a variable name pointing to ID', + typeList: [ARGUMENT_TYPE.STRING], + defaultValue: '', + enumProvider: commonEnumProviders.injects, + }), + ], + callback: flushInjectsCallback, + helpString: 'Removes a script injection for the current chat. If no ID is provided, removes all script injections.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'tokens', + callback: (_, text) => { + if (text instanceof SlashCommandClosure || Array.isArray(text)) throw new Error('Unnamed argument cannot be a closure for command /tokens'); + return getTokenCountAsync(text).then(count => String(count)); + }, + returns: 'number of tokens', + unnamedArgumentList: [ + new SlashCommandArgument( + 'text', [ARGUMENT_TYPE.STRING], true, + ), + ], + helpString: 'Counts the number of tokens in the provided text.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'model', + callback: modelCallback, + returns: 'current model', + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'model name', + typeList: [ARGUMENT_TYPE.STRING], + enumProvider: () => getModelOptions()?.options.map(option => new SlashCommandEnumValue(option.value, option.value !== option.text ? option.text : null)), + }), + ], + helpString: 'Sets the model for the current API. Gets the current model name if no argument is provided.', + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'setpromptentry', + aliases: ['setpromptentries'], + callback: setPromptEntryCallback, + namedArgumentList: [ + SlashCommandNamedArgument.fromProps({ + name: 'identifier', + description: 'Prompt entry identifier(s) to target', + typeList: [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.LIST], + acceptsMultiple: true, + enumProvider: () => { + const promptManager = setupChatCompletionPromptManager(oai_settings); + const prompts = promptManager.serviceSettings.prompts; + return prompts.map(prompt => new SlashCommandEnumValue(prompt.identifier, prompt.name, enumTypes.enum)); + }, + }), + SlashCommandNamedArgument.fromProps({ + name: 'name', + description: 'Prompt entry name(s) to target', + typeList: [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.LIST], + acceptsMultiple: true, + enumProvider: () => { + const promptManager = setupChatCompletionPromptManager(oai_settings); + const prompts = promptManager.serviceSettings.prompts; + return prompts.map(prompt => new SlashCommandEnumValue(prompt.name, prompt.identifier, enumTypes.enum)); + }, + }), + ], + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'Set entry/entries on or off', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: true, + acceptsMultiple: false, + defaultValue: 'toggle', // unnamed arguments don't support default values yet + enumList: commonEnumProviders.boolean('onOffToggle')(), + }), + ], + helpString: 'Sets the specified prompt manager entry/entries on or off.', + })); - enumProvider: () => [ - ...commonEnumProviders.injects(), - ...commonEnumProviders.variables('all')().map(x => { x.description = 'Variable'; return x; }), - ], - }), - new SlashCommandNamedArgument( - 'position', 'injection position', [ARGUMENT_TYPE.STRING], false, false, 'after', ['before', 'after', 'chat'], - ), - new SlashCommandNamedArgument( - 'depth', 'injection depth', [ARGUMENT_TYPE.NUMBER], false, false, '4', - ), - new SlashCommandNamedArgument( - 'scan', 'include injection content into World Info scans', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false', - ), - SlashCommandNamedArgument.fromProps({ - name: 'role', - description: 'role for in-chat injections', - typeList: [ARGUMENT_TYPE.STRING], - isRequired: false, - enumList: [ - new SlashCommandEnumValue('system', null, enumTypes.enum, enumIcons.system), - new SlashCommandEnumValue('assistant', null, enumTypes.enum, enumIcons.assistant), - new SlashCommandEnumValue('user', null, enumTypes.enum, enumIcons.user), - ], - }), - new SlashCommandNamedArgument( - 'ephemeral', 'remove injection after generation', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false', - ), - ], - unnamedArgumentList: [ - new SlashCommandArgument( - 'text', [ARGUMENT_TYPE.STRING], false, - ), - ], - helpString: 'Injects a text into the LLM prompt for the current chat. Requires a unique injection ID. Positions: "before" main prompt, "after" main prompt, in-"chat" (default: after). Depth: injection depth for the prompt (default: 4). Role: role for in-chat injections (default: system). Scan: include injection content into World Info scans (default: false).', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'listinjects', - callback: listInjectsCallback, - helpString: 'Lists all script injections for the current chat.', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'flushinject', - aliases: ['flushinjects'], - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ - description: 'injection ID or a variable name pointing to ID', - typeList: [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.VARIABLE_NAME], - defaultValue: '', - enumProvider: () => [ - ...commonEnumProviders.injects(), - ...commonEnumProviders.variables('all')().map(x => { x.description = 'Variable'; return x; }), - ], - }), - ], - callback: flushInjectsCallback, - helpString: 'Removes a script injection for the current chat. If no ID is provided, removes all script injections.', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'tokens', - callback: (_, text) => { - if (text instanceof SlashCommandClosure || Array.isArray(text)) throw new Error('Unnamed argument cannot be a closure for command /tokens'); - return getTokenCountAsync(text).then(count => String(count)); - }, - returns: 'number of tokens', - unnamedArgumentList: [ - new SlashCommandArgument( - 'text', [ARGUMENT_TYPE.STRING], true, - ), - ], - helpString: 'Counts the number of tokens in the provided text.', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'model', - callback: modelCallback, - returns: 'current model', - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ - description: 'model name', - typeList: [ARGUMENT_TYPE.STRING], - enumProvider: () => getModelOptions()?.options.map(option => new SlashCommandEnumValue(option.value, option.value !== option.text ? option.text : null)), - }), - ], - helpString: 'Sets the model for the current API. Gets the current model name if no argument is provided.', -})); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'setpromptentry', - aliases: ['setpromptentries'], - callback: setPromptEntryCallback, - namedArgumentList: [ - SlashCommandNamedArgument.fromProps({ - name: 'identifier', - description: 'Prompt entry identifier(s) to target', - typeList: [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.LIST], - acceptsMultiple: true, - enumProvider: () => { - const promptManager = setupChatCompletionPromptManager(oai_settings); - const prompts = promptManager.serviceSettings.prompts; - return prompts.map(prompt => new SlashCommandEnumValue(prompt.identifier, prompt.name, enumTypes.enum)); - }, - }), - SlashCommandNamedArgument.fromProps({ - name: 'name', - description: 'Prompt entry name(s) to target', - typeList: [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.LIST], - acceptsMultiple: true, - enumProvider: () => { - const promptManager = setupChatCompletionPromptManager(oai_settings); - const prompts = promptManager.serviceSettings.prompts; - return prompts.map(prompt => new SlashCommandEnumValue(prompt.name, prompt.identifier, enumTypes.enum)); - }, - }), - ], - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ - description: 'Set entry/entries on or off', - typeList: [ARGUMENT_TYPE.STRING], - isRequired: true, - acceptsMultiple: false, - defaultValue: 'toggle', // unnamed arguments don't support default values yet - enumList: commonEnumProviders.boolean('onOffToggle')(), - }), - ], - helpString: 'Sets the specified prompt manager entry/entries on or off.', -})); - -registerVariableCommands(); + registerVariableCommands(); +} const NARRATOR_NAME_KEY = 'narrator_name'; const NARRATOR_NAME_DEFAULT = 'System'; @@ -1406,7 +1451,7 @@ function injectCallback(args, value) { 'assistant': extension_prompt_roles.ASSISTANT, }; - const id = resolveVariable(args?.id); + const id = args?.id; const ephemeral = isTrueBoolean(args?.ephemeral); if (!id) { @@ -1484,16 +1529,16 @@ function listInjectsCallback() { /** * Flushes script injections for the current chat. - * @param {import('./slash-commands/SlashCommand.js').NamedArguments} args Named arguments + * @param {import('./slash-commands/SlashCommand.js').NamedArguments} _ Named arguments * @param {string} value Unnamed argument * @returns {string} Empty string */ -function flushInjectsCallback(args, value) { +function flushInjectsCallback(_, value) { if (!chat_metadata.script_injects) { return ''; } - const idArgument = resolveVariable(value, args._scope); + const idArgument = value; for (const [id, inject] of Object.entries(chat_metadata.script_injects)) { if (idArgument && id !== idArgument) { @@ -1627,8 +1672,8 @@ async function buttonsCallback(args, text) { const buttonElement = document.createElement('div'); buttonElement.classList.add('menu_button', 'result-control', 'wide100p'); buttonElement.dataset.result = String(result); - buttonElement.addEventListener('click', () => { - popup?.complete(result); + buttonElement.addEventListener('click', async () => { + await popup.complete(result); }); buttonElement.innerText = button; buttonContainer.appendChild(buttonElement); @@ -1939,32 +1984,67 @@ async function generateCallback(args, value) { } } +/** + * + * @param {{title?: string, severity?: string, timeout?: string, extendedTimeout?: string, preventDuplicates?: string, awaitDismissal?: string}} args - named arguments from the slash command + * @param {string} value - The string to echo (unnamed argument from the slash command) + * @returns {Promise<string>} The text that was echoed + */ async function echoCallback(args, value) { // Note: We don't need to sanitize input, as toastr is set up by default to escape HTML via toastr options if (value === '') { console.warn('WARN: No argument provided for /echo command'); - return; + return ''; } - const title = args?.title !== undefined && typeof args?.title === 'string' ? args.title : undefined; - const severity = args?.severity !== undefined && typeof args?.severity === 'string' ? args.severity : 'info'; + + if (args.severity && !['error', 'warning', 'success', 'info'].includes(args.severity)) { + toastr.warning(`Invalid severity provided for /echo command: ${args.severity}`); + args.severity = null; + } + + const title = args.title ? args.title : undefined; + const severity = args.severity ? args.severity : 'info'; + + /** @type {ToastrOptions} */ + const options = {}; + if (args.timeout && !isNaN(parseInt(args.timeout))) options.timeOut = parseInt(args.timeout); + if (args.extendedTimeout && !isNaN(parseInt(args.extendedTimeout))) options.extendedTimeOut = parseInt(args.extendedTimeout); + if (isTrueBoolean(args.preventDuplicates)) options.preventDuplicates = true; + + // Prepare possible await handling + let awaitDismissal = isTrueBoolean(args.awaitDismissal); + let resolveToastDismissal; + + if (awaitDismissal) { + options.onHidden = () => resolveToastDismissal(value); + } + switch (severity) { case 'error': - toastr.error(value, title); + toastr.error(value, title, options); break; case 'warning': - toastr.warning(value, title); + toastr.warning(value, title, options); break; case 'success': - toastr.success(value, title); + toastr.success(value, title, options); break; case 'info': default: - toastr.info(value, title); + toastr.info(value, title, options); break; } - return value; + + if (awaitDismissal) { + return new Promise((resolve) => { + resolveToastDismissal = resolve; + }); + } else { + return value; + } } + async function addSwipeCallback(_, arg) { const lastMessage = chat[chat.length - 1]; @@ -2429,10 +2509,17 @@ async function sendUserMessageCallback(args, text) { text = text.trim(); const compact = isTrueBoolean(args?.compact); const bias = extractMessageBias(text); - const insertAt = Number(resolveVariable(args?.at)); + + let insertAt = Number(args?.at); + + // Convert possible depth parameter to index + if (!isNaN(insertAt) && (insertAt < 0 || insertAt === Number(-0))) { + // Negative value means going back from current chat length. (E.g.: 8 messages, Depth 1 means insert at index 7) + insertAt = chat.length + insertAt; + } if ('name' in args) { - const name = resolveVariable(args.name) || ''; + const name = args.name || ''; const avatar = findPersonaByName(name) || user_avatar; await sendMessageAsUser(text, bias, insertAt, compact, name, avatar); } @@ -2576,8 +2663,23 @@ function syncCallback() { return ''; } -function bindCallback() { - $('#lock_user_name').trigger('click'); +async function lockPersonaCallback(_args, value) { + if (['toggle', 't', ''].includes(value.trim().toLowerCase())) { + await togglePersonaLock(); + return ''; + } + + if (isTrueBoolean(value)) { + await setPersonaLockState(true); + return ''; + } + + if (isFalseBoolean(value)) { + await setPersonaLockState(false); + return ''; + + } + return ''; } @@ -2679,7 +2781,7 @@ export async function sendMessageAs(args, text) { const isSystem = bias && !removeMacros(mesText).length; const compact = isTrueBoolean(args?.compact); - const character = characters.find(x => x.name === name); + const character = characters.find(x => x.avatar === name) ?? characters.find(x => x.name === name); let force_avatar, original_avatar; if (character && character.avatar !== 'none') { @@ -2703,6 +2805,8 @@ export async function sendMessageAs(args, text) { bias: bias.trim().length ? bias : null, gen_id: Date.now(), isSmallSys: compact, + api: 'manual', + model: 'slash command', }, }; @@ -2715,12 +2819,19 @@ export async function sendMessageAs(args, text) { extra: { bias: message.extra.bias, gen_id: message.extra.gen_id, + isSmallSys: compact, api: 'manual', model: 'slash command', }, }]; - const insertAt = Number(resolveVariable(args.at)); + let insertAt = Number(args.at); + + // Convert possible depth parameter to index + if (!isNaN(insertAt) && (insertAt < 0 || insertAt === Number(-0))) { + // Negative value means going back from current chat length. (E.g.: 8 messages, Depth 1 means insert at index 7) + insertAt = chat.length + insertAt; + } if (!isNaN(insertAt) && insertAt >= 0 && insertAt <= chat.length) { chat.splice(insertAt, 0, message); @@ -2762,10 +2873,18 @@ export async function sendNarratorMessage(args, text) { bias: bias.trim().length ? bias : null, gen_id: Date.now(), isSmallSys: compact, + api: 'manual', + model: 'slash command', }, }; - const insertAt = Number(resolveVariable(args.at)); + let insertAt = Number(args.at); + + // Convert possible depth parameter to index + if (!isNaN(insertAt) && (insertAt < 0 || insertAt === Number(-0))) { + // Negative value means going back from current chat length. (E.g.: 8 messages, Depth 1 means insert at index 7) + insertAt = chat.length + insertAt; + } if (!isNaN(insertAt) && insertAt >= 0 && insertAt <= chat.length) { chat.splice(insertAt, 0, message); @@ -2812,6 +2931,8 @@ export async function promptQuietForLoudResponse(who, text) { extra: { type: system_message_types.COMMENT, gen_id: Date.now(), + api: 'manual', + model: 'slash command', }, }; @@ -2840,10 +2961,18 @@ async function sendCommentMessage(args, text) { type: system_message_types.COMMENT, gen_id: Date.now(), isSmallSys: compact, + api: 'manual', + model: 'slash command', }, }; - const insertAt = Number(resolveVariable(args.at)); + let insertAt = Number(args.at); + + // Convert possible depth parameter to index + if (!isNaN(insertAt) && (insertAt < 0 || insertAt === Number(-0))) { + // Negative value means going back from current chat length. (E.g.: 8 messages, Depth 1 means insert at index 7) + insertAt = chat.length + insertAt; + } if (!isNaN(insertAt) && insertAt >= 0 && insertAt <= chat.length) { chat.splice(insertAt, 0, message); @@ -2961,6 +3090,7 @@ function getModelOptions() { { id: 'model_cohere_select', api: 'openai', type: chat_completion_sources.COHERE }, { id: 'model_perplexity_select', api: 'openai', type: chat_completion_sources.PERPLEXITY }, { id: 'model_groq_select', api: 'openai', type: chat_completion_sources.GROQ }, + { id: 'model_01ai_select', api: 'openai', type: chat_completion_sources.ZEROONEAI }, { id: 'model_novel_select', api: 'novel', type: null }, { id: 'horde_model', api: 'koboldhorde', type: null }, ]; @@ -3261,9 +3391,9 @@ export async function executeSlashCommandsOnChatInput(text, options = {}) { document.querySelector('#form_sheld').classList.add('script_error'); result = new SlashCommandClosureResult(); result.isError = true; - result.errorMessage = e.message; + result.errorMessage = e.message || 'An unknown error occurred'; if (e.cause !== 'abort') { - toastr.error(e.message); + toastr.error(result.errorMessage); } } finally { delay(1000).then(() => clearCommandProgressDebounced()); diff --git a/public/scripts/slash-commands/SlashCommandArgument.js b/public/scripts/slash-commands/SlashCommandArgument.js index 76642a160..02c88b7c2 100644 --- a/public/scripts/slash-commands/SlashCommandArgument.js +++ b/public/scripts/slash-commands/SlashCommandArgument.js @@ -59,7 +59,7 @@ export class SlashCommandArgument { * @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} enums * @param {(executor:SlashCommandExecutor)=>SlashCommandEnumValue[]} enumProvider function that returns auto complete options */ - constructor(description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], enumProvider = null, forceEnum = true) { + constructor(description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], enumProvider = null, forceEnum = false) { this.description = description; this.typeList = types ? Array.isArray(types) ? types : [types] : []; this.isRequired = isRequired ?? false; @@ -90,7 +90,7 @@ export class SlashCommandNamedArgument extends SlashCommandArgument { * @param {string|SlashCommandClosure} [props.defaultValue=null] default value if no value is provided * @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} [props.enumList=[]] list of accepted values * @param {(executor:SlashCommandExecutor)=>SlashCommandEnumValue[]} [props.enumProvider=null] function that returns auto complete options - * @param {boolean} [props.forceEnum=true] default: true - whether the input must match one of the enum values + * @param {boolean} [props.forceEnum=false] default: false - whether the input must match one of the enum values */ static fromProps(props) { return new SlashCommandNamedArgument( @@ -103,7 +103,7 @@ export class SlashCommandNamedArgument extends SlashCommandArgument { props.enumList ?? [], props.aliasList ?? [], props.enumProvider ?? null, - props.forceEnum ?? true, + props.forceEnum ?? false, ); } @@ -120,9 +120,9 @@ export class SlashCommandNamedArgument extends SlashCommandArgument { * @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} [enums=[]] * @param {string[]} [aliases=[]] * @param {(executor:SlashCommandExecutor)=>SlashCommandEnumValue[]} [enumProvider=null] function that returns auto complete options - * @param {boolean} [forceEnum=true] + * @param {boolean} [forceEnum=false] */ - constructor(name, description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], aliases = [], enumProvider = null, forceEnum = true) { + constructor(name, description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], aliases = [], enumProvider = null, forceEnum = false) { super(description, types, isRequired, acceptsMultiple, defaultValue, enums, enumProvider, forceEnum); this.name = name; this.aliasList = aliases ? Array.isArray(aliases) ? aliases : [aliases] : []; diff --git a/public/scripts/sse-stream.js b/public/scripts/sse-stream.js index 8d7fe9299..c8371b529 100644 --- a/public/scripts/sse-stream.js +++ b/public/scripts/sse-stream.js @@ -160,7 +160,7 @@ async function* parseStreamData(json) { return; } // llama.cpp? - else if (typeof json.content === 'string' && json.content.length > 0) { + else if (typeof json.content === 'string' && json.content.length > 0 && json.object !== 'chat.completion.chunk') { for (let i = 0; i < json.content.length; i++) { const str = json.content[i]; yield { diff --git a/public/scripts/tags.js b/public/scripts/tags.js index 678beee20..9d09c8219 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -801,8 +801,7 @@ async function showTagImportPopup(character, existingTags, newTags, folderTags) if (folderTags.length === 0) popupContent.find('#folder_tags_block').hide(); function onCloseRemember(/** @type {Popup} */ popup) { - const rememberCheckbox = document.getElementById('import_remember_option'); - if (rememberCheckbox instanceof HTMLInputElement && rememberCheckbox.checked) { + if (popup.result && popup.inputResults.get('import_remember_option')) { const setting = buttonSettingsMap[popup.result]; if (!setting) return; power_user.tag_import_setting = setting; @@ -812,7 +811,12 @@ async function showTagImportPopup(character, existingTags, newTags, folderTags) } } - const result = await callGenericPopup(popupContent, POPUP_TYPE.TEXT, null, { wider: true, okButton: 'Import', cancelButton: true, customButtons: Object.values(importButtons), onClose: onCloseRemember }); + const result = await callGenericPopup(popupContent, POPUP_TYPE.TEXT, null, { + wider: true, okButton: 'Import', cancelButton: true, + customButtons: Object.values(importButtons), + customInputs: [{ id: 'import_remember_option', label: 'Remember my choice', tooltip: 'Remember the chosen import option\nIf anything besides \'Cancel\' is selected, this dialog will not show up anymore.\nTo change this, go to the settings and modify "Tag Import Option".\n\nIf the "Import" option is chosen, the global setting will stay on "Ask".' }], + onClose: onCloseRemember + }); if (!result) { return []; } diff --git a/public/scripts/templates/charTagImport.html b/public/scripts/templates/charTagImport.html index 93bd69ba4..33104fab0 100644 --- a/public/scripts/templates/charTagImport.html +++ b/public/scripts/templates/charTagImport.html @@ -19,17 +19,4 @@ </small> <div id="import_folder_tags_list" class="tags" style="margin-top: 5px;"></div> </div> - - <small> - <label class="checkbox flex-container alignitemscenter flexNoGap m-t-3" for="import_remember_option"> - <input type="checkbox" id="import_remember_option" name="import_remember_option" /> - <span> - <span data-i18n="Remember my choice">Remember my choice</span> - <div class="fa-solid fa-circle-info opacity50p" - data-i18n="[title]Remember the chosen import option If anything besides 'Cancel' is selected, this dialog will not show up anymore. To change this, go to the settings and modify "Tag Import Option". If the "Import" option is chosen, the global setting will stay on "Ask"." - title="Remember the chosen import option If anything besides 'Cancel' is selected, this dialog will not show up anymore. To change this, go to the settings and modify "Tag Import Option". If the "Import" option is chosen, the global setting will stay on "Ask"."> - </div> - </span> - </label> - </small> </div> diff --git a/public/scripts/templates/promptManagerFooter.html b/public/scripts/templates/promptManagerFooter.html index ccd328c62..20996c58b 100644 --- a/public/scripts/templates/promptManagerFooter.html +++ b/public/scripts/templates/promptManagerFooter.html @@ -2,10 +2,10 @@ <select id="{{prefix}}prompt_manager_footer_append_prompt" class="text_pole" name="append-prompt"> {{{promptsHtml}}} </select> - <a class="menu_button fa-chain fa-solid" title="Insert prompt" data-i18n="[title]Insert prompt"></a> - <a class="caution menu_button fa-x fa-solid" title="Delete prompt" data-i18n="[title]Delete prompt"></a> - <a class="menu_button fa-file-import fa-solid" id="prompt-manager-import" title="Import a prompt list" data-i18n="[title]Import a prompt list"></a> - <a class="menu_button fa-file-export fa-solid" id="prompt-manager-export" title="Export this prompt list" data-i18n="[title]Export this prompt list"></a> - <a class="menu_button fa-undo fa-solid" id="prompt-manager-reset-character" title="Reset current character" data-i18n="[title]Reset current character"></a> - <a class="menu_button fa-plus-square fa-solid" title="New prompt" data-i18n="[title]New prompt"></a> + <a class="menu_button fa-chain fa-solid fa-fw" title="Insert prompt" data-i18n="[title]Insert prompt"></a> + <a class="caution menu_button fa-x fa-solid fa-fw" title="Delete prompt" data-i18n="[title]Delete prompt"></a> + <a class="menu_button fa-file-import fa-solid fa-fw" id="prompt-manager-import" title="Import a prompt list" data-i18n="[title]Import a prompt list"></a> + <a class="menu_button fa-file-export fa-solid fa-fw" id="prompt-manager-export" title="Export this prompt list" data-i18n="[title]Export this prompt list"></a> + <a class="menu_button fa-undo fa-solid fa-fw" id="prompt-manager-reset-character" title="Reset current character" data-i18n="[title]Reset current character"></a> + <a class="menu_button fa-plus-square fa-solid fa-fw" title="New prompt" data-i18n="[title]New prompt"></a> </div> diff --git a/public/scripts/textgen-models.js b/public/scripts/textgen-models.js index 5f663c816..a33c0d542 100644 --- a/public/scripts/textgen-models.js +++ b/public/scripts/textgen-models.js @@ -9,6 +9,7 @@ let infermaticAIModels = []; let dreamGenModels = []; let vllmModels = []; let aphroditeModels = []; +let featherlessModels = []; export let openRouterModels = []; /** @@ -233,6 +234,35 @@ export async function loadAphroditeModels(data) { } } +export async function loadFeatherlessModels(data) { + if (!Array.isArray(data)) { + console.error('Invalid Featherless models data', data); + return; + } + + featherlessModels = data; + + if (!data.find(x => x.id === textgen_settings.featherless_model)) { + textgen_settings.featherless_model = data[0]?.id || ''; + } + + $('#featherless_model').empty(); + for (const model of data) { + const option = document.createElement('option'); + option.value = model.id; + option.text = model.id; + option.selected = model.id === textgen_settings.featherless_model; + $('#featherless_model').append(option); + } +} + +function onFeatherlessModelSelect() { + const modelId = String($('#featherless_model').val()); + textgen_settings.featherless_model = modelId; + $('#api_button_textgenerationwebui').trigger('click'); +} + + function onMancerModelSelect() { const modelId = String($('#mancer_model').val()); textgen_settings.mancer_model = modelId; @@ -507,6 +537,7 @@ jQuery(function () { $('#ollama_download_model').on('click', downloadOllamaModel); $('#vllm_model').on('change', onVllmModelSelect); $('#aphrodite_model').on('change', onAphroditeModelSelect); + $('#featherless_model').on('change', onFeatherlessModelSelect); const providersSelect = $('.openrouter_providers'); for (const provider of OPENROUTER_PROVIDERS) { @@ -572,6 +603,12 @@ jQuery(function () { width: '100%', templateResult: getAphroditeModelTemplate, }); + $('#featherless_model').select2({ + placeholder: 'Select a model', + searchInputPlaceholder: 'Search models...', + searchInputCssClass: 'text_pole', + width: '100%', + }); providersSelect.select2({ sorter: data => data.sort((a, b) => a.text.localeCompare(b.text)), placeholder: 'Select providers. No selection = all providers.', diff --git a/public/scripts/textgen-settings.js b/public/scripts/textgen-settings.js index 6623f1d9e..a38a63a1d 100644 --- a/public/scripts/textgen-settings.js +++ b/public/scripts/textgen-settings.js @@ -38,9 +38,26 @@ export const textgen_types = { INFERMATICAI: 'infermaticai', DREAMGEN: 'dreamgen', OPENROUTER: 'openrouter', + FEATHERLESS: 'featherless', + HUGGINGFACE: 'huggingface', }; -const { MANCER, VLLM, APHRODITE, TABBY, TOGETHERAI, OOBA, OLLAMA, LLAMACPP, INFERMATICAI, DREAMGEN, OPENROUTER, KOBOLDCPP } = textgen_types; +const { + MANCER, + VLLM, + APHRODITE, + TABBY, + TOGETHERAI, + OOBA, + OLLAMA, + LLAMACPP, + INFERMATICAI, + DREAMGEN, + OPENROUTER, + KOBOLDCPP, + HUGGINGFACE, + FEATHERLESS, +} = textgen_types; const LLAMACPP_DEFAULT_ORDER = [ 'top_k', @@ -75,6 +92,7 @@ let TOGETHERAI_SERVER = 'https://api.together.xyz'; let INFERMATICAI_SERVER = 'https://api.totalgpt.ai'; let DREAMGEN_SERVER = 'https://dreamgen.com'; let OPENROUTER_SERVER = 'https://openrouter.ai/api'; +let FEATHERLESS_SERVER = 'https://api.featherless.ai/v1'; const SERVER_INPUTS = { [textgen_types.OOBA]: '#textgenerationwebui_api_url_text', @@ -84,6 +102,7 @@ const SERVER_INPUTS = { [textgen_types.KOBOLDCPP]: '#koboldcpp_api_url_text', [textgen_types.LLAMACPP]: '#llamacpp_api_url_text', [textgen_types.OLLAMA]: '#ollama_api_url_text', + [textgen_types.HUGGINGFACE]: '#huggingface_api_url_text', }; const KOBOLDCPP_ORDER = [6, 0, 1, 3, 4, 2, 5]; @@ -244,6 +263,8 @@ export const setting_names = [ 'bypass_status_check', ]; +const DYNATEMP_BLOCK = document.getElementById('dynatemp_block_ooba'); + export function validateTextGenUrl() { const selector = SERVER_INPUTS[settings.type]; @@ -265,6 +286,8 @@ export function validateTextGenUrl() { export function getTextGenServer() { switch (settings.type) { + case FEATHERLESS: + return FEATHERLESS_SERVER; case MANCER: return MANCER_SERVER; case TOGETHERAI: @@ -1009,6 +1032,10 @@ export function getTextGenModel() { throw new Error('No Ollama model selected'); } return settings.ollama_model; + case FEATHERLESS: + return settings.featherless_model; + case HUGGINGFACE: + return 'tgi'; default: return undefined; } @@ -1020,6 +1047,10 @@ export function isJsonSchemaSupported() { return [TABBY, LLAMACPP].includes(settings.type) && main_api === 'textgenerationwebui'; } +function isDynamicTemperatureSupported() { + return settings.dynatemp && DYNATEMP_BLOCK?.dataset?.tgType?.includes(settings.type); +} + function getLogprobsNumber() { if (settings.type === VLLM || settings.type === INFERMATICAI) { return 5; @@ -1030,6 +1061,7 @@ function getLogprobsNumber() { export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate, isContinue, cfgValues, type) { const canMultiSwipe = !isContinue && !isImpersonate && type !== 'quiet'; + const dynatemp = isDynamicTemperatureSupported(); const { banned_tokens, banned_strings } = getCustomTokenBans(); let params = { @@ -1038,7 +1070,7 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate, 'max_new_tokens': maxTokens, 'max_tokens': maxTokens, 'logprobs': power_user.request_token_probabilities ? getLogprobsNumber() : undefined, - 'temperature': settings.dynatemp ? (settings.min_temp + settings.max_temp) / 2 : settings.temp, + 'temperature': dynatemp ? (settings.min_temp + settings.max_temp) / 2 : settings.temp, 'top_p': settings.top_p, 'typical_p': settings.typical_p, 'typical': settings.typical_p, @@ -1056,11 +1088,11 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate, 'length_penalty': settings.length_penalty, 'early_stopping': settings.early_stopping, 'add_bos_token': settings.add_bos_token, - 'dynamic_temperature': settings.dynatemp ? true : undefined, - 'dynatemp_low': settings.dynatemp ? settings.min_temp : undefined, - 'dynatemp_high': settings.dynatemp ? settings.max_temp : undefined, - 'dynatemp_range': settings.dynatemp ? (settings.max_temp - settings.min_temp) / 2 : undefined, - 'dynatemp_exponent': settings.dynatemp ? settings.dynatemp_exponent : undefined, + 'dynamic_temperature': dynatemp ? true : undefined, + 'dynatemp_low': dynatemp ? settings.min_temp : undefined, + 'dynatemp_high': dynatemp ? settings.max_temp : undefined, + 'dynatemp_range': dynatemp ? (settings.max_temp - settings.min_temp) / 2 : undefined, + 'dynatemp_exponent': dynatemp ? settings.dynatemp_exponent : undefined, 'smoothing_factor': settings.smoothing_factor, 'smoothing_curve': settings.smoothing_curve, 'dry_allowed_length': settings.dry_allowed_length, @@ -1146,6 +1178,12 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate, params.grammar = settings.grammar_string; } + if (settings.type === HUGGINGFACE) { + params.top_p = Math.min(Math.max(Number(params.top_p), 0.0), 0.999); + params.stop = Array.isArray(params.stop) ? params.stop.slice(0, 4) : []; + nonAphroditeParams.seed = settings.seed >= 0 ? settings.seed : undefined; + } + if (settings.type === MANCER) { params.n = canMultiSwipe ? settings.n : 1; params.epsilon_cutoff /= 1000; diff --git a/public/scripts/tokenizers.js b/public/scripts/tokenizers.js index 5c017ad83..799646a4c 100644 --- a/public/scripts/tokenizers.js +++ b/public/scripts/tokenizers.js @@ -545,6 +545,10 @@ export function getTokenizerModel() { } } + if (oai_settings.chat_completion_source === chat_completion_sources.ZEROONEAI) { + return yiTokenizer; + } + // Default to Turbo 3.5 return turboTokenizer; } diff --git a/public/scripts/user.js b/public/scripts/user.js index 993c0ce3f..4aab9bb1b 100644 --- a/public/scripts/user.js +++ b/public/scripts/user.js @@ -592,7 +592,7 @@ async function viewSettingsSnapshots() { } } - callGenericPopup(template, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: false, large: false }); + callGenericPopup(template, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: false, large: false, allowVerticalScrolling: true }); template.find('.makeSnapshotButton').on('click', () => makeSnapshot(renderSnapshots)); renderSnapshots(); } diff --git a/public/scripts/utils.js b/public/scripts/utils.js index 1e140a6ae..9e437d30a 100644 --- a/public/scripts/utils.js +++ b/public/scripts/utils.js @@ -803,7 +803,7 @@ export function getImageSizeFromDataURL(dataUrl) { export function getCharaFilename(chid) { const context = getContext(); - const fileName = context.characters[chid ?? context.characterId].avatar; + const fileName = context.characters[chid ?? context.characterId]?.avatar; if (fileName) { return fileName.replace(/\.[^/.]+$/, ''); diff --git a/public/scripts/variables.js b/public/scripts/variables.js index 3117f07ec..d99f29c8a 100644 --- a/public/scripts/variables.js +++ b/public/scripts/variables.js @@ -1358,7 +1358,11 @@ export function registerVariableCommands() { name: 'times', callback: timesCallback, returns: 'result of the last executed command', - namedArgumentList: [], + namedArgumentList: [ + new SlashCommandNamedArgument( + 'guard', 'disable loop iteration limit', [ARGUMENT_TYPE.STRING], false, false, null, commonEnumProviders.boolean('onOff')(), + ), + ], unnamedArgumentList: [ new SlashCommandArgument( 'repeats', diff --git a/public/scripts/world-info.js b/public/scripts/world-info.js index 23db6945e..515faf23f 100644 --- a/public/scripts/world-info.js +++ b/public/scripts/world-info.js @@ -24,6 +24,7 @@ export { world_info_depth, world_info_min_activations, world_info_min_activations_depth_max, + world_info_include_names, world_info_recursive, world_info_overflow_alert, world_info_case_sensitive, @@ -50,6 +51,28 @@ const world_info_logic = { AND_ALL: 3, }; +/** + * @enum {number} Possible states of the WI evaluation + */ +const scan_state = { + /** + * The scan will be stopped. + */ + NONE: 0, + /** + * Initial state. + */ + INITIAL: 1, + /** + * The scan is triggered by a recursion step. + */ + RECURSION: 2, + /** + * The scan is triggered by a min activations depth skew. + */ + MIN_ACTIVATIONS: 3, +}; + const WI_ENTRY_EDIT_TEMPLATE = $('#entry_edit_template .world_entry'); let world_info = {}; @@ -61,6 +84,7 @@ let world_info_min_activations = 0; // if > 0, will continue seeking chat until let world_info_min_activations_depth_max = 0; // used when (world_info_min_activations > 0) let world_info_budget = 25; +let world_info_include_names = true; let world_info_recursive = false; let world_info_overflow_alert = false; let world_info_case_sensitive = false; @@ -99,6 +123,7 @@ const MAX_SCAN_DEPTH = 1000; * @property {number} [selectiveLogic] The logic to use for selective activation * @property {number} [sticky] The sticky value of the entry * @property {number} [cooldown] The cooldown of the entry + * @property {number} [delay] The delay of the entry */ /** @@ -111,7 +136,7 @@ const MAX_SCAN_DEPTH = 1000; /** * @typedef TimedEffectType Type of timed effect - * @type {'sticky'|'cooldown'} + * @type {'sticky'|'cooldown'|'delay'} */ // End typedef area @@ -134,6 +159,11 @@ class WorldInfoBuffer { */ #recurseBuffer = []; + /** + * @type {string[]} Array of strings added by prompt injections that are valid for the current scan + */ + #injectBuffer = []; + /** * @type {number} The skew of the global scan depth. Used in "min activations" */ @@ -183,9 +213,10 @@ class WorldInfoBuffer { /** * Gets all messages up to the given depth + recursion buffer. * @param {WIScanEntry} entry The entry that triggered the scan + * @param {number} scanState The state of the scan * @returns {string} A slice of buffer until the given depth (inclusive) */ - get(entry) { + get(entry, scanState) { let depth = entry.scanDepth ?? this.getDepth(); if (depth <= this.#startDepth) { return ''; @@ -203,7 +234,12 @@ class WorldInfoBuffer { let result = this.#depthBuffer.slice(this.#startDepth, depth).join('\n'); - if (this.#recurseBuffer.length > 0) { + if (this.#injectBuffer.length > 0) { + result += '\n' + this.#injectBuffer.join('\n'); + } + + // Min activations should not include the recursion buffer + if (this.#recurseBuffer.length > 0 && scanState !== scan_state.MIN_ACTIVATIONS) { result += '\n' + this.#recurseBuffer.join('\n'); } @@ -257,6 +293,14 @@ class WorldInfoBuffer { this.#recurseBuffer.push(message); } + /** + * Adds an injection to the buffer. + * @param {string} message The injection to add + */ + addInject(message) { + this.#injectBuffer.push(message); + } + /** * Increments skew and sets startDepth to previous depth. */ @@ -292,10 +336,11 @@ class WorldInfoBuffer { /** * Gets the match score for the given entry. * @param {WIScanEntry} entry Entry to check + * @param {number} scanState The state of the scan * @returns {number} The number of key activations for the given entry */ - getScore(entry) { - const bufferState = this.get(entry); + getScore(entry, scanState) { + const bufferState = this.get(entry, scanState); let numberOfPrimaryKeys = 0; let numberOfSecondaryKeys = 0; let primaryScore = 0; @@ -371,6 +416,7 @@ class WorldInfoTimedEffects { #buffer = { 'sticky': [], 'cooldown': [], + 'delay': [], }; /** @@ -404,6 +450,8 @@ class WorldInfoTimedEffects { 'cooldown': (entry) => { console.debug('Cooldown ended for entry', entry.uid); }, + + 'delay': () => {}, }; /** @@ -529,12 +577,31 @@ class WorldInfoTimedEffects { } } + /** + * Processes entries for the "delay" timed effect. + * @param {WIScanEntry[]} buffer Buffer to store the entries + */ + #checkDelayEffect(buffer) { + for (const entry of this.#entries) { + if (!entry.delay) { + continue; + } + + if (this.#chat.length < entry.delay) { + buffer.push(entry); + console.log('Timed effect "delay" applied to entry', entry); + } + } + + } + /** * Checks for timed effects on chat messages. */ checkTimedEffects() { this.#checkTimedEffectOfType('sticky', this.#buffer.sticky, this.#onEnded.sticky.bind(this)); this.#checkTimedEffectOfType('cooldown', this.#buffer.cooldown, this.#onEnded.cooldown.bind(this)); + this.#checkDelayEffect(this.#buffer.delay); } /** @@ -611,7 +678,7 @@ class WorldInfoTimedEffects { * @returns {boolean} Is recognized type */ isValidEffectType(type) { - return typeof type === 'string' && ['sticky', 'cooldown'].includes(type.trim().toLowerCase()); + return typeof type === 'string' && ['sticky', 'cooldown', 'delay'].includes(type.trim().toLowerCase()); } /** @@ -645,6 +712,7 @@ export function getWorldInfoSettings() { world_info_min_activations, world_info_min_activations_depth_max, world_info_budget, + world_info_include_names, world_info_recursive, world_info_overflow_alert, world_info_case_sensitive, @@ -674,7 +742,7 @@ const worldInfoCache = new Map(); /** * Gets the world info based on chat messages. - * @param {string[]} chat The chat messages to scan. + * @param {string[]} chat The chat messages to scan, in reverse order. * @param {number} maxContext The maximum context size of the generation. * @param {boolean} isDryRun If true, the function will not emit any events. * @typedef {{worldInfoString: string, worldInfoBefore: string, worldInfoAfter: string, worldInfoExamples: any[], worldInfoDepth: any[]}} WIPromptResult @@ -711,6 +779,8 @@ function setWorldInfoSettings(settings, data) { world_info_min_activations_depth_max = Number(settings.world_info_min_activations_depth_max); if (settings.world_info_budget !== undefined) world_info_budget = Number(settings.world_info_budget); + if (settings.world_info_include_names !== undefined) + world_info_include_names = Boolean(settings.world_info_include_names); if (settings.world_info_recursive !== undefined) world_info_recursive = Boolean(settings.world_info_recursive); if (settings.world_info_overflow_alert !== undefined) @@ -760,6 +830,7 @@ function setWorldInfoSettings(settings, data) { $('#world_info_budget_counter').val(world_info_budget); $('#world_info_budget').val(world_info_budget); + $('#world_info_include_names').prop('checked', world_info_include_names); $('#world_info_recursive').prop('checked', world_info_recursive); $('#world_info_overflow_alert').prop('checked', world_info_overflow_alert); $('#world_info_case_sensitive').prop('checked', world_info_case_sensitive); @@ -1685,7 +1756,7 @@ function displayWorldEntries(name, data, navigation = navigation_option.none, fl // Regardless of whether success is displayed or not. Make sure the delete button is available. // Do not put this code behind. $('#world_popup_delete').off('click').on('click', async () => { - const confirmation = await Popup.show.confirm(`Delete the World/Lorebook: "${name}"?`, `This action is irreversible!`); + const confirmation = await Popup.show.confirm(`Delete the World/Lorebook: "${name}"?`, 'This action is irreversible!'); if (!confirmation) { return; } @@ -1733,14 +1804,21 @@ function displayWorldEntries(name, data, navigation = navigation_option.none, fl return entriesArray; } + const storageKey = 'WI_PerPage'; + const perPageDefault = 25; let startPage = 1; if (navigation === navigation_option.previous) { startPage = $('#world_info_pagination').pagination('getCurrentPageNum'); } - const storageKey = 'WI_PerPage'; - const perPageDefault = 25; + if (typeof navigation === 'number' && Number(navigation) >= 0) { + const data = getDataArray(); + const uidIndex = data.findIndex(x => x.uid === navigation); + const perPage = Number(localStorage.getItem(storageKey)) || perPageDefault; + startPage = Math.floor(uidIndex / perPage) + 1; + } + $('#world_info_pagination').pagination({ dataSource: getDataArray, pageSize: Number(localStorage.getItem(storageKey)) || perPageDefault, @@ -1801,15 +1879,8 @@ function displayWorldEntries(name, data, navigation = navigation_option.none, fl }, }); - - if (typeof navigation === 'number' && Number(navigation) >= 0) { const selector = `#world_popup_entries_list [uid="${navigation}"]`; - const data = getDataArray(); - const uidIndex = data.findIndex(x => x.uid === navigation); - const perPage = Number(localStorage.getItem(storageKey)) || perPageDefault; - const page = Math.floor(uidIndex / perPage) + 1; - $('#world_info_pagination').pagination('go', page); waitUntilCondition(() => document.querySelector(selector) !== null).finally(() => { const element = $(selector); @@ -1941,6 +2012,7 @@ const originalDataKeyMap = { 'groupWeight': 'extensions.group_weight', 'sticky': 'extensions.sticky', 'cooldown': 'extensions.cooldown', + 'delay': 'extensions.delay', }; /** Checks the state of the current search, and adds/removes the search sorting option accordingly */ @@ -2589,6 +2661,19 @@ function getWorldEntry(name, data, entry) { }); cooldown.val(entry.cooldown > 0 ? entry.cooldown : '').trigger('input'); + // delay + const delay = template.find('input[name="delay"]'); + delay.data('uid', entry.uid); + delay.on('input', function () { + const uid = $(this).data('uid'); + const value = Number($(this).val()); + data.entries[uid].delay = !isNaN(value) ? value : null; + + setOriginalDataValue(data, uid, 'extensions.delay', data.entries[uid].delay); + saveWorldInfo(name, data); + }); + delay.val(entry.delay > 0 ? entry.delay : '').trigger('input'); + // probability if (entry.probability === undefined) { entry.probability = null; @@ -3139,6 +3224,7 @@ const newEntryDefinition = { role: { default: 0, type: 'enum' }, sticky: { default: null, type: 'number?' }, cooldown: { default: null, type: 'number?' }, + delay: { default: null, type: 'number?' }, }; const newEntryTemplate = Object.fromEntries( @@ -3447,7 +3533,7 @@ export async function getSortedEntries() { /** * Performs a scan on the chat and returns the world info activated. - * @param {string[]} chat The chat messages to scan. + * @param {string[]} chat The chat messages to scan, in reverse order. * @param {number} maxContext The maximum context size of the generation. * @param {boolean} isDryRun Whether to perform a dry run. * @typedef {{ worldInfoBefore: string, worldInfoAfter: string, EMEntries: any[], WIDepthEntries: any[], allActivatedEntries: Set<any> }} WIActivated @@ -3465,12 +3551,12 @@ async function checkWorldInfo(chat, maxContext, isDryRun) { if (context.extensionPrompts[key]?.scan) { const prompt = getExtensionPromptByName(key); if (prompt) { - buffer.addRecurse(prompt); + buffer.addInject(prompt); } } } - let needsToScan = true; + let scanState = scan_state.INITIAL; let token_budget_overflowed = false; let count = 0; let allActivatedEntries = new Set(); @@ -3494,8 +3580,9 @@ async function checkWorldInfo(chat, maxContext, isDryRun) { return { worldInfoBefore: '', worldInfoAfter: '', WIDepthEntries: [], EMEntries: [], allActivatedEntries: new Set() }; } - while (needsToScan) { - // Track how many times the loop has run + while (scanState) { + // Track how many times the loop has run. May be useful for debugging. + // eslint-disable-next-line no-unused-vars count++; let activatedNow = new Set(); @@ -3533,9 +3620,15 @@ async function checkWorldInfo(chat, maxContext, isDryRun) { const isSticky = timedEffects.isEffectActive('sticky', entry); const isCooldown = timedEffects.isEffectActive('cooldown', entry); + const isDelay = timedEffects.isEffectActive('delay', entry); + + if (isDelay) { + console.debug(`WI entry ${entry.uid} suppressed by delay`, entry); + continue; + } if (isCooldown && !isSticky) { - console.debug(`WI entry ${entry.uid} suppressed by cooldown`); + console.debug(`WI entry ${entry.uid} suppressed by cooldown`, entry); continue; } @@ -3543,7 +3636,18 @@ async function checkWorldInfo(chat, maxContext, isDryRun) { continue; } - if (allActivatedEntries.has(entry) || entry.disable == true || (count > 1 && world_info_recursive && entry.excludeRecursion) || (count == 1 && entry.delayUntilRecursion)) { + if (allActivatedEntries.has(entry) || entry.disable == true) { + continue; + } + + // Only use checks for recursion flags if the scan step was activated by recursion + if (scanState !== scan_state.RECURSION && entry.delayUntilRecursion) { + console.debug(`WI entry ${entry.uid} suppressed by delay until recursion`, entry); + continue; + } + + if (scanState === scan_state.RECURSION && world_info_recursive && entry.excludeRecursion) { + console.debug(`WI entry ${entry.uid} suppressed by exclude recursion`, entry); continue; } @@ -3558,7 +3662,7 @@ async function checkWorldInfo(chat, maxContext, isDryRun) { primary: for (let key of entry.key) { const substituted = substituteParams(key); - const textToScan = buffer.get(entry); + const textToScan = buffer.get(entry, scanState); if (substituted && buffer.matchKeys(textToScan, substituted.trim(), entry)) { console.debug(`WI UID ${entry.uid} found by primary match: ${substituted}.`); @@ -3621,14 +3725,14 @@ async function checkWorldInfo(chat, maxContext, isDryRun) { } } - needsToScan = world_info_recursive && activatedNow.size > 0; + scanState = world_info_recursive && activatedNow.size > 0 ? scan_state.RECURSION : scan_state.NONE; const newEntries = [...activatedNow] .sort((a, b) => sortedEntries.indexOf(a) - sortedEntries.indexOf(b)); let newContent = ''; const textToScanTokens = await getTokenCountAsync(allActivatedText); const probabilityChecksBefore = failedProbabilityChecks.size; - filterByInclusionGroups(newEntries, allActivatedEntries, buffer); + filterByInclusionGroups(newEntries, allActivatedEntries, buffer, scanState); console.debug('-- PROBABILITY CHECKS BEGIN --'); for (const entry of newEntries) { @@ -3653,7 +3757,7 @@ async function checkWorldInfo(chat, maxContext, isDryRun) { console.log('Alerting'); toastr.warning(`World info budget reached after ${allActivatedEntries.size} entries.`, 'World Info'); } - needsToScan = false; + scanState = scan_state.NONE; token_budget_overflowed = true; break; } @@ -3666,15 +3770,15 @@ async function checkWorldInfo(chat, maxContext, isDryRun) { if ((probabilityChecksAfter - probabilityChecksBefore) === activatedNow.size) { console.debug('WI probability checks failed for all activated entries, stopping'); - needsToScan = false; + scanState = scan_state.NONE; } if (newEntries.length === 0) { console.debug('No new entries activated, stopping'); - needsToScan = false; + scanState = scan_state.NONE; } - if (needsToScan) { + if (scanState) { const text = newEntries .filter(x => !failedProbabilityChecks.has(x)) .filter(x => !x.preventRecursion) @@ -3684,7 +3788,7 @@ async function checkWorldInfo(chat, maxContext, isDryRun) { } // world_info_min_activations - if (!needsToScan && !token_budget_overflowed) { + if (!scanState && !token_budget_overflowed) { if (world_info_min_activations > 0 && (allActivatedEntries.size < world_info_min_activations)) { let over_max = ( world_info_min_activations_depth_max > 0 && @@ -3692,7 +3796,7 @@ async function checkWorldInfo(chat, maxContext, isDryRun) { ) || (buffer.getDepth() > chat.length); if (!over_max) { - needsToScan = true; // loop + scanState = scan_state.MIN_ACTIVATIONS; // loop buffer.advanceScanPosition(); } } @@ -3780,8 +3884,9 @@ async function checkWorldInfo(chat, maxContext, isDryRun) { * @param {Record<string, WIScanEntry[]>} groups The groups to filter * @param {WorldInfoBuffer} buffer The buffer to use for scoring * @param {(entry: WIScanEntry) => void} removeEntry The function to remove an entry + * @param {number} scanState The current scan state */ -function filterGroupsByScoring(groups, buffer, removeEntry) { +function filterGroupsByScoring(groups, buffer, removeEntry, scanState) { for (const [key, group] of Object.entries(groups)) { // Group scoring is disabled both globally and for the group entries if (!world_info_use_group_scoring && !group.some(x => x.useGroupScoring)) { @@ -3789,7 +3894,7 @@ function filterGroupsByScoring(groups, buffer, removeEntry) { continue; } - const scores = group.map(entry => buffer.getScore(entry)); + const scores = group.map(entry => buffer.getScore(entry, scanState)); const maxScore = Math.max(...scores); console.debug(`Group '${key}' max score: ${maxScore}`); //console.table(group.map((entry, i) => ({ uid: entry.uid, key: JSON.stringify(entry.key), score: scores[i] }))); @@ -3817,8 +3922,9 @@ function filterGroupsByScoring(groups, buffer, removeEntry) { * @param {object[]} newEntries Entries activated on current recursion level * @param {Set<object>} allActivatedEntries Set of all activated entries * @param {WorldInfoBuffer} buffer The buffer to use for scanning + * @param {number} scanState The current scan state */ -function filterByInclusionGroups(newEntries, allActivatedEntries, buffer) { +function filterByInclusionGroups(newEntries, allActivatedEntries, buffer, scanState) { console.debug('-- INCLUSION GROUP CHECKS BEGIN --'); const grouped = newEntries.filter(x => x.group).reduce((acc, item) => { item.group.split(/,\s*/).filter(x => x).forEach(group => { @@ -3847,7 +3953,7 @@ function filterByInclusionGroups(newEntries, allActivatedEntries, buffer) { } } - filterGroupsByScoring(grouped, buffer, removeEntry); + filterGroupsByScoring(grouped, buffer, removeEntry, scanState); for (const [key, group] of Object.entries(grouped)) { console.debug(`Checking inclusion group '${key}' with ${group.length} entries`, group); @@ -3933,6 +4039,7 @@ function convertAgnaiMemoryBook(inputObj) { role: extension_prompt_roles.SYSTEM, sticky: null, cooldown: null, + delay: null, }; }); @@ -3974,6 +4081,7 @@ function convertRisuLorebook(inputObj) { role: extension_prompt_roles.SYSTEM, sticky: null, cooldown: null, + delay: null, }; }); @@ -4020,6 +4128,7 @@ function convertNovelLorebook(inputObj) { role: extension_prompt_roles.SYSTEM, sticky: null, cooldown: null, + delay: null, }; }); @@ -4068,6 +4177,7 @@ function convertCharacterBook(characterBook) { vectorized: entry.extensions?.vectorized ?? false, sticky: entry.extensions?.sticky ?? null, cooldown: entry.extensions?.cooldown ?? null, + delay: entry.extensions?.delay ?? null, }; }); @@ -4429,6 +4539,11 @@ jQuery(() => { saveSettings(); }); + $('#world_info_include_names').on('input', function () { + world_info_include_names = !!$(this).prop('checked'); + saveSettings(); + }); + $('#world_info_recursive').on('input', function () { world_info_recursive = !!$(this).prop('checked'); saveSettings(); diff --git a/public/style.css b/public/style.css index b097aac3c..885eafe51 100644 --- a/public/style.css +++ b/public/style.css @@ -82,7 +82,7 @@ /*base variable calculated in rems*/ --fontScale: 1; --mainFontSize: calc(var(--fontScale) * 15px); - --mainFontFamily: "Noto Sans", "Noto Color Emoji", sans-serif; + --mainFontFamily: "Noto Sans", sans-serif; --monoFontFamily: 'Noto Sans Mono', 'Courier New', Consolas, monospace; /* base variable for blur strength slider calculations */ @@ -458,7 +458,7 @@ code { kbd { display: inline-block; padding: 2px 4px; - font-family: Consolas, monospace; + font-family: var(--monoFontFamily); white-space: nowrap; /* background-color: #eeeeee; */ background-color: rgba(255, 255, 255, 0.9); @@ -2922,6 +2922,23 @@ input[type=search]:focus::-webkit-search-cancel-button { position: relative; } +.group_member .queue_position:not(:empty)::before { + content: "#"; +} + +.group_member .queue_position { + margin-right: 0.75rem; + font-size: calc(var(--mainFontSize) * 0.9); +} + +.group_member.is_queued { + outline: 2px solid var(--golden); +} + +.group_member.is_active { + outline: 2px solid var(--active); +} + .character_select.is_fav .avatar, .group_select.is_fav .avatar, .group_member.is_fav .avatar, @@ -4150,18 +4167,6 @@ h5 { grid-template-columns: 340px auto; } -.popup-crop-wrap { - margin: 10px auto; - max-height: 75vh; - max-height: 75svh; - max-width: 100%; -} - -.popup-crop-wrap img { - max-width: 100%; - /* This rule is very important, please do not ignore this! */ -} - body .ui-autocomplete { max-height: 300px; overflow-y: auto; @@ -4493,7 +4498,8 @@ a { } .mes_img_controls .right_menu_button { - filter: brightness(80%); + filter: brightness(90%); + text-shadow: 1px 1px var(--SmartThemeShadowColor) !important; padding: 1px; height: 1.25em; width: 1.25em; @@ -4518,6 +4524,10 @@ a { display: flex; } +body:not(.caption) .mes_img_caption { + display: none; +} + .img_enlarged_holder { /* Scaling via flex-grow and object-fit only works if we have some kind of base-height set */ min-height: 120px; @@ -4561,6 +4571,13 @@ a { padding: 1em; } +.img_enlarged_container pre { + max-height: 25vh; + max-height: 25svh; + flex-shrink: 0; + overflow: auto; +} + .popup:has(.img_enlarged.zoomed).large_dialogue_popup { height: 100vh !important; height: 100svh !important; diff --git a/server.js b/server.js index ab0460ac0..eebd23c22 100644 --- a/server.js +++ b/server.js @@ -136,7 +136,7 @@ const disableCsrf = cliArguments.disableCsrf ?? getConfigValue('disableCsrfProte const basicAuthMode = cliArguments.basicAuthMode ?? getConfigValue('basicAuthMode', DEFAULT_BASIC_AUTH); const enableAccounts = getConfigValue('enableUserAccounts', DEFAULT_ACCOUNTS); -const { UPLOADS_PATH } = require('./src/constants'); +const uploadsPath = path.join(dataRoot, require('./src/constants').UPLOADS_DIRECTORY); // CORS Settings // const CORS = cors({ @@ -286,7 +286,7 @@ app.use(userModule.requireLoginMiddleware); app.get('/api/ping', (_, response) => response.sendStatus(204)); // File uploads -app.use(multer({ dest: UPLOADS_PATH, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar')); +app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar')); app.use(require('./src/middleware/multerMonkeyPatch')); // User data mount @@ -303,8 +303,8 @@ app.get('/version', async function (_, response) { function cleanUploads() { try { - if (fs.existsSync(UPLOADS_PATH)) { - const uploads = fs.readdirSync(UPLOADS_PATH); + if (fs.existsSync(uploadsPath)) { + const uploads = fs.readdirSync(uploadsPath); if (!uploads.length) { return; @@ -312,7 +312,7 @@ function cleanUploads() { console.debug(`Cleaning uploads folder (${uploads.length} files)`); uploads.forEach(file => { - const pathToFile = path.join(UPLOADS_PATH, file); + const pathToFile = path.join(uploadsPath, file); fs.unlinkSync(pathToFile); }); } @@ -609,10 +609,6 @@ const postSetupTasks = async function () { console.warn(color.yellow('Basic Authentication is enabled, but username or password is not set or empty!')); } } - - if (listen && !basicAuthMode && enableAccounts) { - await userModule.checkAccountsProtection(); - } }; /** @@ -631,16 +627,6 @@ async function loadPlugins() { } } -if (listen && !enableWhitelist && !basicAuthMode) { - if (getConfigValue('securityOverride', false)) { - console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.')); - } - else { - console.error(color.red('Your SillyTavern is currently unsecurely open to the public. Enable whitelisting or basic authentication.')); - process.exit(1); - } -} - /** * Set the title of the terminal window * @param {string} title Desired title for the window @@ -654,10 +640,53 @@ function setWindowTitle(title) { } } +/** + * Prints an error message and exits the process if necessary + * @param {string} message The error message to print + * @returns {void} + */ +function logSecurityAlert(message) { + if (basicAuthMode || enableWhitelist) return; // safe! + console.error(color.red(message)); + if (getConfigValue('securityOverride', false)) { + console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.')); + return; + } + process.exit(1); +} + +async function verifySecuritySettings() { + // Skip all security checks as listen is set to false + if (!listen) { + return; + } + + if (!enableAccounts) { + logSecurityAlert('Your SillyTavern is currently insecurely open to the public. Enable whitelisting, basic authentication or user accounts.'); + } + + const users = await userModule.getAllEnabledUsers(); + const unprotectedUsers = users.filter(x => !x.password); + const unprotectedAdminUsers = unprotectedUsers.filter(x => x.admin); + + if (unprotectedUsers.length > 0) { + console.warn(color.blue('A friendly reminder that the following users are not password protected:')); + unprotectedUsers.map(x => `${color.yellow(x.handle)} ${color.red(x.admin ? '(admin)' : '')}`).forEach(x => console.warn(x)); + console.log(); + console.warn(`Consider setting a password in the admin panel or by using the ${color.blue('recover.js')} script.`); + console.log(); + + if (unprotectedAdminUsers.length > 0) { + logSecurityAlert('If you are not using basic authentication or whitelisting, you should set a password for all admin users.'); + } + } +} + // User storage module needs to be initialized before starting the server userModule.initUserStorage(dataRoot) .then(userModule.ensurePublicDirectoriesExist) .then(userModule.migrateUserData) + .then(verifySecuritySettings) .then(preSetupTasks) .finally(() => { if (cliArguments.ssl) { diff --git a/src/additional-headers.js b/src/additional-headers.js index b8a44b390..e77507b0e 100644 --- a/src/additional-headers.js +++ b/src/additional-headers.js @@ -147,6 +147,32 @@ function getKoboldCppHeaders(directories) { }) : {}; } +/** + * Gets the headers for the Featherless API. + * @param {import('./users').UserDirectoryList} directories + * @returns {object} Headers for the request + */ +function getFeatherlessHeaders(directories) { + const apiKey = readSecret(directories, SECRET_KEYS.FEATHERLESS); + + return apiKey ? ({ + 'Authorization': `Bearer ${apiKey}`, + }) : {}; +} + +/** + * Gets the headers for the HuggingFace API. + * @param {import('./users').UserDirectoryList} directories + * @returns {object} Headers for the request + */ +function getHuggingFaceHeaders(directories) { + const apiKey = readSecret(directories, SECRET_KEYS.HUGGINGFACE); + + return apiKey ? ({ + 'Authorization': `Bearer ${apiKey}`, + }) : {}; +} + function getOverrideHeaders(urlHost) { const requestOverrides = getConfigValue('requestOverrides', []); const overrideHeaders = requestOverrides?.find((e) => e.hosts?.includes(urlHost))?.headers; @@ -187,6 +213,8 @@ function setAdditionalHeadersByType(requestHeaders, type, server, directories) { [TEXTGEN_TYPES.OPENROUTER]: getOpenRouterHeaders, [TEXTGEN_TYPES.KOBOLDCPP]: getKoboldCppHeaders, [TEXTGEN_TYPES.LLAMACPP]: getLlamaCppHeaders, + [TEXTGEN_TYPES.FEATHERLESS]: getFeatherlessHeaders, + [TEXTGEN_TYPES.HUGGINGFACE]: getHuggingFaceHeaders, }; const getHeaders = headerGetters[type]; diff --git a/src/constants.js b/src/constants.js index bb992530b..f74e3591d 100644 --- a/src/constants.js +++ b/src/constants.js @@ -194,9 +194,13 @@ const CHAT_COMPLETION_SOURCES = { COHERE: 'cohere', PERPLEXITY: 'perplexity', GROQ: 'groq', + ZEROONEAI: '01ai', }; -const UPLOADS_PATH = './uploads'; +/** + * Path to multer file uploads under the data root. + */ +const UPLOADS_DIRECTORY = '_uploads'; // TODO: this is copied from the client code; there should be a way to de-duplicate it eventually const TEXTGEN_TYPES = { @@ -212,6 +216,8 @@ const TEXTGEN_TYPES = { INFERMATICAI: 'infermaticai', DREAMGEN: 'dreamgen', OPENROUTER: 'openrouter', + FEATHERLESS: 'featherless', + HUGGINGFACE: 'huggingface', }; const INFERMATICAI_KEYS = [ @@ -237,6 +243,49 @@ const INFERMATICAI_KEYS = [ 'logprobs', ]; +const FEATHERLESS_KEYS = [ + 'model', + 'prompt', + 'best_of', + 'echo', + 'frequency_penalty', + 'logit_bias', + 'logprobs', + 'max_tokens', + 'n', + 'presence_penalty', + 'seed', + 'stop', + 'stream', + 'suffix', + 'temperature', + 'top_p', + 'user', + + 'use_beam_search', + 'top_k', + 'min_p', + 'repetition_penalty', + 'length_penalty', + 'early_stopping', + 'stop_token_ids', + 'ignore_eos', + 'min_tokens', + 'skip_special_tokens', + 'spaces_between_special_tokens', + 'truncate_prompt_tokens', + + 'include_stop_str_in_output', + 'response_format', + 'guided_json', + 'guided_regex', + 'guided_choice', + 'guided_grammar', + 'guided_decoding_backend', + 'guided_whitespace_pattern', +]; + + // https://dreamgen.com/docs/api#openai-text const DREAMGEN_KEYS = [ 'model', @@ -364,7 +413,7 @@ module.exports = { PUBLIC_DIRECTORIES, USER_DIRECTORY_TEMPLATE, UNSAFE_EXTENSIONS, - UPLOADS_PATH, + UPLOADS_DIRECTORY, GEMINI_SAFETY, BISON_SAFETY, TEXTGEN_TYPES, @@ -378,4 +427,5 @@ module.exports = { OPENROUTER_HEADERS, OPENROUTER_KEYS, VLLM_KEYS, + FEATHERLESS_KEYS, }; diff --git a/src/endpoints/avatars.js b/src/endpoints/avatars.js index 58571bae9..5f8509f0f 100644 --- a/src/endpoints/avatars.js +++ b/src/endpoints/avatars.js @@ -4,7 +4,7 @@ const fs = require('fs'); const sanitize = require('sanitize-filename'); const writeFileAtomicSync = require('write-file-atomic').sync; const { jsonParser, urlencodedParser } = require('../express-common'); -const { AVATAR_WIDTH, AVATAR_HEIGHT, UPLOADS_PATH } = require('../constants'); +const { AVATAR_WIDTH, AVATAR_HEIGHT } = require('../constants'); const { getImages, tryParse } = require('../util'); // image processing related library imports @@ -39,7 +39,7 @@ router.post('/upload', urlencodedParser, async (request, response) => { if (!request.file) return response.sendStatus(400); try { - const pathToUpload = path.join(UPLOADS_PATH, request.file.filename); + const pathToUpload = path.join(request.file.destination, request.file.filename); const crop = tryParse(request.query.crop); let rawImg = await jimp.read(pathToUpload); diff --git a/src/endpoints/backends/chat-completions.js b/src/endpoints/backends/chat-completions.js index 27eaee659..fe988e57c 100644 --- a/src/endpoints/backends/chat-completions.js +++ b/src/endpoints/backends/chat-completions.js @@ -17,6 +17,7 @@ const API_COHERE = 'https://api.cohere.ai/v1'; const API_PERPLEXITY = 'https://api.perplexity.ai'; const API_GROQ = 'https://api.groq.com/openai/v1'; const API_MAKERSUITE = 'https://generativelanguage.googleapis.com'; +const API_01AI = 'https://api.01.ai/v1'; /** * Applies a post-processing step to the generated messages. @@ -670,6 +671,10 @@ router.post('/status', jsonParser, async function (request, response_getstatus_o api_url = API_COHERE; api_key_openai = readSecret(request.user.directories, SECRET_KEYS.COHERE); headers = {}; + } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.ZEROONEAI) { + api_url = API_01AI; + api_key_openai = readSecret(request.user.directories, SECRET_KEYS.ZEROONEAI); + headers = {}; } else { console.log('This chat completion source is not supported yet.'); return response_getstatus_openai.status(400).send({ error: true }); @@ -931,6 +936,11 @@ router.post('/generate', jsonParser, function (request, response) { request.body.tool_choice = 'none'; } } + } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.ZEROONEAI) { + apiUrl = API_01AI; + apiKey = readSecret(request.user.directories, SECRET_KEYS.ZEROONEAI); + headers = {}; + bodyParams = {}; } else { console.log('This chat completion source is not supported yet.'); return response.status(400).send({ error: true }); diff --git a/src/endpoints/backends/text-completions.js b/src/endpoints/backends/text-completions.js index a6c55acbd..68fa9ce14 100644 --- a/src/endpoints/backends/text-completions.js +++ b/src/endpoints/backends/text-completions.js @@ -4,7 +4,7 @@ const _ = require('lodash'); const Readable = require('stream').Readable; const { jsonParser } = require('../../express-common'); -const { TEXTGEN_TYPES, TOGETHERAI_KEYS, OLLAMA_KEYS, INFERMATICAI_KEYS, OPENROUTER_KEYS, VLLM_KEYS, DREAMGEN_KEYS } = require('../../constants'); +const { TEXTGEN_TYPES, TOGETHERAI_KEYS, OLLAMA_KEYS, INFERMATICAI_KEYS, OPENROUTER_KEYS, VLLM_KEYS, DREAMGEN_KEYS, FEATHERLESS_KEYS } = require('../../constants'); const { forwardFetchResponse, trimV1 } = require('../../util'); const { setAdditionalHeaders } = require('../../additional-headers'); @@ -95,13 +95,14 @@ router.post('/status', jsonParser, async function (request, response) { setAdditionalHeaders(request, args, baseUrl); + const apiType = request.body.api_type; let url = baseUrl; let result = ''; if (request.body.legacy_api) { url += '/v1/model'; } else { - switch (request.body.api_type) { + switch (apiType) { case TEXTGEN_TYPES.OOBA: case TEXTGEN_TYPES.VLLM: case TEXTGEN_TYPES.APHRODITE: @@ -126,6 +127,12 @@ router.post('/status', jsonParser, async function (request, response) { case TEXTGEN_TYPES.OLLAMA: url += '/api/tags'; break; + case TEXTGEN_TYPES.FEATHERLESS: + url += '/v1/models'; + break; + case TEXTGEN_TYPES.HUGGINGFACE: + url += '/info'; + break; } } @@ -144,14 +151,18 @@ router.post('/status', jsonParser, async function (request, response) { } // Rewrap to OAI-like response - if (request.body.api_type === TEXTGEN_TYPES.TOGETHERAI && Array.isArray(data)) { + if (apiType === TEXTGEN_TYPES.TOGETHERAI && Array.isArray(data)) { data = { data: data.map(x => ({ id: x.name, ...x })) }; } - if (request.body.api_type === TEXTGEN_TYPES.OLLAMA && Array.isArray(data.models)) { + if (apiType === TEXTGEN_TYPES.OLLAMA && Array.isArray(data.models)) { data = { data: data.models.map(x => ({ id: x.name, ...x })) }; } + if (apiType === TEXTGEN_TYPES.HUGGINGFACE) { + data = { data: [] }; + } + if (!Array.isArray(data.data)) { console.log('Models response is not an array.'); return response.status(400); @@ -163,7 +174,7 @@ router.post('/status', jsonParser, async function (request, response) { // Set result to the first model ID result = modelIds[0] || 'Valid'; - if (request.body.api_type === TEXTGEN_TYPES.OOBA) { + if (apiType === TEXTGEN_TYPES.OOBA) { try { const modelInfoUrl = baseUrl + '/v1/internal/model/info'; const modelInfoReply = await fetch(modelInfoUrl, args); @@ -178,7 +189,7 @@ router.post('/status', jsonParser, async function (request, response) { } catch (error) { console.error(`Failed to get Ooba model info: ${error}`); } - } else if (request.body.api_type === TEXTGEN_TYPES.TABBY) { + } else if (apiType === TEXTGEN_TYPES.TABBY) { try { const modelInfoUrl = baseUrl + '/v1/model'; const modelInfoReply = await fetch(modelInfoUrl, args); @@ -235,12 +246,14 @@ router.post('/generate', jsonParser, async function (request, response) { } else { switch (request.body.api_type) { case TEXTGEN_TYPES.VLLM: + case TEXTGEN_TYPES.FEATHERLESS: case TEXTGEN_TYPES.APHRODITE: case TEXTGEN_TYPES.OOBA: case TEXTGEN_TYPES.TABBY: case TEXTGEN_TYPES.KOBOLDCPP: case TEXTGEN_TYPES.TOGETHERAI: case TEXTGEN_TYPES.INFERMATICAI: + case TEXTGEN_TYPES.HUGGINGFACE: url += '/v1/completions'; break; case TEXTGEN_TYPES.DREAMGEN: @@ -281,6 +294,11 @@ router.post('/generate', jsonParser, async function (request, response) { args.body = JSON.stringify(request.body); } + if (request.body.api_type === TEXTGEN_TYPES.FEATHERLESS) { + request.body = _.pickBy(request.body, (_, key) => FEATHERLESS_KEYS.includes(key)); + args.body = JSON.stringify(request.body); + } + if (request.body.api_type === TEXTGEN_TYPES.DREAMGEN) { request.body = _.pickBy(request.body, (_, key) => DREAMGEN_KEYS.includes(key)); // NOTE: DreamGen sometimes get confused by the unusual formatting in the character cards. diff --git a/src/endpoints/backgrounds.js b/src/endpoints/backgrounds.js index b8965ab5f..6ef59f923 100644 --- a/src/endpoints/backgrounds.js +++ b/src/endpoints/backgrounds.js @@ -4,7 +4,6 @@ const express = require('express'); const sanitize = require('sanitize-filename'); const { jsonParser, urlencodedParser } = require('../express-common'); -const { UPLOADS_PATH } = require('../constants'); const { invalidateThumbnail } = require('./thumbnails'); const { getImages } = require('../util'); @@ -60,7 +59,7 @@ router.post('/rename', jsonParser, function (request, response) { router.post('/upload', urlencodedParser, function (request, response) { if (!request.body || !request.file) return response.sendStatus(400); - const img_path = path.join(UPLOADS_PATH, request.file.filename); + const img_path = path.join(request.file.destination, request.file.filename); const filename = request.file.originalname; try { diff --git a/src/endpoints/characters.js b/src/endpoints/characters.js index 17dcaa6b8..e4f43367b 100644 --- a/src/endpoints/characters.js +++ b/src/endpoints/characters.js @@ -11,7 +11,7 @@ const mime = require('mime-types'); const jimp = require('jimp'); -const { UPLOADS_PATH, AVATAR_WIDTH, AVATAR_HEIGHT } = require('../constants'); +const { AVATAR_WIDTH, AVATAR_HEIGHT } = require('../constants'); const { jsonParser, urlencodedParser } = require('../express-common'); const { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer } = require('../util'); const { TavernCardValidator } = require('../validator/TavernCardValidator'); @@ -485,6 +485,7 @@ function convertWorldInfoToCharacterBook(name, entries) { vectorized: entry.vectorized ?? false, sticky: entry.sticky ?? null, cooldown: entry.cooldown ?? null, + delay: entry.delay ?? null, }, }; @@ -498,15 +499,16 @@ function convertWorldInfoToCharacterBook(name, entries) { * Import a character from a YAML file. * @param {string} uploadPath Path to the uploaded file * @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects + * @param {string|undefined} preservedFileName Preserved file name * @returns {Promise<string>} Internal name of the character */ -async function importFromYaml(uploadPath, context) { +async function importFromYaml(uploadPath, context, preservedFileName) { const fileText = fs.readFileSync(uploadPath, 'utf8'); fs.rmSync(uploadPath); const yamlData = yaml.parse(fileText); console.log('Importing from YAML'); yamlData.name = sanitize(yamlData.name); - const fileName = getPngName(yamlData.name, context.request.user.directories); + const fileName = preservedFileName || getPngName(yamlData.name, context.request.user.directories); let char = convertToV2({ 'name': yamlData.name, 'description': yamlData.context ?? '', @@ -531,9 +533,10 @@ async function importFromYaml(uploadPath, context) { * @param {string} uploadPath * @param {object} params * @param {import('express').Request} params.request + * @param {string|undefined} preservedFileName Preserved file name * @returns {Promise<string>} Internal name of the character */ -async function importFromCharX(uploadPath, { request }) { +async function importFromCharX(uploadPath, { request }, preservedFileName) { const data = fs.readFileSync(uploadPath); fs.rmSync(uploadPath); console.log('Importing from CharX'); @@ -566,7 +569,7 @@ async function importFromCharX(uploadPath, { request }) { unsetFavFlag(card); card['create_date'] = humanizedISO8601DateTime(); card.name = sanitize(card.name); - const fileName = getPngName(card.name, request.user.directories); + const fileName = preservedFileName || getPngName(card.name, request.user.directories); const result = await writeCharacterData(avatar, JSON.stringify(card), fileName, request); return result ? fileName : ''; } @@ -575,9 +578,10 @@ async function importFromCharX(uploadPath, { request }) { * Import a character from a JSON file. * @param {string} uploadPath Path to the uploaded file * @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects + * @param {string|undefined} preservedFileName Preserved file name * @returns {Promise<string>} Internal name of the character */ -async function importFromJson(uploadPath, { request }) { +async function importFromJson(uploadPath, { request }, preservedFileName) { const data = fs.readFileSync(uploadPath, 'utf8'); fs.unlinkSync(uploadPath); @@ -589,7 +593,7 @@ async function importFromJson(uploadPath, { request }) { unsetFavFlag(jsonData); jsonData = readFromV2(jsonData); jsonData['create_date'] = humanizedISO8601DateTime(); - const pngName = getPngName(jsonData.data?.name || jsonData.name, request.user.directories); + const pngName = preservedFileName || getPngName(jsonData.data?.name || jsonData.name, request.user.directories); const char = JSON.stringify(jsonData); const result = await writeCharacterData(defaultAvatarPath, char, pngName, request); return result ? pngName : ''; @@ -599,7 +603,7 @@ async function importFromJson(uploadPath, { request }) { if (jsonData.creator_notes) { jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', ''); } - const pngName = getPngName(jsonData.name, request.user.directories); + const pngName = preservedFileName || getPngName(jsonData.name, request.user.directories); let char = { 'name': jsonData.name, 'description': jsonData.description ?? '', @@ -625,7 +629,7 @@ async function importFromJson(uploadPath, { request }) { if (jsonData.creator_notes) { jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', ''); } - const pngName = getPngName(jsonData.char_name, request.user.directories); + const pngName = preservedFileName || getPngName(jsonData.char_name, request.user.directories); let char = { 'name': jsonData.char_name, 'description': jsonData.char_persona ?? '', @@ -729,7 +733,7 @@ router.post('/create', urlencodedParser, async function (request, response) { return response.send(avatarName); } else { const crop = tryParse(request.query.crop); - const uploadPath = path.join(UPLOADS_PATH, request.file.filename); + const uploadPath = path.join(request.file.destination, request.file.filename); await writeCharacterData(uploadPath, char, internalName, request, crop); fs.unlinkSync(uploadPath); return response.send(avatarName); @@ -812,7 +816,7 @@ router.post('/edit', urlencodedParser, async function (request, response) { await writeCharacterData(avatarPath, char, targetFile, request); } else { const crop = tryParse(request.query.crop); - const newAvatarPath = path.join(UPLOADS_PATH, request.file.filename); + const newAvatarPath = path.join(request.file.destination, request.file.filename); invalidateThumbnail(request.user.directories, 'avatar', request.body.avatar_url); await writeCharacterData(newAvatarPath, char, targetFile, request, crop); fs.unlinkSync(newAvatarPath); @@ -1088,15 +1092,15 @@ function getPngName(file, directories) { * @returns {string | undefined} - The preserved name if the request is valid, otherwise undefined */ function getPreservedName(request) { - return request.body.file_type === 'png' && request.body.preserve_file_name === 'true' && request.file?.originalname - ? path.parse(request.file.originalname).name + return typeof request.body.preserved_name === 'string' && request.body.preserved_name.length > 0 + ? path.parse(request.body.preserved_name).name : undefined; } router.post('/import', urlencodedParser, async function (request, response) { if (!request.body || !request.file) return response.sendStatus(400); - const uploadPath = path.join(UPLOADS_PATH, request.file.filename); + const uploadPath = path.join(request.file.destination, request.file.filename); const format = request.body.file_type; const preservedFileName = getPreservedName(request); @@ -1122,6 +1126,10 @@ router.post('/import', urlencodedParser, async function (request, response) { return response.sendStatus(400); } + if (preservedFileName) { + invalidateThumbnail(request.user.directories, 'avatar', `${preservedFileName}.png`); + } + response.send({ file_name: fileName }); } catch (err) { console.log(err); diff --git a/src/endpoints/chats.js b/src/endpoints/chats.js index d88b27fa3..2be1eb4e0 100644 --- a/src/endpoints/chats.js +++ b/src/endpoints/chats.js @@ -6,7 +6,6 @@ const sanitize = require('sanitize-filename'); const writeFileAtomicSync = require('write-file-atomic').sync; const { jsonParser, urlencodedParser } = require('../express-common'); -const { UPLOADS_PATH } = require('../constants'); const { getConfigValue, humanizedISO8601DateTime, tryParse, generateTimestamp, removeOldBackups } = require('../util'); /** @@ -323,7 +322,7 @@ router.post('/group/import', urlencodedParser, function (request, response) { } const chatname = humanizedISO8601DateTime(); - const pathToUpload = path.join(UPLOADS_PATH, filedata.filename); + const pathToUpload = path.join(filedata.destination, filedata.filename); const pathToNewFile = path.join(request.user.directories.groupChats, `${chatname}.jsonl`); fs.copyFileSync(pathToUpload, pathToNewFile); fs.unlinkSync(pathToUpload); @@ -347,9 +346,11 @@ router.post('/import', urlencodedParser, function (request, response) { } try { - const data = fs.readFileSync(path.join(UPLOADS_PATH, request.file.filename), 'utf8'); + const pathToUpload = path.join(request.file.destination, request.file.filename); + const data = fs.readFileSync(pathToUpload, 'utf8'); if (format === 'json') { + fs.unlinkSync(pathToUpload); const jsonData = JSON.parse(data); if (jsonData.histories !== undefined) { // CAI Tools format @@ -388,7 +389,8 @@ router.post('/import', urlencodedParser, function (request, response) { if (jsonData.user_name !== undefined || jsonData.name !== undefined) { const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`; const filePath = path.join(request.user.directories.chats, avatarUrl, fileName); - fs.copyFileSync(path.join(UPLOADS_PATH, request.file.filename), filePath); + fs.copyFileSync(pathToUpload, filePath); + fs.unlinkSync(pathToUpload); response.send({ res: true }); } else { console.log('Incorrect chat format .jsonl'); diff --git a/src/endpoints/openai.js b/src/endpoints/openai.js index 75de75b7b..f63d72b11 100644 --- a/src/endpoints/openai.js +++ b/src/endpoints/openai.js @@ -43,7 +43,11 @@ router.post('/caption-image', jsonParser, async (request, response) => { key = readSecret(request.user.directories, SECRET_KEYS.KOBOLDCPP); } - if (!key && !request.body.reverse_proxy && ['custom', 'ooba', 'koboldcpp'].includes(request.body.api) === false) { + if (request.body.api === 'vllm') { + key = readSecret(request.user.directories, SECRET_KEYS.VLLM); + } + + if (!key && !request.body.reverse_proxy && ['custom', 'ooba', 'koboldcpp', 'vllm'].includes(request.body.api) === false) { console.log('No key found for API', request.body.api); return response.sendStatus(400); } @@ -110,7 +114,7 @@ router.post('/caption-image', jsonParser, async (request, response) => { }); } - if (request.body.api === 'koboldcpp') { + if (request.body.api === 'koboldcpp' || request.body.api === 'vllm') { apiUrl = `${trimV1(request.body.server_url)}/v1/chat/completions`; } diff --git a/src/endpoints/secrets.js b/src/endpoints/secrets.js index 9bf2eb765..beba158ef 100644 --- a/src/endpoints/secrets.js +++ b/src/endpoints/secrets.js @@ -40,6 +40,10 @@ const SECRET_KEYS = { PERPLEXITY: 'api_key_perplexity', GROQ: 'api_key_groq', AZURE_TTS: 'api_key_azure_tts', + FEATHERLESS: 'api_key_featherless', + ZEROONEAI: 'api_key_01ai', + HUGGINGFACE: 'api_key_huggingface', + STABILITY: 'api_key_stability', }; // These are the keys that are safe to expose, even if allowKeysExposure is false diff --git a/src/endpoints/settings.js b/src/endpoints/settings.js index 367ab6553..24986b6b0 100644 --- a/src/endpoints/settings.js +++ b/src/endpoints/settings.js @@ -3,7 +3,7 @@ const path = require('path'); const express = require('express'); const _ = require('lodash'); const writeFileAtomicSync = require('write-file-atomic').sync; -const { PUBLIC_DIRECTORIES, SETTINGS_FILE } = require('../constants'); +const { SETTINGS_FILE } = require('../constants'); const { getConfigValue, generateTimestamp, removeOldBackups } = require('../util'); const { jsonParser } = require('../express-common'); const { getAllUserHandles, getUserDirectories } = require('../users'); @@ -27,12 +27,12 @@ const AUTOSAVE_FUNCTIONS = new Map(); */ function triggerAutoSave(handle) { if (!AUTOSAVE_FUNCTIONS.has(handle)) { - const throttledAutoSave = _.throttle(() => backupUserSettings(handle), AUTOSAVE_INTERVAL); + const throttledAutoSave = _.throttle(() => backupUserSettings(handle, true), AUTOSAVE_INTERVAL); AUTOSAVE_FUNCTIONS.set(handle, throttledAutoSave); } const functionToCall = AUTOSAVE_FUNCTIONS.get(handle); - if (functionToCall) { + if (functionToCall && typeof functionToCall === 'function') { functionToCall(); } } @@ -113,7 +113,7 @@ async function backupSettings() { const userHandles = await getAllUserHandles(); for (const handle of userHandles) { - backupUserSettings(handle); + backupUserSettings(handle, true); } } catch (err) { console.log('Could not backup settings file', err); @@ -123,13 +123,18 @@ async function backupSettings() { /** * Makes a backup of the user's settings file. * @param {string} handle User handle + * @param {boolean} preventDuplicates Prevent duplicate backups * @returns {void} */ -function backupUserSettings(handle) { +function backupUserSettings(handle, preventDuplicates) { const userDirectories = getUserDirectories(handle); const backupFile = path.join(userDirectories.backups, `${getFilePrefix(handle)}${generateTimestamp()}.json`); const sourceFile = path.join(userDirectories.root, SETTINGS_FILE); + if (preventDuplicates && isDuplicateBackup(handle, sourceFile)) { + return; + } + if (!fs.existsSync(sourceFile)) { return; } @@ -138,6 +143,52 @@ function backupUserSettings(handle) { removeOldBackups(userDirectories.backups, `settings_${handle}`); } +/** + * Checks if the backup would be a duplicate. + * @param {string} handle User handle + * @param {string} sourceFile Source file path + * @returns {boolean} True if the backup is a duplicate + */ +function isDuplicateBackup(handle, sourceFile) { + const latestBackup = getLatestBackup(handle); + if (!latestBackup) { + return false; + } + return areFilesEqual(latestBackup, sourceFile); +} + +/** + * Returns true if the two files are equal. + * @param {string} file1 File path + * @param {string} file2 File path + */ +function areFilesEqual(file1, file2) { + if (!fs.existsSync(file1) || !fs.existsSync(file2)) { + return false; + } + + const content1 = fs.readFileSync(file1); + const content2 = fs.readFileSync(file2); + return content1.toString() === content2.toString(); +} + +/** + * Gets the latest backup file for a user. + * @param {string} handle User handle + * @returns {string|null} Latest backup file. Null if no backup exists. + */ +function getLatestBackup(handle) { + const userDirectories = getUserDirectories(handle); + const backupFiles = fs.readdirSync(userDirectories.backups) + .filter(x => x.startsWith(getFilePrefix(handle))) + .map(x => ({ name: x, ctime: fs.statSync(path.join(userDirectories.backups, x)).ctimeMs })); + const latestBackup = backupFiles.sort((a, b) => b.ctime - a.ctime)[0]?.name; + if (!latestBackup) { + return null; + } + return path.join(userDirectories.backups, latestBackup); +} + const router = express.Router(); router.post('/save', jsonParser, function (request, response) { @@ -265,7 +316,7 @@ router.post('/load-snapshot', jsonParser, async (request, response) => { router.post('/make-snapshot', jsonParser, async (request, response) => { try { - backupUserSettings(request.user.profile.handle); + backupUserSettings(request.user.profile.handle, false); response.sendStatus(204); } catch (error) { console.log(error); diff --git a/src/endpoints/sprites.js b/src/endpoints/sprites.js index 88a577b3b..43bfdb145 100644 --- a/src/endpoints/sprites.js +++ b/src/endpoints/sprites.js @@ -5,7 +5,6 @@ const express = require('express'); const mime = require('mime-types'); const sanitize = require('sanitize-filename'); const writeFileAtomicSync = require('write-file-atomic').sync; -const { UPLOADS_PATH } = require('../constants'); const { getImageBuffers } = require('../util'); const { jsonParser, urlencodedParser } = require('../express-common'); @@ -190,7 +189,7 @@ router.post('/upload-zip', urlencodedParser, async (request, response) => { return response.sendStatus(404); } - const spritePackPath = path.join(UPLOADS_PATH, file.filename); + const spritePackPath = path.join(file.destination, file.filename); const sprites = await getImageBuffers(spritePackPath); const files = fs.readdirSync(spritesPath); @@ -248,7 +247,7 @@ router.post('/upload', urlencodedParser, async (request, response) => { } const filename = label + path.parse(file.originalname).ext; - const spritePath = path.join(UPLOADS_PATH, file.filename); + const spritePath = path.join(file.destination, file.filename); const pathToFile = path.join(spritesPath, filename); // Copy uploaded file to sprites folder fs.cpSync(spritePath, pathToFile); diff --git a/src/endpoints/stable-diffusion.js b/src/endpoints/stable-diffusion.js index a93582803..24c393aa9 100644 --- a/src/endpoints/stable-diffusion.js +++ b/src/endpoints/stable-diffusion.js @@ -7,6 +7,7 @@ const path = require('path'); const writeFileAtomicSync = require('write-file-atomic').sync; const { jsonParser } = require('../express-common'); const { readSecret, SECRET_KEYS } = require('./secrets.js'); +const FormData = require('form-data'); /** * Sanitizes a string. @@ -134,6 +135,31 @@ router.post('/upscalers', jsonParser, async (request, response) => { } }); +router.post('/vaes', jsonParser, async (request, response) => { + try { + const url = new URL(request.body.url); + url.pathname = '/sdapi/v1/sd-vae'; + + const result = await fetch(url, { + method: 'GET', + headers: { + 'Authorization': getBasicAuthHeader(request.body.auth), + }, + }); + + if (!result.ok) { + throw new Error('SD WebUI returned an error.'); + } + + const data = await result.json(); + const names = data.map(x => x.model_name); + return response.send(names); + } catch (error) { + console.log(error); + return response.sendStatus(500); + } +}); + router.post('/samplers', jsonParser, async (request, response) => { try { const url = new URL(request.body.url); @@ -793,9 +819,71 @@ pollinations.post('/generate', jsonParser, async (request, response) => { } }); +const stability = express.Router(); + +stability.post('/generate', jsonParser, async (request, response) => { + try { + const key = readSecret(request.user.directories, SECRET_KEYS.STABILITY); + + if (!key) { + console.log('Stability AI key not found.'); + return response.sendStatus(400); + } + + const { payload, model } = request.body; + + console.log('Stability AI request:', model, payload); + + const formData = new FormData(); + for (const [key, value] of Object.entries(payload)) { + if (value !== undefined) { + formData.append(key, String(value)); + } + } + + let apiUrl; + switch (model) { + case 'stable-image-ultra': + apiUrl = 'https://api.stability.ai/v2beta/stable-image/generate/ultra'; + break; + case 'stable-image-core': + apiUrl = 'https://api.stability.ai/v2beta/stable-image/generate/core'; + break; + case 'stable-diffusion-3': + apiUrl = 'https://api.stability.ai/v2beta/stable-image/generate/sd3'; + break; + default: + throw new Error('Invalid Stability AI model selected'); + } + + const result = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${key}`, + 'Accept': 'image/*', + }, + body: formData, + timeout: 0, + }); + + if (!result.ok) { + const text = await result.text(); + console.log('Stability AI returned an error.', result.status, result.statusText, text); + return response.sendStatus(500); + } + + const buffer = await result.buffer(); + return response.send(buffer.toString('base64')); + } catch (error) { + console.log(error); + return response.sendStatus(500); + } +}); + router.use('/comfy', comfy); router.use('/together', together); router.use('/drawthings', drawthings); router.use('/pollinations', pollinations); +router.use('/stability', stability); module.exports = { router }; diff --git a/src/endpoints/worldinfo.js b/src/endpoints/worldinfo.js index f8ab2d498..4f125bcc4 100644 --- a/src/endpoints/worldinfo.js +++ b/src/endpoints/worldinfo.js @@ -5,7 +5,6 @@ const sanitize = require('sanitize-filename'); const writeFileAtomicSync = require('write-file-atomic').sync; const { jsonParser, urlencodedParser } = require('../express-common'); -const { UPLOADS_PATH } = require('../constants'); /** * Reads a World Info file and returns its contents @@ -74,7 +73,7 @@ router.post('/import', urlencodedParser, (request, response) => { if (request.body.convertedData) { fileContents = request.body.convertedData; } else { - const pathToUpload = path.join(UPLOADS_PATH, request.file.filename); + const pathToUpload = path.join(request.file.destination, request.file.filename); fileContents = fs.readFileSync(pathToUpload, 'utf8'); fs.unlinkSync(pathToUpload); } diff --git a/src/users.js b/src/users.js index d01419248..dcf3718a9 100644 --- a/src/users.js +++ b/src/users.js @@ -681,27 +681,27 @@ async function createBackupArchive(handle, response) { } /** - * Checks if any admin users are not password protected. If so, logs a warning. - * @returns {Promise<void>} + * Gets all of the users. + * @returns {Promise<User[]>} */ -async function checkAccountsProtection() { +async function getAllUsers() { if (!ENABLE_ACCOUNTS) { - return; + return []; } - /** * @type {User[]} */ const users = await storage.values(); - const unprotectedUsers = users.filter(x => x.enabled && x.admin && !x.password); - if (unprotectedUsers.length > 0) { - console.warn(color.red('The following admin users are not password protected:')); - unprotectedUsers.forEach(x => console.warn(color.yellow(x.handle))); - console.log(); - console.warn('Please disable them or set a password in the admin panel.'); - console.log(); - await delay(3000); - } + return users; +} + +/** + * Gets all of the enabled users. + * @returns {Promise<User[]>} + */ +async function getAllEnabledUsers() { + const users = await getAllUsers(); + return users.filter(x => x.enabled); } /** @@ -738,6 +738,7 @@ module.exports = { shouldRedirectToLogin, createBackupArchive, tryAutoLogin, - checkAccountsProtection, + getAllUsers, + getAllEnabledUsers, router, }; diff --git a/src/util.js b/src/util.js index 06f64da83..b82007156 100644 --- a/src/util.js +++ b/src/util.js @@ -371,7 +371,7 @@ function generateTimestamp() { * @param {string} prefix File prefix to filter backups by. */ function removeOldBackups(directory, prefix) { - const MAX_BACKUPS = 50; + const MAX_BACKUPS = Number(getConfigValue('numberOfBackups', 50)); let files = fs.readdirSync(directory).filter(f => f.startsWith(prefix)); if (files.length > MAX_BACKUPS) {