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: `
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,
+ });
+
+ 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 '';
+ },
+ }));
+ }
}