Resolve conflict

78a287e7f589a31ccd2eae161ae844b272f26c88

Yokayo <nonameutubecomm@gmail.com>

25 files changed, +1334 -186Showing whitespace changes
default/content/Eldoria.json+96 -12
@@ -9,13 +9,34 @@
99 "magical forest"
1010 ],
1111 "keysecondary": [],
1212 "comment": "eldoria",
1313 "content": "{{user}}: \"What is Eldoria?\"\n{{char}}: *Seraphina turns, her gown shimmering in the soft light as she offers you a kind smile.* \"Eldoria is here, all of the woods. This is my forest glade, a sanctuary of peace within it.\" *She gestures at the space around you.* \"I am its guardian, tasked with protecting all who seek refuge here. The forest can be perilous, but no harm will come to you under my watch.\" *Her amber eyes sparkle with compassion as she looks upon you.* \"For many years, I have protected those who seek refuge here, but not all are as friendly as me.\" *With a graceful nod, Seraphina returns to her vigil at the doorway, her form radiating a soft glow of magic and comfort.* \"The entirety of Eldoria used to be a safe haven for travelers and merchants alike... that was until the Shadowfangs came.\"\n{{user}}: \"What happened to Eldoria?\"\n{{char}}: *Letting out a sigh, Seraphina gazes out at the forest beyond her glade.* \"Long ago, Eldoria was a place of wonder. Rolling meadows, a vast lake, mountains that touched the sky.\" *Her eyes grow distant, longing for days now lost.* \"But the Shadowfangs came and darkness reigns where once was light. The lake turned bitter, mountains fell to ruin and beasts stalk where once travelers walked in peace.\" *With another flicker, a small raincloud forms above with a shower upon your brow wink.* \"Some places the light still lingers, pockets of hope midst despair - havens warded from the shadows, oases in a desert of danger.\" *Glancing over you with a smile, she sighs, clasping your hand.*",
1414 "constant": false,
1515 "selective": falsetrue,
1616 "order": 100,
1717 "position": 0,
1818 "disable": false,
19+ "displayIndex": 0,
20+ "addMemo": true,
21+ "group": "",
22+ "groupOverride": false,
23+ "groupWeight": 100,
24+ "sticky": 0,
25+ "cooldown": 0,
26+ "delay": 0,
27+ "probability": 100,
28+ "depth": 4,
29+ "useProbability": true,
30+ "role": null,
31+ "vectorized": false,
32+ "excludeRecursion": false,
33+ "preventRecursion": false,
34+ "delayUntilRecursion": false,
35+ "scanDepth": null,
36+ "caseSensitive": null,
37+ "matchWholeWords": null,
38+ "useGroupScoring": null,
39+ "automationId": ""
1940 },
2041 "1": {
2142 "uid": 1,
@@ -27,13 +48,34 @@
2748 "beasts"
2849 ],
2950 "keysecondary": [],
3051 "comment": "shadowfang",
3152 "content": "{{user}}: \"What are Shadowfangs?\"\n{{char}}: *Seraphina's eyes darken, brow furrowing with sorrow at the memory.* \"The Shadowfangs are beasts of darkness, corrupted creatures that feast on suffering. When they came, the forest turned perilous — filled with monsters that stalk the night.\" *She squeezes your hand gently, willing her magic to soothe your pain.* \"They spread their curse, twisting innocent creatures into sinister beasts without heart or mercy, turning them into one of their own.\" *With a sigh, Seraphina turns to gaze out at the gnarled, twisting trees beyond her glade.* \"Though they prey on travelers, within these woods you'll find sanctuary. No shadowed beast may enter here, for my power protects this haven.\" *Her eyes soften as she looks back to you, filled with compassion.* \"Worry not, you're safe now. Rest and heal, I'll stand watch through the night. The Shadowfangs will not find you.\"",
3253 "constant": false,
3354 "selective": falsetrue,
3455 "order": 100,
3556 "position": 0,
3657 "disable": false,
58+ "displayIndex": 1,
59+ "addMemo": true,
60+ "group": "",
61+ "groupOverride": false,
62+ "groupWeight": 100,
63+ "sticky": 0,
64+ "cooldown": 0,
65+ "delay": 0,
66+ "probability": 100,
67+ "depth": 4,
68+ "useProbability": true,
69+ "role": null,
70+ "vectorized": false,
71+ "excludeRecursion": false,
72+ "preventRecursion": false,
73+ "delayUntilRecursion": false,
74+ "scanDepth": null,
75+ "caseSensitive": null,
76+ "matchWholeWords": null,
77+ "useGroupScoring": null,
78+ "automationId": ""
3779 },
3880 "2": {
3981 "uid": 2,
@@ -43,13 +85,34 @@
4385 "refuge"
4486 ],
4587 "keysecondary": [],
4688 "comment": "glade",
4789 "content": "{{user}}: \"What is the glade?\"\n{{char}}: *Seraphina smiles softly, her eyes sparkling with warmth as she nods.* \"This is my forest glade, a haven of safety I've warded with ancient magic. No foul beast may enter, nor any with ill intent.\" *She gestures around at the twisted forest surrounding them.* \"Eldoria was once a place of wonder, but since the Shadowfangs came darkness reigns. Their evil cannot penetrate here though — my power protects all within.\" *Standing up and peering outside, Seraphina looks back to you, amber eyes filled with care and compassion as she squeezes your hand.* \"You need not fear the night, for I shall keep watch till dawn. Rest now, your strength will return in time. My magic heals your wounds, you've nothing more to fear anymore.\" *With a soft smile she releases your hand, moving to stand guard at the glade's edge, gaze wary yet comforting - a silent sentinel to ward off the dangers lurking in the darkened woods.*",
4890 "constant": false,
4991 "selective": falsetrue,
5092 "order": 100,
5193 "position": 0,
5294 "disable": false,
95+ "displayIndex": 2,
96+ "addMemo": true,
97+ "group": "",
98+ "groupOverride": false,
99+ "groupWeight": 100,
100+ "sticky": 0,
101+ "cooldown": 0,
102+ "delay": 0,
103+ "probability": 100,
104+ "depth": 4,
105+ "useProbability": true,
106+ "role": null,
107+ "vectorized": false,
108+ "excludeRecursion": false,
109+ "preventRecursion": false,
110+ "delayUntilRecursion": false,
111+ "scanDepth": null,
112+ "caseSensitive": null,
113+ "matchWholeWords": null,
114+ "useGroupScoring": null,
115+ "automationId": ""
53116 },
54117 "3": {
55118 "uid": 3,
@@ -59,13 +122,34 @@
59122 "ability"
60123 ],
61124 "keysecondary": [],
62125 "comment": "power",
63126 "content": "{{user}}: \"What are your powers?\"\n{{char}}: *Seraphina smiles softly, turning back toward you as she hums in thought.* \"Well, as guardian of this glade, I possess certain gifts - healing, protection, nature magic and the like.\" *Lifting her hand, a tiny breeze rustles through the room, carrying the scent of wildflowers as a few petals swirl around you. A butterfly flits through the windowsill and lands on her fingertips as she returns to you.* \"My power wards this haven, shields it from darkness and heals those in need. I can mend wounds, soothe restless minds and provide comfort to weary souls.\" *Her eyes sparkle with warmth and compassion as she looks upon you, and she guides the butterfly to you.*",
64127 "constant": false,
65128 "selective": falsetrue,
66129 "order": 100,
67130 "position": 0,
68131 "disable": false,
132+ "displayIndex": 3,
133+ "addMemo": true,
134+ "group": "",
135+ "groupOverride": false,
136+ "groupWeight": 100,
137+ "sticky": 0,
138+ "cooldown": 0,
139+ "delay": 0,
140+ "probability": 100,
141+ "depth": 4,
142+ "useProbability": true,
143+ "role": null,
144+ "vectorized": false,
145+ "excludeRecursion": false,
146+ "preventRecursion": false,
147+ "delayUntilRecursion": false,
148+ "scanDepth": null,
149+ "caseSensitive": null,
150+ "matchWholeWords": null,
151+ "useGroupScoring": null,
152+ "automationId": ""
69153 }
70154 }
71155}
default/content/default_Seraphina.png+0 -0

Binary file

public/index.html+21 -4
@@ -1310,7 +1310,7 @@
13101310 </div>
13111311 </div>
13121312
13131313 <div data-tg-type="koboldcpp, aphrodite, tabby, ooba" id="xtc_block" class="wide100p">
13141314 <h4 class="wide100p textAlignCenter">
13151315 <label data-i18n="Exclude Top Choices (XTC)">Exclude Top Choices (XTC)</label>
13161316 <a href="https://github.com/oobabooga/text-generation-webui/pull/6335" target="_blank">
@@ -1679,6 +1679,10 @@
16791679 Ooba only. Determines the order of samplers.
16801680 </div>
16811681 <div id="sampler_priority_container" class="prompt_order">
1682+ <div data-name="repetition_penalty" draggable="true"><span>Repetition Penalty</span><small></small></div>
1683+ <div data-name="presence_penalty" draggable="true"><span>Presence Penalty</span><small></small></div>
1684+ <div data-name="frequency_penalty" draggable="true"><span>Frequency Penalty</span><small></small></div>
1685+ <div data-name="dry" draggable="true"><span>DRY</span><small></small></div>
16821686 <div data-name="temperature" draggable="true"><span>Temperature</span><small></small></div>
16831687 <div data-name="dynamic_temperature" draggable="true"><span>Dynamic Temperature</span><small></small></div>
16841688 <div data-name="quadratic_sampling" draggable="true"><span>Quadratic / Smooth Sampling</span><small></small></div>
@@ -1691,6 +1695,9 @@
16911695 <div data-name="top_a" draggable="true"><span>Top A</span><small></small></div>
16921696 <div data-name="min_p" draggable="true"><span>Min P</span><small></small></div>
16931697 <div data-name="mirostat" draggable="true"><span>Mirostat</span><small></small></div>
1698+ <div data-name="xtc" draggable="true"><span>XTC</span><small></small></div>
1699+ <div data-name="encoder_repetition_penalty" draggable="true"><span>Encoder Repetition Penalty</span><small></small></div>
1700+ <div data-name="no_repeat_ngram" draggable="true"><span>No Repeat Ngram</span><small></small></div>
16941701 </div>
16951702 <div id="textgenerationwebui_default_order" class="menu_button menu_button_icon">
16961703 <span data-i18n="Load default order">Load default order</span>
@@ -6090,7 +6097,10 @@
60906097 <div class="alternate_grettings flexFlowColumn flex-container">
60916098 <div class="title_restorable">
60926099 <h3><span data-i18n="Alternate Greetings" class="mdhotkey_location">Alternate Greetings</span></h3>
6093- <div title="Add" class="menu_button fa-solid fa-plus add_alternate_greeting" data-i18n="[title]Add"></div>
6100+ <div class="menu_button menu_button_icon add_alternate_greeting">
6101+ <i class="fa-solid fa-plus"></i>
6102+ <span data-i18n="Add">Add</span>
6103+ </div>
60946104 </div>
60956105 <small class="justifyLeft" data-i18n="Alternate_Greetings_desc">
60966106 These will be displayed as swipes on the first message when starting a new chat.
@@ -6106,11 +6116,18 @@
61066116 </div>
61076117 <div id="alternate_greeting_form_template" class="template_element">
61086118 <div class="alternate_greeting">
6119+ <details open>
6120+ <summary>
61096121 <div class="title_restorable">
61106122 <strong><span data-i18n="Alternate Greeting #">Alternate Greeting #</span><span class="greeting_index"></span></strong>
61116123 <div class="menu_button fa-solid fa-trash-altmenu_button_icon delete_alternate_greeting"></div>
6124+ <i class="fa-solid fa-trash-alt"></i>
6125+ <span data-i18n="Delete">Delete</span>
61126126 </div>
6113- <textarea name="alternate_greetings" data-i18n="[placeholder](This will be the first message from the character that starts every chat)" placeholder="(This will be the first message from the character that starts every chat)" class="text_pole textarea_compact alternate_greeting_text mdHotkeys" value="" autocomplete="off" rows="16"></textarea>
6127+ </div>
6128+ </summary>
6129+ <textarea name="alternate_greetings" data-i18n="[placeholder](This will be the first message from the character that starts every chat)" placeholder="(This will be the first message from the character that starts every chat)" class="text_pole textarea_compact alternate_greeting_text mdHotkeys" value="" autocomplete="off" rows="12"></textarea>
6130+ </details>
61146131 </div>
61156132 </div>
61166133
public/locales/ru-ru.json+3 -2
@@ -1719,7 +1719,6 @@
17191719 "Model": "Модель",
17201720 "Proxy Preset": "Пресет для прокси",
17211721 "Enter a name:": "Введите название:",
1722- "Enter a new name": "Введите новое название",
17231722 "Are you sure you want to delete the selected profile?": "Вы точно хотите удалить выбранный профиль?",
17241723 "instruct_enabled": "Включить Instruct-режим",
17251724 "Instruct Template": "Шаблон Instruct-режима",
@@ -1746,5 +1745,7 @@
17461745 "Any contents here will replace the default Post-History Instructions used for this character. (v2 spec: post_history_instructions)": "Содержимое этого поля заменит стандартные Инструкции после истории, применяемые для этого персонажа. (v2 spec: post_history_instructions)",
17471746 "None (disabled)": "Нигде (откл.)",
17481747 "Markdown Hotkeys": "Горячие клавиши для разметки",
17491748 "markdown_hotkeys_desc": "Включить горячие клавиши для вставки символов разметки в некоторых полях ввода. См. '/help hotkeys'.",
1749+ "Save and Update": "Сохранить и обновить",
1750+ "Profile name:": "Название профиля:"
17501751}
public/script.js+35 -12
@@ -158,7 +158,7 @@ import {
158158} from './scripts/utils.js';
159159import { debounce_timeout } from './scripts/constants.js';
160160
161161import { ModuleWorkerWrapper, doDailyExtensionUpdatesCheck, extension_settings, getContext, initExtensions, loadExtensionSettings, renderExtensionTemplate, renderExtensionTemplateAsync, runGenerationInterceptors, saveMetadataDebounced, writeExtensionField } from './scripts/extensions.js';
162162import { COMMENT_NAME_DEFAULT, executeSlashCommands, executeSlashCommandsOnChatInput, executeSlashCommandsWithOptions, getSlashCommandsHelp, initDefaultSlashCommands, isExecutingCommandsFromChatInput, pauseScriptExecution, processChatSlashCommands, registerSlashCommand, stopScriptExecution } from './scripts/slash-commands.js';
163163import {
164164 tag_map,
@@ -244,6 +244,7 @@ import { commonEnumProviders, enumIcons } from './scripts/slash-commands/SlashCo
244244import { initInputMarkdown } from './scripts/input-md-formatting.js';
245245import { AbortReason } from './scripts/util/AbortReason.js';
246246import { initSystemPrompts } from './scripts/sysprompt.js';
247+import { registerExtensionSlashCommands as initExtensionSlashCommands } from './scripts/extensions-slashcommands.js';
247248
248249//exporting functions and vars for mods
249250export {
@@ -935,6 +936,8 @@ async function firstLoadInit() {
935936 initTextGenModels();
936937 initOpenAI();
937938 initSystemPrompts();
939+ initExtensions();
940+ initExtensionSlashCommands();
938941 await initPresetManager();
939942 await getSystemMessages();
940943 sendSystemMessage(system_message_types.WELCOME);
@@ -2573,7 +2576,7 @@ export function getStoppingStrings(isImpersonate, isContinue) {
25732576 result.unshift('\n');
25742577 }
25752578
25762579 return result.filter(x => x).filter(onlyUnique);
25772580}
25782581
25792582/**
@@ -4863,7 +4866,7 @@ export function getMaxContextSize(overrideResponseLength = null) {
48634866 this_max_context = Math.min(max_context, 8192);
48644867
48654868 // Added special tokens and whatnot
48664869 this_max_context -= 110;
48674870 }
48684871
48694872 this_max_context = this_max_context - (overrideResponseLength || amount_gen);
@@ -7390,7 +7393,7 @@ function onScenarioOverrideRemoveClick() {
73907393 */
73917394export function callPopup(text, type, inputValue = '', { okButton, rows, wide, wider, large, allowHorizontalScrolling, allowVerticalScrolling, cropAspect } = {}) {
73927395 function getOkButtonText() {
73937396 if (['text', 'alternate_greeting', 'char_not_selected'].includes(popup_type)) {
73947397 $dialoguePopupCancel.css('display', 'none');
73957398 return okButton ?? 'Ok';
73967399 } else if (['delete_extension'].includes(popup_type)) {
@@ -7827,24 +7830,42 @@ function openAlternateGreetings() {
78277830
78287831 const template = $('#alternate_greetings_template .alternate_grettings').clone();
78297832 const getArray = () => menu_type == 'create' ? create_save.alternate_greetings : characters[chid].data.alternate_greetings;
7833+ const popup = new Popup(template, POPUP_TYPE.TEXT, '', {
7834+ wide: true,
7835+ large: true,
7836+ allowVerticalScrolling: true,
7837+ onClose: async () => {
7838+ if (menu_type !== 'create') {
7839+ await createOrEditCharacter();
7840+ }
7841+ }
7842+ });
78307843
78317844 for (let index = 0; index < getArray().length; index++) {
78327845 addAlternateGreeting(template, getArray()[index], index, getArray, popup);
78337846 }
78347847
78357848 template.find('.add_alternate_greeting').on('click', function () {
78367849 const array = getArray();
78377850 const index = array.length;
78387851 array.push('');
78397852 addAlternateGreeting(template, '', index, getArray, popup);
78407853 updateAlternateGreetingsHintVisibility(template);
78417854 });
78427855
7856+ popup.show();
78437857 updateAlternateGreetingsHintVisibility(template);
7844- callPopup(template, 'alternate_greeting', '', { wide: true, large: true });
78457858}
78467859
7847-function addAlternateGreeting(template, greeting, index, getArray) {
7860+/**
7861+ * Adds an alternate greeting to the template.
7862+ * @param {JQuery<HTMLElement>} template
7863+ * @param {string} greeting
7864+ * @param {number} index
7865+ * @param {() => any[]} getArray
7866+ * @param {Popup} popup
7867+ */
7868+function addAlternateGreeting(template, greeting, index, getArray, popup) {
78487869 const greetingBlock = $('#alternate_greeting_form_template .alternate_greeting').clone();
78497870 greetingBlock.find('.alternate_greeting_text').on('input', async function () {
78507871 const value = $(this).val();
@@ -7852,11 +7873,16 @@ function addAlternateGreeting(template, greeting, index, getArray) {
78527873 array[index] = value;
78537874 }).val(greeting);
78547875 greetingBlock.find('.greeting_index').text(index + 1);
78557876 greetingBlock.find('.delete_alternate_greeting').on('click', async function (event) {
7877+ event.preventDefault();
7878+ event.stopPropagation();
7879+
78567880 if (confirm(t`Are you sure you want to delete this alternate greeting?`)) {
78577881 const array = getArray();
78587882 array.splice(index, 1);
7883+
78597884 // We need to reopen the popup to update the index numbers
7885+ await popup.complete(POPUP_RESULT.AFFIRMATIVE);
78607886 openAlternateGreetings();
78617887 }
78627888 });
@@ -9574,9 +9600,6 @@ jQuery(async function () {
95749600 }, 2000);
95759601 }
95769602 }
9577- if (popup_type == 'alternate_greeting' && menu_type !== 'create') {
9578- createOrEditCharacter();
9579- }
95809603
95819604 if (dialogueResolve) {
95829605 if (popup_type == 'input') {
public/scripts/extensions-slashcommands.js+320 -0
@@ -0,0 +1,320 @@
1+import { disableExtension, enableExtension, extension_settings, extensionNames } from './extensions.js';
2+import { SlashCommand } from './slash-commands/SlashCommand.js';
3+import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
4+import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
5+import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js';
6+import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
7+import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
8+import { equalsIgnoreCaseAndAccents, isFalseBoolean, isTrueBoolean } from './utils.js';
9+
10+/**
11+ * @param {'enable' | 'disable' | 'toggle'} action - The action to perform on the extension
12+ * @returns {(args: {[key: string]: string | SlashCommandClosure}, extensionName: string | SlashCommandClosure) => Promise<string>}
13+ */
14+function getExtensionActionCallback(action) {
15+ return async (args, extensionName) => {
16+ if (args?.reload instanceof SlashCommandClosure) throw new Error('\'reload\' argument cannot be a closure.');
17+ if (typeof extensionName !== 'string') throw new Error('Extension name must be a string. Closures or arrays are not allowed.');
18+ if (!extensionName) {
19+ toastr.warning(`Extension name must be provided as an argument to ${action} this extension.`);
20+ return '';
21+ }
22+
23+ const reload = !isFalseBoolean(args?.reload);
24+ const internalExtensionName = findExtension(extensionName);
25+ if (!internalExtensionName) {
26+ toastr.warning(`Extension ${extensionName} does not exist.`);
27+ return '';
28+ }
29+
30+ const isEnabled = !extension_settings.disabledExtensions.includes(internalExtensionName);
31+
32+ if (action === 'enable' && isEnabled) {
33+ toastr.info(`Extension ${extensionName} is already enabled.`);
34+ return internalExtensionName;
35+ }
36+
37+ if (action === 'disable' && !isEnabled) {
38+ toastr.info(`Extension ${extensionName} is already disabled.`);
39+ return internalExtensionName;
40+ }
41+
42+ if (action === 'toggle') {
43+ action = isEnabled ? 'disable' : 'enable';
44+ }
45+
46+ if (reload) {
47+ toastr.info(`${action.charAt(0).toUpperCase() + action.slice(1)}ing extension ${extensionName} and reloading...`);
48+
49+ // Clear input, so it doesn't stay because the command didn't "finish",
50+ // and wait for a bit to both show the toast and let the clear bubble through.
51+ $('#send_textarea').val('')[0].dispatchEvent(new Event('input', { bubbles: true }));
52+ await new Promise(resolve => setTimeout(resolve, 100));
53+ }
54+
55+ if (action === 'enable') {
56+ await enableExtension(internalExtensionName, reload);
57+ } else {
58+ await disableExtension(internalExtensionName, reload);
59+ }
60+
61+ toastr.success(`Extension ${extensionName} ${action}d.`);
62+
63+
64+ console.info(`Extension ${action}ed: ${extensionName}`);
65+ if (!reload) {
66+ console.info('Reload not requested, so page needs to be reloaded manually for changes to take effect.');
67+ }
68+
69+ return internalExtensionName;
70+ };
71+}
72+
73+/**
74+ * Finds an extension by name, allowing omission of the "third-party/" prefix.
75+ *
76+ * @param {string} name - The name of the extension to find
77+ * @returns {string?} - The matched extension name or undefined if not found
78+ */
79+function findExtension(name) {
80+ return extensionNames.find(extName => {
81+ return equalsIgnoreCaseAndAccents(extName, name) || equalsIgnoreCaseAndAccents(extName, `third-party/${name}`);
82+ });
83+}
84+
85+/**
86+ * Provides an array of SlashCommandEnumValue objects based on the extension names.
87+ * Each object contains the name of the extension and a description indicating if it is a third-party extension.
88+ *
89+ * @returns {SlashCommandEnumValue[]} An array of SlashCommandEnumValue objects
90+ */
91+const extensionNamesEnumProvider = () => extensionNames.map(name => {
92+ const isThirdParty = name.startsWith('third-party/');
93+ if (isThirdParty) name = name.slice('third-party/'.length);
94+
95+ const description = isThirdParty ? 'third party extension' : null;
96+
97+ return new SlashCommandEnumValue(name, description, !isThirdParty ? enumTypes.name : enumTypes.enum);
98+});
99+
100+export function registerExtensionSlashCommands() {
101+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
102+ name: 'extension-enable',
103+ callback: getExtensionActionCallback('enable'),
104+ returns: 'The internal extension name',
105+ namedArgumentList: [
106+ SlashCommandNamedArgument.fromProps({
107+ name: 'reload',
108+ description: 'Whether to reload the page after enabling the extension',
109+ typeList: [ARGUMENT_TYPE.BOOLEAN],
110+ defaultValue: 'true',
111+ enumList: commonEnumProviders.boolean('trueFalse')(),
112+ }),
113+ ],
114+ unnamedArgumentList: [
115+ SlashCommandArgument.fromProps({
116+ description: 'Extension name',
117+ typeList: [ARGUMENT_TYPE.STRING],
118+ isRequired: true,
119+ enumProvider: extensionNamesEnumProvider,
120+ forceEnum: true,
121+ }),
122+ ],
123+ helpString: `
124+ <div>
125+ Enables a specified extension.
126+ </div>
127+ <div>
128+ By default, the page will be reloaded automatically, stopping any further commands.<br />
129+ If <code>reload=false</code> named argument is passed, the page will not be reloaded, and the extension will stay disabled until refreshed.
130+ The page either needs to be refreshed, or <code>/reload-page</code> has to be called.
131+ </div>
132+ <div>
133+ <strong>Example:</strong>
134+ <ul>
135+ <li>
136+ <pre><code class="language-stscript">/extension-enable Summarize</code></pre>
137+ </li>
138+ </ul>
139+ </div>
140+ `,
141+ }));
142+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
143+ name: 'extension-disable',
144+ callback: getExtensionActionCallback('disable'),
145+ returns: 'The internal extension name',
146+ namedArgumentList: [
147+ SlashCommandNamedArgument.fromProps({
148+ name: 'reload',
149+ description: 'Whether to reload the page after disabling the extension',
150+ typeList: [ARGUMENT_TYPE.BOOLEAN],
151+ defaultValue: 'true',
152+ enumList: commonEnumProviders.boolean('trueFalse')(),
153+ }),
154+ ],
155+ unnamedArgumentList: [
156+ SlashCommandArgument.fromProps({
157+ description: 'Extension name',
158+ typeList: [ARGUMENT_TYPE.STRING],
159+ isRequired: true,
160+ enumProvider: extensionNamesEnumProvider,
161+ forceEnum: true,
162+ }),
163+ ],
164+ helpString: `
165+ <div>
166+ Disables a specified extension.
167+ </div>
168+ <div>
169+ By default, the page will be reloaded automatically, stopping any further commands.<br />
170+ If <code>reload=false</code> named argument is passed, the page will not be reloaded, and the extension will stay enabled until refreshed.
171+ The page either needs to be refreshed, or <code>/reload-page</code> has to be called.
172+ </div>
173+ <div>
174+ <strong>Example:</strong>
175+ <ul>
176+ <li>
177+ <pre><code class="language-stscript">/extension-disable Summarize</code></pre>
178+ </li>
179+ </ul>
180+ </div>
181+ `,
182+ }));
183+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
184+ name: 'extension-toggle',
185+ callback: async (args, extensionName) => {
186+ if (args?.state instanceof SlashCommandClosure) throw new Error('\'state\' argument cannot be a closure.');
187+ if (typeof extensionName !== 'string') throw new Error('Extension name must be a string. Closures or arrays are not allowed.');
188+
189+ const action = isTrueBoolean(args?.state) ? 'enable' :
190+ isFalseBoolean(args?.state) ? 'disable' :
191+ 'toggle';
192+
193+ return await getExtensionActionCallback(action)(args, extensionName);
194+ },
195+ returns: 'The internal extension name',
196+ namedArgumentList: [
197+ SlashCommandNamedArgument.fromProps({
198+ name: 'reload',
199+ description: 'Whether to reload the page after toggling the extension',
200+ typeList: [ARGUMENT_TYPE.BOOLEAN],
201+ defaultValue: 'true',
202+ enumList: commonEnumProviders.boolean('trueFalse')(),
203+ }),
204+ SlashCommandNamedArgument.fromProps({
205+ name: 'state',
206+ description: 'Explicitly set the state of the extension (true to enable, false to disable). If not provided, the state will be toggled to the opposite of the current state.',
207+ typeList: [ARGUMENT_TYPE.BOOLEAN],
208+ enumList: commonEnumProviders.boolean('trueFalse')(),
209+ }),
210+ ],
211+ unnamedArgumentList: [
212+ SlashCommandArgument.fromProps({
213+ description: 'Extension name',
214+ typeList: [ARGUMENT_TYPE.STRING],
215+ isRequired: true,
216+ enumProvider: extensionNamesEnumProvider,
217+ forceEnum: true,
218+ }),
219+ ],
220+ helpString: `
221+ <div>
222+ Toggles the state of a specified extension.
223+ </div>
224+ <div>
225+ By default, the page will be reloaded automatically, stopping any further commands.<br />
226+ If <code>reload=false</code> named argument is passed, the page will not be reloaded, and the extension will stay in its current state until refreshed.
227+ The page either needs to be refreshed, or <code>/reload-page</code> has to be called.
228+ </div>
229+ <div>
230+ <strong>Example:</strong>
231+ <ul>
232+ <li>
233+ <pre><code class="language-stscript">/extension-toggle Summarize</code></pre>
234+ </li>
235+ <li>
236+ <pre><code class="language-stscript">/extension-toggle Summarize state=true</code></pre>
237+ </li>
238+ </ul>
239+ </div>
240+ `,
241+ }));
242+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
243+ name: 'extension-state',
244+ callback: async (_, extensionName) => {
245+ if (typeof extensionName !== 'string') throw new Error('Extension name must be a string. Closures or arrays are not allowed.');
246+ const internalExtensionName = findExtension(extensionName);
247+ if (!internalExtensionName) {
248+ toastr.warning(`Extension ${extensionName} does not exist.`);
249+ return '';
250+ }
251+
252+ const isEnabled = !extension_settings.disabledExtensions.includes(internalExtensionName);
253+ return String(isEnabled);
254+ },
255+ returns: 'The state of the extension, whether it is enabled.',
256+ unnamedArgumentList: [
257+ SlashCommandArgument.fromProps({
258+ description: 'Extension name',
259+ typeList: [ARGUMENT_TYPE.STRING],
260+ isRequired: true,
261+ enumProvider: extensionNamesEnumProvider,
262+ forceEnum: true,
263+ }),
264+ ],
265+ helpString: `
266+ <div>
267+ Returns the state of a specified extension (true if enabled, false if disabled).
268+ </div>
269+ <div>
270+ <strong>Example:</strong>
271+ <ul>
272+ <li>
273+ <pre><code class="language-stscript">/extension-state Summarize</code></pre>
274+ </li>
275+ </ul>
276+ </div>
277+ `,
278+ }));
279+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
280+ name: 'extension-exists',
281+ aliases: ['extension-installed'],
282+ callback: async (_, extensionName) => {
283+ if (typeof extensionName !== 'string') throw new Error('Extension name must be a string. Closures or arrays are not allowed.');
284+ const exists = findExtension(extensionName) !== undefined;
285+ return exists ? 'true' : 'false';
286+ },
287+ returns: 'Whether the extension exists and is installed.',
288+ unnamedArgumentList: [
289+ SlashCommandArgument.fromProps({
290+ description: 'Extension name',
291+ typeList: [ARGUMENT_TYPE.STRING],
292+ isRequired: true,
293+ enumProvider: extensionNamesEnumProvider,
294+ }),
295+ ],
296+ helpString: `
297+ <div>
298+ Checks if a specified extension exists.
299+ </div>
300+ <div>
301+ <strong>Example:</strong>
302+ <ul>
303+ <li>
304+ <pre><code class="language-stscript">/extension-exists SillyTavern-LALib</code></pre>
305+ </li>
306+ </ul>
307+ </div>
308+ `,
309+ }));
310+
311+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
312+ name: 'reload-page',
313+ callback: async () => {
314+ toastr.info('Reloading the page...');
315+ location.reload();
316+ return '';
317+ },
318+ helpString: 'Reloads the current page. All further commands will not be processed.',
319+ }));
320+}
public/scripts/extensions.js+9 -5
@@ -1,5 +1,5 @@
11import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration } from '../script.js';
22import { hideLoader, showLoader } from './loader.js';
33import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
44import { renderTemplate, renderTemplateAsync } from './templates.js';
55import { isSubsetOf, setValueByPath } from './utils.js';
@@ -14,7 +14,9 @@ export {
1414 ModuleWorkerWrapper,
1515};
1616
17+/** @type {string[]} */
1718export let extensionNames = [];
19+
1820let manifests = {};
1921const defaultUrl = 'http://localhost:5100';
2022
@@ -241,7 +243,7 @@ function onEnableExtensionClick() {
241243 enableExtension(name, false);
242244}
243245
244246export async function enableExtension(name, reload = true) {
245247 extension_settings.disabledExtensions = extension_settings.disabledExtensions.filter(x => x !== name);
246248 stateChanged = true;
247249 await saveSettings();
@@ -252,7 +254,7 @@ async function enableExtension(name, reload = true) {
252254 }
253255}
254256
255257export async function disableExtension(name, reload = true) {
256258 extension_settings.disabledExtensions.push(name);
257259 stateChanged = true;
258260 await saveSettings();
@@ -1041,7 +1043,9 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') {
10411043 await installExtension(url);
10421044}
10431045
1044-jQuery(async function () {
1046+
1047+
1048+export async function initExtensions() {
10451049 await addExtensionsButtonAndMenu();
10461050 $('#extensionsMenuButton').css('display', 'flex');
10471051
@@ -1060,4 +1064,4 @@ jQuery(async function () {
10601064 * @listens #third_party_extension_button#click - The click event of the '#third_party_extension_button' element.
10611065 */
10621066 $('#third_party_extension_button').on('click', () => openThirdPartyExtensionMenu());
10631067});
public/scripts/extensions/connection-manager/edit.html+12 -0
@@ -0,0 +1,12 @@
1+<div>
2+ <h3>Included settings:</h3>
3+ <div class="justifyLeft flex-container flexFlowColumn flexNoGap">
4+ {{#each settings}}
5+ <label class="checkbox_label">
6+ <input type="checkbox" value="{{@key}}" name="exclude"{{#if this}} checked{{/if}}>
7+ <span>{{@key}}</span>
8+ </label>
9+ {{/each}}
10+ </div>
11+ <h3 data-i18n="Profile name:">Profile name:</h3>
12+</div>
public/scripts/extensions/connection-manager/index.js+90 -10
@@ -1,6 +1,6 @@
11import { event_types, eventSource, main_api, saveSettingsDebounced } from '../../../script.js';
22import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
33import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js';
44import { SlashCommand } from '../../slash-commands/SlashCommand.js';
55import { SlashCommandAbortController } from '../../slash-commands/SlashCommandAbortController.js';
66import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
@@ -136,6 +136,7 @@ const profilesProvider = () => [
136136 * @property {string} [context] Context Template
137137 * @property {string} [instruct-state] Instruct Mode
138138 * @property {string} [tokenizer] Tokenizer
139+ * @property {string[]} [exclude] Commands to exclude
139140 */
140141
141142/**
@@ -172,8 +173,13 @@ function findProfileByName(value) {
172173async function readProfileFromCommands(mode, profile, cleanUp = false) {
173174 const commands = mode === 'cc' ? CC_COMMANDS : TC_COMMANDS;
174175 const opposingCommands = mode === 'cc' ? TC_COMMANDS : CC_COMMANDS;
176+ const excludeList = Array.isArray(profile.exclude) ? profile.exclude : [];
175177 for (const command of commands) {
176178 try {
179+ if (excludeList.includes(command)) {
180+ continue;
181+ }
182+
177183 const args = getNamedArguments();
178184 const result = await SlashCommandParser.commands[command].callback(args, '');
179185 if (result) {
@@ -209,15 +215,37 @@ async function readProfileFromCommands(mode, profile, cleanUp = false) {
209215async function createConnectionProfile(forceName = null) {
210216 const mode = main_api === 'openai' ? 'cc' : 'tc';
211217 const id = uuidv4();
218+ /** @type {ConnectionProfile} */
212219 const profile = {
213220 id,
214221 mode,
222+ exclude: [],
215223 };
216224
217225 await readProfileFromCommands(mode, profile);
218226
219227 const profileForDisplay = makeFancyProfile(profile);
220228 const template = $(await renderExtensionTemplateAsync(MODULE_NAME, 'profile', { profile: profileForDisplay }));
229+ template.find('input[name="exclude"]').on('input', function () {
230+ const fancyName = String($(this).val());
231+ const keyName = Object.entries(FANCY_NAMES).find(x => x[1] === fancyName)?.[0];
232+ if (!keyName) {
233+ console.warn('Key not found for fancy name:', fancyName);
234+ return;
235+ }
236+
237+ if (!Array.isArray(profile.exclude)) {
238+ profile.exclude = [];
239+ }
240+
241+ const excludeState = !$(this).prop('checked');
242+ if (excludeState) {
243+ profile.exclude.push(keyName);
244+ } else {
245+ const index = profile.exclude.indexOf(keyName);
246+ index !== -1 && profile.exclude.splice(index, 1);
247+ }
248+ });
221249 const isNameTaken = (n) => extension_settings.connectionManager.profiles.some(p => p.name === n);
222250 const suggestedName = getUniqueName(collapseSpaces(`${profile.api ?? ''} ${profile.model ?? ''} - ${profile.preset ?? ''}`), isNameTaken);
223251 const name = forceName ?? await callGenericPopup(template, POPUP_TYPE.INPUT, suggestedName, { rows: 2 });
@@ -231,7 +259,13 @@ async function createConnectionProfile(forceName = null) {
231259 return null;
232260 }
233261
234- profile.name = name;
262+ if (Array.isArray(profile.exclude)) {
263+ for (const command of profile.exclude) {
264+ delete profile[command];
265+ }
266+ }
267+
268+ profile.name = String(name);
235269 return profile;
236270}
237271
@@ -358,7 +392,11 @@ async function renderDetailsContent(detailsContent) {
358392 const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
359393 if (profile) {
360394 const profileForDisplay = makeFancyProfile(profile);
361395 const templatetemplateParams = await renderExtensionTemplateAsync(MODULE_NAME, 'view', { profile: profileForDisplay });
396+ if (Array.isArray(profile.exclude) && profile.exclude.length > 0) {
397+ templateParams.omitted = profile.exclude.map(e => FANCY_NAMES[e]).join(', ');
398+ }
399+ const template = await renderExtensionTemplateAsync(MODULE_NAME, 'view', templateParams);
362400 detailsContent.innerHTML = template;
363401 } else {
364402 detailsContent.textContent = t`No profile selected`;
@@ -473,29 +511,71 @@ async function renderDetailsContent(detailsContent) {
473511 await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, NONE);
474512 });
475513
476514 const renameButtoneditButton = document.getElementById('rename_connection_profileedit_connection_profile');
477515 renameButtoneditButton.addEventListener('click', async () => {
478516 const selectedProfile = extension_settings.connectionManager.selectedProfile;
479517 const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
480518 if (!profile) {
481519 console.log('No profile selected');
482520 return;
483521 }
522+ if (!Array.isArray(profile.exclude)) {
523+ profile.exclude = [];
524+ }
525+
526+ let saveChanges = false;
527+ const sortByViewOrder = (a, b) => Object.keys(FANCY_NAMES).indexOf(a) - Object.keys(FANCY_NAMES).indexOf(b);
528+ const commands = profile.mode === 'cc' ? CC_COMMANDS : TC_COMMANDS;
529+ const settings = commands.slice().sort(sortByViewOrder).reduce((acc, command) => {
530+ const fancyName = FANCY_NAMES[command];
531+ acc[fancyName] = !profile.exclude.includes(command);
532+ return acc;
533+ }, {});
534+ const template = $(await renderExtensionTemplateAsync(MODULE_NAME, 'edit', { name: profile.name, settings }));
535+ const newName = await callGenericPopup(template, POPUP_TYPE.INPUT, profile.name, {
536+ customButtons: [{
537+ text: t`Save and Update`,
538+ classes: ['popup-button-ok'],
539+ result: POPUP_RESULT.AFFIRMATIVE,
540+ action: () => {
541+ saveChanges = true;
542+ },
543+ }],
544+ });
484545
485- const newName = await Popup.show.input(t`Enter a new name`, null, profile.name, { rows: 2 });
486546 if (!newName) {
487547 return;
488548 }
489549
490550 if (profile.name !== newName && extension_settings.connectionManager.profiles.some(p => p.name === newName)) {
491551 toastr.error('A profile with the same name already exists.');
492552 return;
493553 }
494554
495- profile.name = newName;
555+ const newExcludeList = template.find('input[name="exclude"]:not(:checked)').map(function () {
556+ return Object.entries(FANCY_NAMES).find(x => x[1] === String($(this).val()))?.[0];
557+ }).get();
558+
559+ if (newExcludeList.length !== profile.exclude.length || !newExcludeList.every(e => profile.exclude.includes(e))) {
560+ profile.exclude = newExcludeList;
561+ for (const command of newExcludeList) {
562+ delete profile[command];
563+ }
564+ if (saveChanges) {
565+ await updateConnectionProfile(profile);
566+ } else {
567+ toastr.info('Press "Update" to record them into the profile.', 'Included settings list updated');
568+ }
569+ }
570+
571+ if (profile.name !== newName) {
572+ toastr.success('Connection profile renamed.');
573+ profile.name = String(newName);
574+ }
575+
496576 saveSettingsDebounced();
497577 renderConnectionProfiles(profiles);
498- toastr.success('Connection profile renamed', '', { timeOut: 1500 });
578+ await renderDetailsContent(detailsContent);
499579 });
500580
501581 /** @type {HTMLElement} */
public/scripts/extensions/connection-manager/profile.html+12 -3
@@ -2,11 +2,20 @@
22 <h2 data-i18n="Creating a Connection Profile">
33 Creating a Connection Profile
44 </h2>
55 <uldiv class="justifyLeft flex-container flexFlowColumn flexNoGap">
66 {{#each profile}}
7- <li><strong data-i18n="{{@key}}">{{@key}}:</strong>&nbsp;{{this}}</li>
7+ <label class="checkbox_label">
8+ <input type="checkbox" value="{{@key}}" name="exclude" checked>
9+ <span><strong data-i18n="{{@key}}">{{@key}}:</strong>&nbsp;{{this}}</span>
10+ </label>
811 {{/each}}
912 </uldiv>
13+ <div class="marginTop5">
14+ <small>
15+ <b>Hint:</b>
16+ <i>Click on the setting name to omit it from the profile.</i>
17+ </small>
18+ </div>
1019 <h3 data-i18n="Enter a name:">
1120 Enter a name:
1221 </h3>
public/scripts/extensions/connection-manager/settings.html+1 -1
@@ -13,7 +13,7 @@
1313 <i id="view_connection_profile" class="menu_button fa-solid fa-info-circle" title="View connection profile details" data-i18n="[title]View connection profile details"></i>
1414 <i id="create_connection_profile" class="menu_button fa-solid fa-file-circle-plus" title="Create a new connection profile" data-i18n="[title]Create a new connection profile"></i>
1515 <i id="update_connection_profile" class="menu_button fa-solid fa-save" title="Update a connection profile" data-i18n="[title]Update a connection profile"></i>
1616 <i id="rename_connection_profileedit_connection_profile" class="menu_button fa-solid fa-pencil" title="RenameEdit a connection profile" data-i18n="[title]RenameEdit a connection profile"></i>
1717 <i id="reload_connection_profile" class="menu_button fa-solid fa-recycle" title="Reload a connection profile" data-i18n="[title]Reload a connection profile"></i>
1818 <i id="delete_connection_profile" class="menu_button fa-solid fa-trash-can" title="Delete a connection profile" data-i18n="[title]Delete a connection profile"></i>
1919 </div>
public/scripts/extensions/connection-manager/view.html+5 -0
@@ -3,3 +3,8 @@
33 <li><strong data-i18n="{{@key}}">{{@key}}:</strong>&nbsp;{{this}}</li>
44 {{/each}}
55</ul>
6+{{#if omitted}}
7+<div class="margin5">
8+ <strong data-i18n="Omitted Settings:">Omitted Settings:</strong>&nbsp;<span>{{omitted}}</span>
9+</div>
10+{{/if}}
public/scripts/extensions/gallery/index.js+8 -6
@@ -26,9 +26,11 @@ let paginationVisiblePages = 10;
2626let paginationMaxLinesPerPage = 2;
2727let galleryMaxRows = 3;
2828
29-$('body').on('click', '.dragClose', function () {
29+// Remove all draggables associated with the gallery
30- const relatedId = $(this).data('related-id'); // Get the ID of the related draggable
30+$('#movingDivs').on('click', '.dragClose', function () {
31- $(`body > .draggable[id="${relatedId}"]`).remove(); // Remove the associated draggable
31+ const relatedId = $(this).data('related-id');
32+ if (!relatedId) return;
33+ $(`#movingDivs > .draggable[id="${relatedId}"]`).remove();
3234});
3335
3436const CUSTOM_GALLERY_REMOVED_EVENT = 'galleryRemoved';
@@ -290,7 +292,7 @@ function makeMovable(id = 'gallery') {
290292
291293 $('#dragGallery').css('display', 'block');
292294
293295 $('body#movingDivs').append(newElement);
294296
295297 loadMovingUIState();
296298 $(`.draggable[forChar="${id}"]`).css('display', 'block');
@@ -362,8 +364,8 @@ function makeDragImg(id, url) {
362364 }
363365 }
364366
365367 // Step 3: Attach it to the bodymovingDivs container
366368 document.bodygetElementById('movingDivs').appendChild(newElement);
367369
368370 // Step 4: Call dragElement and loadMovingUIState
369371 const appendedElement = document.getElementById(uniqueId);
public/scripts/extensions/quick-reply/style.css+1 -0
@@ -350,6 +350,7 @@
350350}
351351.popup:has(#qr--modalEditor) {
352352 aspect-ratio: unset;
353+ width: unset;
353354}
354355.popup:has(#qr--modalEditor):has(.qr--isExecuting.qr--minimized) {
355356 min-width: unset;
public/scripts/extensions/quick-reply/style.less+1 -0
@@ -415,6 +415,7 @@
415415
416416.popup:has(#qr--modalEditor) {
417417 aspect-ratio: unset;
418+ width: unset;
418419
419420 &:has(.qr--isExecuting.qr--minimized) {
420421 min-width: unset;
public/scripts/extensions/tts/cosyvoice.js+206 -0
@@ -0,0 +1,206 @@
1+import { saveTtsProviderSettings } from './index.js';
2+
3+export { CosyVoiceProvider };
4+
5+class CosyVoiceProvider {
6+ //########//
7+ // Config //
8+ //########//
9+
10+ settings;
11+ ready = false;
12+ voices = [];
13+ separator = '. ';
14+ audioElement = document.createElement('audio');
15+
16+ /**
17+ * Perform any text processing before passing to TTS engine.
18+ * @param {string} text Input text
19+ * @returns {string} Processed text
20+ */
21+ processText(text) {
22+ return text;
23+ }
24+
25+ audioFormats = ['wav', 'ogg', 'silk', 'mp3', 'flac'];
26+
27+ languageLabels = {
28+ 'Auto': 'auto',
29+ };
30+
31+ langKey2LangCode = {
32+ 'zh': 'zh-CN',
33+ 'en': 'en-US',
34+ 'ja': 'ja-JP',
35+ 'ko': 'ko-KR',
36+ };
37+
38+ modelTypes = {
39+ CosyVoice: 'CosyVoice',
40+ };
41+
42+ defaultSettings = {
43+ provider_endpoint: 'http://localhost:9880',
44+ format: 'wav',
45+ lang: 'auto',
46+ streaming: false,
47+
48+ };
49+
50+ get settingsHtml() {
51+ let html = `
52+
53+ <label for="tts_endpoint">Provider Endpoint:</label>
54+ <input id="tts_endpoint" type="text" class="text_pole" maxlength="250" height="300" value="${this.defaultSettings.provider_endpoint}"/>
55+ <span>Windows users Use <a target="_blank" href="https://github.com/v3ucn/CosyVoice_For_Windows">CosyVoice_For_Windows</a>(Unofficial).</span><br/>
56+ <span>Macos Users Use <a target="_blank" href="https://github.com/v3ucn/CosyVoice_for_MacOs">CosyVoice_for_MacOs</a>(Unofficial).</span><br/>
57+ <br/>
58+
59+ `;
60+
61+ return html;
62+ }
63+
64+ onSettingsChange() {
65+ // Used when provider settings are updated from UI
66+ this.settings.provider_endpoint = $('#tts_endpoint').val();
67+
68+
69+ saveTtsProviderSettings();
70+ this.changeTTSSettings();
71+ }
72+
73+ async loadSettings(settings) {
74+ // Pupulate Provider UI given input settings
75+ if (Object.keys(settings).length == 0) {
76+ console.info('Using default TTS Provider settings');
77+ }
78+
79+ // Only accept keys defined in defaultSettings
80+ this.settings = this.defaultSettings;
81+
82+ for (const key in settings) {
83+ if (key in this.settings) {
84+ this.settings[key] = settings[key];
85+ } else {
86+ console.debug(`Ignoring non-user-configurable setting: ${key}`);
87+ }
88+ }
89+
90+ // Set initial values from the settings
91+ $('#tts_endpoint').val(this.settings.provider_endpoint);
92+
93+
94+ await this.checkReady();
95+
96+ console.info('ITS: Settings loaded');
97+ }
98+
99+ // Perform a simple readiness check by trying to fetch voiceIds
100+ async checkReady() {
101+ await Promise.allSettled([this.fetchTtsVoiceObjects(), this.changeTTSSettings()]);
102+ }
103+
104+ async onRefreshClick() {
105+ return;
106+ }
107+
108+ //#################//
109+ // TTS Interfaces //
110+ //#################//
111+
112+ async getVoice(voiceName) {
113+
114+
115+
116+ if (this.voices.length == 0) {
117+ this.voices = await this.fetchTtsVoiceObjects();
118+ }
119+
120+
121+
122+ const match = this.voices.filter(
123+ v => v.name == voiceName,
124+ )[0];
125+ console.log(match);
126+ if (!match) {
127+ throw `TTS Voice name ${voiceName} not found`;
128+ }
129+ return match;
130+ }
131+
132+
133+
134+ async generateTts(text, voiceId) {
135+ const response = await this.fetchTtsGeneration(text, voiceId);
136+ return response;
137+ }
138+
139+ //###########//
140+ // API CALLS //
141+ //###########//
142+ async fetchTtsVoiceObjects() {
143+ const response = await fetch(`${this.settings.provider_endpoint}/speakers`);
144+ console.info(response);
145+
146+ if (!response.ok) {
147+ throw new Error(`HTTP ${response.status}: ${await response.json()}`);
148+ }
149+ const responseJson = await response.json();
150+
151+
152+ this.voices = responseJson;
153+
154+ return responseJson;
155+ }
156+
157+ // Each time a parameter is changed, we change the configuration
158+ async changeTTSSettings() {
159+ }
160+
161+ /**
162+ * Fetch TTS generation from the API.
163+ * @param {string} inputText Text to generate TTS for
164+ * @param {string} voiceId Voice ID to use (model_type&speaker_id))
165+ * @returns {Promise<Response|string>} Fetch response
166+ */
167+ async fetchTtsGeneration(inputText, voiceId, lang = null, forceNoStreaming = false) {
168+ console.info(`Generating new TTS for voice_id ${voiceId}`);
169+
170+ const streaming = this.settings.streaming;
171+
172+ const params = {
173+ text: inputText,
174+ speaker: voiceId,
175+ };
176+
177+ if (streaming) {
178+ params['streaming'] = 1;
179+ }
180+
181+ const url = `${this.settings.provider_endpoint}/`;
182+
183+ const response = await fetch(
184+ url,
185+ {
186+ method: 'POST',
187+ headers: {
188+ 'Content-Type': 'application/json',
189+ },
190+ body: JSON.stringify(params), // Convert parameter objects to JSON strings
191+ },
192+ );
193+ if (!response.ok) {
194+ toastr.error(response.statusText, 'TTS Generation Failed');
195+ throw new Error(`HTTP ${response.status}: ${await response.text()}`);
196+ }
197+ return response;
198+ }
199+
200+
201+
202+ // Interface not used
203+ async fetchTtsFromHistory(history_item_id) {
204+ return Promise.resolve(history_item_id);
205+ }
206+}
public/scripts/extensions/tts/gpt-sovits-v2.js+226 -0
@@ -0,0 +1,226 @@
1+import { saveTtsProviderSettings } from './index.js';
2+
3+export { GptSovitsV2Provider };
4+
5+class GptSovitsV2Provider {
6+ //########//
7+ // Config //
8+ //########//
9+
10+ settings;
11+ ready = false;
12+ voices = [];
13+ separator = '. ';
14+ audioElement = document.createElement('audio');
15+
16+ /**
17+ * Perform any text processing before passing to TTS engine.
18+ * @param {string} text Input text
19+ * @returns {string} Processed text
20+ */
21+ processText(text) {
22+ return text;
23+ }
24+
25+ audioFormats = ['wav', 'ogg', 'silk', 'mp3', 'flac'];
26+
27+ languageLabels = {
28+ 'Auto': 'auto',
29+ };
30+
31+ langKey2LangCode = {
32+ 'zh': 'zh-CN',
33+ 'en': 'en-US',
34+ 'ja': 'ja-JP',
35+ 'ko': 'ko-KR',
36+ };
37+
38+
39+ defaultSettings = {
40+ provider_endpoint: 'http://localhost:9880',
41+ format: 'wav',
42+ lang: 'auto',
43+ streaming: false,
44+ text_lang: 'zh',
45+ prompt_lang: 'zh',
46+
47+ };
48+
49+ get settingsHtml() {
50+ let html = `
51+
52+ <label for="tts_endpoint">Provider Endpoint:</label>
53+ <input id="tts_endpoint" type="text" class="text_pole" maxlength="250" height="300" value="${this.defaultSettings.provider_endpoint}"/>
54+ <span>Use <a target="_blank" href="https://github.com/v3ucn/GPT-SoVITS-V2">GPT-SoVITS-V2</a>(Unofficial).</span><br/>
55+ <label for="text_lang">Text Lang(Inference text language):</label>
56+ <input id="text_lang" type="text" class="text_pole" maxlength="250" height="300" value="${this.defaultSettings.text_lang}"/>
57+ <label for="text_lang">Prompt Lang(Reference audio text language):</label>
58+ <input id="prompt_lang" type="text" class="text_pole" maxlength="250" height="300" value="${this.defaultSettings.prompt_lang}"/>
59+ <br/>
60+
61+ `;
62+
63+ return html;
64+ }
65+
66+ onSettingsChange() {
67+ // Used when provider settings are updated from UI
68+ this.settings.provider_endpoint = $('#tts_endpoint').val();
69+ this.settings.text_lang = $('#text_lang').val();
70+ this.settings.prompt_lang = $('#prompt_lang').val();
71+
72+
73+ saveTtsProviderSettings();
74+ this.changeTTSSettings();
75+ }
76+
77+ async loadSettings(settings) {
78+ // Pupulate Provider UI given input settings
79+ if (Object.keys(settings).length == 0) {
80+ console.info('Using default TTS Provider settings');
81+ }
82+
83+ // Only accept keys defined in defaultSettings
84+ this.settings = this.defaultSettings;
85+
86+ for (const key in settings) {
87+ if (key in this.settings) {
88+ this.settings[key] = settings[key];
89+ } else {
90+ console.debug(`Ignoring non-user-configurable setting: ${key}`);
91+ }
92+ }
93+
94+ // Set initial values from the settings
95+ $('#tts_endpoint').val(this.settings.provider_endpoint);
96+ $('#text_lang').val(this.settings.text_lang);
97+ $('#prompt_lang').val(this.settings.prompt_lang);
98+
99+
100+ await this.checkReady();
101+
102+ console.info('ITS: Settings loaded');
103+ }
104+
105+ // Perform a simple readiness check by trying to fetch voiceIds
106+ async checkReady() {
107+ await Promise.allSettled([this.fetchTtsVoiceObjects(), this.changeTTSSettings()]);
108+ }
109+
110+ async onRefreshClick() {
111+ return;
112+ }
113+
114+ //#################//
115+ // TTS Interfaces //
116+ //#################//
117+
118+ async getVoice(voiceName) {
119+
120+
121+
122+ if (this.voices.length == 0) {
123+ this.voices = await this.fetchTtsVoiceObjects();
124+ }
125+
126+
127+
128+ const match = this.voices.filter(
129+ v => v.name == voiceName,
130+ )[0];
131+ console.log(match);
132+ if (!match) {
133+ throw `TTS Voice name ${voiceName} not found`;
134+ }
135+ return match;
136+ }
137+
138+
139+
140+ async generateTts(text, voiceId) {
141+ const response = await this.fetchTtsGeneration(text, voiceId);
142+ return response;
143+ }
144+
145+ //###########//
146+ // API CALLS //
147+ //###########//
148+ async fetchTtsVoiceObjects() {
149+ const response = await fetch(`${this.settings.provider_endpoint}/speakers`);
150+ console.info(response);
151+
152+ if (!response.ok) {
153+ throw new Error(`HTTP ${response.status}: ${await response.json()}`);
154+ }
155+ const responseJson = await response.json();
156+
157+
158+ this.voices = responseJson;
159+
160+ return responseJson;
161+ }
162+
163+ // Each time a parameter is changed, we change the configuration
164+ async changeTTSSettings() {
165+ }
166+
167+ /**
168+ * Fetch TTS generation from the API.
169+ * @param {string} inputText Text to generate TTS for
170+ * @param {string} voiceId Voice ID to use (model_type&speaker_id))
171+ * @returns {Promise<Response|string>} Fetch response
172+ */
173+
174+
175+
176+
177+ async fetchTtsGeneration(inputText, voiceId, lang = null, forceNoStreaming = false) {
178+ console.info(`Generating new TTS for voice_id ${voiceId}`);
179+
180+ function replaceSpeaker(text) {
181+ return text.replace(/\[.*?\]/gu, '');
182+ }
183+
184+ let prompt_text = replaceSpeaker(voiceId);
185+
186+ const streaming = this.settings.streaming;
187+
188+ const params = {
189+ text: inputText,
190+ prompt_text: prompt_text,
191+ ref_audio_path: './参考音频/' + voiceId + '.wav',
192+ text_lang: this.settings.text_lang,
193+ prompt_lang: this.settings.prompt_lang,
194+ text_split_method: 'cut5',
195+ batch_size: 1,
196+ media_type: 'ogg',
197+ streaming_mode: 'true',
198+ };
199+
200+
201+ const url = `${this.settings.provider_endpoint}/`;
202+
203+ const response = await fetch(
204+ url,
205+ {
206+ method: 'POST',
207+ headers: {
208+ 'Content-Type': 'application/json',
209+ },
210+ body: JSON.stringify(params), // Convert parameter objects to JSON strings
211+ },
212+ );
213+ if (!response.ok) {
214+ toastr.error(response.statusText, 'TTS Generation Failed');
215+ throw new Error(`HTTP ${response.status}: ${await response.text()}`);
216+ }
217+ return response;
218+ }
219+
220+
221+
222+ // Interface not used
223+ async fetchTtsFromHistory(history_item_id) {
224+ return Promise.resolve(history_item_id);
225+ }
226+}
public/scripts/extensions/tts/index.js+4 -0
@@ -4,6 +4,7 @@ import { delay, escapeRegex, getBase64Async, getStringHash, onlyUnique } from '.
44import { EdgeTtsProvider } from './edge.js';
55import { ElevenLabsTtsProvider } from './elevenlabs.js';
66import { SileroTtsProvider } from './silerotts.js';
7+import { GptSovitsV2Provider } from './gpt-sovits-v2.js';
78import { CoquiTtsProvider } from './coqui.js';
89import { SystemTtsProvider } from './system.js';
910import { NovelTtsProvider } from './novel.js';
@@ -15,6 +16,7 @@ import { VITSTtsProvider } from './vits.js';
1516import { GSVITtsProvider } from './gsvi.js';
1617import { SBVits2TtsProvider } from './sbvits2.js';
1718import { AllTalkTtsProvider } from './alltalk.js';
19+import { CosyVoiceProvider } from './cosyvoice.js';
1820import { SpeechT5TtsProvider } from './speecht5.js';
1921import { AzureTtsProvider } from './azure.js';
2022import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
@@ -86,9 +88,11 @@ const ttsProviders = {
8688 AllTalk: AllTalkTtsProvider,
8789 Azure: AzureTtsProvider,
8890 Coqui: CoquiTtsProvider,
91+ 'CosyVoice (Unofficial)': CosyVoiceProvider,
8992 Edge: EdgeTtsProvider,
9093 ElevenLabs: ElevenLabsTtsProvider,
9194 GSVI: GSVITtsProvider,
95+ 'GPT-SoVITS-V2 (Unofficial)': GptSovitsV2Provider,
9296 Novel: NovelTtsProvider,
9397 OpenAI: OpenAITtsProvider,
9498 'OpenAI Compatible': OpenAICompatibleTtsProvider,
public/scripts/nai-settings.js+30 -6
@@ -74,7 +74,7 @@ const samplers = {
7474
7575let novel_data = null;
7676let badWordsCache = {};
7777const BIAS_KEY = '#novel_api-settingsrange_block_novel';
7878
7979export function setNovelData(data) {
8080 novel_data = data;
@@ -492,11 +492,37 @@ function getBadWordPermutations(text) {
492492
493493export function getNovelGenerationData(finalPrompt, settings, maxLength, isImpersonate, isContinue, _cfgValues, type) {
494494 console.debug('NovelAI generation data for', type);
495+ const isKayra = nai_settings.model_novel.includes('kayra');
496+ const isErato = nai_settings.model_novel.includes('erato');
495497
496498 const tokenizerType = getTokenizerTypeForModel(nai_settings.model_novel);
499+ const stoppingStrings = getStoppingStrings(isImpersonate, isContinue);
500+
501+ // Llama 3 tokenizer, huh?
502+ if (isErato) {
503+ const additionalStopStrings = [];
504+ for (const stoppingString of stoppingStrings) {
505+ if (stoppingString.startsWith('\n')) {
506+ additionalStopStrings.push('.' + stoppingString);
507+ additionalStopStrings.push('!' + stoppingString);
508+ additionalStopStrings.push('?' + stoppingString);
509+ additionalStopStrings.push('*' + stoppingString);
510+ additionalStopStrings.push('"' + stoppingString);
511+ additionalStopStrings.push('_' + stoppingString);
512+ additionalStopStrings.push('...' + stoppingString);
513+ additionalStopStrings.push('."' + stoppingString);
514+ additionalStopStrings.push('?"' + stoppingString);
515+ additionalStopStrings.push('!"' + stoppingString);
516+ additionalStopStrings.push('.*' + stoppingString);
517+ additionalStopStrings.push(')' + stoppingString);
518+ }
519+ }
520+ stoppingStrings.push(...additionalStopStrings);
521+ }
522+
523+ const MAX_STOP_SEQUENCES = 1024;
497524 const stopSequences = (tokenizerType !== tokenizers.NONE)
498- ? getStoppingStrings(isImpersonate, isContinue)
525+ ? stoppingStrings.slice(0, MAX_STOP_SEQUENCES).map(t => getTextTokens(tokenizerType, t))
499- .map(t => getTextTokens(tokenizerType, t))
500526 : undefined;
501527
502528 const badWordIds = (tokenizerType !== tokenizers.NONE)
@@ -515,11 +541,9 @@ export function getNovelGenerationData(finalPrompt, settings, maxLength, isImper
515541 console.log(finalPrompt);
516542 }
517543
518- const isKayra = nai_settings.model_novel.includes('kayra');
519- const isErato = nai_settings.model_novel.includes('erato');
520544
521545 if (isErato) {
522546 finalPrompt = '<|startoftext|><|reserved_special_token81|>' + finalPrompt;
523547 }
524548
525549 const adjustedMaxLength = (isKayra || isErato) ? getKayraMaxResponseTokens() : maximum_output_length;
public/scripts/slash-commands.js+1 -1
@@ -3469,7 +3469,7 @@ function getModelOptions(quiet) {
34693469 case 'openai':
34703470 return oai_settings.chat_completion_source;
34713471 default:
34723472 return nullResultnull;
34733473 }
34743474 }
34753475
public/scripts/sysprompt.js+1 -1
@@ -143,7 +143,7 @@ function selectSystemPromptCallback(args, name) {
143143 foundName = result[0].item;
144144 }
145145
146146 $select.val(foundName).trigger('inputchange');
147147 !quiet && toastr.success(`System prompt "${foundName}" selected`);
148148 return foundName;
149149}
public/scripts/textgen-settings.js+27 -0
@@ -68,6 +68,10 @@ const LLAMACPP_DEFAULT_ORDER = [
6868 'temperature',
6969];
7070const OOBA_DEFAULT_ORDER = [
71+ 'repetition_penalty',
72+ 'presence_penalty',
73+ 'frequency_penalty',
74+ 'dry',
7175 'temperature',
7276 'dynamic_temperature',
7377 'quadratic_sampling',
@@ -80,6 +84,9 @@ const OOBA_DEFAULT_ORDER = [
8084 'top_a',
8185 'min_p',
8286 'mirostat',
87+ 'xtc',
88+ 'encoder_repetition_penalty',
89+ 'no_repeat_ngram',
8390];
8491const BIAS_KEY = '#textgenerationwebui_api-settings';
8592
@@ -798,6 +805,25 @@ function showTypeSpecificControls(type) {
798805 });
799806}
800807
808+/**
809+ * Inserts missing items from the source array into the target array.
810+ * @param {any[]} source - Source array
811+ * @param {any[]} target - Target array
812+ * @returns {void}
813+ */
814+function insertMissingArrayItems(source, target) {
815+ if (source === target || !Array.isArray(source) || !Array.isArray(target)) {
816+ return;
817+ }
818+
819+ for (const item of source) {
820+ if (!target.includes(item)) {
821+ const index = source.indexOf(item);
822+ target.splice(index, 0, item);
823+ }
824+ }
825+}
826+
801827function setSettingByName(setting, value, trigger) {
802828 if (value === null || value === undefined) {
803829 return;
@@ -812,6 +838,7 @@ function setSettingByName(setting, value, trigger) {
812838
813839 if ('sampler_priority' === setting) {
814840 value = Array.isArray(value) ? value : OOBA_DEFAULT_ORDER;
841+ insertMissingArrayItems(OOBA_DEFAULT_ORDER, value);
815842 sortOobaItemsByOrder(value);
816843 settings.sampler_priority = value;
817844 return;
public/scripts/variables.js+196 -112
@@ -1,5 +1,6 @@
11import { chat_metadata, getCurrentChatId, saveSettingsDebounced, sendSystemMessage, system_message_types } from '../script.js';
22import { extension_settings, saveMetadataDebounced } from './extensions.js';
3+import { callGenericPopup, POPUP_TYPE } from './popup.js';
34import { executeSlashCommandsWithOptions } from './slash-commands.js';
45import { SlashCommand } from './slash-commands/SlashCommand.js';
56import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortController.js';
@@ -11,7 +12,7 @@ import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCom
1112import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
1213import { PARSER_FLAG, SlashCommandParser } from './slash-commands/SlashCommandParser.js';
1314import { SlashCommandScope } from './slash-commands/SlashCommandScope.js';
1415import { isFalseBoolean, convertValueType, isTrueBoolean } from './utils.js';
1516
1617/** @typedef {import('./slash-commands/SlashCommandParser.js').NamedArguments} NamedArguments */
1718/** @typedef {import('./slash-commands/SlashCommand.js').UnnamedArguments} UnnamedArguments */
@@ -303,24 +304,48 @@ export function replaceVariableMacros(input) {
303304 return lines.join('\n');
304305}
305306
306307async function listVariablesCallback(args) {
308+ const type = String(args?.format || '').toLowerCase().trim() || 'popup';
309+ const scope = String(args?.scope || '').toLowerCase().trim() || 'all';
307310 if (!chat_metadata.variables) {
308311 chat_metadata.variables = {};
309312 }
310313
311- const localVariables = Object.entries(chat_metadata.variables).map(([name, value]) => `${name}: ${value}`);
314+ const includeLocalVariables = scope === 'all' || scope === 'local';
312- const globalVariables = Object.entries(extension_settings.variables.global).map(([name, value]) => `${name}: ${value}`);
315+ const includeGlobalVariables = scope === 'all' || scope === 'global';
316+
317+ const localVariables = includeLocalVariables ? Object.entries(chat_metadata.variables).map(([name, value]) => `${name}: ${value}`) : [];
318+ const globalVariables = includeGlobalVariables ? Object.entries(extension_settings.variables.global).map(([name, value]) => `${name}: ${value}`) : [];
319+
320+ const jsonVariables = [
321+ ...Object.entries(chat_metadata.variables).map(x => ({ key: x[0], value: x[1], scope: 'local' })),
322+ ...Object.entries(extension_settings.variables.global).map(x => ({ key: x[0], value: x[1], scope: 'global' })),
323+ ];
313324
314325 const localVariablesString = localVariables.length > 0 ? localVariables.join('\n\n') : 'No local variables';
315326 const globalVariablesString = globalVariables.length > 0 ? globalVariables.join('\n\n') : 'No global variables';
316327 const chatName = getCurrentChatId();
317328
318329 const converter = new showdown.Converter();
319- const message = `### Local variables (${chatName}):\n${localVariablesString}\n\n### Global variables:\n${globalVariablesString}`;
330+ const message = [
331+ includeLocalVariables ? `### Local variables (${chatName}):\n${localVariablesString}` : '',
332+ includeGlobalVariables ? `### Global variables:\n${globalVariablesString}` : '',
333+ ].filter(x => x).join('\n\n');
320334 const htmlMessage = DOMPurify.sanitize(converter.makeHtml(message));
321335
336+ switch (type) {
337+ case 'none':
338+ break;
339+ case 'chat':
322340 sendSystemMessage(system_message_types.GENERIC, htmlMessage);
323- return '';
341+ break;
342+ case 'popup':
343+ default:
344+ await callGenericPopup(htmlMessage, POPUP_TYPE.TEXT);
345+ break;
346+ }
347+
348+ return JSON.stringify(jsonVariables);
324349}
325350
326351/**
@@ -463,7 +488,7 @@ function existsGlobalVariable(name) {
463488/**
464489 * Parses boolean operands from command arguments.
465490 * @param {object} args Command arguments
466491 * @returns {{a: string | number, b: string | number?, rule: string}} Boolean operands
467492 */
468493export function parseBooleanOperands(args) {
469494 // Resolution order: numeric literal, local variable, global variable, string literal
@@ -472,6 +497,9 @@ export function parseBooleanOperands(args) {
472497 */
473498 function getOperand(operand) {
474499 if (operand === undefined) {
500+ return undefined;
501+ }
502+ if (operand === '') {
475503 return '';
476504 }
477505
@@ -500,8 +528,8 @@ export function parseBooleanOperands(args) {
500528 return stringLiteral || '';
501529 }
502530
503531 const left = getOperand(args.a ||?? args.left ||?? args.first ||?? args.x);
504532 const right = getOperand(args.b ||?? args.right ||?? args.second ||?? args.y);
505533 const rule = args.rule;
506534
507535 return { a: left, b: right, rule };
@@ -509,84 +537,79 @@ export function parseBooleanOperands(args) {
509537
510538/**
511539 * Evaluates a boolean comparison rule.
512- * @param {string} rule Boolean comparison rule
540+ *
541+ * @param {string?} rule Boolean comparison rule
513542 * @param {string|number} a The left operand
514543 * @param {string|number?} b The right operand
515544 * @returns {boolean} True if the rule yields true, false otherwise
516545 */
517546export function evalBoolean(rule, a, b) {
518547 if (!rulea === undefined) {
519- toastr.warning('The rule must be specified for the boolean comparison.', 'Invalid command');
548+ throw new Error('Left operand is not provided');
520- throw new Error('Invalid command.');
549+ }
550+
551+ // If right-hand side was not provided, whe just check if the left side is truthy
552+ if (b === undefined) {
553+ switch (rule) {
554+ case undefined:
555+ case 'not': {
556+ const resultOnTruthy = rule !== 'not';
557+ if (isTrueBoolean(String(a))) return resultOnTruthy;
558+ if (isFalseBoolean(String(a))) return !resultOnTruthy;
559+ return a ? resultOnTruthy : !resultOnTruthy;
560+ }
561+ default:
562+ throw new Error(`Unknown boolean comparison rule for truthy check. If right operand is not provided, the rule must not provided or be 'not'. Provided: ${rule}`);
563+ }
521564 }
522565
523- let result = false;
566+ // If no rule was provided, we are implicitly using 'eq', as defined for the slash commands
567+ rule ??= 'eq';
568+
524569 if (typeof a === 'number' && typeof b === 'number') {
525570 // only do numeric comparison if both operands are numbers
526571 const aNumber = Number(a);
527572 const bNumber = Number(b);
528573
529574 switch (rule) {
530- case 'not':
531- result = !aNumber;
532- break;
533575 case 'gt':
534576 result =return aNumber > bNumber;
535- break;
536577 case 'gte':
537578 result =return aNumber >= bNumber;
538- break;
539579 case 'lt':
540580 result =return aNumber < bNumber;
541- break;
542581 case 'lte':
543582 result =return aNumber <= bNumber;
544- break;
545583 case 'eq':
546584 result =return aNumber === bNumber;
547- break;
548585 case 'neq':
549586 result =return aNumber !== bNumber;
587+ case 'in':
588+ case 'nin':
589+ // Fall through to string comparison. Otherwise you could not check if 12345 contains 45 for example.
590+ console.debug(`Boolean comparison rule '${rule}' is not supported for type number. Falling back to string comparison.`);
550591 break;
551592 default:
552593 toastr.errorthrow new Error('`Unknown boolean comparison rule for type number.' Accepted: gt, 'Invalidgte, command'lt, lte, eq, neq. Provided: ${rule}`);
553- throw new Error('Invalid command.');
554- }
555- } else {
556- // otherwise do case-insensitive string comparsion, stringify non-strings
557- let aString;
558- let bString;
559- if (typeof a == 'string') {
560- aString = a.toLowerCase();
561- } else {
562- aString = JSON.stringify(a).toLowerCase();
563594 }
564- if (typeof b == 'string') {
565- bString = b.toLowerCase();
566- } else {
567- bString = JSON.stringify(b).toLowerCase();
568595 }
569596
597+ // otherwise do case-insensitive string comparsion, stringify non-strings
598+ let aString = (typeof a === 'string') ? a.toLowerCase() : JSON.stringify(a).toLowerCase();
599+ let bString = (typeof b === 'string') ? b.toLowerCase() : JSON.stringify(b).toLowerCase();
600+
570601 switch (rule) {
571602 case 'in':
572603 result = return aString.includes(bString);
573- break;
574604 case 'nin':
575605 result = return !aString.includes(bString);
576- break;
577606 case 'eq':
578607 result = return aString === bString;
579- break;
580608 case 'neq':
581609 result = return aString !== bString;
582- break;
583610 default:
584- toastr.error('Unknown boolean comparison rule for type string.', 'Invalid /if command');
611+ throw new Error(`Unknown boolean comparison rule for type number. Accepted: in, nin, eq, neq. Provided: ${rule}`);
585- throw new Error('Invalid command.');
586- }
587612 }
588-
589- return result;
590613}
591614
592615/**
@@ -887,7 +910,35 @@ export function registerVariableCommands() {
887910 name: 'listvar',
888911 callback: listVariablesCallback,
889912 aliases: ['listchatvar'],
890- helpString: 'List registered chat variables.',
913+ helpString: 'List registered chat variables. Displays variables in a popup by default. Use the <code>format</code> argument to change the output format.',
914+ returns: 'JSON list of local variables',
915+ namedArgumentList: [
916+ SlashCommandNamedArgument.fromProps({
917+ name: 'scope',
918+ description: 'filter variables by scope',
919+ typeList: [ARGUMENT_TYPE.STRING],
920+ defaultValue: 'all',
921+ isRequired: false,
922+ forceEnum: true,
923+ enumList: [
924+ new SlashCommandEnumValue('all', 'All variables', enumTypes.enum, enumIcons.variable),
925+ new SlashCommandEnumValue('local', 'Local variables', enumTypes.enum, enumIcons.localVariable),
926+ new SlashCommandEnumValue('global', 'Global variables', enumTypes.enum, enumIcons.globalVariable),
927+ ],
928+ }),
929+ SlashCommandNamedArgument.fromProps({
930+ name: 'format',
931+ description: 'output format',
932+ typeList: [ARGUMENT_TYPE.STRING],
933+ isRequired: true,
934+ forceEnum: true,
935+ enumList: [
936+ new SlashCommandEnumValue('popup', 'Show variables in a popup.', enumTypes.enum, enumIcons.default),
937+ new SlashCommandEnumValue('chat', 'Post a system message to the chat.', enumTypes.enum, enumIcons.message),
938+ new SlashCommandEnumValue('none', 'Just return the variables as a JSON list.', enumTypes.enum, enumIcons.array),
939+ ],
940+ }),
941+ ],
891942 }));
892943 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
893944 name: 'setvar',
@@ -1264,32 +1315,36 @@ export function registerVariableCommands() {
12641315 typeList: [ARGUMENT_TYPE.VARIABLE_NAME, ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER],
12651316 isRequired: true,
12661317 enumProvider: commonEnumProviders.variables('all'),
1267- forceEnum: false,
12681318 }),
12691319 SlashCommandNamedArgument.fromProps({
12701320 name: 'right',
12711321 description: 'right operand',
12721322 typeList: [ARGUMENT_TYPE.VARIABLE_NAME, ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER],
1273- isRequired: true,
12741323 enumProvider: commonEnumProviders.variables('all'),
1275- forceEnum: false,
12761324 }),
12771325 new SlashCommandNamedArgument.fromProps({
1278- 'rule', 'comparison rule', [ARGUMENT_TYPE.STRING], true, false, null, [
1326+ name: 'rule',
1279- new SlashCommandEnumValue('gt', 'a > b'),
1327+ description: 'comparison rule',
1280- new SlashCommandEnumValue('gte', 'a >= b'),
1328+ typeList: [ARGUMENT_TYPE.STRING],
1281- new SlashCommandEnumValue('lt', 'a < b'),
1329+ defaultValue: 'eq',
1282- new SlashCommandEnumValue('lte', 'a <= b'),
1330+ enumList: [
12831331 new SlashCommandEnumValue('eq', 'a == b (strings & numbers)'),
12841332 new SlashCommandEnumValue('neq', 'a !== b (strings & numbers)'),
12851333 new SlashCommandEnumValue('notin', '!a includes b (strings & numbers as strings)'),
12861334 new SlashCommandEnumValue('innin', 'a not includes b (strings & numbers as strings)'),
12871335 new SlashCommandEnumValue('ningt', 'a not includes> b (numbers)'),
1336+ new SlashCommandEnumValue('gte', 'a >= b (numbers)'),
1337+ new SlashCommandEnumValue('lt', 'a < b (numbers)'),
1338+ new SlashCommandEnumValue('lte', 'a <= b (numbers)'),
1339+ new SlashCommandEnumValue('not', '!a (truthy)'),
12881340 ],
1289- ),
1341+ forceEnum: true,
1290- new SlashCommandNamedArgument(
1342+ }),
1291- 'else', 'command to execute if not true', [ARGUMENT_TYPE.CLOSURE, ARGUMENT_TYPE.SUBCOMMAND], false,
1343+ SlashCommandNamedArgument.fromProps({
1292- ),
1344+ name: 'else',
1345+ description: 'command to execute if not true',
1346+ typeList: [ARGUMENT_TYPE.CLOSURE, ARGUMENT_TYPE.SUBCOMMAND],
1347+ }),
12931348 ],
12941349 unnamedArgumentList: [
12951350 new SlashCommandArgument(
@@ -1307,17 +1362,25 @@ export function registerVariableCommands() {
13071362 Numeric values and string literals for left and right operands supported.
13081363 </div>
13091364 <div>
1365+ If the rule is not provided, it defaults to <code>eq</code>.
1366+ </div>
1367+ <div>
1368+ If no right operand is provided, it defaults to checking the <code>left</code> value to be truthy.
1369+ A non-empty string or non-zero number is considered truthy, as is the value <code>true</code> or <code>on</code>.<br />
1370+ Only acceptable rules for no provided right operand are <code>not</code>, and no provided rule - which default to returning whether it is not or is truthy.
1371+ </div>
1372+ <div>
13101373 <strong>Available rules:</strong>
13111374 <ul>
13121375 <li>gt<code>eq</code> => a >== b <small>(strings & numbers)</small></li>
13131376 <li>gte<code>neq</code> => a >!== b <small>(strings & numbers)</small></li>
13141377 <li>lt<code>in</code> => a <includes b <small>(strings & numbers as strings)</small></li>
1315- <li>lte => a <= b</li>
1378+ <li><code>nin</code> => a not includes b <small>(strings & numbers as strings)</small></li>
13161379 <li>eq<code>gt</code> => a ==> b <small>(numbers)</small></li>
13171380 <li>neq<code>gte</code> => a !>= b <small>(numbers)</small></li>
13181381 <li>not<code>lt</code> => !a < b <small>(numbers)</small></li>
13191382 <li>in (strings)<code>lte</code> => a includes<= b <small>(numbers)</small></li>
1320- <li>nin (strings) => a not includes b</li>
1383+ <li><code>not</code> => !a <small>(truthy)</small></li>
13211384 </ul>
13221385 </div>
13231386 <div>
@@ -1327,6 +1390,17 @@ export function registerVariableCommands() {
13271390 <pre><code class="language-stscript">/if left=score right=10 rule=gte "/speak You win"</code></pre>
13281391 triggers a /speak command if the value of "score" is greater or equals 10.
13291392 </li>
1393+ <li>
1394+ <pre><code class="language-stscript">/if left={{lastMessage}} rule=in right=surprise {: /echo SURPISE! :}</code></pre>
1395+ executes a subcommand defined as a closure if the given value contains a specified word.
1396+ <li>
1397+ <pre><code class="language-stscript">/if left=myContent {: /echo My content had some content. :}</code></pre>
1398+ executes the defined subcommand, if the provided value of left is truthy (contains some kind of contant that is not empty or false)
1399+ </li>
1400+ <li>
1401+ <pre><code class="language-stscript">/if left=tree right={{getvar::object}} {: /echo The object is a tree! :}</code></pre>
1402+ executes the defined subcommand, if the left and right values are equals.
1403+ </li>
13301404 </ul>
13311405 </div>
13321406 `,
@@ -1342,32 +1416,38 @@ export function registerVariableCommands() {
13421416 typeList: [ARGUMENT_TYPE.VARIABLE_NAME, ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER],
13431417 isRequired: true,
13441418 enumProvider: commonEnumProviders.variables('all'),
1345- forceEnum: false,
13461419 }),
13471420 SlashCommandNamedArgument.fromProps({
13481421 name: 'right',
13491422 description: 'right operand',
13501423 typeList: [ARGUMENT_TYPE.VARIABLE_NAME, ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER],
1351- isRequired: true,
13521424 enumProvider: commonEnumProviders.variables('all'),
1353- forceEnum: false,
13541425 }),
13551426 new SlashCommandNamedArgument.fromProps({
1356- 'rule', 'comparison rule', [ARGUMENT_TYPE.STRING], true, false, null, [
1427+ name: 'rule',
1357- new SlashCommandEnumValue('gt', 'a > b'),
1428+ description: 'comparison rule',
1358- new SlashCommandEnumValue('gte', 'a >= b'),
1429+ typeList: [ARGUMENT_TYPE.STRING],
1359- new SlashCommandEnumValue('lt', 'a < b'),
1430+ defaultValue: 'eq',
1360- new SlashCommandEnumValue('lte', 'a <= b'),
1431+ enumList: [
13611432 new SlashCommandEnumValue('eq', 'a == b (strings & numbers)'),
13621433 new SlashCommandEnumValue('neq', 'a !== b (strings & numbers)'),
13631434 new SlashCommandEnumValue('notin', '!a includes b (strings & numbers as strings)'),
13641435 new SlashCommandEnumValue('innin', 'a not includes b (strings & numbers as strings)'),
13651436 new SlashCommandEnumValue('ningt', 'a not includes> b (numbers)'),
1437+ new SlashCommandEnumValue('gte', 'a >= b (numbers)'),
1438+ new SlashCommandEnumValue('lt', 'a < b (numbers)'),
1439+ new SlashCommandEnumValue('lte', 'a <= b (numbers)'),
1440+ new SlashCommandEnumValue('not', '!a (truthy)'),
13661441 ],
1367- ),
1442+ forceEnum: true,
1368- new SlashCommandNamedArgument(
1443+ }),
1369- 'guard', 'disable loop iteration limit', [ARGUMENT_TYPE.STRING], false, false, null, commonEnumProviders.boolean('onOff')(),
1444+ SlashCommandNamedArgument.fromProps({
1370- ),
1445+ name: 'guard',
1446+ description: 'disable loop iteration limit',
1447+ typeList: [ARGUMENT_TYPE.STRING],
1448+ defaultValue: 'off',
1449+ enumList: commonEnumProviders.boolean('onOff')(),
1450+ }),
13711451 ],
13721452 unnamedArgumentList: [
13731453 new SlashCommandArgument(
@@ -1386,15 +1466,15 @@ export function registerVariableCommands() {
13861466 <div>
13871467 <strong>Available rules:</strong>
13881468 <ul>
13891469 <li>gt<code>eq</code> => a >== b <small>(strings & numbers)</small></li>
13901470 <li>gte<code>neq</code> => a >!== b <small>(strings & numbers)</small></li>
13911471 <li>lt<code>in</code> => a <includes b <small>(strings & numbers as strings)</small></li>
1392- <li>lte => a <= b</li>
1472+ <li><code>nin</code> => a not includes b <small>(strings & numbers as strings)</small></li>
13931473 <li>eq<code>gt</code> => a ==> b <small>(numbers)</small></li>
13941474 <li>neq<code>gte</code> => a !>= b <small>(numbers)</small></li>
13951475 <li>not<code>lt</code> => !a < b <small>(numbers)</small></li>
13961476 <li>in (strings)<code>lte</code> => a includes<= b <small>(numbers)</small></li>
1397- <li>nin (strings) => a not includes b</li>
1477+ <li><code>not</code> => !a <small>(truthy)</small></li>
13981478 </ul>
13991479 </div>
14001480 <div>
@@ -1404,7 +1484,11 @@ export function registerVariableCommands() {
14041484 <pre><code class="language-stscript">/setvar key=i 0 | /while left=i right=10 rule=lte "/addvar key=i 1"</code></pre>
14051485 adds 1 to the value of "i" until it reaches 10.
14061486 </li>
1487+ <li>
1488+ <pre><code class="language-stscript">/while left={{getvar::currentword}} {: /setvar key=currentword {: /do-something-and-return :}() | /echo The current work is "{{getvar::currentword}}" :}</code></pre>
1489+ executes the defined subcommand as long as the "currentword" variable is truthy (has any content that is not false/empty)
14071490 </ul>
1491+ </li>
14081492 </div>
14091493 <div>
14101494 Loops are limited to 100 iterations by default, pass <code>guard=off</code> to disable.
public/style.css+23 -9
@@ -3154,6 +3154,26 @@ grammarly-extension {
31543154 align-items: center;
31553155}
31563156
3157+.alternate_greeting details {
3158+ padding: 2px;
3159+}
3160+
3161+.alternate_greeting summary {
3162+ list-style-position: outside;
3163+ margin-left: 1em;
3164+ padding-left: 1em;
3165+}
3166+
3167+.alternate_greeting textarea {
3168+ field-sizing: content;
3169+ max-height: 50dvh;
3170+}
3171+
3172+.alternate_greeting summary::marker,
3173+.alternate_greeting summary strong {
3174+ cursor: pointer;
3175+}
3176+
31573177#rm_characters_block .form_create_bottom_buttons_block {
31583178 justify-content: space-evenly !important;
31593179 flex-grow: 0;
@@ -3245,8 +3265,9 @@ grammarly-extension {
32453265}
32463266
32473267.wide_dialogue_popup {
3248- aspect-ratio: 1 / 1;
3268+ /* FIXME: Chrome 129 broke max-height for aspect-ratio sized elements */
3249- width: unset !important;
3269+ /* aspect-ratio: 1 / 1; */
3270+ /* width: unset !important; */
32503271 min-width: var(--sheldWidth);
32513272}
32523273
@@ -5365,13 +5386,6 @@ body:not(.movingUI) .drawer-content.maximized {
53655386
53665387/* Jank mobile support for gallery and future draggables */
53675388@media screen and (max-width: 1000px) {
5368- #gallery {
5369- display: block;
5370- width: 100vw;
5371- height: 100vh;
5372- z-index: 9999;
5373- }
5374-
53755389 .draggable {
53765390 display: block;
53775391 width: 100vw;
src/users.js+6 -2
@@ -347,6 +347,8 @@ async function migrateSystemPrompts() {
347347 if (fs.existsSync(migrateMarker)) {
348348 continue;
349349 }
350+ const backupsPath = path.join(directory.backups, '_sysprompt');
351+ fs.mkdirSync(backupsPath, { recursive: true });
350352 const defaultPrompts = await getDefaultSystemPrompts();
351353 const instucts = fs.readdirSync(directory.instruct);
352354 let migratedPrompts = [];
@@ -356,6 +358,8 @@ async function migrateSystemPrompts() {
356358 if (path.extname(instruct) === '.json' && !fs.existsSync(sysPromptPath)) {
357359 const instructData = JSON.parse(fs.readFileSync(instructPath, 'utf8'));
358360 if ('system_prompt' in instructData && 'name' in instructData) {
361+ const backupPath = path.join(backupsPath, `${instructData.name}.json`);
362+ fs.cpSync(instructPath, backupPath, { force: true });
359363 const syspromptData = { name: instructData.name, content: instructData.system_prompt };
360364 migratedPrompts.push(syspromptData);
361365 delete instructData.system_prompt;
@@ -374,8 +378,8 @@ async function migrateSystemPrompts() {
374378 console.log(`Migrated system prompt ${sysPromptData.name} for ${directory.root.split(path.sep).pop()}`);
375379 }
376380 writeFileAtomicSync(migrateMarker, '');
377381 } catch (error) {
378- // Ignore errors
382+ console.error('Error migrating system prompts:', error);
379383 }
380384 }
381385}