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() {
948 initSystemPrompts();948 initSystemPrompts();
949 initExtensions();949 initExtensions();
950 initExtensionSlashCommands();950 initExtensionSlashCommands();
951 ToolManager.initToolSlashCommands();
951 await initPresetManager();952 await initPresetManager();
952 await getSystemMessages();953 await getSystemMessages();
953 sendSystemMessage(system_message_types.WELCOME);954 sendSystemMessage(system_message_types.WELCOME);
public/scripts/tool-calling.js+284 -2
@@ -1,6 +1,13 @@
1import { addOneMessage, chat, event_types, eventSource, main_api, saveChatConditional, system_avatar, systemUserName } from '../script.js';1import { addOneMessage, chat, event_types, eventSource, main_api, saveChatConditional, system_avatar, systemUserName } from '../script.js';
2import { chat_completion_sources, oai_settings } from './openai.js';2import { chat_completion_sources, oai_settings } from './openai.js';
3import { Popup } from './popup.js';3import { Popup } from './popup.js';
4import { SlashCommand } from './slash-commands/SlashCommand.js';
5import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
6import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
7import { enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
8import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
9import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
10import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js';
411
5/**12/**
6 * @typedef {object} ToolInvocation13 * @typedef {object} ToolInvocation
@@ -28,6 +35,60 @@ import { Popup } from './popup.js';
28 */35 */
2936
30/**37/**
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 */
53function 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 */
69function 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 */
83function tryParse(str) {
84 try {
85 return JSON.parse(str);
86 } catch {
87 return str;
88 }
89}
90
91/**
31 * A class that represents a tool definition.92 * A class that represents a tool definition.
32 */93 */
33class ToolDefinition {94class ToolDefinition {
@@ -87,7 +148,7 @@ class ToolDefinition {
87148
88 /**149 /**
89 * Converts the ToolDefinition to an OpenAI API representation150 * Converts the ToolDefinition to an OpenAI API representation
90 * @returns {object} OpenAI API representation of the tool.151 * @returns {ToolDefinitionOpenAI} OpenAI API representation of the tool.
91 */152 */
92 toFunctionOpenAI() {153 toFunctionOpenAI() {
93 return {154 return {
@@ -97,6 +158,9 @@ class ToolDefinition {
97 description: this.#description,158 description: this.#description,
98 parameters: this.#parameters,159 parameters: this.#parameters,
99 },160 },
161 toString: (/** @type {ToolDefinitionOpenAI} */ tool) => {
162 return `${tool?.function?.name} - ${tool?.function?.description}\n${JSON.stringify(tool?.function?.parameters, null, 2)}`;
163 },
100 };164 };
101 }165 }
102166
@@ -522,7 +586,6 @@ export class ToolManager {
522 * @returns {string} Formatted message with tool invocations.586 * @returns {string} Formatted message with tool invocations.
523 */587 */
524 static #formatToolInvocationMessage(invocations) {588 static #formatToolInvocationMessage(invocations) {
525 const tryParse = (x) => { try { return JSON.parse(x); } catch { return x; } };
526 const data = structuredClone(invocations);589 const data = structuredClone(invocations);
527 const detailsElement = document.createElement('details');590 const detailsElement = document.createElement('details');
528 const summaryElement = document.createElement('summary');591 const summaryElement = document.createElement('summary');
@@ -578,4 +641,223 @@ export class ToolManager {
578 timeOut: 5000,641 timeOut: 5000,
579 });642 });
580 }643 }
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 }
581}863}