Merge branch 'staging' into parser-followup-2

This commit is contained in:
LenAnderson 2024-07-04 11:37:35 -04:00
commit 32ec6aac1c
54 changed files with 2457 additions and 1285 deletions

View File

@ -1,4 +1,6 @@
.git
.github
.vscode
node_modules
npm-debug.log
readme*

View File

@ -1,5 +1,5 @@
name: Bug Report 🐛
description: Report something that's not working the intended way. Support requests for external programs (reverse proxies, 3rd party servers, other peoples' forks) will be refused!
description: Report something that's not working the intended way. Support requests for external programs (reverse proxies, 3rd party servers, other peoples' forks) will be refused! Please use English only.
title: '[BUG] <title>'
labels: ['🐛 Bug']
body:

View File

@ -1,5 +1,5 @@
name: Feature Request ✨
description: Suggest an idea for future development of this project
description: Suggest an idea for future development of this project. Please use English only.
title: '[FEATURE_REQUEST] <title>'
labels: ['🦄 Feature Request']

5
.github/pull_request_template.md vendored Normal file
View File

@ -0,0 +1,5 @@
<!-- Put X in the box below to confirm -->
## Checklist:
- [ ] I have read the [Contributing guidelines](https://github.com/SillyTavern/SillyTavern/blob/release/CONTRIBUTING.md).

View File

@ -8,3 +8,6 @@ secrets.json
/data
/cache
access.log
.github
.vscode
.git

32
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,32 @@
# How to contribute to SillyTavern
## Setting up the dev environment
1. Required software: git and node.
2. Recommended editor: Visual Studio Code.
3. You can also use GitHub Codespaces which sets up everything for you.
## Getting the code ready
1. Register a GitHub account.
2. Fork this repository under your account.
3. Clone the fork onto your machine.
4. Open the cloned repository in the code editor.
5. Create a git branch (recommended).
6. Make your changes and test them locally.
7. Commit the changes and push the branch to the remote repo.
8. Go to GitHub, and open a pull request, targeting the upstream branch.
## Contribution guidelines
1. Our standards are pretty low, but make sure the code is not too ugly:
- Run VS Code's autoformat when you're done.
- Check with ESLint by running `npm run lint`, then fix the errors.
- Use common sense and follow existing naming conventions.
2. Create pull requests for the staging branch, 99% of contributions should go there. That way people could test your code before the next stable release.
3. You can still send a pull request for release in the following scenarios:
- Updating README.
- Updating GitHub Actions.
- Hotfixing a critical bug.
4. Project maintainers will test and can change your code before merging.
5. Mind the license. Your contributions will be licensed under the GNU Affero General Public License. If you don't know what that implies, consult your lawyer.

View File

@ -48,6 +48,8 @@ allowKeysExposure: false
skipContentCheck: false
# Disable automatic chats backup
disableChatBackup: false
# Number of backups to keep for each chat and settings file
numberOfBackups: 50
# Allowed hosts for card downloads
whitelistImportDomains:
- localhost

View File

@ -19,6 +19,10 @@
"filename": "themes/Dark V 1.0.json",
"type": "theme"
},
{
"filename": "themes/Azure.json",
"type": "theme"
},
{
"filename": "backgrounds/__transparent.png",
"type": "background"

View File

@ -0,0 +1,35 @@
{
"name": "Azure",
"blur_strength": 11,
"main_text_color": "rgba(171, 198, 223, 1)",
"italics_text_color": "rgba(255, 255, 255, 1)",
"underline_text_color": "rgba(188, 231, 207, 1)",
"quote_text_color": "rgba(111, 133, 253, 1)",
"blur_tint_color": "rgba(23, 30, 33, 0.61)",
"chat_tint_color": "rgba(23, 23, 23, 0)",
"user_mes_blur_tint_color": "rgba(0, 28, 174, 0.2)",
"bot_mes_blur_tint_color": "rgba(0, 13, 57, 0.22)",
"shadow_color": "rgba(0, 0, 0, 1)",
"shadow_width": 5,
"border_color": "rgba(0, 0, 0, 0.5)",
"font_scale": 1,
"fast_ui_mode": false,
"waifuMode": false,
"avatar_style": 1,
"chat_display": 1,
"noShadows": false,
"chat_width": 50,
"timer_enabled": true,
"timestamps_enabled": true,
"timestamp_model_icon": false,
"mesIDDisplay_enabled": true,
"message_token_count_enabled": false,
"expand_message_actions": false,
"enableZenSliders": false,
"enableLabMode": false,
"hotswap_enabled": true,
"custom_css": "",
"bogus_folders": false,
"reduced_motion": false,
"compact_input_area": false
}

View File

@ -15,6 +15,11 @@
"**/node_modules/*",
"public/lib",
"backups/*",
"data/*"
"data/*",
"**/dist/*",
"dist/*",
"cache/*",
"src/tokenizers/*",
"docker/*",
]
}

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "sillytavern",
"version": "1.12.1",
"version": "1.12.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sillytavern",
"version": "1.12.1",
"version": "1.12.3",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {

View File

@ -70,7 +70,7 @@
"type": "git",
"url": "https://github.com/SillyTavern/SillyTavern.git"
},
"version": "1.12.1",
"version": "1.12.3",
"scripts": {
"start": "node server.js",
"start:no-csrf": "node server.js --disableCsrf",

View File

@ -1,4 +1,4 @@
#loader, #preloader {
#preloader {
position: fixed;
margin: 0;
padding: 0;
@ -10,9 +10,9 @@
width: 100svw;
height: 100svh;
background-color: var(--SmartThemeBlurTintColor);
color: var(--SmartThemeBodyColor);
/*for some reason the full screen blur does not work on iOS*/
backdrop-filter: blur(30px);
color: var(--SmartThemeBodyColor);
opacity: 1;
}

View File

@ -1,8 +1,10 @@
/* iPhone copium land */
@media screen and (max-width: 1000px) {
.ios .popup .popup-body {
height: fit-content;
max-height: 90vh;
max-height: 90svh;
}
body.safari .popup .popup-body:has(.maximized_textarea) {
height: 100%;
}
body.safari .popup .popup-body {
height: fit-content;
max-height: 90vh;
max-height: 90svh;
}

View File

@ -27,8 +27,17 @@ dialog {
backface-visibility: hidden;
transform: translateZ(0);
-webkit-font-smoothing: subpixel-antialiased;
/* Variables setup */
--popup-animation-speed: var(--animation-duration-slow);
}
/** Popup styles applied to the main popup */
.popup--animation-fast { --popup-animation-speed: var(--animation-duration); }
.popup--animation-slow { --popup-animation-speed: var(--animation-duration-slow); }
.popup--animation-none { --popup-animation-speed: 0ms; }
/* Styling of main popup elements */
.popup .popup-body {
display: flex;
flex-direction: column;
@ -60,11 +69,11 @@ dialog {
/* Opening animation */
.popup[opening] {
animation: pop-in var(--animation-duration-slow) ease-in-out;
animation: pop-in var(--popup-animation-speed) ease-in-out;
}
.popup[opening]::backdrop {
animation: fade-in var(--animation-duration-slow) ease-in-out;
animation: fade-in var(--popup-animation-speed) ease-in-out;
}
/* Open state of the dialog */
@ -85,11 +94,11 @@ body.no-blur .popup[open]::backdrop {
/* Closing animation */
.popup[closing] {
animation: pop-out var(--animation-duration-slow) ease-in-out;
animation: pop-out var(--popup-animation-speed) ease-in-out;
}
.popup[closing]::backdrop {
animation: fade-out var(--animation-duration-slow) ease-in-out;
animation: fade-out var(--popup-animation-speed) ease-in-out;
}
.popup #toast-container {

View File

@ -1969,7 +1969,7 @@
<small data-i18n="Example: http://127.0.0.1:5000/api ">Example: http://127.0.0.1:5000/api </small>
<input id="api_url_text" name="api_url" class="text_pole" placeholder="http://127.0.0.1:5000/api" maxlength="500" value="" autocomplete="off" data-server-history="kobold">
<div id="koboldcpp_hint" class="neutral_warning displayNone">
We have a dedicated KoboldCpp support under Text Completion ⇒ KoboldCpp.
KoboldCpp works better when you select the Text Completion API and then KoboldCpp as a type!
</div>
<div class="flex-container">
<div id="api_button" class="api_button menu_button" type="submit" data-i18n="Connect" data-server-connect="kobold">Connect</div>
@ -3903,8 +3903,8 @@
<label for="tag_import_setting"><small data-i18n="Import Card Tags">Import Card Tags</small></label>
<select id="tag_import_setting" class="widthNatural flex1 margin0">
<option data-i18n="Ask" value="1">Ask</option>
<option data-i18n="None" value="2">None</option>
<option data-i18n="All" value="3">All</option>
<option data-i18n="tag_import_none" value="2">None</option>
<option data-i18n="tag_import_all" value="3">All</option>
<option data-i18n="Existing" value="4">Existing</option>
</select>
</div>
@ -4796,7 +4796,7 @@
<input multiple type="file" id="character_import_file" accept=".json, image/png, .yaml, .yml, .charx" name="avatar">
<input id="character_import_file_type" name="file_type" class="text_pole" maxlength="999" size="2" value="" autocomplete="off">
</form>
<input type="file" id="character_replace_file" accept="image/png" name="replace_avatar" hidden>
<input type="file" id="character_replace_file" accept=".json, image/png, .yaml, .yml, .charx" name="replace_avatar" hidden>
</div>
<div name="Character List Panel" id="rm_characters_block" class="right_menu">
<div id="charListFixedTop">
@ -5686,10 +5686,11 @@
<div class="mes_edit_cancel menu_button fa-solid fa-xmark" title="Cancel" data-i18n="[title]Cancel"></div>
</div>
</div>
<div class=mes_text></div>
<div class="mes_text"></div>
<div class="mes_img_container">
<div class="mes_img_controls">
<div title="Enlarge" class="right_menu_button fa-lg fa-solid fa-magnifying-glass mes_img_enlarge" data-i18n="[title]Enlarge"></div>
<div title="Caption" class="right_menu_button fa-lg fa-solid fa-envelope-open-text mes_img_caption" data-i18n="[title]Caption"></div>
<div title="Delete" class="right_menu_button fa-lg fa-solid fa-trash-can mes_img_delete" data-i18n="[title]Delete"></div>
</div>
<img class="mes_img" src="" />

View File

@ -1,6 +1,6 @@
{
"Favorite": "お気に入り",
"Tag": "鬼ごっこ",
"Tag": "タグ",
"Duplicate": "重複",
"Persona": "ペルソナ",
"Delete": "削除",
@ -29,13 +29,13 @@
"Text Adventure": "テキストアドベンチャー",
"response legth(tokens)": "応答の長さ(トークン数)",
"Streaming": "ストリーミング",
"Streaming_desc": "生成された応答をビット単位で表示します。",
"Streaming_desc": "生成された応答を逐次表示します。",
"context size(tokens)": "コンテキストのサイズ(トークン数)",
"unlocked": "ロック解除",
"Only enable this if your model supports context sizes greater than 4096 tokens": "モデルが4096トークンを超えるコンテキストサイズをサポートしている場合にのみ有効にします",
"Max prompt cost:": "最大プロンプトコスト:",
"Display the response bit by bit as it is generated.": "生成されるたびに、応答をビットごとに表示します。",
"When this is off, responses will be displayed all at once when they are complete.": "この機能がオフの場合、応答は完全になるとすぐにすべて一度に表示されます。",
"Display the response bit by bit as it is generated.": "生成されるたびに、応答を逐次表示します。",
"When this is off, responses will be displayed all at once when they are complete.": "この機能がオフの場合、応答は完全に生成されたときに一度ですべて表示されます。",
"Temperature": "温度",
"rep.pen": "繰り返しペナルティ",
"Rep. Pen. Range.": "繰り返しペナルティの範囲",
@ -46,10 +46,10 @@
"Phrase Repetition Penalty": "フレーズの繰り返しペナルティ",
"Off": "オフ",
"Very light": "非常に軽い",
"Light": "ライト",
"Medium": "ミディアム",
"Aggressive": "攻撃的",
"Very aggressive": "非常に攻撃的",
"Light": "軽め",
"Medium": "中程度",
"Aggressive": "強め",
"Very aggressive": "非常に強い",
"Unlocked Context Size": "ロック解除されたコンテキストサイズ",
"Unrestricted maximum value for the context slider": "コンテキストスライダーの制限なしの最大値",
"Context Size (tokens)": "コンテキストサイズ(トークン数)",
@ -132,7 +132,7 @@
"CFG Scale": "CFGスケール",
"Negative Prompt": "ネガティブプロンプト",
"Add text here that would make the AI generate things you don't want in your outputs.": "出力に望ましくないものを生成させるAIを作成するテキストをここに追加します。",
"Used if CFG Scale is unset globally, per chat or character": "CFGスケールがグローバル、チャットごと、または文字ごとに設定されていない場合に使用されます",
"Used if CFG Scale is unset globally, per chat or character": "CFGスケールがグローバル、チャットごと、またはキャラクターごとに設定されていない場合に使用されます",
"Mirostat Tau": "Mirostat Tau",
"Mirostat LR": "ミロスタットLR",
"Min Length": "最小長",
@ -214,7 +214,7 @@
"Sampler Priority": "サンプラー優先度",
"Ooba only. Determines the order of samplers.": "Oobaのみ。サンプラーの順序を決定します。",
"Character Names Behavior": "キャラクター名の動作",
"Helps the model to associate messages with characters.": "モデルがメッセージを文字に関連付けるのに役立ちます。",
"Helps the model to associate messages with characters.": "モデルがメッセージをキャラクターに関連付けるのに役立ちます。",
"None": "なし",
"character_names_none": "グループと過去のペルソナを除きます。それ以外の場合は、プロンプトに名前を必ず入力してください。",
"Don't add character names.": "キャラクター名を追加しないでください。",
@ -222,7 +222,7 @@
"character_names_completion": "制限事項: ラテン英数字とアンダースコアのみ。すべてのソースで機能するわけではありません。特に、Claude、MistralAI、Google では機能しません。",
"Add character names to completion objects.": "完了オブジェクトにキャラクター名を追加します。",
"Message Content": "メッセージ内容",
"Prepend character names to message contents.": "メッセージの内容の先頭に文字名を追加します。",
"Prepend character names to message contents.": "メッセージの内容の先頭にキャラクター名を追加します。",
"Continue Postfix": "ポストフィックスの継続",
"The next chunk of the continued message will be appended using this as a separator.": "継続メッセージの次のチャンクは、これを区切り文字として使用して追加されます。",
"Space": "空間",
@ -270,9 +270,9 @@
"Text Completion": "テキスト補完",
"Chat Completion": "チャット完了",
"NovelAI": "NovelAI",
"KoboldAI Horde": "KoboldAIホルド",
"KoboldAI Horde": "KoboldAI Horde",
"KoboldAI": "KoboldAI",
"Avoid sending sensitive information to the Horde.": "ホルドに機密情報を送信しないでください。",
"Avoid sending sensitive information to the Horde.": "Hordeに機密情報を送信しないでください。",
"Review the Privacy statement": "プライバシー声明を確認する",
"Register a Horde account for faster queue times": "キュー待ち時間を短縮するためにHordeアカウントを登録する",
"Learn how to contribute your idle GPU cycles to the Horde": "アイドルのGPUサイクルをホルドに貢献する方法を学びます",
@ -633,7 +633,7 @@
"Tags_as_Folders_desc": "最近の変更: タグは、タグ管理メニューでフォルダーとしてマークされて初めてフォルダーとして表示されます。ここをクリックして表示します。",
"Character Handling": "キャラクター処理",
"If set in the advanced character definitions, this field will be displayed in the characters list.": "高度なキャラクター定義で設定されている場合、このフィールドがキャラクターリストに表示されます。",
"Char List Subheader": "文字リストサブヘッダー",
"Char List Subheader": "キャラクターリストサブヘッダー",
"Character Version": "キャラクターバージョン",
"Created by": "作成者",
"Use fuzzy matching, and search characters in the list by all data fields, not just by a name substring": "曖昧な一致を使用し、名前の部分文字列ではなく、すべてのデータフィールドでリスト内のキャラクターを検索する",
@ -729,8 +729,8 @@
"Automatically hide details": "詳細を自動的に非表示にする",
"Determines how entries are found for autocomplete.": "オートコンプリートのエントリの検索方法を決定します。",
"Autocomplete Matching": "マッチング",
"Starts with": "始まりは",
"Includes": "含まれるもの",
"Starts with": "前方一致",
"Includes": "部分一致",
"Fuzzy": "ファジー",
"Sets the style of the autocomplete.": "オートコンプリートのスタイルを設定します。",
"Autocomplete Style": "スタイル",
@ -755,8 +755,8 @@
"Auto-select": "自動選択",
"System Backgrounds": "システムの背景",
"Chat Backgrounds": "チャットの背景",
"bg_chat_hint_1": "チャットの背景は、",
"bg_chat_hint_2": "拡張子がここに表示されます。",
"bg_chat_hint_1": "",
"bg_chat_hint_2": "拡張機能で生成したチャットの背景はここに表示されます。",
"Extensions": "拡張機能",
"Notify on extension updates": "拡張機能の更新時に通知",
"Manage extensions": "拡張機能を管理",
@ -770,9 +770,9 @@
"How do I use this?": "これをどのように使用しますか?",
"Click for stats!": "統計をクリック!",
"Usage Stats": "使用状況統計",
"Backup your personas to a file": "キャラクタをファイルにバックアップします",
"Backup your personas to a file": "キャラクタをファイルにバックアップします",
"Backup": "バックアップ",
"Restore your personas from a file": "ファイルからキャラクタを復元します",
"Restore your personas from a file": "ファイルからキャラクタを復元します",
"Restore": "復元",
"Create a dummy persona": "ダミーのペルソナを作成",
"Create": "作成",
@ -839,7 +839,7 @@
"Describe your character's physical and mental traits here.": "ここにキャラクターの身体的および精神的特徴を説明します。",
"First message": "最初のメッセージ",
"Click to set additional greeting messages": "追加の挨拶メッセージを設定するにはクリック",
"Alt. Greetings": "挨拶",
"Alt. Greetings": "他の挨拶",
"This will be the first message from the character that starts every chat.": "これはすべてのチャットを開始するキャラクターからの最初のメッセージになります。",
"Group Controls": "グループコントロール",
"Chat Name (Optional)": "チャット名(任意)",
@ -889,7 +889,7 @@
"popup-button-yes": "はい",
"popup-button-no": "いいえ",
"popup-button-cancel": "キャンセル",
"popup-button-import": "輸入",
"popup-button-import": "インポート",
"Advanced Defininitions": "高度な定義",
"Prompt Overrides": "プロンプトのオーバーライド",
"(For Chat Completion and Instruct Mode)": "(チャット補完と指示モード用)",
@ -937,7 +937,7 @@
"Type here...": "ここに入力...",
"Chat Lorebook": "チャットロアブック",
"Chat Lorebook for": "チャットロアブック",
"chat_world_template_txt": "選択したワールド情報はこのチャットにバインドされます。AI の返信を生成する際、\nグローバルおよびキャラクターの伝承書のエントリと結合されます。",
"chat_world_template_txt": "選択したワールド情報はこのチャットにバインドされます。AI の返信を生成する際、\nグローバルおよびキャラクターのロアブックのエントリと結合されます。",
"Select a World Info file for": "次のためにワールド情報ファイルを選択",
"Primary Lorebook": "プライマリロアブック",
"A selected World Info will be bound to this character as its own Lorebook.": "選択したワールド情報は、このキャラクターにその独自のロアブックとしてバインドされます。",
@ -1065,9 +1065,9 @@
"Change it later in the 'User Settings' panel.": "後で「ユーザー設定」パネルで変更します。",
"Enable simple UI mode": "シンプルUIモードを有効にする",
"Looking for AI characters?": "AIキャラクターをお探しですか?",
"onboarding_import": "輸入",
"onboarding_import": "インポート",
"from supported sources or view": "サポートされているソースからまたは表示",
"Sample characters": "サンプル文字",
"Sample characters": "サンプルキャラクター",
"Your Persona": "あなたのペルソナ",
"Before you get started, you must select a persona name.": "始める前に、ペルソナ名を選択する必要があります。",
"welcome_message_part_8": "これはいつでも変更可能です。",
@ -1081,7 +1081,7 @@
"View character card": "キャラクターカードを表示",
"Remove from group": "グループから削除",
"Add to group": "グループに追加",
"Alternate Greetings": "代わりの挨拶",
"Alternate Greetings": "挨拶のバリエーション",
"Alternate_Greetings_desc": "これらは、新しいチャットを開始するときに最初のメッセージにスワイプとして表示されます。\nグループのメンバーは、そのうちの 1 つを選択して会話を開始できます。",
"Alternate Greetings Hint": "ボタンをクリックして始めましょう!",
"(This will be the first message from the character that starts every chat)": "(これはすべてのチャットを開始するキャラクターからの最初のメッセージになります)",
@ -1118,11 +1118,11 @@
"Will be used as the default CFG options for every chat unless overridden.": "上書きされない限り、すべてのチャットのデフォルトの CFG オプションとして使用されます。",
"CFG Prompt Cascading": "CFG プロンプト カスケード",
"Combine positive/negative prompts from other boxes.": "他のボックスからの肯定的/否定的なプロンプトを組み合わせます。",
"For example, ticking the chat, global, and character boxes combine all negative prompts into a comma-separated string.": "たとえば、チャット、グローバル、および文字のボックスにチェックを入れると、すべての否定プロンプトがコンマ区切りの文字列に結合されます。",
"For example, ticking the chat, global, and character boxes combine all negative prompts into a comma-separated string.": "たとえば、チャット、グローバル、およびキャラクターのボックスにチェックを入れると、すべてのネガティブプロンプトがコンマ区切りの文字列に結合されます。",
"Always Include": "常に含めます",
"Chat Negatives": "チャットのネガティブ",
"Character Negatives": "性格のマイナス面",
"Global Negatives": "世界的なマイナス",
"Character Negatives": "キャラクターのネガティブ",
"Global Negatives": "グローバルネガティブ",
"Custom Separator:": "カスタムセパレーター:",
"Insertion Depth:": "挿入深さ:",
"Token Probabilities": "トークン確率",
@ -1236,7 +1236,7 @@
"ext_regex_title": "正規表現",
"ext_regex_new_global_script": "+ グローバル",
"ext_regex_new_scoped_script": "+ スコープ付き",
"ext_regex_import_script": "輸入",
"ext_regex_import_script": "インポート",
"ext_regex_global_scripts": "グローバルスクリプト",
"ext_regex_global_scripts_desc": "すべてのキャラクターで使用可能。ローカル設定に保存されます。",
"ext_regex_scoped_scripts": "スコープ付きスクリプト",
@ -1301,8 +1301,8 @@
"Authentication (optional)": "認証(オプション)",
"Example: username:password": "例: ユーザー名:パスワード",
"Important:": "重要:",
"sd_auto_auth_warning_1": "SD Web UIを実行する",
"sd_auto_auth_warning_2": "フラグ! サーバーは SillyTavern ホスト マシンからアクセスできる必要があります。",
"sd_auto_auth_warning_1": "SD Web UIを",
"sd_auto_auth_warning_2": "フラグを指定して実行してください! サーバーは SillyTavern ホスト マシンからアクセスできる必要があります。",
"sd_drawthings_url": "例: {{drawthings_url}}",
"sd_drawthings_auth_txt": "UI で HTTP API スイッチを有効にして DrawThings アプリを実行します。サーバーは SillyTavern ホスト マシンからアクセスできる必要があります。",
"sd_vlad_url": "例: {{vlad_url}}",
@ -1326,36 +1326,36 @@
"Enhance": "強化する",
"Refine": "リファイン",
"Decrisper": "デクリスパー",
"Sampling steps": "サンプリング手順 ()",
"Width": "幅 ",
"Height": "身長 ",
"Resolution": "解",
"Sampling steps": "サンプリングステップ数",
"Width": "幅",
"Height": "高さ",
"Resolution": "解像度",
"Model": "モデル",
"Sampling method": "サンプリング方法",
"Karras (not all samplers supported)": "Karras (すべてのサンプラーがサポートされているわけではありません)",
"SMEA versions of samplers are modified to perform better at high resolution.": "SMEA バージョンのサンプラーは、高解像度でより優れたパフォーマンスを発揮するように変更されています。",
"SMEA": "中小企業庁",
"SMEA": "SMEA",
"DYN variants of SMEA samplers often lead to more varied output, but may fail at very high resolutions.": "SMEA サンプラーの DYN バリアントは、多くの場合、より多様な出力をもたらしますが、非常に高い解像度では失敗する可能性があります。",
"DYN": "ダイナミック",
"Scheduler": "スケジューラ",
"Restore Faces": "顔を復元する",
"Hires. Fix": "雇用。修正",
"Scheduler": "スケジューラ",
"Restore Faces": "顔の修復",
"Hires. Fix": "高解像度補助",
"Upscaler": "アップスケーラー",
"Upscale by": "高級化",
"Upscale by": "アップスケール倍率",
"Denoising strength": "ノイズ除去の強さ",
"Hires steps (2nd pass)": "採用手順2回目のパス",
"Preset for prompt prefix and negative prompt": "プロンプトプレフィックスと否定プロンプトのプリセット",
"Hires steps (2nd pass)": "高解像度でのステップ数",
"Preset for prompt prefix and negative prompt": "プロンプトプレフィックスとネガティブプロンプトのプリセット",
"Style": "スタイル",
"Save style": "スタイルを保存",
"Delete style": "スタイルを削除",
"Common prompt prefix": "一般的なプロンプトプレフィックス",
"Common prompt prefix": "共通のプロンプトプレフィックス",
"sd_prompt_prefix_placeholder": "生成されたプロンプトを挿入する場所を指定するには、{prompt}を使用します。",
"Negative common prompt prefix": "否定の共通プロンプト接頭辞",
"Character-specific prompt prefix": "文字固有のプロンプトプレフィックス",
"Negative common prompt prefix": "共通のネガティブプロンプトプレフィックス",
"Character-specific prompt prefix": "キャラクター固有のプロンプトプレフィックス",
"Won't be used in groups.": "グループでは使用されません。",
"sd_character_prompt_placeholder": "現在選択されているキャラクターを説明する任意の特性。共通のプロンプト プレフィックスの後に追加されます。\n例: 女性、緑の目、茶色の髪、ピンクのシャツ",
"Character-specific negative prompt prefix": "文字固有の否定プロンプト接頭辞",
"sd_character_negative_prompt_placeholder": "選択したキャラクターに表示されるべきではない特性。否定の共通プロンプト接頭辞の後に追加されます。\n例: ジュエリー、靴、メガネ",
"sd_character_prompt_placeholder": "現在選択されているキャラクターを説明する特徴。共通のプロンプトプレフィックスの後に追加されます。\n例: 女性、緑の目、茶色の髪、ピンクのシャツ",
"Character-specific negative prompt prefix": "キャラクター固有のネガティブプロンプトプレフィックス",
"sd_character_negative_prompt_placeholder": "選択したキャラクターに表示されるべきではない特徴。共通のネガティブプロンプトプレフィックスの後に追加されます。\n例: ジュエリー、靴、メガネ",
"Shareable": "共有可能",
"Image Prompt Templates": "画像プロンプトテンプレート",
"Vectors Model Warning": "チャットの途中でモデルを変更する場合は、ベクトルを消去することをお勧めします。そうしないと、標準以下の結果になります。",
@ -1380,22 +1380,22 @@
"Warning:": "警告:",
"This action is irreversible.": "この操作は元に戻せません。",
"Type the user's handle below to confirm:": "確認するには、以下のユーザーのハンドルを入力してください。",
"Import Characters": "文字をインポートする",
"Import Characters": "キャラクターをインポートする",
"Enter the URL of the content to import": "インポートするコンテンツの URL を入力します",
"Supported sources:": "サポートされているソース:",
"char_import_1": "チャブキャラクター直接リンクまたはID",
"char_import_1": "Chub キャラクター 直接リンクまたはID",
"char_import_example": "例:",
"char_import_2": "チャブの伝承集 (直接リンクまたは ID)",
"char_import_2": "Chub ロアブック (直接リンクまたは ID)",
"char_import_3": "JanitorAI キャラクター (直接リンクまたは UUID)",
"char_import_4": "Pygmalion.chat キャラクター (直接リンクまたは UUID)",
"char_import_5": "AICharacterCard.com キャラクター (直接リンクまたは ID)",
"char_import_6": "直接PNGリンク参照",
"char_import_7": "許可されたホストの場合)",
"char_import_8": "RisuRealm キャラクター (直接リンク)",
"Supports importing multiple characters.": "複数の文字のインポートをサポートします。",
"Supports importing multiple characters.": "複数のキャラクターのインポートをサポートします。",
"Write each URL or ID into a new line.": "各 URL または ID を新しい行に入力します。",
"Export for character": "文字のエクスポート",
"Export prompts for this character, including their order.": "この文字のプロンプトを順序も含めてエクスポートします。",
"Export for character": "キャラクターのエクスポート",
"Export prompts for this character, including their order.": "このキャラクターのプロンプトを順序も含めてエクスポートします。",
"Export all": "すべてをエクスポート",
"Export all your prompts to a file": "すべてのプロンプトをファイルにエクスポートする",
"Insert prompt": "プロンプトを挿入",

View File

@ -163,9 +163,9 @@
"View hidden API keys": "Посмотреть скрытые API-ключи",
"Advanced Formatting": "Расширенное форматирование",
"Context Template": "Шаблон контекста",
"Replace Macro in Custom Stopping Strings": "Заменить макросы в пользовательских стоп-строках",
"Replace Macro in Custom Stopping Strings": "Заменять макросы в пользовательских стоп-строках",
"Story String": "Строка истории",
"Example Separator": "Пример разделителя",
"Example Separator": "Разделитель примеров сообщений",
"Chat Start": "Начало чата",
"Activation Regex": "Regex для активации",
"Instruct Mode": "Режим Instruct",
@ -187,7 +187,7 @@
"Misc. Settings": "Доп. настройки",
"Auto-Continue": "Авто-продолжение",
"Collapse Consecutive Newlines": "Сворачивать последовательные новые строки",
"Allow for Chat Completion APIs": "Разрешить для API Chat Completion",
"Allow for Chat Completion APIs": "Разрешить для Chat Completion API",
"Target length (tokens)": "Целевая длина (в токенах)",
"World Info": "Информация о мире",
"Scan Depth": "Глубина сканирования",
@ -377,7 +377,7 @@
"Log prompts to console": "Выводить промпты в консоль",
"Never resize avatars": "Не менять размер аватарок",
"Show avatar filenames": "Показывать названия файлов аватарок",
"Import Card Tags": "Импорт тегов карточки",
"Import Card Tags": "Импортировать теги карточки",
"Confirm message deletion": "Подтверждение удаления сообщений",
"Spoiler Free Mode": "Режим без спойлеров",
"Auto-swipe": "Автоматические свайпы",
@ -1274,7 +1274,7 @@
"openrouter_force_instruct": "This option is outdated and will be removed in the future. To use instruct formatting, please switch to OpenRouter under Text Completion API instead.",
"Force_Instruct_Mode_formatting_Description": "If both Instruct Mode and this are enabled, the prompt will be formatted by SillyTavern using the current\n advanced formatting settings (except instruct System Prompt). If disabled, the prompt will be formatted by OpenRouter.",
"Clear your cookie": "Clear your cookie",
"Add Chat Start and Example Separator to a list of stopping strings.": "Add Chat Start and Example Separator to a list of stopping strings.",
"Add Chat Start and Example Separator to a list of stopping strings.": "Использовать Начало чата и Разделитель примеров сообщений в качестве стоп-строк.",
"context_allow_jailbreak": "Если в карточке есть джейлбрейк И ПРИ ЭТОМ включена опция \"Приоритет джейлбрейку из карточки персонажа\", то этот джейлбрейк добавляется в конец промпта.\nНЕ РЕКОМЕНДУЕТСЯ ДЛЯ МОДЕЛЕЙ TEXT COMPLETION, МОЖЕТ ПОРТИТЬ ВЫХОДНОЙ ТЕКСТ.",
"Context Order": "Context Order",
"Summary": "Summary",
@ -1636,5 +1636,10 @@
"Change Password": "Сменить пароль",
"Reset Code:": "Reset Code:",
"Click _space": "Нажмите ",
"Alternate Greeting #": "Вариант #"
"Alternate Greeting #": "Вариант #",
"Defines on importing cards which action should be chosen for importing its listed tags. 'Ask' will always display the dialog.": "Выберите, какие действия следует предпринять по отношению к тегам импортируемой карточки. При выборе опции \"Спрашивать\" вы будете решать это индивидуально для каждой карточки.",
"Ask": "Спрашивать",
"tag_import_all": "Все",
"Existing": "Только существующие",
"tag_import_none": "Не импортировать"
}

View File

@ -217,6 +217,7 @@
"Character Names Behavior": "角色名称行为",
"Helps the model to associate messages with characters.": "有助于模型将消息与角色关联起来。",
"None": "无",
"tag_import_none": "无",
"character_names_none": "群聊和过去的角色除外。否则,请确保在提示词中提供了姓名。",
"Don't add character names.": "不添加角色名称。",
"Completion": "补全对象",
@ -333,6 +334,9 @@
"vLLM API key": "vLLM API 密钥",
"Example: 127.0.0.1:8000": "例如http://127.0.0.1:8000",
"vLLM Model": "vLLM 模型",
"HuggingFace Token": "HuggingFace 代币",
"Endpoint URL": "端点 URL",
"Example: https://****.endpoints.huggingface.cloud": "例如https://****.endpoints.huggingface.cloud",
"PygmalionAI/aphrodite-engine": "PygmalionAI/aphrodite-engine用于OpenAI API的包装器",
"Aphrodite API key": "Aphrodite API 密钥",
"Aphrodite Model": "Aphrodite 模型",
@ -418,6 +422,8 @@
"Prompt Post-Processing": "提示词后处理",
"Applies additional processing to the prompt before sending it to the API.": "在将提示词发送到 API 之前对其进行额外处理。",
"prompt_post_processing_none": "未选择",
"01.AI API Key": "01.AI API密钥",
"01.AI Model": "01.AI模型",
"Additional Parameters": "附加参数",
"Verifies your API connection by sending a short test message. Be aware that you'll be credited for it!": "通过发送简短的测试消息验证您的API连接。请注意您将因此而消耗额度",
"Test Message": "发送测试消息",
@ -653,7 +659,7 @@
"Defines on importing cards which action should be chosen for importing its listed tags. 'Ask' will always display the dialog.": "定义在导入卡片时应选择哪种操作来导入其列出的标签。“询问”将始终显示对话框。",
"Import Card Tags": "导入卡片标签",
"Ask": "询问",
"All": "全部",
"tag_import_all": "全部",
"Existing": "现存的",
"Use fuzzy matching, and search characters in the list by all data fields, not just by a name substring": "使用模糊匹配,在列表中通过所有数据字段搜索角色,而不仅仅是名称子字符串",
"Advanced Character Search": "高级角色搜索",
@ -1032,6 +1038,8 @@
"Sticky": "粘性",
"Entries with a cooldown can't be activated N messages after being triggered.": "具有冷却时间的条目在触发后 N 条消息内无法被激活。",
"Cooldown": "冷却",
"Entries with a delay can't be activated until there are N messages present in the chat.": "直到聊天中出现 N 条消息时,延迟的条目才能被激活。",
"Delay": "延迟",
"Filter to Character(s)": "应用到角色",
"Character Exclusion": "反选角色",
"-- Characters not found --": "-- 未找到角色 --",
@ -1076,6 +1084,7 @@
"Move message up": "将消息上移",
"Move message down": "将消息下移",
"Enlarge": "放大",
"Caption": "标题",
"Welcome to SillyTavern!": "欢迎来到 SillyTavern",
"welcome_message_part_1": "阅读",
"welcome_message_part_2": "官方文档",
@ -1112,10 +1121,6 @@
"alternate_greetings_hint_2": "按钮即可开始!",
"Alternate Greeting #": "额外问候语 #",
"(This will be the first message from the character that starts every chat)": "(这将是角色在每次聊天开始时发送的第一条消息)",
"Forbid Media Override explanation": "当前角色/群组在聊天中使用外部媒体的能力。",
"Forbid Media Override subtitle": "媒体:图像、视频、音频。外部:不在本地服务器上托管。",
"Always forbidden": "始终禁止",
"Always allowed": "始终允许",
"View contents": "查看内容",
"Remove the file": "删除文件",
"Unique to this chat": "此聊天独有",
@ -1239,6 +1244,7 @@
"Message Template": "消息模板",
"(use _space": "(使用",
"macro)": "宏指令)",
"Automatically caption images": "自动为图像添加标题",
"Edit captions before saving": "保存前编辑标题",
"Character Expressions": "角色表情",
"Translate text to English before classification": "分类之前将文本翻译成英文",
@ -1578,6 +1584,10 @@
"Warning:": "警告:",
"This action is irreversible.": "此操作不可逆。",
"Type the user's handle below to confirm:": "在下面输入用户的名称以确认:",
"Forbid Media Override explanation": "当前角色/群组在聊天中使用外部媒体的能力。",
"Forbid Media Override subtitle": "媒体:图像、视频、音频。外部:不在本地服务器上托管。",
"Always forbidden": "始终禁止",
"Always allowed": "始终允许",
"help_format_1": "文本格式化命令:",
"help_format_2": "*文本*",
"help_format_3": "显示为",

View File

@ -43,7 +43,6 @@ import {
saveGroupChat,
getGroups,
generateGroupWrapper,
deleteGroup,
is_group_generating,
resetSelectedGroup,
select_group_chats,
@ -159,7 +158,7 @@ import {
import { debounce_timeout } from './scripts/constants.js';
import { ModuleWorkerWrapper, doDailyExtensionUpdatesCheck, extension_settings, getContext, loadExtensionSettings, renderExtensionTemplate, renderExtensionTemplateAsync, runGenerationInterceptors, saveMetadataDebounced, writeExtensionField } from './scripts/extensions.js';
import { COMMENT_NAME_DEFAULT, executeSlashCommands, executeSlashCommandsOnChatInput, getSlashCommandsHelp, isExecutingCommandsFromChatInput, pauseScriptExecution, processChatSlashCommands, registerSlashCommand, stopScriptExecution } from './scripts/slash-commands.js';
import { COMMENT_NAME_DEFAULT, executeSlashCommands, executeSlashCommandsOnChatInput, getSlashCommandsHelp, initDefaultSlashCommands, isExecutingCommandsFromChatInput, pauseScriptExecution, processChatSlashCommands, registerSlashCommand, stopScriptExecution } from './scripts/slash-commands.js';
import {
tag_map,
tags,
@ -209,7 +208,7 @@ import {
instruct_presets,
selectContextPreset,
} from './scripts/instruct-mode.js';
import { initLocales } from './scripts/i18n.js';
import { initLocales, t, translate } from './scripts/i18n.js';
import { getFriendlyTokenizerName, getTokenCount, getTokenCountAsync, getTokenizerModel, initTokenizers, saveTokenCache } from './scripts/tokenizers.js';
import {
user_avatar,
@ -266,8 +265,6 @@ await new Promise((resolve) => {
});
showLoader();
// Yoink preloader entirely; it only exists to cover up unstyled content while loading JS
document.getElementById('preloader').remove();
// Configure toast library:
toastr.options.escapeHtml = true; // Prevent raw HTML inserts
@ -408,6 +405,7 @@ export const event_types = {
MESSAGE_EDITED: 'message_edited',
MESSAGE_DELETED: 'message_deleted',
MESSAGE_UPDATED: 'message_updated',
MESSAGE_FILE_EMBEDDED: 'message_file_embedded',
IMPERSONATE_READY: 'impersonate_ready',
CHAT_CHANGED: 'chat_id_changed',
GENERATION_STARTED: 'generation_started',
@ -522,6 +520,7 @@ const chatElement = $('#chat');
let dialogueResolve = null;
let dialogueCloseStop = false;
export let chat_metadata = {};
/** @type {StreamingProcessor} */
export let streamingProcessor = null;
let crop_data = undefined;
let is_delete_mode = false;
@ -839,6 +838,7 @@ export let main_api;// = "kobold";
//novel settings
export let novelai_settings;
export let novelai_setting_names;
/** @type {AbortController} */
let abortController;
//css
@ -911,6 +911,7 @@ async function firstLoadInit() {
initKeyboard();
initDynamicStyles();
initTags();
initDefaultSlashCommands();
await getUserAvatars(true, user_avatar);
await getCharacters();
await getBackgrounds();
@ -2077,14 +2078,23 @@ export function updateMessageBlock(messageId, message) {
appendMediaToMessage(message, messageElement);
}
export function appendMediaToMessage(mes, messageElement) {
/**
* Appends image or file to the message element.
* @param {object} mes Message object
* @param {JQuery<HTMLElement>} messageElement Message element
* @param {boolean} [adjustScroll=true] Whether to adjust the scroll position after appending the media
*/
export function appendMediaToMessage(mes, messageElement, adjustScroll = true) {
// Add image to message
if (mes.extra?.image) {
const chatHeight = $('#chat').prop('scrollHeight');
const image = messageElement.find('.mes_img');
const text = messageElement.find('.mes_text');
const isInline = !!mes.extra?.inline_image;
image.on('load', function () {
image.off('load').on('load', function () {
if (!adjustScroll) {
return;
}
const scrollPosition = $('#chat').scrollTop();
const newChatHeight = $('#chat').prop('scrollHeight');
const diff = newChatHeight - chatHeight;
@ -3403,6 +3413,10 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
let regexedMessage = getRegexedString(message, regexType, options);
regexedMessage = await appendFileContent(chatItem, regexedMessage);
if (chatItem?.extra?.append_title && chatItem?.extra?.title) {
regexedMessage = `${regexedMessage}\n\n${chatItem.extra.title}`;
}
return {
...chatItem,
mes: regexedMessage,
@ -4368,6 +4382,25 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
}
}
/**
* Stops the generation and any streaming if it is currently running.
*/
export function stopGeneration() {
let stopped = false;
if (streamingProcessor) {
streamingProcessor.onStopStreaming();
streamingProcessor = null;
stopped = true;
}
if (abortController) {
abortController.abort('Clicked stop button');
hideStopButton();
stopped = true;
}
eventSource.emit(event_types.GENERATION_STOPPED);
return stopped;
}
/**
* Injects extension prompts into chat messages.
* @param {object[]} messages Array of chat messages
@ -5630,7 +5663,7 @@ export async function renameCharacter(name = null, { silent = false, renameChats
}
}
catch (error) {
// Reloading to prevent data corruption
// Reloading to prevent data corruption
if (!silent) await callPopup('Something went wrong. The page will be reloaded.', 'text');
else toastr.error('Something went wrong. The page will be reloaded.', 'Rename Character');
@ -7151,7 +7184,8 @@ function onScenarioOverrideRemoveClick() {
* @param {string} inputValue - Value to set the input to.
* @param {PopupOptions} options - Options for the popup.
* @typedef {{okButton?: string, rows?: number, wide?: boolean, wider?: boolean, large?: boolean, allowHorizontalScrolling?: boolean, allowVerticalScrolling?: boolean, cropAspect?: number }} PopupOptions - Options for the popup.
* @returns
* @returns {Promise<any>} A promise that resolves when the popup is closed.
* @deprecated Use `callGenericPopup` instead.
*/
export function callPopup(text, type, inputValue = '', { okButton, rows, wide, wider, large, allowHorizontalScrolling, allowVerticalScrolling, cropAspect } = {}) {
function getOkButtonText() {
@ -7782,6 +7816,7 @@ window['SillyTavern'].getContext = function () {
eventTypes: event_types,
addOneMessage: addOneMessage,
generate: Generate,
stopGeneration: stopGeneration,
getTokenCount: getTokenCount,
extensionPrompts: extension_prompts,
setExtensionPrompt: setExtensionPrompt,
@ -7814,6 +7849,8 @@ window['SillyTavern'].getContext = function () {
registerDataBankScraper: ScraperManager.registerDataBankScraper,
callPopup: callPopup,
callGenericPopup: callGenericPopup,
showLoader: showLoader,
hideLoader: hideLoader,
mainApi: main_api,
extensionSettings: extension_settings,
ModuleWorkerWrapper: ModuleWorkerWrapper,
@ -7825,6 +7862,8 @@ window['SillyTavern'].getContext = function () {
messageFormatting: messageFormatting,
shouldSendOnEnter: shouldSendOnEnter,
isMobile: isMobile,
t: t,
translate: translate,
tags: tags,
tagMap: tag_map,
menuType: menu_type,
@ -7833,6 +7872,8 @@ window['SillyTavern'].getContext = function () {
* @deprecated Legacy snake-case naming, compatibility with old extensions
*/
event_types: event_types,
POPUP_TYPE: POPUP_TYPE,
POPUP_RESULT: POPUP_RESULT,
};
};
@ -7882,7 +7923,7 @@ function swipe_left() { // when we swipe left..but no generation.
}
$(this).parent().children('.mes_block').transition({
x: swipe_range,
duration: swipe_duration,
duration: animation_duration > 0 ? swipe_duration : 0,
easing: animation_easing,
queue: false,
complete: async function () {
@ -7925,7 +7966,7 @@ function swipe_left() { // when we swipe left..but no generation.
complete: function () {
$(this).parent().children('.mes_block').transition({
x: '0px',
duration: swipe_duration,
duration: animation_duration > 0 ? swipe_duration : 0,
easing: animation_easing,
queue: false,
complete: async function () {
@ -7940,7 +7981,7 @@ function swipe_left() { // when we swipe left..but no generation.
$(this).parent().children('.avatar').transition({
x: swipe_range,
duration: swipe_duration,
duration: animation_duration > 0 ? swipe_duration : 0,
easing: animation_easing,
queue: false,
complete: function () {
@ -7952,7 +7993,7 @@ function swipe_left() { // when we swipe left..but no generation.
complete: function () {
$(this).parent().children('.avatar').transition({
x: '0px',
duration: swipe_duration,
duration: animation_duration > 0 ? swipe_duration : 0,
easing: animation_easing,
queue: false,
complete: function () {
@ -8059,7 +8100,7 @@ const swipe_right = () => {
this_mes_div.children('.swipe_left').css('display', 'flex');
this_mes_div.children('.mes_block').transition({ // this moves the div back and forth
x: '-' + swipe_range,
duration: swipe_duration,
duration: animation_duration > 0 ? swipe_duration : 0,
easing: animation_easing,
queue: false,
complete: async function () {
@ -8118,7 +8159,7 @@ const swipe_right = () => {
complete: function () {
this_mes_div.children('.mes_block').transition({
x: '0px',
duration: swipe_duration,
duration: animation_duration > 0 ? swipe_duration : 0,
easing: animation_easing,
queue: false,
complete: async function () {
@ -8141,7 +8182,7 @@ const swipe_right = () => {
});
this_mes_div.children('.avatar').transition({ // moves avatar along with swipe
x: '-' + swipe_range,
duration: swipe_duration,
duration: animation_duration > 0 ? swipe_duration : 0,
easing: animation_easing,
queue: false,
complete: function () {
@ -8153,7 +8194,7 @@ const swipe_right = () => {
complete: function () {
this_mes_div.children('.avatar').transition({
x: '0px',
duration: swipe_duration,
duration: animation_duration > 0 ? swipe_duration : 0,
easing: animation_easing,
queue: false,
complete: function () {
@ -8433,10 +8474,10 @@ async function connectAPISlash(_, text) {
/**
* Imports supported files dropped into the app window.
* @param {File[]} files Array of files to process
* @param {boolean?} preserveFileNames Whether to preserve original file names
* @param {Map<File, string>} [data] Extra data to pass to the import function
* @returns {Promise<void>}
*/
export async function processDroppedFiles(files, preserveFileNames = false) {
export async function processDroppedFiles(files, data = new Map()) {
const allowedMimeTypes = [
'application/json',
'image/png',
@ -8453,7 +8494,8 @@ export async function processDroppedFiles(files, preserveFileNames = false) {
for (const file of files) {
const extension = file.name.split('.').pop().toLowerCase();
if (allowedMimeTypes.includes(file.type) || allowedExtensions.includes(extension)) {
await importCharacter(file, preserveFileNames);
const preservedName = data instanceof Map && data.get(file);
await importCharacter(file, preservedName);
} else {
toastr.warning('Unsupported file type: ' + file.name);
}
@ -8463,10 +8505,10 @@ export async function processDroppedFiles(files, preserveFileNames = false) {
/**
* Imports a character from a file.
* @param {File} file File to import
* @param {boolean?} preserveFileName Whether to preserve original file name
* @param {string?} preserveFileName Whether to preserve original file name
* @returns {Promise<void>}
*/
async function importCharacter(file, preserveFileName = false) {
async function importCharacter(file, preserveFileName = '') {
if (is_group_generating || is_send_press) {
toastr.error('Cannot import characters while generating. Stop the request and try again.', 'Import aborted');
throw new Error('Cannot import character while generating');
@ -8482,7 +8524,7 @@ async function importCharacter(file, preserveFileName = false) {
const formData = new FormData();
formData.append('avatar', file);
formData.append('file_type', format);
formData.append('preserve_file_name', String(preserveFileName));
if (preserveFileName) formData.append('preserved_name', preserveFileName);
const data = await jQuery.ajax({
type: 'POST',
@ -9153,14 +9195,26 @@ jQuery(async function () {
chooseBogusFolder($(this), tagId);
});
$(document).on('input', '.edit_textarea', function () {
scroll_holder = $('#chat').scrollTop();
$(this).height(0).height(this.scrollHeight);
/**
* Sets the scroll height of the edit textarea to fit the content.
* @param {HTMLTextAreaElement} e Textarea element to auto-fit
*/
function autoFitEditTextArea(e) {
scroll_holder = chatElement[0].scrollTop;
e.style.height = '0';
e.style.height = `${e.scrollHeight + 4}px`;
is_use_scroll_holder = true;
}
const autoFitEditTextAreaDebounced = debounce(autoFitEditTextArea, debounce_timeout.short);
document.addEventListener('input', e => {
if (e.target instanceof HTMLTextAreaElement && e.target.classList.contains('edit_textarea')) {
const immediately = e.target.scrollHeight > e.target.offsetHeight || e.target.value === '';
immediately ? autoFitEditTextArea(e.target) : autoFitEditTextAreaDebounced(e.target);
}
});
$('#chat').on('scroll', function () {
document.getElementById('chat').addEventListener('scroll', function () {
if (is_use_scroll_holder) {
$('#chat').scrollTop(scroll_holder);
this.scrollTop = scroll_holder;
is_use_scroll_holder = false;
}
});
@ -9692,14 +9746,8 @@ jQuery(async function () {
});
$('#newChatFromManageScreenButton').on('click', function () {
setTimeout(() => {
$('#option_start_new_chat').trigger('click');
}, 1);
setTimeout(() => {
$('#dialogue_popup_ok').trigger('click');
}, 1);
doNewChat({ deleteCurrentChat: false });
$('#select_chat_cross').trigger('click');
});
//////////////////////////////////////////////////////////////////////////////////////////////
@ -10319,15 +10367,7 @@ jQuery(async function () {
});
$(document).on('click', '.mes_stop', function () {
if (streamingProcessor) {
streamingProcessor.onStopStreaming();
streamingProcessor = null;
}
if (abortController) {
abortController.abort('Clicked stop button');
hideStopButton();
}
eventSource.emit(event_types.GENERATION_STOPPED);
stopGeneration();
});
$(document).on('click', '#form_sheld .stscript_continue', function () {
@ -10615,10 +10655,12 @@ jQuery(async function () {
}
try {
const cloneFile = new File([file], characters[this_chid].avatar, { type: file.type });
const chatFile = characters[this_chid]['chat'];
await processDroppedFiles([cloneFile], true);
const data = new Map();
data.set(file, characters[this_chid].avatar);
await processDroppedFiles([file], data);
await openCharacterChat(chatFile);
await fetch(getThumbnailUrl('avatar', characters[this_chid].avatar), { cache: 'no-cache' });
} catch {
toastr.error('Failed to replace the character card.', 'Something went wrong');
}
@ -10814,3 +10856,4 @@ jQuery(async function () {
initCustomSelectedSamplers();
});

View File

@ -725,8 +725,14 @@ export function initRossMods() {
RA_autoconnect();
}
if (getParsedUA()?.os?.name === 'iOS') {
document.body.classList.add('ios');
const userAgent = getParsedUA();
console.debug('User Agent', userAgent);
const isMobileSafari = /iPad|iPhone|iPod/.test(navigator.platform) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const isDesktopSafari = userAgent?.browser?.name === 'Safari' && userAgent?.platform?.type === 'desktop';
const isIOS = userAgent?.os?.name === 'iOS';
if (isIOS || isMobileSafari || isDesktopSafari) {
document.body.classList.add('safari');
}
$('#main_api').change(function () {

View File

@ -185,18 +185,19 @@ export async function populateFileAttachment(message, inputId = 'file_form_input
const file = fileInput.files[0];
if (!file) return;
const slug = getStringHash(file.name);
const fileNamePrefix = `${Date.now()}_${slug}`;
const fileBase64 = await getBase64Async(file);
let base64Data = fileBase64.split(',')[1];
// If file is image
if (file.type.startsWith('image/')) {
const extension = file.type.split('/')[1];
const imageUrl = await saveBase64AsFile(base64Data, name2, file.name, extension);
const imageUrl = await saveBase64AsFile(base64Data, name2, fileNamePrefix, extension);
message.extra.image = imageUrl;
message.extra.inline_image = true;
} else {
const slug = getStringHash(file.name);
const uniqueFileName = `${Date.now()}_${slug}.txt`;
const uniqueFileName = `${fileNamePrefix}.txt`;
if (isConvertible(file.type)) {
try {
@ -319,12 +320,10 @@ export function hasPendingFileAttachment() {
/**
* Displays file information in the message sending form.
* @param {File} file File object
* @returns {Promise<void>}
*/
async function onFileAttach() {
const fileInput = document.getElementById('file_form_input');
if (!(fileInput instanceof HTMLInputElement)) return;
const file = fileInput.files[0];
async function onFileAttach(file) {
if (!file) return;
const isValid = await validateFile(file);
@ -419,6 +418,7 @@ function embedMessageFile(messageId, messageBlock) {
}
await populateFileAttachment(message, 'embed_file_input');
await eventSource.emit(event_types.MESSAGE_FILE_EMBEDDED, messageId);
appendMediaToMessage(message, messageBlock);
await saveChatConditional();
}
@ -616,6 +616,8 @@ async function deleteMessageImage() {
const message = chat[mesId];
delete message.extra.image;
delete message.extra.inline_image;
delete message.extra.title;
delete message.extra.append_title;
mesBlock.find('.mes_img_container').removeClass('img_extra');
mesBlock.find('.mes_img').attr('src', '');
await saveChatConditional();
@ -1428,7 +1430,7 @@ jQuery(function () {
wrapper.classList.add('flexFlowColumn', 'justifyCenter', 'alignitemscenter');
const textarea = document.createElement('textarea');
textarea.value = String(bro.val());
textarea.classList.add('height100p', 'wide100p');
textarea.classList.add('height100p', 'wide100p', 'maximized_textarea');
bro.hasClass('monospace') && textarea.classList.add('monospace');
textarea.addEventListener('input', function () {
bro.val(textarea.value).trigger('input');
@ -1503,8 +1505,34 @@ jQuery(function () {
$(document).on('click', '.mes_img_enlarge', enlargeMessageImage);
$(document).on('click', '.mes_img_delete', deleteMessageImage);
$('#file_form_input').on('change', onFileAttach);
$('#file_form_input').on('change', async () => {
const fileInput = document.getElementById('file_form_input');
if (!(fileInput instanceof HTMLInputElement)) return;
const file = fileInput.files[0];
await onFileAttach(file);
});
$('#file_form').on('reset', function () {
$('#file_form').addClass('displayNone');
});
document.getElementById('send_textarea').addEventListener('paste', async function (event) {
if (event.clipboardData.files.length === 0) {
return;
}
event.preventDefault();
event.stopPropagation();
const fileInput = document.getElementById('file_form_input');
if (!(fileInput instanceof HTMLInputElement)) return;
// Workaround for Firefox: Use a DataTransfer object to indirectly set fileInput.files
const dataTransfer = new DataTransfer();
for (let i = 0; i < event.clipboardData.files.length; i++) {
dataTransfer.items.add(event.clipboardData.files[i]);
}
fileInput.files = dataTransfer.files;
await onFileAttach(fileInput.files[0]);
});
});

View File

@ -5,6 +5,8 @@
export const debounce_timeout = {
/** [100 ms] For ultra-fast responses, typically for keypresses or executions that might happen multiple times in a loop or recursion. */
quick: 100,
/** [200 ms] Slightly slower than quick, but still very responsive. */
short: 200,
/** [300 ms] Default time for general use, good balance between responsiveness and performance. */
standard: 300,
/** [1.000 ms] For situations where the function triggers more intensive tasks. */

View File

@ -154,7 +154,7 @@ export function initDynamicStyles() {
// Process all stylesheets on initial load
Array.from(document.styleSheets).forEach(sheet => {
try {
applyDynamicFocusStyles(sheet, { fromExtension: sheet.href.toLowerCase().includes('scripts/extensions') });
applyDynamicFocusStyles(sheet, { fromExtension: sheet.href?.toLowerCase().includes('scripts/extensions') == true });
} catch (e) {
console.warn('Failed to process stylesheet on initial load:', e);
}

View File

@ -374,7 +374,7 @@ async function addExtensionsButtonAndMenu() {
$('html').on('click', function (e) {
const clickTarget = $(e.target);
const noCloseTargets = ['#sd_gen', '#extensionsMenuButton'];
const noCloseTargets = ['#sd_gen', '#extensionsMenuButton', '#roll_dice'];
if (dropdown.is(':visible') && !noCloseTargets.some(id => clickTarget.closest(id).length > 0)) {
$(dropdown).fadeOut(animation_duration);
}
@ -641,9 +641,16 @@ async function showExtensionsDetails() {
action: async () => {
requiresReload = true;
await autoUpdateExtensions(true);
popup.complete(POPUP_RESULT.AFFIRMATIVE);
await popup.complete(POPUP_RESULT.AFFIRMATIVE);
},
};
// If we are updating an extension, the "old" popup is still active. We should close that.
const oldPopup = Popup.util.popups.find(popup => popup.content.querySelector('.extensions_info'));
if (oldPopup) {
await oldPopup.complete(POPUP_RESULT.CANCELLED);
}
const popup = new Popup(`<div class="extensions_info">${html}</div>`, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: true, large: true, customButtons: [updateAllButton], allowVerticalScrolling: true });
popupPromise = popup.show();
} catch (error) {

View File

@ -1,6 +1,6 @@
import { ensureImageFormatSupported, getBase64Async, isTrueBoolean, saveBase64AsFile } from '../../utils.js';
import { getContext, getApiUrl, doExtrasFetch, extension_settings, modules, renderExtensionTemplateAsync } from '../../extensions.js';
import { callPopup, getRequestHeaders, saveSettingsDebounced, substituteParamsExtended } from '../../../script.js';
import { appendMediaToMessage, callPopup, eventSource, event_types, getRequestHeaders, saveChatConditional, saveSettingsDebounced, substituteParamsExtended } from '../../../script.js';
import { getMessageTimeStamp } from '../../RossAscends-mods.js';
import { SECRET_KEYS, secret_state } from '../../secrets.js';
import { getMultimodalCaption } from '../shared.js';
@ -84,12 +84,11 @@ async function setSpinnerIcon() {
}
/**
* Sends a captioned message to the chat.
* @param {string} caption Caption text
* @param {string} image Image URL
* Wraps a caption with a message template.
* @param {string} caption Raw caption
* @returns {Promise<string>} Wrapped caption
*/
async function sendCaptionedMessage(caption, image) {
const context = getContext();
async function wrapCaptionTemplate(caption) {
let template = extension_settings.caption.template || TEMPLATE_DEFAULT;
if (!/{{caption}}/i.test(template)) {
@ -101,7 +100,7 @@ async function sendCaptionedMessage(caption, image) {
if (extension_settings.caption.refine_mode) {
messageText = await callPopup(
'<h3>Review and edit the generated message:</h3>Press "Cancel" to abort the caption sending.',
'<h3>Review and edit the generated caption:</h3>Press "Cancel" to abort the caption sending.',
'input',
messageText,
{ rows: 5, okButton: 'Send' });
@ -111,6 +110,55 @@ async function sendCaptionedMessage(caption, image) {
}
}
return messageText;
}
/**
* Appends caption to an existing message.
* @param {Object} data Message data
* @returns {Promise<void>}
*/
async function captionExistingMessage(data) {
if (!(data?.extra?.image)) {
return;
}
const imageData = await fetch(data.extra.image);
const blob = await imageData.blob();
const type = imageData.headers.get('Content-Type');
const file = new File([blob], 'image.png', { type });
const caption = await getCaptionForFile(file, null, true);
if (!caption) {
console.warn('Failed to generate a caption for the image.');
return;
}
const wrappedCaption = await wrapCaptionTemplate(caption);
const messageText = String(data.mes).trim();
if (!messageText) {
data.extra.inline_image = false;
data.mes = wrappedCaption;
data.extra.title = wrappedCaption;
}
else {
data.extra.inline_image = true;
data.extra.append_title = true;
data.extra.title = wrappedCaption;
}
}
/**
* Sends a captioned message to the chat.
* @param {string} caption Caption text
* @param {string} image Image URL
*/
async function sendCaptionedMessage(caption, image) {
const messageText = await wrapCaptionTemplate(caption);
const context = getContext();
const message = {
name: context.name1,
is_user: true,
@ -356,6 +404,7 @@ jQuery(async function () {
(extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'llamacpp' && textgenerationwebui_settings.server_urls[textgen_types.LLAMACPP]) ||
(extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'ooba' && textgenerationwebui_settings.server_urls[textgen_types.OOBA]) ||
(extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'koboldcpp' && textgenerationwebui_settings.server_urls[textgen_types.KOBOLDCPP]) ||
(extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'vllm' && textgenerationwebui_settings.server_urls[textgen_types.VLLM]) ||
(extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'custom') ||
extension_settings.caption.source === 'local' ||
extension_settings.caption.source === 'horde';
@ -408,7 +457,7 @@ jQuery(async function () {
});
}
async function addSettings() {
const html = await renderExtensionTemplateAsync('caption', 'settings');
const html = await renderExtensionTemplateAsync('caption', 'settings', { TEMPLATE_DEFAULT, PROMPT_DEFAULT });
$('#caption_container').append(html);
}
@ -422,6 +471,7 @@ jQuery(async function () {
$('#caption_refine_mode').prop('checked', !!(extension_settings.caption.refine_mode));
$('#caption_allow_reverse_proxy').prop('checked', !!(extension_settings.caption.allow_reverse_proxy));
$('#caption_prompt_ask').prop('checked', !!(extension_settings.caption.prompt_ask));
$('#caption_auto_mode').prop('checked', !!(extension_settings.caption.auto_mode));
$('#caption_source').val(extension_settings.caption.source);
$('#caption_prompt').val(extension_settings.caption.prompt);
$('#caption_template').val(extension_settings.caption.template);
@ -447,6 +497,41 @@ jQuery(async function () {
extension_settings.caption.prompt_ask = $('#caption_prompt_ask').prop('checked');
saveSettingsDebounced();
});
$('#caption_auto_mode').on('input', () => {
extension_settings.caption.auto_mode = !!$('#caption_auto_mode').prop('checked');
saveSettingsDebounced();
});
const onMessageEvent = async (index) => {
if (!extension_settings.caption.auto_mode) {
return;
}
const data = getContext().chat[index];
await captionExistingMessage(data);
};
eventSource.on(event_types.MESSAGE_SENT, onMessageEvent);
eventSource.on(event_types.MESSAGE_FILE_EMBEDDED, onMessageEvent);
$(document).on('click', '.mes_img_caption', async function () {
const animationClass = 'fa-fade';
const messageBlock = $(this).closest('.mes');
const messageImg = messageBlock.find('.mes_img');
if (messageImg.hasClass(animationClass)) return;
messageImg.addClass(animationClass);
try {
const index = Number(messageBlock.attr('mesid'));
const data = getContext().chat[index];
await captionExistingMessage(data);
appendMediaToMessage(data, messageBlock, false);
await saveChatConditional();
} catch(e) {
console.error('Message image recaption failed', e);
} finally {
messageImg.removeClass(animationClass);
}
});
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'caption',
callback: captionCommandCallback,
@ -482,4 +567,6 @@ jQuery(async function () {
</div>
`,
}));
document.body.classList.add('caption');
});

View File

@ -26,6 +26,7 @@
<option value="openai">OpenAI</option>
<option value="openrouter">OpenRouter</option>
<option value="ooba" data-i18n="Text Generation WebUI (oobabooga)">Text Generation WebUI (oobabooga)</option>
<option value="vllm">vLLM</option>
</select>
</div>
<div class="flex1 flex-container flexFlowColumn flexNoGap">
@ -66,6 +67,7 @@
<option data-type="llamacpp" value="llamacpp_current" data-i18n="currently_loaded">[Currently loaded]</option>
<option data-type="ooba" value="ooba_current" data-i18n="currently_loaded">[Currently loaded]</option>
<option data-type="koboldcpp" value="koboldcpp_current" data-i18n="currently_loaded">[Currently loaded]</option>
<option data-type="vllm" value="vllm_current" data-i18n="currently_selected">[Currently selected]</option>
<option data-type="custom" value="custom_current" data-i18n="currently_selected">[Currently selected]</option>
</select>
</div>
@ -82,14 +84,19 @@
</div>
<div id="caption_prompt_block">
<label for="caption_prompt" data-i18n="Caption Prompt">Caption Prompt</label>
<textarea id="caption_prompt" class="text_pole" rows="1" placeholder="&lt; Use default &gt;">${PROMPT_DEFAULT}</textarea>
<textarea id="caption_prompt" class="text_pole" rows="1" placeholder="&lt; Use default &gt;">{{PROMPT_DEFAULT}}</textarea>
<label class="checkbox_label margin-bot-10px" for="caption_prompt_ask" title="Ask for a custom prompt every time an image is captioned.">
<input id="caption_prompt_ask" type="checkbox" class="checkbox">
<span data-i18n="Ask every time">Ask every time</span>
</label>
</div>
<label for="caption_template"><span data-i18n="Message Template">Message Template</span> <small><span data-i18n="(use _space">(use </span> <code>&lcub;&lcub;caption&rcub;&rcub;</code> <span data-i18n="macro)">macro)</span></small></label>
<textarea id="caption_template" class="text_pole" rows="2" placeholder="&lt; Use default &gt;">${TEMPLATE_DEFAULT}</textarea>
<textarea id="caption_template" class="text_pole" rows="2" placeholder="&lt; Use default &gt;">{{TEMPLATE_DEFAULT}}</textarea>
<label class="checkbox_label" for="caption_auto_mode">
<input id="caption_auto_mode" type="checkbox" class="checkbox">
<span data-i18n="Automatically caption images">Automatically caption images</span>
<i class="fa-solid fa-info-circle" title="Automatically caption images when they are pasted into the chat or attached to messages."></i>
</label>
<label class="checkbox_label margin-bot-10px" for="caption_refine_mode">
<input id="caption_refine_mode" type="checkbox" class="checkbox">
<span data-i18n="Edit captions before saving">Edit captions before saving</span>

View File

@ -34,6 +34,7 @@ export async function getMultimodalCaption(base64Img, prompt) {
const isCustom = extension_settings.caption.multimodal_api === 'custom';
const isOoba = extension_settings.caption.multimodal_api === 'ooba';
const isKoboldCpp = extension_settings.caption.multimodal_api === 'koboldcpp';
const isVllm = extension_settings.caption.multimodal_api === 'vllm';
const base64Bytes = base64Img.length * 0.75;
const compressionLimit = 2 * 1024 * 1024;
if ((['google', 'openrouter'].includes(extension_settings.caption.multimodal_api) && base64Bytes > compressionLimit) || isOoba || isKoboldCpp) {
@ -65,6 +66,14 @@ export async function getMultimodalCaption(base64Img, prompt) {
requestBody.server_url = textgenerationwebui_settings.server_urls[textgen_types.OLLAMA];
}
if (isVllm) {
if (extension_settings.caption.multimodal_model === 'vllm_current') {
requestBody.model = textgenerationwebui_settings.vllm_model;
}
requestBody.server_url = textgenerationwebui_settings.server_urls[textgen_types.VLLM];
}
if (isLlamaCpp) {
requestBody.server_url = textgenerationwebui_settings.server_urls[textgen_types.LLAMACPP];
}
@ -151,6 +160,14 @@ function throwIfInvalidModel(useReverseProxy) {
throw new Error('KoboldCpp server URL is not set.');
}
if (extension_settings.caption.multimodal_api === 'vllm' && !textgenerationwebui_settings.server_urls[textgen_types.VLLM]) {
throw new Error('vLLM server URL is not set.');
}
if (extension_settings.caption.multimodal_api === 'vllm' && extension_settings.caption.multimodal_model === 'vllm_current' && !textgenerationwebui_settings.vllm_model) {
throw new Error('vLLM model is not set.');
}
if (extension_settings.caption.multimodal_api === 'custom' && !oai_settings.custom_url) {
throw new Error('Custom API URL is not set.');
}

View File

@ -3,7 +3,6 @@ import {
systemUserName,
hideSwipeButtons,
showSwipeButtons,
callPopup,
getRequestHeaders,
event_types,
eventSource,
@ -29,10 +28,9 @@ import { getMultimodalCaption } from '../shared.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
import { resolveVariable } from '../../variables.js';
import { debounce_timeout } from '../../constants.js';
import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
export { MODULE_NAME };
const MODULE_NAME = 'sd';
@ -572,7 +570,7 @@ async function onDeleteStyleClick() {
return;
}
const confirmed = await callPopup(`Are you sure you want to delete the style "${selectedStyle}"?`, 'confirm', '', { okButton: 'Delete' });
const confirmed = await callGenericPopup(`Are you sure you want to delete the style "${selectedStyle}"?`, POPUP_TYPE.CONFIRM, '', { okButton: 'Delete', cancelButton: 'Cancel' });
if (!confirmed) {
return;
@ -601,7 +599,7 @@ async function onDeleteStyleClick() {
}
async function onSaveStyleClick() {
const userInput = await callPopup('Enter style name:', 'input', '', { okButton: 'Save' });
const userInput = await callGenericPopup('Enter style name:', POPUP_TYPE.INPUT);
if (!userInput) {
return;
@ -670,7 +668,7 @@ async function refinePrompt(prompt, allowExpand, isNegative = false) {
if (extension_settings.sd.refine_mode) {
const text = isNegative ? '<h3>Review and edit the <i>negative</i> prompt:</h3>' : '<h3>Review and edit the prompt:</h3>';
const refinedPrompt = await callPopup(text + 'Press "Cancel" to abort the image generation.', 'input', prompt.trim(), { rows: 5, okButton: 'Continue' });
const refinedPrompt = await callGenericPopup(text + 'Press "Cancel" to abort the image generation.', POPUP_TYPE.INPUT, prompt.trim(), { rows: 5, okButton: 'Continue' });
if (refinedPrompt) {
return refinedPrompt;
@ -2918,25 +2916,25 @@ async function generateComfyImage(prompt, negativePrompt) {
const text = await workflowResponse.text();
toastr.error(`Failed to load workflow.\n\n${text}`);
}
let workflow = (await workflowResponse.json()).replace('"%prompt%"', JSON.stringify(prompt));
workflow = workflow.replace('"%negative_prompt%"', JSON.stringify(negativePrompt));
let workflow = (await workflowResponse.json()).replaceAll('"%prompt%"', JSON.stringify(prompt));
workflow = workflow.replaceAll('"%negative_prompt%"', JSON.stringify(negativePrompt));
const seed = extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : Math.round(Math.random() * Number.MAX_SAFE_INTEGER);
workflow = workflow.replaceAll('"%seed%"', JSON.stringify(seed));
placeholders.forEach(ph => {
workflow = workflow.replace(`"%${ph}%"`, JSON.stringify(extension_settings.sd[ph]));
workflow = workflow.replaceAll(`"%${ph}%"`, JSON.stringify(extension_settings.sd[ph]));
});
(extension_settings.sd.comfy_placeholders ?? []).forEach(ph => {
workflow = workflow.replace(`"%${ph.find}%"`, JSON.stringify(substituteParams(ph.replace)));
workflow = workflow.replaceAll(`"%${ph.find}%"`, JSON.stringify(substituteParams(ph.replace)));
});
if (/%user_avatar%/gi.test(workflow)) {
const response = await fetch(getUserAvatarUrl());
if (response.ok) {
const avatarBlob = await response.blob();
const avatarBase64 = await getBase64Async(avatarBlob);
workflow = workflow.replace('"%user_avatar%"', JSON.stringify(avatarBase64));
workflow = workflow.replaceAll('"%user_avatar%"', JSON.stringify(avatarBase64));
} else {
workflow = workflow.replace('"%user_avatar%"', JSON.stringify(PNG_PIXEL));
workflow = workflow.replaceAll('"%user_avatar%"', JSON.stringify(PNG_PIXEL));
}
}
if (/%char_avatar%/gi.test(workflow)) {
@ -2944,9 +2942,9 @@ async function generateComfyImage(prompt, negativePrompt) {
if (response.ok) {
const avatarBlob = await response.blob();
const avatarBase64 = await getBase64Async(avatarBlob);
workflow = workflow.replace('"%char_avatar%"', JSON.stringify(avatarBase64));
workflow = workflow.replaceAll('"%char_avatar%"', JSON.stringify(avatarBase64));
} else {
workflow = workflow.replace('"%char_avatar%"', JSON.stringify(PNG_PIXEL));
workflow = workflow.replaceAll('"%char_avatar%"', JSON.stringify(PNG_PIXEL));
}
}
console.log(`{
@ -2978,7 +2976,7 @@ async function onComfyOpenWorkflowEditorClick() {
}),
})).json();
const editorHtml = $(await $.get('scripts/extensions/stable-diffusion/comfyWorkflowEditor.html'));
const popupResult = callPopup(editorHtml, 'confirm', undefined, { okButton: 'Save', wide: true, large: true, rows: 1 });
const popupResult = callGenericPopup(editorHtml, POPUP_TYPE.CONFIRM, '', { okButton: 'Save', cancelButton: 'Cancel', wide: true, large: true });
const checkPlaceholders = () => {
workflow = $('#sd_comfy_workflow_editor_workflow').val().toString();
$('.sd_comfy_workflow_editor_placeholder_list > li[data-placeholder]').each(function (idx) {
@ -3058,7 +3056,7 @@ async function onComfyOpenWorkflowEditorClick() {
}
async function onComfyNewWorkflowClick() {
let name = await callPopup('<h3>Workflow name:</h3>', 'input');
let name = await callGenericPopup('Workflow name:', POPUP_TYPE.INPUT);
if (!name) {
return;
}
@ -3085,7 +3083,7 @@ async function onComfyNewWorkflowClick() {
}
async function onComfyDeleteWorkflowClick() {
const confirm = await callPopup('Delete the workflow? This action is irreversible.', 'confirm');
const confirm = await callGenericPopup('Delete the workflow? This action is irreversible.', POPUP_TYPE.CONFIRM, '', { okButton: 'Delete', cancelButton: 'Cancel' });
if (!confirm) {
return;
}

View File

@ -1,4 +1,5 @@
import { callPopup, getRequestHeaders } from '../../../script.js';
import { getRequestHeaders } from '../../../script.js';
import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
import { SECRET_KEYS, findSecret, secret_state, writeSecret } from '../../secrets.js';
import { getPreviewString, saveTtsProviderSettings } from './index.js';
export { AzureTtsProvider };
@ -69,13 +70,13 @@ class AzureTtsProvider {
const popupText = 'Azure TTS API Key';
const savedKey = secret_state[SECRET_KEYS.AZURE_TTS] ? await findSecret(SECRET_KEYS.AZURE_TTS) : '';
const key = await callPopup(popupText, 'input', savedKey);
const key = await callGenericPopup(popupText, POPUP_TYPE.INPUT, savedKey);
if (key == false || key == '') {
return;
}
await writeSecret(SECRET_KEYS.AZURE_TTS, key);
await writeSecret(SECRET_KEYS.AZURE_TTS, String(key));
toastr.success('API Key saved');
$('#azure_tts_key').addClass('success');

View File

@ -5,8 +5,8 @@ TODO:
*/
import { doExtrasFetch, extension_settings, getApiUrl, modules } from '../../extensions.js';
import { callPopup } from '../../../script.js';
import { initVoiceMap } from './index.js';
import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
export { CoquiTtsProvider };
@ -246,7 +246,7 @@ class CoquiTtsProvider {
}
// Ask user for voiceId name to save voice
const voiceName = await callPopup('<h3>Name of Coqui voice to add to voice select dropdown:</h3>', 'input');
const voiceName = await callGenericPopup('Name of Coqui voice to add to voice select dropdown:', POPUP_TYPE.INPUT);
const model_origin = $('#coqui_model_origin').val();
const model_language = $('#coqui_api_language').val();

View File

@ -1,4 +1,4 @@
import { cancelTtsPlay, eventSource, event_types, isStreamingEnabled, name2, saveSettingsDebounced, substituteParams } from '../../../script.js';
import { cancelTtsPlay, eventSource, event_types, getCurrentChatId, isStreamingEnabled, name2, saveSettingsDebounced, substituteParams } from '../../../script.js';
import { ModuleWorkerWrapper, doExtrasFetch, extension_settings, getApiUrl, getContext, modules, renderExtensionTemplateAsync } from '../../extensions.js';
import { delay, escapeRegex, getBase64Async, getStringHash, onlyUnique } from '../../utils.js';
import { EdgeTtsProvider } from './edge.js';
@ -10,6 +10,7 @@ import { NovelTtsProvider } from './novel.js';
import { power_user } from '../../power-user.js';
import { OpenAITtsProvider } from './openai.js';
import { XTTSTtsProvider } from './xtts.js';
import { VITSTtsProvider } from './vits.js';
import { GSVITtsProvider } from './gsvi.js';
import { SBVits2TtsProvider } from './sbvits2.js';
import { AllTalkTtsProvider } from './alltalk.js';
@ -34,6 +35,7 @@ let lastMessage = null;
let lastMessageHash = null;
let periodicMessageGenerationTimer = null;
let lastPositionOfParagraphEnd = -1;
let currentInitVoiceMapPromise = null;
const DEFAULT_VOICE_MARKER = '[Default Voice]';
const DISABLED_VOICE_MARKER = 'disabled';
@ -83,6 +85,7 @@ const ttsProviders = {
ElevenLabs: ElevenLabsTtsProvider,
Silero: SileroTtsProvider,
XTTSv2: XTTSTtsProvider,
VITS: VITSTtsProvider,
GSVI: GSVITtsProvider,
SBVits2: SBVits2TtsProvider,
System: SystemTtsProvider,
@ -1008,9 +1011,39 @@ class VoiceMapEntry {
/**
* Init voiceMapEntries for character select list.
* If an initialization is already in progress, it returns the existing Promise instead of starting a new one.
* @param {boolean} unrestricted - If true, will include all characters in voiceMapEntries, even if they are not in the current chat.
* @returns {Promise} A promise that resolves when the initialization is complete.
*/
export async function initVoiceMap(unrestricted = false) {
// Preventing parallel execution
if (currentInitVoiceMapPromise) {
return currentInitVoiceMapPromise;
}
currentInitVoiceMapPromise = (async () => {
const initialChatId = getCurrentChatId();
try {
await initVoiceMapInternal(unrestricted);
} finally {
currentInitVoiceMapPromise = null;
}
const currentChatId = getCurrentChatId();
if (initialChatId !== currentChatId) {
// Chat changed during initialization, reinitialize
await initVoiceMap(unrestricted);
}
})();
return currentInitVoiceMapPromise;
}
/**
* Init voiceMapEntries for character select list.
* @param {boolean} unrestricted - If true, will include all characters in voiceMapEntries, even if they are not in the current chat.
*/
async function initVoiceMapInternal(unrestricted) {
// Gate initialization if not enabled or TTS Provider not ready. Prevents error popups.
const enabled = $('#tts_enabled').is(':checked');
if (!enabled) {

View File

@ -1,4 +1,5 @@
import { getRequestHeaders, callPopup } from '../../../script.js';
import { getRequestHeaders } from '../../../script.js';
import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
import { splitRecursive } from '../../utils.js';
import { getPreviewString, saveTtsProviderSettings } from './index.js';
import { initVoiceMap } from './index.js';
@ -56,7 +57,7 @@ class NovelTtsProvider {
// Add a new Novel custom voice to provider
async addCustomVoice() {
const voiceName = await callPopup('<h3>Custom Voice name:</h3>', 'input');
const voiceName = await callGenericPopup('Custom Voice name:', POPUP_TYPE.INPUT);
this.settings.customVoices.push(voiceName);
this.populateCustomVoices();
initVoiceMap(); // Update TTS extension voiceMap

View File

@ -0,0 +1,404 @@
import { getPreviewString, saveTtsProviderSettings } from './index.js';
export { VITSTtsProvider };
class VITSTtsProvider {
//########//
// Config //
//########//
settings;
ready = false;
voices = [];
separator = '. ';
audioElement = document.createElement('audio');
/**
* Perform any text processing before passing to TTS engine.
* @param {string} text Input text
* @returns {string} Processed text
*/
processText(text) {
return text;
}
audioFormats = ['wav', 'ogg', 'silk', 'mp3', 'flac'];
languageLabels = {
'Auto': 'auto',
'Chinese': 'zh',
'English': 'en',
'Japanese': 'ja',
'Korean': 'ko',
};
langKey2LangCode = {
'zh': 'zh-CN',
'en': 'en-US',
'ja': 'ja-JP',
'ko': 'ko-KR',
};
modelTypes = {
VITS: 'VITS',
W2V2_VITS: 'W2V2-VITS',
BERT_VITS2: 'BERT-VITS2',
};
defaultSettings = {
provider_endpoint: 'http://localhost:23456',
format: 'wav',
lang: 'auto',
length: 1.0,
noise: 0.33,
noisew: 0.4,
segment_size: 50,
streaming: false,
dim_emotion: 0,
sdp_ratio: 0.2,
emotion: 0,
text_prompt: '',
style_text: '',
style_weight: 1,
};
get settingsHtml() {
let html = `
<label for="vits_lang">Text Language</label>
<select id="vits_lang">`;
for (let language in this.languageLabels) {
if (this.languageLabels[language] == this.settings?.lang) {
html += `<option value="${this.languageLabels[language]}" selected="selected">${language}</option>`;
continue;
}
html += `<option value="${this.languageLabels[language]}">${language}</option>`;
}
html += `
</select>
<label>VITS / W2V2-VITS / Bert-VITS2 Settings:</label><br/>
<label for="vits_endpoint">Provider Endpoint:</label>
<input id="vits_endpoint" type="text" class="text_pole" maxlength="250" value="${this.defaultSettings.provider_endpoint}"/>
<span>Use <a target="_blank" href="https://github.com/Artrajz/vits-simple-api">vits-simple-api</a>.</span><br/>
<label for="vits_format">Audio format:</label>
<select id="vits_format">`;
for (let format of this.audioFormats) {
if (format == this.settings?.format) {
html += `<option value="${format}" selected="selected">${format}</option>`;
continue;
}
html += `<option value="${format}">${format}</option>`;
}
html += `
</select>
<label for="vits_length">Audio length: <span id="vits_length_output">${this.defaultSettings.length}</span></label>
<input id="vits_length" type="range" value="${this.defaultSettings.length}" min="0.0" max="5" step="0.01" />
<label for="vits_noise">Noise: <span id="vits_noise_output">${this.defaultSettings.noise}</span></label>
<input id="vits_noise" type="range" value="${this.defaultSettings.noise}" min="0.1" max="2" step="0.01" />
<label for="vits_noisew">SDP noise: <span id="vits_noisew_output">${this.defaultSettings.noisew}</span></label>
<input id="vits_noisew" type="range" value="${this.defaultSettings.noisew}" min="0.1" max="2" step="0.01" />
<label for="vits_segment_size">Segment Size: <span id="vits_segment_size_output">${this.defaultSettings.segment_size}</span></label>
<input id="vits_segment_size" type="range" value="${this.defaultSettings.segment_size}" min="0" max="1000" step="1" />
<label for="vits_streaming" class="checkbox_label">
<input id="vits_streaming" type="checkbox" />
<span>Streaming</span>
</label>
<label>W2V2-VITS Settings:</label><br/>
<label for="vits_dim_emotion">Dimensional emotion:</label>
<input id="vits_dim_emotion" type="number" class="text_pole" min="0" max="5457" step="1" value="${this.defaultSettings.dim_emotion}"/>
<label>BERT-VITS2 Settings:</label><br/>
<label for="vits_sdp_ratio">sdp_ratio: <span id="vits_sdp_ratio_output">${this.defaultSettings.sdp_ratio}</span></label>
<input id="vits_sdp_ratio" type="range" value="${this.defaultSettings.sdp_ratio}" min="0.0" max="1" step="0.01" />
<label for="vits_emotion">emotion: <span id="vits_emotion_output">${this.defaultSettings.emotion}</span></label>
<input id="vits_emotion" type="range" value="${this.defaultSettings.emotion}" min="0" max="9" step="1" />
<label for="vits_text_prompt">Text Prompt:</label>
<input id="vits_text_prompt" type="text" class="text_pole" maxlength="512" value="${this.defaultSettings.text_prompt}"/>
<label for="vits_style_text">Style text:</label>
<input id="vits_style_text" type="text" class="text_pole" maxlength="512" value="${this.defaultSettings.style_text}"/>
<label for="vits_style_weight">Style weight <span id="vits_style_weight_output">${this.defaultSettings.style_weight}</span></label>
<input id="vits_style_weight" type="range" value="${this.defaultSettings.style_weight}" min="0" max="1" step="0.01" />
`;
return html;
}
onSettingsChange() {
// Used when provider settings are updated from UI
this.settings.provider_endpoint = $('#vits_endpoint').val();
this.settings.lang = $('#vits_lang').val();
this.settings.format = $('#vits_format').val();
this.settings.dim_emotion = $('#vits_dim_emotion').val();
this.settings.text_prompt = $('#vits_text_prompt').val();
this.settings.style_text = $('#vits_style_text').val();
// Update the default TTS settings based on input fields
this.settings.length = $('#vits_length').val();
this.settings.noise = $('#vits_noise').val();
this.settings.noisew = $('#vits_noisew').val();
this.settings.segment_size = $('#vits_segment_size').val();
this.settings.streaming = $('#vits_streaming').is(':checked');
this.settings.sdp_ratio = $('#vits_sdp_ratio').val();
this.settings.emotion = $('#vits_emotion').val();
this.settings.style_weight = $('#vits_style_weight').val();
// Update the UI to reflect changes
$('#vits_length_output').text(this.settings.length);
$('#vits_noise_output').text(this.settings.noise);
$('#vits_noisew_output').text(this.settings.noisew);
$('#vits_segment_size_output').text(this.settings.segment_size);
$('#vits_sdp_ratio_output').text(this.settings.sdp_ratio);
$('#vits_emotion_output').text(this.settings.emotion);
$('#vits_style_weight_output').text(this.settings.style_weight);
saveTtsProviderSettings();
this.changeTTSSettings();
}
async loadSettings(settings) {
// Pupulate Provider UI given input settings
if (Object.keys(settings).length == 0) {
console.info('Using default TTS Provider settings');
}
// Only accept keys defined in defaultSettings
this.settings = this.defaultSettings;
for (const key in settings) {
if (key in this.settings) {
this.settings[key] = settings[key];
} else {
console.debug(`Ignoring non-user-configurable setting: ${key}`);
}
}
// Set initial values from the settings
$('#vits_endpoint').val(this.settings.provider_endpoint);
$('#vits_lang').val(this.settings.lang);
$('#vits_format').val(this.settings.format);
$('#vits_length').val(this.settings.length);
$('#vits_noise').val(this.settings.noise);
$('#vits_noisew').val(this.settings.noisew);
$('#vits_segment_size').val(this.settings.segment_size);
$('#vits_streaming').prop('checked', this.settings.streaming);
$('#vits_dim_emotion').val(this.settings.dim_emotion);
$('#vits_sdp_ratio').val(this.settings.sdp_ratio);
$('#vits_emotion').val(this.settings.emotion);
$('#vits_text_prompt').val(this.settings.text_prompt);
$('#vits_style_text').val(this.settings.style_text);
$('#vits_style_weight').val(this.settings.style_weight);
// Update the UI to reflect changes
$('#vits_length_output').text(this.settings.length);
$('#vits_noise_output').text(this.settings.noise);
$('#vits_noisew_output').text(this.settings.noisew);
$('#vits_segment_size_output').text(this.settings.segment_size);
$('#vits_sdp_ratio_output').text(this.settings.sdp_ratio);
$('#vits_emotion_output').text(this.settings.emotion);
$('#vits_style_weight_output').text(this.settings.style_weight);
// Register input/change event listeners to update settings on user interaction
$('#vits_endpoint').on('input', () => { this.onSettingsChange(); });
$('#vits_lang').on('change', () => { this.onSettingsChange(); });
$('#vits_format').on('change', () => { this.onSettingsChange(); });
$('#vits_length').on('change', () => { this.onSettingsChange(); });
$('#vits_noise').on('change', () => { this.onSettingsChange(); });
$('#vits_noisew').on('change', () => { this.onSettingsChange(); });
$('#vits_segment_size').on('change', () => { this.onSettingsChange(); });
$('#vits_streaming').on('change', () => { this.onSettingsChange(); });
$('#vits_dim_emotion').on('change', () => { this.onSettingsChange(); });
$('#vits_sdp_ratio').on('change', () => { this.onSettingsChange(); });
$('#vits_emotion').on('change', () => { this.onSettingsChange(); });
$('#vits_text_prompt').on('change', () => { this.onSettingsChange(); });
$('#vits_style_text').on('change', () => { this.onSettingsChange(); });
$('#vits_style_weight').on('change', () => { this.onSettingsChange(); });
await this.checkReady();
console.info('VITS: Settings loaded');
}
// Perform a simple readiness check by trying to fetch voiceIds
async checkReady() {
await Promise.allSettled([this.fetchTtsVoiceObjects(), this.changeTTSSettings()]);
}
async onRefreshClick() {
return;
}
//#################//
// TTS Interfaces //
//#################//
async getVoice(voiceName) {
if (this.voices.length == 0) {
this.voices = await this.fetchTtsVoiceObjects();
}
const match = this.voices.filter(
v => v.name == voiceName,
)[0];
if (!match) {
throw `TTS Voice name ${voiceName} not found`;
}
return match;
}
async getVoiceById(voiceId) {
if (this.voices.length == 0) {
this.voices = await this.fetchTtsVoiceObjects();
}
const match = this.voices.filter(
v => v.voice_id == voiceId,
)[0];
if (!match) {
throw `TTS Voice id ${voiceId} not found`;
}
return match;
}
async generateTts(text, voiceId) {
const response = await this.fetchTtsGeneration(text, voiceId);
return response;
}
//###########//
// API CALLS //
//###########//
async fetchTtsVoiceObjects() {
const response = await fetch(`${this.settings.provider_endpoint}/voice/speakers`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.json()}`);
}
const jsonData = await response.json();
const voices = [];
const addVoices = (modelType) => {
jsonData[modelType].forEach(voice => {
voices.push({
name: `[${modelType}] ${voice.name} (${voice.lang})`,
voice_id: `${modelType}&${voice.id}`,
preview_url: false,
lang: voice.lang,
});
});
};
for (const key in this.modelTypes) {
addVoices(this.modelTypes[key]);
}
this.voices = voices; // Assign to the class property
return voices; // Also return this list
}
// Each time a parameter is changed, we change the configuration
async changeTTSSettings() {
}
/**
* Fetch TTS generation from the API.
* @param {string} inputText Text to generate TTS for
* @param {string} voiceId Voice ID to use (model_type&speaker_id))
* @returns {Promise<Response|string>} Fetch response
*/
async fetchTtsGeneration(inputText, voiceId, lang = null, forceNoStreaming = false) {
console.info(`Generating new TTS for voice_id ${voiceId}`);
const streaming = !forceNoStreaming && this.settings.streaming;
const [model_type, speaker_id] = voiceId.split('&');
const params = new URLSearchParams();
params.append('text', inputText);
params.append('id', speaker_id);
if (streaming) {
params.append('streaming', streaming);
// Streaming response only supports MP3
}
else {
params.append('format', this.settings.format);
}
params.append('lang', lang ?? this.settings.lang);
params.append('length', this.settings.length);
params.append('noise', this.settings.noise);
params.append('noisew', this.settings.noisew);
params.append('segment_size', this.settings.segment_size);
if (model_type == this.modelTypes.W2V2_VITS) {
params.append('emotion', this.settings.dim_emotion);
}
else if (model_type == this.modelTypes.BERT_VITS2) {
params.append('sdp_ratio', this.settings.sdp_ratio);
params.append('emotion', this.settings.emotion);
if (this.settings.text_prompt) {
params.append('text_prompt', this.settings.text_prompt);
}
if (this.settings.style_text) {
params.append('style_text', this.settings.style_text);
params.append('style_weight', this.settings.style_weight);
}
}
const url = `${this.settings.provider_endpoint}/voice/${model_type.toLowerCase()}`;
if (streaming) {
return url + `?${params.toString()}`;
}
const response = await fetch(
url,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params,
},
);
if (!response.ok) {
toastr.error(response.statusText, 'TTS Generation Failed');
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
return response;
}
/**
* Preview TTS for a given voice ID.
* @param {string} id Voice ID
*/
async previewTtsVoice(id) {
this.audioElement.pause();
this.audioElement.currentTime = 0;
const voice = await this.getVoiceById(id);
const lang = voice.lang.includes(this.settings.lang) ? this.settings.lang : voice.lang[0];
let lang_code = this.langKey2LangCode[lang];
const text = getPreviewString(lang_code);
const response = await this.fetchTtsGeneration(text, id, lang, true);
if (typeof response != 'string') {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
const audio = await response.blob();
const url = URL.createObjectURL(audio);
this.audioElement.src = url;
this.audioElement.play();
}
}
// Interface not used
async fetchTtsFromHistory(history_item_id) {
return Promise.resolve(history_item_id);
}
}

View File

@ -178,8 +178,37 @@ async function loadGroupChat(chatId) {
return [];
}
async function validateGroup(group) {
if (!group) return;
// Validate that all members exist as characters
let dirty = false;
group.members = group.members.filter(member => {
const character = characters.find(x => x.avatar === member || x.name === member);
if (!character) {
const msg = `Warning: Listed member ${member} does not exist as a character. It will be removed from the group.`;
toastr.warning(msg, 'Group Validation');
console.warn(msg);
dirty = true;
}
return character;
});
if (dirty) {
await editGroup(group.id, true, false);
}
}
export async function getGroupChat(groupId, reload = false) {
const group = groups.find((x) => x.id === groupId);
if (!group) {
console.warn('Group not found', groupId);
return;
}
// Run validation before any loading
validateGroup(group);
const chat_id = group.chat_id;
const data = await loadGroupChat(chat_id);
let freshChat = false;
@ -197,7 +226,6 @@ export async function getGroupChat(groupId, reload = false) {
if (group && Array.isArray(group.members)) {
for (let member of group.members) {
const character = characters.find(x => x.avatar === member || x.name === member);
if (!character) {
continue;
}
@ -219,10 +247,8 @@ export async function getGroupChat(groupId, reload = false) {
freshChat = true;
}
if (group) {
let metadata = group.chat_metadata ?? {};
updateChatMetadata(metadata, true);
}
let metadata = group.chat_metadata ?? {};
updateChatMetadata(metadata, true);
if (reload) {
select_group_chats(groupId, true);
@ -1576,6 +1602,8 @@ export async function openGroupById(groupId) {
}
if (!is_send_press && !is_group_generating) {
select_group_chats(groupId);
if (selected_group !== groupId) {
await clearChat();
cancelTtsPlay();
@ -1587,8 +1615,6 @@ export async function openGroupById(groupId) {
chat.length = 0;
await getGroupChat(groupId);
}
select_group_chats(groupId);
}
}

View File

@ -9,6 +9,71 @@ const langs = await fetch('/locales/lang.json').then(response => response.json()
// eslint-disable-next-line prefer-const
var localeData = await getLocaleData(localeFile);
/**
* An observer that will check if any new i18n elements are added to the document
* @type {MutationObserver}
*/
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE && node instanceof Element) {
if (node.hasAttribute('data-i18n')) {
translateElement(node);
}
node.querySelectorAll('[data-i18n]').forEach(element => {
translateElement(element);
});
}
});
if (mutation.attributeName === 'data-i18n' && mutation.target instanceof Element) {
translateElement(mutation.target);
}
});
});
/**
* Translates a template string with named arguments
*
* Uses the template literal with all values replaced by index placeholder for translation key.
*
* @example
* ```js
* toastr.warn(t`Tag ${tagName} not found.`);
* ```
* Should be translated in the translation files as:
* ```
* Tag ${0} not found. -> Tag ${0} nicht gefunden.
* ```
*
* @param {TemplateStringsArray} strings - Template strings array
* @param {...any} values - Values for placeholders in the template string
* @returns {string} Translated and formatted string
*/
export function t(strings, ...values) {
let str = strings.reduce((result, string, i) => result + string + (values[i] !== undefined ? `\${${i}}` : ''), '');
let translatedStr = translate(str);
// Replace indexed placeholders with actual values
return translatedStr.replace(/\$\{(\d+)\}/g, (match, index) => values[index]);
}
/**
* Translates a given key or text
*
* If the translation is based on a key, that one is used to find a possible translation in the translation file.
* The original text still has to be provided, as that is the default value being returned if no translation is found.
*
* For in-code text translation on a format string, using the template literal `t` is preferred.
*
* @param {string} text - The text to translate
* @param {string?} key - The key to use for translation. If not provided, text is used as the key.
* @returns {string} - The translated text
*/
export function translate(text, key = null) {
const translationKey = key || text;
return localeData?.[translationKey] || text;
}
/**
* Fetches the locale data for the given language.
* @param {string} language Language code
@ -40,6 +105,29 @@ function findLang(language) {
return supportedLang;
}
/**
* Translates a given element based on its data-i18n attribute.
* @param {Element} element The element to translate
*/
function translateElement(element) {
const keys = element.getAttribute('data-i18n').split(';'); // Multi-key entries are ; delimited
for (const key of keys) {
const attributeMatch = key.match(/\[(\S+)\](.+)/); // [attribute]key
if (attributeMatch) { // attribute-tagged key
const localizedValue = localeData?.[attributeMatch[2]];
if (localizedValue || localizedValue === '') {
element.setAttribute(attributeMatch[1], localizedValue);
}
} else { // No attribute tag, treat as 'text'
const localizedValue = localeData?.[key];
if (localizedValue || localizedValue === '') {
element.textContent = localizedValue;
}
}
}
}
async function getMissingTranslations() {
const missingData = [];
@ -103,22 +191,7 @@ export function applyLocale(root = document) {
//find all the elements with `data-i18n` attribute
$root.find('[data-i18n]').each(function () {
//read the translation from the language data
const keys = $(this).data('i18n').split(';'); // Multi-key entries are ; delimited
for (const key of keys) {
const attributeMatch = key.match(/\[(\S+)\](.+)/); // [attribute]key
if (attributeMatch) { // attribute-tagged key
const localizedValue = localeData?.[attributeMatch[2]];
if (localizedValue || localizedValue == '') {
$(this).attr(attributeMatch[1], localizedValue);
}
} else { // No attribute tag, treat as 'text'
const localizedValue = localeData?.[key];
if (localizedValue || localizedValue == '') {
$(this).text(localizedValue);
}
}
}
translateElement(this);
});
if (root !== document) {
@ -126,7 +199,6 @@ export function applyLocale(root = document) {
}
}
function addLanguagesToDropdown() {
const uiLanguageSelects = $('#ui_language_select, #onboarding_ui_language_select');
for (const langObj of langs) { // Set the value to the language code
@ -159,6 +231,13 @@ export function initLocales() {
location.reload();
});
observer.observe(document, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['data-i18n'],
});
registerDebugFunction('getMissingTranslations', 'Get missing translations', 'Detects missing localization data in the current locale and dumps the data into the browser console. If the current locale is English, searches all other locales.', getMissingTranslations);
registerDebugFunction('applyLocale', 'Apply locale', 'Reapplies the currently selected locale to the page.', applyLocale);
}

View File

@ -1,25 +1,55 @@
const ELEMENT_ID = 'loader';
import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js';
/** @type {Popup} */
let loaderPopup;
let preloaderYoinked = false;
export function showLoader() {
const container = $('<div></div>').attr('id', ELEMENT_ID);
const loader = $('<div></div>').attr('id', 'load-spinner').addClass('fa-solid fa-gear fa-spin fa-3x');
container.append(loader);
$('body').append(container);
// Two loaders don't make sense. Don't await, we can overlay the old loader while it closes
if (loaderPopup) loaderPopup.complete(POPUP_RESULT.CANCELLED);
loaderPopup = new Popup(`
<div id="loader">
<div id="load-spinner" class="fa-solid fa-gear fa-spin fa-3x"></div>
</div>`, POPUP_TYPE.DISPLAY, null, { transparent: true, animation: 'none' });
// No close button, loaders are not closable
loaderPopup.closeButton.style.display = 'none';
loaderPopup.show();
}
export async function hideLoader() {
//Sets up a 2-step animation. Spinner blurs/fades out, and then the loader shadow does the same.
$('#load-spinner').on('transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd', function () {
$(`#${ELEMENT_ID}`)
//only fade out the spinner and replace with login screen
.animate({ opacity: 0 }, 300, function () {
$(`#${ELEMENT_ID}`).remove();
if (!loaderPopup) {
console.warn('There is no loader showing to hide');
return Promise.resolve();
}
return new Promise((resolve) => {
// Spinner blurs/fades out
$('#load-spinner').on('transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd', function () {
$('#loader').remove();
// Yoink preloader entirely; it only exists to cover up unstyled content while loading JS
// If it's present, we remove it once and then it's gone.
yoinkPreloader();
loaderPopup.complete(POPUP_RESULT.AFFIRMATIVE).then(() => {
loaderPopup = null;
resolve();
});
});
$('#load-spinner')
.css({
'filter': 'blur(15px)',
'opacity': '0',
});
});
$('#load-spinner')
.css({
'filter': 'blur(15px)',
'opacity': '0',
});
}
function yoinkPreloader() {
if (preloaderYoinked) return;
document.getElementById('preloader').remove();
preloaderYoinked = true;
}

View File

@ -254,7 +254,7 @@ function getCurrentSwipeId() {
// For swipe macro, we are accepting using the message that is currently being swiped
const mid = getLastMessageId({ exclude_swipe_in_propress: false });
const swipeId = chat[mid]?.swipe_id;
return swipeId ? swipeId + 1 : null;
return swipeId !== null ? swipeId + 1 : null;
}
/**
@ -401,7 +401,7 @@ function timeDiffReplace(input) {
const time2 = moment(matchPart2);
const timeDifference = moment.duration(time1.diff(time2));
return timeDifference.humanize();
return timeDifference.humanize(true);
});
return output;

View File

@ -689,7 +689,7 @@ function formatWorldInfo(value) {
return '';
}
if (!oai_settings.wi_format) {
if (!oai_settings.wi_format.trim()) {
return value;
}

View File

@ -1,3 +1,4 @@
import { shouldSendOnEnter } from './RossAscends-mods.js';
import { power_user } from './power-user.js';
import { removeFromArray, runAfterAnimation, uuidv4 } from './utils.js';
@ -35,6 +36,7 @@ export const POPUP_RESULT = {
* @property {boolean?} [transparent=false] - Whether to display the popup in transparent mode (no background, border, shadow or anything, only its content)
* @property {boolean?} [allowHorizontalScrolling=false] - Whether to allow horizontal scrolling in the popup
* @property {boolean?} [allowVerticalScrolling=false] - Whether to allow vertical scrolling in the popup
* @property {'slow'|'fast'|'none'?} [animation='slow'] - Animation speed for the popup (opening, closing, ...)
* @property {POPUP_RESULT|number?} [defaultResult=POPUP_RESULT.AFFIRMATIVE] - The default result of this popup when Enter is pressed. Can be changed from `POPUP_RESULT.AFFIRMATIVE`.
* @property {CustomPopupButton[]|string[]?} [customButtons=null] - Custom buttons to add to the popup. If only strings are provided, the buttons will be added with default options, and their result will be in order from `2` onward.
* @property {CustomPopupInput[]?} [customInputs=null] - Custom inputs to add to the popup. The display below the content and the input box, one by one.
@ -98,7 +100,7 @@ const showPopupHelper = {
const result = await popup.show();
if (typeof result === 'string' || typeof result === 'boolean') throw new Error(`Invalid popup result. CONFIRM popups only support numbers, or null. Result: ${result}`);
return result;
}
},
};
export class Popup {
@ -142,7 +144,7 @@ export class Popup {
* @param {string} [inputValue=''] - The initial value of the input field
* @param {PopupOptions} [options={}] - Additional options for the popup
*/
constructor(content, type, inputValue = '', { okButton = null, cancelButton = null, rows = 1, wide = false, wider = false, large = false, transparent = false, allowHorizontalScrolling = false, allowVerticalScrolling = false, defaultResult = POPUP_RESULT.AFFIRMATIVE, customButtons = null, customInputs = null, onClosing = null, onClose = null, cropAspect = null, cropImage = null } = {}) {
constructor(content, type, inputValue = '', { okButton = null, cancelButton = null, rows = 1, wide = false, wider = false, large = false, transparent = false, allowHorizontalScrolling = false, allowVerticalScrolling = false, animation = 'fast', defaultResult = POPUP_RESULT.AFFIRMATIVE, customButtons = null, customInputs = null, onClosing = null, onClose = null, cropAspect = null, cropImage = null } = {}) {
Popup.util.popups.push(this);
// Make this popup uniquely identifiable
@ -175,6 +177,7 @@ export class Popup {
if (transparent) this.dlg.classList.add('transparent_dialogue_popup');
if (allowHorizontalScrolling) this.dlg.classList.add('horizontal_scrolling_dialogue_popup');
if (allowVerticalScrolling) this.dlg.classList.add('vertical_scrolling_dialogue_popup');
if (animation) this.dlg.classList.add('popup--animation-' + animation);
// If custom button captions are provided, we set them beforehand
this.okButton.textContent = typeof okButton === 'string' ? okButton : 'OK';
@ -210,7 +213,7 @@ export class Popup {
this.customInputs = customInputs;
this.customInputs?.forEach(input => {
if (!input.id || !(typeof input.id === 'string')) {
console.warn('Given custom input does not have a valid id set')
console.warn('Given custom input does not have a valid id set');
return;
}
@ -318,20 +321,20 @@ export class Popup {
if (String(undefined) === String(resultControl.dataset.result)) return;
if (isNaN(result)) throw new Error('Invalid result control. Result must be a number. ' + resultControl.dataset.result);
const type = resultControl.dataset.resultEvent || 'click';
resultControl.addEventListener(type, () => this.complete(result));
resultControl.addEventListener(type, async () => await this.complete(result));
});
// Bind dialog listeners manually, so we can be sure context is preserved
const cancelListener = (evt) => {
this.complete(POPUP_RESULT.CANCELLED);
const cancelListener = async (evt) => {
evt.preventDefault();
evt.stopPropagation();
await this.complete(POPUP_RESULT.CANCELLED);
window.removeEventListener('cancel', cancelListenerBound);
};
const cancelListenerBound = cancelListener.bind(this);
this.dlg.addEventListener('cancel', cancelListenerBound);
const keyListener = (evt) => {
const keyListener = async (evt) => {
switch (evt.key) {
case 'Enter': {
// CTRL+Enter counts as a closing action, but all other modifiers (ALT, SHIFT) should not trigger this
@ -342,15 +345,23 @@ export class Popup {
if (this.dlg != document.activeElement?.closest('.popup'))
return;
// Check if the current focus is a result control. Only should we apply the compelete action
// Check if the current focus is a result control. Only should we apply the complete action
const resultControl = document.activeElement?.closest('.result-control');
if (!resultControl)
return;
const result = Number(document.activeElement.getAttribute('data-result') ?? this.defaultResult);
this.complete(result);
// Check if we are inside an input type text or a textarea field and send on enter is disabled
const textarea = document.activeElement?.closest('textarea');
if (textarea instanceof HTMLTextAreaElement && !shouldSendOnEnter())
return;
const input = document.activeElement?.closest('input[type="text"]');
if (input instanceof HTMLInputElement && !shouldSendOnEnter())
return;
evt.preventDefault();
evt.stopPropagation();
const result = Number(document.activeElement.getAttribute('data-result') ?? this.defaultResult);
await this.complete(result);
window.removeEventListener('keydown', keyListenerBound);
break;
@ -430,8 +441,10 @@ export class Popup {
* - All other will return the result value as provided as `POPUP_RESULT` or a custom number value
*
* @param {POPUP_RESULT|number} result - The result of the popup (either an existing `POPUP_RESULT` or a custom result value)
*
* @returns {Promise<string|number|boolean?>} A promise that resolves with the value of the popup when it is completed.
*/
complete(result) {
async complete(result) {
// In all cases besides INPUT the popup value should be the result
/** @type {POPUP_RESULT|number|boolean|string?} */
let value = result;
@ -468,6 +481,8 @@ export class Popup {
Popup.util.lastResult = { value, result, inputResults: this.inputResults };
this.#hide();
return this.#promise;
}
completeAffirmative() {
return this.complete(POPUP_RESULT.AFFIRMATIVE);
@ -627,8 +642,13 @@ export function fixToastrForDialogs() {
// Now another case is if we only have one popup and that is currently closing. In that case the toastr container exists,
// but we don't have an open dialog to move it into. It's just inside the existing one that will be gone in milliseconds.
// To prevent new toasts from being showing up in there and then vanish in an instant,
// we move the toastr back to the main body
// we move the toastr back to the main body, or delete if its empty
if (!dlg && isAlreadyPresent) {
document.body.appendChild(toastContainer);
if (!toastContainer.childNodes.length) {
toastContainer.remove();
} else {
document.body.appendChild(toastContainer);
toastContainer.classList.add('toast-top-center');
}
}
}

View File

@ -40,7 +40,7 @@ import { tokenizers } from './tokenizers.js';
import { BIAS_CACHE } from './logit-bias.js';
import { renderTemplateAsync } from './templates.js';
import { countOccurrences, debounce, delay, download, getFileText, isOdd, isTrueBoolean, onlyUnique, resetScrollHeight, shuffle, sortMoments, stringToRange, timestampToMoment } from './utils.js';
import { countOccurrences, debounce, delay, download, getFileText, getStringHash, isOdd, isTrueBoolean, onlyUnique, resetScrollHeight, shuffle, sortMoments, stringToRange, timestampToMoment } from './utils.js';
import { FILTER_TYPES } from './filters.js';
import { PARSER_FLAG, SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
@ -335,6 +335,8 @@ const storage_keys = {
compact_input_area: 'compact_input_area',
auto_connect_legacy: 'AutoConnectEnabled',
auto_load_chat_legacy: 'AutoLoadChatEnabled',
storyStringValidationCache: 'StoryStringValidationCache',
};
const contextControls = [
@ -2105,6 +2107,9 @@ export function fuzzySearchGroups(searchValue) {
*/
export function renderStoryString(params) {
try {
// Validate and log possible warnings/errors
validateStoryString(power_user.context.story_string, params);
// compile the story string template into a function, with no HTML escaping
const compiledTemplate = Handlebars.compile(power_user.context.story_string, { noEscape: true });
@ -2132,6 +2137,55 @@ export function renderStoryString(params) {
}
}
/**
* Validate the story string for possible warnings or issues
*
* @param {string} storyString - The story string
* @param {Object} params - The story string parameters
*/
function validateStoryString(storyString, params) {
/** @type {{hashCache: {[hash: string]: {fieldsWarned: {[key: string]: boolean}}}}} */
const cache = JSON.parse(localStorage.getItem(storage_keys.storyStringValidationCache)) ?? { hashCache: {} };
const hash = getStringHash(storyString);
// Initialize the cache for the current hash if it doesn't exist
if (!cache.hashCache[hash]) {
cache.hashCache[hash] = { fieldsWarned: {} };
}
const currentCache = cache.hashCache[hash];
const fieldsToWarn = [];
function validateMissingField(field, fallbackLegacyField = null) {
const contains = storyString.includes(`{{${field}}}`) || (!!fallbackLegacyField && storyString.includes(`{{${fallbackLegacyField}}}`));
if (!contains && params[field]) {
const wasLogged = currentCache.fieldsWarned[field];
if (!wasLogged) {
fieldsToWarn.push(field);
currentCache.fieldsWarned[field] = true;
}
console.warn(`The story string does not contain {{${field}}}, but it would contain content:\n`, params[field]);
}
}
validateMissingField('description');
validateMissingField('personality');
validateMissingField('persona');
validateMissingField('scenario');
validateMissingField('system');
validateMissingField('wiBefore', 'loreBefore');
validateMissingField('wiAfter', 'loreAfter');
if (fieldsToWarn.length > 0) {
const fieldsList = fieldsToWarn.map(field => `{{${field}}}`).join(', ');
toastr.warning(`The story string does not contain the following fields, but they would contain content: ${fieldsList}`, 'Story String Validation');
}
localStorage.setItem(storage_keys.storyStringValidationCache, JSON.stringify(cache));
}
const sortFunc = (a, b) => power_user.sort_order == 'asc' ? compareFunc(a, b) : compareFunc(b, a);
const compareFunc = (first, second) => {
const a = first[power_user.sort_field];

File diff suppressed because it is too large Load Diff

View File

@ -62,7 +62,7 @@ export class SlashCommandArgument {
* @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} enums
* @param {(executor:SlashCommandExecutor, scope:SlashCommandScope)=>SlashCommandEnumValue[]} enumProvider function that returns auto complete options
*/
constructor(description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], enumProvider = null, forceEnum = true) {
constructor(description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], enumProvider = null, forceEnum = false) {
this.description = description;
this.typeList = types ? Array.isArray(types) ? types : [types] : [];
this.isRequired = isRequired ?? false;
@ -93,7 +93,7 @@ export class SlashCommandNamedArgument extends SlashCommandArgument {
* @param {string|SlashCommandClosure} [props.defaultValue=null] default value if no value is provided
* @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} [props.enumList=[]] list of accepted values
* @param {(executor:SlashCommandExecutor, scope:SlashCommandScope)=>SlashCommandEnumValue[]} [props.enumProvider=null] function that returns auto complete options
* @param {boolean} [props.forceEnum=true] default: true - whether the input must match one of the enum values
* @param {boolean} [props.forceEnum=false] default: false - whether the input must match one of the enum values
*/
static fromProps(props) {
return new SlashCommandNamedArgument(
@ -106,7 +106,7 @@ export class SlashCommandNamedArgument extends SlashCommandArgument {
props.enumList ?? [],
props.aliasList ?? [],
props.enumProvider ?? null,
props.forceEnum ?? true,
props.forceEnum ?? false,
);
}
@ -123,9 +123,9 @@ export class SlashCommandNamedArgument extends SlashCommandArgument {
* @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} [enums=[]]
* @param {string[]} [aliases=[]]
* @param {(executor:SlashCommandExecutor, scope:SlashCommandScope)=>SlashCommandEnumValue[]} [enumProvider=null] function that returns auto complete options
* @param {boolean} [forceEnum=true]
* @param {boolean} [forceEnum=false]
*/
constructor(name, description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], aliases = [], enumProvider = null, forceEnum = true) {
constructor(name, description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], aliases = [], enumProvider = null, forceEnum = false) {
super(description, types, isRequired, acceptsMultiple, defaultValue, enums, enumProvider, forceEnum);
this.name = name;
this.aliasList = aliases ? Array.isArray(aliases) ? aliases : [aliases] : [];

View File

@ -160,7 +160,7 @@ async function* parseStreamData(json) {
return;
}
// llama.cpp?
else if (typeof json.content === 'string' && json.content.length > 0) {
else if (typeof json.content === 'string' && json.content.length > 0 && json.object !== 'chat.completion.chunk') {
for (let i = 0; i < json.content.length; i++) {
const str = json.content[i];
yield {

View File

@ -263,6 +263,8 @@ export const setting_names = [
'bypass_status_check',
];
const DYNATEMP_BLOCK = document.getElementById('dynatemp_block_ooba');
export function validateTextGenUrl() {
const selector = SERVER_INPUTS[settings.type];
@ -1045,6 +1047,10 @@ export function isJsonSchemaSupported() {
return [TABBY, LLAMACPP].includes(settings.type) && main_api === 'textgenerationwebui';
}
function isDynamicTemperatureSupported() {
return settings.dynatemp && DYNATEMP_BLOCK?.dataset?.tgType?.includes(settings.type);
}
function getLogprobsNumber() {
if (settings.type === VLLM || settings.type === INFERMATICAI) {
return 5;
@ -1055,6 +1061,7 @@ function getLogprobsNumber() {
export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate, isContinue, cfgValues, type) {
const canMultiSwipe = !isContinue && !isImpersonate && type !== 'quiet';
const dynatemp = isDynamicTemperatureSupported();
const { banned_tokens, banned_strings } = getCustomTokenBans();
let params = {
@ -1063,7 +1070,7 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate,
'max_new_tokens': maxTokens,
'max_tokens': maxTokens,
'logprobs': power_user.request_token_probabilities ? getLogprobsNumber() : undefined,
'temperature': settings.dynatemp ? (settings.min_temp + settings.max_temp) / 2 : settings.temp,
'temperature': dynatemp ? (settings.min_temp + settings.max_temp) / 2 : settings.temp,
'top_p': settings.top_p,
'typical_p': settings.typical_p,
'typical': settings.typical_p,
@ -1081,11 +1088,11 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate,
'length_penalty': settings.length_penalty,
'early_stopping': settings.early_stopping,
'add_bos_token': settings.add_bos_token,
'dynamic_temperature': settings.dynatemp ? true : undefined,
'dynatemp_low': settings.dynatemp ? settings.min_temp : undefined,
'dynatemp_high': settings.dynatemp ? settings.max_temp : undefined,
'dynatemp_range': settings.dynatemp ? (settings.max_temp - settings.min_temp) / 2 : undefined,
'dynatemp_exponent': settings.dynatemp ? settings.dynatemp_exponent : undefined,
'dynamic_temperature': dynatemp ? true : undefined,
'dynatemp_low': dynatemp ? settings.min_temp : undefined,
'dynatemp_high': dynatemp ? settings.max_temp : undefined,
'dynatemp_range': dynatemp ? (settings.max_temp - settings.min_temp) / 2 : undefined,
'dynatemp_exponent': dynatemp ? settings.dynatemp_exponent : undefined,
'smoothing_factor': settings.smoothing_factor,
'smoothing_curve': settings.smoothing_curve,
'dry_allowed_length': settings.dry_allowed_length,

View File

@ -803,7 +803,7 @@ export function getImageSizeFromDataURL(dataUrl) {
export function getCharaFilename(chid) {
const context = getContext();
const fileName = context.characters[chid ?? context.characterId].avatar;
const fileName = context.characters[chid ?? context.characterId]?.avatar;
if (fileName) {
return fileName.replace(/\.[^/.]+$/, '');

View File

@ -1373,7 +1373,11 @@ export function registerVariableCommands() {
name: 'times',
callback: timesCallback,
returns: 'result of the last executed command',
namedArgumentList: [],
namedArgumentList: [
new SlashCommandNamedArgument(
'guard', 'disable loop iteration limit', [ARGUMENT_TYPE.STRING], false, false, null, commonEnumProviders.boolean('onOff')(),
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'repeats',

View File

@ -4515,7 +4515,8 @@ a {
}
.mes_img_controls .right_menu_button {
filter: brightness(80%);
filter: brightness(90%);
text-shadow: 1px 1px var(--SmartThemeShadowColor) !important;
padding: 1px;
height: 1.25em;
width: 1.25em;
@ -4540,6 +4541,10 @@ a {
display: flex;
}
body:not(.caption) .mes_img_caption {
display: none;
}
.img_enlarged_holder {
/* Scaling via flex-grow and object-fit only works if we have some kind of base-height set */
min-height: 120px;

View File

@ -609,10 +609,6 @@ const postSetupTasks = async function () {
console.warn(color.yellow('Basic Authentication is enabled, but username or password is not set or empty!'));
}
}
if (listen && !basicAuthMode && enableAccounts) {
await userModule.checkAccountsProtection();
}
};
/**
@ -631,16 +627,6 @@ async function loadPlugins() {
}
}
if (listen && !enableWhitelist && !basicAuthMode) {
if (getConfigValue('securityOverride', false)) {
console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.'));
}
else {
console.error(color.red('Your SillyTavern is currently unsecurely open to the public. Enable whitelisting or basic authentication.'));
process.exit(1);
}
}
/**
* Set the title of the terminal window
* @param {string} title Desired title for the window
@ -654,10 +640,53 @@ function setWindowTitle(title) {
}
}
/**
* Prints an error message and exits the process if necessary
* @param {string} message The error message to print
* @returns {void}
*/
function logSecurityAlert(message) {
if (basicAuthMode || enableWhitelist) return; // safe!
console.error(color.red(message));
if (getConfigValue('securityOverride', false)) {
console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.'));
return;
}
process.exit(1);
}
async function verifySecuritySettings() {
// Skip all security checks as listen is set to false
if (!listen) {
return;
}
if (!enableAccounts) {
logSecurityAlert('Your SillyTavern is currently insecurely open to the public. Enable whitelisting, basic authentication or user accounts.');
}
const users = await userModule.getAllEnabledUsers();
const unprotectedUsers = users.filter(x => !x.password);
const unprotectedAdminUsers = unprotectedUsers.filter(x => x.admin);
if (unprotectedUsers.length > 0) {
console.warn(color.blue('A friendly reminder that the following users are not password protected:'));
unprotectedUsers.map(x => `${color.yellow(x.handle)} ${color.red(x.admin ? '(admin)' : '')}`).forEach(x => console.warn(x));
console.log();
console.warn(`Consider setting a password in the admin panel or by using the ${color.blue('recover.js')} script.`);
console.log();
if (unprotectedAdminUsers.length > 0) {
logSecurityAlert('If you are not using basic authentication or whitelisting, you should set a password for all admin users.');
}
}
}
// User storage module needs to be initialized before starting the server
userModule.initUserStorage(dataRoot)
.then(userModule.ensurePublicDirectoriesExist)
.then(userModule.migrateUserData)
.then(verifySecuritySettings)
.then(preSetupTasks)
.finally(() => {
if (cliArguments.ssl) {

View File

@ -499,15 +499,16 @@ function convertWorldInfoToCharacterBook(name, entries) {
* Import a character from a YAML file.
* @param {string} uploadPath Path to the uploaded file
* @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects
* @param {string|undefined} preservedFileName Preserved file name
* @returns {Promise<string>} Internal name of the character
*/
async function importFromYaml(uploadPath, context) {
async function importFromYaml(uploadPath, context, preservedFileName) {
const fileText = fs.readFileSync(uploadPath, 'utf8');
fs.rmSync(uploadPath);
const yamlData = yaml.parse(fileText);
console.log('Importing from YAML');
yamlData.name = sanitize(yamlData.name);
const fileName = getPngName(yamlData.name, context.request.user.directories);
const fileName = preservedFileName || getPngName(yamlData.name, context.request.user.directories);
let char = convertToV2({
'name': yamlData.name,
'description': yamlData.context ?? '',
@ -532,9 +533,10 @@ async function importFromYaml(uploadPath, context) {
* @param {string} uploadPath
* @param {object} params
* @param {import('express').Request} params.request
* @param {string|undefined} preservedFileName Preserved file name
* @returns {Promise<string>} Internal name of the character
*/
async function importFromCharX(uploadPath, { request }) {
async function importFromCharX(uploadPath, { request }, preservedFileName) {
const data = fs.readFileSync(uploadPath);
fs.rmSync(uploadPath);
console.log('Importing from CharX');
@ -567,7 +569,7 @@ async function importFromCharX(uploadPath, { request }) {
unsetFavFlag(card);
card['create_date'] = humanizedISO8601DateTime();
card.name = sanitize(card.name);
const fileName = getPngName(card.name, request.user.directories);
const fileName = preservedFileName || getPngName(card.name, request.user.directories);
const result = await writeCharacterData(avatar, JSON.stringify(card), fileName, request);
return result ? fileName : '';
}
@ -576,9 +578,10 @@ async function importFromCharX(uploadPath, { request }) {
* Import a character from a JSON file.
* @param {string} uploadPath Path to the uploaded file
* @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects
* @param {string|undefined} preservedFileName Preserved file name
* @returns {Promise<string>} Internal name of the character
*/
async function importFromJson(uploadPath, { request }) {
async function importFromJson(uploadPath, { request }, preservedFileName) {
const data = fs.readFileSync(uploadPath, 'utf8');
fs.unlinkSync(uploadPath);
@ -590,7 +593,7 @@ async function importFromJson(uploadPath, { request }) {
unsetFavFlag(jsonData);
jsonData = readFromV2(jsonData);
jsonData['create_date'] = humanizedISO8601DateTime();
const pngName = getPngName(jsonData.data?.name || jsonData.name, request.user.directories);
const pngName = preservedFileName || getPngName(jsonData.data?.name || jsonData.name, request.user.directories);
const char = JSON.stringify(jsonData);
const result = await writeCharacterData(defaultAvatarPath, char, pngName, request);
return result ? pngName : '';
@ -600,7 +603,7 @@ async function importFromJson(uploadPath, { request }) {
if (jsonData.creator_notes) {
jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', '');
}
const pngName = getPngName(jsonData.name, request.user.directories);
const pngName = preservedFileName || getPngName(jsonData.name, request.user.directories);
let char = {
'name': jsonData.name,
'description': jsonData.description ?? '',
@ -626,7 +629,7 @@ async function importFromJson(uploadPath, { request }) {
if (jsonData.creator_notes) {
jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', '');
}
const pngName = getPngName(jsonData.char_name, request.user.directories);
const pngName = preservedFileName || getPngName(jsonData.char_name, request.user.directories);
let char = {
'name': jsonData.char_name,
'description': jsonData.char_persona ?? '',
@ -1089,8 +1092,8 @@ function getPngName(file, directories) {
* @returns {string | undefined} - The preserved name if the request is valid, otherwise undefined
*/
function getPreservedName(request) {
return request.body.file_type === 'png' && request.body.preserve_file_name === 'true' && request.file?.originalname
? path.parse(request.file.originalname).name
return typeof request.body.preserved_name === 'string' && request.body.preserved_name.length > 0
? path.parse(request.body.preserved_name).name
: undefined;
}
@ -1123,6 +1126,10 @@ router.post('/import', urlencodedParser, async function (request, response) {
return response.sendStatus(400);
}
if (preservedFileName) {
invalidateThumbnail(request.user.directories, 'avatar', `${preservedFileName}.png`);
}
response.send({ file_name: fileName });
} catch (err) {
console.log(err);

View File

@ -43,7 +43,11 @@ router.post('/caption-image', jsonParser, async (request, response) => {
key = readSecret(request.user.directories, SECRET_KEYS.KOBOLDCPP);
}
if (!key && !request.body.reverse_proxy && ['custom', 'ooba', 'koboldcpp'].includes(request.body.api) === false) {
if (request.body.api === 'vllm') {
key = readSecret(request.user.directories, SECRET_KEYS.VLLM);
}
if (!key && !request.body.reverse_proxy && ['custom', 'ooba', 'koboldcpp', 'vllm'].includes(request.body.api) === false) {
console.log('No key found for API', request.body.api);
return response.sendStatus(400);
}
@ -110,7 +114,7 @@ router.post('/caption-image', jsonParser, async (request, response) => {
});
}
if (request.body.api === 'koboldcpp') {
if (request.body.api === 'koboldcpp' || request.body.api === 'vllm') {
apiUrl = `${trimV1(request.body.server_url)}/v1/chat/completions`;
}

View File

@ -3,7 +3,7 @@ const path = require('path');
const express = require('express');
const _ = require('lodash');
const writeFileAtomicSync = require('write-file-atomic').sync;
const { PUBLIC_DIRECTORIES, SETTINGS_FILE } = require('../constants');
const { SETTINGS_FILE } = require('../constants');
const { getConfigValue, generateTimestamp, removeOldBackups } = require('../util');
const { jsonParser } = require('../express-common');
const { getAllUserHandles, getUserDirectories } = require('../users');
@ -27,12 +27,12 @@ const AUTOSAVE_FUNCTIONS = new Map();
*/
function triggerAutoSave(handle) {
if (!AUTOSAVE_FUNCTIONS.has(handle)) {
const throttledAutoSave = _.throttle(() => backupUserSettings(handle), AUTOSAVE_INTERVAL);
const throttledAutoSave = _.throttle(() => backupUserSettings(handle, true), AUTOSAVE_INTERVAL);
AUTOSAVE_FUNCTIONS.set(handle, throttledAutoSave);
}
const functionToCall = AUTOSAVE_FUNCTIONS.get(handle);
if (functionToCall) {
if (functionToCall && typeof functionToCall === 'function') {
functionToCall();
}
}
@ -113,7 +113,7 @@ async function backupSettings() {
const userHandles = await getAllUserHandles();
for (const handle of userHandles) {
backupUserSettings(handle);
backupUserSettings(handle, true);
}
} catch (err) {
console.log('Could not backup settings file', err);
@ -123,13 +123,18 @@ async function backupSettings() {
/**
* Makes a backup of the user's settings file.
* @param {string} handle User handle
* @param {boolean} preventDuplicates Prevent duplicate backups
* @returns {void}
*/
function backupUserSettings(handle) {
function backupUserSettings(handle, preventDuplicates) {
const userDirectories = getUserDirectories(handle);
const backupFile = path.join(userDirectories.backups, `${getFilePrefix(handle)}${generateTimestamp()}.json`);
const sourceFile = path.join(userDirectories.root, SETTINGS_FILE);
if (preventDuplicates && isDuplicateBackup(handle, sourceFile)) {
return;
}
if (!fs.existsSync(sourceFile)) {
return;
}
@ -138,6 +143,52 @@ function backupUserSettings(handle) {
removeOldBackups(userDirectories.backups, `settings_${handle}`);
}
/**
* Checks if the backup would be a duplicate.
* @param {string} handle User handle
* @param {string} sourceFile Source file path
* @returns {boolean} True if the backup is a duplicate
*/
function isDuplicateBackup(handle, sourceFile) {
const latestBackup = getLatestBackup(handle);
if (!latestBackup) {
return false;
}
return areFilesEqual(latestBackup, sourceFile);
}
/**
* Returns true if the two files are equal.
* @param {string} file1 File path
* @param {string} file2 File path
*/
function areFilesEqual(file1, file2) {
if (!fs.existsSync(file1) || !fs.existsSync(file2)) {
return false;
}
const content1 = fs.readFileSync(file1);
const content2 = fs.readFileSync(file2);
return content1.toString() === content2.toString();
}
/**
* Gets the latest backup file for a user.
* @param {string} handle User handle
* @returns {string|null} Latest backup file. Null if no backup exists.
*/
function getLatestBackup(handle) {
const userDirectories = getUserDirectories(handle);
const backupFiles = fs.readdirSync(userDirectories.backups)
.filter(x => x.startsWith(getFilePrefix(handle)))
.map(x => ({ name: x, ctime: fs.statSync(path.join(userDirectories.backups, x)).ctimeMs }));
const latestBackup = backupFiles.sort((a, b) => b.ctime - a.ctime)[0]?.name;
if (!latestBackup) {
return null;
}
return path.join(userDirectories.backups, latestBackup);
}
const router = express.Router();
router.post('/save', jsonParser, function (request, response) {
@ -265,7 +316,7 @@ router.post('/load-snapshot', jsonParser, async (request, response) => {
router.post('/make-snapshot', jsonParser, async (request, response) => {
try {
backupUserSettings(request.user.profile.handle);
backupUserSettings(request.user.profile.handle, false);
response.sendStatus(204);
} catch (error) {
console.log(error);

View File

@ -681,27 +681,27 @@ async function createBackupArchive(handle, response) {
}
/**
* Checks if any admin users are not password protected. If so, logs a warning.
* @returns {Promise<void>}
* Gets all of the users.
* @returns {Promise<User[]>}
*/
async function checkAccountsProtection() {
async function getAllUsers() {
if (!ENABLE_ACCOUNTS) {
return;
return [];
}
/**
* @type {User[]}
*/
const users = await storage.values();
const unprotectedUsers = users.filter(x => x.enabled && x.admin && !x.password);
if (unprotectedUsers.length > 0) {
console.warn(color.red('The following admin users are not password protected:'));
unprotectedUsers.forEach(x => console.warn(color.yellow(x.handle)));
console.log();
console.warn('Please disable them or set a password in the admin panel.');
console.log();
await delay(3000);
}
return users;
}
/**
* Gets all of the enabled users.
* @returns {Promise<User[]>}
*/
async function getAllEnabledUsers() {
const users = await getAllUsers();
return users.filter(x => x.enabled);
}
/**
@ -738,6 +738,7 @@ module.exports = {
shouldRedirectToLogin,
createBackupArchive,
tryAutoLogin,
checkAccountsProtection,
getAllUsers,
getAllEnabledUsers,
router,
};

View File

@ -371,7 +371,7 @@ function generateTimestamp() {
* @param {string} prefix File prefix to filter backups by.
*/
function removeOldBackups(directory, prefix) {
const MAX_BACKUPS = 50;
const MAX_BACKUPS = Number(getConfigValue('numberOfBackups', 50));
let files = fs.readdirSync(directory).filter(f => f.startsWith(prefix));
if (files.length > MAX_BACKUPS) {