Add slash commands for tools management

077ba8b03d38c6e61be21229d6c2157cd0def533

Cohee <18619528+Cohee1207@users.noreply.github.com>

2 files changed, +285 -2Showing whitespace changes
public/script.js+1 -0
@@ -948,6 +948,7 @@ async function firstLoadInit() {
948948 initSystemPrompts();
949949 initExtensions();
950950 initExtensionSlashCommands();
951+ ToolManager.initToolSlashCommands();
951952 await initPresetManager();
952953 await getSystemMessages();
953954 sendSystemMessage(system_message_types.WELCOME);
public/scripts/tool-calling.js+284 -2
@@ -1,6 +1,13 @@
11import { addOneMessage, chat, event_types, eventSource, main_api, saveChatConditional, system_avatar, systemUserName } from '../script.js';
22import { chat_completion_sources, oai_settings } from './openai.js';
33import { Popup } from './popup.js';
4+import { SlashCommand } from './slash-commands/SlashCommand.js';
5+import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
6+import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
7+import { enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
8+import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
9+import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
10+import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js';
411
512/**
613 * @typedef {object} ToolInvocation
@@ -28,6 +35,60 @@ import { Popup } from './popup.js';
2835 */
2936
3037/**
38+ * @typedef {object} ToolDefinitionOpenAI
39+ * @property {string} type - The type of the tool.
40+ * @property {object} function - The function definition.
41+ * @property {string} function.name - The name of the function.
42+ * @property {string} function.description - The description of the function.
43+ * @property {object} function.parameters - The parameters of the function.
44+ * @property {function} toString - A function to convert the tool to a string.
45+ */
46+
47+/**
48+ * Assigns nested variables to a scope.
49+ * @param {import('./slash-commands/SlashCommandScope.js').SlashCommandScope} scope The scope to assign variables to.
50+ * @param {object} arg Object to assign variables from.
51+ * @param {string} prefix Prefix for the variable names.
52+ */
53+function assignNestedVariables(scope, arg, prefix) {
54+ Object.entries(arg).forEach(([key, value]) => {
55+ const newPrefix = `${prefix}.${key}`;
56+ if (typeof value === 'object' && value !== null) {
57+ assignNestedVariables(scope, value, newPrefix);
58+ } else {
59+ scope.letVariable(newPrefix, value);
60+ }
61+ });
62+}
63+
64+/**
65+ * Checks if a string is a valid JSON string.
66+ * @param {string} str The string to check
67+ * @returns {boolean} If the string is a valid JSON string
68+ */
69+function isJson(str) {
70+ try {
71+ JSON.parse(str);
72+ return true;
73+ } catch {
74+ return false;
75+ }
76+}
77+
78+/**
79+ * Tries to parse a string as JSON, returning the original string if parsing fails.
80+ * @param {string} str The string to try to parse
81+ * @returns {object|string} Parsed JSON or the original string
82+ */
83+function tryParse(str) {
84+ try {
85+ return JSON.parse(str);
86+ } catch {
87+ return str;
88+ }
89+}
90+
91+/**
3192 * A class that represents a tool definition.
3293 */
3394class ToolDefinition {
@@ -87,7 +148,7 @@ class ToolDefinition {
87148
88149 /**
89150 * Converts the ToolDefinition to an OpenAI API representation
90151 * @returns {objectToolDefinitionOpenAI} OpenAI API representation of the tool.
91152 */
92153 toFunctionOpenAI() {
93154 return {
@@ -97,6 +158,9 @@ class ToolDefinition {
97158 description: this.#description,
98159 parameters: this.#parameters,
99160 },
161+ toString: (/** @type {ToolDefinitionOpenAI} */ tool) => {
162+ return `${tool?.function?.name} - ${tool?.function?.description}\n${JSON.stringify(tool?.function?.parameters, null, 2)}`;
163+ },
100164 };
101165 }
102166
@@ -522,7 +586,6 @@ export class ToolManager {
522586 * @returns {string} Formatted message with tool invocations.
523587 */
524588 static #formatToolInvocationMessage(invocations) {
525- const tryParse = (x) => { try { return JSON.parse(x); } catch { return x; } };
526589 const data = structuredClone(invocations);
527590 const detailsElement = document.createElement('details');
528591 const summaryElement = document.createElement('summary');
@@ -578,4 +641,223 @@ export class ToolManager {
578641 timeOut: 5000,
579642 });
580643 }
644+
645+ static initToolSlashCommands() {
646+ const toolsEnumProvider = () => ToolManager.tools.map(tool => {
647+ const toolOpenAI = tool.toFunctionOpenAI();
648+ return new SlashCommandEnumValue(toolOpenAI.function.name, toolOpenAI.function.description, enumTypes.enum, enumIcons.closure);
649+ });
650+
651+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
652+ name: 'tools-list',
653+ aliases: ['tool-list'],
654+ 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.',
655+ returns: 'A list of all registered tools.',
656+ namedArgumentList: [
657+ SlashCommandNamedArgument.fromProps({
658+ name: 'return',
659+ description: 'The way how you want the return value to be provided',
660+ typeList: [ARGUMENT_TYPE.STRING],
661+ defaultValue: 'none',
662+ enumList: slashCommandReturnHelper.enumList({ allowObject: true }),
663+ forceEnum: true,
664+ }),
665+ ],
666+ callback: async (args) => {
667+ /** @type {any} */
668+ const returnType = String(args?.return ?? 'popup-html').trim().toLowerCase();
669+ const objectToStringFunc = (tool) => tool.toString();
670+ const tools = ToolManager.tools.map(tool => tool.toFunctionOpenAI());
671+ return await slashCommandReturnHelper.doReturn(returnType ?? 'popup-html', tools ?? [], { objectToStringFunc });
672+ },
673+ }));
674+
675+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
676+ name: 'tools-invoke',
677+ aliases: ['tool-invoke'],
678+ helpString: 'Invokes a registered tool by name. The <code>parameters</code> argument MUST be a JSON-serialized object.',
679+ namedArgumentList: [
680+ SlashCommandNamedArgument.fromProps({
681+ name: 'parameters',
682+ description: 'The parameters to pass to the tool.',
683+ typeList: [ARGUMENT_TYPE.DICTIONARY],
684+ isRequired: true,
685+ acceptsMultiple: false,
686+ }),
687+ ],
688+ unnamedArgumentList: [
689+ SlashCommandArgument.fromProps({
690+ description: 'The name of the tool to invoke.',
691+ typeList: [ARGUMENT_TYPE.STRING],
692+ isRequired: true,
693+ acceptsMultiple: false,
694+ forceEnum: true,
695+ enumProvider: toolsEnumProvider,
696+ }),
697+ ],
698+ callback: async (args, name) => {
699+ const { parameters } = args;
700+
701+ const result = await ToolManager.invokeFunctionTool(String(name), parameters);
702+ if (result instanceof Error) {
703+ throw result;
704+ }
705+
706+ return result;
707+ },
708+ }));
709+
710+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
711+ name: 'tools-register',
712+ aliases: ['tool-register'],
713+ helpString: `<div>Registers a new tool with the tool registry.</div>
714+ <ul>
715+ <li>The <code>parameters</code> argument MUST be a JSON-serialized object with a valid JSON schema.</li>
716+ <li>The unnamed argument MUST be a closure that accepts the function parameters as local script variables.</li>
717+ </ul>
718+ <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>
719+ <div>Example:</div>
720+ <pre><code>/let key=echoSchema
721+{
722+ "$schema": "http://json-schema.org/draft-04/schema#",
723+ "type": "object",
724+ "properties": {
725+ "message": {
726+ "type": "string",
727+ "description": "The message to echo."
728+ }
729+ },
730+ "required": [
731+ "message"
732+ ]
733+}
734+||
735+/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>`,
736+ namedArgumentList: [
737+ SlashCommandNamedArgument.fromProps({
738+ name: 'name',
739+ description: 'The name of the tool.',
740+ typeList: [ARGUMENT_TYPE.STRING],
741+ isRequired: true,
742+ acceptsMultiple: false,
743+ }),
744+ SlashCommandNamedArgument.fromProps({
745+ name: 'description',
746+ description: 'A description of what the tool does.',
747+ typeList: [ARGUMENT_TYPE.STRING],
748+ isRequired: true,
749+ acceptsMultiple: false,
750+ }),
751+ SlashCommandNamedArgument.fromProps({
752+ name: 'parameters',
753+ description: 'The parameters for the tool.',
754+ typeList: [ARGUMENT_TYPE.DICTIONARY],
755+ isRequired: true,
756+ acceptsMultiple: false,
757+ }),
758+ SlashCommandNamedArgument.fromProps({
759+ name: 'displayName',
760+ description: 'The display name of the tool.',
761+ typeList: [ARGUMENT_TYPE.STRING],
762+ isRequired: false,
763+ acceptsMultiple: false,
764+ }),
765+ SlashCommandNamedArgument.fromProps({
766+ name: 'formatMessage',
767+ description: 'The closure to be executed to format the tool call message. Must return a string.',
768+ typeList: [ARGUMENT_TYPE.CLOSURE],
769+ isRequired: true,
770+ acceptsMultiple: false,
771+ }),
772+ ],
773+ unnamedArgumentList: [
774+ SlashCommandArgument.fromProps({
775+ description: 'The closure to be executed when the tool is invoked.',
776+ typeList: [ARGUMENT_TYPE.CLOSURE],
777+ isRequired: true,
778+ acceptsMultiple: false,
779+ }),
780+ ],
781+ callback: async (args, action) => {
782+ /**
783+ * Converts a slash command closure to a function.
784+ * @param {SlashCommandClosure} action Closure to convert to a function
785+ * @returns {function} Function that executes the closure
786+ */
787+ function closureToFunction(action) {
788+ return async (args) => {
789+ const localClosure = action.getCopy();
790+ localClosure.onProgress = () => { };
791+ const scope = localClosure.scope;
792+ if (typeof args === 'object' && args !== null) {
793+ assignNestedVariables(scope, args, 'arg');
794+ } else if (typeof args !== 'undefined') {
795+ scope.letVariable('arg', args);
796+ }
797+ const result = await localClosure.execute();
798+ return result.pipe;
799+ };
800+ }
801+
802+ const { name, displayName, description, parameters, formatMessage } = args;
803+
804+ if (!(action instanceof SlashCommandClosure)) {
805+ throw new Error('The unnamed argument must be a closure.');
806+ }
807+ if (typeof name !== 'string' || !name) {
808+ throw new Error('The "name" argument must be a non-empty string.');
809+ }
810+ if (typeof description !== 'string' || !description) {
811+ throw new Error('The "description" argument must be a non-empty string.');
812+ }
813+ if (typeof parameters !== 'string' || !isJson(parameters)) {
814+ throw new Error('The "parameters" argument must be a JSON-serialized object.');
815+ }
816+ if (displayName && typeof displayName !== 'string') {
817+ throw new Error('The "displayName" argument must be a string.');
818+ }
819+ if (formatMessage && !(formatMessage instanceof SlashCommandClosure)) {
820+ throw new Error('The "formatMessage" argument must be a closure.');
821+ }
822+
823+ const actionFunc = closureToFunction(action);
824+ const formatMessageFunc = formatMessage instanceof SlashCommandClosure ? closureToFunction(formatMessage) : null;
825+
826+ ToolManager.registerFunctionTool({
827+ name: String(name ?? ''),
828+ displayName: String(displayName ?? ''),
829+ description: String(description ?? ''),
830+ parameters: JSON.parse(parameters ?? '{}'),
831+ action: actionFunc,
832+ formatMessage: formatMessageFunc,
833+ });
834+
835+ return '';
836+ },
837+ }));
838+
839+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
840+ name: 'tools-unregister',
841+ aliases: ['tool-unregister'],
842+ helpString: 'Unregisters a tool from the tool registry.',
843+ unnamedArgumentList: [
844+ SlashCommandArgument.fromProps({
845+ description: 'The name of the tool to unregister.',
846+ typeList: [ARGUMENT_TYPE.STRING],
847+ isRequired: true,
848+ acceptsMultiple: false,
849+ forceEnum: true,
850+ enumProvider: toolsEnumProvider,
851+ }),
852+ ],
853+ callback: async (name) => {
854+ if (typeof name !== 'string' || !name) {
855+ throw new Error('The unnamed argument must be a non-empty string.');
856+ }
857+
858+ ToolManager.unregisterFunctionTool(name);
859+ return '';
860+ },
861+ }));
862+ }
581863}