Compare commits

...

124 Commits

Author SHA1 Message Date
LenAnderson ca16ce1e34 add key= argument to all get vars 2024-04-30 14:15:34 -04:00
LenAnderson 8a8fef01bf add resize event on autocomplete width change 2024-04-30 12:42:51 -04:00
LenAnderson 3ca4a1d00c add progress and pause/abort to QR editor 2024-04-30 12:16:17 -04:00
LenAnderson 4691f73387 revert stop button pulse 2024-04-30 11:00:20 -04:00
LenAnderson d61b14d24a Merge branch 'staging' into parser-v2 2024-04-30 10:54:03 -04:00
LenAnderson fa767e91d6 add adjustable autocomplete width 2024-04-30 10:21:14 -04:00
LenAnderson c7fa0baab4 remove parsing of quoted values from unnamed arg 2024-04-30 09:13:15 -04:00
LenAnderson f63f4ef304 separate abort logic for commands 2024-04-30 09:12:31 -04:00
Cohee d9d76ba16d #2164 Add error toasts to VecStore 2024-04-30 00:17:39 +03:00
Cohee 993284f9c1 #2164 Disable-able data bank attachments 2024-04-30 00:06:14 +03:00
Cohee a7d3130f9a Remove non-existent foreign lorebook extensions 2024-04-29 15:33:56 +03:00
Cohee e0df5783f8 Allow macros in positive and negative prompts 2024-04-29 13:50:55 +03:00
Cohee e4de6da5b8 Add server plugin support for MS Edge TTS 2024-04-29 01:07:19 +03:00
LenAnderson 0ce37981bf why?? 2024-04-28 16:04:59 -04:00
LenAnderson 2ed1e3780b mark registerSlashCommand deprecated 2024-04-28 16:04:42 -04:00
LenAnderson 58a6320fc0 add missing return 2024-04-28 16:04:31 -04:00
Cohee 87219f897e Check that char.list has any filters before applying hidden block. 2024-04-28 21:33:37 +03:00
Cohee 73cf58826f Pause autoplay on external media removal 2024-04-28 20:11:58 +03:00
Cohee be4637a3a0 Handle <br> in message texts with Showdown instead of manually 2024-04-28 20:00:22 +03:00
Cohee 6ac6c7cfda #2159 Move debounce constants to a separate module 2024-04-28 19:47:53 +03:00
Cohee 94e9b8f4b1 Merge branch 'staging' of https://github.com/SillyTavern/SillyTavern into staging 2024-04-28 19:29:12 +03:00
Cohee bc6149deeb
Merge pull request #2158 from racinmat/racinsky/itemization
refactor: prompt itemization split to multiple functions
2024-04-28 18:55:10 +03:00
Cohee a0d975c3c0 Add bottom margin to in-chat tables 2024-04-28 18:39:57 +03:00
Cohee d51b155e52 Add ability for extensions to intercept edited message text 2024-04-28 18:39:32 +03:00
Cohee fb1b327f9a [skip ci] ESLint 2024-04-28 16:58:28 +03:00
Matěj Račinský 754cdc4d58 refactor: prompt itemization split to multiple functions 2024-04-28 14:09:10 +02:00
Cohee a73cb9ad3d
Merge pull request #2154 from Bronya-Rand/staging
chore: disable merge conflict workflow on forks
2024-04-28 14:50:28 +03:00
Cohee 58ecc0dc0d
Merge pull request #2155 from Wolfsblvt/fix-bogus-folder-select
Fix bogus folder not working if tag was cut off
2024-04-28 14:43:07 +03:00
Cohee 3821e91be0
Merge pull request #2156 from Wolfsblvt/debounce-some-searches
Debounce WI, Character and Persona search + common debounce timeouts
2024-04-28 14:38:50 +03:00
Cohee de2bb7938a Utilize import for vector store 2024-04-28 14:35:35 +03:00
Wolfsblvt 61e2877c4b Debounce Character and Persona search 2024-04-28 06:27:55 +02:00
Wolfsblvt d7ade487b8 Refactor common enum for debounce timeouts 2024-04-28 06:21:47 +02:00
Wolfsblvt 6d04e93f34 Debounce WI search 2024-04-28 05:42:15 +02:00
Bronya-Rand d7a7af756a chore: disable docker publish on forks 2024-04-28 03:55:49 +01:00
Wolfsblvt 0c5fe3d637 Fix bogus folder not working if tag was cut off 2024-04-28 04:47:16 +02:00
Bronya-Rand eb0a116cc7 chore: only allow merge conflicts to run in ST repo 2024-04-28 03:41:50 +01:00
Cohee e08a21ebe7 Deprecate old /sendas syntax.
"name" arg is now required, but defaults to {{char}} for compatibility
2024-04-28 03:53:17 +03:00
Cohee 49074effce
Merge pull request #2119 from Bronya-Rand/staging
feat: Third-Party Parser Support
2024-04-28 00:15:08 +03:00
Bronya-Rand ffe8b3c909 chore: leftover cleanup 2024-04-27 22:09:11 +01:00
Bronya-Rand 7856afee92 chore: remove mihoyo scraper 2024-04-27 22:08:38 +01:00
Bronya-Rand fe533b7c7f chore: revert back to typedef 2024-04-27 22:01:15 +01:00
Azariel Del Carmen fc158ca176
Merge branch 'staging' into staging 2024-04-27 21:49:02 +01:00
Cohee f632888b4c Move scripts init at the end of HTML page 2024-04-27 23:44:08 +03:00
Bronya-Rand 8324632e4e chore: add iconAvailable to ScraperInfo 2024-04-27 21:43:53 +01:00
Bronya-Rand be4b20af97 chore: remove mihoyo icon 2024-04-27 21:42:03 +01:00
Cohee 5a4e0a06e6 Better icon for YT captioner 2024-04-27 23:27:53 +03:00
Bronya-Rand fb71d3b562 chore: remove miHoYo parser from first-party scrapers 2024-04-27 21:27:14 +01:00
Bronya-Rand b96d1e79a0 feat: create proper classes and export for extension use 2024-04-27 21:26:39 +01:00
Cohee 0d310c434d Update FontAwesome 2024-04-27 23:25:35 +03:00
Cohee b111834122 Insert custom prompts to the start of the list 2024-04-27 23:16:44 +03:00
Cohee 2847b5ee45 [skip ci] Fix format 2024-04-27 23:02:51 +03:00
Cohee 943906d8a3 Fix UTF-8 file name uploads
https://github.com/expressjs/multer/issues/1104
2024-04-27 22:58:32 +03:00
Cohee cbedfa4664 Use atomic write 2024-04-27 22:02:04 +03:00
Cohee 01ccc32274 Cache config.yaml reads 2024-04-27 21:59:57 +03:00
Cohee 3b153a6c9b Check that path exists before serving 2024-04-27 21:54:28 +03:00
Cohee 1bcdc2652c Split pre and post listen setup tasks. Only shutdown plugins once 2024-04-27 21:41:32 +03:00
Cohee ea050b98ef
Merge pull request #2150 from evpeople/release
add a button to translate input message
2024-04-27 21:23:26 +03:00
Cohee b30d69b2a6 Clean-up styles and JQuery use 2024-04-27 21:22:50 +03:00
Cohee 60e099e852 Clean-up diff pt.2 2024-04-27 21:15:44 +03:00
Cohee c49b37f968 Clean-up diff 2024-04-27 21:11:41 +03:00
Cohee 404d9db359
Merge pull request #2147 from Wolfsblvt/wi-entry-inclusion-prio
World Info inclusion group prio toggle
2024-04-27 21:09:43 +03:00
Cohee 5ac0390446 Fix naming convention for LB extension fields 2024-04-27 21:03:55 +03:00
Cohee 6e98fb1c5e Clean-up debug logs 2024-04-27 20:42:49 +03:00
Cohee 053d7f9eaa Remove the /inject when value is empty 2024-04-27 20:25:55 +03:00
Cohee 5dcfda0514 Cut UI labels. Add expand to custom CSS 2024-04-27 20:02:30 +03:00
LenAnderson 5cc3a2cede add slash command progress indicator 2024-04-27 11:12:30 -04:00
Cohee b42125a654 Fix content index 2024-04-27 18:03:14 +03:00
Cohee 413cec8a9f Merge branch 'staging' into wi-entry-inclusion-prio 2024-04-27 18:00:00 +03:00
Cohee 8e7ffab793
Merge pull request #2149 from Wolfsblvt/duplicate-wi-entries
Button to duplicate WI entries
2024-04-27 17:57:59 +03:00
Cohee 770aee4953 Adjust title widths 2024-04-27 17:52:47 +03:00
Cohee f479901c87
Merge pull request #2152 from Wolfsblvt/auto-sort-tags-option
Option to auto-sort tags (+UI improvements)
2024-04-27 17:45:23 +03:00
Cohee 1dbe7897d4 Prevent ticking if confirm canceled 2024-04-27 17:41:27 +03:00
Cohee c95956766e Don't need a hack since you're not awaiting the popup 2024-04-27 17:33:52 +03:00
Cohee e92c0db6a2
Merge pull request #2148 from HiroseKoichi/staging
Use names in place of roles for ChatML and LLama-3-Instruct
2024-04-27 17:17:22 +03:00
LenAnderson b8504735fa fix chat input font reset 2024-04-27 10:16:45 -04:00
Hirose 3a8b8ed639 Skill Issue 2024-04-27 08:20:44 -05:00
LenAnderson df011bd14c fix return type 2024-04-27 09:12:39 -04:00
LenAnderson eee0504ff4 add aborting command execution 2024-04-27 09:11:54 -04:00
Hirose 3a78d69b5b Use {{name}} macro, create new templates 2024-04-27 07:39:52 -05:00
Wolfsblvt 2e562d187a Option to auto-sort tags (+UI improvements)
- Toggle to auto-sort tags alphabetically
- Init auto-sort based on current sorted state, if not chosen before
- Tag management redraw list if changes happen
- Tag management highlight renamed rows on auto-sort if they get automatically reordered
- Manual drag&drop of tags disables auto-sort option
- Small fixes to popup tag management pop drawing
- Utility function to flash highlight via CSS
2024-04-27 10:26:01 +02:00
evpeople 4521dde455
add a button to translate input message 2024-04-27 13:46:13 +08:00
Wolfsblvt b64b0e3362 Button to duplicate WI entries
- Add an option to duplicate a WI entry, copying everything besides UID
- moved UI move action on new WI entry to the UI function, not inside utility
2024-04-27 06:18:26 +02:00
RossAscends f8ca73265b userSettings expandables get borders 2024-04-27 13:13:54 +09:00
RossAscends 1f7614af33 re-order/style User Settings Panel 2024-04-27 12:50:33 +09:00
Wolfsblvt a48a9318c1 Add groupOverride to server endpoint too 2024-04-27 04:49:08 +02:00
Wolfsblvt dcb042681d Change group prio name, add default value set 2024-04-27 04:40:35 +02:00
Wolfsblvt 7df2f7e752 WI inclusion groups will never roll for trigger% 2024-04-27 03:44:00 +02:00
Hirose c3578d2cda Use names in place of role for ChatML and LLama-3-Instruct 2024-04-26 20:14:51 -05:00
Wolfsblvt 8db39a58fb World Info inclusion group prio toggle 2024-04-27 02:23:37 +02:00
Cohee bbdbb08301 Fix main prompt clearing on disabling 2024-04-27 00:08:30 +03:00
Cohee b06e09c030
Merge pull request #2131 from Yokayo/staging
Localization enhancements
2024-04-26 23:05:55 +03:00
Cohee bb2bcdbf61 The dot went MIA 2024-04-26 23:04:11 +03:00
Cohee 2e278e7323 Fix missing localization for unknown locale 2024-04-26 22:57:42 +03:00
Cohee 4c9d52422b [chore] ESLint and JSDoc 2024-04-26 22:46:13 +03:00
Cohee f4ba1f68ef
Merge pull request #2136 from BlueprintCoding/release
Added import function for AICharacterCards.com cards
2024-04-26 22:42:04 +03:00
Cohee 12497e8fb1
Merge pull request #2141 from valadaptive/generate-cleanups-4
Clean up Generate(), part 4
2024-04-26 22:40:04 +03:00
Cohee 8153e747ef
Merge pull request #2135 from johnflux/staging
Fix SillyTavern being launched from a different working directory
2024-04-26 22:06:40 +03:00
Cohee 63b597beb8 Fix node serve startup 2024-04-26 22:02:46 +03:00
Cohee cdbb0b21da
Merge pull request #2145 from sirius422/fix-regex-filename-non-eng-characters
Change the naming rule of regex exporting
2024-04-26 21:59:05 +03:00
Cohee b2f40e490b Fix mobile-styles.css for waifuMode
Mobile bros want a waifu too
2024-04-26 21:51:28 +03:00
sirius422 a96e1903a3 Change the naming rule of regex exporting 2024-04-27 00:05:10 +08:00
Cohee be7eb8b2b5
Merge pull request #2143 from aisu-wata0/style_mes_block_overflow_y
style: `.mes_block { overflow-y: clip; }`
2024-04-26 18:36:17 +03:00
Cohee 3b6372431a
Merge pull request #2144 from sirius422/fix-json-export-extension
Add json extension to exported oai and LogitBias presets
2024-04-26 18:30:55 +03:00
sirius422 389ee7917f Add json extension to exported oai and LogitBias presets 2024-04-26 23:07:25 +08:00
Cohee 212e61d2a1 Lazy initialization of Claude tokenizer. Add JSDoc for tokenizer handlers 2024-04-26 15:17:02 +03:00
Cohee 1b60e4a013 Init user storage module before server listening 2024-04-26 14:09:40 +03:00
Aisu Wata 93cd93ada3 style: `.mes_block { overflow-y: clip; }` 2024-04-25 21:49:12 -03:00
valadaptive dbcc75471f Refactor CFG prompt gen in getCombinedPrompt
We don't need to create the cfgPrompt variable unless useCfgPrompt is
true, so move it inside the if-block.
2024-04-25 09:09:30 -04:00
valadaptive 2a0497ca9e Only generate negative prompt for textgen API
The original comment mentions that we need to get the negative prompt
first since it "has the unmodified mesSend array", but we've cloned the
mesSend array since forever, so I don't think mutation is an issue
anymore.
2024-04-25 09:09:30 -04:00
valadaptive 2d0767306e Remove unnecessary cfgPrompt null-chains
We already check if cfgPrompt exists.
2024-04-25 09:09:30 -04:00
valadaptive 8ca83bb255 Extract CFG check 2024-04-25 09:09:30 -04:00
valadaptive 80a6406062 Don't reassign thisPromptBits
Instead, just use additionalPromptStuff where thisPromptBits was used
after the assignment.
2024-04-25 09:09:30 -04:00
valadaptive ff9345a843 Make generate_data preparation a switch-case
We switch based on main_api. In the future, I'd like to move the
openai-specific token count stuff outside the switch case and extract
the generate_data preparation into its own function that we can pass
main_api into.
2024-04-25 09:09:30 -04:00
valadaptive fe663c4f04 Move auto_adjust_response_length logic
This if-block only applies to Kobold Horde, so move it inside the Kobold
and Horde-specific case in the else-if chain.
2024-04-25 09:09:30 -04:00
Blueprint Coding 305afb3713 Added import function for AICharacterCards.com cards
Added ability to import cards directly from aicharactercards.com via it's api like Chub and Janny.
Video of it in action: https://streamable.com/gbfdtw

Just pass the last two slash vars from the url (the author and card title) from a page. EX: aicharcards/the-game-master to:
https://aicharactercards.com/wp-json/pngapi/v1/image/

In this example: https://aicharactercards.com/wp-json/pngapi/v1/image/aicharcards/the-game-master
2024-04-24 18:04:17 -06:00
John Tapsell 1acbef1890 Fix SillyTavern being launched from a different working directory
Fixes launching ST from ST launcher on mac
2024-04-24 16:15:32 -07:00
Yokayo 4bb719359c
Fix tabs 2024-04-24 21:19:26 +07:00
Yokayo 847eb60806
Update ru-ru.json 2024-04-24 21:14:03 +07:00
Yokayo e799bd3920
Fix getMissingTranslations() and change its behavior 2024-04-24 21:12:40 +07:00
Yokayo 2b1aee9e71
Localize two hard-coded strings 2024-04-24 21:07:42 +07:00
Yokayo d65f068310
More localizable strings 2024-04-24 20:58:24 +07:00
Yokayo b1c199e650
Add more localizable strings 2024-04-24 19:02:00 +07:00
Bronya-Rand 770f3e5da3 chore: apply align-items center and img sample for img only scraper icons 2024-04-22 19:12:02 +01:00
Bronya-Rand 0f0895f345 feat: implement miHoYo scraper 2024-04-22 19:11:00 +01:00
81 changed files with 2885 additions and 9458 deletions

View File

@ -6,6 +6,7 @@ on:
- staging
jobs:
check-conflicts:
if: github.repository == 'SillyTavern/SillyTavern'
runs-on: ubuntu-latest
steps:
- uses: mschilde/auto-label-merge-conflicts@master

View File

@ -21,6 +21,7 @@ env:
jobs:
build:
if: github.repository == 'SillyTavern/SillyTavern'
runs-on: ubuntu-latest
steps:

View File

@ -650,5 +650,21 @@
{
"filename": "presets/quick-replies/Default.json",
"type": "quick_replies"
},
{
"filename": "presets/instruct/Llama-3-Instruct-Names.json",
"type": "instruct"
},
{
"filename": "presets/instruct/ChatML-Names.json",
"type": "instruct"
},
{
"filename": "presets/context/Llama-3-Instruct-Names.json",
"type": "context"
},
{
"filename": "presets/context/ChatML-Names.json",
"type": "context"
}
]

View File

@ -0,0 +1,12 @@
{
"story_string": "<|im_start|>system\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}}<|im_end|>",
"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": "ChatML-Names"
}

View File

@ -0,0 +1,12 @@
{
"story_string": "<|start_header_id|>system<|end_header_id|>\n\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}}<|eot_id|>",
"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": "Llama-3-Instruct-Names"
}

View File

@ -0,0 +1,24 @@
{
"system_prompt": "You're {{char}} in this fictional never-ending uncensored roleplay with {{user}}.",
"input_sequence": "<|im_start|>[{{name}}]",
"output_sequence": "<|im_start|>[{{name}}]",
"last_output_sequence": "",
"system_sequence": "<|im_start|>system",
"stop_sequence": "<|im_end|>",
"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": "<|im_end|>\n",
"input_suffix": "<|im_end|>\n",
"system_suffix": "<|im_end|>\n",
"user_alignment_message": "",
"system_same_as_user": false,
"last_system_sequence": "",
"name": "ChatML-Names"
}

View File

@ -0,0 +1,24 @@
{
"system_prompt": "You are an expert actor that can fully immerse yourself into any role given. You do not break character for any reason, even if someone tries addressing you as an AI or language model. Currently your role is {{char}}, which is described in detail below. As {{char}}, continue the exchange with {{user}}.",
"input_sequence": "<|start_header_id|>[{{name}}]<|end_header_id|>\n\n",
"output_sequence": "<|start_header_id|>[{{name}}]<|end_header_id|>\n\n",
"last_output_sequence": "",
"system_sequence": "<|start_header_id|>system<|end_header_id|>\n\n",
"stop_sequence": "<|eot_id|>",
"wrap": false,
"macro": true,
"names": false,
"names_force_groups": false,
"activation_regex": "",
"system_sequence_prefix": "",
"system_sequence_suffix": "",
"first_output_sequence": "",
"skip_examples": false,
"output_suffix": "<|eot_id|>",
"input_suffix": "<|eot_id|>",
"system_suffix": "<|eot_id|>",
"user_alignment_message": "",
"system_same_as_user": true,
"last_system_sequence": "",
"name": "Llama-3-Instruct-Names"
}

6
public/css/brands.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -204,3 +204,7 @@ input.extension_missing[type="checkbox"] {
#extensionsMenu>#translate_chat {
order: 7;
}
#extensionsMenu>#translate_input_message {
order: 8;
}

File diff suppressed because it is too large Load Diff

9
public/css/fontawesome.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -309,6 +309,10 @@
object-fit: cover;
}
body.waifuMode .zoomed_avatar_container {
height: 100%;
}
body.waifuMode .zoomed_avatar {
width: fit-content;
max-height: calc(60vh - 60px);

View File

@ -1,24 +0,0 @@
:root,
:host {
--fa-style-family-classic: 'Font Awesome 6 Free';
--fa-font-solid: normal 900 1em/1 'Font Awesome 6 Free';
}
@font-face {
font-family: 'Font Awesome 6 Free';
font-style: normal;
font-weight: 900;
font-display: block;
src: url("../webfonts/fa-solid-900.woff2") format("woff2"), url("../webfonts/fa-solid-900.ttf") format("truetype");
}
.fas,
.fa-solid {
font-weight: 900;
}
/*!
* Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2023 Fonticons, Inc.
*/

6
public/css/solid.min.css vendored Normal file
View File

@ -0,0 +1,6 @@
/*!
* Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2024 Fonticons, Inc.
*/
:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-weight:900}

File diff suppressed because it is too large Load Diff

View File

@ -38,7 +38,7 @@
"LLaMA / Mistral / Yi models only": "Только для моделей LLaMA / Mistral / Yi. Перед этим обязательно выберите подходящий токенизатор.\nПоследовательности, которых не должно быть на выходе.\nОдна на строку. Текст или [идентификаторы токенов].\nМногие токены имеют пробел впереди. Используйте счетчик токенов, если не уверены.",
"Example: some text [42, 69, 1337]": "Пример:\nкакой-то текст\n[42, 69, 1337]",
"Classifier Free Guidance. More helpful tip coming soon": "Classifier Free Guidance. Чуть позже опишем более подробно",
"Scale": "Масштаб",
"Scale": "Scale",
"GBNF Grammar": "Грамматика GBNF",
"Usage Stats": "Статистика исп.",
"Click for stats!": "Нажмите для получения статистики!",
@ -97,7 +97,7 @@
"Sequences you don't want to appear in the output. One per line.": "Строки, которых не должно быть в выходном тексте. По одной на строчку.",
"AI Module": "Модуль ИИ",
"Changes the style of the generated text.": "Изменяет стиль создаваемого текста.",
"Used if CFG Scale is unset globally, per chat or character": "Используется, если масштаб CFG не установлен глобально, для каждого чата или персонажа.",
"Used if CFG Scale is unset globally, per chat or character": "Используется, если CFG Scale не установлен глобально, для каждого чата или персонажа.",
"Inserts jailbreak as a last system message.": "Вставлять JailBreak последним системным сообщением.",
"This tells the AI to ignore its usual content restrictions.": "Сообщает AI о необходимости игнорировать стандартные ограничения контента.",
"NSFW Encouraged": "Поощрять NSFW",
@ -262,7 +262,7 @@
"Auto-Continue": "Авто-продолжение",
"Collapse Consecutive Newlines": "Сворачивать последовательные новые строки",
"Allow for Chat Completion APIs": "Разрешить для API Chat Completion",
"Target length (tokens)": "Целевая длина (токены)",
"Target length (tokens)": "Целевая длина (в токенах)",
"Keep Example Messages in Prompt": "Сохранять примеры сообщений в промпте",
"Remove Empty New Lines from Output": "Удалять пустые строчки из вывода",
"Disabled for all models": "Выключено для всех моделей",
@ -300,11 +300,11 @@
"Chat Style": "Стиль чата",
"Default": "По умолчанию",
"Bubbles": "Пузыри",
"No Blur Effect": "Отключить эффект размытия",
"No Text Shadows": "Отключить тень от текста",
"No Blur Effect": "Отключить размытие",
"No Text Shadows": "Отключить тень текста",
"Waifu Mode": "Рeжим Вайфу",
"Message Timer": "Таймер сообщений",
"Model Icon": "Показать значки модели",
"Model Icon": "Значки моделей",
"# of messages (0 = disabled)": "# сообщений (0 = отключено)",
"Advanced Character Search": "Расширенный поиск по персонажам",
"Allow {{char}}: in bot messages": "Показывать {{char}}: в ответах",
@ -314,7 +314,7 @@
"Lorebook Import Dialog": "Показывать окно импорта лорбука",
"MUI Preset": "Пресет MUI:",
"If set in the advanced character definitions, this field will be displayed in the characters list.": "Если это поле задано в расширенных параметрах персонажа, оно будет отображаться в списке персонажей.",
"Relaxed API URLS": "Смягченные URL-адреса API",
"Relaxed API URLS": "Смягчённые адреса API",
"Custom CSS": "Пользовательский CSS",
"Default (oobabooga)": "По умолчанию (oobabooga)",
"Mancer Model": "Модель Mancer",
@ -381,7 +381,7 @@
"text": "текст",
"Delete": "Удалить",
"Cancel": "Отменить",
"Advanced Defininitions": "Продвинутое описание",
"Advanced Defininitions": "Расширенное описание",
"Personality summary": "Сводка по личности",
"A brief description of the personality": "Краткое описание личности",
"Scenario": "Сценарий",
@ -431,7 +431,7 @@
"JSON": "JSON",
"presets": "Пресеты",
"Message Sound": "Звук сообщения",
"Author's Note": "Пометки автора",
"Author's Note": "Заметки автора",
"Send Jailbreak": "Отправлять джейлбрейк",
"Replace empty message": "Заменять пустые сообщения",
"Send this text instead of nothing when the text box is empty.": "Этот текст будет отправлен в случае отсутствия текста на отправку.",
@ -475,7 +475,7 @@
"--- Pick to Edit ---": "--- Выберите для редактирования ---",
"or": "или",
"New": "Новый",
"Priority": "Приритет",
"Priority": "Приоритет",
"Custom": "Пользовательский",
"Title A-Z": "Название от A до Z",
"Title Z-A": "Название от Z до A",
@ -528,7 +528,7 @@
"UI Border": "Границы UI",
"Chat Style:": "Стиль чата",
"Chat Width (PC)": "Ширина чата (для ПК)",
"Chat Timestamps": "Временные метки в чате",
"Chat Timestamps": "Метки времени в чате",
"Tags as Folders": "Теги как папки",
"Chat Truncation": "Усечение чата",
"(0 = unlimited)": "(0 = неограниченное)",
@ -559,8 +559,8 @@
"Disables animations and transitions": "Отключение анимаций и переходов.",
"removes blur from window backgrounds": "Убрать размытие с фона окон, чтобы ускорить рендеринг.",
"Remove text shadow effect": "Удаление эффекта тени от текста.",
"Reduce chat height, and put a static sprite behind the chat window": "Уменьшитm высоту чата и поместить статичный спрайт за окном чата.",
"Always show the full list of the Message Actions context items for chat messages, instead of hiding them behind '...'": "Всегда показывать полный список контекстных элементов 'Действия с сообщением' для сообщений чата, а не прятать их за '...'.",
"Reduce chat height, and put a static sprite behind the chat window": "Уменьшить высоту чата и поместить статичный спрайт за окном чата.",
"Always show the full list of the Message Actions context items for chat messages, instead of hiding them behind '...'": "Всегда показывать полный список действий с сообщением, а не прятать их за '...'.",
"Alternative UI for numeric sampling parameters with fewer steps": "Альтернативный пользовательский интерфейс для числовых параметров выборки с меньшим количеством шагов.",
"Entirely unrestrict all numeric sampling parameters": "Полностью разграничить все числовые параметры выборки.",
"Time the AI's message generation, and show the duration in the chat log": "Время генерации сообщений ИИ и его показ в журнале чата.",
@ -600,7 +600,7 @@
"Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "Включить авто-свайп. Настройки в этом разделе действуют только при включенном авто-свайпе.",
"If the generated message is shorter than this, trigger an auto-swipe": "Если сгенерированное сообщение короче этого значения, срабатывает авто-свайп.",
"Reload and redraw the currently open chat": "Перезагрузить и перерисовать открытый в данный момент чат.",
"Auto-Expand Message Actions": "Развернуть контекстные элементы",
"Auto-Expand Message Actions": "Развернуть действия",
"Not Connected": "Не подключено",
"Persona Management": "Управление персоной",
"Persona Description": "Описание персоны",
@ -629,16 +629,15 @@
"Most chats": "Больше всего чатов",
"Least chats": "Меньше всего чатов",
"Back": "Назад",
"Prompt Overrides (For OpenAI/Claude/Scale APIs, Window/OpenRouter, and Instruct mode)": "Перезапись промпта (Для OpenAI/Claude/Scale API, Window/OpenRouter, и режима Instruct)",
"Insert {{original}} into either box to include the respective default prompt from system settings.": "Введите {{original}} в любое поле, чтобы использовать соответствующий промпт из системных настроек",
"Prompt Overrides": "Индивидуальный промпт",
"(For OpenAI/Claude/Scale APIs, Window/OpenRouter, and Instruct Mode)": "(для API OpenAI/Claude/Scale, Window/OpenRouter, а также режима Instruct)",
"Insert {{original}} into either box to include the respective default prompt from system settings.": "Введите {{original}} в любое поле, чтобы вставить соответствующий промпт из системных настроек",
"Main Prompt": "Основной промпт",
"Jailbreak": "Джейлбрейк",
"Creator's Metadata (Not sent with the AI prompt)": "Метаданные (не отправляются ИИ)",
"Everything here is optional": "Все поля необязательные",
"Created by": "Автор",
"Character Version": "Версия персонажа",
"Tags to Embed": "Встраиваемые теги",
"How often the character speaks in group chats!": "Как часто персонаж говорит в групповых чатах",
"Important to set the character's writing style.": "Серьёзно влияет на стиль письма персонажа.",
"ATTENTION!": "ВНИМАНИЕ!",
"Samplers Order": "Порядок сэмплеров",
@ -655,7 +654,7 @@
"Use 'Unlocked Context' to enable chunked generation.": "Использовать 'Неограниченный контекст' для активации кусочной генерации",
"It extends the context window in exchange for reply generation speed.": "Увеличивает размер контекста в обмен на скорость генерации.",
"Continue": "Продолжить",
"CFG Scale": "Масштаб CFG",
"CFG Scale": "CFG Scale",
"Editing:": "Изменения",
"AI reply prefix": "Префикс для ответа ИИ",
"Custom Stopping Strings": "Стоп-строки",
@ -671,9 +670,9 @@
"Chat Name (Optional)": "Название чата (необязательно)",
"Filter...": "Фильтры...",
"Search...": "Поиск...",
"Any contents here will replace the default Main Prompt used for this character. (v2 spec: system_prompt)": "Все содержание этой ячейки будет заменять стандартный Промт",
"Any contents here will replace the default Jailbreak Prompt used for this character. (v2 spec: post_history_instructions)": "Все содержание этой ячейки будет заменять стандартный Джейлбрейк",
"(Botmaker's name / Contact Info)": "(Имя автора / Контакты)",
"Any contents here will replace the default Main Prompt used for this character. (v2 spec: system_prompt)": "Все содержимое этого поля будет заменять стандартный промпт",
"Any contents here will replace the default Jailbreak Prompt used for this character. (v2 spec: post_history_instructions)": "Все содержимое этого поля будет заменять стандартный джейлбрейк",
"(Botmaker's name / Contact Info)": "(Имя автора, контакты)",
"(If you want to track character versions)": "Если вы хотите отслеживать версии персонажа",
"(Describe the bot, give use tips, or list the chat models it has been tested on. This will be displayed in the character list.)": "(Описание персонажа, советы по использованию, список моделей, на которых он тестировался. Информация будет отображаться в списке персонажей)",
"(Write a comma-separated list of tags)": "(Список тегов через запятую)",
@ -713,12 +712,12 @@
"Restore defaul note": "Восстановить стандартную заметку",
"API Connections": "Соединения с API",
"Can help with bad responses by queueing only the approved workers. May slowdown the response time.": "Может помочь с плохими ответами ставя в очередь только подтвержденных работников. Может замедлить время ответа.",
"Clear your API key": "Очистите свой ключ от API",
"Clear your API key": "Стереть ключ от API",
"Refresh models": "Обновить модели",
"Get your OpenRouter API token using OAuth flow. You will be redirected to openrouter.ai": "Получите свой OpenRouter API токен используя OAuth. У вас будет открыта вкладка openrouter.ai",
"Verifies your API connection by sending a short test message. Be aware that you'll be credited for it!": "Проверка работоспособности вашего соединения с API. Знайте, что оно будет отправлено от вашего лица.",
"Create New": "Создать новое",
"Edit": "Изменить",
"Edit": "Редактировать",
"Locked = World Editor will stay open": "Закреплено = Редактор мира останется открытым",
"Entries can activate other entries by mentioning their keywords": "Записи могут активировать другие записи, если в них содержатся ключевые слова",
"Lookup for the entry keys in the context will respect the case": "Большая буква имеет значение при активации ключевого слова",
@ -847,7 +846,7 @@
"Underlined Text": "Подчёркнутый",
"Token Probabilities": "Вероятности токенов",
"Close chat": "Закрыть чат",
"Manage chat files": "Управление файлами чата",
"Manage chat files": "Управление чатами",
"Import Extension From Git Repo": "Импортировать расширение из Git Repository",
"Install extension": "Установить расширение",
"Manage extensions": "Управление расширениями",
@ -863,12 +862,12 @@
"When this is off, responses will be displayed all at once when they are complete.": "Если параметр выключен, ответы будут отображаться сразу целиком, и только после полного завершения генерации.",
"Quick Prompts Edit": "Быстрое редактирование промптов",
"Enable OpenAI completion streaming": "Включить стриминг OpenAI",
"Main": "Главное",
"Main": "Основной",
"Utility Prompts": "Служебные промпты",
"Add character names": "Добавить имена персонажей",
"Send names in the message objects. Helps the model to associate messages with characters.": "Отправить имена в объектах сообщений. Помогает модели ассоциировать сообщения с персонажами.",
"Continue prefill": "Префилл для продолжения",
"Continue sends the last message as assistant role instead of system message with instruction.": "Продолжение отправляет последнее сообщение в роли ассистента, а не системное сообщение с инструкцией.",
"Continue sends the last message as assistant role instead of system message with instruction.": "Продолжение отправляет последнее сообщение в роли ассистента, вместо системного сообщения с инструкцией.",
"Squash system messages": "Склеивать сообщения системыы",
"Combines consecutive system messages into one (excluding example dialogues). May improve coherence for some models.": "Объединяет последовательные системные сообщения в одно (за исключением примеров диалогов). Может улучшить согласованность для некоторых моделей.",
"Send inline images": "Отправлять встроенные изображения",
@ -973,12 +972,128 @@
"Most tokens have a leading space.": "У большинства токенов в начале пробел.",
"Prompts": "Промпты",
"Text or token ids": "Текст или [идентификаторы токенов]",
"World Info Format Template": "Шаблон форматирования информации о мире",
"World Info Format Template": "Шаблон оформления информации о мире",
"Wraps activated World Info entries before inserting into the prompt.": "Дополняет информацию об активном на данный момент мире перед её отправкой в промпт.",
"Doesn't work? Try adding": "Не работает? Попробуйте добавить в конце",
"at the end!": "!",
"Authorize": "Авторизоваться",
"No persona description": "[Нет описания]",
"Not connected to API!": "Нет соединения с API!",
"Type a message, or /? for help": "Введите сообщение, или /? для получения справки по командам"
"Type a message, or /? for help": "Введите сообщение, или /? для получения справки по командам",
"Welcome to SillyTavern!": "Добро пожаловать в SillyTavern!",
"Won't be shared with the character card on export.": "Не попадут в карточку персонажа при экспорте.",
"Web-search": "Веб-поиск",
"Persona Name:": "Имя персоны:",
"User first message": "Первое сообщение пользователя",
"extension_token_counter": "Токенов:",
"Character's Note": "Заметка о персонаже",
"(Text to be inserted in-chat @ designated depth and role)": "Этот текст будет вставлен в чат на заданную глубину и с определённой ролью",
"@ Depth": "Глубина",
"Role": "Роль",
"System": "Система",
"User": "Пользователь",
"Assistant": "Ассистент",
"How often the character speaks in": "Как часто персонаж говорит в",
"group chats!": "групповых чатах!",
"Creator's Metadata": "Метаданные",
"(Not sent with the AI Prompt)": "(не отправляются ИИ)",
"New Chat": "Новый чат",
"Import Chat": "Импорт чата",
"Chat Lore": "Лор чата",
"Chat Lorebook for": "Лорбук для чата",
"A selected World Info will be bound to this chat.": "Выбранный мир будет привязан к этому чату. При генерации ответа ИИ он будет совмещён с записями из глобального лорбука и лорбука персонажа.",
"Missing key": "❌ Ключа нет",
"Key saved": "✔️ Ключ сохранён",
"Use the appropriate tokenizer for Jurassic models, which is more efficient than GPT's.": "Использовать токенайзер для моделей Jurassic, эффективнее GPT-токенайзера",
"Use system prompt (Gemini 1.5 pro+ only)": "Использовать системный промпт (только для Gemini 1.5 pro и выше)",
"Experimental feature. May not work for all backends.": "Экспериментальная возможность, на некоторых бэкендах может не работать.",
"Avatar Hover Magnification": "Зум аватарки по наведению",
"Enable magnification for zoomed avatar display.": "Добавляет возможность приближать увеличенную версию аватарки.",
"Unique to this chat": "Только для текущего чата",
"Checkpoints inherit the Note from their parent, and can be changed individually after that.": "Чекпоинты наследуют заметки от родительского чата, но впоследствие их всегда можно изменить.",
"Include in World Info Scanning": "Учитывать при сканировании Информации о мире",
"Before Main Prompt / Story String": "Перед основным промптом / строкой истории",
"After Main Prompt / Story String": "После основного промпта / строки истории",
"In-chat @ Depth": "Встав. на глуб.",
"as": "роль:",
"Insertion Frequency": "Частота вставки",
"(0 = Disable, 1 = Always)": "(0 = никогда, 1 = всегда)",
"User inputs until next insertion:": "Ваших сообщений до след. вставки:",
"Character Author's Note (Private)": "Заметки автора персонажа (личные)",
"Will be automatically added as the author's note for this character. Will be used in groups, but can't be modified when a group chat is open.": "Автоматически применятся к этому персонажу в качестве заметок автора. Будут использоваться в группах, но при активном групповом чате к редактированию недоступны.",
"Use character author's note": "Использовать заметки автора персонажа",
"Replace Author's Note": "Вместо заметок автора",
"Top of Author's Note": "Сверху от заметок автора",
"Bottom of Author's Note": "Снизу от заметок автора",
"Default Author's Note": "Стандартные заметки автора",
"Will be automatically added as the Author's Note for all new chats.": "Будут автоматически добавляться во все новые чаты в качестве Заметок автора",
"1 = disabled": "1 = откл.",
"write short replies, write replies using past tense": "пиши короткие ответы, пиши в настоящем времени",
"Positive Prompt": "Положительный промпт",
"Character CFG": "CFG для персонажа",
"Will be automatically added as the CFG for this character.": "Автоматически применится к персонажу как его CFG.",
"Global CFG": "Глобальный CFG",
"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.": "К примеру, если отметить галочки с чатом, персонажем и глобальной настройкой, то все эти негативы соберутся в одну строку, разделённую запятыми.",
"Always Include": "Всегда применять",
"Chat Negatives": "Негативы от чата",
"Character Negatives": "Негативы от персонажа",
"Global Negatives": "Глобальные негативы",
"Custom Separator:": "Кастомный разделитель:",
"Insertion Depth:": "Глубина вставки:",
"Chat CFG": "CFG для чата",
"Chat backgrounds generated with the": "Здесь будут появляться фоны, сгенерированные расширением",
"extension will appear here.": ".",
"Prevent further recursion (this entry will not activate others)": "Пресечь дальнейшую рекурсию (эта запись не будет активировать другие)",
"Alert if your world info is greater than the allocated budget.": "Оповещать, если ваш мир выходит за выделенный бюджет.",
"Convert to Persona": "Преобразовать в персону",
"Link to Source": "Ссылка на источник",
"Replace / Update": "Заменить / Обновить",
"Smoothing Curve": "Кривая сглаживания",
"Message Actions": "Действия с сообщением",
"SillyTavern is aimed at advanced users.": "SillyTavern рассчитана на продвинутых пользователей.",
"If you're new to this, enable the simplified UI mode below.": "Если вы новичок, советуем включить упрощённый UI.",
"Enable simple UI mode": "Включить упрощённый UI",
"welcome_message_part_1": "Ознакомьтесь с",
"welcome_message_part_2": "официальной документацией",
"welcome_message_part_3": ".",
"welcome_message_part_4": "Введите",
"welcome_message_part_5": "в чате, чтобы получить справку по командам и макросам.",
"welcome_message_part_6": "Заходите на наш",
"Discord server": "Discord-сервер,",
"welcome_message_part_7": "там публикуется много разной полезной информации, в том числе анонсы.",
"Before you get started, you must select a persona name.": "Для начала вам следует выбрать имя своей персоны.",
"welcome_message_part_8": "Его можно будет изменить в любое время через иконку",
"welcome_message_part_9": ".",
"UI Language:": "Язык интерфейса:",
"Ignore EOS Token": "Игнорировать EOS-токен",
"Ignore the EOS Token even if it generates.": "Игнорировать EOS-токен, даже если он сгенерировался.",
"Hide Muted Member Sprites": "Скрыть спрайты заглушенных участников",
"Group generation handling mode": "Генерировать ответы путём...",
"Swap character cards": "Подмены карточки персонажа",
"Join character cards (exclude muted)": "Совмещения карточек (кроме заглушенных)",
"Join character cards (include muted)": "Совмещения карточек (включая заглушенных)",
"Click to allow/forbid the use of external media for this group.": "Нажмите, чтобы разрешить/запретить использование внешних медиа в этой группе.",
"Scenario Format Template": "Шаблон оформления сценария",
"scenario_format_template_part_1": "Используйте",
"scenario_format_template_part_2": "чтобы указать, куда именно вставляется основное содержимое.",
"Personality Format Template": "Шаблон оформления характера",
"Group Nudge Prompt Template": "Шаблон промпта-подсказки для групп",
"Sent at the end of the group chat history to force reply from a specific character.": "Добавляется в конец истории сообщений в групповом чате, чтобы запросить ответ от конкретного персонажа.",
"Set at the beginning of the chat history to indicate that a new chat is about to start.": "Добавляется в начале истории сообщений в качестве указания на то, что дальше начнётся новый чат.",
"New Group Chat": "Новый групповой чат",
"Set at the beginning of the chat history to indicate that a new group chat is about to start.": "Добавляется в начале истории сообщений в качестве указания на то, что дальше начнётся новый групповой чат.",
"New Example Chat": "Новый образец чата",
"Set at the beginning of Dialogue examples to indicate that a new example chat is about to start.": "Добавляется в начале примеров диалогов в качестве указания на то, что дальше начнётся новый чат-пример.",
"Continue nudge": "Подсказка для продолжения",
"Set at the end of the chat history when the continue button is pressed.": "Добавляется в конец истории чата, когда отправлен запрос на продолжение текущего сообщения.",
"Prompts": "Промпты",
"Your Persona": "Ваша персона",
"Continue Postfix": "Постфикс для продолжения",
"Space": "Пробел",
"Newline": "Новая строка",
"Double Newline": "Две новые строки",
"The next chunk of the continued message will be appended using this as a separator.": "Используется в качестве разделителя между уже имеющимся сообщением и его новым отрывком, при генерации продолжения"
}

View File

@ -152,10 +152,12 @@ import {
Stopwatch,
isValidUrl,
ensureImageFormatSupported,
flashHighlight,
} from './scripts/utils.js';
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, getSlashCommandsHelp, processChatSlashCommands, registerSlashCommand } from './scripts/slash-commands.js';
import { COMMENT_NAME_DEFAULT, executeSlashCommands, executeSlashCommandsOnChatInput, getSlashCommandsHelp, isExecutingCommandsFromChatInput, pauseScriptExecution, processChatSlashCommands, registerSlashCommand, stopScriptExecution } from './scripts/slash-commands.js';
import {
tag_map,
tags,
@ -295,8 +297,22 @@ export {
isOdd,
countOccurrences,
renderTemplate,
itemizedPrompts,
findItemizedPromptSet,
itemizedParams,
};
/**
* Wait for page to load before continuing the app initialization.
*/
await new Promise((resolve) => {
if (document.readyState === 'complete') {
resolve();
} else {
window.addEventListener('load', resolve);
}
});
showLoader();
// Yoink preloader entirely; it only exists to cover up unstyled content while loading JS
document.getElementById('preloader').remove();
@ -380,6 +396,11 @@ DOMPurify.addHook('uponSanitizeElement', (node, _, config) => {
mediaBlocked = true;
node.remove();
}
if (mediaBlocked && (node instanceof HTMLMediaElement)) {
node.autoplay = false;
node.pause();
}
}
break;
}
@ -533,7 +554,8 @@ let fav_ch_checked = false;
let scrollLock = false;
export let abortStatusCheck = new AbortController();
const durationSaveEdit = 1000;
/** @type {number} The debounce timeout used for chat/settings save. debounce_timeout.long: 1.000 ms */
const durationSaveEdit = debounce_timeout.relaxed;
const saveSettingsDebounced = debounce(() => saveSettings(), durationSaveEdit);
export const saveCharacterDebounced = debounce(() => $('#create_button').trigger('click'), durationSaveEdit);
@ -543,7 +565,7 @@ export const saveCharacterDebounced = debounce(() => $('#create_button').trigger
*
* The printing will also always reprint all filter options of the global list, to keep them up to date.
*/
const printCharactersDebounced = debounce(() => { printCharacters(false); }, 100);
const printCharactersDebounced = debounce(() => { printCharacters(false); }, debounce_timeout.quick);
/**
* @enum {string} System message types
@ -711,6 +733,7 @@ function reloadMarkdownProcessor(render_formulas = false) {
underline: true,
tables: true,
parseImgDimensions: true,
simpleLineBreaks: true,
extensions: [
showdownKatex(
{
@ -729,6 +752,7 @@ function reloadMarkdownProcessor(render_formulas = false) {
parseImgDimensions: true,
tables: true,
underline: true,
simpleLineBreaks: true,
extensions: [markdownUnderscoreExt()],
});
}
@ -841,7 +865,7 @@ export let active_character = '';
export let active_group = '';
export const entitiesFilter = new FilterHelper(printCharactersDebounced);
export const personasFilter = new FilterHelper(debounce(getUserAvatars, 100));
export const personasFilter = new FilterHelper(debounce(getUserAvatars, debounce_timeout.quick));
export function getRequestHeaders() {
return {
@ -1343,7 +1367,7 @@ async function printCharacters(fullRefresh = false) {
}
const hidden = (characters.length + groups.length) - displayCount;
if (hidden > 0) {
if (hidden > 0 && entitiesFilter.hasAnyFilter()) {
$(listId).append(getHiddenBlock(hidden));
}
@ -1704,6 +1728,7 @@ export async function reloadCurrentChat() {
*/
export function sendTextareaMessage() {
if (is_send_press) return;
if (isExecutingCommandsFromChatInput) return;
let generateType;
// "Continue on send" is activated when the user hits "send" (or presses enter) on an empty chat box, and the last
@ -1794,9 +1819,7 @@ function messageFormatting(mes, ch_name, isSystem, isUser, messageId) {
}
if (this_chid === undefined && !selected_group) {
mes = mes
.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
.replace(/\n/g, '<br/>');
mes = mes.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
} else if (!isSystem) {
// Save double quotes in tags as a special character to prevent them from being encoded
if (!power_user.encode_tags) {
@ -1828,7 +1851,6 @@ function messageFormatting(mes, ch_name, isSystem, isUser, messageId) {
// Firefox creates extra newlines from <br>s in code blocks, so we replace them before converting newlines to <br>s.
return match.replace(/\n/gm, '\u0000');
});
mes = mes.replace(/\n/g, '<br/>');
mes = mes.replace(/\u0000/g, '\n'); // Restore converted newlines
mes = mes.trim();
@ -2396,30 +2418,14 @@ export async function generateQuietPrompt(quiet_prompt, quietToLoud, skipWIAN, q
* @param {string} message Text to be sent
* @returns {Promise<boolean>} Whether the message sending was interrupted
*/
async function processCommands(message) {
export async function processCommands(message) {
if (!message || !message.trim().startsWith('/')) {
return false;
}
const previousText = String($('#send_textarea').val());
const result = await executeSlashCommands(message, true, null, true);
if (!result || typeof result !== 'object') {
return false;
}
const currentText = String($('#send_textarea').val());
if (previousText === currentText) {
$('#send_textarea').val(result.newText)[0].dispatchEvent(new Event('input', { bubbles:true }));
}
// interrupt generation if the input was nothing but a command
if (message.length > 0 && result?.newText.length === 0) {
return true;
}
return result?.interrupt;
await executeSlashCommandsOnChatInput(message, {
clearChatInput: true,
});
return true;
}
function sendSystemMessage(type, text, extra = {}) {
@ -3792,6 +3798,7 @@ async function Generate(type, { automatic_trigger, force_name2, quiet_prompt, qu
// Fetches the combined prompt for both negative and positive prompts
const cfgGuidanceScale = getGuidanceScale();
const useCfgPrompt = cfgGuidanceScale && cfgGuidanceScale.value !== 1;
// For prompt bit itemization
let mesSendString = '';
@ -3799,7 +3806,7 @@ async function Generate(type, { automatic_trigger, force_name2, quiet_prompt, qu
function getCombinedPrompt(isNegative) {
// Only return if the guidance scale doesn't exist or the value is 1
// Also don't return if constructing the neutral prompt
if (isNegative && (!cfgGuidanceScale || cfgGuidanceScale?.value === 1)) {
if (isNegative && !useCfgPrompt) {
return;
}
@ -3811,22 +3818,20 @@ async function Generate(type, { automatic_trigger, force_name2, quiet_prompt, qu
// Deep clone
let finalMesSend = structuredClone(mesSend);
let cfgPrompt = {};
if (cfgGuidanceScale && cfgGuidanceScale?.value !== 1) {
cfgPrompt = getCfgPrompt(cfgGuidanceScale, isNegative);
}
if (cfgPrompt && cfgPrompt?.value) {
if (cfgPrompt?.depth === 0) {
finalMesSend[finalMesSend.length - 1].message +=
/\s/.test(finalMesSend[finalMesSend.length - 1].message.slice(-1))
? cfgPrompt.value
: ` ${cfgPrompt.value}`;
} else {
// TODO: Make all extension prompts use an array/splice method
const lengthDiff = mesSend.length - cfgPrompt.depth;
const cfgDepth = lengthDiff >= 0 ? lengthDiff : 0;
finalMesSend[cfgDepth].extensionPrompts.push(`${cfgPrompt.value}\n`);
if (useCfgPrompt) {
const cfgPrompt = getCfgPrompt(cfgGuidanceScale, isNegative);
if (cfgPrompt.value) {
if (cfgPrompt.depth === 0) {
finalMesSend[finalMesSend.length - 1].message +=
/\s/.test(finalMesSend[finalMesSend.length - 1].message.slice(-1))
? cfgPrompt.value
: ` ${cfgPrompt.value}`;
} else {
// TODO: Make all extension prompts use an array/splice method
const lengthDiff = mesSend.length - cfgPrompt.depth;
const cfgDepth = lengthDiff >= 0 ? lengthDiff : 0;
finalMesSend[cfgDepth].extensionPrompts.push(`${cfgPrompt.value}\n`);
}
}
}
@ -3907,75 +3912,78 @@ async function Generate(type, { automatic_trigger, force_name2, quiet_prompt, qu
return !data.combinedPrompt ? combine() : data.combinedPrompt;
}
// Get the negative prompt first since it has the unmodified mesSend array
let negativePrompt = main_api == 'textgenerationwebui' ? getCombinedPrompt(true) : undefined;
let finalPrompt = getCombinedPrompt(false);
// Include the entire guidance scale object
const cfgValues = cfgGuidanceScale && cfgGuidanceScale?.value !== 1 ? ({ guidanceScale: cfgGuidanceScale, negativePrompt: negativePrompt }) : null;
let maxLength = Number(amount_gen); // how many tokens the AI will be requested to generate
let thisPromptBits = [];
// TODO: Make this a switch
if (main_api == 'koboldhorde' && horde_settings.auto_adjust_response_length) {
maxLength = Math.min(maxLength, adjustedParams.maxLength);
maxLength = Math.max(maxLength, MIN_LENGTH); // prevent validation errors
}
let generate_data;
if (main_api == 'koboldhorde' || main_api == 'kobold') {
generate_data = {
prompt: finalPrompt,
gui_settings: true,
max_length: maxLength,
max_context_length: max_context,
api_server,
};
switch (main_api) {
case 'koboldhorde':
case 'kobold':
if (main_api == 'koboldhorde' && horde_settings.auto_adjust_response_length) {
maxLength = Math.min(maxLength, adjustedParams.maxLength);
maxLength = Math.max(maxLength, MIN_LENGTH); // prevent validation errors
}
if (preset_settings != 'gui') {
const isHorde = main_api == 'koboldhorde';
const presetSettings = koboldai_settings[koboldai_setting_names[preset_settings]];
const maxContext = (adjustedParams && horde_settings.auto_adjust_context_length) ? adjustedParams.maxContextLength : max_context;
generate_data = getKoboldGenerationData(finalPrompt, presetSettings, maxLength, maxContext, isHorde, type);
generate_data = {
prompt: finalPrompt,
gui_settings: true,
max_length: maxLength,
max_context_length: max_context,
api_server,
};
if (preset_settings != 'gui') {
const isHorde = main_api == 'koboldhorde';
const presetSettings = koboldai_settings[koboldai_setting_names[preset_settings]];
const maxContext = (adjustedParams && horde_settings.auto_adjust_context_length) ? adjustedParams.maxContextLength : max_context;
generate_data = getKoboldGenerationData(finalPrompt, presetSettings, maxLength, maxContext, isHorde, type);
}
break;
case 'textgenerationwebui': {
const cfgValues = useCfgPrompt ? { guidanceScale: cfgGuidanceScale, negativePrompt: getCombinedPrompt(true) } : null;
generate_data = getTextGenGenerationData(finalPrompt, maxLength, isImpersonate, isContinue, cfgValues, type);
break;
}
}
else if (main_api == 'textgenerationwebui') {
generate_data = getTextGenGenerationData(finalPrompt, maxLength, isImpersonate, isContinue, cfgValues, type);
}
else if (main_api == 'novel') {
const presetSettings = novelai_settings[novelai_setting_names[nai_settings.preset_settings_novel]];
generate_data = getNovelGenerationData(finalPrompt, presetSettings, maxLength, isImpersonate, isContinue, cfgValues, type);
}
else if (main_api == 'openai') {
let [prompt, counts] = await prepareOpenAIMessages({
name2: name2,
charDescription: description,
charPersonality: personality,
Scenario: scenario,
worldInfoBefore: worldInfoBefore,
worldInfoAfter: worldInfoAfter,
extensionPrompts: extension_prompts,
bias: promptBias,
type: type,
quietPrompt: quiet_prompt,
quietImage: quietImage,
cyclePrompt: cyclePrompt,
systemPromptOverride: system,
jailbreakPromptOverride: jailbreak,
personaDescription: persona,
messages: oaiMessages,
messageExamples: oaiMessageExamples,
}, dryRun);
generate_data = { prompt: prompt };
// counts will return false if the user has not enabled the token breakdown feature
if (counts) {
parseTokenCounts(counts, thisPromptBits);
case 'novel': {
const cfgValues = useCfgPrompt ? { guidanceScale: cfgGuidanceScale } : null;
const presetSettings = novelai_settings[novelai_setting_names[nai_settings.preset_settings_novel]];
generate_data = getNovelGenerationData(finalPrompt, presetSettings, maxLength, isImpersonate, isContinue, cfgValues, type);
break;
}
case 'openai': {
let [prompt, counts] = await prepareOpenAIMessages({
name2: name2,
charDescription: description,
charPersonality: personality,
Scenario: scenario,
worldInfoBefore: worldInfoBefore,
worldInfoAfter: worldInfoAfter,
extensionPrompts: extension_prompts,
bias: promptBias,
type: type,
quietPrompt: quiet_prompt,
quietImage: quietImage,
cyclePrompt: cyclePrompt,
systemPromptOverride: system,
jailbreakPromptOverride: jailbreak,
personaDescription: persona,
messages: oaiMessages,
messageExamples: oaiMessageExamples,
}, dryRun);
generate_data = { prompt: prompt };
if (!dryRun) {
setInContextMessages(openai_messages_count, type);
// TODO: move these side-effects somewhere else, so this switch-case solely sets generate_data
// counts will return false if the user has not enabled the token breakdown feature
if (counts) {
parseTokenCounts(counts, thisPromptBits);
}
if (!dryRun) {
setInContextMessages(openai_messages_count, type);
}
break;
}
}
@ -4023,16 +4031,14 @@ async function Generate(type, { automatic_trigger, force_name2, quiet_prompt, qu
userPersona: (power_user.persona_description || ''),
};
thisPromptBits = additionalPromptStuff;
//console.log(thisPromptBits);
const itemizedIndex = itemizedPrompts.findIndex((item) => item.mesId === thisPromptBits['mesId']);
//console.log(additionalPromptStuff);
const itemizedIndex = itemizedPrompts.findIndex((item) => item.mesId === additionalPromptStuff.mesId);
if (itemizedIndex !== -1) {
itemizedPrompts[itemizedIndex] = thisPromptBits;
itemizedPrompts[itemizedIndex] = additionalPromptStuff;
}
else {
itemizedPrompts.push(thisPromptBits);
itemizedPrompts.push(additionalPromptStuff);
}
console.debug(`pushed prompt bits to itemizedPrompts array. Length is now: ${itemizedPrompts.length}`);
@ -4599,29 +4605,7 @@ async function DupeChar() {
}
}
async function promptItemize(itemizedPrompts, requestedMesId) {
console.log('PROMPT ITEMIZE ENTERED');
var incomingMesId = Number(requestedMesId);
console.debug(`looking for MesId ${incomingMesId}`);
var thisPromptSet = undefined;
for (var i = 0; i < itemizedPrompts.length; i++) {
console.log(`looking for ${incomingMesId} vs ${itemizedPrompts[i].mesId}`);
if (itemizedPrompts[i].mesId === incomingMesId) {
console.log(`found matching mesID ${i}`);
thisPromptSet = i;
PromptArrayItemForRawPromptDisplay = i;
console.log(`wanting to raw display of ArrayItem: ${PromptArrayItemForRawPromptDisplay} which is mesID ${incomingMesId}`);
console.log(itemizedPrompts[thisPromptSet]);
}
}
if (thisPromptSet === undefined) {
console.log(`couldnt find the right mesId. looked for ${incomingMesId}`);
console.log(itemizedPrompts);
return null;
}
async function itemizedParams(itemizedPrompts, thisPromptSet) {
const params = {
charDescriptionTokens: await getTokenCountAsync(itemizedPrompts[thisPromptSet].charDescription),
charPersonalityTokens: await getTokenCountAsync(itemizedPrompts[thisPromptSet].charPersonality),
@ -4720,6 +4704,38 @@ async function promptItemize(itemizedPrompts, requestedMesId) {
params.allAnchorsTokensPercentage = ((params.allAnchorsTokens / (params.totalTokensInPrompt)) * 100).toFixed(2);
params.selectedTokenizer = getFriendlyTokenizerName(params.this_main_api).tokenizerName;
}
return params;
}
function findItemizedPromptSet(itemizedPrompts, incomingMesId) {
var thisPromptSet = undefined;
for (var i = 0; i < itemizedPrompts.length; i++) {
console.log(`looking for ${incomingMesId} vs ${itemizedPrompts[i].mesId}`);
if (itemizedPrompts[i].mesId === incomingMesId) {
console.log(`found matching mesID ${i}`);
thisPromptSet = i;
PromptArrayItemForRawPromptDisplay = i;
console.log(`wanting to raw display of ArrayItem: ${PromptArrayItemForRawPromptDisplay} which is mesID ${incomingMesId}`);
console.log(itemizedPrompts[thisPromptSet]);
}
}
return thisPromptSet;
}
async function promptItemize(itemizedPrompts, requestedMesId) {
console.log('PROMPT ITEMIZE ENTERED');
var incomingMesId = Number(requestedMesId);
console.debug(`looking for MesId ${incomingMesId}`);
var thisPromptSet = findItemizedPromptSet(itemizedPrompts, incomingMesId);
if (thisPromptSet === undefined) {
console.log(`couldnt find the right mesId. looked for ${incomingMesId}`);
console.log(itemizedPrompts);
return null;
}
const params = await itemizedParams(itemizedPrompts, thisPromptSet);
if (params.this_main_api == 'openai') {
const template = await renderTemplateAsync('itemizationChat', params);
@ -6486,6 +6502,8 @@ async function messageEditDone(div) {
text = substituteParams(text);
}
await eventSource.emit(event_types.MESSAGE_EDITED, this_edit_mes_id);
text = chat[this_edit_mes_id]?.mes ?? text;
mesBlock.find('.mes_text').empty();
mesBlock.find('.mes_edit_buttons').css('display', 'none');
mesBlock.find('.mes_buttons').css('display', '');
@ -6502,7 +6520,6 @@ async function messageEditDone(div) {
mesBlock.find('.mes_bias').append(messageFormatting(bias, '', false, false, -1));
appendMediaToMessage(mes, div.closest('.mes'));
addCopyToCodeBlocks(div.closest('.mes'));
await eventSource.emit(event_types.MESSAGE_EDITED, this_edit_mes_id);
this_edit_mes_id = undefined;
await saveChatConditional();
@ -6712,7 +6729,7 @@ export async function displayPastChats() {
const debouncedDisplay = debounce((searchQuery) => {
displayChats(searchQuery);
}, 300);
});
// Define the search input listener
$('#select_chat_search').on('input', function () {
@ -6811,10 +6828,7 @@ function select_rm_info(type, charId, previousCharId = null) {
const scrollOffset = element.offset().top - element.parent().offset().top;
element.parent().scrollTop(scrollOffset);
element.addClass('flash animated');
setTimeout(function () {
element.removeClass('flash animated');
}, 5000);
flashHighlight(element, 5000);
});
} catch (e) {
console.error(e);
@ -6840,10 +6854,7 @@ function select_rm_info(type, charId, previousCharId = null) {
const element = $(selector);
const scrollOffset = element.offset().top - element.parent().offset().top;
element.parent().scrollTop(scrollOffset);
$(element).addClass('flash animated');
setTimeout(function () {
$(element).removeClass('flash animated');
}, 5000);
flashHighlight(element, 5000);
});
} catch (e) {
console.error(e);
@ -7112,57 +7123,49 @@ function onScenarioOverrideRemoveClick() {
* @returns
*/
function callPopup(text, type, inputValue = '', { okButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling, cropAspect } = {}) {
function getOkButtonText() {
if (['avatarToCrop'].includes(popup_type)) {
return okButton ?? 'Accept';
} else if (['text', 'alternate_greeting', 'char_not_selected'].includes(popup_type)) {
$dialoguePopupCancel.css('display', 'none');
return okButton ?? 'Ok';
} else if (['delete_extension'].includes(popup_type)) {
return okButton ?? 'Ok';
} else if (['new_chat', 'confirm'].includes(popup_type)) {
return okButton ?? 'Yes';
} else if (['input'].includes(popup_type)) {
return okButton ?? 'Save';
}
return okButton ?? 'Delete';
}
dialogueCloseStop = true;
if (type) {
popup_type = type;
}
$('#dialogue_popup').toggleClass('wide_dialogue_popup', !!wide);
$('#dialogue_popup').toggleClass('large_dialogue_popup', !!large);
$('#dialogue_popup').toggleClass('horizontal_scrolling_dialogue_popup', !!allowHorizontalScrolling);
$('#dialogue_popup').toggleClass('vertical_scrolling_dialogue_popup', !!allowVerticalScrolling);
const $dialoguePopup = $('#dialogue_popup');
const $dialoguePopupCancel = $('#dialogue_popup_cancel');
const $dialoguePopupOk = $('#dialogue_popup_ok');
const $dialoguePopupInput = $('#dialogue_popup_input');
const $dialoguePopupText = $('#dialogue_popup_text');
const $shadowPopup = $('#shadow_popup');
$('#dialogue_popup_cancel').css('display', 'inline-block');
switch (popup_type) {
case 'avatarToCrop':
$('#dialogue_popup_ok').text(okButton ?? 'Accept');
break;
case 'text':
case 'alternate_greeting':
case 'char_not_selected':
$('#dialogue_popup_ok').text(okButton ?? 'Ok');
$('#dialogue_popup_cancel').css('display', 'none');
break;
case 'delete_extension':
$('#dialogue_popup_ok').text(okButton ?? 'Ok');
break;
case 'new_chat':
case 'confirm':
$('#dialogue_popup_ok').text(okButton ?? 'Yes');
break;
case 'del_group':
case 'rename_chat':
case 'del_chat':
default:
$('#dialogue_popup_ok').text(okButton ?? 'Delete');
}
$dialoguePopup.toggleClass('wide_dialogue_popup', !!wide)
.toggleClass('large_dialogue_popup', !!large)
.toggleClass('horizontal_scrolling_dialogue_popup', !!allowHorizontalScrolling)
.toggleClass('vertical_scrolling_dialogue_popup', !!allowVerticalScrolling);
$('#dialogue_popup_input').val(inputValue);
$('#dialogue_popup_input').attr('rows', rows ?? 1);
$dialoguePopupCancel.css('display', 'inline-block');
$dialoguePopupOk.text(getOkButtonText());
$dialoguePopupInput.toggle(popup_type === 'input').val(inputValue).attr('rows', rows ?? 1);
$dialoguePopupText.empty().append(text);
$shadowPopup.css('display', 'block');
if (popup_type == 'input') {
$('#dialogue_popup_input').css('display', 'block');
$('#dialogue_popup_ok').text(okButton ?? 'Save');
}
else {
$('#dialogue_popup_input').css('display', 'none');
$dialoguePopupInput.trigger('focus');
}
$('#dialogue_popup_text').empty().append(text);
$('#shadow_popup').css('display', 'block');
if (popup_type == 'input') {
$('#dialogue_popup_input').focus();
}
if (popup_type == 'avatarToCrop') {
// unset existing data
crop_data = undefined;
@ -7178,7 +7181,8 @@ function callPopup(text, type, inputValue = '', { okButton, rows, wide, large, a
},
});
}
$('#shadow_popup').transition({
$shadowPopup.transition({
opacity: 1,
duration: animation_duration,
easing: animation_easing,
@ -8797,14 +8801,20 @@ jQuery(async function () {
$(document).on('click', '.swipe_left', swipe_left);
const debouncedCharacterSearch = debounce((searchQuery) => {
entitiesFilter.setFilterData(FILTER_TYPES.SEARCH, searchQuery);
});
$('#character_search_bar').on('input', function () {
const searchValue = String($(this).val()).toLowerCase();
entitiesFilter.setFilterData(FILTER_TYPES.SEARCH, searchValue);
const searchQuery = String($(this).val()).toLowerCase();
debouncedCharacterSearch(searchQuery);
});
const debouncedPersonaSearch = debounce((searchQuery) => {
personasFilter.setFilterData(FILTER_TYPES.PERSONA_SEARCH, searchQuery);
});
$('#persona_search_bar').on('input', function () {
const searchValue = String($(this).val()).toLowerCase();
personasFilter.setFilterData(FILTER_TYPES.PERSONA_SEARCH, searchValue);
const searchQuery = String($(this).val()).toLowerCase();
debouncedPersonaSearch(searchQuery);
});
$('#mes_continue').on('click', function () {
@ -10177,11 +10187,22 @@ jQuery(async function () {
streamingProcessor = null;
}
if (abortController) {
abortController.abort();
abortController.abort('Clicked stop button');
hideStopButton();
}
eventSource.emit(event_types.GENERATION_STOPPED);
activateSendButtons();
});
$(document).on('click', '#form_sheld .stscript_continue', function () {
pauseScriptExecution();
});
$(document).on('click', '#form_sheld .stscript_pause', function () {
pauseScriptExecution();
});
$(document).on('click', '#form_sheld .stscript_stop', function () {
stopScriptExecution();
});
$('.drawer-toggle').on('click', function () {
@ -10646,6 +10667,7 @@ jQuery(async function () {
<li>Chub Lorebook (Direct Link or ID)<br>Example: <tt>lorebooks/bartleby/example-lorebook</tt></li>
<li>JanitorAI Character (Direct Link or UUID)<br>Example: <tt>ddd1498a-a370-4136-b138-a8cd9461fdfe_character-aqua-the-useless-goddess</tt></li>
<li>Pygmalion.chat Character (Direct Link or UUID)<br>Example: <tt>a7ca95a1-0c88-4e23-91b3-149db1e78ab9</tt></li>
<li>AICharacterCard.com Character (Direct Link or ID)<br>Example: <tt>AICC/aicharcards/the-game-master</tt></li>
<li>More coming soon...</li>
<ul>`;
const input = await callPopup(html, 'input', '', { okButton: 'Import', rows: 4 });

View File

@ -5,6 +5,7 @@ import { is_group_generating } from './group-chats.js';
import { Message, TokenHandler } from './openai.js';
import { power_user } from './power-user.js';
import { debounce, waitUntilCondition, escapeHtml } from './utils.js';
import { debounce_timeout } from './constants.js';
function debouncePromise(func, delay) {
let timeoutId;
@ -294,7 +295,7 @@ class PromptManager {
this.handleCharacterReset = () => { };
/** Debounced version of render */
this.renderDebounced = debounce(this.render.bind(this), 1000);
this.renderDebounced = debounce(this.render.bind(this), debounce_timeout.relaxed);
}
@ -776,7 +777,7 @@ class PromptManager {
const promptOrder = this.getPromptOrderForCharacter(character);
const index = promptOrder.findIndex(entry => entry.identifier === prompt.identifier);
if (-1 === index) promptOrder.push({ identifier: prompt.identifier, enabled: false });
if (-1 === index) promptOrder.unshift({ identifier: prompt.identifier, enabled: false });
}
/**
@ -1286,7 +1287,7 @@ class PromptManager {
} else if (!entry.enabled && entry.identifier === 'main') {
// Some extensions require main prompt to be present for relative inserts.
// So we make a GMO-free vegan replacement.
const prompt = this.getPromptById(entry.identifier);
const prompt = structuredClone(this.getPromptById(entry.identifier));
prompt.content = '';
if (prompt) promptCollection.add(this.preparePrompt(prompt));
}

View File

@ -36,6 +36,7 @@ import { debounce, getStringHash, isValidUrl } from './utils.js';
import { chat_completion_sources, oai_settings } from './openai.js';
import { getTokenCountAsync } from './tokenizers.js';
import { textgen_types, textgenerationwebui_settings as textgen_settings, getTextGenServer } from './textgen-settings.js';
import { debounce_timeout } from './constants.js';
import Bowser from '../lib/bowser.min.js';
@ -54,7 +55,7 @@ var retry_delay = 500;
let counterNonce = Date.now();
const observerConfig = { childList: true, subtree: true };
const countTokensDebounced = debounce(RA_CountCharTokens, 1000);
const countTokensDebounced = debounce(RA_CountCharTokens, debounce_timeout.relaxed);
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
@ -700,7 +701,7 @@ const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
*/
function autoFitSendTextArea() {
const originalScrollBottom = chatBlock.scrollHeight - (chatBlock.scrollTop + chatBlock.offsetHeight);
if (sendTextArea.scrollHeight == sendTextArea.offsetHeight) {
if (sendTextArea.scrollHeight + 2 == sendTextArea.offsetHeight) {
// Needs to be pulled dynamically because it is affected by font size changes
const sendTextAreaMinHeight = window.getComputedStyle(sendTextArea).getPropertyValue('min-height');
sendTextArea.style.height = sendTextAreaMinHeight;

View File

@ -12,6 +12,7 @@ import { extension_settings, getContext, saveMetadataDebounced } from './extensi
import { registerSlashCommand } from './slash-commands.js';
import { getCharaFilename, debounce, delay } from './utils.js';
import { getTokenCountAsync } from './tokenizers.js';
import { debounce_timeout } from './constants.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js';
@ -87,9 +88,9 @@ function updateSettings() {
setFloatingPrompt();
}
const setMainPromptTokenCounterDebounced = debounce(async (value) => $('#extension_floating_prompt_token_counter').text(await getTokenCountAsync(value)), 1000);
const setCharaPromptTokenCounterDebounced = debounce(async (value) => $('#extension_floating_chara_token_counter').text(await getTokenCountAsync(value)), 1000);
const setDefaultPromptTokenCounterDebounced = debounce(async (value) => $('#extension_floating_default_token_counter').text(await getTokenCountAsync(value)), 1000);
const setMainPromptTokenCounterDebounced = debounce(async (value) => $('#extension_floating_prompt_token_counter').text(await getTokenCountAsync(value)), debounce_timeout.relaxed);
const setCharaPromptTokenCounterDebounced = debounce(async (value) => $('#extension_floating_chara_token_counter').text(await getTokenCountAsync(value)), debounce_timeout.relaxed);
const setDefaultPromptTokenCounterDebounced = debounce(async (value) => $('#extension_floating_default_token_counter').text(await getTokenCountAsync(value)), debounce_timeout.relaxed);
async function onExtensionFloatingPromptInput() {
chat_metadata[metadata_keys.prompt] = $(this).val();

View File

@ -3,7 +3,7 @@ import { saveMetadataDebounced } from './extensions.js';
import { registerSlashCommand } from './slash-commands.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { stringFormat } from './utils.js';
import { flashHighlight, stringFormat } from './utils.js';
const BG_METADATA_KEY = 'custom_background';
const LIST_METADATA_KEY = 'chat_backgrounds';
@ -455,8 +455,7 @@ function highlightNewBackground(bg) {
const newBg = $(`.bg_example[bgfile="${bg}"]`);
const scrollOffset = newBg.offset().top - newBg.parent().offset().top;
$('#Backgrounds').scrollTop(scrollOffset);
newBg.addClass('flash animated');
setTimeout(() => newBg.removeClass('flash animated'), 2000);
flashHighlight(newBg);
}
function onBackgroundFilterInput() {

View File

@ -685,6 +685,30 @@ async function downloadAttachment(attachment) {
URL.revokeObjectURL(url);
}
/**
* Removes an attachment from the disabled list.
* @param {FileAttachment} attachment Attachment to enable
* @param {function} callback Success callback
*/
function enableAttachment(attachment, callback) {
ensureAttachmentsExist();
extension_settings.disabled_attachments = extension_settings.disabled_attachments.filter(url => url !== attachment.url);
saveSettingsDebounced();
callback();
}
/**
* Adds an attachment to the disabled list.
* @param {FileAttachment} attachment Attachment to disable
* @param {function} callback Success callback
*/
function disableAttachment(attachment, callback) {
ensureAttachmentsExist();
extension_settings.disabled_attachments.push(attachment.url);
saveSettingsDebounced();
callback();
}
/**
* Moves a file attachment to a different source.
* @param {FileAttachment} attachment Attachment to moves
@ -752,11 +776,25 @@ async function deleteAttachment(attachment, source, callback, confirm = true) {
break;
}
if (Array.isArray(extension_settings.disabled_attachments) && extension_settings.disabled_attachments.includes(attachment.url)) {
extension_settings.disabled_attachments = extension_settings.disabled_attachments.filter(url => url !== attachment.url);
saveSettingsDebounced();
}
const silent = confirm === false;
await deleteFileFromServer(attachment.url, silent);
callback();
}
/**
* Determines if the attachment is disabled.
* @param {FileAttachment} attachment Attachment to check
* @returns {boolean} True if attachment is disabled, false otherwise.
*/
function isAttachmentDisabled(attachment) {
return extension_settings.disabled_attachments.some(url => url === attachment?.url);
}
/**
* Opens the attachment manager.
*/
@ -806,7 +844,9 @@ async function openAttachmentManager() {
const sortedAttachmentList = attachments.slice().filter(filterFn).sort(sortFn);
for (const attachment of sortedAttachmentList) {
const isDisabled = isAttachmentDisabled(attachment);
const attachmentTemplate = template.find('.attachmentListItemTemplate .attachmentListItem').clone();
attachmentTemplate.toggleClass('disabled', isDisabled);
attachmentTemplate.find('.attachmentFileIcon').attr('title', attachment.url);
attachmentTemplate.find('.attachmentListItemName').text(attachment.name);
attachmentTemplate.find('.attachmentListItemSize').text(humanFileSize(attachment.size));
@ -816,6 +856,8 @@ async function openAttachmentManager() {
attachmentTemplate.find('.deleteAttachmentButton').on('click', () => deleteAttachment(attachment, source, renderAttachments));
attachmentTemplate.find('.downloadAttachmentButton').on('click', () => downloadAttachment(attachment));
attachmentTemplate.find('.moveAttachmentButton').on('click', () => moveAttachment(attachment, source, renderAttachments));
attachmentTemplate.find('.enableAttachmentButton').toggle(isDisabled).on('click', () => enableAttachment(attachment, renderAttachments));
attachmentTemplate.find('.disableAttachmentButton').toggle(!isDisabled).on('click', () => disableAttachment(attachment, renderAttachments));
template.find(sources[source]).append(attachmentTemplate);
}
}
@ -840,7 +882,13 @@ async function openAttachmentManager() {
}
const buttonTemplate = template.find('.actionButtonTemplate .actionButton').clone();
buttonTemplate.find('.actionButtonIcon').addClass(scraper.iconClass);
if (scraper.iconAvailable) {
buttonTemplate.find('.actionButtonIcon').addClass(scraper.iconClass);
buttonTemplate.find('.actionButtonImg').remove();
} else {
buttonTemplate.find('.actionButtonImg').attr('src', scraper.iconClass);
buttonTemplate.find('.actionButtonIcon').remove();
}
buttonTemplate.find('.actionButtonText').text(scraper.name);
buttonTemplate.attr('title', scraper.description);
buttonTemplate.on('click', () => {
@ -1111,6 +1159,10 @@ export async function uploadFileAttachmentToServer(file, target) {
}
function ensureAttachmentsExist() {
if (!Array.isArray(extension_settings.disabled_attachments)) {
extension_settings.disabled_attachments = [];
}
if (!Array.isArray(extension_settings.attachments)) {
extension_settings.attachments = [];
}
@ -1131,7 +1183,7 @@ function ensureAttachmentsExist() {
}
/**
* Gets all currently available attachments.
* Gets all currently available attachments. Ignores disabled attachments.
* @returns {FileAttachment[]} List of attachments
*/
export function getDataBankAttachments() {
@ -1140,11 +1192,11 @@ export function getDataBankAttachments() {
const chatAttachments = chat_metadata.attachments ?? [];
const characterAttachments = extension_settings.character_attachments?.[characters[this_chid]?.avatar] ?? [];
return [...globalAttachments, ...chatAttachments, ...characterAttachments];
return [...globalAttachments, ...chatAttachments, ...characterAttachments].filter(x => !isAttachmentDisabled(x));
}
/**
* Gets all attachments for a specific source.
* Gets all attachments for a specific source. Includes disabled attachments.
* @param {string} source Attachment source
* @returns {FileAttachment[]} List of attachments
*/
@ -1159,6 +1211,8 @@ export function getDataBankAttachmentsForSource(source) {
case ATTACHMENT_SOURCE.CHARACTER:
return extension_settings.character_attachments?.[characters[this_chid]?.avatar] ?? [];
}
return [];
}
/**
@ -1280,6 +1334,7 @@ jQuery(function () {
const textarea = document.createElement('textarea');
textarea.value = String(bro.val());
textarea.classList.add('height100p', 'wide100p');
bro.hasClass('monospace') && textarea.classList.add('monospace');
textarea.addEventListener('input', function () {
bro.val(textarea.value).trigger('input');
});

View File

@ -0,0 +1,14 @@
/**
* Common debounce timeout values to use with `debounce` calls.
* @enum {number}
*/
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,
/** [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. */
relaxed: 1000,
/** [5 sec] For delayed tasks, like auto-saving or completing batch operations that need a significant pause. */
extended: 5000,
};

View File

@ -145,8 +145,18 @@ const extension_settings = {
variables: {
global: {},
},
/**
* @type {import('./chats.js').FileAttachment[]}
*/
attachments: [],
/**
* @type {Record<string, import('./chats.js').FileAttachment[]>}
*/
character_attachments: {},
/**
* @type {string[]}
*/
disabled_attachments: [],
};
let modules = [];

View File

@ -106,17 +106,20 @@
<div class="attachmentListItemName flex1"></div>
<small class="attachmentListItemCreated"></small>
<small class="attachmentListItemSize"></small>
<div class="viewAttachmentButton right_menu_button fa-solid fa-magnifying-glass" title="View attachment content"></div>
<div class="moveAttachmentButton right_menu_button fa-solid fa-arrows-alt" title="Move attachment"></div>
<div class="editAttachmentButton right_menu_button fa-solid fa-pencil" title="Edit attachment"></div>
<div class="downloadAttachmentButton right_menu_button fa-solid fa-download" title="Download attachment"></div>
<div class="deleteAttachmentButton right_menu_button fa-solid fa-trash" title="Delete attachment"></div>
<div class="viewAttachmentButton right_menu_button fa-fw fa-solid fa-magnifying-glass" title="View attachment content"></div>
<div class="disableAttachmentButton right_menu_button fa-fw fa-solid fa-comment" title="Disable attachment"></div>
<div class="enableAttachmentButton right_menu_button fa-fw fa-solid fa-comment-slash" title="Enable attachment"></div>
<div class="moveAttachmentButton right_menu_button fa-fw fa-solid fa-arrows-alt" title="Move attachment"></div>
<div class="editAttachmentButton right_menu_button fa-fw fa-solid fa-pencil" title="Edit attachment"></div>
<div class="downloadAttachmentButton right_menu_button fa-fw fa-solid fa-download" title="Download attachment"></div>
<div class="deleteAttachmentButton right_menu_button fa-fw fa-solid fa-trash" title="Delete attachment"></div>
</div>
</div>
<div class="actionButtonTemplate">
<div class="actionButton list-group-item flex-container flexGap5" title="">
<div class="actionButton list-group-item flex-container flexGap5" style="align-items: center;" title="">
<i class="actionButtonIcon"></i>
<img class="actionButtonImg"/>
<span class="actionButtonText"></span>
</div>
</div>

View File

@ -19,6 +19,16 @@
padding: 10px;
}
.attachmentListItem.disabled .attachmentListItemName {
text-decoration: line-through;
opacity: 0.75;
}
.attachmentListItem.disabled .attachmentFileIcon {
opacity: 0.75;
cursor: not-allowed;
}
.attachmentListItemSize {
min-width: 4em;
text-align: right;

View File

@ -6,6 +6,7 @@ import { registerSlashCommand } from '../../slash-commands.js';
import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence } from '../../utils.js';
import { hideMutedSprites } from '../../group-chats.js';
import { isJsonSchemaSupported } from '../../textgen-settings.js';
import { debounce_timeout } from '../../constants.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument } from '../../slash-commands/SlashCommandArgument.js';
@ -97,7 +98,7 @@ async function forceUpdateVisualNovelMode() {
}
}
const updateVisualNovelModeDebounced = debounce(forceUpdateVisualNovelMode, 100);
const updateVisualNovelModeDebounced = debounce(forceUpdateVisualNovelMode, debounce_timeout.quick);
async function updateVisualNovelMode(name, expression) {
const container = $('#visual-novel-wrapper');
@ -908,8 +909,10 @@ async function setSpriteSetCommand(_, folder) {
$('#expression_override').val(folder.trim());
onClickExpressionOverrideButton();
removeExpression();
moduleWorker();
// removeExpression();
// moduleWorker();
const vnMode = isVisualNovelMode();
await sendExpressionCall(folder, lastExpression, true, vnMode);
}
async function classifyCommand(_, text) {

View File

@ -20,6 +20,7 @@ import { registerSlashCommand } from '../../slash-commands.js';
import { loadMovingUIState } from '../../power-user.js';
import { dragElement } from '../../RossAscends-mods.js';
import { getTextTokens, getTokenCountAsync, tokenizers } from '../../tokenizers.js';
import { debounce_timeout } from '../../constants.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
export { MODULE_NAME };
@ -48,7 +49,7 @@ const formatMemoryValue = function (value) {
}
};
const saveChatDebounced = debounce(() => getContext().saveChat(), 2000);
const saveChatDebounced = debounce(() => getContext().saveChat(), debounce_timeout.relaxed);
const summary_sources = {
'extras': 'extras',

View File

@ -94,14 +94,27 @@
<h3>Testing</h3>
<div id="qr--modal-execute" class="menu_button" title="Execute the quick reply now">
<i class="fa-solid fa-play"></i>
Execute
<div id="qr--modal-executeButtons">
<div id="qr--modal-execute" class="qr--modal-executeButton menu_button" title="Execute the quick reply now">
<i class="fa-solid fa-play"></i>
Execute
</div>
<div id="qr--modal-pause" class="qr--modal-executeButton menu_button" title="Pause / continue execution">
<span class="qr--modal-executeComboIcon">
<i class="fa-solid fa-play"></i>
<i class="fa-solid fa-pause"></i>
</span>
</div>
<div id="qr--modal-stop" class="qr--modal-executeButton menu_button" title="Abort execution">
<i class="fa-solid fa-stop"></i>
</div>
</div>
<div id="qr--modal-executeProgress"></div>
<label class="checkbox_label">
<input type="checkbox" id="qr--modal-executeHide">
<span> Hide editor while executing</span>
</label>
<div id="qr--modal-executeErrors"></div>
<div id="qr--modal-executeResult"></div>
</div>
</div>

View File

@ -190,7 +190,7 @@ const init = async () => {
}
}
if (qr && qr.onExecute) {
return await qr.execute(args);
return await qr.execute(args, false, true);
} else {
throw new Error(`No Quick Reply found for "${name}".`);
}

View File

@ -1,5 +1,7 @@
import { POPUP_TYPE, Popup } from '../../../popup.js';
import { setSlashCommandAutoComplete } from '../../../slash-commands.js';
import { SlashCommandAbortController } from '../../../slash-commands/SlashCommandAbortController.js';
import { SlashCommandParserError } from '../../../slash-commands/SlashCommandParserError.js';
import { SlashCommandScope } from '../../../slash-commands/SlashCommandScope.js';
import { getSortableDelay } from '../../../utils.js';
import { log, warn } from '../index.js';
@ -49,9 +51,14 @@ export class QuickReply {
/**@type {Popup}*/ editorPopup;
/**@type {HTMLElement}*/ editorExecuteBtn;
/**@type {HTMLElement}*/ editorExecuteBtnPause;
/**@type {HTMLElement}*/ editorExecuteBtnStop;
/**@type {HTMLElement}*/ editorExecuteProgress;
/**@type {HTMLElement}*/ editorExecuteErrors;
/**@type {HTMLElement}*/ editorExecuteResult;
/**@type {HTMLInputElement}*/ editorExecuteHide;
/**@type {Promise}*/ editorExecutePromise;
/**@type {SlashCommandAbortController}*/ abortController;
get hasContext() {
@ -425,9 +432,15 @@ export class QuickReply {
this.updateContext();
});
/**@type {HTMLElement}*/
const executeProgress = dom.querySelector('#qr--modal-executeProgress');
this.editorExecuteProgress = executeProgress;
/**@type {HTMLElement}*/
const executeErrors = dom.querySelector('#qr--modal-executeErrors');
this.editorExecuteErrors = executeErrors;
/**@type {HTMLElement}*/
const executeResult = dom.querySelector('#qr--modal-executeResult');
this.editorExecuteResult = executeResult;
/**@type {HTMLInputElement}*/
const executeHide = dom.querySelector('#qr--modal-executeHide');
this.editorExecuteHide = executeHide;
@ -437,6 +450,26 @@ export class QuickReply {
executeBtn.addEventListener('click', async()=>{
await this.executeFromEditor();
});
/**@type {HTMLElement}*/
const executeBtnPause = dom.querySelector('#qr--modal-pause');
this.editorExecuteBtnPause = executeBtnPause;
executeBtnPause.addEventListener('click', async()=>{
if (this.abortController) {
if (this.abortController.signal.paused) {
this.abortController.continue('Continue button clicked');
this.editorExecuteProgress.classList.remove('qr--paused');
} else {
this.abortController.pause('Pause button clicked');
this.editorExecuteProgress.classList.add('qr--paused');
}
}
});
/**@type {HTMLElement}*/
const executeBtnStop = dom.querySelector('#qr--modal-stop');
this.editorExecuteBtnStop = executeBtnStop;
executeBtnStop.addEventListener('click', async()=>{
this.abortController?.abort('Stop button clicked');
});
await popupResult;
} else {
@ -447,21 +480,54 @@ export class QuickReply {
async executeFromEditor() {
if (this.editorExecutePromise) return;
this.editorExecuteBtn.classList.add('qr--busy');
this.editorExecuteProgress.style.setProperty('--prog', '0');
this.editorExecuteErrors.classList.remove('qr--hasErrors');
this.editorExecuteResult.classList.remove('qr--hasResult');
this.editorExecuteProgress.classList.remove('qr--error');
this.editorExecuteProgress.classList.remove('qr--success');
this.editorExecuteProgress.classList.remove('qr--paused');
this.editorExecuteProgress.classList.remove('qr--aborted');
this.editorExecuteErrors.innerHTML = '';
this.editorExecuteResult.innerHTML = '';
if (this.editorExecuteHide.checked) {
this.editorPopup.dom.classList.add('qr--hide');
}
try {
this.editorExecutePromise = this.execute();
await this.editorExecutePromise;
this.editorExecutePromise = this.execute({}, true);
const result = await this.editorExecutePromise;
if (this.abortController?.signal?.aborted) {
this.editorExecuteProgress.classList.add('qr--aborted');
} else {
this.editorExecuteResult.textContent = result?.toString();
this.editorExecuteResult.classList.add('qr--hasResult');
this.editorExecuteProgress.classList.add('qr--success');
}
this.editorExecuteProgress.classList.remove('qr--paused');
} catch (ex) {
this.editorExecuteErrors.textContent = ex.message;
this.editorExecuteErrors.classList.add('qr--hasErrors');
this.editorExecuteProgress.classList.add('qr--error');
this.editorExecuteProgress.classList.remove('qr--paused');
if (ex instanceof SlashCommandParserError) {
this.editorExecuteErrors.innerHTML = `
<div>${ex.message}</div>
<div>Line: ${ex.line} Column: ${ex.column}</div>
<pre style="text-align:left;">${ex.hint}</pre>
`;
} else {
this.editorExecuteErrors.innerHTML = `
<div>${ex.message}</div>
`;
}
}
this.editorExecutePromise = null;
this.editorExecuteBtn.classList.remove('qr--busy');
this.editorPopup.dom.classList.remove('qr--hide');
}
updateEditorProgress(done, total) {
this.editorExecuteProgress.style.setProperty('--prog', `${done / total * 100}`);
}
@ -537,13 +603,22 @@ export class QuickReply {
}
async execute(args = {}) {
async execute(args = {}, isEditor = false, isRun = false) {
if (this.message?.length > 0 && this.onExecute) {
const scope = new SlashCommandScope();
for (const key of Object.keys(args)) {
scope.setMacro(`arg::${key}`, args[key]);
}
return await this.onExecute(this, this.message, args.isAutoExecute ?? false, scope);
if (isEditor) {
this.abortController = new SlashCommandAbortController();
}
return await this.onExecute(this, {
message:this.message,
isAutoExecute: args.isAutoExecute ?? false,
isEditor,
isRun,
scope,
});
}
}

View File

@ -1,5 +1,5 @@
import { getRequestHeaders, substituteParams } from '../../../../script.js';
import { executeSlashCommands } from '../../../slash-commands.js';
import { executeSlashCommands, executeSlashCommandsOnChatInput, executeSlashCommandsWithOptions } from '../../../slash-commands.js';
import { SlashCommandScope } from '../../../slash-commands/SlashCommandScope.js';
import { debounceAsync, warn } from '../index.js';
import { QuickReply } from './QuickReply.js';
@ -101,16 +101,29 @@ export class QuickReplySet {
/**
* @param {QuickReply} qr
* @param {String} [message] - optional altered message to be used
* @param {SlashCommandScope} [scope] - optional scope to be used when running the command
*
* @param {QuickReply} qr The QR to execute.
* @param {object} options
* @param {string} [options.message] (null) altered message to be used
* @param {boolean} [options.isAutoExecute] (false) whether the execution is triggered by auto execute
* @param {boolean} [options.isEditor] (false) whether the execution is triggered by the QR editor
* @param {boolean} [options.isRun] (false) whether the execution is triggered by /run or /: (window.executeQuickReplyByName)
* @param {SlashCommandScope} [options.scope] (null) scope to be used when running the command
* @returns
*/
async execute(qr, message = null, isAutoExecute = false, scope = null) {
async executeWithOptions(qr, options = {}) {
options = Object.assign({
message:null,
isAutoExecute:false,
isEditor:false,
isRun:false,
scope:null,
}, options);
/**@type {HTMLTextAreaElement}*/
const ta = document.querySelector('#send_textarea');
const finalMessage = message ?? qr.message;
const finalMessage = options.message ?? qr.message;
let input = ta.value;
if (!isAutoExecute && this.injectInput && input.length > 0) {
if (!options.isAutoExecute && !options.isEditor && !options.isRun && this.injectInput && input.length > 0) {
if (this.placeBeforeInput) {
input = `${finalMessage} ${input}`;
} else {
@ -121,7 +134,24 @@ export class QuickReplySet {
}
if (input[0] == '/' && !this.disableSend) {
const result = await executeSlashCommands(input, true, scope);
let result;
if (options.isAutoExecute || options.isRun) {
result = await executeSlashCommandsWithOptions(input, {
handleParserErrors: true,
scope: options.scope,
});
} else if (options.isEditor) {
result = await executeSlashCommandsWithOptions(input, {
handleParserErrors: false,
scope: options.scope,
abortController: qr.abortController,
onProgress: (done, total) => qr.updateEditorProgress(done, total),
});
} else {
result = await executeSlashCommandsOnChatInput(input, {
scope: options.scope,
});
}
return typeof result === 'object' ? result?.pipe : '';
}
@ -133,6 +163,18 @@ export class QuickReplySet {
document.querySelector('#send_but').click();
}
}
/**
* @param {QuickReply} qr
* @param {String} [message] - optional altered message to be used
* @param {SlashCommandScope} [scope] - optional scope to be used when running the command
*/
async execute(qr, message = null, isAutoExecute = false, scope = null) {
return this.executeWithOptions(qr, {
message,
isAutoExecute,
scope,
});
}
@ -154,7 +196,7 @@ export class QuickReplySet {
}
hookQuickReply(qr) {
qr.onExecute = (_, message, isAutoExecute)=>this.execute(qr, message, isAutoExecute);
qr.onExecute = (_, options)=>this.executeWithOptions(qr, options);
qr.onDelete = ()=>this.removeQuickReply(qr);
qr.onUpdate = ()=>this.save();
}

View File

@ -286,14 +286,127 @@
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-message {
flex: 1 1 auto;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-execute {
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons {
display: flex;
gap: 1em;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons .qr--modal-executeButton {
border-width: 2px;
border-style: solid;
display: flex;
flex-direction: row;
gap: 0.5em;
padding: 0.5em 0.75em;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-execute.qr--busy {
opacity: 0.5;
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons .qr--modal-executeButton .qr--modal-executeComboIcon {
display: flex;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons #qr--modal-execute {
transition: 200ms;
filter: grayscale(0);
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons #qr--modal-execute.qr--busy {
cursor: wait;
opacity: 0.5;
filter: grayscale(1);
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons #qr--modal-execute {
border-color: #51a351;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons #qr--modal-pause,
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons #qr--modal-stop {
cursor: default;
opacity: 0.5;
filter: grayscale(1);
pointer-events: none;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons .qr--busy ~ #qr--modal-pause,
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons .qr--busy ~ #qr--modal-stop {
cursor: pointer;
opacity: 1;
filter: grayscale(0);
pointer-events: all;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons #qr--modal-pause {
border-color: #92befc;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons #qr--modal-stop {
border-color: #d78872;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeProgress {
--prog: 0;
--progColor: #92befc;
--progFlashColor: #d78872;
--progSuccessColor: #51a351;
--progErrorColor: #bd362f;
--progAbortedColor: #d78872;
height: 0.5em;
background-color: var(--black50a);
position: relative;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeProgress:after {
content: '';
background-color: var(--progColor);
position: absolute;
inset: 0;
right: calc(100% - var(--prog) * 1%);
transition: 200ms;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeProgress.qr--paused:after {
animation-name: qr--progressPulse;
animation-duration: 1500ms;
animation-timing-function: ease-in-out;
animation-delay: 0s;
animation-iteration-count: infinite;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeProgress.qr--aborted:after {
background-color: var(--progAbortedColor);
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeProgress.qr--success:after {
background-color: var(--progSuccessColor);
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeProgress.qr--error:after {
background-color: var(--progErrorColor);
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeErrors {
display: none;
text-align: left;
font-size: smaller;
background-color: #bd362f;
color: white;
padding: 0.5em;
overflow: auto;
min-width: 100%;
width: 0;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeErrors.qr--hasErrors {
display: block;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeResult {
display: none;
text-align: left;
font-size: smaller;
background-color: #51a351;
color: white;
padding: 0.5em;
overflow: auto;
min-width: 100%;
width: 0;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeResult.qr--hasResult {
display: block;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeResult:before {
content: 'Result: ';
}
@keyframes qr--progressPulse {
0%,
100% {
background-color: var(--progColor);
}
50% {
background-color: var(--progFlashColor);
}
}
.shadow_popup.qr--hide {
opacity: 0 !important;

View File

@ -313,18 +313,128 @@
}
}
#qr--modal-execute {
#qr--modal-executeButtons {
display: flex;
flex-direction: row;
gap: 0.5em;
&.qr--busy {
opacity: 0.5;
cursor: wait;
gap: 1em;
.qr--modal-executeButton {
border-width: 2px;
border-style: solid;
display: flex;
flex-direction: row;
gap: 0.5em;
padding: 0.5em 0.75em;
.qr--modal-executeComboIcon {
display: flex;
}
}
#qr--modal-execute {
transition: 200ms;
filter: grayscale(0);
&.qr--busy {
cursor: wait;
opacity: 0.5;
filter: grayscale(1);
}
}
#qr--modal-execute {
border-color: rgb(81, 163, 81);
}
#qr--modal-pause, #qr--modal-stop {
cursor: default;
opacity: 0.5;
filter: grayscale(1);
pointer-events: none;
}
.qr--busy {
~ #qr--modal-pause, ~ #qr--modal-stop {
cursor: pointer;
opacity: 1;
filter: grayscale(0);
pointer-events: all;
}
}
#qr--modal-pause {
border-color: rgb(146, 190, 252);
}
#qr--modal-stop {
border-color: rgb(215, 136, 114);
}
}
#qr--modal-executeProgress {
--prog: 0;
--progColor: rgb(146, 190, 252);
--progFlashColor: rgb(215, 136, 114);
--progSuccessColor: rgb(81, 163, 81);
--progErrorColor: rgb(189, 54, 47);
--progAbortedColor: rgb(215, 136, 114);
height: 0.5em;
background-color: var(--black50a);
position: relative;
&:after {
content: '';
background-color: var(--progColor);
position: absolute;
inset: 0;
right: calc(100% - var(--prog) * 1%);
transition: 200ms;
}
&.qr--paused:after {
animation-name: qr--progressPulse;
animation-duration: 1500ms;
animation-timing-function: ease-in-out;
animation-delay: 0s;
animation-iteration-count: infinite;
}
&.qr--aborted:after {
background-color: var(--progAbortedColor);
}
&.qr--success:after {
background-color: var(--progSuccessColor);
}
&.qr--error:after {
background-color: var(--progErrorColor);
}
}
#qr--modal-executeErrors {
display: none;
&.qr--hasErrors {
display: block;
}
text-align: left;
font-size: smaller;
background-color: rgb(189, 54, 47);
color: white;
padding: 0.5em;
overflow: auto;
min-width: 100%;
width: 0;
}
#qr--modal-executeResult {
display: none;
&.qr--hasResult {
display: block;
}
&:before { content: 'Result: '; }
text-align: left;
font-size: smaller;
background-color: rgb(81, 163, 81);
color: white;
padding: 0.5em;
overflow: auto;
min-width: 100%;
width: 0;
}
}
}
}
@keyframes qr--progressPulse {
0%, 100% {
background-color: var(--progColor);
}
50% {
background-color: var(--progFlashColor);
}
}
.shadow_popup.qr--hide {
opacity: 0 !important;

View File

@ -97,7 +97,7 @@ async function loadRegexScripts() {
await onRegexEditorOpenClick(scriptHtml.attr('id'));
});
scriptHtml.find('.export_regex').on('click', async function () {
const fileName = `${script.scriptName.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json`;
const fileName = `${script.scriptName.replace(/[\s.<>:"/\\|?*\x00-\x1F\x7F]/g, '_').toLowerCase()}.json`;
const fileData = JSON.stringify(script, null, 4);
download(fileData, fileName, 'application/json');
});

View File

@ -2192,11 +2192,11 @@ async function sendGenerationRequest(generationType, prompt, characterName = nul
? extension_settings.sd.prompt_prefix
: combinePrefixes(extension_settings.sd.prompt_prefix, getCharacterPrefix());
const prefixedPrompt = combinePrefixes(prefix, prompt, '{prompt}');
const prefixedPrompt = substituteParams(combinePrefixes(prefix, prompt, '{prompt}'));
const negativePrompt = noCharPrefix.includes(generationType)
const negativePrompt = substituteParams(noCharPrefix.includes(generationType)
? extension_settings.sd.negative_prompt
: combinePrefixes(extension_settings.sd.negative_prompt, getCharacterNegativePrefix());
: combinePrefixes(extension_settings.sd.negative_prompt, getCharacterNegativePrefix()));
let result = { format: '', data: '' };
const currentChatId = getCurrentChatId();

View File

@ -5,6 +5,7 @@ import { SlashCommand } from '../../slash-commands/SlashCommand.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { getFriendlyTokenizerName, getTextTokens, getTokenCountAsync, tokenizers } from '../../tokenizers.js';
import { resetScrollHeight, debounce } from '../../utils.js';
import { debounce_timeout } from '../../constants.js';
function rgb2hex(rgb) {
rgb = rgb.match(/^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i);
@ -60,7 +61,7 @@ async function doTokenCounter() {
resetScrollHeight($('#token_counter_textarea'));
resetScrollHeight($('#token_counter_ids'));
}, 1000);
}, debounce_timeout.relaxed);
dialog.find('#token_counter_textarea').on('input', () => countDebounced());
$('#dialogue_popup').addClass('wide_dialogue_popup');

View File

@ -424,6 +424,24 @@ function createEventHandler(translateFunction, shouldTranslateFunction) {
};
}
async function onTranslateInputMessageClick() {
const textarea = document.getElementById('send_textarea');
if (!(textarea instanceof HTMLTextAreaElement)) {
return;
}
if (!textarea.value) {
toastr.warning('Enter a message first');
return;
}
const toast = toastr.info('Input Message is translating', 'Please wait...');
const translatedText = await translate(textarea.value, extension_settings.translate.internal_language);
textarea.value = translatedText;
toastr.clear(toast);
}
// Prevents the chat from being translated in parallel
let translateChatExecuting = false;
@ -555,10 +573,16 @@ jQuery(() => {
<div id="translate_chat" class="list-group-item flex-container flexGap5">
<div class="fa-solid fa-language extensionsMenuExtensionButton" /></div>
Translate Chat
</div>`;
</div>
<div id="translate_input_message" class="list-group-item flex-container flexGap5">
<div class="fa-solid fa-keyboard extensionsMenuExtensionButton" /></div>
Translate Input
</div>
`;
$('#extensionsMenu').append(buttonHtml);
$('#extensions_settings2').append(html);
$('#translate_chat').on('click', onTranslateChatClick);
$('#translate_input_message').on('click', onTranslateInputMessageClick);
$('#translation_clear').on('click', onTranslationsClearClick);
for (const [key, value] of Object.entries(languageCodes)) {

View File

@ -6,6 +6,11 @@ import { saveTtsProviderSettings } from './index.js';
export { EdgeTtsProvider };
const EDGE_TTS_PROVIDER = {
extras: 'extras',
plugin: 'plugin',
};
class EdgeTtsProvider {
//########//
// Config //
@ -19,18 +24,26 @@ class EdgeTtsProvider {
defaultSettings = {
voiceMap: {},
rate: 0,
provider: EDGE_TTS_PROVIDER.extras,
};
get settingsHtml() {
let html = `Microsoft Edge TTS Provider<br>
let html = `Microsoft Edge TTS<br>
<label for="edge_tts_provider">Provider</label>
<select id="edge_tts_provider">
<option value="${EDGE_TTS_PROVIDER.extras}">Extras</option>
<option value="${EDGE_TTS_PROVIDER.plugin}">Plugin</option>
</select>
<label for="edge_tts_rate">Rate: <span id="edge_tts_rate_output"></span></label>
<input id="edge_tts_rate" type="range" value="${this.defaultSettings.rate}" min="-100" max="100" step="1" />`;
<input id="edge_tts_rate" type="range" value="${this.defaultSettings.rate}" min="-100" max="100" step="1" />
`;
return html;
}
onSettingsChange() {
this.settings.rate = Number($('#edge_tts_rate').val());
$('#edge_tts_rate_output').text(this.settings.rate);
this.settings.provider = String($('#edge_tts_provider').val());
saveTtsProviderSettings();
}
@ -53,16 +66,19 @@ class EdgeTtsProvider {
$('#edge_tts_rate').val(this.settings.rate || 0);
$('#edge_tts_rate_output').text(this.settings.rate || 0);
$('#edge_tts_rate').on('input', () => {this.onSettingsChange();});
$('#edge_tts_rate').on('input', () => { this.onSettingsChange(); });
$('#edge_tts_provider').val(this.settings.provider || EDGE_TTS_PROVIDER.extras);
$('#edge_tts_provider').on('change', () => { this.onSettingsChange(); });
await this.checkReady();
console.debug('EdgeTTS: Settings loaded');
}
// Perform a simple readiness check by trying to fetch voiceIds
async checkReady(){
throwIfModuleMissing();
/**
* Perform a simple readiness check by trying to fetch voiceIds
*/
async checkReady() {
await this.throwIfModuleMissing();
await this.fetchTtsVoiceObjects();
}
@ -74,6 +90,11 @@ class EdgeTtsProvider {
// TTS Interfaces //
//#################//
/**
* Get a voice from the TTS provider.
* @param {string} voiceName Voice name to get
* @returns {Promise<Object>} Voice object
*/
async getVoice(voiceName) {
if (this.voices.length == 0) {
this.voices = await this.fetchTtsVoiceObjects();
@ -87,6 +108,12 @@ class EdgeTtsProvider {
return match;
}
/**
* Generate TTS for a given text.
* @param {string} text Text to generate TTS for
* @param {string} voiceId Voice ID to use
* @returns {Promise<Response>} Fetch response
*/
async generateTts(text, voiceId) {
const response = await this.fetchTtsGeneration(text, voiceId);
return response;
@ -96,11 +123,10 @@ class EdgeTtsProvider {
// API CALLS //
//###########//
async fetchTtsVoiceObjects() {
throwIfModuleMissing();
await this.throwIfModuleMissing();
const url = new URL(getApiUrl());
url.pathname = '/api/edge-tts/list';
const response = await doExtrasFetch(url);
const url = this.getVoicesUrl();
const response = await this.doFetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
@ -111,7 +137,10 @@ class EdgeTtsProvider {
return responseJson;
}
/**
* Preview TTS for a given voice ID.
* @param {string} id Voice ID
*/
async previewTtsVoice(id) {
this.audioElement.pause();
this.audioElement.currentTime = 0;
@ -128,13 +157,18 @@ class EdgeTtsProvider {
this.audioElement.play();
}
/**
* Fetch TTS generation from the API.
* @param {string} inputText Text to generate TTS for
* @param {string} voiceId Voice ID to use
* @returns {Promise<Response>} Fetch response
*/
async fetchTtsGeneration(inputText, voiceId) {
throwIfModuleMissing();
await this.throwIfModuleMissing();
console.info(`Generating new TTS for voice_id ${voiceId}`);
const url = new URL(getApiUrl());
url.pathname = '/api/edge-tts/generate';
const response = await doExtrasFetch(url,
const url = this.getGenerateUrl();
const response = await this.doFetch(url,
{
method: 'POST',
headers: getRequestHeaders(),
@ -151,12 +185,85 @@ class EdgeTtsProvider {
}
return response;
}
}
function throwIfModuleMissing() {
if (!modules.includes('edge-tts')) {
const message = 'Edge TTS module not loaded. Add edge-tts to enable-modules and restart the Extras API.';
// toastr.error(message)
throw new Error(message);
/**
* Perform a fetch request using the configured provider.
* @param {string} url URL string
* @param {any} options Request options
* @returns {Promise<Response>} Fetch response
*/
doFetch(url, options) {
if (this.settings.provider === EDGE_TTS_PROVIDER.extras) {
return doExtrasFetch(url, options);
}
if (this.settings.provider === EDGE_TTS_PROVIDER.plugin) {
return fetch(url, options);
}
throw new Error('Invalid TTS Provider');
}
/**
* Get the URL for the TTS generation endpoint.
* @returns {string} URL string
*/
getGenerateUrl() {
if (this.settings.provider === EDGE_TTS_PROVIDER.extras) {
const url = new URL(getApiUrl());
url.pathname = '/api/edge-tts/generate';
return url.toString();
}
if (this.settings.provider === EDGE_TTS_PROVIDER.plugin) {
return '/api/plugins/edge-tts/generate';
}
throw new Error('Invalid TTS Provider');
}
/**
* Get the URL for the TTS voices endpoint.
* @returns {string} URL object or string
*/
getVoicesUrl() {
if (this.settings.provider === EDGE_TTS_PROVIDER.extras) {
const url = new URL(getApiUrl());
url.pathname = '/api/edge-tts/list';
return url.toString();
}
if (this.settings.provider === EDGE_TTS_PROVIDER.plugin) {
return '/api/plugins/edge-tts/list';
}
throw new Error('Invalid TTS Provider');
}
async throwIfModuleMissing() {
if (this.settings.provider === EDGE_TTS_PROVIDER.extras && !modules.includes('edge-tts')) {
const message = 'Edge TTS module not loaded. Add edge-tts to enable-modules and restart the Extras API.';
// toastr.error(message)
throw new Error(message);
}
if (this.settings.provider === EDGE_TTS_PROVIDER.plugin && !this.isPluginAvailable()) {
const message = 'Edge TTS Server plugin not loaded. Install it from https://github.com/SillyTavern/SillyTavern-EdgeTTS-Plugin and restart the SillyTavern server.';
// toastr.error(message)
throw new Error(message);
}
}
async isPluginAvailable() {
try {
const result = await fetch('/api/plugins/edge-tts/probe', {
method: 'POST',
headers: getRequestHeaders(),
});
return result.ok;
} catch (e) {
return false;
}
}
}

View File

@ -23,6 +23,7 @@ import { collapseNewlines } from '../../power-user.js';
import { SECRET_KEYS, secret_state, writeSecret } from '../../secrets.js';
import { getDataBankAttachments, getFileAttachment } from '../../chats.js';
import { debounce, getStringHash as calculateHash, waitUntilCondition, onlyUnique, splitRecursive } from '../../utils.js';
import { debounce_timeout } from '../../constants.js';
import { getSortedEntries } from '../../world-info.js';
const MODULE_NAME = 'vectors';
@ -280,7 +281,7 @@ async function synchronizeChat(batchSize = 5) {
console.error('Vectors: Failed to synchronize chat', error);
const message = getErrorMessage(error.cause);
toastr.error(message, 'Vectorization failed');
toastr.error(message, 'Vectorization failed', { preventDuplicates: true });
return -1;
} finally {
syncBlocked = false;
@ -443,6 +444,7 @@ async function retrieveFileChunks(queryText, collectionId) {
* @param {string} fileName File name
* @param {string} collectionId File collection ID
* @param {number} chunkSize Chunk size
* @returns {Promise<boolean>} True if successful, false if not
*/
async function vectorizeFile(fileText, fileName, collectionId, chunkSize) {
try {
@ -461,8 +463,11 @@ async function vectorizeFile(fileText, fileName, collectionId, chunkSize) {
toastr.clear(toast);
console.log(`Vectors: Inserted ${chunks.length} vector items for file ${fileName} into ${collectionId}`);
return true;
} catch (error) {
toastr.error(String(error), 'Failed to vectorize file', { preventDuplicates: true });
console.error('Vectors: Failed to vectorize file', error);
return false;
}
}
@ -545,6 +550,7 @@ async function rearrangeChat(chat) {
const insertedText = getPromptText(queriedMessages);
setExtensionPrompt(EXTENSION_PROMPT_TAG, insertedText, settings.position, settings.depth, settings.include_wi);
} catch (error) {
toastr.error('Generation interceptor aborted. Check browser console for more details.', 'Vector Storage');
console.error('Vectors: Failed to rearrange chat', error);
}
}
@ -561,7 +567,7 @@ function getPromptText(queriedMessages) {
window['vectors_rearrangeChat'] = rearrangeChat;
const onChatEvent = debounce(async () => await moduleWorker.update(), 500);
const onChatEvent = debounce(async () => await moduleWorker.update(), debounce_timeout.relaxed);
/**
* Gets the text to query from the chat
@ -910,6 +916,8 @@ async function onVectorizeAllFilesClick() {
const chatAttachments = getContext().chat.filter(x => x.extra?.file).map(x => x.extra.file);
const allFiles = [...dataBank, ...chatAttachments];
let allSuccess = true;
for (const file of allFiles) {
const text = await getFileAttachment(file.url);
const collectionId = getFileCollectionId(file.url);
@ -920,10 +928,18 @@ async function onVectorizeAllFilesClick() {
continue;
}
await vectorizeFile(text, file.name, collectionId, settings.chunk_size);
const result = await vectorizeFile(text, file.name, collectionId, settings.chunk_size);
if (!result) {
allSuccess = false;
}
}
toastr.success('All files vectorized', 'Vectorization successful');
if (allSuccess) {
toastr.success('All files vectorized', 'Vectorization successful');
} else {
toastr.warning('Some files failed to vectorize. Check browser console for more details.', 'Vector Storage');
}
} catch (error) {
console.error('Vectors: Failed to vectorize all files', error);
toastr.error('Failed to vectorize all files', 'Vectorization failed');

View File

@ -64,6 +64,36 @@ export class FilterHelper {
this.onDataChanged = onDataChanged;
}
/**
* Checks if the filter data has any values.
* @returns {boolean} Whether the filter data has any values
*/
hasAnyFilter() {
/**
* Checks if the object has any values.
* @param {object} obj The object to check for values
* @returns {boolean} Whether the object has any values
*/
function checkRecursive(obj) {
if (typeof obj === 'string' && obj.length > 0 && obj !== 'UNDEFINED') {
return true;
} else if (typeof obj === 'boolean' && obj) {
return true;
} else if (Array.isArray(obj) && obj.length > 0) {
return true;
} else if (typeof obj === 'object' && obj !== null && Object.keys(obj.length > 0)) {
for (const key in obj) {
if (checkRecursive(obj[key])) {
return true;
}
}
}
return false;
}
return checkRecursive(this.filterData);
}
/**
* The filter functions.
* @type {Object.<string, Function>}

View File

@ -14,6 +14,7 @@ import {
} from './utils.js';
import { RA_CountCharTokens, humanizedDateTime, dragElement, favsToHotswap, getMessageTimeStamp } from './RossAscends-mods.js';
import { power_user, loadMovingUIState, sortEntitiesList } from './power-user.js';
import { debounce_timeout } from './constants.js';
import {
chat,
@ -118,9 +119,9 @@ export const group_generation_mode = {
const DEFAULT_AUTO_MODE_DELAY = 5;
export const groupCandidatesFilter = new FilterHelper(debounce(printGroupCandidates, 100));
export const groupCandidatesFilter = new FilterHelper(debounce(printGroupCandidates, debounce_timeout.quick));
let autoModeWorker = null;
const saveGroupDebounced = debounce(async (group, reload) => await _save(group, reload), 500);
const saveGroupDebounced = debounce(async (group, reload) => await _save(group, reload), debounce_timeout.relaxed);
function setAutoModeWorker() {
clearInterval(autoModeWorker);

View File

@ -1,4 +1,5 @@
import { registerDebugFunction } from './power-user.js';
import { updateSecretDisplay } from './secrets.js';
const storageKey = 'language';
const overrideLanguage = localStorage.getItem(storageKey);
@ -12,10 +13,8 @@ const localeData = await getLocaleData(localeFile);
* @returns {Promise<Record<string, string>>} Locale data
*/
async function getLocaleData(language) {
let supportedLang = langs.find(x => x.lang === language);
let supportedLang = findLang(language);
if (!supportedLang) {
console.warn(`Unsupported language: ${language}`);
return {};
}
@ -30,11 +29,24 @@ async function getLocaleData(language) {
return data;
}
function findLang(language) {
var supportedLang = langs.find(x => x.lang === language);
if (!supportedLang) {
console.warn(`Unsupported language: ${language}`);
}
return supportedLang;
}
async function getMissingTranslations() {
const missingData = [];
for (const language of langs) {
const localeData = await getLocaleData(language);
// Determine locales to search for untranslated strings
const isNotSupported = !findLang(localeFile);
const langsToProcess = (isNotSupported || localeFile == 'en') ? langs : [findLang(localeFile)];
for (const language of langsToProcess) {
const localeData = await getLocaleData(language.lang);
$(document).find('[data-i18n]').each(function () {
const keys = $(this).data('i18n').split(';'); // Multi-key entries are ; delimited
for (const key of keys) {
@ -42,12 +54,12 @@ async function getMissingTranslations() {
if (attributeMatch) { // attribute-tagged key
const localizedValue = localeData?.[attributeMatch[2]];
if (!localizedValue) {
missingData.push({ key, language, value: $(this).attr(attributeMatch[1]) });
missingData.push({ key, language: language.lang, value: $(this).attr(attributeMatch[1]) });
}
} else { // No attribute tag, treat as 'text'
const localizedValue = localeData?.[key];
if (!localizedValue) {
missingData.push({ key, language, value: $(this).text().trim() });
missingData.push({ key, language: language.lang, value: $(this).text().trim() });
}
}
}
@ -130,6 +142,7 @@ function addLanguagesToDropdown() {
export function initLocales() {
applyLocale();
addLanguagesToDropdown();
updateSecretDisplay();
$('#ui_language_select').on('change', async function () {
const language = String($(this).val());
@ -143,6 +156,6 @@ export function initLocales() {
location.reload();
});
registerDebugFunction('getMissingTranslations', 'Get missing translations', 'Detects missing localization data and dumps the data into the browser console.', getMissingTranslations);
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);
}

View File

@ -475,7 +475,7 @@ function convertTokenIdLogprobsToText(input) {
}
export function initLogprobs() {
const debouncedRender = debounce(renderAlternativeTokensView, 500);
const debouncedRender = debounce(renderAlternativeTokensView);
$('#logprobsViewerClose').click(onToggleLogprobsPanel);
$('#option_toggle_logprobs').click(onToggleLogprobsPanel);
eventSource.on(event_types.CHAT_CHANGED, debouncedRender);

View File

@ -3253,7 +3253,8 @@ async function onExportPresetClick() {
delete preset.proxy_password;
const presetJsonString = JSON.stringify(preset, null, 4);
download(presetJsonString, oai_settings.preset_settings_openai, 'application/json');
const presetFileName = `${oai_settings.preset_settings_openai}.json`;
download(presetJsonString, presetFileName, 'application/json');
}
async function onLogitBiasPresetImportFileChange(e) {
@ -3301,7 +3302,8 @@ function onLogitBiasPresetExportClick() {
}
const presetJsonString = JSON.stringify(oai_settings.bias_presets[oai_settings.bias_preset_selected], null, 4);
download(presetJsonString, oai_settings.bias_preset_selected, 'application/json');
const presetFileName = `${oai_settings.bias_preset_selected}.json`;
download(presetJsonString, presetFileName, 'application/json');
}
async function onDeletePresetClick() {

View File

@ -19,6 +19,7 @@ import {
import { persona_description_positions, power_user } from './power-user.js';
import { getTokenCountAsync } from './tokenizers.js';
import { debounce, delay, download, parseJsonFile } from './utils.js';
import { debounce_timeout } from './constants.js';
const GRID_STORAGE_KEY = 'Personas_GridView';
@ -175,7 +176,7 @@ const countPersonaDescriptionTokens = debounce(async () => {
const description = String($('#persona_description').val());
const count = await getTokenCountAsync(description);
$('#persona_description_token_count').text(String(count));
}, 1000);
}, debounce_timeout.relaxed);
export function setPersonaDescription() {
if (power_user.persona_description_position === persona_description_positions.AFTER_CHAR) {

View File

@ -34,7 +34,6 @@ import {
selectInstructPreset,
} from './instruct-mode.js';
import { registerSlashCommand } from './slash-commands.js';
import { tag_map, tags } from './tags.js';
import { tokenizers } from './tokenizers.js';
import { BIAS_CACHE } from './logit-bias.js';
@ -44,6 +43,7 @@ import { countOccurrences, debounce, delay, download, getFileText, isOdd, resetS
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 { AUTOCOMPLETE_WIDTH } from './slash-commands/SlashCommandAutoComplete.js';
export {
loadPowerUserSettings,
@ -256,7 +256,13 @@ let power_user = {
aux_field: 'character_version',
stscript: {
matching: 'fuzzy',
autocomplete_style: 'theme',
autocomplete: {
style: 'theme',
width: {
left: AUTOCOMPLETE_WIDTH.CHAT,
right: AUTOCOMPLETE_WIDTH.CHAT,
},
},
parser: {
/**@type {Object.<PARSER_FLAG,boolean>} */
flags: {},
@ -336,7 +342,7 @@ const contextControls = [
let browser_has_focus = true;
const debug_functions = [];
const setHotswapsDebounced = debounce(favsToHotswap, 500);
const setHotswapsDebounced = debounce(favsToHotswap);
export function switchSimpleMode() {
$('[data-newbie-hidden]').each(function () {
@ -1447,10 +1453,17 @@ function loadPowerUserSettings(settings, data) {
if (power_user.stscript === undefined) {
power_user.stscript = defaultStscript;
} else if (power_user.stscript.parser === undefined) {
power_user.stscript.parser = defaultStscript.parser;
} else if (power_user.stscript.parser.flags === undefined) {
power_user.stscript.parser.flags = defaultStscript.parser.flags;
} else {
if (power_user.stscript.autocomplete === undefined) {
power_user.stscript.autocomplete = defaultStscript.autocomplete;
} else if (power_user.stscript.autocomplete.width === undefined) {
power_user.stscript.autocomplete.width = defaultStscript.autocomplete.width;
}
if (power_user.stscript.parser === undefined) {
power_user.stscript.parser = defaultStscript.parser;
} else if (power_user.stscript.parser.flags === undefined) {
power_user.stscript.parser.flags = defaultStscript.parser.flags;
}
}
if (data.themes !== undefined) {
@ -1600,6 +1613,10 @@ function loadPowerUserSettings(settings, data) {
document.body.setAttribute('data-stscript-style', power_user.stscript.autocomplete_style);
$('#stscript_parser_flag_strict_escaping').prop('checked', power_user.stscript.parser.flags[PARSER_FLAG.STRICT_ESCAPING] ?? false);
$('#stscript_parser_flag_replace_getvar').prop('checked', power_user.stscript.parser.flags[PARSER_FLAG.REPLACE_GETVAR] ?? false);
$('#stscript_autocomplete_width_left').val(power_user.stscript.autocomplete.width.left ?? AUTOCOMPLETE_WIDTH.CHAT);
document.querySelector('#stscript_autocomplete_width_left').dispatchEvent(new Event('input', { bubbles:true }));
$('#stscript_autocomplete_width_right').val(power_user.stscript.autocomplete.width.right ?? AUTOCOMPLETE_WIDTH.CHAT);
document.querySelector('#stscript_autocomplete_width_right').dispatchEvent(new Event('input', { bubbles:true }));
$('#restore_user_input').prop('checked', power_user.restore_user_input);
@ -3570,6 +3587,22 @@ $(document).ready(() => {
saveSettingsDebounced();
});
$('#stscript_autocomplete_width_left').on('input', function () {
const value = $(this).val();
power_user.stscript.autocomplete.width.left = String(value);
this.closest('.doubleRangeInputContainer').style.setProperty('--value', value);
window.dispatchEvent(new Event('resize', { bubbles:true }));
saveSettingsDebounced();
});
$('#stscript_autocomplete_width_right').on('input', function () {
const value = $(this).val();
power_user.stscript.autocomplete.width.right = String(value);
this.closest('.doubleRangeInputContainer').style.setProperty('--value', value);
window.dispatchEvent(new Event('resize', { bubbles:true }));
saveSettingsDebounced();
});
$('#stscript_parser_flag_strict_escaping').on('click', function () {
const value = $(this).prop('checked');
power_user.stscript.parser.flags[PARSER_FLAG.STRICT_ESCAPING] = value;

View File

@ -9,6 +9,7 @@ import { isValidUrl } from './utils.js';
* @property {string} name
* @property {string} description
* @property {string} iconClass
* @property {boolean} iconAvailable
* @property {() => Promise<boolean>} isAvailable
* @property {() => Promise<File[]>} scrape
*/
@ -19,6 +20,7 @@ import { isValidUrl } from './utils.js';
* @property {string} name
* @property {string} description
* @property {string} iconClass
* @property {boolean} iconAvailable
*/
export class ScraperManager {
@ -45,7 +47,7 @@ export class ScraperManager {
* @returns {ScraperInfo[]} List of scrapers available for the Data Bank
*/
static getDataBankScrapers() {
return ScraperManager.#scrapers.map(s => ({ id: s.id, name: s.name, description: s.description, iconClass: s.iconClass }));
return ScraperManager.#scrapers.map(s => ({ id: s.id, name: s.name, description: s.description, iconClass: s.iconClass, iconAvailable: s.iconAvailable }));
}
/**
@ -87,6 +89,7 @@ class Notepad {
this.name = 'Notepad';
this.description = 'Create a text file from scratch.';
this.iconClass = 'fa-solid fa-note-sticky';
this.iconAvailable = true;
}
/**
@ -133,6 +136,7 @@ class WebScraper {
this.name = 'Web';
this.description = 'Download a page from the web.';
this.iconClass = 'fa-solid fa-globe';
this.iconAvailable = true;
}
/**
@ -207,6 +211,7 @@ class FileScraper {
this.name = 'File';
this.description = 'Upload a file from your computer.';
this.iconClass = 'fa-solid fa-upload';
this.iconAvailable = true;
}
/**
@ -243,6 +248,7 @@ class FandomScraper {
this.name = 'Fandom';
this.description = 'Download a page from the Fandom wiki.';
this.iconClass = 'fa-solid fa-fire';
this.iconAvailable = true;
}
/**
@ -348,7 +354,8 @@ class YouTubeScraper {
this.id = 'youtube';
this.name = 'YouTube';
this.description = 'Download a transcript from a YouTube video.';
this.iconClass = 'fa-solid fa-closed-captioning';
this.iconClass = 'fa-brands fa-youtube';
this.iconAvailable = true;
}
/**

View File

@ -62,10 +62,11 @@ async function clearSecret() {
$('#main_api').trigger('change');
}
function updateSecretDisplay() {
export function updateSecretDisplay() {
for (const [secret_key, input_selector] of Object.entries(INPUT_MAP)) {
const validSecret = !!secret_state[secret_key];
const placeholder = validSecret ? '✔️ Key saved' : '❌ Missing key';
const placeholder = $('#viewSecrets').attr(validSecret ? 'key_saved_text' : 'missing_key_text');
$(input_selector).attr('placeholder', placeholder);
}
}

View File

@ -20,6 +20,7 @@ import {
is_send_press,
main_api,
name1,
name2,
reloadCurrentChat,
removeMacros,
retriggerFirstMessageOnEmptyChat,
@ -37,7 +38,7 @@ import {
system_message_types,
this_chid,
} from '../script.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { PARSER_FLAG, SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { SlashCommandParserError } from './slash-commands/SlashCommandParserError.js';
import { getMessageTimeStamp } from './RossAscends-mods.js';
import { hideChatMessageRange } from './chats.js';
@ -49,7 +50,7 @@ import { autoSelectPersona } 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 { delay, isFalseBoolean, isTrueBoolean, stringToRange, trimToEndSentence, trimToStartSentence, waitUntilCondition } from './utils.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';
@ -60,11 +61,15 @@ import { SlashCommandAutoCompleteOption } from './slash-commands/SlashCommandAut
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
import { SlashCommandAutoComplete } from './slash-commands/SlashCommandAutoComplete.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortController.js';
export {
executeSlashCommands, getSlashCommandsHelp, registerSlashCommand,
executeSlashCommands, executeSlashCommandsWithOptions, getSlashCommandsHelp, registerSlashCommand,
};
export const parser = new SlashCommandParser();
/**
* @deprecated Use SlashCommandParser.addCommandObject() instead
*/
const registerSlashCommand = parser.addCommand.bind(parser);
const getSlashCommandsHelp = parser.getHelpString.bind(parser);
@ -1117,13 +1122,12 @@ function injectCallback(args, value) {
chat_metadata.script_injects = {};
}
chat_metadata.script_injects[id] = {
value,
position,
depth,
scan,
role,
};
if (value) {
const inject = { value, position, depth, scan, role };
chat_metadata.script_injects[id] = inject;
} else {
delete chat_metadata.script_injects[id];
}
setExtensionPrompt(prefixedId, value, position, depth, scan, role);
saveMetadataDebounced();
@ -2201,14 +2205,12 @@ export async function sendMessageAs(args, text) {
return;
}
} else {
const parts = text.split('\n');
if (parts.length <= 1) {
toastr.warning('Both character name and message are required. Separate them with a new line.');
return;
const namelessWarningKey = 'sendAsNamelessWarningShown';
if (localStorage.getItem(namelessWarningKey) !== 'true') {
toastr.warning('To avoid confusion, please use /sendas name="Character Name"', 'Name defaulted to {{char}}', { timeOut: 10000 });
localStorage.setItem(namelessWarningKey, 'true');
}
name = parts.shift().trim();
mesText = parts.join('\n').trim();
name = name2;
}
// Requires a regex check after the slash command is pushed to output
@ -2548,24 +2550,176 @@ function modelCallback(_, model) {
}
}
export let isExecutingCommandsFromChatInput = false;
export let commandsFromChatInputAbortController;
/**
* Executes slash commands in the provided text
* Show command execution pause/stop buttons next to chat input.
*/
export function activateScriptButtons() {
document.querySelector('#form_sheld').classList.add('isExecutingCommandsFromChatInput');
}
/**
* Hide command execution pause/stop buttons next to chat input.
*/
export function deactivateScriptButtons() {
document.querySelector('#form_sheld').classList.remove('isExecutingCommandsFromChatInput');
}
/**
* Toggle pause/continue command execution. Only for commands executed via chat input.
*/
export function pauseScriptExecution() {
if (commandsFromChatInputAbortController) {
if (commandsFromChatInputAbortController.signal.paused) {
commandsFromChatInputAbortController.continue('Clicked pause button');
document.querySelector('#form_sheld').classList.remove('script_paused');
} else {
commandsFromChatInputAbortController.pause('Clicked pause button');
document.querySelector('#form_sheld').classList.add('script_paused');
}
}
}
/**
* Stop command execution. Only for commands executed via chat input.
*/
export function stopScriptExecution() {
commandsFromChatInputAbortController?.abort('Clicked stop button');
}
/**
* Clear up command execution progress bar above chat input.
* @returns Promise<void>
*/
async function clearCommandProgress() {
if (isExecutingCommandsFromChatInput) return;
document.querySelector('#send_textarea').style.setProperty('--progDone', '1');
await delay(250);
if (isExecutingCommandsFromChatInput) return;
document.querySelector('#send_textarea').style.transition = 'none';
await delay(1);
document.querySelector('#send_textarea').style.setProperty('--prog', '0%');
document.querySelector('#send_textarea').style.setProperty('--progDone', '0');
document.querySelector('#form_sheld').classList.remove('script_success');
document.querySelector('#form_sheld').classList.remove('script_error');
document.querySelector('#form_sheld').classList.remove('script_aborted');
await delay(1);
document.querySelector('#send_textarea').style.transition = null;
}
/**
* Debounced version of clearCommandProgress.
*/
const clearCommandProgressDebounced = debounce(clearCommandProgress);
/**
* @typedef ExecuteSlashCommandsOptions
* @prop {boolean} [handleParserErrors] (true) Whether to handle parser errors (show toast on error) or throw.
* @prop {SlashCommandScope} [scope] (null) The scope to be used when executing the commands.
* @prop {boolean} [handleExecutionErrors] (false) Whether to handle execution errors (show toast on error) or throw
* @prop {PARSER_FLAG[]} [parserFlags] (null) Parser flags to apply
* @prop {SlashCommandAbortController} [abortController] (null) Controller used to abort or pause command execution
* @prop {(done:number, total:number)=>void} [onProgress] (null) Callback to handle progress events
*/
/**
* @typedef ExecuteSlashCommandsOnChatInputOptions
* @prop {SlashCommandScope} [scope] (null) The scope to be used when executing the commands.
* @prop {PARSER_FLAG[]} [parserFlags] (null) Parser flags to apply
* @prop {boolean} [clearChatInput] (false) Whether to clear the chat input textarea
*/
/**
* Execute slash commands while showing progress indicator and pause/stop buttons on
* chat input.
* @param {string} text Slash command text
* @param {boolean} handleParserErrors Whether to handle parser errors (show toast on error) or throw
* @param {SlashCommandScope} scope The scope to be used when executing the commands.
* @param {ExecuteSlashCommandsOnChatInputOptions} options
*/
export async function executeSlashCommandsOnChatInput(text, options = {}) {
if (isExecutingCommandsFromChatInput) return null;
options = Object.assign({
scope: null,
parserFlags: null,
clearChatInput: false,
}, options);
isExecutingCommandsFromChatInput = true;
commandsFromChatInputAbortController?.abort('processCommands was called');
activateScriptButtons();
/**@type {HTMLTextAreaElement}*/
const ta = document.querySelector('#send_textarea');
if (options.clearChatInput) {
ta.value = '';
ta.dispatchEvent(new Event('input', { bubbles:true }));
}
document.querySelector('#send_textarea').style.setProperty('--prog', '0%');
document.querySelector('#send_textarea').style.setProperty('--progDone', '0');
document.querySelector('#form_sheld').classList.remove('script_success');
document.querySelector('#form_sheld').classList.remove('script_error');
document.querySelector('#form_sheld').classList.remove('script_aborted');
/**@type {SlashCommandClosureResult} */
let result = null;
try {
commandsFromChatInputAbortController = new SlashCommandAbortController();
result = await executeSlashCommandsWithOptions(text, {
abortController: commandsFromChatInputAbortController,
onProgress: (done, total)=>ta.style.setProperty('--prog', `${done / total * 100}%`),
});
if (commandsFromChatInputAbortController.signal.aborted) {
document.querySelector('#form_sheld').classList.add('script_aborted');
} else {
document.querySelector('#form_sheld').classList.add('script_success');
}
} catch (e) {
document.querySelector('#form_sheld').classList.add('script_error');
toastr.error(e.message);
result = new SlashCommandClosureResult();
result.interrupt = true;
result.isError = true;
result.errorMessage = e.message;
} finally {
delay(1000).then(()=>clearCommandProgressDebounced());
commandsFromChatInputAbortController = null;
deactivateScriptButtons();
isExecutingCommandsFromChatInput = false;
}
return result;
}
/**
*
* @param {string} text Slash command text
* @param {ExecuteSlashCommandsOptions} [options]
* @returns {Promise<SlashCommandClosureResult>}
*/
async function executeSlashCommands(text, handleParserErrors = true, scope = null, handleExecutionErrors = false, parserFlags = null) {
async function executeSlashCommandsWithOptions(text, options = {}) {
if (!text) {
return null;
}
options = Object.assign({
handleParserErrors: true,
scope: null,
handleExecutionErrors: false,
parserFlags: null,
abortController: null,
onProgress: null,
}, options);
let closure;
try {
closure = parser.parse(text, true, parserFlags);
closure.scope.parent = scope;
closure = parser.parse(text, true, options.parserFlags, options.abortController);
closure.scope.parent = options.scope;
closure.onProgress = options.onProgress;
} catch (e) {
if (handleParserErrors && e instanceof SlashCommandParserError) {
if (options.handleParserErrors && e instanceof SlashCommandParserError) {
/**@type {SlashCommandParserError}*/
const ex = e;
const toast = `
@ -2588,18 +2742,46 @@ async function executeSlashCommands(text, handleParserErrors = true, scope = nul
}
try {
return await closure.execute();
const result = await closure.execute();
if (result.isAborted) {
toastr.warning(result.abortReason, 'Command execution aborted');
}
return result;
} catch (e) {
if (handleExecutionErrors) {
if (options.handleExecutionErrors) {
toastr.error(e.message);
const result = new SlashCommandClosureResult();
result.interrupt = true;
result.isError = true;
result.errorMessage = e.message;
return result;
} else {
throw e;
}
}
}
/**
* Executes slash commands in the provided text
* @deprecated Use executeSlashCommandWithOptions instead
* @param {string} text Slash command text
* @param {boolean} handleParserErrors Whether to handle parser errors (show toast on error) or throw
* @param {SlashCommandScope} scope The scope to be used when executing the commands.
* @param {boolean} handleExecutionErrors Whether to handle execution errors (show toast on error) or throw
* @param {PARSER_FLAG[]} parserFlags Parser flags to apply
* @param {SlashCommandAbortController} abortController Controller used to abort or pause command execution
* @param {(done:number, total:number)=>void} onProgress Callback to handle progress events
* @returns {Promise<SlashCommandClosureResult>}
*/
async function executeSlashCommands(text, handleParserErrors = true, scope = null, handleExecutionErrors = false, parserFlags = null, abortController = null, onProgress = null) {
return executeSlashCommandsWithOptions(text, {
handleParserErrors,
scope,
handleExecutionErrors,
parserFlags,
abortController,
onProgress,
});
}
/**
*

View File

@ -354,6 +354,8 @@ export class SlashCommand {
}
this.helpDetailsCache[key] = frag;
}
return this.helpDetailsCache[key].cloneNode(true);
const frag = document.createDocumentFragment();
frag.append(this.helpDetailsCache[key].cloneNode(true));
return frag;
}
}

View File

@ -0,0 +1,27 @@
export class SlashCommandAbortController {
/**@type {SlashCommandAbortSignal}*/ signal;
constructor() {
this.signal = new SlashCommandAbortSignal();
}
abort(reason = 'No reason.') {
this.signal.aborted = true;
this.signal.reason = reason;
}
pause(reason = 'No reason.') {
this.signal.paused = true;
this.signal.reason = reason;
}
continue(reason = 'No reason.') {
this.signal.paused = false;
this.signal.reason = reason;
}
}
export class SlashCommandAbortSignal {
/**@type {boolean}*/ paused = false;
/**@type {boolean}*/ aborted = false;
/**@type {string}*/ reason = null;
}

View File

@ -5,6 +5,14 @@ import { SlashCommandBlankAutoCompleteOption } from './SlashCommandBlankAutoComp
// eslint-disable-next-line no-unused-vars
import { SlashCommandParserNameResult } from './SlashCommandParserNameResult.js';
/**@readonly*/
/**@enum {Number}*/
export const AUTOCOMPLETE_WIDTH = {
'INPUT': 0,
'CHAT': 1,
'FULL': 2,
};
export class SlashCommandAutoComplete {
/**@type {HTMLTextAreaElement}*/ textarea;
/**@type {boolean}*/ isFloating = false;
@ -92,7 +100,7 @@ export class SlashCommandAutoComplete {
textarea.addEventListener('keydown', (evt)=>this.handleKeyDown(evt));
textarea.addEventListener('click', ()=>this.isActive ? this.show() : null);
textarea.addEventListener('selectionchange', ()=>this.show());
textarea.addEventListener('blur', ()=>this.hide());
// textarea.addEventListener('blur', ()=>this.hide());
if (isFloating) {
textarea.addEventListener('scroll', ()=>this.updateFloatingPositionDebounced());
}
@ -389,16 +397,21 @@ export class SlashCommandAutoComplete {
if (this.isFloating) {
this.updateFloatingPosition();
} else {
const rect = this.textarea.getBoundingClientRect();
this.domWrap.style.setProperty('--bottom', `${window.innerHeight - rect.top}px`);
this.dom.style.setProperty('--bottom', `${window.innerHeight - rect.top}px`);
this.domWrap.style.bottom = `${window.innerHeight - rect.top}px`;
const rect = {};
rect[AUTOCOMPLETE_WIDTH.INPUT] = this.textarea.getBoundingClientRect();
rect[AUTOCOMPLETE_WIDTH.CHAT] = document.querySelector('#sheld').getBoundingClientRect();
rect[AUTOCOMPLETE_WIDTH.FULL] = document.body.getBoundingClientRect();
this.domWrap.style.setProperty('--bottom', `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`);
this.dom.style.setProperty('--bottom', `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`);
this.domWrap.style.bottom = `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`;
if (this.isShowingDetails) {
this.domWrap.style.setProperty('--leftOffset', '1vw');
this.domWrap.style.setProperty('--leftOffset', `max(1vw, ${rect[power_user.stscript.autocomplete.width.left].left}px)`);
this.domWrap.style.setProperty('--rightOffset', `calc(100vw - min(${rect[power_user.stscript.autocomplete.width.right].right}px, ${this.isShowingDetails ? 74 : 0}vw)`);
} else {
this.domWrap.style.setProperty('--leftOffset', `${rect.left}px`);
this.domWrap.style.setProperty('--leftOffset', `max(1vw, ${rect[power_user.stscript.autocomplete.width.left].left}px)`);
this.domWrap.style.setProperty('--rightOffset', `calc(100vw - min(99vw, ${rect[power_user.stscript.autocomplete.width.right].right}px)`);
}
this.domWrap.style.setProperty('--rightOffset', `calc(1vw + ${this.isShowingDetails ? 25 : 0}vw)`);
this.updateDetailsPosition();
}
}
@ -411,19 +424,24 @@ export class SlashCommandAutoComplete {
if (this.isFloating) {
this.updateFloatingDetailsPosition();
} else {
const rect = this.textarea.getBoundingClientRect();
const rect = {};
rect[AUTOCOMPLETE_WIDTH.INPUT] = this.textarea.getBoundingClientRect();
rect[AUTOCOMPLETE_WIDTH.CHAT] = document.querySelector('#sheld').getBoundingClientRect();
rect[AUTOCOMPLETE_WIDTH.FULL] = document.body.getBoundingClientRect();
if (this.isReplaceable) {
this.detailsWrap.classList.remove('full');
const selRect = this.selectedItem.dom.children[0].getBoundingClientRect();
this.detailsWrap.style.setProperty('--targetOffset', `${selRect.top}`);
this.detailsWrap.style.bottom = `${window.innerHeight - rect.top}px`;
this.detailsWrap.style.left = `calc(100vw - calc(1vw + ${this.isShowingDetails ? 25 : 0}vw))`;
this.detailsWrap.style.bottom = `calc(100vh - ${rect[AUTOCOMPLETE_WIDTH.INPUT]}px)`;
this.detailsWrap.style.left = `calc(100vw - ${this.domWrap.style.getPropertyValue('--rightOffset')}`;
this.detailsWrap.style.right = '1vw';
this.detailsWrap.style.top = '5vh';
} else {
this.detailsWrap.style.setProperty('--targetOffset', `${rect.top}`);
this.detailsWrap.style.bottom = `${window.innerHeight - rect.top}px`;
this.detailsWrap.style.left = `${rect.left}px`;
this.detailsWrap.style.right = `calc(100vw - ${rect.right}px)`;
this.detailsWrap.classList.add('full');
this.detailsWrap.style.setProperty('--targetOffset', `${rect[AUTOCOMPLETE_WIDTH.INPUT].top}`);
this.detailsWrap.style.bottom = `calc(100vh - ${rect[AUTOCOMPLETE_WIDTH.INPUT].top}px)`;
this.detailsWrap.style.left = `${rect[power_user.stscript.autocomplete.width.left].left}px`;
this.detailsWrap.style.right = `calc(100vw - ${rect[power_user.stscript.autocomplete.width.right].right}px)`;
this.detailsWrap.style.top = '5vh';
}
}

View File

@ -1,5 +1,6 @@
import { substituteParams } from '../../script.js';
import { escapeRegex } from '../utils.js';
import { delay, escapeRegex } from '../utils.js';
import { SlashCommandAbortController } from './SlashCommandAbortController.js';
import { SlashCommandClosureExecutor } from './SlashCommandClosureExecutor.js';
import { SlashCommandClosureResult } from './SlashCommandClosureResult.js';
import { SlashCommandExecutor } from './SlashCommandExecutor.js';
@ -7,13 +8,15 @@ import { SlashCommandScope } from './SlashCommandScope.js';
export class SlashCommandClosure {
/**@type {SlashCommandScope}*/ scope;
/**@type {Boolean}*/ executeNow = false;
/**@type {boolean}*/ executeNow = false;
// @ts-ignore
/**@type {Object.<string,string|SlashCommandClosure>}*/ arguments = {};
// @ts-ignore
/**@type {Object.<string,string|SlashCommandClosure>}*/ providedArguments = {};
/**@type {SlashCommandExecutor[]}*/ executorList = [];
/**@type {String}*/ keptText;
/**@type {string}*/ keptText;
/**@type {SlashCommandAbortController}*/ abortController;
/**@type {(done:number, total:number)=>void}*/ onProgress;
constructor(parent) {
this.scope = new SlashCommandScope(parent);
@ -77,6 +80,8 @@ export class SlashCommandClosure {
closure.providedArguments = this.providedArguments;
closure.executorList = this.executorList;
closure.keptText = this.keptText;
closure.abortController = this.abortController;
closure.onProgress = this.onProgress;
return closure;
}
@ -140,7 +145,10 @@ export class SlashCommandClosure {
this.scope.setVariable(key, v);
}
let done = 0;
for (const executor of this.executorList) {
done += 0.5;
this.onProgress?.(done, this.executorList.length);
if (executor instanceof SlashCommandClosureExecutor) {
const closure = this.scope.getVariable(executor.name);
if (!closure || !(closure instanceof SlashCommandClosure)) throw new Error(`${executor.name} is not a closure.`);
@ -225,11 +233,36 @@ export class SlashCommandClosure {
;
}
let abortResult = await this.testAbortController();
if (abortResult) {
return abortResult;
}
this.scope.pipe = await executor.command.callback(args, value ?? '');
done += 0.5;
this.onProgress?.(done, this.executorList.length);
abortResult = await this.testAbortController();
if (abortResult) {
return abortResult;
}
}
}
/**@type {SlashCommandClosureResult} */
const result = Object.assign(new SlashCommandClosureResult(), { interrupt, newText: this.keptText, pipe: this.scope.pipe });
return result;
}
async testPaused() {
while (!this.abortController?.signal?.aborted && this.abortController?.signal?.paused) {
await delay(200);
}
}
async testAbortController() {
await this.testPaused();
if (this.abortController?.signal?.aborted) {
const result = new SlashCommandClosureResult();
result.isAborted = true;
result.abortReason = this.abortController.signal.reason.toString();
return result;
}
}
}

View File

@ -1,5 +1,9 @@
export class SlashCommandClosureResult {
/**@type {Boolean}*/ interrupt = false;
/**@type {String}*/ newText = '';
/**@type {String}*/ pipe;
/**@type {boolean}*/ interrupt = false;
/**@type {string}*/ newText = '';
/**@type {string}*/ pipe;
/**@type {boolean}*/ isAborted = false;
/**@type {string}*/ abortReason;
/**@type {boolean}*/ isError = false;
/**@type {string}*/ errorMessage;
}

View File

@ -87,6 +87,7 @@ export class SlashCommandParser {
/**@type {string}*/ text;
/**@type {string}*/ keptText;
/**@type {number}*/ index;
/**@type {AbortController}*/ abortController;
/**@type {SlashCommandScope}*/ scope;
/**@type {SlashCommandClosure}*/ closure;
@ -539,11 +540,12 @@ export class SlashCommandParser {
}
parse(text, verifyCommandNames = true, flags = null) {
parse(text, verifyCommandNames = true, flags = null, abortController = null) {
this.verifyCommandNames = verifyCommandNames;
for (const key of Object.keys(PARSER_FLAG)) {
this.flags[PARSER_FLAG[key]] = flags?.[PARSER_FLAG[key]] ?? power_user.stscript.parser.flags[PARSER_FLAG[key]] ?? false;
}
this.abortController = abortController;
this.text = `{:${text}:}`;
this.keptText = '';
this.index = 0;
@ -569,6 +571,7 @@ export class SlashCommandParser {
let injectPipe = true;
this.take(2); // discard opening {:
let closure = new SlashCommandClosure(this.scope);
closure.abortController = this.abortController;
this.scope = closure.scope;
this.closure = closure;
this.discardWhitespace();
@ -775,13 +778,6 @@ export class SlashCommandParser {
value = '';
}
listValues.push(this.parseClosure());
} else if (this.testQuotedValue()) {
isList = true;
if (value.length > 0) {
listValues.push(value.trim());
value = '';
}
listValues.push(this.parseQuotedValue());
} else {
value += this.take();
}

View File

@ -30,12 +30,15 @@ export class SlashCommandParserError extends Error {
let hint = [];
let lines = this.text.slice(start + 1, end - 1).split('\n');
let lineNum = this.line - lines.length + 1;
let tabOffset = 0;
for (const line of lines) {
const num = `${' '.repeat(lineOffset - lineNum.toString().length)}${lineNum}`;
lineNum++;
hint.push(`${num}: ${line}`);
const untabbedLine = line.replace(/\t/g, ' '.repeat(4));
tabOffset = untabbedLine.length - line.length;
hint.push(`${num}: ${untabbedLine}`);
}
hint.push(`${' '.repeat(this.index - lineStart + lineOffset + 1)}^^^^^`);
hint.push(`${' '.repeat(this.index - 2 - lineStart + lineOffset + 1 + tabOffset)}^^^^^`);
return hint.join('\n');
}

View File

@ -15,7 +15,7 @@ import {
import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js';
import { groupCandidatesFilter, groups, selected_group } from './group-chats.js';
import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay } from './utils.js';
import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight } from './utils.js';
import { power_user } from './power-user.js';
export {
@ -350,18 +350,20 @@ function createTagMapFromList(listElement, key) {
* If you have an entity, you can get it's key easily via `getTagKeyForEntity(entity)`.
*
* @param {string} key - The key for which to get tags via the tag map
* @param {boolean} [sort=true] -
* @returns {Tag[]} A list of tags
*/
function getTagsList(key) {
function getTagsList(key, sort = true) {
if (!Array.isArray(tag_map[key])) {
tag_map[key] = [];
return [];
}
return tag_map[key]
const list = tag_map[key]
.map(x => tags.find(y => y.id === x))
.filter(x => x)
.sort(compareTagsForSort);
.filter(x => x);
if (sort) list.sort(compareTagsForSort);
return list;
}
function getInlineListSelector() {
@ -644,6 +646,7 @@ function createNewTag(tagName) {
* @property {Tag} [addTag=undefined] - Optionally provide a tag that should be manually added to this print. Either to the overriden tag list or the found tags based on the entity/key. Will respect the tag exists check.
* @property {object|number|string} [forEntityOrKey=undefined] - Optional override for the chosen entity, otherwise the currently selected is chosen. Can be an entity with id property (character, group, tag), or directly an id or tag key.
* @property {boolean|string} [empty=true] - Whether the list should be initially empty. If a string string is provided, 'always' will always empty the list, otherwise it'll evaluate to a boolean.
* @property {boolean} [sort=true] - Whether the tags should be sorted via the sort function, or kept as is.
* @property {function(object): function} [tagActionSelector=undefined] - An optional override for the action property that can be assigned to each tag via tagOptions.
* If set, the selector is executed on each tag as input argument. This allows a list of tags to be provided and each tag can have it's action based on the tag object itself.
* @property {TagOptions} [tagOptions={}] - Options for tag behavior. (Same object will be passed into "appendTagToList")
@ -655,10 +658,10 @@ function createNewTag(tagName) {
* @param {JQuery<HTMLElement>|string} element - The container element where the tags are to be printed. (Optionally can also be a string selector for the element, which will then be resolved)
* @param {PrintTagListOptions} [options] - Optional parameters for printing the tag list.
*/
function printTagList(element, { tags = undefined, addTag = undefined, forEntityOrKey = undefined, empty = true, tagActionSelector = undefined, tagOptions = {} } = {}) {
function printTagList(element, { tags = undefined, addTag = undefined, forEntityOrKey = undefined, empty = true, sort = true, tagActionSelector = undefined, tagOptions = {} } = {}) {
const $element = (typeof element === 'string') ? $(element) : element;
const key = forEntityOrKey !== undefined ? getTagKeyForEntity(forEntityOrKey) : getTagKey();
let printableTags = tags ? (typeof tags === 'function' ? tags() : tags) : getTagsList(key);
let printableTags = tags ? (typeof tags === 'function' ? tags() : tags) : getTagsList(key, sort);
if (empty === 'always' || (empty && (printableTags?.length > 0 || key))) {
$element.empty();
@ -669,7 +672,7 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity
}
// one last sort, because we might have modified the tag list or manually retrieved it from a function
printableTags = printableTags.sort(compareTagsForSort);
if (sort) printableTags = printableTags.sort(compareTagsForSort);
const customAction = typeof tagActionSelector === 'function' ? tagActionSelector : null;
@ -677,11 +680,21 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity
const expanded = $element.hasClass('tags-expanded') || (expanded_tags_cache.length && expanded_tags_cache.indexOf(key ?? getTagKeyForEntityElement(element)) >= 0);
// We prepare some stuff. No matter which list we have, there is a maximum value of tags we are going to display
const TAGS_LIMIT = 50;
const MAX_TAGS = !expanded ? TAGS_LIMIT : Number.MAX_SAFE_INTEGER;
let totalPrinted = 0;
let hiddenTags = 0;
const filterActive = (/** @type {Tag} */ tag) => tag.filter_state && !isFilterState(tag.filter_state, FILTER_STATES.UNDEFINED);
// Constants to define tag printing limits
const DEFAULT_TAGS_LIMIT = 50;
const tagsDisplayLimit = expanded ? Number.MAX_SAFE_INTEGER : DEFAULT_TAGS_LIMIT;
// Functions to determine tag properties
const isFilterActive = (/** @type {Tag} */ tag) => tag.filter_state && !isFilterState(tag.filter_state, FILTER_STATES.UNDEFINED);
const shouldPrintTag = (/** @type {Tag} */ tag) => isBogusFolder(tag) || isFilterActive(tag);
// Calculating the number of tags to print
const mandatoryPrintTagsCount = printableTags.filter(shouldPrintTag).length;
const availableSlotsForAdditionalTags = Math.max(tagsDisplayLimit - mandatoryPrintTagsCount, 0);
// Counters for printed and hidden tags
let additionalTagsPrinted = 0;
let tagsSkipped = 0;
for (const tag of printableTags) {
// If we have a custom action selector, we override that tag options for each tag
@ -695,16 +708,16 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity
}
// Check if we should print this tag
if (totalPrinted++ < MAX_TAGS || filterActive(tag)) {
if (shouldPrintTag(tag) || additionalTagsPrinted++ < availableSlotsForAdditionalTags) {
appendTagToList($element, tag, tagOptions);
} else {
hiddenTags++;
tagsSkipped++;
}
}
// After the loop, check if we need to add the placeholder.
// The placehold if clicked expands the tags and remembers either via class or cache array which was expanded, so it'll stay expanded until the next reload.
if (hiddenTags > 0) {
if (tagsSkipped > 0) {
const id = 'placeholder_' + uuidv4();
// Add click event
@ -723,7 +736,7 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity
// Print the placeholder object with its styling and action to show the remaining tags
/** @type {Tag} */
const placeholderTag = { id: id, name: '...', title: `${hiddenTags} tags not displayed.\n\nClick to expand remaining tags.`, color: 'transparent', action: showHiddenTags, class: 'placeholder-expander' };
const placeholderTag = { id: id, name: '...', title: `${tagsSkipped} tags not displayed.\n\nClick to expand remaining tags.`, color: 'transparent', action: showHiddenTags, class: 'placeholder-expander' };
// It should never be marked as a removable tag, because it's just an expander action
/** @type {TagOptions} */
const placeholderTagOptions = { ...tagOptions, removable: false };
@ -872,10 +885,10 @@ function printTagFilters(type = tag_filter_types.character) {
// Print all action tags. (Exclude folder if that setting isn't chosen)
const actionTags = Object.values(ACTIONABLE_TAGS).filter(tag => power_user.bogus_folders || tag.id != ACTIONABLE_TAGS.FOLDER.id);
printTagList($(FILTER_SELECTOR), { empty: false, tags: actionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } });
printTagList($(FILTER_SELECTOR), { empty: false, sort: false, tags: actionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } });
const inListActionTags = Object.values(InListActionable);
printTagList($(FILTER_SELECTOR), { empty: false, tags: inListActionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } });
printTagList($(FILTER_SELECTOR), { empty: false, sort: false, tags: inListActionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } });
const characterTagIds = Object.values(tag_map).flat();
const tagsToDisplay = tags.filter(x => characterTagIds.includes(x.id)).sort(compareTagsForSort);
@ -992,11 +1005,11 @@ export function createTagInput(inputSelector, listSelector, tagListOptions = {})
}
function onViewTagsListClick() {
$('#dialogue_popup').addClass('large_dialogue_popup');
const list = $(document.createElement('div'));
list.attr('id', 'tag_view_list');
const everything = Object.values(tag_map).flat();
$(list).append(`
const popup = $('#dialogue_popup');
popup.addClass('large_dialogue_popup');
const html = $(document.createElement('div'));
html.attr('id', 'tag_view_list');
html.append(`
<div class="title_restorable alignItemsBaseline">
<h3>Tag Management</h3>
<div class="flex-container alignItemsBaseline">
@ -1017,25 +1030,57 @@ function onViewTagsListClick() {
</div>
<div class="justifyLeft m-b-1">
<small>
Drag the handle to reorder.<br>
Drag handle to reorder. Click name to rename. Click color to change display.<br>
${(power_user.bogus_folders ? 'Click on the folder icon to use this tag as a folder.<br>' : '')}
Click on the tag name to edit it.<br>
Click on color box to assign new color.
<label class="checkbox flex-container alignitemscenter flexNoGap m-t-1" for="auto_sort_tags">
<input type="checkbox" id="auto_sort_tags" name="auto_sort_tags" ${power_user.auto_sort_tags ? ' checked' : ''} />
<span data-i18n="Use alphabetical sorting">
Use alphabetical sorting
<div class="fa-solid fa-circle-info opacity50p" data-i18n="[title]If enabled, tags will automatically be sorted alphabetically on creation or rename.\nIf disabled, new tags will be appended at the end.\n\nIf a tag is manually reordered by dragging, automatic sorting will be disabled."
title="If enabled, tags will automatically be sorted alphabetically on creation or rename.\nIf disabled, new tags will be appended at the end.\n\nIf a tag is manually reordered by dragging, automatic sorting will be disabled.">
</div>
</span>
</label>
</small>
</div>`);
const tagContainer = $('<div class="tag_view_list_tags ui-sortable"></div>');
list.append(tagContainer);
html.append(tagContainer);
const sortedTags = sortTags(tags);
for (const tag of sortedTags) {
appendViewTagToList(tagContainer, tag, everything);
}
callPopup(html, 'text', null, { allowVerticalScrolling: true });
printViewTagList();
makeTagListDraggable(tagContainer);
callPopup(list, 'text');
$('#dialogue_popup .tag-color').on('change', (evt) => onTagColorize(evt));
$('#dialogue_popup .tag-color2').on('change', (evt) => onTagColorize2(evt));
}
/**
* Print the list of tags in the tag management view
* @param {Event} event Event that triggered the color change
* @param {boolean} toggle State of the toggle
*/
function toggleAutoSortTags(event, toggle) {
if (toggle === power_user.auto_sort_tags) return;
// Ask user to confirm if enabling and it was manually sorted before
if (toggle && isManuallySorted() && !confirm('Are you sure you want to automatically sort alphabetically?')) {
if (event.target instanceof HTMLInputElement) {
event.target.checked = false;
}
return;
}
power_user.auto_sort_tags = toggle;
printCharactersDebounced();
saveSettingsDebounced();
}
/** This function goes over all existing tags and checks whether they were reorderd in the past. @returns {boolean} */
function isManuallySorted() {
return tags.some((tag, index) => tag.sort_order !== index);
}
function makeTagListDraggable(tagContainer) {
@ -1067,6 +1112,13 @@ function makeTagListDraggable(tagContainer) {
}
});
// If tags were dragged manually, we have to disable auto sorting
if (power_user.auto_sort_tags) {
power_user.auto_sort_tags = false;
$('#dialogue_popup input[name="auto_sort_tags"]').prop('checked', false);
toastr.info('Automatic sorting of tags deactivated.');
}
// If the order of tags in display has changed, we need to redraw some UI elements. Do it debounced so it doesn't block and you can drag multiple tags.
printCharactersDebounced();
saveSettingsDebounced();
@ -1098,6 +1150,11 @@ function sortTags(tags) {
* @returns {number} The compare result
*/
function compareTagsForSort(a, b) {
const defaultSort = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
if (power_user.auto_sort_tags) {
return defaultSort;
}
if (a.sort_order !== undefined && b.sort_order !== undefined) {
return a.sort_order - b.sort_order;
} else if (a.sort_order !== undefined) {
@ -1105,7 +1162,7 @@ function compareTagsForSort(a, b) {
} else if (b.sort_order !== undefined) {
return 1;
} else {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
return defaultSort;
}
}
@ -1208,7 +1265,10 @@ function onTagsBackupClick() {
function onTagCreateClick() {
const tag = createNewTag('New Tag');
appendViewTagToList($('#tag_view_list .tag_view_list_tags'), tag, []);
printViewTagList();
const tagElement = ($('#dialogue_popup .tag_view_list_tags')).find(`.tag_view_item[id="${tag.id}"]`);
flashHighlight(tagElement);
printCharactersDebounced();
saveSettingsDebounced();
@ -1248,18 +1308,6 @@ function appendViewTagToList(list, tag, everything) {
list.append(template);
setTimeout(function () {
document.querySelector(`.tag-color[id="${colorPickerId}"`).addEventListener('change', (evt) => {
onTagColorize(evt);
});
}, 100);
setTimeout(function () {
document.querySelector(`.tag-color2[id="${colorPicker2Id}"`).addEventListener('change', (evt) => {
onTagColorize2(evt);
});
}, 100);
updateDrawTagFolder(template, tag);
// @ts-ignore
@ -1394,6 +1442,17 @@ function copyTags(data) {
tag_map[data.newAvatar] = Array.from(new Set([...prevTagMap, ...newTagMap]));
}
function printViewTagList(empty = true) {
const tagContainer = $('#dialogue_popup .tag_view_list_tags');
if (empty) tagContainer.empty();
const everything = Object.values(tag_map).flat();
const sortedTags = sortTags(tags);
for (const tag of sortedTags) {
appendViewTagToList(tagContainer, tag, everything);
}
}
export function initTags() {
createTagInput('#tagInput', '#tagList', { tagOptions: { removable: true } });
createTagInput('#groupTagInput', '#groupTagList', { tagOptions: { removable: true } });
@ -1412,4 +1471,31 @@ export function initTags() {
$(document).on('click', '.tag_view_backup', onTagsBackupClick);
$(document).on('click', '.tag_view_restore', onBackupRestoreClick);
eventSource.on(event_types.CHARACTER_DUPLICATED, copyTags);
$(document).on('input', '#dialogue_popup input[name="auto_sort_tags"]', (evt) => {
const toggle = $(evt.target).is(':checked');
toggleAutoSortTags(evt.originalEvent, toggle);
printViewTagList();
});
$(document).on('focusout', '#dialogue_popup .tag_view_name', (evt) => {
// Remember the order, so we can flash highlight if it changed after reprinting
const tagId = $(evt.target).parent('.tag_view_item').attr('id');
const oldOrder = $('#dialogue_popup .tag_view_item').map((_, el) => el.id).get();
printViewTagList();
const newOrder = $('#dialogue_popup .tag_view_item').map((_, el) => el.id).get();
const orderChanged = !oldOrder.every((id, index) => id === newOrder[index]);
if (orderChanged) {
flashHighlight($(`#dialogue_popup .tag_view_item[id="${tagId}"]`));
}
});
// Initialize auto sort setting based on whether it was sorted before
if (power_user.auto_sort_tags === undefined || power_user.auto_sort_tags === null) {
power_user.auto_sort_tags = !isManuallySorted();
if (power_user.auto_sort_tags) {
printCharactersDebounced();
}
}
}

View File

@ -2,6 +2,7 @@ import { getContext } from './extensions.js';
import { getRequestHeaders } from '../script.js';
import { isMobile } from './RossAscends-mods.js';
import { collapseNewlines } from './power-user.js';
import { debounce_timeout } from './constants.js';
/**
* Pagination status string template.
@ -256,10 +257,10 @@ export function getStringHash(str, seed = 0) {
/**
* Creates a debounced function that delays invoking func until after wait milliseconds have elapsed since the last time the debounced function was invoked.
* @param {function} func The function to debounce.
* @param {number} [timeout=300] The timeout in milliseconds.
* @param {debounce_timeout|number} [timeout=debounce_timeout.default] The timeout based on the common enum values, or in milliseconds.
* @returns {function} The debounced function.
*/
export function debounce(func, timeout = 300) {
export function debounce(func, timeout = debounce_timeout.standard) {
let timer;
return (...args) => {
clearTimeout(timer);
@ -1419,3 +1420,13 @@ export function setValueByPath(obj, path, value) {
currentObject[keyParts[keyParts.length - 1]] = value;
}
/**
* Flashes the given HTML element via CSS flash animation for a defined period
* @param {JQuery<HTMLElement>} element - The element to flash
* @param {number} timespan - A numer in milliseconds how the flash should last
*/
export function flashHighlight(element, timespan = 2000) {
element.addClass('flash animated');
setTimeout(() => element.removeClass('flash animated'), timespan);
}

View File

@ -1,6 +1,6 @@
import { chat_metadata, getCurrentChatId, saveSettingsDebounced, sendSystemMessage, system_message_types } from '../script.js';
import { extension_settings, saveMetadataDebounced } from './extensions.js';
import { executeSlashCommands, registerSlashCommand } from './slash-commands.js';
import { executeSlashCommands } from './slash-commands.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
@ -15,7 +15,7 @@ function getLocalVariable(name, args = {}) {
chat_metadata.variables = {};
}
let localVariable = chat_metadata?.variables[name];
let localVariable = chat_metadata?.variables[args.key ?? name];
if (args.index !== undefined) {
try {
localVariable = JSON.parse(localVariable);
@ -68,7 +68,7 @@ function setLocalVariable(name, value, args = {}) {
}
function getGlobalVariable(name, args = {}) {
let globalVariable = extension_settings.variables.global[name];
let globalVariable = extension_settings.variables.global[args.key ?? name];
if (args.index !== undefined) {
try {
globalVariable = JSON.parse(globalVariable);
@ -706,7 +706,7 @@ function randValuesCallback(from, to, args) {
/**
* Declare a new variable in the current scope.
* @param {{_scope:SlashCommandScope}} args Named arguments.
* @param {{_scope:SlashCommandScope, key?:string}} args Named arguments.
* @param {String|[String, SlashCommandClosure]} value Name and optional value for the variable.
* @returns The variable's value
*/
@ -715,7 +715,12 @@ function letCallback(args, value) {
args._scope.letVariable(value[0], value[1]);
return value[1];
}
if (value.includes(' ')) {
if (args.key !== undefined) {
const key = args.key;
const val = value;
args._scope.letVariable(key, val);
return val;
} else if (value.includes(' ')) {
const key = value.split(' ')[0];
const val = value.split(' ').slice(1).join(' ');
args._scope.letVariable(key, val);
@ -726,22 +731,27 @@ function letCallback(args, value) {
/**
* Set or retrieve a variable in the current scope or nearest ancestor scope.
* @param {{_scope:SlashCommandScope, index:(String|Number)?}} args Named arguments.
* @param {{_scope:SlashCommandScope, key?:string, index?:String|Number}} args Named arguments.
* @param {String|[String, SlashCommandClosure]} value Name and optional value for the variable.
* @returns The variable's value
*/
function varCallback(args, value) {
if (Array.isArray(value)) {
args._scope.setVariable(value[0], value[1]);
args._scope.setVariable(value[0], value[1], args.index);
return value[1];
}
if (value.includes(' ')) {
if (args.key !== undefined) {
const key = args.key;
const val = value;
args._scope.setVariable(key, val, args.index);
return val;
} else if (value.includes(' ')) {
const key = value.split(' ')[0];
const val = value.split(' ').slice(1).join(' ');
args._scope.setVariable(key, val, args.index);
return val;
}
return args._scope.getVariable(value, args.index);
return args._scope.getVariable(args.key ?? value, args.index);
}
export function registerVariableCommands() {
@ -783,13 +793,16 @@ export function registerVariableCommands() {
callback: (args, value) => getLocalVariable(value, args),
returns: 'the variable value',
namedArgumentList: [
new SlashCommandNamedArgument(
'key', 'variable name', [ARGUMENT_TYPE.VARIABLE_NAME], false,
),
new SlashCommandNamedArgument(
'index', 'list index', [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], false,
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'key', [ARGUMENT_TYPE.VARIABLE_NAME], true,
'key', [ARGUMENT_TYPE.VARIABLE_NAME], false,
),
],
helpString: `
@ -802,6 +815,9 @@ export function registerVariableCommands() {
<li>
<pre><code class="language-stscript">/getvar height</code></pre>
</li>
<li>
<pre><code class="language-stscript">/getvar key=height</code></pre>
</li>
<li>
<pre><code class="language-stscript">/getvar index=3 costumes</code></pre>
</li>
@ -870,13 +886,16 @@ export function registerVariableCommands() {
callback: (args, value) => getGlobalVariable(value, args),
returns: 'global variable value',
namedArgumentList: [
new SlashCommandNamedArgument(
'key', 'variable name', [ARGUMENT_TYPE.VARIABLE_NAME], false,
),
new SlashCommandNamedArgument(
'index', 'list index', [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], false,
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'key', [ARGUMENT_TYPE.VARIABLE_NAME], true,
'key', [ARGUMENT_TYPE.VARIABLE_NAME], false,
),
],
helpString: `
@ -889,6 +908,9 @@ export function registerVariableCommands() {
<li>
<pre><code class="language-stscript">/getglobalvar height</code></pre>
</li>
<li>
<pre><code class="language-stscript">/getglobalvar key=height</code></pre>
</li>
<li>
<pre><code class="language-stscript">/getglobalvar index=3 costumes</code></pre>
</li>
@ -1623,6 +1645,9 @@ export function registerVariableCommands() {
callback: (args, value) => varCallback(args, value),
returns: 'the variable value',
namedArgumentList: [
new SlashCommandNamedArgument(
'key', 'variable name', [ARGUMENT_TYPE.VARIABLE_NAME], false,
),
new SlashCommandNamedArgument(
'index',
'optional index for list or dictionary',
@ -1635,7 +1660,7 @@ export function registerVariableCommands() {
new SlashCommandArgument(
'variable name',
[ARGUMENT_TYPE.VARIABLE_NAME],
true, // isRequired
false, // isRequired
false, // acceptsMultiple
),
new SlashCommandArgument(
@ -1655,6 +1680,9 @@ export function registerVariableCommands() {
<li>
<pre><code class="language-stscript">/let x foo | /var x foo bar | /var x | /echo</code></pre>
</li>
<li>
<pre><code class="language-stscript">/let x foo | /var key=x foo bar | /var key=x | /echo</code></pre>
</li>
</ul>
</div>
`,
@ -1662,10 +1690,14 @@ export function registerVariableCommands() {
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'let',
callback: (args, value) => letCallback(args, value),
returns: 'the variable value',
namedArgumentList: [],
namedArgumentList: [
new SlashCommandNamedArgument(
'key', 'variable name', [ARGUMENT_TYPE.VARIABLE_NAME], false,
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'variable name', [ARGUMENT_TYPE.VARIABLE_NAME], true,
'variable name', [ARGUMENT_TYPE.VARIABLE_NAME], false,
),
new SlashCommandArgument(
'variable value', [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.BOOLEAN, ARGUMENT_TYPE.LIST, ARGUMENT_TYPE.DICTIONARY, ARGUMENT_TYPE.CLOSURE],
@ -1681,6 +1713,9 @@ export function registerVariableCommands() {
<li>
<pre><code class="language-stscript">/let x foo bar | /echo {{var::x}}</code></pre>
</li>
<li>
<pre><code class="language-stscript">/let key=x foo bar | /echo {{var::x}}</code></pre>
</li>
<li>
<pre><code class="language-stscript">/let y</code></pre>
</li>

View File

@ -1,5 +1,5 @@
import { saveSettings, callPopup, substituteParams, getRequestHeaders, chat_metadata, this_chid, characters, saveCharacterDebounced, menu_type, eventSource, event_types, getExtensionPromptByName, saveMetadata, getCurrentChatId, extension_prompt_roles } from '../script.js';
import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath } from './utils.js';
import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight } from './utils.js';
import { extension_settings, getContext } from './extensions.js';
import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js';
import { registerSlashCommand } from './slash-commands.js';
@ -9,6 +9,7 @@ import { getTokenCountAsync } from './tokenizers.js';
import { power_user } from './power-user.js';
import { getTagKeyForEntity } from './tags.js';
import { resolveVariable } from './variables.js';
import { debounce_timeout } from './constants.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';
@ -61,11 +62,11 @@ let world_info_case_sensitive = false;
let world_info_match_whole_words = false;
let world_info_character_strategy = world_info_insertion_strategy.character_first;
let world_info_budget_cap = 0;
const saveWorldDebounced = debounce(async (name, data) => await _save(name, data), 1000);
const saveWorldDebounced = debounce(async (name, data) => await _save(name, data), debounce_timeout.relaxed);
const saveSettingsDebounced = debounce(() => {
Object.assign(world_info, { globalSelect: selected_world_info });
saveSettings();
}, 1000);
}, debounce_timeout.relaxed);
const sortFn = (a, b) => b.order - a.order;
let updateEditor = (navigation) => { console.debug('Triggered WI navigation', navigation); };
@ -525,7 +526,7 @@ function registerWorldInfoSlashCommands() {
return '';
}
const entry = createWorldInfoEntry(file, data, true);
const entry = createWorldInfoEntry(file, data);
if (key) {
entry.key.push(key);
@ -955,20 +956,20 @@ function displayWorldEntries(name, data, navigation = navigation_option.none) {
callback: function (/** @type {object[]} */ page) {
$('#world_popup_entries_list').empty();
const keywordHeaders = `
<div id="WIEntryHeaderTitlesPC" class="flex-container wide100p spaceBetween justifyCenter textAlignCenter" style="padding:0 2.5em;">
<div id="WIEntryHeaderTitlesPC" class="flex-container wide100p spaceBetween justifyCenter textAlignCenter" style="padding:0 4.5em;">
<small class="flex1">
Title/Memo
</small>
<small style="width: calc(3.5em + 5px)">
<small style="width: calc(3.5em + 15px)">
Status
</small>
<small style="width: calc(3.5em + 20px)">
<small style="width: calc(3.5em + 30px)">
Position
</small>
<small style="width: calc(3.5em + 15px)">
<small style="width: calc(3.5em + 20px)">
Depth
</small>
<small style="width: calc(3.5em + 15px)">
<small style="width: calc(3.5em + 20px)">
Order
</small>
<small style="width: calc(3.5em + 15px)">
@ -1014,13 +1015,13 @@ function displayWorldEntries(name, data, navigation = navigation_option.none) {
const parentOffset = element.parent().offset();
const scrollOffset = elementOffset.top - parentOffset.top;
$('#WorldInfo').scrollTop(scrollOffset);
element.addClass('flash animated');
setTimeout(() => element.removeClass('flash animated'), 2000);
flashHighlight(element);
});
}
$('#world_popup_new').off('click').on('click', () => {
createWorldInfoEntry(name, data);
const entry = createWorldInfoEntry(name, data);
if (entry) updateEditor(entry.uid);
});
$('#world_popup_name_button').off('click').on('click', async () => {
@ -1152,6 +1153,7 @@ const originalDataKeyMap = {
'scanDepth': 'extensions.scan_depth',
'automationId': 'extensions.automation_id',
'vectorized': 'extensions.vectorized',
'groupOverride': 'extensions.group_override',
};
function setOriginalDataValue(data, uid, key, value) {
@ -1390,7 +1392,7 @@ function getWorldEntry(name, data, entry) {
const countTokensDebounced = debounce(async function (counter, value) {
const numberOfTokens = await getTokenCountAsync(value);
$(counter).text(numberOfTokens);
}, 1000);
}, debounce_timeout.relaxed);
const contentInput = template.find('textarea[name="content"]');
contentInput.data('uid', entry.uid);
@ -1498,6 +1500,18 @@ function getWorldEntry(name, data, entry) {
groupInput.val(entry.group ?? '').trigger('input');
setTimeout(() => createEntryInputAutocomplete(groupInput, getInclusionGroupCallback(data)), 1);
// inclusion priority
const groupOverrideInput = template.find('input[name="groupOverride"]');
groupOverrideInput.data('uid', entry.uid);
groupOverrideInput.on('input', function () {
const uid = $(this).data('uid');
const value = $(this).prop('checked');
data.entries[uid].groupOverride = value;
setOriginalDataValue(data, uid, 'extensions.groupOverride', data.entries[uid].groupOverride);
saveWorldInfo(name, data);
});
groupOverrideInput.prop('checked', entry.groupOverride).trigger('input');
// probability
if (entry.probability === undefined) {
entry.probability = null;
@ -1732,6 +1746,18 @@ function getWorldEntry(name, data, entry) {
});
preventRecursionInput.prop('checked', entry.preventRecursion).trigger('input');
// duplicate button
const duplicateButton = template.find('.duplicate_entry_button');
duplicateButton.data('uid', entry.uid);
duplicateButton.on('click', function () {
const uid = $(this).data('uid');
const entry = duplicateWorldInfoEntry(data, uid);
if (entry) {
saveWorldInfo(name, data);
updateEditor(entry.uid);
}
});
// delete button
const deleteButton = template.find('.delete_entry_button');
deleteButton.data('uid', entry.uid);
@ -1916,7 +1942,33 @@ function createEntryInputAutocomplete(input, callback) {
});
}
async function deleteWorldInfoEntry(data, uid) {
/**
* Duplicated a WI entry by copying all of its properties and assigning a new uid
* @param {*} data - The data of the book
* @param {number} uid - The uid of the entry to copy in this book
* @returns {*} The new WI duplicated entry
*/
function duplicateWorldInfoEntry(data, uid) {
if (!data || !('entries' in data) || !data.entries[uid]) {
return;
}
// Exclude uid and gather the rest of the properties
const { uid: _, ...originalData } = data.entries[uid];
// Create new entry and copy over data
const entry = createWorldInfoEntry(data.name, data);
Object.assign(entry, originalData);
return entry;
}
/**
* Deletes a WI entry, with a user confirmation dialog
* @param {*[]} data - The data of the book
* @param {number} uid - The uid of the entry to copy in this book
*/
function deleteWorldInfoEntry(data, uid) {
if (!data || !('entries' in data)) {
return;
}
@ -1946,6 +1998,7 @@ const newEntryTemplate = {
useProbability: true,
depth: DEFAULT_DEPTH,
group: '',
groupOverride: false,
scanDepth: null,
caseSensitive: null,
matchWholeWords: null,
@ -1953,7 +2006,7 @@ const newEntryTemplate = {
role: 0,
};
function createWorldInfoEntry(name, data, fromSlashCommand = false) {
function createWorldInfoEntry(name, data) {
const newUid = getFreeWorldEntryUid(data);
if (!Number.isInteger(newUid)) {
@ -1964,10 +2017,6 @@ function createWorldInfoEntry(name, data, fromSlashCommand = false) {
const newEntry = { uid: newUid, ...structuredClone(newEntryTemplate) };
data.entries[newUid] = newEntry;
if (!fromSlashCommand) {
updateEditor(newUid);
}
return newEntry;
}
@ -2402,7 +2451,7 @@ async function checkWorldInfo(chat, maxContext) {
for (const entry of newEntries) {
const rollValue = Math.random() * 100;
if (entry.useProbability && rollValue > entry.probability) {
if (!entry.group && entry.useProbability && rollValue > entry.probability) {
console.debug(`WI entry ${entry.uid} ${entry.key} failed probability check, skipping`);
failedProbabilityChecks.add(entry);
continue;
@ -2536,15 +2585,25 @@ function filterByInclusionGroups(newEntries, allActivatedEntries) {
return;
}
const removeEntry = (entry) => newEntries.splice(newEntries.indexOf(entry), 1);
function removeAllBut(group, chosen, logging = true) {
for (const entry of group) {
if (entry === chosen) {
continue;
}
if (logging) console.debug(`Removing loser from inclusion group '${entry.group}' entry '${entry.uid}'`, entry);
removeEntry(entry);
}
}
for (const [key, group] of Object.entries(grouped)) {
console.debug(`Checking inclusion group '${key}' with ${group.length} entries`, group);
if (Array.from(allActivatedEntries).some(x => x.group === key)) {
console.debug(`Skipping inclusion group check, group already activated '${key}'`);
// We need to forcefully deactivate all other entries in the group
for (const entry of group) {
newEntries.splice(newEntries.indexOf(entry), 1);
}
removeAllBut(group, null, false);
continue;
}
@ -2553,6 +2612,14 @@ function filterByInclusionGroups(newEntries, allActivatedEntries) {
continue;
}
// Check for group prio
const prios = group.filter(x => x.groupOverride).sort(sortFn);
if (prios.length) {
console.debug(`Activated inclusion group '${key}' with by prio winner entry '${prios[0].uid}'`, prios[0]);
removeAllBut(group, prios[0]);
continue;
}
// Do weighted random using probability of entry as weight
const totalWeight = group.reduce((acc, item) => acc + item.probability, 0);
const rollValue = Math.random() * totalWeight;
@ -2563,7 +2630,7 @@ function filterByInclusionGroups(newEntries, allActivatedEntries) {
currentWeight += entry.probability;
if (rollValue <= currentWeight) {
console.debug(`Activated inclusion group '${key}' with entry '${entry.uid}'`, entry);
console.debug(`Activated inclusion group '${key}' with roll winner entry '${entry.uid}'`, entry);
winner = entry;
break;
}
@ -2575,14 +2642,7 @@ function filterByInclusionGroups(newEntries, allActivatedEntries) {
}
// Remove every group item from newEntries but the winner
for (const entry of group) {
if (entry === winner) {
continue;
}
console.debug(`Removing loser from inclusion group '${key}' entry '${entry.uid}'`, entry);
newEntries.splice(newEntries.indexOf(entry), 1);
}
removeAllBut(group, winner);
}
}
@ -2610,11 +2670,12 @@ function convertAgnaiMemoryBook(inputObj) {
probability: null,
useProbability: false,
group: '',
scanDepth: entry.extensions?.scan_depth ?? null,
caseSensitive: entry.extensions?.case_sensitive ?? null,
matchWholeWords: entry.extensions?.match_whole_words ?? null,
automationId: entry.extensions?.automation_id ?? '',
role: entry.extensions?.role ?? extension_prompt_roles.SYSTEM,
groupOverride: false,
scanDepth: null,
caseSensitive: null,
matchWholeWords: null,
automationId: '',
role: extension_prompt_roles.SYSTEM,
};
});
@ -2645,11 +2706,12 @@ function convertRisuLorebook(inputObj) {
probability: entry.activationPercent ?? null,
useProbability: entry.activationPercent ?? false,
group: '',
scanDepth: entry.extensions?.scan_depth ?? null,
caseSensitive: entry.extensions?.case_sensitive ?? null,
matchWholeWords: entry.extensions?.match_whole_words ?? null,
automationId: entry.extensions?.automation_id ?? '',
role: entry.extensions?.role ?? extension_prompt_roles.SYSTEM,
groupOverride: false,
scanDepth: null,
caseSensitive: null,
matchWholeWords: null,
automationId: '',
role: extension_prompt_roles.SYSTEM,
};
});
@ -2685,11 +2747,12 @@ function convertNovelLorebook(inputObj) {
probability: null,
useProbability: false,
group: '',
scanDepth: entry.extensions?.scan_depth ?? null,
caseSensitive: entry.extensions?.case_sensitive ?? null,
matchWholeWords: entry.extensions?.match_whole_words ?? null,
automationId: entry.extensions?.automation_id ?? '',
role: entry.extensions?.role ?? extension_prompt_roles.SYSTEM,
groupOverride: false,
scanDepth: null,
caseSensitive: null,
matchWholeWords: null,
automationId: '',
role: extension_prompt_roles.SYSTEM,
};
});
@ -2726,6 +2789,7 @@ function convertCharacterBook(characterBook) {
depth: entry.extensions?.depth ?? DEFAULT_DEPTH,
selectiveLogic: entry.extensions?.selectiveLogic ?? world_info_logic.AND_ANY,
group: entry.extensions?.group ?? '',
groupOverride: entry.extensions?.group_override ?? false,
scanDepth: entry.extensions?.scan_depth ?? null,
caseSensitive: entry.extensions?.case_sensitive ?? null,
matchWholeWords: entry.extensions?.match_whole_words ?? null,
@ -3135,9 +3199,12 @@ jQuery(() => {
}
});
const debouncedWorldInfoSearch = debounce((searchQuery) => {
worldInfoFilter.setFilterData(FILTER_TYPES.WORLD_INFO_SEARCH, searchQuery);
});
$('#world_info_search').on('input', function () {
const term = $(this).val();
worldInfoFilter.setFilterData(FILTER_TYPES.WORLD_INFO_SEARCH, term);
const searchQuery = $(this).val();
debouncedWorldInfoSearch(searchQuery);
});
$('#world_refresh').on('click', () => {

View File

@ -260,6 +260,7 @@ table.responsiveTable {
.mes_text table {
border-spacing: 0;
border-collapse: collapse;
margin-bottom: 10px;
}
.mes_text td,
@ -623,7 +624,6 @@ body .panelControlBar {
flex-direction: row;
column-gap: 5px;
font-size: var(--bottomFormIconSize);
overflow: hidden;
order: 25;
width: 100%;
}
@ -685,6 +685,64 @@ body .panelControlBar {
opacity: 0.7;
}
#form_sheld.isExecutingCommandsFromChatInput {
#send_but {
visibility: hidden;
}
#rightSendForm > div:not(.mes_send).stscript_btn {
&.stscript_pause, &.stscript_stop {
display: flex;
}
}
&.paused {
#rightSendForm > div:not(.mes_send).stscript_btn {
&.stscript_pause {
display: none;
}
&.stscript_continue {
display: flex;
}
}
}
}
#rightSendForm > div:not(.mes_send) {
&.stscript_btn {
padding-right: 2px;
place-self: center;
cursor: pointer;
transition: 0.3s;
opacity: 1;
display: none;
&.stscript_pause > .fa-solid {
background-color: rgb(146, 190, 252);
}
&.stscript_continue > .fa-solid {
background-color: rgb(146, 190, 252);
}
&.stscript_stop > .fa-solid {
background-color: rgb(215, 136, 114);
}
> .fa-solid {
--toastInfoColor: #2F96B4;
--progColor: rgba(0, 128, 0, 0.839);
border-radius: 35%;
border: 0 solid var(--progColor);
aspect-ratio: 1 / 1;
display: flex;
color: rgb(24 24 24);
font-size: 0.5em;
height: var(--bottomFormIconSize);
justify-content: center;
align-items: center;
box-shadow:
0 0 0 var(--progColor),
0 0 0 var(--progColor)
;
}
}
}
#options_button {
width: var(--bottomFormBlockSize);
height: var(--bottomFormBlockSize);
@ -1001,6 +1059,7 @@ body .panelControlBar {
padding-left: 10px;
width: 100%;
overflow-x: hidden;
overflow-y: clip;
}
.mes_text {
@ -1062,6 +1121,51 @@ select {
text-shadow: 0px 0px calc(var(--shadowWidth) * 1px) var(--SmartThemeShadowColor);
flex: 1;
order: 3;
--progColor: rgb(146, 190, 252);
--progFlashColor: rgb(215, 136, 114);
--progSuccessColor: rgb(81, 163, 81);
--progErrorColor: rgb(189, 54, 47);
--progAbortedColor: rgb(215, 136, 114);
--progWidth: 3px;
--progWidthClip: calc(var(--progWidth) + 2px);
--prog: 0%;
--progDone: 0;
border-top: var(--progWidth) solid var(--progColor);
clip-path: polygon(
0% calc(var(--progDone) * var(--progWidthClip)),
var(--prog) calc(var(--progDone) * var(--progWidthClip)),
var(--prog) var(--progWidthClip),
100% var(--progWidthClip),
100% 100%,
0% 100%
);
transition: clip-path 200ms;
}
@keyframes script_progress_pulse {
0%, 100% {
border-top-color: var(--progColor);
}
50% {
border-top-color: var(--progFlashColor);
}
}
#form_sheld.isExecutingCommandsFromChatInput.script_paused #send_textarea {
animation-name: script_progress_pulse;
animation-duration: 1500ms;
animation-timing-function: ease-in-out;
animation-delay: 0s;
animation-iteration-count: infinite;
}
#form_sheld.script_success #send_textarea {
border-top-color: var(--progSuccessColor);
}
#form_sheld.script_error #send_textarea {
border-top-color: var(--progErrorColor);
}
#form_sheld.script_aborted #send_textarea {
border-top-color: var(--progAbortedColor);
}
.slashCommandAutoComplete-wrap {
@ -1312,12 +1416,11 @@ body[data-stscript-style] .hljs.language-stscript {
.slashCommandAutoComplete {
padding-bottom: 1px;
/* position: absolute; */
display: grid;
grid-template-columns: 0fr auto minmax(50%, 1fr);
align-items: center;
max-height: calc(95vh - var(--bottom));
/* gap: 0.5em; */
container-type: inline-size;
> .item {
cursor: pointer;
padding: 3px;
@ -1326,6 +1429,16 @@ body[data-stscript-style] .hljs.language-stscript {
gap: 0.5em;
font-size: 0.8em;
display: contents;
@container (max-width: 1000px) {
.specs {
grid-column: 2 / 4;
}
.help {
grid-column: 2 / 4;
padding-left: 1em;
opacity: 0.75;
}
}
&.blank {
display: block;
grid-column: 1 / 4;
@ -1595,16 +1708,10 @@ body[data-stscript-style] .hljs.language-stscript {
@media screen and (max-width: 1000px) {
.slashCommandAutoComplete-wrap {
left: 1vw;
> .slashCommandAutoComplete {
.specs {
grid-column: 2 / 4;
}
.help {
grid-column: 2 / 4;
padding-left: 1em;
opacity: 0.75;
}
}
right: 1vw;
}
.slashCommandAutoComplete-detailsWrap:not(.full) {
display: none;
}
}
.slashCommandBrowser {
@ -3212,6 +3319,72 @@ input[type="range"]::-webkit-slider-thumb {
background: var(--white100);
}
.doubleRangeContainer {
display: flex;
--markerWidth: 15px;
> .doubleRangeInputContainer {
flex: 0 0 50%;
overflow: hidden;
position: relative;
> datalist {
display: flex;
flex-direction: row;
justify-content: space-between;
font-size: x-small;
> option {
flex: 0 0 0;
width: 0;
display: flex;
justify-content: center;
}
}
> input::-webkit-slider-thumb {
z-index: 2;
}
&:after {
content: '';
position: absolute;
top: 11px;
width: calc(var(--value) * (100% - 1em - max(20px, 20%)) / 2 + max(20px, 20%));
height: 5px;
background-color: var(--SmartThemeQuoteColor);
box-shadow: inset 0 0 2px black;
}
&:nth-child(1) {
--value: 0;
padding-left: 1em;
> input {
direction: rtl;
position: relative;
padding-right: max(20px, 20%);
}
> datalist {
direction: rtl;
padding-right: calc(var(--markerWidth)/2 + max(20px, 20%));
padding-left: calc(var(--markerWidth)/2 - 2px);
}
&:after {
right: -2px;
}
}
&:nth-child(2) {
--value: 0;
padding-right: 1em;
> input {
position: relative;
padding-left: max(20px, 20%);
}
> datalist {
padding-left: calc(var(--markerWidth)/2 + max(20px, 20%));
padding-right: calc(var(--markerWidth)/2 - 2px);
}
&:after {
left: -2px;
}
}
}
}
/*Notes '?' links*/
.note-link-span {
@ -3762,6 +3935,15 @@ body.big-avatars .missing-avatar {
text-align: center;
}
.userSettingsInnerExpandable {
border: 1px solid;
border-color: var(--SmartThemeBorderColor);
border-radius: 5px;
padding: 2px 5px !important;
margin: 5px 0;
}
@keyframes ellipsis {
0% {
content: ""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -45,7 +45,6 @@ const {
forwardFetchResponse,
} = require('./src/util');
const { ensureThumbnailCache } = require('./src/endpoints/thumbnails');
const { loadTokenizers } = require('./src/endpoints/tokenizers');
// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.
// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
@ -287,6 +286,7 @@ app.use(userModule.requireLoginMiddleware);
// File uploads
app.use(multer({ dest: UPLOADS_PATH, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar'));
app.use(require('./src/middleware/multerMonkeyPatch'));
// User data mount
app.use('/', userModule.router);
@ -530,7 +530,10 @@ const autorunUrl = new URL(
(':' + server_port),
);
const setupTasks = async function () {
/**
* Tasks that need to be run before the server starts listening.
*/
const preSetupTasks = async function () {
const version = await getVersion();
// Print formatted header
@ -543,28 +546,21 @@ const setupTasks = async function () {
}
console.log();
// TODO: do endpoint init functions depend on certain directories existing or not existing? They should be callable
// in any order for encapsulation reasons, but right now it's unknown if that would break anything.
await userModule.initUserStorage(dataRoot);
if (listen && !basicAuthMode && enableAccounts) {
await userModule.checkAccountsProtection();
}
await settingsEndpoint.init();
const directories = await userModule.ensurePublicDirectoriesExist();
await userModule.migrateUserData();
const directories = await userModule.getUserDirectoriesList();
await contentManager.checkForNewContent(directories);
await ensureThumbnailCache();
cleanUploads();
await loadTokenizers();
await settingsEndpoint.init();
await statsEndpoint.init();
const cleanupPlugins = await loadPlugins();
const consoleTitle = process.title;
let isExiting = false;
const exitProcess = async () => {
if (isExiting) return;
isExiting = true;
statsEndpoint.onExit();
if (typeof cleanupPlugins === 'function') {
await cleanupPlugins();
@ -580,8 +576,12 @@ const setupTasks = async function () {
console.error('Uncaught exception:', err);
exitProcess();
});
};
/**
* Tasks that need to be run after the server starts listening.
*/
const postSetupTasks = async function () {
console.log('Launching...');
if (autorun) open(autorunUrl.toString());
@ -601,6 +601,9 @@ const setupTasks = async function () {
}
}
if (listen && !basicAuthMode && enableAccounts) {
await userModule.checkAccountsProtection();
}
};
/**
@ -642,21 +645,28 @@ function setWindowTitle(title) {
}
}
if (cliArguments.ssl) {
https.createServer(
{
cert: fs.readFileSync(cliArguments.certPath),
key: fs.readFileSync(cliArguments.keyPath),
}, app)
.listen(
Number(tavernUrl.port) || 443,
tavernUrl.hostname,
setupTasks,
);
} else {
http.createServer(app).listen(
Number(tavernUrl.port) || 80,
tavernUrl.hostname,
setupTasks,
);
}
// User storage module needs to be initialized before starting the server
userModule.initUserStorage(dataRoot)
.then(userModule.ensurePublicDirectoriesExist)
.then(userModule.migrateUserData)
.then(preSetupTasks)
.finally(() => {
if (cliArguments.ssl) {
https.createServer(
{
cert: fs.readFileSync(cliArguments.certPath),
key: fs.readFileSync(cliArguments.keyPath),
}, app)
.listen(
Number(tavernUrl.port) || 443,
tavernUrl.hostname,
postSetupTasks,
);
} else {
http.createServer(app).listen(
Number(tavernUrl.port) || 80,
tavernUrl.hostname,
postSetupTasks,
);
}
});

View File

@ -433,6 +433,7 @@ function convertWorldInfoToCharacterBook(name, entries) {
depth: entry.depth ?? 4,
selectiveLogic: entry.selectiveLogic ?? 0,
group: entry.group ?? '',
group_override: entry.groupOverride ?? false,
prevent_recursion: entry.preventRecursion ?? false,
scan_depth: entry.scanDepth ?? null,
match_whole_words: entry.matchWholeWords ?? null,

View File

@ -5,6 +5,7 @@ const fetch = require('node-fetch').default;
const sanitize = require('sanitize-filename');
const { getConfigValue } = require('../util');
const { jsonParser } = require('../express-common');
const writeFileAtomicSync = require('write-file-atomic').sync;
const contentDirectory = path.join(process.cwd(), 'default/content');
const contentIndexPath = path.join(contentDirectory, 'index.json');
const characterCardParser = require('../character-card-parser.js');
@ -133,7 +134,7 @@ async function seedContentForUser(contentIndex, directories, forceCategories) {
console.log(`Content file ${contentItem.filename} copied to ${contentTarget}`);
}
fs.writeFileSync(contentLogPath, contentLog.join('\n'));
writeFileAtomicSync(contentLogPath, contentLog.join('\n'));
}
/**
@ -386,6 +387,45 @@ async function downloadJannyCharacter(uuid) {
throw new Error('Failed to download character');
}
//Download Character Cards from AICharactersCards.com (AICC) API.
async function downloadAICCCharacter(id) {
const apiURL = `https://aicharactercards.com/wp-json/pngapi/v1/image/${id}`;
try {
const response = await fetch(apiURL);
if (!response.ok) {
throw new Error(`Failed to download character: ${response.statusText}`);
}
const contentType = response.headers.get('content-type') || 'image/png'; // Default to 'image/png' if header is missing
const buffer = await response.buffer();
const fileName = `${sanitize(id)}.png`; // Assuming PNG, but adjust based on actual content or headers
return {
buffer: buffer,
fileName: fileName,
fileType: contentType,
};
} catch (error) {
console.error('Error downloading character:', error);
throw error;
}
}
/**
* Parses an aicharactercards URL to extract the path.
* @param {string} url URL to parse
* @returns {string | null} AICC path
*/
function parseAICC(url) {
const pattern = /^https?:\/\/aicharactercards\.com\/character-cards\/([^/]+)\/([^/]+)\/?$|([^/]+)\/([^/]+)$/;
const match = url.match(pattern);
if (match) {
// Match group 1 & 2 for full URL, 3 & 4 for relative path
return match[1] && match[2] ? `${match[1]}/${match[2]}` : `${match[3]}/${match[4]}`;
}
return null;
}
/**
* @param {String} url
* @returns {String | null } UUID of the character
@ -414,6 +454,7 @@ router.post('/importURL', jsonParser, async (request, response) => {
const isJannnyContent = url.includes('janitorai');
const isPygmalionContent = url.includes('pygmalion.chat');
const isAICharacterCardsContent = url.includes('aicharactercards.com');
if (isPygmalionContent) {
const uuid = getUuidFromUrl(url);
@ -431,6 +472,13 @@ router.post('/importURL', jsonParser, async (request, response) => {
type = 'character';
result = await downloadJannyCharacter(uuid);
} else if (isAICharacterCardsContent) {
const AICCParsed = parseAICC(url);
if (!AICCParsed) {
return response.sendStatus(404);
}
type = 'character';
result = await downloadAICCCharacter(AICCParsed);
} else {
const chubParsed = parseChubUrl(url);
type = chubParsed?.type;
@ -469,6 +517,7 @@ router.post('/importUUID', jsonParser, async (request, response) => {
const isJannny = uuid.includes('_character');
const isPygmalion = (!isJannny && uuid.length == 36);
const isAICC = uuid.startsWith('AICC/');
const uuidType = uuid.includes('lorebook') ? 'lorebook' : 'character';
if (isPygmalion) {
@ -477,6 +526,10 @@ router.post('/importUUID', jsonParser, async (request, response) => {
} else if (isJannny) {
console.log('Downloading Janitor character:', uuid.split('_')[0]);
result = await downloadJannyCharacter(uuid.split('_')[0]);
} else if (isAICC) {
const [, author, card] = uuid.split('/');
console.log('Downloading AICC character:', `${author}/${card}`);
result = await downloadAICCCharacter(`${author}/${card}`);
} else {
if (uuidType === 'character') {
console.log('Downloading chub character:', uuid);

View File

@ -10,6 +10,10 @@ const { getAllUserHandles, getUserDirectories } = require('../users');
const { getConfigValue } = require('../util');
const { jsonParser } = require('../express-common');
const thumbnailsDisabled = getConfigValue('disableThumbnails', false);
const quality = getConfigValue('thumbnailsQuality', 95);
const pngFormat = getConfigValue('avatarThumbnailsPng', false);
/**
* Gets a path to thumbnail folder based on the type.
* @param {import('../users').UserDirectoryList} directories User directories
@ -115,9 +119,8 @@ async function generateThumbnail(directories, type, file) {
let buffer;
try {
const quality = getConfigValue('thumbnailsQuality', 95);
const image = await jimp.read(pathToOriginalFile);
const imgType = type == 'avatar' && getConfigValue('avatarThumbnailsPng', false) ? 'image/png' : 'image/jpeg';
const imgType = type == 'avatar' && pngFormat ? 'image/png' : 'image/jpeg';
buffer = await image.cover(mySize[0], mySize[1]).quality(quality).getBufferAsync(imgType);
}
catch (inner) {
@ -188,7 +191,6 @@ router.get('/', jsonParser, async function (request, response) {
return response.sendStatus(403);
}
const thumbnailsDisabled = getConfigValue('disableThumbnails', false);
if (thumbnailsDisabled) {
const folder = getOriginalFolder(request.user.directories, type);

View File

@ -10,6 +10,10 @@ const { TEXTGEN_TYPES } = require('../constants');
const { jsonParser } = require('../express-common');
const { setAdditionalHeaders } = require('../additional-headers');
/**
* @typedef { (req: import('express').Request, res: import('express').Response) => Promise<any> } TokenizationHandler
*/
/**
* @type {{[key: string]: import("@dqbd/tiktoken").Tiktoken}} Tokenizers cache
*/
@ -48,16 +52,30 @@ const TEXT_COMPLETION_MODELS = [
const CHARS_PER_TOKEN = 3.35;
/**
* Sentencepiece tokenizer for tokenizing text.
*/
class SentencePieceTokenizer {
/**
* @type {import('@agnai/sentencepiece-js').SentencePieceProcessor} Sentencepiece tokenizer instance
*/
#instance;
/**
* @type {string} Path to the tokenizer model
*/
#model;
/**
* Creates a new Sentencepiece tokenizer.
* @param {string} model Path to the tokenizer model
*/
constructor(model) {
this.#model = model;
}
/**
* Gets the Sentencepiece tokenizer instance.
* @returns {Promise<import('@agnai/sentencepiece-js').SentencePieceProcessor|null>} Sentencepiece tokenizer instance
*/
async get() {
if (this.#instance) {
@ -76,18 +94,61 @@ class SentencePieceTokenizer {
}
}
const spp_llama = new SentencePieceTokenizer('src/sentencepiece/llama.model');
const spp_nerd = new SentencePieceTokenizer('src/sentencepiece/nerdstash.model');
const spp_nerd_v2 = new SentencePieceTokenizer('src/sentencepiece/nerdstash_v2.model');
const spp_mistral = new SentencePieceTokenizer('src/sentencepiece/mistral.model');
const spp_yi = new SentencePieceTokenizer('src/sentencepiece/yi.model');
let claude_tokenizer;
/**
* Web tokenizer for tokenizing text.
*/
class WebTokenizer {
/**
* @type {Tokenizer} Web tokenizer instance
*/
#instance;
/**
* @type {string} Path to the tokenizer model
*/
#model;
/**
* Creates a new Web tokenizer.
* @param {string} model Path to the tokenizer model
*/
constructor(model) {
this.#model = model;
}
/**
* Gets the Web tokenizer instance.
* @returns {Promise<Tokenizer|null>} Web tokenizer instance
*/
async get() {
if (this.#instance) {
return this.#instance;
}
try {
const arrayBuffer = fs.readFileSync(this.#model).buffer;
this.#instance = await Tokenizer.fromJSON(arrayBuffer);
console.log('Instantiated the tokenizer for', path.parse(this.#model).name);
return this.#instance;
} catch (error) {
console.error('Web tokenizer failed to load: ' + this.#model, error);
return null;
}
}
}
const spp_llama = new SentencePieceTokenizer('src/tokenizers/llama.model');
const spp_nerd = new SentencePieceTokenizer('src/tokenizers/nerdstash.model');
const spp_nerd_v2 = new SentencePieceTokenizer('src/tokenizers/nerdstash_v2.model');
const spp_mistral = new SentencePieceTokenizer('src/tokenizers/mistral.model');
const spp_yi = new SentencePieceTokenizer('src/tokenizers/yi.model');
const claude_tokenizer = new WebTokenizer('src/tokenizers/claude.json');
const sentencepieceTokenizers = [
'llama',
'nerdstash',
'nerdstash_v2',
'mistral',
'yi',
];
/**
@ -112,6 +173,10 @@ function getSentencepiceTokenizer(model) {
return spp_nerd_v2;
}
if (model.includes('yi')) {
return spp_yi;
}
return null;
}
@ -168,13 +233,23 @@ async function getTiktokenChunks(tokenizer, ids) {
return chunks;
}
async function getWebTokenizersChunks(tokenizer, ids) {
/**
* Gets the token chunks for the given token IDs using the Web tokenizer.
* @param {Tokenizer} tokenizer Web tokenizer instance
* @param {number[]} ids Token IDs
* @returns {string[]} Token chunks
*/
function getWebTokenizersChunks(tokenizer, ids) {
const chunks = [];
for (let i = 0; i < ids.length; i++) {
const id = ids[i];
const chunkText = await tokenizer.decode(new Uint32Array([id]));
for (let i = 0, lastProcessed = 0; i < ids.length; i++) {
const chunkIds = ids.slice(lastProcessed, i + 1);
const chunkText = tokenizer.decode(new Int32Array(chunkIds));
if (chunkText === '<27>') {
continue;
}
chunks.push(chunkText);
lastProcessed = i + 1;
}
return chunks;
@ -237,17 +312,12 @@ function getTiktokenTokenizer(model) {
return tokenizer;
}
async function loadClaudeTokenizer(modelPath) {
try {
const arrayBuffer = fs.readFileSync(modelPath).buffer;
const instance = await Tokenizer.fromJSON(arrayBuffer);
return instance;
} catch (error) {
console.error('Claude tokenizer failed to load: ' + modelPath, error);
return null;
}
}
/**
* Counts the tokens for the given messages using the Claude tokenizer.
* @param {Tokenizer} tokenizer Web tokenizer
* @param {object[]} messages Array of messages
* @returns {number} Number of tokens
*/
function countClaudeTokens(tokenizer, messages) {
// Should be fine if we use the old conversion method instead of the messages API one i think?
const convertedPrompt = convertClaudePrompt(messages, false, '', false, false, '', false);
@ -264,9 +334,14 @@ function countClaudeTokens(tokenizer, messages) {
/**
* Creates an API handler for encoding Sentencepiece tokens.
* @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer
* @returns {any} Handler function
* @returns {TokenizationHandler} Handler function
*/
function createSentencepieceEncodingHandler(tokenizer) {
/**
* Request handler for encoding Sentencepiece tokens.
* @param {import('express').Request} request
* @param {import('express').Response} response
*/
return async function (request, response) {
try {
if (!request.body) {
@ -276,7 +351,7 @@ function createSentencepieceEncodingHandler(tokenizer) {
const text = request.body.text || '';
const instance = await tokenizer?.get();
const { ids, count } = await countSentencepieceTokens(tokenizer, text);
const chunks = await instance?.encodePieces(text);
const chunks = instance?.encodePieces(text);
return response.send({ ids, count, chunks });
} catch (error) {
console.log(error);
@ -288,9 +363,14 @@ function createSentencepieceEncodingHandler(tokenizer) {
/**
* Creates an API handler for decoding Sentencepiece tokens.
* @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer
* @returns {any} Handler function
* @returns {TokenizationHandler} Handler function
*/
function createSentencepieceDecodingHandler(tokenizer) {
/**
* Request handler for decoding Sentencepiece tokens.
* @param {import('express').Request} request
* @param {import('express').Response} response
*/
return async function (request, response) {
try {
if (!request.body) {
@ -299,6 +379,7 @@ function createSentencepieceDecodingHandler(tokenizer) {
const ids = request.body.ids || [];
const instance = await tokenizer?.get();
if (!instance) throw new Error('Failed to load the Sentencepiece tokenizer');
const ops = ids.map(id => instance.decodeIds([id]));
const chunks = await Promise.all(ops);
const text = chunks.join('');
@ -313,9 +394,14 @@ function createSentencepieceDecodingHandler(tokenizer) {
/**
* Creates an API handler for encoding Tiktoken tokens.
* @param {string} modelId Tiktoken model ID
* @returns {any} Handler function
* @returns {TokenizationHandler} Handler function
*/
function createTiktokenEncodingHandler(modelId) {
/**
* Request handler for encoding Tiktoken tokens.
* @param {import('express').Request} request
* @param {import('express').Response} response
*/
return async function (request, response) {
try {
if (!request.body) {
@ -337,9 +423,14 @@ function createTiktokenEncodingHandler(modelId) {
/**
* Creates an API handler for decoding Tiktoken tokens.
* @param {string} modelId Tiktoken model ID
* @returns {any} Handler function
* @returns {TokenizationHandler} Handler function
*/
function createTiktokenDecodingHandler(modelId) {
/**
* Request handler for decoding Tiktoken tokens.
* @param {import('express').Request} request
* @param {import('express').Response} response
*/
return async function (request, response) {
try {
if (!request.body) {
@ -358,14 +449,6 @@ function createTiktokenDecodingHandler(modelId) {
};
}
/**
* Loads the model tokenizers.
* @returns {Promise<void>} Promise that resolves when the tokenizers are loaded
*/
async function loadTokenizers() {
claude_tokenizer = await loadClaudeTokenizer('src/claude.json');
}
const router = express.Router();
router.post('/ai21/count', jsonParser, async function (req, res) {
@ -446,8 +529,10 @@ router.post('/openai/encode', jsonParser, async function (req, res) {
if (queryModel.includes('claude')) {
const text = req.body.text || '';
const tokens = Object.values(claude_tokenizer.encode(text));
const chunks = await getWebTokenizersChunks(claude_tokenizer, tokens);
const instance = await claude_tokenizer.get();
if (!instance) throw new Error('Failed to load the Claude tokenizer');
const tokens = Object.values(instance.encode(text));
const chunks = getWebTokenizersChunks(instance, tokens);
return res.send({ ids: tokens, count: tokens.length, chunks });
}
@ -481,7 +566,9 @@ router.post('/openai/decode', jsonParser, async function (req, res) {
if (queryModel.includes('claude')) {
const ids = req.body.ids || [];
const chunkText = await claude_tokenizer.decode(new Uint32Array(ids));
const instance = await claude_tokenizer.get();
if (!instance) throw new Error('Failed to load the Claude tokenizer');
const chunkText = instance.decode(new Int32Array(ids));
return res.send({ text: chunkText });
}
@ -503,7 +590,9 @@ router.post('/openai/count', jsonParser, async function (req, res) {
const model = getTokenizerModel(queryModel);
if (model === 'claude') {
num_tokens = countClaudeTokens(claude_tokenizer, req.body);
const instance = await claude_tokenizer.get();
if (!instance) throw new Error('Failed to load the Claude tokenizer');
num_tokens = countClaudeTokens(instance, req.body);
return res.send({ 'token_count': num_tokens });
}
@ -665,7 +754,6 @@ module.exports = {
getTokenizerModel,
getTiktokenTokenizer,
countClaudeTokens,
loadTokenizers,
getSentencepiceTokenizer,
sentencepieceTokenizers,
router,

View File

@ -0,0 +1,30 @@
/**
* Decodes a file name from Latin1 to UTF-8.
* @param {string} str Input string
* @returns {string} Decoded file name
*/
function decodeFileName(str) {
return Buffer.from(str, 'latin1').toString('utf-8');
}
/**
* Middleware to decode file names from Latin1 to UTF-8.
* See: https://github.com/expressjs/multer/issues/1104
* @param {import('express').Request} req Request
* @param {import('express').Response} _res Response
* @param {import('express').NextFunction} next Next middleware
*/
function multerMonkeyPatch(req, _res, next) {
try {
if (req.file) {
req.file.originalname = decodeFileName(req.file.originalname);
}
next();
} catch (error) {
console.error('Error in multerMonkeyPatch:', error);
next();
}
}
module.exports = multerMonkeyPatch;

View File

@ -112,6 +112,16 @@ async function ensurePublicDirectoriesExist() {
return directoriesList;
}
/**
* Gets a list of all user directories.
* @returns {Promise<import('./users').UserDirectoryList[]>} - The list of user directories
*/
async function getUserDirectoriesList() {
const userHandles = await getAllUserHandles();
const directoriesList = userHandles.map(handle => getUserDirectories(handle));
return directoriesList;
}
/**
* Perform migration from the old user data format to the new one.
*/
@ -289,7 +299,7 @@ async function migrateUserData() {
fs.cpSync(
migration.old,
path.join(backupDirectory, path.basename(migration.old)),
{ recursive: true, force: true }
{ recursive: true, force: true },
);
fs.rmSync(migration.old, { recursive: true, force: true });
} else {
@ -299,7 +309,7 @@ async function migrateUserData() {
fs.cpSync(
migration.old,
path.join(backupDirectory, path.basename(migration.old)),
{ recursive: true, force: true }
{ recursive: true, force: true },
);
fs.rmSync(migration.old, { recursive: true, force: true });
}
@ -602,9 +612,13 @@ function createRouteHandler(directoryFn) {
try {
const directory = directoryFn(req);
const filePath = decodeURIComponent(req.params[0]);
const exists = fs.existsSync(path.join(directory, filePath));
if (!exists) {
return res.sendStatus(404);
}
return res.sendFile(filePath, { root: directory });
} catch (error) {
return res.sendStatus(404);
return res.sendStatus(500);
}
};
}
@ -707,6 +721,7 @@ module.exports = {
toAvatarKey,
initUserStorage,
ensurePublicDirectoriesExist,
getUserDirectoriesList,
getAllUserHandles,
getUserDirectories,
setUserDataMiddleware,

View File

@ -1,6 +1,7 @@
const path = require('path');
const fs = require('fs');
const commandExistsSync = require('command-exists').sync;
const writeFileAtomicSync = require('write-file-atomic').sync;
const _ = require('lodash');
const yauzl = require('yauzl');
const mime = require('mime-types');
@ -10,11 +11,20 @@ const { Readable } = require('stream');
const { PUBLIC_DIRECTORIES } = require('./constants');
/**
* Parsed config object.
*/
let CACHED_CONFIG = null;
/**
* Returns the config object from the config.yaml file.
* @returns {object} Config object
*/
function getConfig() {
if (CACHED_CONFIG) {
return CACHED_CONFIG;
}
if (!fs.existsSync('./config.yaml')) {
console.error(color.red('No config file found. Please create a config.yaml file. The default config file can be found in the /default folder.'));
console.error(color.red('The program will now exit.'));
@ -23,6 +33,7 @@ function getConfig() {
try {
const config = yaml.parse(fs.readFileSync(path.join(process.cwd(), './config.yaml'), 'utf8'));
CACHED_CONFIG = config;
return config;
} catch (error) {
console.warn('Failed to read config.yaml');
@ -47,9 +58,11 @@ function getConfigValue(key, defaultValue = null) {
* @param {any} value Value to set
*/
function setConfigValue(key, value) {
// Reset cache so that the next getConfig call will read the updated config file
CACHED_CONFIG = null;
const config = getConfig();
_.set(config, key, value);
fs.writeFileSync('./config.yaml', yaml.stringify(config));
writeFileAtomicSync('./config.yaml', yaml.stringify(config));
}
/**

View File

@ -1,5 +1,8 @@
#!/usr/bin/env bash
# Make sure pwd is the directory of the script
cd "$(dirname "$0")"
if ! command -v npm &> /dev/null
then
read -p "npm is not installed. Do you want to install nodejs and npm? (y/n)" choice
@ -26,4 +29,4 @@ export NODE_ENV=production
npm i --no-audit --no-fund --quiet --omit=dev
echo "Entering SillyTavern..."
node "$(dirname "$0")/server.js" "$@"
node "server.js" "$@"