diff --git a/public/global.d.ts b/public/global.d.ts index ea96ddffb..af42edf00 100644 --- a/public/global.d.ts +++ b/public/global.d.ts @@ -1358,3 +1358,44 @@ declare namespace moment { declare global { const moment: typeof moment; } + +/** + * Callback data for the `LLM_FUNCTION_TOOL_REGISTER` event type that is triggered when a function tool can be registered. + */ +interface FunctionToolRegister { + /** + * The type of generation that is being used + */ + type?: string; + /** + * Generation data, including messages and sampling parameters + */ + data: Record; + /** + * Callback to register an LLM function tool. + */ + registerFunctionTool: typeof registerFunctionTool; +} + +/** + * Callback data for the `LLM_FUNCTION_TOOL_REGISTER` event type that is triggered when a function tool is registered. + * @param name Name of the function tool to register + * @param description Description of the function tool + * @param params JSON schema for the parameters of the function tool + * @param required Whether the function tool should be forced to be used + */ +declare function registerFunctionTool(name: string, description: string, params: object, required: boolean): Promise; + +/** + * Callback data for the `LLM_FUNCTION_TOOL_CALL` event type that is triggered when a function tool is called. + */ +interface FunctionToolCall { + /** + * Name of the function tool to call + */ + name: string; + /** + * JSON object with the parameters to pass to the function tool + */ + arguments: string; +} diff --git a/public/index.html b/public/index.html index 914842375..96bd005ce 100644 --- a/public/index.html +++ b/public/index.html @@ -1739,6 +1739,16 @@ +
+ +
- Will be used if the API doesn't support JSON schemas. + Will be used if the API doesn't support JSON schemas or function calling.
diff --git a/public/scripts/extensions/regex/dropdown.html b/public/scripts/extensions/regex/dropdown.html index 9e57b914e..87973bfee 100644 --- a/public/scripts/extensions/regex/dropdown.html +++ b/public/scripts/extensions/regex/dropdown.html @@ -1,24 +1,52 @@
- Regex + + Regex +
-
diff --git a/public/scripts/extensions/regex/editor.html b/public/scripts/extensions/regex/editor.html index b51d96379..75f2376a4 100644 --- a/public/scripts/extensions/regex/editor.html +++ b/public/scripts/extensions/regex/editor.html @@ -56,7 +56,7 @@
diff --git a/public/scripts/extensions/regex/embeddedScripts.html b/public/scripts/extensions/regex/embeddedScripts.html new file mode 100644 index 000000000..af72244e5 --- /dev/null +++ b/public/scripts/extensions/regex/embeddedScripts.html @@ -0,0 +1,5 @@ +
+

This character has embedded regex script(s).

+

Would you like to allow using them?

+
If you want to do it later, select "Regex" from the extensions menu.
+
diff --git a/public/scripts/extensions/regex/engine.js b/public/scripts/extensions/regex/engine.js index a85dedc44..24aa18d49 100644 --- a/public/scripts/extensions/regex/engine.js +++ b/public/scripts/extensions/regex/engine.js @@ -1,4 +1,4 @@ -import { substituteParams } from '../../../script.js'; +import { characters, substituteParams, this_chid } from '../../../script.js'; import { extension_settings } from '../../extensions.js'; import { regexFromString } from '../../utils.js'; export { @@ -22,6 +22,22 @@ const regex_placement = { WORLD_INFO: 5, }; +function getScopedRegex() { + const isAllowed = extension_settings?.character_allowed_regex?.includes(characters?.[this_chid]?.avatar); + + if (!isAllowed) { + return []; + } + + const scripts = characters[this_chid]?.data?.extensions?.regex_scripts; + + if (!Array.isArray(scripts)) { + return []; + } + + return scripts; +} + /** * Parent function to fetch a regexed version of a raw string * @param {string} rawString The raw string to be regexed @@ -42,7 +58,8 @@ function getRegexedString(rawString, placement, { characterOverride, isMarkdown, return finalString; } - extension_settings.regex.forEach((script) => { + const allRegex = [...(extension_settings.regex ?? []), ...(getScopedRegex() ?? [])]; + allRegex.forEach((script) => { if ( // Script applies to Markdown and input is Markdown (script.markdownOnly && isMarkdown) || @@ -95,7 +112,7 @@ function runRegexScript(regexScript, rawString, { characterOverride } = {}) { } // Run replacement. Currently does not support the Overlay strategy - newString = rawString.replace(findRegex, function(match) { + newString = rawString.replace(findRegex, function (match) { const args = [...arguments]; const replaceString = regexScript.replaceString.replace(/{{match}}/gi, '$0'); const replaceWithGroups = replaceString.replaceAll(/\$(\d+)/g, (_, num) => { diff --git a/public/scripts/extensions/regex/importTarget.html b/public/scripts/extensions/regex/importTarget.html new file mode 100644 index 000000000..b35f62410 --- /dev/null +++ b/public/scripts/extensions/regex/importTarget.html @@ -0,0 +1,19 @@ +
+

+ Import To: +

+
+ + +
+
diff --git a/public/scripts/extensions/regex/index.js b/public/scripts/extensions/regex/index.js index 1c25dba89..374004e8b 100644 --- a/public/scripts/extensions/regex/index.js +++ b/public/scripts/extensions/regex/index.js @@ -1,5 +1,6 @@ -import { callPopup, getCurrentChatId, reloadCurrentChat, saveSettingsDebounced } from '../../../script.js'; -import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js'; +import { callPopup, characters, eventSource, event_types, getCurrentChatId, reloadCurrentChat, saveSettingsDebounced, this_chid } from '../../../script.js'; +import { extension_settings, renderExtensionTemplateAsync, writeExtensionField } from '../../extensions.js'; +import { selected_group } from '../../group-chats.js'; import { SlashCommand } from '../../slash-commands/SlashCommand.js'; import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js'; import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; @@ -7,8 +8,21 @@ import { download, getFileText, getSortableDelay, uuidv4 } from '../../utils.js' import { resolveVariable } from '../../variables.js'; import { regex_placement, runRegexScript } from './engine.js'; -async function saveRegexScript(regexScript, existingScriptIndex) { +/** + * Saves a regex script to the extension settings or character data. + * @param {import('../../char-data.js').RegexScriptData} regexScript + * @param {number} existingScriptIndex Index of the existing script + * @param {boolean} isScoped Is the script scoped to a character? + * @returns {Promise} + */ +async function saveRegexScript(regexScript, existingScriptIndex, isScoped) { // If not editing + const array = (isScoped ? characters[this_chid]?.data?.extensions?.regex_scripts : extension_settings.regex) ?? []; + + // Assign a UUID if it doesn't exist + if (!regexScript.id) { + regexScript.id = uuidv4(); + } // Is the script name undefined or empty? if (!regexScript.scriptName) { @@ -16,22 +30,6 @@ async function saveRegexScript(regexScript, existingScriptIndex) { return; } - if (existingScriptIndex === -1) { - // Does the script name already exist? - if (extension_settings.regex.find((e) => e.scriptName === regexScript.scriptName)) { - toastr.error(`Could not save regex script: A script with name ${regexScript.scriptName} already exists.`); - return; - } - } else { - // Does the script name already exist somewhere else? - // (If this fails, make it a .filter().map() to index array) - const foundIndex = extension_settings.regex.findIndex((e) => e.scriptName === regexScript.scriptName); - if (foundIndex !== existingScriptIndex && foundIndex !== -1) { - toastr.error(`Could not save regex script: A script with name ${regexScript.scriptName} already exists.`); - return; - } - } - // Is a find regex present? if (regexScript.findRegex.length === 0) { toastr.warning('This regex script will not work, but was saved anyway: A find regex isn\'t present.'); @@ -43,9 +41,18 @@ async function saveRegexScript(regexScript, existingScriptIndex) { } if (existingScriptIndex !== -1) { - extension_settings.regex[existingScriptIndex] = regexScript; + array[existingScriptIndex] = regexScript; } else { - extension_settings.regex.push(regexScript); + array.push(regexScript); + } + + if (isScoped) { + await writeExtensionField(this_chid, 'regex_scripts', array); + + // Add the character to the allowed list + if (!extension_settings.character_allowed_regex.includes(characters[this_chid].avatar)) { + extension_settings.character_allowed_regex.push(characters[this_chid].avatar); + } } saveSettingsDebounced(); @@ -58,12 +65,16 @@ async function saveRegexScript(regexScript, existingScriptIndex) { } } -async function deleteRegexScript({ existingId }) { - let scriptName = $(`#${existingId}`).find('.regex_script_name').text(); +async function deleteRegexScript({ id, isScoped }) { + const array = (isScoped ? characters[this_chid]?.data?.extensions?.regex_scripts : extension_settings.regex) ?? []; - const existingScriptIndex = extension_settings.regex.findIndex((script) => script.scriptName === scriptName); + const existingScriptIndex = array.findIndex((script) => script.id === id); if (!existingScriptIndex || existingScriptIndex !== -1) { - extension_settings.regex.splice(existingScriptIndex, 1); + array.splice(existingScriptIndex, 1); + + if (isScoped) { + await writeExtensionField(this_chid, 'regex_scripts', array); + } saveSettingsDebounced(); await loadRegexScripts(); @@ -72,19 +83,32 @@ async function deleteRegexScript({ existingId }) { async function loadRegexScripts() { $('#saved_regex_scripts').empty(); + $('#saved_scoped_scripts').empty(); const scriptTemplate = $(await renderExtensionTemplateAsync('regex', 'scriptTemplate')); - extension_settings.regex.forEach((script) => { + /** + * Renders a script to the UI. + * @param {string} container Container to render the script to + * @param {import('../../char-data.js').RegexScriptData} script Script data + * @param {boolean} isScoped Script is scoped to a character + * @param {number} index Index of the script in the array + */ + function renderScript(container, script, isScoped, index) { // Have to clone here const scriptHtml = scriptTemplate.clone(); - scriptHtml.attr('id', uuidv4()); + const save = () => saveRegexScript(script, index, isScoped); + + if (!script.id) { + script.id = uuidv4(); + } + + scriptHtml.attr('id', script.id); scriptHtml.find('.regex_script_name').text(script.scriptName); scriptHtml.find('.disable_regex').prop('checked', script.disabled ?? false) - .on('input', function () { + .on('input', async function () { script.disabled = !!$(this).prop('checked'); - reloadCurrentChat(); - saveSettingsDebounced(); + await save(); }); scriptHtml.find('.regex-toggle-on').on('click', function () { scriptHtml.find('.disable_regex').prop('checked', true).trigger('input'); @@ -93,7 +117,37 @@ async function loadRegexScripts() { scriptHtml.find('.disable_regex').prop('checked', false).trigger('input'); }); scriptHtml.find('.edit_existing_regex').on('click', async function () { - await onRegexEditorOpenClick(scriptHtml.attr('id')); + await onRegexEditorOpenClick(scriptHtml.attr('id'), isScoped); + }); + scriptHtml.find('.move_to_global').on('click', async function () { + const confirm = await callPopup('Are you sure you want to move this regex script to global?', 'confirm'); + + if (!confirm) { + return; + } + + await deleteRegexScript({ id: script.id, isScoped: true }); + await saveRegexScript(script, -1, false); + }); + scriptHtml.find('.move_to_scoped').on('click', async function () { + if (this_chid === undefined) { + toastr.error('No character selected.'); + return; + } + + if (selected_group) { + toastr.error('Cannot edit scoped scripts in group chats.'); + return; + } + + const confirm = await callPopup('Are you sure you want to move this regex script to scoped?', 'confirm'); + + if (!confirm) { + return; + } + + await deleteRegexScript({ id: script.id, isScoped: false }); + await saveRegexScript(script, -1, true); }); scriptHtml.find('.export_regex').on('click', async function () { const fileName = `${script.scriptName.replace(/[\s.<>:"/\\|?*\x00-\x1F\x7F]/g, '_').toLowerCase()}.json`; @@ -107,23 +161,36 @@ async function loadRegexScripts() { return; } - await deleteRegexScript({ existingId: scriptHtml.attr('id') }); + await deleteRegexScript({ id: script.id, isScoped }); + await reloadCurrentChat(); }); - $('#saved_regex_scripts').append(scriptHtml); - }); + $(container).append(scriptHtml); + } + + extension_settings?.regex?.forEach((script, index, array) => renderScript('#saved_regex_scripts', script, false, index, array)); + characters[this_chid]?.data?.extensions?.regex_scripts?.forEach((script, index, array) => renderScript('#saved_scoped_scripts', script, true, index, array)); + + const isAllowed = extension_settings?.character_allowed_regex?.includes(characters?.[this_chid]?.avatar); + $('#regex_scoped_toggle').prop('checked', isAllowed); } -async function onRegexEditorOpenClick(existingId) { +/** + * Opens the regex editor. + * @param {string|boolean} existingId Existing ID + * @param {boolean} isScoped Is the script scoped to a character? + * @returns {Promise} + */ +async function onRegexEditorOpenClick(existingId, isScoped) { const editorHtml = $(await renderExtensionTemplateAsync('regex', 'editor')); + const array = (isScoped ? characters[this_chid]?.data?.extensions?.regex_scripts : extension_settings.regex) ?? []; // If an ID exists, fill in all the values let existingScriptIndex = -1; if (existingId) { - const existingScriptName = $(`#${existingId}`).find('.regex_script_name').text(); - existingScriptIndex = extension_settings.regex.findIndex((script) => script.scriptName === existingScriptName); + existingScriptIndex = array.findIndex((script) => script.id === existingId); if (existingScriptIndex !== -1) { - const existingScript = extension_settings.regex[existingScriptIndex]; + const existingScript = array[existingScriptIndex]; if (existingScript.scriptName) { editorHtml.find('.regex_script_name').val(existingScript.scriptName); } else { @@ -173,6 +240,7 @@ async function onRegexEditorOpenClick(existingId) { } const testScript = { + id: uuidv4(), scriptName: editorHtml.find('.regex_script_name').val(), findRegex: editorHtml.find('.find_regex').val(), replaceString: editorHtml.find('.regex_replace_string').val(), @@ -189,9 +257,10 @@ async function onRegexEditorOpenClick(existingId) { const popupResult = await callPopup(editorHtml, 'confirm', undefined, { okButton: 'Save' }); if (popupResult) { const newRegexScript = { - scriptName: editorHtml.find('.regex_script_name').val(), - findRegex: editorHtml.find('.find_regex').val(), - replaceString: editorHtml.find('.regex_replace_string').val(), + id: existingId ? String(existingId) : uuidv4(), + scriptName: String(editorHtml.find('.regex_script_name').val()), + findRegex: String(editorHtml.find('.find_regex').val()), + replaceString: String(editorHtml.find('.regex_replace_string').val()), trimStrings: editorHtml.find('.regex_trim_strings').val().split('\n').filter((e) => e.length !== 0) || [], placement: editorHtml @@ -209,7 +278,7 @@ async function onRegexEditorOpenClick(existingId) { maxDepth: parseInt(String(editorHtml.find('input[name="max_depth"]').val())), }; - saveRegexScript(newRegexScript, existingScriptIndex); + saveRegexScript(newRegexScript, existingScriptIndex, isScoped); } } @@ -220,6 +289,11 @@ function migrateSettings() { // Current: If MD Display is present in placement, remove it and add new placements/MD option extension_settings.regex.forEach((script) => { + if (!script.id) { + script.id = uuidv4(); + performSave = true; + } + if (script.placement.includes(regex_placement.MD_DISPLAY)) { script.placement = script.placement.length === 1 ? Object.values(regex_placement).filter((e) => e !== regex_placement.MD_DISPLAY) : @@ -242,6 +316,11 @@ function migrateSettings() { } }); + if (!extension_settings.character_allowed_regex) { + extension_settings.character_allowed_regex = []; + performSave = true; + } + if (performSave) { saveSettingsDebounced(); } @@ -260,8 +339,9 @@ function runRegexCallback(args, value) { } const scriptName = String(resolveVariable(args.name)); + const scripts = [...(extension_settings.regex ?? []), ...(characters[this_chid]?.data?.extensions?.regex_scripts ?? [])]; - for (const script of extension_settings.regex) { + for (const script of scripts) { if (String(script.scriptName).toLowerCase() === String(scriptName).toLowerCase()) { if (script.disabled) { toastr.warning(`Regex script "${scriptName}" is disabled.`); @@ -280,8 +360,9 @@ function runRegexCallback(args, value) { /** * Performs the import of the regex file. * @param {File} file Input file + * @param {boolean} isScoped Is the script scoped to a character? */ -async function onRegexImportFileChange(file) { +async function onRegexImportFileChange(file, isScoped) { if (!file) { toastr.error('No file provided.'); return; @@ -294,7 +375,15 @@ async function onRegexImportFileChange(file) { throw new Error('No script name provided.'); } - extension_settings.regex.push(regexScript); + // Assign a new UUID + regexScript.id = uuidv4(); + + const array = (isScoped ? characters[this_chid]?.data?.extensions?.regex_scripts : extension_settings.regex) ?? []; + array.push(regexScript); + + if (isScoped) { + await writeExtensionField(this_chid, 'regex_scripts', array); + } saveSettingsDebounced(); await loadRegexScripts(); @@ -306,6 +395,47 @@ async function onRegexImportFileChange(file) { } } +function purgeEmbeddedRegexScripts( { character }){ + const avatar = character?.avatar; + + if (avatar && extension_settings.character_allowed_regex?.includes(avatar)) { + const index = extension_settings.character_allowed_regex.indexOf(avatar); + if (index !== -1) { + extension_settings.character_allowed_regex.splice(index, 1); + saveSettingsDebounced(); + } + } +} + +async function checkEmbeddedRegexScripts() { + const chid = this_chid; + + if (chid !== undefined && !selected_group) { + const avatar = characters[chid]?.avatar; + const scripts = characters[chid]?.data?.extensions?.regex_scripts; + + if (Array.isArray(scripts) && scripts.length > 0) { + if (avatar && !extension_settings.character_allowed_regex.includes(avatar)) { + const checkKey = `AlertRegex_${characters[chid].avatar}`; + + if (!localStorage.getItem(checkKey)) { + localStorage.setItem(checkKey, 'true'); + const template = await renderExtensionTemplateAsync('regex', 'embeddedScripts', {}); + const result = await callPopup(template, 'confirm', '', { okButton: 'Yes' }); + + if (result) { + extension_settings.character_allowed_regex.push(avatar); + await reloadCurrentChat(); + saveSettingsDebounced(); + } + } + } + } + } + + loadRegexScripts(); +} + // Workaround for loading in sequence with other extensions // NOTE: Always puts extension at the top of the list, but this is fine since it's static jQuery(async () => { @@ -321,12 +451,32 @@ jQuery(async () => { const settingsHtml = $(await renderExtensionTemplateAsync('regex', 'dropdown')); $('#extensions_settings2').append(settingsHtml); $('#open_regex_editor').on('click', function () { - onRegexEditorOpenClick(false); + onRegexEditorOpenClick(false, false); + }); + $('#open_scoped_editor').on('click', function () { + if (this_chid === undefined) { + toastr.error('No character selected.'); + return; + } + + if (selected_group) { + toastr.error('Cannot edit scoped scripts in group chats.'); + return; + } + + onRegexEditorOpenClick(false, true); }); $('#import_regex_file').on('change', async function () { + let target = 'global'; + const template = $(await renderExtensionTemplateAsync('regex', 'importTarget')); + template.find('#regex_import_target_global').on('input', () => target = 'global'); + template.find('#regex_import_target_scoped').on('input', () => target = 'scoped'); + + await callPopup(template, 'text'); + const inputElement = this instanceof HTMLInputElement && this; for (const file of inputElement.files) { - await onRegexImportFileChange(file); + await onRegexImportFileChange(file, target === 'scoped'); } inputElement.value = ''; }); @@ -334,30 +484,75 @@ jQuery(async () => { $('#import_regex_file').trigger('click'); }); - $('#saved_regex_scripts').sortable({ - delay: getSortableDelay(), - stop: function () { - let newScripts = []; - $('#saved_regex_scripts').children().each(function () { - const scriptName = $(this).find('.regex_script_name').text(); - const existingScript = extension_settings.regex.find((e) => e.scriptName === scriptName); - if (existingScript) { - newScripts.push(existingScript); - } - }); - - extension_settings.regex = newScripts; - saveSettingsDebounced(); - - console.debug('Regex scripts reordered'); - // TODO: Maybe reload regex scripts after move + let sortableDatas = [ + { + selector: '#saved_regex_scripts', + setter: x => extension_settings.regex = x, + getter: () => extension_settings.regex ?? [], }, + { + selector: '#saved_scoped_scripts', + setter: x => writeExtensionField(this_chid, 'regex_scripts', x), + getter: () => characters[this_chid]?.data?.extensions?.regex_scripts ?? [], + }, + ]; + for (const { selector, setter, getter } of sortableDatas) { + $(selector).sortable({ + delay: getSortableDelay(), + stop: async function () { + const oldScripts = getter(); + const newScripts = []; + $(selector).children().each(function () { + const id = $(this).attr('id'); + const existingScript = oldScripts.find((e) => e.id === id); + if (existingScript) { + newScripts.push(existingScript); + } + }); + + await setter(newScripts); + saveSettingsDebounced(); + + console.debug(`Regex scripts in ${selector} reordered`); + await loadRegexScripts(); + }, + }); + } + + $('#regex_scoped_toggle').on('input', function () { + if (this_chid === undefined) { + toastr.error('No character selected.'); + return; + } + + if (selected_group) { + toastr.error('Cannot edit scoped scripts in group chats.'); + return; + } + + const isEnable = !!$(this).prop('checked'); + const avatar = characters[this_chid].avatar; + + if (isEnable) { + if (!extension_settings.character_allowed_regex.includes(avatar)) { + extension_settings.character_allowed_regex.push(avatar); + } + } else { + const index = extension_settings.character_allowed_regex.indexOf(avatar); + if (index !== -1) { + extension_settings.character_allowed_regex.splice(index, 1); + } + } + + saveSettingsDebounced(); + reloadCurrentChat(); }); await loadRegexScripts(); $('#saved_regex_scripts').sortable('enable'); - SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'regex', + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'regex', callback: runRegexCallback, returns: 'replaced text', namedArgumentList: [ @@ -373,4 +568,6 @@ jQuery(async () => { helpString: 'Runs a Regex extension script by name on the provided string. The script must be enabled.', })); + eventSource.on(event_types.CHAT_CHANGED, checkEmbeddedRegexScripts); + eventSource.on(event_types.CHARACTER_DELETED, purgeEmbeddedRegexScripts); }); diff --git a/public/scripts/extensions/regex/scriptTemplate.html b/public/scripts/extensions/regex/scriptTemplate.html index 92fc05e3f..cafe992f0 100644 --- a/public/scripts/extensions/regex/scriptTemplate.html +++ b/public/scripts/extensions/regex/scriptTemplate.html @@ -10,6 +10,12 @@ + + diff --git a/public/scripts/extensions/regex/style.css b/public/scripts/extensions/regex/style.css index 1bc81dd5f..2816d562f 100644 --- a/public/scripts/extensions/regex/style.css +++ b/public/scripts/extensions/regex/style.css @@ -14,6 +14,47 @@ margin-bottom: 10px; } +.regex-script-container:empty::after { + content: "No scripts found"; + font-size: 0.95em; + opacity: 0.7; + display: block; + text-align: center; +} + +#scoped_scripts_block { + opacity: 1; + transition: opacity 0.2s ease-in-out; +} + +#scoped_scripts_block .move_to_scoped { + display: none; +} + +#global_scripts_block .move_to_global { + display: none; +} + +#scoped_scripts_block:not(:has(#regex_scoped_toggle:checked)) { + opacity: 0.5; +} + +.enable_scoped:checked ~ .regex-toggle-on { + display: block; +} + +.enable_scoped:checked ~ .regex-toggle-off { + display: none; +} + +.enable_scoped:not(:checked) ~ .regex-toggle-on { + display: none; +} + +.enable_scoped:not(:checked) ~ .regex-toggle-off { + display: block; +} + .regex-script-label { align-items: center; border: 1px solid var(--SmartThemeBorderColor); @@ -23,7 +64,13 @@ margin-bottom: 1px; } -input.disable_regex { +.regex-script-label:has(.disable_regex:checked) .regex_script_name { + text-decoration: line-through; + filter: grayscale(0.5); +} + +input.disable_regex, +input.enable_scoped { display: none !important; } @@ -31,6 +78,12 @@ input.disable_regex { cursor: pointer; opacity: 0.5; filter: grayscale(0.5); + transition: opacity 0.2s ease-in-out; +} + +.regex-toggle-off:hover { + opacity: 1; + filter: none; } .regex-toggle-on { diff --git a/public/scripts/openai.js b/public/scripts/openai.js index f0cde3173..f542d3f14 100644 --- a/public/scripts/openai.js +++ b/public/scripts/openai.js @@ -278,6 +278,7 @@ const default_settings = { inline_image_quality: 'low', bypass_status_check: false, continue_prefill: false, + function_calling: false, names_behavior: character_names_behavior.NONE, continue_postfix: continue_postfix_types.SPACE, custom_prompt_post_processing: custom_prompt_post_processing_types.NONE, @@ -355,6 +356,7 @@ const oai_settings = { inline_image_quality: 'low', bypass_status_check: false, continue_prefill: false, + function_calling: false, names_behavior: character_names_behavior.NONE, continue_postfix: continue_postfix_types.SPACE, custom_prompt_post_processing: custom_prompt_post_processing_types.NONE, @@ -1851,6 +1853,10 @@ async function sendOpenAIRequest(type, messages, signal) { await eventSource.emit(event_types.CHAT_COMPLETION_SETTINGS_READY, generate_data); + if (isFunctionCallingSupported()) { + await registerFunctionTools(type, generate_data); + } + const generate_url = '/api/backends/chat-completions/generate'; const response = await fetch(generate_url, { method: 'POST', @@ -1907,10 +1913,125 @@ async function sendOpenAIRequest(type, messages, signal) { delay(1).then(() => saveLogprobsForActiveMessage(logprobs, null)); } + if (isFunctionCallingSupported()) { + await checkFunctionToolCalls(data); + } + return data; } } +/** + * Register function tools for the next chat completion request. + * @param {string} type Generation type + * @param {object} data Generation data + */ +async function registerFunctionTools(type, data) { + let toolChoice = 'auto'; + const tools = []; + + /** + * @type {registerFunctionTool} + */ + const registerFunctionTool = (name, description, parameters, required) => { + tools.push({ + type: 'function', + function: { + name, + description, + parameters, + }, + }); + + if (required) { + toolChoice = 'required'; + } + }; + + /** + * @type {FunctionToolRegister} + */ + const args = { + type, + data, + registerFunctionTool, + }; + + await eventSource.emit(event_types.LLM_FUNCTION_TOOL_REGISTER, args); + + if (tools.length) { + console.log('Registered function tools:', tools); + + data['tools'] = tools; + data['tool_choice'] = toolChoice; + } +} + +async function checkFunctionToolCalls(data) { + if ([chat_completion_sources.OPENAI, chat_completion_sources.CUSTOM, chat_completion_sources.MISTRALAI].includes(oai_settings.chat_completion_source)) { + if (!Array.isArray(data?.choices)) { + return; + } + + // Find a choice with 0-index + const choice = data.choices.find(choice => choice.index === 0); + + if (!choice) { + return; + } + + const toolCalls = choice.message.tool_calls; + + if (!Array.isArray(toolCalls)) { + return; + } + + for (const toolCall of toolCalls) { + if (typeof toolCall.function !== 'object') { + continue; + } + + /** @type {FunctionToolCall} */ + const args = toolCall.function; + console.log('Function tool call:', toolCall); + await eventSource.emit(event_types.LLM_FUNCTION_TOOL_CALL, args); + data.allowEmptyResponse = true; + } + } + + if ([chat_completion_sources.COHERE].includes(oai_settings.chat_completion_source)) { + if (!Array.isArray(data?.tool_calls)) { + return; + } + + for (const toolCall of data.tool_calls) { + /** @type {FunctionToolCall} */ + const args = { name: toolCall.name, arguments: JSON.stringify(toolCall.parameters) }; + console.log('Function tool call:', toolCall); + await eventSource.emit(event_types.LLM_FUNCTION_TOOL_CALL, args); + data.allowEmptyResponse = true; + } + } +} + +export function isFunctionCallingSupported() { + if (main_api !== 'openai') { + return false; + } + + if (!oai_settings.function_calling) { + return false; + } + + const supportedSources = [ + chat_completion_sources.OPENAI, + chat_completion_sources.COHERE, + chat_completion_sources.CUSTOM, + chat_completion_sources.MISTRALAI, + ]; + return supportedSources.includes(oai_settings.chat_completion_source); +} + function getStreamingReply(data) { if (oai_settings.chat_completion_source == chat_completion_sources.CLAUDE) { return data?.delta?.text || ''; @@ -2781,6 +2902,7 @@ function loadOpenAISettings(data, settings) { oai_settings.continue_prefill = settings.continue_prefill ?? default_settings.continue_prefill; oai_settings.names_behavior = settings.names_behavior ?? default_settings.names_behavior; oai_settings.continue_postfix = settings.continue_postfix ?? default_settings.continue_postfix; + oai_settings.function_calling = settings.function_calling ?? default_settings.function_calling; // Migrate from old settings if (settings.names_in_completion === true) { @@ -2849,6 +2971,7 @@ function loadOpenAISettings(data, settings) { $('#openrouter_providers_chat').val(oai_settings.openrouter_providers).trigger('change'); $('#squash_system_messages').prop('checked', oai_settings.squash_system_messages); $('#continue_prefill').prop('checked', oai_settings.continue_prefill); + $('#openai_function_calling').prop('checked', oai_settings.function_calling); if (settings.impersonation_prompt !== undefined) oai_settings.impersonation_prompt = settings.impersonation_prompt; $('#impersonation_prompt_textarea').val(oai_settings.impersonation_prompt); @@ -3132,6 +3255,7 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) { bypass_status_check: settings.bypass_status_check, continue_prefill: settings.continue_prefill, continue_postfix: settings.continue_postfix, + function_calling: settings.function_calling, seed: settings.seed, n: settings.n, }; @@ -3518,6 +3642,7 @@ function onSettingsPresetChange() { inline_image_quality: ['#openai_inline_image_quality', 'inline_image_quality', false], continue_prefill: ['#continue_prefill', 'continue_prefill', true], continue_postfix: ['#continue_postfix', 'continue_postfix', false], + function_calling: ['#openai_function_calling', 'function_calling', true], seed: ['#seed_openai', 'seed', false], n: ['#n_openai', 'n', false], }; @@ -3857,7 +3982,7 @@ async function onModelChange() { else if (['command-r', 'command-r-plus'].includes(oai_settings.cohere_model)) { $('#openai_max_context').attr('max', max_128k); } - else if(['c4ai-aya-23'].includes(oai_settings.cohere_model)) { + else if (['c4ai-aya-23'].includes(oai_settings.cohere_model)) { $('#openai_max_context').attr('max', max_8k); } else { @@ -4448,7 +4573,8 @@ function runProxyCallback(_, value) { return foundName; } -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'proxy', +SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'proxy', callback: runProxyCallback, returns: 'current proxy', namedArgumentList: [], @@ -4785,6 +4911,11 @@ $(document).ready(async function () { saveSettingsDebounced(); }); + $('#openai_function_calling').on('input', function () { + oai_settings.function_calling = !!$(this).prop('checked'); + saveSettingsDebounced(); + }); + $('#seed_openai').on('input', function () { oai_settings.seed = Number($(this).val()); saveSettingsDebounced(); diff --git a/public/scripts/world-info.js b/public/scripts/world-info.js index 1fa9afd0f..f971acc56 100644 --- a/public/scripts/world-info.js +++ b/public/scripts/world-info.js @@ -3581,7 +3581,7 @@ export async function importWorldInfo(file) { return; } - const worldName = file.name.substr(0, file.name.lastIndexOf(".")); + const worldName = file.name.substr(0, file.name.lastIndexOf('.')); const sanitizedWorldName = await getSanitizedFilename(worldName); const allowed = await checkOverwriteExistingData('World Info', world_names, sanitizedWorldName, { interactive: true, actionName: 'Import', deleteAction: (existingName) => deleteWorldInfo(existingName) }); if (!allowed) { diff --git a/src/endpoints/backends/chat-completions.js b/src/endpoints/backends/chat-completions.js index f4cbe85c2..0d91f9657 100644 --- a/src/endpoints/backends/chat-completions.js +++ b/src/endpoints/backends/chat-completions.js @@ -5,7 +5,7 @@ const Readable = require('stream').Readable; const { jsonParser } = require('../../express-common'); const { CHAT_COMPLETION_SOURCES, GEMINI_SAFETY, BISON_SAFETY, OPENROUTER_HEADERS } = require('../../constants'); const { forwardFetchResponse, getConfigValue, tryParse, uuidv4, mergeObjectWithYaml, excludeKeysByYaml, color } = require('../../util'); -const { convertClaudeMessages, convertGooglePrompt, convertTextCompletionPrompt, convertCohereMessages, convertMistralMessages } = require('../../prompt-converters'); +const { convertClaudeMessages, convertGooglePrompt, convertTextCompletionPrompt, convertCohereMessages, convertMistralMessages, convertCohereTools } = require('../../prompt-converters'); const { readSecret, SECRET_KEYS } = require('../secrets'); const { getTokenizerModel, getSentencepiceTokenizer, getTiktokenTokenizer, sentencepieceTokenizers, TEXT_COMPLETION_MODELS } = require('../tokenizers'); @@ -486,6 +486,11 @@ async function sendMistralAIRequest(request, response) { 'random_seed': request.body.seed === -1 ? undefined : request.body.seed, }; + if (Array.isArray(request.body.tools) && request.body.tools.length > 0) { + requestBody['tools'] = request.body.tools; + requestBody['tool_choice'] = request.body.tool_choice === 'required' ? 'any' : 'auto'; + } + const config = { method: 'POST', headers: { @@ -544,6 +549,7 @@ async function sendCohereRequest(request, response) { try { const convertedHistory = convertCohereMessages(request.body.messages, request.body.char_name, request.body.user_name); const connectors = []; + const tools = []; if (request.body.websearch) { connectors.push({ @@ -551,6 +557,12 @@ async function sendCohereRequest(request, response) { }); } + if (Array.isArray(request.body.tools) && request.body.tools.length > 0) { + tools.push(...convertCohereTools(request.body.tools)); + // Can't have both connectors and tools in the same request + connectors.splice(0, connectors.length); + } + // https://docs.cohere.com/reference/chat const requestBody = { stream: Boolean(request.body.stream), @@ -569,8 +581,7 @@ async function sendCohereRequest(request, response) { prompt_truncation: 'AUTO_PRESERVE_ORDER', connectors: connectors, documents: [], - tools: [], - tool_results: [], + tools: tools, search_queries_only: false, }; @@ -920,6 +931,11 @@ router.post('/generate', jsonParser, function (request, response) { controller.abort(); }); + if (!isTextCompletion) { + bodyParams['tools'] = request.body.tools; + bodyParams['tool_choice'] = request.body.tool_choice; + } + const requestBody = { 'messages': isTextCompletion === false ? request.body.messages : undefined, 'prompt': isTextCompletion === true ? textPrompt : undefined, diff --git a/src/endpoints/tokenizers.js b/src/endpoints/tokenizers.js index 7ed3c603a..0c8c99034 100644 --- a/src/endpoints/tokenizers.js +++ b/src/endpoints/tokenizers.js @@ -308,6 +308,10 @@ function getTokenizerModel(requestModel) { return 'yi'; } + if (requestModel.includes('gemini')) { + return 'gpt-4o'; + } + // default return 'gpt-3.5-turbo'; } diff --git a/src/prompt-converters.js b/src/prompt-converters.js index 5a76f3802..b42246e44 100644 --- a/src/prompt-converters.js +++ b/src/prompt-converters.js @@ -451,6 +451,76 @@ function convertTextCompletionPrompt(messages) { return messageStrings.join('\n') + '\nassistant:'; } +/** + * Convert OpenAI Chat Completion tools to the format used by Cohere. + * @param {object[]} tools OpenAI Chat Completion tool definitions + */ +function convertCohereTools(tools) { + if (!Array.isArray(tools) || tools.length === 0) { + return []; + } + + const jsonSchemaToPythonTypes = { + 'string': 'str', + 'number': 'float', + 'integer': 'int', + 'boolean': 'bool', + 'array': 'list', + 'object': 'dict', + }; + + const cohereTools = []; + + for (const tool of tools) { + if (tool?.type !== 'function') { + console.log(`Unsupported tool type: ${tool.type}`); + continue; + } + + const name = tool?.function?.name; + const description = tool?.function?.description; + const properties = tool?.function?.parameters?.properties; + const required = tool?.function?.parameters?.required; + const parameters = {}; + + if (!name) { + console.log('Tool name is missing'); + continue; + } + + if (!description) { + console.log('Tool description is missing'); + } + + if (!properties || typeof properties !== 'object') { + console.log(`No properties found for tool: ${tool?.function?.name}`); + continue; + } + + for (const property in properties) { + const parameterDefinition = properties[property]; + const description = parameterDefinition.description || (parameterDefinition.enum ? JSON.stringify(parameterDefinition.enum) : ''); + const type = jsonSchemaToPythonTypes[parameterDefinition.type] || 'str'; + const isRequired = Array.isArray(required) && required.includes(property); + parameters[property] = { + description: description, + type: type, + required: isRequired, + }; + } + + const cohereTool = { + name: tool.function.name, + description: tool.function.description, + parameter_definitions: parameters, + }; + + cohereTools.push(cohereTool); + } + + return cohereTools; +} + module.exports = { convertClaudePrompt, convertClaudeMessages, @@ -458,4 +528,5 @@ module.exports = { convertTextCompletionPrompt, convertCohereMessages, convertMistralMessages, + convertCohereTools, };