diff --git a/.eslintrc.js b/.eslintrc.js index 8d9e36fe3..8d8386505 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -59,15 +59,18 @@ module.exports = { }, }, ], - // There are various vendored libraries that shouldn't be linted ignorePatterns: [ - 'public/lib/**/*', - '*.min.js', - 'src/ai_horde/**/*', - 'plugins/**/*', - 'data/**/*', - 'backups/**/*', - 'node_modules/**/*', + '**/node_modules/**', + '**/dist/**', + '**/.git/**', + 'public/lib/**', + 'backups/**', + 'data/**', + 'cache/**', + 'src/tokenizers/**', + 'docker/**', + 'plugins/**', + '**/*.min.js', ], rules: { 'no-unused-vars': ['error', { args: 'none' }], diff --git a/.github/readme-de_de.md b/.github/readme-de_de.md new file mode 100644 index 000000000..d9bcbf66f --- /dev/null +++ b/.github/readme-de_de.md @@ -0,0 +1,383 @@ +> [!IMPORTANT] +> Die hier veröffentlichten Informationen sind möglicherweise veraltet oder unvollständig. Für aktuelle Informationen nutzen Sie bitte die englische Version. +> Letztes Update dieser README: 28.9.2024 + + + +![][cover] + +
/imagine-source comfy
. Returns the current source.',
+ callback: async (_args, name) => {
+ if (!name) {
+ return extension_settings.sd.source;
+ }
+ const isKnownSource = Object.keys(sources).includes(String(name));
+ if (!isKnownSource) {
+ throw new Error('The value provided is not a valid image generation source.');
+ }
+ const option = document.querySelector(`#sd_source [value="${name}"]`);
+ if (!(option instanceof HTMLOptionElement)) {
+ throw new Error('Could not find the source option in the dropdown.');
+ }
+ option.selected = true;
+ await onSourceChange();
+ return extension_settings.sd.source;
+ },
+ }));
+
+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
+ name: 'imagine-style',
+ aliases: ['sd-style', 'img-style'],
+ returns: 'a name of the current style',
+ unnamedArgumentList: [
+ SlashCommandArgument.fromProps({
+ description: 'style name',
+ typeList: [ARGUMENT_TYPE.STRING],
+ isRequired: false,
+ forceEnum: true,
+ enumProvider: getSelectEnumProvider('sd_style', false),
+ }),
+ ],
+ helpString: 'If an argument is provided, change the style of the image generation, e.g. /imagine-style MyStyle
. Returns the current style.',
+ callback: async (_args, name) => {
+ if (!name) {
+ return extension_settings.sd.style;
+ }
+ const option = document.querySelector(`#sd_style [value="${name}"]`);
+ if (!(option instanceof HTMLOptionElement)) {
+ throw new Error('Could not find the style option in the dropdown.');
+ }
+ option.selected = true;
+ onStyleSelect();
+ return extension_settings.sd.style;
+ },
+ }));
+
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'imagine-comfy-workflow',
callback: changeComfyWorkflow,
@@ -3832,7 +4164,7 @@ jQuery(async () => {
description: 'workflow name',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
- enumProvider: () => Array.from(document.querySelectorAll('#sd_comfy_workflow > [value]')).map(x => x.getAttribute('value')).map(workflow => new SlashCommandEnumValue(workflow)),
+ enumProvider: getSelectEnumProvider('sd_comfy_workflow', false),
}),
],
helpString: '(workflowName) - change the workflow to be used for image generation with ComfyUI, e.g. /imagine-comfy-workflow MyWorkflow
',
@@ -3874,7 +4206,6 @@ jQuery(async () => {
$('#sd_hr_scale').on('input', onHrScaleInput);
$('#sd_denoising_strength').on('input', onDenoisingStrengthInput);
$('#sd_hr_second_pass_steps').on('input', onHrSecondPassStepsInput);
- $('#sd_novel_upscale_ratio').on('input', onNovelUpscaleRatioInput);
$('#sd_novel_anlas_guard').on('input', onNovelAnlasGuardInput);
$('#sd_novel_view_anlas').on('click', onViewAnlasClick);
$('#sd_novel_sm').on('input', onNovelSmInput);
@@ -3887,7 +4218,6 @@ jQuery(async () => {
$('#sd_comfy_open_workflow_editor').on('click', onComfyOpenWorkflowEditorClick);
$('#sd_comfy_new_workflow').on('click', onComfyNewWorkflowClick);
$('#sd_comfy_delete_workflow').on('click', onComfyDeleteWorkflowClick);
- $('#sd_expand').on('input', onExpandInput);
$('#sd_style').on('change', onStyleSelect);
$('#sd_save_style').on('click', onSaveStyleClick);
$('#sd_delete_style').on('click', onDeleteStyleClick);
@@ -3908,6 +4238,7 @@ jQuery(async () => {
$('#sd_stability_key').on('click', onStabilityKeyClick);
$('#sd_stability_style_preset').on('change', onStabilityStylePresetChange);
$('#sd_huggingface_model_id').on('input', onHFModelInput);
+ $('#sd_function_tool').on('input', onFunctionToolInput);
if (!CSS.supports('field-sizing', 'content')) {
$('.sd_settings .inline-drawer-toggle').on('click', function () {
diff --git a/public/scripts/extensions/stable-diffusion/settings.html b/public/scripts/extensions/stable-diffusion/settings.html
index f43ed7246..3046ff95c 100644
--- a/public/scripts/extensions/stable-diffusion/settings.html
+++ b/public/scripts/extensions/stable-diffusion/settings.html
@@ -14,9 +14,13 @@
Edit prompts before generation
vectors.enableModelScopes
to true in config.yaml to switch between vectorization models without needing to purge existing vectors.
- This option will soon be enabled by default.
-
-
-
This will also delete all your chats with that group. If you want to delete a single conversation, select a "View past chats" option in the lower left menu.
'); + const confirm = await Popup.show.confirm(t`Delete the group?`, '' + t`This will also delete all your chats with that group. If you want to delete a single conversation, select a "View past chats" option in the lower left menu.` + '
'); if (confirm) { deleteGroup(openGroupId); } @@ -1630,7 +1630,7 @@ function updateFavButtonState(state) { export async function openGroupById(groupId) { if (isChatSaving) { - toastr.info('Please wait until the chat is saved before switching characters.', 'Your chat is still saving...'); + toastr.info(t`Please wait until the chat is saved before switching characters.`, t`Your chat is still saving...`); return; } @@ -1659,7 +1659,7 @@ export async function openGroupById(groupId) { function openCharacterDefinition(characterSelect) { if (is_group_generating) { - toastr.warning('Can\'t peek a character while group reply is being generated'); + toastr.warning(t`Can't peek a character while group reply is being generated`); console.warn('Can\'t peek a character def while group reply is being generated'); return; } @@ -1908,7 +1908,7 @@ export async function saveGroupBookmarkChat(groupId, name, metadata, mesId) { }); if (!response.ok) { - toastr.error('Check the server connection and reload the page to prevent data loss.', 'Group chat could not be saved'); + toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Group chat could not be saved`); console.error('Group chat could not be saved', response); } } diff --git a/public/scripts/i18n.js b/public/scripts/i18n.js index a8f48e221..4764b1b8d 100644 --- a/public/scripts/i18n.js +++ b/public/scripts/i18n.js @@ -215,6 +215,7 @@ function addLanguagesToDropdown() { } export async function initLocales() { + moment.locale(localeFile); langs = await fetch('/locales/lang.json').then(response => response.json()); localeData = await getLocaleData(localeFile); applyLocale(); diff --git a/public/scripts/instruct-mode.js b/public/scripts/instruct-mode.js index 033f5c3e8..c5b70ac52 100644 --- a/public/scripts/instruct-mode.js +++ b/public/scripts/instruct-mode.js @@ -384,6 +384,10 @@ export function formatInstructModeChat(name, mes, isUser, isNarrator, forceAvata * @returns {string} Formatted instruct mode system prompt. */ export function formatInstructModeSystemPrompt(systemPrompt) { + if (!systemPrompt) { + return ''; + } + const separator = power_user.instruct.wrap ? '\n' : ''; if (power_user.instruct.system_sequence_prefix) { @@ -550,11 +554,9 @@ export function formatInstructModePrompt(name, isImpersonate, promptBias, name1, * @param {string} name Preset name. */ function selectMatchingContextTemplate(name) { - let foundMatch = false; for (const context_preset of context_presets) { // If context template matches the instruct preset if (context_preset.name === name) { - foundMatch = true; selectContextPreset(context_preset.name, { isAuto: true }); break; } diff --git a/public/scripts/kai-settings.js b/public/scripts/kai-settings.js index 27a204c42..6efadce87 100644 --- a/public/scripts/kai-settings.js +++ b/public/scripts/kai-settings.js @@ -188,7 +188,7 @@ export async function generateKoboldWithStreaming(generate_data, signal) { if (data?.token) { text += data.token; } - yield { text, swipes: [] }; + yield { text, swipes: [], toolCalls: [] }; } }; } diff --git a/public/scripts/login.js b/public/scripts/login.js index e02ec1039..dec6a90e9 100644 --- a/public/scripts/login.js +++ b/public/scripts/login.js @@ -180,7 +180,13 @@ function displayError(message) { * Preserves the query string. */ function redirectToHome() { - window.location.href = '/' + window.location.search; + // After a login theres no need to preserve the + // noauto (if present) + const urlParams = new URLSearchParams(window.location.search); + + urlParams.delete('noauto'); + + window.location.href = '/' + urlParams.toString(); } /** diff --git a/public/scripts/nai-settings.js b/public/scripts/nai-settings.js index e24353499..040c6f203 100644 --- a/public/scripts/nai-settings.js +++ b/public/scripts/nai-settings.js @@ -492,11 +492,37 @@ function getBadWordPermutations(text) { export function getNovelGenerationData(finalPrompt, settings, maxLength, isImpersonate, isContinue, _cfgValues, type) { console.debug('NovelAI generation data for', type); + const isKayra = nai_settings.model_novel.includes('kayra'); + const isErato = nai_settings.model_novel.includes('erato'); const tokenizerType = getTokenizerTypeForModel(nai_settings.model_novel); + const stoppingStrings = getStoppingStrings(isImpersonate, isContinue); + + // Llama 3 tokenizer, huh? + if (isErato) { + const additionalStopStrings = []; + for (const stoppingString of stoppingStrings) { + if (stoppingString.startsWith('\n')) { + additionalStopStrings.push('.' + stoppingString); + additionalStopStrings.push('!' + stoppingString); + additionalStopStrings.push('?' + stoppingString); + additionalStopStrings.push('*' + stoppingString); + additionalStopStrings.push('"' + stoppingString); + additionalStopStrings.push('_' + stoppingString); + additionalStopStrings.push('...' + stoppingString); + additionalStopStrings.push('."' + stoppingString); + additionalStopStrings.push('?"' + stoppingString); + additionalStopStrings.push('!"' + stoppingString); + additionalStopStrings.push('.*' + stoppingString); + additionalStopStrings.push(')' + stoppingString); + } + } + stoppingStrings.push(...additionalStopStrings); + } + + const MAX_STOP_SEQUENCES = 1024; const stopSequences = (tokenizerType !== tokenizers.NONE) - ? getStoppingStrings(isImpersonate, isContinue) - .map(t => getTextTokens(tokenizerType, t)) + ? stoppingStrings.slice(0, MAX_STOP_SEQUENCES).map(t => getTextTokens(tokenizerType, t)) : undefined; const badWordIds = (tokenizerType !== tokenizers.NONE) @@ -515,11 +541,9 @@ export function getNovelGenerationData(finalPrompt, settings, maxLength, isImper console.log(finalPrompt); } - const isKayra = nai_settings.model_novel.includes('kayra'); - const isErato = nai_settings.model_novel.includes('erato'); if (isErato) { - finalPrompt = '<|startoftext|>' + finalPrompt; + finalPrompt = '<|startoftext|><|reserved_special_token81|>' + finalPrompt; } const adjustedMaxLength = (isKayra || isErato) ? getKayraMaxResponseTokens() : maximum_output_length; @@ -722,7 +746,7 @@ export async function generateNovelWithStreaming(generate_data, signal) { text += data.token; } - yield { text, swipes: [], logprobs: parseNovelAILogprobs(data.logprobs) }; + yield { text, swipes: [], logprobs: parseNovelAILogprobs(data.logprobs), toolCalls: [] }; } }; } diff --git a/public/scripts/openai.js b/public/scripts/openai.js index 057ceaa0e..7fb23e711 100644 --- a/public/scripts/openai.js +++ b/public/scripts/openai.js @@ -61,12 +61,6 @@ import { stringFormat, } from './utils.js'; import { countTokensOpenAI, getTokenizerModel } from './tokenizers.js'; -import { - formatInstructModeChat, - formatInstructModeExamples, - formatInstructModePrompt, - formatInstructModeSystemPrompt, -} from './instruct-mode.js'; import { isMobile } from './RossAscends-mods.js'; import { saveLogprobsForActiveMessage } from './logprobs.js'; import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; @@ -76,6 +70,7 @@ import { renderTemplateAsync } from './templates.js'; import { SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js'; import { Popup, POPUP_RESULT } from './popup.js'; import { t } from './i18n.js'; +import { ToolManager } from './tool-calling.js'; export { openai_messages_count, @@ -204,7 +199,10 @@ const continue_postfix_types = { const custom_prompt_post_processing_types = { NONE: '', + /** @deprecated Use MERGE instead. */ CLAUDE: 'claude', + MERGE: 'merge', + STRICT: 'strict', }; const sensitiveFields = [ @@ -227,7 +225,6 @@ const default_settings = { top_a_openai: 0, repetition_penalty_openai: 1, stream_openai: false, - websearch_cohere: false, openai_max_context: max_4k, openai_max_tokens: 300, wrap_in_quotes: false, @@ -263,7 +260,6 @@ const default_settings = { windowai_model: '', openrouter_model: openrouter_website_model, openrouter_use_fallback: false, - openrouter_force_instruct: false, openrouter_group_models: false, openrouter_sort_models: 'alphabetically', openrouter_providers: [], @@ -305,7 +301,6 @@ const oai_settings = { top_a_openai: 0, repetition_penalty_openai: 1, stream_openai: false, - websearch_cohere: false, openai_max_context: max_4k, openai_max_tokens: 300, wrap_in_quotes: false, @@ -341,7 +336,6 @@ const oai_settings = { windowai_model: '', openrouter_model: openrouter_website_model, openrouter_use_fallback: false, - openrouter_force_instruct: false, openrouter_group_models: false, openrouter_sort_models: 'alphabetically', openrouter_providers: [], @@ -396,7 +390,7 @@ async function validateReverseProxy() { new URL(oai_settings.reverse_proxy); } catch (err) { - toastr.error('Entered reverse proxy address is not a valid URL'); + toastr.error(t`Entered reverse proxy address is not a valid URL`); setOnlineStatus('no_connection'); resultCheckStatus(); throw err; @@ -407,7 +401,7 @@ async function validateReverseProxy() { const confirmation = skipConfirm || await Popup.show.confirm(t`Connecting To Proxy`, await renderTemplateAsync('proxyConnectionWarning', { proxyURL: DOMPurify.sanitize(oai_settings.reverse_proxy) })); if (!confirmation) { - toastr.error('Update or remove your reverse proxy settings.'); + toastr.error(t`Update or remove your reverse proxy settings.`); setOnlineStatus('no_connection'); resultCheckStatus(); throw new Error('Proxy connection denied.'); @@ -416,108 +410,6 @@ async function validateReverseProxy() { localStorage.setItem(rememberKey, String(true)); } -/** - * Converts the Chat Completion object to an Instruct Mode prompt string. - * @param {object[]} messages Array of messages - * @param {string} type Generation type - * @returns {string} Text completion prompt - */ -function convertChatCompletionToInstruct(messages, type) { - const newChatPrompts = [ - substituteParams(oai_settings.new_chat_prompt), - substituteParams(oai_settings.new_example_chat_prompt), - substituteParams(oai_settings.new_group_chat_prompt), - ]; - messages = messages.filter(x => !newChatPrompts.includes(x.content)); - - let chatMessagesText = ''; - let systemPromptText = ''; - let examplesText = ''; - - function getPrefix(message) { - let prefix; - - if (message.role === 'user' || message.name === 'example_user') { - if (selected_group) { - prefix = ''; - } else if (message.name === 'example_user') { - prefix = name1; - } else { - prefix = message.name ?? name1; - } - } - - if (message.role === 'assistant' || message.name === 'example_assistant') { - if (selected_group) { - prefix = ''; - } - else if (message.name === 'example_assistant') { - prefix = name2; - } else { - prefix = message.name ?? name2; - } - } - - return prefix; - } - - function toString(message) { - if (message.role === 'system' && !message.name) { - return message.content; - } - - const prefix = getPrefix(message); - return prefix ? `${prefix}: ${message.content}` : message.content; - } - - const firstChatMessage = messages.findIndex(message => message.role === 'assistant' || message.role === 'user'); - const systemPromptMessages = messages.slice(0, firstChatMessage).filter(message => message.role === 'system' && !message.name); - - if (systemPromptMessages.length) { - systemPromptText = systemPromptMessages.map(message => message.content).join('\n'); - systemPromptText = formatInstructModeSystemPrompt(systemPromptText); - } - - const exampleMessages = messages.filter(x => x.role === 'system' && (x.name === 'example_user' || x.name === 'example_assistant')); - - if (exampleMessages.length) { - const blockHeading = power_user.context.example_separator ? (substituteParams(power_user.context.example_separator) + '\n') : ''; - const examplesArray = exampleMessages.map(m => '/sendas
or other commands in need of a character name
+ if you have multiple characters with the same name.
+ /char-find name="Chloe"
+ Returns the avatar key for "Chloe".
+ /search name="Chloe" tag="friend"
+ Returns the avatar key for the character "Chloe" that is tagged with "friend".
+ This is useful if you for example have multiple characters named "Chloe", and the others are "foe", "goddess", or anything else,
+ so you can actually select the character you are looking for.
+ /sendas name="Chloe" Hello, guys!
will send "Hello, guys!" from "Chloe".
+ /sendas name="Chloe" avatar="BigBadBoss" Hehehe, I am the big bad evil, fear me.
+ will send a message as the character "Chloe", but utilizing the avatar from a character named "BigBadBoss".
+ format
argument to change the output format.',
- returns: 'JSON object of script injections',
+ helpString: 'Lists all script injections for the current chat. Displays injects in a popup by default. Use the return
argument to change the return type.',
+ returns: 'Optionalls the JSON object of script injections',
namedArgumentList: [
+ SlashCommandNamedArgument.fromProps({
+ name: 'return',
+ description: 'The way how you want the return value to be provided',
+ typeList: [ARGUMENT_TYPE.STRING],
+ defaultValue: 'popup-html',
+ enumList: slashCommandReturnHelper.enumList({ allowPipe: false, allowObject: true, allowChat: true, allowPopup: true, allowTextVersion: false }),
+ forceEnum: true,
+ }),
+ // TODO remove some day
SlashCommandNamedArgument.fromProps({
name: 'format',
- description: 'output format',
+ description: '!!! DEPRECATED - use "return" instead !!! output format',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
forceEnum: true,
@@ -1761,37 +1896,43 @@ function injectCallback(args, value) {
}
async function listInjectsCallback(args) {
- const type = String(args?.format).toLowerCase().trim();
- if (!chat_metadata.script_injects || !Object.keys(chat_metadata.script_injects).length) {
- type !== 'none' && toastr.info('No script injections for the current chat');
- return JSON.stringify({});
+ /** @type {import('./slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */
+ let returnType = args.return;
+
+ // Old legacy return type handling
+ if (args.format) {
+ toastr.warning(`Legacy argument 'format' with value '${args.format}' is deprecated. Please use 'return' instead. Routing to the correct return type...`, 'Deprecation warning');
+ const type = String(args?.format).toLowerCase().trim();
+ if (!chat_metadata.script_injects || !Object.keys(chat_metadata.script_injects).length) {
+ type !== 'none' && toastr.info('No script injections for the current chat');
+ }
+ switch (type) {
+ case 'none':
+ returnType = 'none';
+ break;
+ case 'chat':
+ returnType = 'chat-html';
+ break;
+ case 'popup':
+ default:
+ returnType = 'popup-html';
+ break;
+ }
}
- const injects = Object.entries(chat_metadata.script_injects)
- .map(([id, inject]) => {
- const position = Object.entries(extension_prompt_types);
- const positionName = position.find(([_, value]) => value === inject.position)?.[0] ?? 'unknown';
- return `* **${id}**: ${inject.value}
(${positionName}, depth: ${inject.depth}, scan: ${inject.scan ?? false}, role: ${inject.role ?? extension_prompt_roles.SYSTEM})`;
- })
- .join('\n');
+ // Now the actual new return type handling
+ const buildTextValue = (injects) => {
+ const injectsStr = Object.entries(injects)
+ .map(([id, inject]) => {
+ const position = Object.entries(extension_prompt_types);
+ const positionName = position.find(([_, value]) => value === inject.position)?.[0] ?? 'unknown';
+ return `* **${id}**: ${inject.value}
(${positionName}, depth: ${inject.depth}, scan: ${inject.scan ?? false}, role: ${inject.role ?? extension_prompt_roles.SYSTEM})`;
+ })
+ .join('\n');
+ return `### Script injections:\n${injectsStr || 'No script injections for the current chat'}`;
+ };
- const converter = new showdown.Converter();
- const messageText = `### Script injections:\n${injects}`;
- const htmlMessage = DOMPurify.sanitize(converter.makeHtml(messageText));
-
- switch (type) {
- case 'none':
- break;
- case 'chat':
- sendSystemMessage(system_message_types.GENERIC, htmlMessage);
- break;
- case 'popup':
- default:
- await callGenericPopup(htmlMessage, POPUP_TYPE.TEXT);
- break;
- }
-
- return JSON.stringify(chat_metadata.script_injects);
+ return await slashCommandReturnHelper.doReturn(returnType ?? 'popup-html', chat_metadata.script_injects ?? {}, { objectToStringFunc: buildTextValue });
}
/**
@@ -2293,7 +2434,8 @@ async function generateCallback(args, value) {
setEphemeralStopStrings(resolveVariable(args?.stop));
const name = args?.name;
- const result = await generateQuietPrompt(value, quietToLoud, false, '', name, length);
+ const char = findChar({ name: name });
+ const result = await generateQuietPrompt(value, quietToLoud, false, '', char?.name ?? name, length);
return result;
} catch (err) {
console.error('Error on /gen generation', err);
@@ -2477,30 +2619,26 @@ async function askCharacter(args, text) {
// Not supported in group chats
// TODO: Maybe support group chats?
if (selected_group) {
- toastr.error('Cannot run /ask command in a group chat!');
+ toastr.warning('Cannot run /ask command in a group chat!');
return '';
}
- let name = '';
-
- if (args?.name) {
- name = args.name.trim();
-
- if (!name) {
- toastr.warning('You must specify a name of the character to ask.');
- return '';
- }
+ if (!args.name) {
+ toastr.warning('You must specify a name of the character to ask.');
+ return '';
}
const prevChId = this_chid;
// Find the character
- const chId = characters.findIndex((e) => e.name === name || e.avatar === name);
- if (!characters[chId] || chId === -1) {
+ const character = findChar({ name: args?.name });
+ if (!character) {
toastr.error('Character not found.');
return '';
}
+ const chId = getCharIndex(character);
+
if (text) {
const mesText = getRegexedString(text.trim(), regex_placement.SLASH_COMMAND);
// Sending a message implicitly saves the chat, so this needs to be done before changing the character
@@ -2511,32 +2649,27 @@ async function askCharacter(args, text) {
// Override character and send a user message
setCharacterId(String(chId));
- const character = characters[chId];
- let force_avatar, original_avatar;
+ const { name, force_avatar, original_avatar } = getNameAndAvatarForMessage(character, args?.name);
- if (character && character.avatar !== 'none') {
- force_avatar = getThumbnailUrl('avatar', character.avatar);
- original_avatar = character.avatar;
- }
- else {
- force_avatar = default_avatar;
- original_avatar = default_avatar;
- }
-
- setCharacterName(character.name);
+ setCharacterName(name);
const restoreCharacter = () => {
if (String(this_chid) !== String(chId)) {
return;
}
- setCharacterId(prevChId);
- setCharacterName(characters[prevChId].name);
+ if (prevChId !== undefined) {
+ setCharacterId(prevChId);
+ setCharacterName(characters[prevChId].name);
+ } else {
+ setCharacterId(undefined);
+ setCharacterName(neutralCharacterName);
+ }
// Only force the new avatar if the character name is the same
// This skips if an error was fired
const lastMessage = chat[chat.length - 1];
- if (lastMessage && lastMessage?.name === character.name) {
+ if (lastMessage && lastMessage?.name === name) {
lastMessage.force_avatar = force_avatar;
lastMessage.original_avatar = original_avatar;
}
@@ -2547,7 +2680,7 @@ async function askCharacter(args, text) {
// Run generate and restore previous character
try {
eventSource.once(event_types.MESSAGE_RECEIVED, restoreCharacter);
- toastr.info(`Asking ${character.name} something...`);
+ toastr.info(`Asking ${name} something...`);
askResult = await Generate('ask_command');
} catch (error) {
restoreCharacter();
@@ -2560,7 +2693,9 @@ async function askCharacter(args, text) {
}
}
- return askResult;
+ const message = askResult ? chat[chat.length - 1] : null;
+
+ return await slashCommandReturnHelper.doReturn(args.return ?? 'pipe', message, { objectToStringFunc: x => x.mes });
}
async function hideMessageCallback(_, arg) {
@@ -2741,26 +2876,23 @@ async function removeGroupMemberCallback(_, arg) {
return '';
}
-async function addGroupMemberCallback(_, arg) {
+async function addGroupMemberCallback(_, name) {
if (!selected_group) {
toastr.warning('Cannot run /memberadd command outside of a group chat.');
return '';
}
- if (!arg) {
+ if (!name) {
console.warn('WARN: No argument provided for /memberadd command');
return '';
}
- arg = arg.trim();
- const chid = findCharacterIndex(arg);
-
- if (chid === -1) {
- console.warn(`WARN: No character found for argument ${arg}`);
+ const character = findChar({ name: name, preferCurrentChar: false });
+ if (!character) {
+ console.warn(`WARN: No character found for argument ${name}`);
return '';
}
- const character = characters[chid];
const group = groups.find(x => x.id === selected_group);
if (!group || !Array.isArray(group.members)) {
@@ -2829,7 +2961,7 @@ function findPersonaByName(name) {
}
for (const persona of Object.entries(power_user.personas)) {
- if (persona[1].toLowerCase() === name.toLowerCase()) {
+ if (equalsIgnoreCaseAndAccents(persona[1], name)) {
return persona[0];
}
}
@@ -2838,7 +2970,7 @@ function findPersonaByName(name) {
async function sendUserMessageCallback(args, text) {
if (!text) {
- console.warn('WARN: No text provided for /send command');
+ toastr.warning('You must specify text to send');
return;
}
@@ -2854,16 +2986,17 @@ async function sendUserMessageCallback(args, text) {
insertAt = chat.length + insertAt;
}
+ let message;
if ('name' in args) {
const name = args.name || '';
const avatar = findPersonaByName(name) || user_avatar;
- await sendMessageAsUser(text, bias, insertAt, compact, name, avatar);
+ message = await sendMessageAsUser(text, bias, insertAt, compact, name, avatar);
}
else {
- await sendMessageAsUser(text, bias, insertAt, compact);
+ message = await sendMessageAsUser(text, bias, insertAt, compact);
}
- return '';
+ return await slashCommandReturnHelper.doReturn(args.return ?? 'none', message, { objectToStringFunc: x => x.mes });
}
async function deleteMessagesByNameCallback(_, name) {
@@ -2872,7 +3005,9 @@ async function deleteMessagesByNameCallback(_, name) {
return;
}
- name = name.trim();
+ // Search for a matching character to get the real name, or take the name provided
+ const character = findChar({ name: name });
+ name = character?.name || name;
const messagesToDelete = [];
chat.forEach((value) => {
@@ -2901,60 +3036,34 @@ async function deleteMessagesByNameCallback(_, name) {
return '';
}
-function findCharacterIndex(name) {
- const matchTypes = [
- (a, b) => a === b,
- (a, b) => a.startsWith(b),
- (a, b) => a.includes(b),
- ];
-
- const exactAvatarMatch = characters.findIndex(x => x.avatar === name);
-
- if (exactAvatarMatch !== -1) {
- return exactAvatarMatch;
- }
-
- for (const matchType of matchTypes) {
- const index = characters.findIndex(x => matchType(x.name.toLowerCase(), name.toLowerCase()));
- if (index !== -1) {
- return index;
- }
- }
-
- return -1;
-}
-
async function goToCharacterCallback(_, name) {
if (!name) {
console.warn('WARN: No character name provided for /go command');
return;
}
- name = name.trim();
- const characterIndex = findCharacterIndex(name);
-
- if (characterIndex !== -1) {
- await openChat(new String(characterIndex));
- setActiveCharacter(characters[characterIndex]?.avatar);
+ const character = findChar({ name: name });
+ if (character) {
+ const chid = getCharIndex(character);
+ await openChat(new String(chid));
+ setActiveCharacter(character.avatar);
setActiveGroup(null);
- return characters[characterIndex]?.name;
- } else {
- const group = groups.find(it => it.name.toLowerCase() == name.toLowerCase());
- if (group) {
- await openGroupById(group.id);
- setActiveCharacter(null);
- setActiveGroup(group.id);
- return group.name;
- } else {
- console.warn(`No matches found for name "${name}"`);
- return '';
- }
+ return character.name;
}
+ const group = groups.find(it => equalsIgnoreCaseAndAccents(it.name, name));
+ if (group) {
+ await openGroupById(group.id);
+ setActiveCharacter(null);
+ setActiveGroup(group.id);
+ return group.name;
+ }
+ console.warn(`No matches found for name "${name}"`);
+ return '';
}
-async function openChat(id) {
+async function openChat(chid) {
resetSelectedGroup();
- setCharacterId(id);
+ setCharacterId(chid);
await delay(1);
await reloadCurrentChat();
}
@@ -2981,7 +3090,7 @@ async function continueChatCallback(args, prompt) {
resolve();
} catch (error) {
console.error('Error running /continue command:', error);
- reject();
+ reject(error);
}
});
@@ -3100,32 +3209,95 @@ async function setNarratorName(_, text) {
return '';
}
+/**
+ * Checks if an argument is a string array (or undefined), and if not, throws an error
+ * @param {string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined} arg The named argument to check
+ * @param {string} name The name of the argument for the error message
+ * @param {object} [options={}] - The optional arguments
+ * @param {boolean} [options.allowUndefined=false] - Whether the argument can be undefined
+ * @throws {Error} If the argument is not an array
+ * @returns {string[]}
+ */
+export function validateArrayArgString(arg, name, { allowUndefined = true } = {}) {
+ if (arg === undefined) {
+ if (allowUndefined) return undefined;
+ throw new Error(`Argument "${name}" is undefined, but must be a string array`);
+ }
+ if (!Array.isArray(arg)) throw new Error(`Argument "${name}" must be an array`);
+ if (!arg.every(x => typeof x === 'string')) throw new Error(`Argument "${name}" must be an array of strings`);
+ return arg;
+}
+
+/**
+ * Checks if an argument is a string or closure array (or undefined), and if not, throws an error
+ * @param {string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined} arg The named argument to check
+ * @param {string} name The name of the argument for the error message
+ * @param {object} [options={}] - The optional arguments
+ * @param {boolean} [options.allowUndefined=false] - Whether the argument can be undefined
+ * @throws {Error} If the argument is not an array of strings or closures
+ * @returns {(string|SlashCommandClosure)[]}
+ */
+export function validateArrayArg(arg, name, { allowUndefined = true } = {}) {
+ if (arg === undefined) {
+ if (allowUndefined) return [];
+ throw new Error(`Argument "${name}" is undefined, but must be an array of strings or closures`);
+ }
+ if (!Array.isArray(arg)) throw new Error(`Argument "${name}" must be an array`);
+ if (!arg.every(x => typeof x === 'string' || x instanceof SlashCommandClosure)) throw new Error(`Argument "${name}" must be an array of strings or closures`);
+ return arg;
+}
+
+
+/**
+ * Retrieves the name and avatar information for a message
+ *
+ * The name of the character will always have precendence over the one given as argument. If you want to specify a different name for the message,
+ * explicitly implement this in the code using this.
+ *
+ * @param {object?} character - The character object to get the avatar data for
+ * @param {string?} name - The name to get the avatar data for
+ * @returns {{name: string, force_avatar: string, original_avatar: string}} An object containing the name for the message, forced avatar URL, and original avatar
+ */
+export function getNameAndAvatarForMessage(character, name = null) {
+ const isNeutralCharacter = !character && name2 === neutralCharacterName && name === neutralCharacterName;
+ const currentChar = characters[this_chid];
+
+ let force_avatar, original_avatar;
+ if (character?.avatar === currentChar?.avatar || isNeutralCharacter) {
+ // If the targeted character is the currently selected one in a solo chat, we don't need to force any avatars
+ }
+ else if (character && character.avatar !== 'none') {
+ force_avatar = getThumbnailUrl('avatar', character.avatar);
+ original_avatar = character.avatar;
+ }
+ else {
+ force_avatar = default_avatar;
+ original_avatar = default_avatar;
+ }
+
+ return {
+ name: character?.name || name,
+ force_avatar: force_avatar,
+ original_avatar: original_avatar,
+ };
+}
+
export async function sendMessageAs(args, text) {
if (!text) {
+ toastr.warning('You must specify text to send as');
return '';
}
- let name;
+ let name = args.name?.trim();
let mesText;
- if (args.name) {
- name = args.name.trim();
-
- if (!name && !text) {
- toastr.warning('You must specify a name and text to send as');
- return '';
- }
- } else {
+ if (!name) {
const namelessWarningKey = 'sendAsNamelessWarningShown';
if (localStorage.getItem(namelessWarningKey) !== 'true') {
toastr.warning('To avoid confusion, please use /sendas name="Character Name"', 'Name defaulted to {{char}}', { timeOut: 10000 });
localStorage.setItem(namelessWarningKey, 'true');
}
name = name2;
- if (!text) {
- toastr.warning('You must specify text to send as');
- return '';
- }
}
mesText = text.trim();
@@ -3138,26 +3310,18 @@ export async function sendMessageAs(args, text) {
const isSystem = bias && !removeMacros(mesText).length;
const compact = isTrueBoolean(args?.compact);
- const character = characters.find(x => x.avatar === name) ?? characters.find(x => x.name === name);
- let force_avatar, original_avatar;
+ const character = findChar({ name: name });
- const chatCharacter = this_chid !== undefined ? characters[this_chid] : null;
- const isNeutralCharacter = !chatCharacter && name2 === neutralCharacterName && name === neutralCharacterName;
+ const avatarCharacter = args.avatar ? findChar({ name: args.avatar }) : character;
+ if (args.avatar && !avatarCharacter) {
+ toastr.warning(`Character for avatar ${args.avatar} not found`);
+ return '';
+ }
- if (chatCharacter === character || isNeutralCharacter) {
- // If the targeted character is the currently selected one in a solo chat, we don't need to force any avatars
- }
- else if (character && character.avatar !== 'none') {
- force_avatar = getThumbnailUrl('avatar', character.avatar);
- original_avatar = character.avatar;
- }
- else {
- force_avatar = default_avatar;
- original_avatar = default_avatar;
- }
+ const { name: avatarCharName, force_avatar, original_avatar } = getNameAndAvatarForMessage(avatarCharacter, name);
const message = {
- name: name,
+ name: character?.name || name || avatarCharName,
is_user: false,
is_system: isSystem,
send_date: getMessageTimeStamp(),
@@ -3210,11 +3374,12 @@ export async function sendMessageAs(args, text) {
await saveChatConditional();
}
- return '';
+ return await slashCommandReturnHelper.doReturn(args.return ?? 'none', message, { objectToStringFunc: x => x.mes });
}
export async function sendNarratorMessage(args, text) {
if (!text) {
+ toastr.warning('You must specify text to send');
return '';
}
@@ -3263,7 +3428,7 @@ export async function sendNarratorMessage(args, text) {
await saveChatConditional();
}
- return '';
+ return await slashCommandReturnHelper.doReturn(args.return ?? 'none', message, { objectToStringFunc: x => x.mes });
}
export async function promptQuietForLoudResponse(who, text) {
@@ -3309,6 +3474,7 @@ export async function promptQuietForLoudResponse(who, text) {
async function sendCommentMessage(args, text) {
if (!text) {
+ toastr.warning('You must specify text to send');
return '';
}
@@ -3351,7 +3517,7 @@ async function sendCommentMessage(args, text) {
await saveChatConditional();
}
- return '';
+ return await slashCommandReturnHelper.doReturn(args.return ?? 'none', message, { objectToStringFunc: x => x.mes });
}
/**
@@ -3431,11 +3597,12 @@ function setBackgroundCallback(_, bg) {
* Retrieves the available model options based on the currently selected main API and its subtype
* @param {boolean} quiet - Whether to suppress toasts
*
- * @returns {{control: HTMLSelectElement, options: HTMLOptionElement[]}?} An array of objects representing the available model options, or null if not supported
+ * @returns {{control: HTMLSelectElement|HTMLInputElement, options: HTMLOptionElement[]}?} An array of objects representing the available model options, or null if not supported
*/
function getModelOptions(quiet) {
const nullResult = { control: null, options: null };
const modelSelectMap = [
+ { id: 'custom_model_textgenerationwebui', api: 'textgenerationwebui', type: textgen_types.OOBA },
{ id: 'model_togetherai_select', api: 'textgenerationwebui', type: textgen_types.TOGETHERAI },
{ id: 'openrouter_model', api: 'textgenerationwebui', type: textgen_types.OPENROUTER },
{ id: 'model_infermaticai_select', api: 'textgenerationwebui', type: textgen_types.INFERMATICAI },
@@ -3445,6 +3612,7 @@ function getModelOptions(quiet) {
{ id: 'aphrodite_model', api: 'textgenerationwebui', type: textgen_types.APHRODITE },
{ id: 'ollama_model', api: 'textgenerationwebui', type: textgen_types.OLLAMA },
{ id: 'tabby_model', api: 'textgenerationwebui', type: textgen_types.TABBY },
+ { id: 'featherless_model', api: 'textgenerationwebui', type: textgen_types.FEATHERLESS },
{ id: 'model_openai_select', api: 'openai', type: chat_completion_sources.OPENAI },
{ id: 'model_claude_select', api: 'openai', type: chat_completion_sources.CLAUDE },
{ id: 'model_windowai_select', api: 'openai', type: chat_completion_sources.WINDOWAI },
@@ -3452,7 +3620,7 @@ function getModelOptions(quiet) {
{ id: 'model_ai21_select', api: 'openai', type: chat_completion_sources.AI21 },
{ id: 'model_google_select', api: 'openai', type: chat_completion_sources.MAKERSUITE },
{ id: 'model_mistralai_select', api: 'openai', type: chat_completion_sources.MISTRALAI },
- { id: 'model_custom_select', api: 'openai', type: chat_completion_sources.CUSTOM },
+ { id: 'custom_model_id', api: 'openai', type: chat_completion_sources.CUSTOM },
{ id: 'model_cohere_select', api: 'openai', type: chat_completion_sources.COHERE },
{ id: 'model_perplexity_select', api: 'openai', type: chat_completion_sources.PERPLEXITY },
{ id: 'model_groq_select', api: 'openai', type: chat_completion_sources.GROQ },
@@ -3469,7 +3637,7 @@ function getModelOptions(quiet) {
case 'openai':
return oai_settings.chat_completion_source;
default:
- return nullResult;
+ return null;
}
}
@@ -3483,12 +3651,31 @@ function getModelOptions(quiet) {
const modelSelectControl = document.getElementById(modelSelectItem);
- if (!(modelSelectControl instanceof HTMLSelectElement)) {
+ if (!(modelSelectControl instanceof HTMLSelectElement) && !(modelSelectControl instanceof HTMLInputElement)) {
!quiet && toastr.error(`Model select control not found: ${main_api}[${apiSubType}]`);
return nullResult;
}
- const options = Array.from(modelSelectControl.options).filter(x => x.value);
+ /**
+ * Get options from a HTMLSelectElement or HTMLInputElement with a list.
+ * @param {HTMLSelectElement | HTMLInputElement} control Control containing the options
+ * @returns {HTMLOptionElement[]} Array of options
+ */
+ const getOptions = (control) => {
+ if (control instanceof HTMLSelectElement) {
+ return Array.from(control.options);
+ }
+
+ const valueOption = new Option(control.value, control.value);
+
+ if (control instanceof HTMLInputElement && control.list instanceof HTMLDataListElement) {
+ return [valueOption, ...Array.from(control.list.options)];
+ }
+
+ return [valueOption];
+ };
+
+ const options = getOptions(modelSelectControl).filter(x => x.value).filter(onlyUnique);
return { control: modelSelectControl, options };
}
@@ -3507,11 +3694,6 @@ function modelCallback(args, model) {
return '';
}
- if (!options.length) {
- !quiet && toastr.warning('No model options found. Check your API settings.');
- return '';
- }
-
model = String(model || '').trim();
if (!model) {
@@ -3520,6 +3702,18 @@ function modelCallback(args, model) {
console.log('Set model to ' + model);
+ if (modelSelectControl instanceof HTMLInputElement) {
+ modelSelectControl.value = model;
+ $(modelSelectControl).trigger('input');
+ !quiet && toastr.success(`Model set to "${model}"`);
+ return model;
+ }
+
+ if (!options.length) {
+ !quiet && toastr.warning('No model options found. Check your API settings.');
+ return '';
+ }
+
let newSelectedOption = null;
const fuse = new Fuse(options, { keys: ['text', 'value'] });
@@ -3561,11 +3755,17 @@ function setPromptEntryCallback(args, targetState) {
const prompts = promptManager.serviceSettings.prompts;
function parseArgs(arg) {
+ // Arg is already an array
+ if (Array.isArray(arg)) {
+ return arg;
+ }
const list = [];
try {
+ // Arg is a JSON-stringified array
const parsedArg = JSON.parse(arg);
list.push(...Array.isArray(parsedArg) ? parsedArg : [arg]);
} catch {
+ // Arg is a string
list.push(arg);
}
return list;
diff --git a/public/scripts/slash-commands/SlashCommand.js b/public/scripts/slash-commands/SlashCommand.js
index f485e438e..c56803249 100644
--- a/public/scripts/slash-commands/SlashCommand.js
+++ b/public/scripts/slash-commands/SlashCommand.js
@@ -15,13 +15,13 @@ import { SlashCommandScope } from './SlashCommandScope.js';
* _abortController:SlashCommandAbortController,
* _debugController:SlashCommandDebugController,
* _hasUnnamedArgument:boolean,
- * [id:string]:string|SlashCommandClosure,
+ * [id:string]:string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined,
* }} NamedArguments
*/
/**
* Alternative object for local JSDocs, where you don't need existing pipe, scope, etc. arguments
- * @typedef {{[id:string]:string|SlashCommandClosure}} NamedArgumentsCapture
+ * @typedef {{[id:string]:string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined}} NamedArgumentsCapture
*/
/**
diff --git a/public/scripts/slash-commands/SlashCommandClosure.js b/public/scripts/slash-commands/SlashCommandClosure.js
index ecf1d968c..0ba1726b5 100644
--- a/public/scripts/slash-commands/SlashCommandClosure.js
+++ b/public/scripts/slash-commands/SlashCommandClosure.js
@@ -2,6 +2,7 @@ import { substituteParams } from '../../script.js';
import { delay, escapeRegex, uuidv4 } from '../utils.js';
import { SlashCommand } from './SlashCommand.js';
import { SlashCommandAbortController } from './SlashCommandAbortController.js';
+import { SlashCommandNamedArgument } from './SlashCommandArgument.js';
import { SlashCommandBreak } from './SlashCommandBreak.js';
import { SlashCommandBreakController } from './SlashCommandBreakController.js';
import { SlashCommandBreakPoint } from './SlashCommandBreakPoint.js';
@@ -53,7 +54,7 @@ export class SlashCommandClosure {
*
* @param {string} text
* @param {SlashCommandScope} scope
- * @returns
+ * @returns {string|SlashCommandClosure|(string|SlashCommandClosure)[]}
*/
substituteParams(text, scope = null) {
let isList = false;
@@ -379,6 +380,52 @@ export class SlashCommandClosure {
* @param {import('./SlashCommand.js').NamedArguments} args
*/
async substituteNamedArguments(executor, args) {
+ /**
+ * Handles the assignment of named arguments, considering if they accept multiple values
+ * @param {string} name The name of the argument, as defined for the command execution
+ * @param {string|SlashCommandClosure|(string|SlashCommandClosure)[]} value The value to be assigned
+ */
+ const assign = (name, value) => {
+ // If an array is supposed to be assigned, assign it one by one
+ if (Array.isArray(value)) {
+ for (const val of value) {
+ assign(name, val);
+ }
+ return;
+ }
+
+ const definition = executor.command.namedArgumentList.find(x => x.name == name);
+
+ // Prefer definition name if a valid named args defintion is found
+ name = definition?.name ?? name;
+
+ // Unescape named argument
+ if (value && typeof value == 'string') {
+ value = value
+ .replace(/\\\{/g, '{')
+ .replace(/\\\}/g, '}');
+ }
+
+ // If the named argument accepts multiple values, we have to make sure to build an array correctly
+ if (definition?.acceptsMultiple) {
+ if (args[name] !== undefined) {
+ // If there already is something for that named arg, make the value is an array and add to it
+ let currentValue = args[name];
+ if (!Array.isArray(currentValue)) {
+ currentValue = [currentValue];
+ }
+ currentValue.push(value);
+ args[name] = currentValue;
+ } else {
+ // If there is nothing in there, we create an array with that singular value
+ args[name] = [value];
+ }
+ } else {
+ args[name] !== undefined && console.debug(`Named argument assigned multiple times: ${name}`);
+ args[name] = value;
+ }
+ };
+
// substitute named arguments
for (const arg of executor.namedArgumentList) {
if (arg.value instanceof SlashCommandClosure) {
@@ -390,19 +437,12 @@ export class SlashCommandClosure {
closure.debugController = this.debugController;
}
if (closure.executeNow) {
- args[arg.name] = (await closure.execute())?.pipe;
+ assign(arg.name, (await closure.execute())?.pipe);
} else {
- args[arg.name] = closure;
+ assign(arg.name, closure);
}
} else {
- args[arg.name] = this.substituteParams(arg.value);
- }
- // unescape named argument
- if (typeof args[arg.name] == 'string') {
- args[arg.name] = args[arg.name]
- ?.replace(/\\\{/g, '{')
- ?.replace(/\\\}/g, '}')
- ;
+ assign(arg.name, this.substituteParams(arg.value));
}
}
}
@@ -424,6 +464,7 @@ export class SlashCommandClosure {
} else {
value = [];
for (let i = 0; i < executor.unnamedArgumentList.length; i++) {
+ /** @type {string|SlashCommandClosure|(string|SlashCommandClosure)[]} */
let v = executor.unnamedArgumentList[i].value;
if (v instanceof SlashCommandClosure) {
/**@type {SlashCommandClosure}*/
@@ -467,6 +508,14 @@ export class SlashCommandClosure {
return v;
});
}
+
+ value ??= '';
+
+ // Make sure that if unnamed args are split, it should always return an array
+ if (executor.command.splitUnnamedArgument && !Array.isArray(value)) {
+ value = [value];
+ }
+
return value;
}
diff --git a/public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js b/public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js
index 5612f47b5..b96b83a53 100644
--- a/public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js
+++ b/public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js
@@ -2,7 +2,7 @@ import { chat_metadata, characters, substituteParams, chat, extension_prompt_rol
import { extension_settings } from '../extensions.js';
import { getGroupMembers, groups } from '../group-chats.js';
import { power_user } from '../power-user.js';
-import { searchCharByName, getTagsList, tags } from '../tags.js';
+import { searchCharByName, getTagsList, tags, tag_map } from '../tags.js';
import { world_names } from '../world-info.js';
import { SlashCommandClosure } from './SlashCommandClosure.js';
import { SlashCommandEnumValue, enumTypes } from './SlashCommandEnumValue.js';
@@ -36,6 +36,7 @@ export const enumIcons = {
message: '💬',
voice: '🎤',
server: '🖥️',
+ popup: '🗔',
true: '✔️',
false: '❌',
@@ -152,6 +153,35 @@ export const commonEnumProviders = {
].filter((item, idx, list)=>idx == list.findIndex(it=>it.value == item.value));
},
+ /**
+ * Enum values for numbers and variable names
+ *
+ * Includes all variable names and the ability to specify any number
+ *
+ * @param {SlashCommandExecutor} executor - The executor of the slash command
+ * @param {SlashCommandScope} scope - The scope of the slash command
+ * @returns {SlashCommandEnumValue[]} The enum values
+ */
+ numbersAndVariables: (executor, scope) => [
+ ...commonEnumProviders.variables('all')(executor, scope),
+ new SlashCommandEnumValue(
+ 'any variable name',
+ null,
+ enumTypes.variable,
+ enumIcons.variable,
+ (input) => /^\w*$/.test(input),
+ (input) => input,
+ ),
+ new SlashCommandEnumValue(
+ 'any number',
+ null,
+ enumTypes.number,
+ enumIcons.number,
+ (input) => input == '' || !Number.isNaN(Number(input)),
+ (input) => input,
+ ),
+ ],
+
/**
* All possible char entities, like characters and groups. Can be filtered down to just one type.
*
@@ -181,6 +211,18 @@ export const commonEnumProviders = {
*/
personas: () => Object.values(power_user.personas).map(persona => new SlashCommandEnumValue(persona, null, enumTypes.name, enumIcons.persona)),
+ /**
+ * All possible tags, or only those that have been assigned
+ *
+ * @param {('all' | 'assigned')} [mode='all'] - Which types of tags to show
+ * @returns {() => SlashCommandEnumValue[]}
+ */
+ tags: (mode = 'all') => () => {
+ let assignedTags = mode === 'assigned' ? new Set(Object.values(tag_map).flat()) : new Set();
+ return tags.filter(tag => mode === 'all' || (mode === 'assigned' && assignedTags.has(tag.id)))
+ .map(tag => new SlashCommandEnumValue(tag.name, null, enumTypes.command, enumIcons.tag));
+ },
+
/**
* All possible tags for a given char/group entity
*
@@ -193,7 +235,7 @@ export const commonEnumProviders = {
if (charName instanceof SlashCommandClosure) throw new Error('Argument \'name\' does not support closures');
const key = searchCharByName(substituteParams(charName), { suppressLogging: true });
const assigned = key ? getTagsList(key) : [];
- return tags.filter(it => !key || mode === 'all' || mode === 'existing' && assigned.includes(it) || mode === 'not-existing' && !assigned.includes(it))
+ return tags.filter(it => mode === 'all' || mode === 'existing' && assigned.includes(it) || mode === 'not-existing' && !assigned.includes(it))
.map(tag => new SlashCommandEnumValue(tag.name, null, enumTypes.command, enumIcons.tag));
},
diff --git a/public/scripts/slash-commands/SlashCommandReturnHelper.js b/public/scripts/slash-commands/SlashCommandReturnHelper.js
new file mode 100644
index 000000000..947e92cd3
--- /dev/null
+++ b/public/scripts/slash-commands/SlashCommandReturnHelper.js
@@ -0,0 +1,80 @@
+import { sendSystemMessage, system_message_types } from '../../script.js';
+import { callGenericPopup, POPUP_TYPE } from '../popup.js';
+import { escapeHtml } from '../utils.js';
+import { enumIcons } from './SlashCommandCommonEnumsProvider.js';
+import { enumTypes, SlashCommandEnumValue } from './SlashCommandEnumValue.js';
+
+/** @typedef {'pipe'|'object'|'chat-html'|'chat-text'|'popup-html'|'popup-text'|'toast-html'|'toast-text'|'console'|'none'} SlashCommandReturnType */
+
+export const slashCommandReturnHelper = {
+ // Without this, VSCode formatter fucks up JS docs. Don't ask me why.
+ _: false,
+
+ /**
+ * Gets/creates the enum list of types of return relevant for a slash command
+ *
+ * @param {object} [options={}] Options
+ * @param {boolean} [options.allowPipe=true] Allow option to pipe the return value
+ * @param {boolean} [options.allowObject=false] Allow option to return the value as an object
+ * @param {boolean} [options.allowChat=false] Allow option to return the value as a chat message
+ * @param {boolean} [options.allowPopup=false] Allow option to return the value as a popup
+ * @param {boolean}[options.allowTextVersion=true] Used in combination with chat/popup/toast, some of them do not make sense for text versions, e.g.if you are building a HTML string anyway
+ * @returns {SlashCommandEnumValue[]} The enum list
+ */
+ enumList: ({ allowPipe = true, allowObject = false, allowChat = false, allowPopup = false, allowTextVersion = true } = {}) => [
+ allowPipe && new SlashCommandEnumValue('pipe', 'Return to the pipe for the next command', enumTypes.name, '|'),
+ allowObject && new SlashCommandEnumValue('object', 'Return as an object (or array) to the pipe for the next command', enumTypes.variable, enumIcons.dictionary),
+ allowChat && new SlashCommandEnumValue('chat-html', 'Sending a chat message with the return value - Can display HTML', enumTypes.command, enumIcons.message),
+ allowChat && allowTextVersion && new SlashCommandEnumValue('chat-text', 'Sending a chat message with the return value - Will only display as text', enumTypes.qr, enumIcons.message),
+ allowPopup && new SlashCommandEnumValue('popup-html', 'Showing as a popup with the return value - Can display HTML', enumTypes.command, enumIcons.popup),
+ allowPopup && allowTextVersion && new SlashCommandEnumValue('popup-text', 'Showing as a popup with the return value - Will only display as text', enumTypes.qr, enumIcons.popup),
+ new SlashCommandEnumValue('toast-html', 'Show the return value as a toast notification - Can display HTML', enumTypes.command, 'ℹ️'),
+ allowTextVersion && new SlashCommandEnumValue('toast-text', 'Show the return value as a toast notification - Will only display as text', enumTypes.qr, 'ℹ️'),
+ new SlashCommandEnumValue('console', 'Log the return value (object, if it can be one) to the console', enumTypes.enum, '>'),
+ new SlashCommandEnumValue('none', 'No return value'),
+ ].filter(x => !!x),
+
+ /**
+ * Handles the return value based on the specified type
+ *
+ * @param {SlashCommandReturnType} type The type of return
+ * @param {object|number|string} value The value to return
+ * @param {object} [options={}] Options
+ * @param {(o: object) => string} [options.objectToStringFunc=null] Function to convert the object to a string, if object was provided and 'object' was not the chosen return type
+ * @param {(o: object) => string} [options.objectToHtmlFunc=null] Analog to 'objectToStringFunc', which will be used here if not provided - but can do a different string layout if HTML is requested
+ * @returns {Promise<*>} The processed return value
+ */
+ async doReturn(type, value, { objectToStringFunc = o => o?.toString(), objectToHtmlFunc = null } = {}) {
+ const shouldHtml = type.endsWith('html');
+ const actualConverterFunc = shouldHtml && objectToHtmlFunc ? objectToHtmlFunc : objectToStringFunc;
+ const stringValue = typeof value !== 'string' ? actualConverterFunc(value) : value;
+
+ switch (type) {
+ case 'popup-html':
+ case 'popup-text':
+ case 'chat-text':
+ case 'chat-html':
+ case 'toast-text':
+ case 'toast-html': {
+ const htmlOrNotHtml = shouldHtml ? DOMPurify.sanitize((new showdown.Converter()).makeHtml(stringValue)) : escapeHtml(stringValue);
+
+ if (type.startsWith('popup')) await callGenericPopup(htmlOrNotHtml, POPUP_TYPE.TEXT, '', { allowVerticalScrolling: true, wide: true });
+ if (type.startsWith('chat')) sendSystemMessage(system_message_types.GENERIC, htmlOrNotHtml);
+ if (type.startsWith('toast')) toastr.info(htmlOrNotHtml, null, { escapeHtml: !shouldHtml });
+
+ return '';
+ }
+ case 'pipe':
+ return stringValue ?? '';
+ case 'object':
+ return JSON.stringify(value);
+ case 'console':
+ console.info(value);
+ return '';
+ case 'none':
+ return '';
+ default:
+ throw new Error(`Unknown return type: ${type}`);
+ }
+ },
+};
diff --git a/public/scripts/sse-stream.js b/public/scripts/sse-stream.js
index 620caf3c5..dbe481e78 100644
--- a/public/scripts/sse-stream.js
+++ b/public/scripts/sse-stream.js
@@ -108,9 +108,21 @@ function getDelay(s) {
* @returns {AsyncGenerator<{data: object, chunk: string}>} The parsed data and the chunk to be sent.
*/
async function* parseStreamData(json) {
+ // Cohere
+ if (typeof json.delta.message === 'object' && ['tool-plan-delta', 'content-delta'].includes(json.type)) {
+ const text = json?.delta?.message?.content?.text ?? '';
+ for (let i = 0; i < text.length; i++) {
+ const str = json.delta.message.content.text[i];
+ yield {
+ data: { ...json, delta: { message: { content: { text: str } } } },
+ chunk: str,
+ };
+ }
+ return;
+ }
// Claude
- if (typeof json.delta === 'object') {
- if (typeof json.delta.text === 'string' && json.delta.text.length > 0) {
+ if (typeof json.delta === 'object' && typeof json.delta.text === 'string') {
+ if (json.delta.text.length > 0) {
for (let i = 0; i < json.delta.text.length; i++) {
const str = json.delta.text[i];
yield {
diff --git a/public/scripts/tags.js b/public/scripts/tags.js
index c56e0d1cb..9a1950d0a 100644
--- a/public/scripts/tags.js
+++ b/public/scripts/tags.js
@@ -15,7 +15,7 @@ import {
import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js';
import { groupCandidatesFilter, groups, selected_group } from './group-chats.js';
-import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight, equalsIgnoreCaseAndAccents, includesIgnoreCaseAndAccents, removeFromArray, getFreeName, debounce } from './utils.js';
+import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight, equalsIgnoreCaseAndAccents, includesIgnoreCaseAndAccents, removeFromArray, getFreeName, debounce, findChar } from './utils.js';
import { power_user } from './power-user.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
@@ -50,7 +50,6 @@ export {
removeTagFromMap,
};
-/** @typedef {import('../scripts/popup.js').Popup} Popup */
/** @typedef {import('../script.js').Character} Character */
const CHARACTER_FILTER_SELECTOR = '#rm_characters_block .rm_tag_filter';
@@ -507,7 +506,7 @@ export function getTagKeyForEntityElement(element) {
*/
export function searchCharByName(charName, { suppressLogging = false } = {}) {
const entity = charName
- ? (characters.find(x => x.name === charName) || groups.find(x => x.name == charName))
+ ? (findChar({ name: charName }) || groups.find(x => equalsIgnoreCaseAndAccents(x.name, charName)))
: (selected_group ? groups.find(x => x.id == selected_group) : characters[this_chid]);
const key = getTagKeyForEntity(entity);
if (!key) {
@@ -1861,8 +1860,9 @@ function registerTagsSlashCommands() {
return String(result);
},
namedArgumentList: [
- SlashCommandNamedArgument.fromProps({ name: 'name',
- description: 'Character name',
+ SlashCommandNamedArgument.fromProps({
+ name: 'name',
+ description: 'Character name - or unique character identifier (avatar key)',
typeList: [ARGUMENT_TYPE.STRING],
defaultValue: '{{char}}',
enumProvider: commonEnumProviders.characters(),
@@ -1907,7 +1907,7 @@ function registerTagsSlashCommands() {
},
namedArgumentList: [
SlashCommandNamedArgument.fromProps({ name: 'name',
- description: 'Character name',
+ description: 'Character name - or unique character identifier (avatar key)',
typeList: [ARGUMENT_TYPE.STRING],
defaultValue: '{{char}}',
enumProvider: commonEnumProviders.characters(),
@@ -1950,7 +1950,7 @@ function registerTagsSlashCommands() {
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'name',
- description: 'Character name',
+ description: 'Character name - or unique character identifier (avatar key)',
typeList: [ARGUMENT_TYPE.STRING],
defaultValue: '{{char}}',
enumProvider: commonEnumProviders.characters(),
@@ -1993,7 +1993,7 @@ function registerTagsSlashCommands() {
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'name',
- description: 'Character name',
+ description: 'Character name - or unique character identifier (avatar key)',
typeList: [ARGUMENT_TYPE.STRING],
defaultValue: '{{char}}',
enumProvider: commonEnumProviders.characters(),
diff --git a/public/scripts/templates/assistantNote.html b/public/scripts/templates/assistantNote.html
index 148b945e3..5984f8f26 100644
--- a/public/scripts/templates/assistantNote.html
+++ b/public/scripts/templates/assistantNote.html
@@ -1,3 +1,3 @@
and select a Chat API.
+ Click
+
+ and connect to an
+
+
+ API.
and pick a character.
+ Click
+
+ and pick a character.
- .
+ You can add more
+
+ or
+
+ from other websites.
${JSON.stringify(this.function.parameters, null, 2)}
return
argument to specify the return value type.',
+ returns: 'A list of all registered tools.',
+ namedArgumentList: [
+ SlashCommandNamedArgument.fromProps({
+ name: 'return',
+ description: 'The way how you want the return value to be provided',
+ typeList: [ARGUMENT_TYPE.STRING],
+ defaultValue: 'none',
+ enumList: slashCommandReturnHelper.enumList({ allowObject: true }),
+ forceEnum: true,
+ }),
+ ],
+ callback: async (args) => {
+ /** @type {any} */
+ const returnType = String(args?.return ?? 'popup-html').trim().toLowerCase();
+ const objectToStringFunc = (tools) => Array.isArray(tools) ? tools.map(x => x.toString()).join('\n\n') : tools.toString();
+ const tools = ToolManager.tools.map(tool => tool.toFunctionOpenAI());
+ return await slashCommandReturnHelper.doReturn(returnType ?? 'popup-html', tools ?? [], { objectToStringFunc });
+ },
+ }));
+
+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
+ name: 'tools-invoke',
+ aliases: ['tool-invoke'],
+ helpString: 'Invokes a registered tool by name. The parameters
argument MUST be a JSON-serialized object.',
+ namedArgumentList: [
+ SlashCommandNamedArgument.fromProps({
+ name: 'parameters',
+ description: 'The parameters to pass to the tool.',
+ typeList: [ARGUMENT_TYPE.DICTIONARY],
+ isRequired: true,
+ acceptsMultiple: false,
+ }),
+ ],
+ unnamedArgumentList: [
+ SlashCommandArgument.fromProps({
+ description: 'The name of the tool to invoke.',
+ typeList: [ARGUMENT_TYPE.STRING],
+ isRequired: true,
+ acceptsMultiple: false,
+ forceEnum: true,
+ enumProvider: toolsEnumProvider,
+ }),
+ ],
+ callback: async (args, name) => {
+ const { parameters } = args;
+
+ const result = await ToolManager.invokeFunctionTool(String(name), parameters);
+ if (result instanceof Error) {
+ throw result;
+ }
+
+ return result;
+ },
+ }));
+
+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
+ name: 'tools-register',
+ aliases: ['tool-register'],
+ helpString: `parameters
argument MUST be a JSON-serialized object with a valid JSON schema./let key=echoSchema
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string",
+ "description": "The message to echo."
+ }
+ },
+ "required": [
+ "message"
+ ]
+}
+||
+/tools-register name=Echo description="Echoes a message. Call when the user is asking to repeat something" parameters={{var::echoSchema}} {: /echo {{var::arg.message}} :}
`,
+ namedArgumentList: [
+ SlashCommandNamedArgument.fromProps({
+ name: 'name',
+ description: 'The name of the tool.',
+ typeList: [ARGUMENT_TYPE.STRING],
+ isRequired: true,
+ acceptsMultiple: false,
+ }),
+ SlashCommandNamedArgument.fromProps({
+ name: 'description',
+ description: 'A description of what the tool does.',
+ typeList: [ARGUMENT_TYPE.STRING],
+ isRequired: true,
+ acceptsMultiple: false,
+ }),
+ SlashCommandNamedArgument.fromProps({
+ name: 'parameters',
+ description: 'The parameters for the tool.',
+ typeList: [ARGUMENT_TYPE.DICTIONARY],
+ isRequired: true,
+ acceptsMultiple: false,
+ }),
+ SlashCommandNamedArgument.fromProps({
+ name: 'displayName',
+ description: 'The display name of the tool.',
+ typeList: [ARGUMENT_TYPE.STRING],
+ isRequired: false,
+ acceptsMultiple: false,
+ }),
+ SlashCommandNamedArgument.fromProps({
+ name: 'formatMessage',
+ description: 'The closure to be executed to format the tool call message. Must return a string.',
+ typeList: [ARGUMENT_TYPE.CLOSURE],
+ isRequired: true,
+ acceptsMultiple: false,
+ }),
+ ],
+ unnamedArgumentList: [
+ SlashCommandArgument.fromProps({
+ description: 'The closure to be executed when the tool is invoked.',
+ typeList: [ARGUMENT_TYPE.CLOSURE],
+ isRequired: true,
+ acceptsMultiple: false,
+ }),
+ ],
+ callback: async (args, action) => {
+ /**
+ * Converts a slash command closure to a function.
+ * @param {SlashCommandClosure} action Closure to convert to a function
+ * @returns {function} Function that executes the closure
+ */
+ function closureToFunction(action) {
+ return async (args) => {
+ const localClosure = action.getCopy();
+ localClosure.onProgress = () => { };
+ const scope = localClosure.scope;
+ if (typeof args === 'object' && args !== null) {
+ assignNestedVariables(scope, args, 'arg');
+ } else if (typeof args !== 'undefined') {
+ scope.letVariable('arg', args);
+ }
+ const result = await localClosure.execute();
+ return result.pipe;
+ };
+ }
+
+ const { name, displayName, description, parameters, formatMessage } = args;
+
+ if (!(action instanceof SlashCommandClosure)) {
+ throw new Error('The unnamed argument must be a closure.');
+ }
+ if (typeof name !== 'string' || !name) {
+ throw new Error('The "name" argument must be a non-empty string.');
+ }
+ if (typeof description !== 'string' || !description) {
+ throw new Error('The "description" argument must be a non-empty string.');
+ }
+ if (typeof parameters !== 'string' || !isJson(parameters)) {
+ throw new Error('The "parameters" argument must be a JSON-serialized object.');
+ }
+ if (displayName && typeof displayName !== 'string') {
+ throw new Error('The "displayName" argument must be a string.');
+ }
+ if (formatMessage && !(formatMessage instanceof SlashCommandClosure)) {
+ throw new Error('The "formatMessage" argument must be a closure.');
+ }
+
+ const actionFunc = closureToFunction(action);
+ const formatMessageFunc = formatMessage instanceof SlashCommandClosure ? closureToFunction(formatMessage) : null;
+
+ ToolManager.registerFunctionTool({
+ name: String(name ?? ''),
+ displayName: String(displayName ?? ''),
+ description: String(description ?? ''),
+ parameters: JSON.parse(parameters ?? '{}'),
+ action: actionFunc,
+ formatMessage: formatMessageFunc,
+ shouldRegister: async () => true, // TODO: Implement shouldRegister
+ });
+
+ return '';
+ },
+ }));
+
+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
+ name: 'tools-unregister',
+ aliases: ['tool-unregister'],
+ helpString: 'Unregisters a tool from the tool registry.',
+ unnamedArgumentList: [
+ SlashCommandArgument.fromProps({
+ description: 'The name of the tool to unregister.',
+ typeList: [ARGUMENT_TYPE.STRING],
+ isRequired: true,
+ acceptsMultiple: false,
+ forceEnum: true,
+ enumProvider: toolsEnumProvider,
+ }),
+ ],
+ callback: async (name) => {
+ if (typeof name !== 'string' || !name) {
+ throw new Error('The unnamed argument must be a non-empty string.');
+ }
+
+ ToolManager.unregisterFunctionTool(name);
+ return '';
+ },
+ }));
+ }
+}
diff --git a/public/scripts/user.js b/public/scripts/user.js
index 4aab9bb1b..c5d984e1b 100644
--- a/public/scripts/user.js
+++ b/public/scripts/user.js
@@ -848,7 +848,14 @@ async function logout() {
headers: getRequestHeaders(),
});
- window.location.reload();
+ // On an explicit logout stop auto login
+ // to allow user to change username even
+ // when auto auth (such as authelia or basic)
+ // would be valid
+ const urlParams = new URLSearchParams(window.location.search);
+ urlParams.set('noauto', 'true');
+
+ window.location.search = urlParams.toString();
}
/**
diff --git a/public/scripts/utils.js b/public/scripts/utils.js
index 9fc1e1e99..6cf92f5c0 100644
--- a/public/scripts/utils.js
+++ b/public/scripts/utils.js
@@ -1,10 +1,12 @@
import { getContext } from './extensions.js';
-import { getRequestHeaders } from '../script.js';
+import { characters, getRequestHeaders, this_chid } from '../script.js';
import { isMobile } from './RossAscends-mods.js';
import { collapseNewlines } from './power-user.js';
import { debounce_timeout } from './constants.js';
import { Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';
import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
+import { getTagsList } from './tags.js';
+import { groups, selected_group } from './group-chats.js';
/**
* Pagination status string template.
@@ -2110,3 +2112,74 @@ export async function showFontAwesomePicker(customList = null) {
}
return null;
}
+
+/**
+ * Finds a character by name, with optional filtering and precedence for avatars
+ * @param {object} [options={}] - The options for the search
+ * @param {string?} [options.name=null] - The name to search for
+ * @param {boolean} [options.allowAvatar=true] - Whether to allow searching by avatar
+ * @param {boolean} [options.insensitive=true] - Whether the search should be case insensitive
+ * @param {string[]?} [options.filteredByTags=null] - Tags to filter characters by
+ * @param {boolean} [options.preferCurrentChar=true] - Whether to prefer the current character(s)
+ * @param {boolean} [options.quiet=false] - Whether to suppress warnings
+ * @returns {any?} - The found character or null if not found
+ */
+export function findChar({ name = null, allowAvatar = true, insensitive = true, filteredByTags = null, preferCurrentChar = true, quiet = false } = {}) {
+ const matches = (char) => !name || (allowAvatar && char.avatar === name) || (insensitive ? equalsIgnoreCaseAndAccents(char.name, name) : char.name === name);
+
+ // Filter characters by tags if provided
+ let filteredCharacters = characters;
+ if (filteredByTags) {
+ filteredCharacters = characters.filter(char => {
+ const charTags = getTagsList(char.avatar, false);
+ return filteredByTags.every(tagName => charTags.some(x => x.name == tagName));
+ });
+ }
+
+ // Get the current character(s)
+ /** @type {any[]} */
+ const currentChars = selected_group ? groups.find(group => group.id === selected_group)?.members.map(member => filteredCharacters.find(char => char.avatar === member))
+ : filteredCharacters.filter(char => characters[this_chid]?.avatar === char.avatar);
+
+ // If we have a current char and prefer it, return that if it matches
+ if (preferCurrentChar) {
+ const preferredCharSearch = currentChars.filter(matches);
+ if (preferredCharSearch.length > 1) {
+ if (!quiet) toastr.warning('Multiple characters found for given conditions.');
+ else console.warn('Multiple characters found for given conditions. Returning the first match.');
+ }
+ if (preferredCharSearch.length) {
+ return preferredCharSearch[0];
+ }
+ }
+
+ // If allowAvatar is true, search by avatar first
+ if (allowAvatar && name) {
+ const characterByAvatar = filteredCharacters.find(char => char.avatar === name);
+ if (characterByAvatar) {
+ return characterByAvatar;
+ }
+ }
+
+ // Search for matching characters by name
+ const matchingCharacters = name ? filteredCharacters.filter(matches) : filteredCharacters;
+ if (matchingCharacters.length > 1) {
+ if (!quiet) toastr.warning('Multiple characters found for given conditions.');
+ else console.warn('Multiple characters found for given conditions. Returning the first match.');
+ }
+
+ return matchingCharacters[0] || null;
+}
+
+/**
+ * Gets the index of a character based on the character object
+ * @param {object} char - The character object to find the index for
+ * @throws {Error} If the character is not found
+ * @returns {number} The index of the character in the characters array
+ */
+export function getCharIndex(char) {
+ if (!char) throw new Error('Character is undefined');
+ const index = characters.findIndex(c => c.avatar === char.avatar);
+ if (index === -1) throw new Error(`Character not found: ${char.avatar}`);
+ return index;
+}
diff --git a/public/scripts/variables.js b/public/scripts/variables.js
index 3e9028591..c768c4f5e 100644
--- a/public/scripts/variables.js
+++ b/public/scripts/variables.js
@@ -1,5 +1,6 @@
import { chat_metadata, getCurrentChatId, saveSettingsDebounced, sendSystemMessage, system_message_types } from '../script.js';
import { extension_settings, saveMetadataDebounced } from './extensions.js';
+import { callGenericPopup, POPUP_TYPE } from './popup.js';
import { executeSlashCommandsWithOptions } from './slash-commands.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortController.js';
@@ -10,8 +11,9 @@ import { SlashCommandClosureResult } from './slash-commands/SlashCommandClosureR
import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
import { PARSER_FLAG, SlashCommandParser } from './slash-commands/SlashCommandParser.js';
+import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js';
import { SlashCommandScope } from './slash-commands/SlashCommandScope.js';
-import { isFalseBoolean, convertValueType } from './utils.js';
+import { isFalseBoolean, convertValueType, isTrueBoolean } from './utils.js';
/** @typedef {import('./slash-commands/SlashCommandParser.js').NamedArguments} NamedArguments */
/** @typedef {import('./slash-commands/SlashCommand.js').UnnamedArguments} UnnamedArguments */
@@ -303,24 +305,58 @@ export function replaceVariableMacros(input) {
return lines.join('\n');
}
-function listVariablesCallback() {
+async function listVariablesCallback(args) {
+ /** @type {import('./slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */
+ let returnType = args.return;
+
+ // Old legacy return type handling
+ if (args.format) {
+ toastr.warning(`Legacy argument 'format' with value '${args.format}' is deprecated. Please use 'return' instead. Routing to the correct return type...`, 'Deprecation warning');
+ const type = String(args?.format).toLowerCase().trim();
+ switch (type) {
+ case 'none':
+ returnType = 'none';
+ break;
+ case 'chat':
+ returnType = 'chat-html';
+ break;
+ case 'popup':
+ default:
+ returnType = 'popup-html';
+ break;
+ }
+ }
+
+ // Now the actual new return type handling
+ const scope = String(args?.scope || '').toLowerCase().trim() || 'all';
if (!chat_metadata.variables) {
chat_metadata.variables = {};
}
- const localVariables = Object.entries(chat_metadata.variables).map(([name, value]) => `${name}: ${value}`);
- const globalVariables = Object.entries(extension_settings.variables.global).map(([name, value]) => `${name}: ${value}`);
+ const includeLocalVariables = scope === 'all' || scope === 'local';
+ const includeGlobalVariables = scope === 'all' || scope === 'global';
- const localVariablesString = localVariables.length > 0 ? localVariables.join('\n\n') : 'No local variables';
- const globalVariablesString = globalVariables.length > 0 ? globalVariables.join('\n\n') : 'No global variables';
- const chatName = getCurrentChatId();
+ const localVariables = includeLocalVariables ? Object.entries(chat_metadata.variables).map(([name, value]) => `${name}: ${value}`) : [];
+ const globalVariables = includeGlobalVariables ? Object.entries(extension_settings.variables.global).map(([name, value]) => `${name}: ${value}`) : [];
- const converter = new showdown.Converter();
- const message = `### Local variables (${chatName}):\n${localVariablesString}\n\n### Global variables:\n${globalVariablesString}`;
- const htmlMessage = DOMPurify.sanitize(converter.makeHtml(message));
+ const buildTextValue = (_) => {
+ const localVariablesString = localVariables.length > 0 ? localVariables.join('\n\n') : 'No local variables';
+ const globalVariablesString = globalVariables.length > 0 ? globalVariables.join('\n\n') : 'No global variables';
+ const chatName = getCurrentChatId();
- sendSystemMessage(system_message_types.GENERIC, htmlMessage);
- return '';
+ const message = [
+ includeLocalVariables ? `### Local variables (${chatName}):\n${localVariablesString}` : '',
+ includeGlobalVariables ? `### Global variables:\n${globalVariablesString}` : '',
+ ].filter(x => x).join('\n\n');
+ return message;
+ };
+
+ const jsonVariables = [
+ ...Object.entries(chat_metadata.variables).map(x => ({ key: x[0], value: x[1], scope: 'local' })),
+ ...Object.entries(extension_settings.variables.global).map(x => ({ key: x[0], value: x[1], scope: 'global' })),
+ ];
+
+ return await slashCommandReturnHelper.doReturn(returnType ?? 'popup-html', jsonVariables, { objectToStringFunc: buildTextValue });
}
/**
@@ -463,7 +499,7 @@ function existsGlobalVariable(name) {
/**
* Parses boolean operands from command arguments.
* @param {object} args Command arguments
- * @returns {{a: string | number, b: string | number, rule: string}} Boolean operands
+ * @returns {{a: string | number, b: string | number?, rule: string}} Boolean operands
*/
export function parseBooleanOperands(args) {
// Resolution order: numeric literal, local variable, global variable, string literal
@@ -472,6 +508,9 @@ export function parseBooleanOperands(args) {
*/
function getOperand(operand) {
if (operand === undefined) {
+ return undefined;
+ }
+ if (operand === '') {
return '';
}
@@ -500,8 +539,8 @@ export function parseBooleanOperands(args) {
return stringLiteral || '';
}
- const left = getOperand(args.a || args.left || args.first || args.x);
- const right = getOperand(args.b || args.right || args.second || args.y);
+ const left = getOperand(args.a ?? args.left ?? args.first ?? args.x);
+ const right = getOperand(args.b ?? args.right ?? args.second ?? args.y);
const rule = args.rule;
return { a: left, b: right, rule };
@@ -509,84 +548,79 @@ export function parseBooleanOperands(args) {
/**
* Evaluates a boolean comparison rule.
- * @param {string} rule Boolean comparison rule
+ *
+ * @param {string?} rule Boolean comparison rule
* @param {string|number} a The left operand
- * @param {string|number} b The right operand
+ * @param {string|number?} b The right operand
* @returns {boolean} True if the rule yields true, false otherwise
*/
export function evalBoolean(rule, a, b) {
- if (!rule) {
- toastr.warning('The rule must be specified for the boolean comparison.', 'Invalid command');
- throw new Error('Invalid command.');
+ if (a === undefined) {
+ throw new Error('Left operand is not provided');
}
- let result = false;
+ // If right-hand side was not provided, whe just check if the left side is truthy
+ if (b === undefined) {
+ switch (rule) {
+ case undefined:
+ case 'not': {
+ const resultOnTruthy = rule !== 'not';
+ if (isTrueBoolean(String(a))) return resultOnTruthy;
+ if (isFalseBoolean(String(a))) return !resultOnTruthy;
+ return a ? resultOnTruthy : !resultOnTruthy;
+ }
+ default:
+ 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}`);
+ }
+ }
+
+ // If no rule was provided, we are implicitly using 'eq', as defined for the slash commands
+ rule ??= 'eq';
+
if (typeof a === 'number' && typeof b === 'number') {
// only do numeric comparison if both operands are numbers
const aNumber = Number(a);
const bNumber = Number(b);
switch (rule) {
- case 'not':
- result = !aNumber;
- break;
case 'gt':
- result = aNumber > bNumber;
- break;
+ return aNumber > bNumber;
case 'gte':
- result = aNumber >= bNumber;
- break;
+ return aNumber >= bNumber;
case 'lt':
- result = aNumber < bNumber;
- break;
+ return aNumber < bNumber;
case 'lte':
- result = aNumber <= bNumber;
- break;
+ return aNumber <= bNumber;
case 'eq':
- result = aNumber === bNumber;
- break;
+ return aNumber === bNumber;
case 'neq':
- result = aNumber !== bNumber;
- break;
- default:
- toastr.error('Unknown boolean comparison rule for type number.', 'Invalid command');
- throw new Error('Invalid command.');
- }
- } else {
- // otherwise do case-insensitive string comparsion, stringify non-strings
- let aString;
- let bString;
- if (typeof a == 'string') {
- aString = a.toLowerCase();
- } else {
- aString = JSON.stringify(a).toLowerCase();
- }
- if (typeof b == 'string') {
- bString = b.toLowerCase();
- } else {
- bString = JSON.stringify(b).toLowerCase();
- }
-
- switch (rule) {
+ return aNumber !== bNumber;
case 'in':
- result = aString.includes(bString);
- break;
case 'nin':
- result = !aString.includes(bString);
- break;
- case 'eq':
- result = aString === bString;
- break;
- case 'neq':
- result = aString !== bString;
+ // Fall through to string comparison. Otherwise you could not check if 12345 contains 45 for example.
+ console.debug(`Boolean comparison rule '${rule}' is not supported for type number. Falling back to string comparison.`);
break;
default:
- toastr.error('Unknown boolean comparison rule for type string.', 'Invalid /if command');
- throw new Error('Invalid command.');
+ throw new Error(`Unknown boolean comparison rule for type number. Accepted: gt, gte, lt, lte, eq, neq. Provided: ${rule}`);
}
}
- return result;
+ // otherwise do case-insensitive string comparsion, stringify non-strings
+ let aString = (typeof a === 'string') ? a.toLowerCase() : JSON.stringify(a).toLowerCase();
+ let bString = (typeof b === 'string') ? b.toLowerCase() : JSON.stringify(b).toLowerCase();
+
+ switch (rule) {
+ case 'in':
+ return aString.includes(bString);
+ case 'nin':
+ return !aString.includes(bString);
+ case 'eq':
+ return aString === bString;
+ case 'neq':
+ return aString !== bString;
+ default:
+ throw new Error(`Unknown boolean comparison rule for type number. Accepted: in, nin, eq, neq. Provided: ${rule}`);
+ }
}
/**
@@ -646,8 +680,8 @@ function deleteGlobalVariable(name) {
}
/**
- * Parses a series of numeric values from a string.
- * @param {string} value A space-separated list of numeric values or variable names
+ * Parses a series of numeric values from a string or a string array.
+ * @param {string|string[]} value A space-separated list of numeric values or variable names
* @param {SlashCommandScope} scope Scope
* @returns {number[]} An array of numeric values
*/
@@ -656,11 +690,17 @@ function parseNumericSeries(value, scope = null) {
return [value];
}
- const array = value
- .split(' ')
- .map(i => i.trim())
+ /** @type {(string|number)[]} */
+ let values = Array.isArray(value) ? value : value.split(' ');
+
+ // If a JSON array was provided as the only value, convert it to an array
+ if (values.length === 1 && typeof values[0] === 'string' && values[0].startsWith('[')) {
+ values = convertValueType(values[0], 'array');
+ }
+
+ const array = values.map(i => typeof i === 'string' ? i.trim() : i)
.filter(i => i !== '')
- .map(i => isNaN(Number(i)) ? Number(resolveVariable(i, scope)) : Number(i))
+ .map(i => isNaN(Number(i)) ? Number(resolveVariable(String(i), scope)) : Number(i))
.filter(i => !isNaN(i));
return array;
@@ -680,7 +720,7 @@ function performOperation(value, operation, singleOperand = false, scope = null)
const result = singleOperand ? operation(array[0]) : operation(array);
- if (isNaN(result) || !isFinite(result)) {
+ if (isNaN(result)) {
return 0;
}
@@ -708,7 +748,7 @@ function maxValuesCallback(args, value) {
}
function subValuesCallback(args, value) {
- return performOperation(value, (array) => array[0] - array[1], false, args._scope);
+ return performOperation(value, (array) => array.reduce((a, b) => a - b, array.shift() ?? 0), false, args._scope);
}
function divValuesCallback(args, value) {
@@ -887,7 +927,44 @@ export function registerVariableCommands() {
name: 'listvar',
callback: listVariablesCallback,
aliases: ['listchatvar'],
- helpString: 'List registered chat variables.',
+ helpString: 'List registered chat variables. Displays variables in a popup by default. Use the return
argument to change the return type.',
+ returns: 'JSON list of local variables',
+ namedArgumentList: [
+ SlashCommandNamedArgument.fromProps({
+ name: 'scope',
+ description: 'filter variables by scope',
+ typeList: [ARGUMENT_TYPE.STRING],
+ defaultValue: 'all',
+ isRequired: false,
+ forceEnum: true,
+ enumList: [
+ new SlashCommandEnumValue('all', 'All variables', enumTypes.enum, enumIcons.variable),
+ new SlashCommandEnumValue('local', 'Local variables', enumTypes.enum, enumIcons.localVariable),
+ new SlashCommandEnumValue('global', 'Global variables', enumTypes.enum, enumIcons.globalVariable),
+ ],
+ }),
+ SlashCommandNamedArgument.fromProps({
+ name: 'return',
+ description: 'The way how you want the return value to be provided',
+ typeList: [ARGUMENT_TYPE.STRING],
+ defaultValue: 'popup-html',
+ enumList: slashCommandReturnHelper.enumList({ allowPipe: false, allowObject: true, allowChat: true, allowPopup: true, allowTextVersion: false }),
+ forceEnum: true,
+ }),
+ // TODO remove some day
+ SlashCommandNamedArgument.fromProps({
+ name: 'format',
+ description: '!!! DEPRECATED - use "return" instead !!! output format',
+ typeList: [ARGUMENT_TYPE.STRING],
+ isRequired: true,
+ forceEnum: true,
+ enumList: [
+ new SlashCommandEnumValue('popup', 'Show variables in a popup.', enumTypes.enum, enumIcons.default),
+ new SlashCommandEnumValue('chat', 'Post a system message to the chat.', enumTypes.enum, enumIcons.message),
+ new SlashCommandEnumValue('none', 'Just return the variables as a JSON list.', enumTypes.enum, enumIcons.array),
+ ],
+ }),
+ ],
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'setvar',
@@ -1264,32 +1341,36 @@ export function registerVariableCommands() {
typeList: [ARGUMENT_TYPE.VARIABLE_NAME, ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER],
isRequired: true,
enumProvider: commonEnumProviders.variables('all'),
- forceEnum: false,
}),
SlashCommandNamedArgument.fromProps({
name: 'right',
description: 'right operand',
typeList: [ARGUMENT_TYPE.VARIABLE_NAME, ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER],
- isRequired: true,
enumProvider: commonEnumProviders.variables('all'),
- forceEnum: false,
}),
- new SlashCommandNamedArgument(
- 'rule', 'comparison rule', [ARGUMENT_TYPE.STRING], true, false, null, [
- new SlashCommandEnumValue('gt', 'a > b'),
- new SlashCommandEnumValue('gte', 'a >= b'),
- new SlashCommandEnumValue('lt', 'a < b'),
- new SlashCommandEnumValue('lte', 'a <= b'),
- new SlashCommandEnumValue('eq', 'a == b'),
- new SlashCommandEnumValue('neq', 'a !== b'),
- new SlashCommandEnumValue('not', '!a'),
- new SlashCommandEnumValue('in', 'a includes b'),
- new SlashCommandEnumValue('nin', 'a not includes b'),
+ SlashCommandNamedArgument.fromProps({
+ name: 'rule',
+ description: 'comparison rule',
+ typeList: [ARGUMENT_TYPE.STRING],
+ defaultValue: 'eq',
+ enumList: [
+ new SlashCommandEnumValue('eq', 'a == b (strings & numbers)'),
+ new SlashCommandEnumValue('neq', 'a !== b (strings & numbers)'),
+ new SlashCommandEnumValue('in', 'a includes b (strings & numbers as strings)'),
+ new SlashCommandEnumValue('nin', 'a not includes b (strings & numbers as strings)'),
+ new SlashCommandEnumValue('gt', 'a > b (numbers)'),
+ new SlashCommandEnumValue('gte', 'a >= b (numbers)'),
+ new SlashCommandEnumValue('lt', 'a < b (numbers)'),
+ new SlashCommandEnumValue('lte', 'a <= b (numbers)'),
+ new SlashCommandEnumValue('not', '!a (truthy)'),
],
- ),
- new SlashCommandNamedArgument(
- 'else', 'command to execute if not true', [ARGUMENT_TYPE.CLOSURE, ARGUMENT_TYPE.SUBCOMMAND], false,
- ),
+ forceEnum: true,
+ }),
+ SlashCommandNamedArgument.fromProps({
+ name: 'else',
+ description: 'command to execute if not true',
+ typeList: [ARGUMENT_TYPE.CLOSURE, ARGUMENT_TYPE.SUBCOMMAND],
+ }),
],
unnamedArgumentList: [
new SlashCommandArgument(
@@ -1306,18 +1387,26 @@ export function registerVariableCommands() {
eq
.
+ left
value to be truthy.
+ A non-empty string or non-zero number is considered truthy, as is the value true
or on
.not
, and no provided rule - which default to returning whether it is not or is truthy.
+ eq
=> a == b (strings & numbers)neq
=> a !== b (strings & numbers)in
=> a includes b (strings & numbers as strings)nin
=> a not includes b (strings & numbers as strings)gt
=> a > b (numbers)gte
=> a >= b (numbers)lt
=> a < b (numbers)lte
=> a <= b (numbers)not
=> !a (truthy)/if left=score right=10 rule=gte "/speak You win"
triggers a /speak command if the value of "score" is greater or equals 10.
+ /if left={{lastMessage}} rule=in right=surprise {: /echo SURPISE! :}
+ executes a subcommand defined as a closure if the given value contains a specified word.
+ /if left=myContent {: /echo My content had some content. :}
+ executes the defined subcommand, if the provided value of left is truthy (contains some kind of contant that is not empty or false)
+ /if left=tree right={{getvar::object}} {: /echo The object is a tree! :}
+ executes the defined subcommand, if the left and right values are equals.
+ eq
=> a == b (strings & numbers)neq
=> a !== b (strings & numbers)in
=> a includes b (strings & numbers as strings)nin
=> a not includes b (strings & numbers as strings)gt
=> a > b (numbers)gte
=> a >= b (numbers)lt
=> a < b (numbers)lte
=> a <= b (numbers)not
=> !a (truthy)/setvar key=i 0 | /while left=i right=10 rule=lte "/addvar key=i 1"
adds 1 to the value of "i" until it reaches 10.
-
+ /while left={{getvar::currentword}} {: /setvar key=currentword {: /do-something-and-return :}() | /echo The current work is "{{getvar::currentword}}" :}
+ executes the defined subcommand as long as the "currentword" variable is truthy (has any content that is not false/empty)
+
+ guard=off
to disable.
@@ -1511,36 +1621,15 @@ export function registerVariableCommands() {
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'add',
- callback: (args, /**@type {string[]}*/value) => addValuesCallback(args, value.join(' ')),
+ callback: (args, value) => addValuesCallback(args, value),
returns: 'sum of the provided values',
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'values to sum',
- typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.VARIABLE_NAME],
+ typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.VARIABLE_NAME, ARGUMENT_TYPE.LIST],
isRequired: true,
acceptsMultiple: true,
- enumProvider: (executor, scope)=>{
- const vars = commonEnumProviders.variables('all')(executor, scope);
- vars.push(
- new SlashCommandEnumValue(
- 'any variable name',
- null,
- enumTypes.variable,
- enumIcons.variable,
- (input)=>/^\w*$/.test(input),
- (input)=>input,
- ),
- new SlashCommandEnumValue(
- 'any number',
- null,
- enumTypes.number,
- enumIcons.number,
- (input)=>input == '' || !Number.isNaN(Number(input)),
- (input)=>input,
- ),
- );
- return vars;
- },
+ enumProvider: commonEnumProviders.numbersAndVariables,
forceEnum: false,
}),
],
@@ -1548,7 +1637,9 @@ export function registerVariableCommands() {
helpString: `
/add 10 i 30 j
/add ["count", 15, 2, "i"]
+ /mul 10 i 30 j
/mul ["count", 15, 2, "i"]
+ /max 10 i 30 j
/max ["count", 15, 2, "i"]
+ /min 10 i 30 j
/min ["count", 15, 2, "i"]
+ /sub i 5
/sub ["count", 4, "i"]
+