mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
Add slash commands for tools management
This commit is contained in:
@ -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 <code>return</code> 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 <code>parameters</code> 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: `<div>Registers a new tool with the tool registry.</div>
|
||||
<ul>
|
||||
<li>The <code>parameters</code> argument MUST be a JSON-serialized object with a valid JSON schema.</li>
|
||||
<li>The unnamed argument MUST be a closure that accepts the function parameters as local script variables.</li>
|
||||
</ul>
|
||||
<div>See <a target="_blank" href="https://json-schema.org/learn/">json-schema.org</a> and <a target="_blank" href="https://platform.openai.com/docs/guides/function-calling">OpenAI Function Calling</a> for more information.</div>
|
||||
<div>Example:</div>
|
||||
<pre><code>/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}} :}</code></pre>`,
|
||||
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 '';
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user