diff --git a/public/script.js b/public/script.js index 027fcf0c6..2acdcc02b 100644 --- a/public/script.js +++ b/public/script.js @@ -948,6 +948,7 @@ async function firstLoadInit() { initSystemPrompts(); initExtensions(); initExtensionSlashCommands(); + ToolManager.initToolSlashCommands(); await initPresetManager(); await getSystemMessages(); sendSystemMessage(system_message_types.WELCOME); diff --git a/public/scripts/tool-calling.js b/public/scripts/tool-calling.js index 922b9dc14..43312d3d8 100644 --- a/public/scripts/tool-calling.js +++ b/public/scripts/tool-calling.js @@ -1,6 +1,13 @@ import { addOneMessage, chat, event_types, eventSource, main_api, saveChatConditional, system_avatar, systemUserName } from '../script.js'; import { chat_completion_sources, oai_settings } from './openai.js'; import { Popup } from './popup.js'; +import { SlashCommand } from './slash-commands/SlashCommand.js'; +import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js'; +import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js'; +import { enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js'; +import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js'; +import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; +import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js'; /** * @typedef {object} ToolInvocation @@ -27,6 +34,60 @@ import { Popup } from './popup.js'; * @property {function} formatMessage - A function to format the tool call message. */ +/** + * @typedef {object} ToolDefinitionOpenAI + * @property {string} type - The type of the tool. + * @property {object} function - The function definition. + * @property {string} function.name - The name of the function. + * @property {string} function.description - The description of the function. + * @property {object} function.parameters - The parameters of the function. + * @property {function} toString - A function to convert the tool to a string. + */ + +/** + * Assigns nested variables to a scope. + * @param {import('./slash-commands/SlashCommandScope.js').SlashCommandScope} scope The scope to assign variables to. + * @param {object} arg Object to assign variables from. + * @param {string} prefix Prefix for the variable names. + */ +function assignNestedVariables(scope, arg, prefix) { + Object.entries(arg).forEach(([key, value]) => { + const newPrefix = `${prefix}.${key}`; + if (typeof value === 'object' && value !== null) { + assignNestedVariables(scope, value, newPrefix); + } else { + scope.letVariable(newPrefix, value); + } + }); +} + +/** + * Checks if a string is a valid JSON string. + * @param {string} str The string to check + * @returns {boolean} If the string is a valid JSON string + */ +function isJson(str) { + try { + JSON.parse(str); + return true; + } catch { + return false; + } +} + +/** + * Tries to parse a string as JSON, returning the original string if parsing fails. + * @param {string} str The string to try to parse + * @returns {object|string} Parsed JSON or the original string + */ +function tryParse(str) { + try { + return JSON.parse(str); + } catch { + return str; + } +} + /** * A class that represents a tool definition. */ @@ -87,7 +148,7 @@ class ToolDefinition { /** * Converts the ToolDefinition to an OpenAI API representation - * @returns {object} OpenAI API representation of the tool. + * @returns {ToolDefinitionOpenAI} OpenAI API representation of the tool. */ toFunctionOpenAI() { return { @@ -97,6 +158,9 @@ class ToolDefinition { description: this.#description, parameters: this.#parameters, }, + toString: (/** @type {ToolDefinitionOpenAI} */ tool) => { + return `${tool?.function?.name} - ${tool?.function?.description}\n${JSON.stringify(tool?.function?.parameters, null, 2)}`; + }, }; } @@ -522,7 +586,6 @@ export class ToolManager { * @returns {string} Formatted message with tool invocations. */ static #formatToolInvocationMessage(invocations) { - const tryParse = (x) => { try { return JSON.parse(x); } catch { return x; } }; const data = structuredClone(invocations); const detailsElement = document.createElement('details'); const summaryElement = document.createElement('summary'); @@ -578,4 +641,223 @@ export class ToolManager { timeOut: 5000, }); } + + static initToolSlashCommands() { + const toolsEnumProvider = () => ToolManager.tools.map(tool => { + const toolOpenAI = tool.toFunctionOpenAI(); + return new SlashCommandEnumValue(toolOpenAI.function.name, toolOpenAI.function.description, enumTypes.enum, enumIcons.closure); + }); + + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'tools-list', + aliases: ['tool-list'], + helpString: 'Gets a list of all registered tools in the OpenAI function JSON format. Use the 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 = (tool) => tool.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: `
Registers a new tool with the tool registry.
+ +
See json-schema.org and OpenAI Function Calling for more information.
+
Example:
+
/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, + }); + + 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 ''; + }, + })); + } }