diff --git a/src/action/actionManager.ts b/src/action/actionManager.ts index c9eb0bf..cc177d1 100644 --- a/src/action/actionManager.ts +++ b/src/action/actionManager.ts @@ -1,25 +1,47 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -import { UiUtilWrapper } from '../util/uiUtil'; import { Action, CustomActions } from './customAction'; -import { createTempSubdirectory, runCommandAndWriteOutput } from '../util/commonUtil'; +import { CommandResult } from '../util/commonUtil'; import { logger } from '../util/logger'; -export interface ChatAction { - name: string; - type: string[]; + +// extend Action +export class CommandRunAction implements Action { + name: string; description: string; + type: string[]; action: string; - handler: (codeBlock: { [key: string]: string }) => Promise; -} + handler: string[]; + args: { "name": string, "description": string, "type": string, "as"?: string, "from": string }[]; + + constructor() { + this.name = 'command_run'; + this.description = 'run command'; + this.type = ['command']; + this.action = 'command_run'; + this.handler = []; + this.args = [ + {"name": "content", "description": "command json to run", "type": "string", "from": "content.content"}, + ]; + } + + async handlerAction(args: {[key: string]: any}): Promise { + try { + const commandData = JSON.parse(args.content); + const result = await ActionManager.getInstance().applyCommandAction(commandData.command, commandData.args); + return result; + } catch (error) { + logger.channel()?.error('Failed to parse code file content: ' + error); + logger.channel()?.show(); + return {exitCode: -1, stdout: '', stderr: `Failed to parse code file content: ${error}`}; + } + } +}; export default class ActionManager { private static instance: ActionManager; - private actions: ChatAction[] = []; + private actions: Action[] = []; private constructor() { } @@ -27,23 +49,106 @@ export default class ActionManager { if (!ActionManager.instance) { ActionManager.instance = new ActionManager(); } + + ActionManager.instance.registerAction(new CommandRunAction()); return ActionManager.instance; } - public registerAction(action: ChatAction): void { + public registerAction(action: Action): void { this.actions.push(action); } - public getActionList(): ChatAction[] { + public getActionList(): Action[] { return this.actions; } - public async applyAction(actionName: string, codeBlock: { [key: string]: string }): Promise { + public async applyAction(actionName: string, content: { "command": string, content: string, fileName: string }): Promise { const action = this.actions.find(action => action.name.trim() === actionName.trim()); - if (action) { - logger.channel()?.info(`Applying action: ${actionName}`); - await action.handler(codeBlock); + if (!action) { + logger.channel()?.info(`Action not found: ${actionName}`); + return {exitCode: -1, stdout: '', stderr: `${actionName} not found in action list: ${this.actions.map(action => action.name)}`}; } + + logger.channel()?.info(`Apply action: ${actionName}`); + + // action.args define what args should be passed to handler + // for example: + // action.args = [ + // {"name": "arg1", "description": "arg1 description", "type": "string", "from": "content.fileName"}, + // {"name": "arg2", "description": "arg2 description", "type": "string", "from": "content.content.v1"}] + // then: + // arg1 = content.fileName + // arg2 = content.content.v1 + // before use content.content.v1, we should parse content.content first as json + + if (action.args === undefined || action.args.length === 0) { + // every action should have args, if not, then it is invalid + logger.channel()?.error(`Action ${actionName} has no args`); + logger.channel()?.show(); + return {exitCode: -1, stdout: '', stderr: `Action ${actionName} has no args`}; + } + + // construct args for handler + let args: {[key: string]: any} = {}; + + // check whether action.args has x.x.x like from value + let hasDotDotFrom = false; + for (const arg of action.args) { + if (arg.from !== undefined) { + // if arg.from has two or more dot, then it is x.x.x + if (arg.from.split('.').length >= 3) { + hasDotDotFrom = true; + break; + } + } + } + + // if hasDotDotFrom is true, then parse content as json + if (hasDotDotFrom) { + try { + content.content = JSON.parse(content.content); + } catch (error) { + logger.channel()?.info(`Parse content as json failed: ${error}`); + return {exitCode: -1, stdout: '', stderr: `Parse content as json failed: ${error}`}; + } + } + + + + for (const arg of action.args) { + let argValue = ''; + if (arg.from !== undefined) { + // visit arg.from, it is string + let argFromValue: any = content; + const argFrom = arg.from.split('.'); + // first item of argFrom is content, so skip it + for (const argFromItem of argFrom.slice(1)) { + argFromValue = argFromValue[argFromItem]; + } + // if argFromValue is undefined, then it is invalid + if (argFromValue === undefined) { + logger.channel()?.error(`Action ${actionName} arg ${arg.name} from ${arg.from} is undefined`); + logger.channel()?.show(); + return {exitCode: -1, stdout: '', stderr: `Action ${actionName} arg ${arg.name} from ${arg.from} is undefined`}; + } + argValue = argFromValue; + } + args[arg.name] = argValue; + } + + return await action.handlerAction(args); + } + + public async applyCommandAction(command: string, args: {[key: string]: any}) : Promise { + const action = this.actions.find(action => action.name.trim() === command.trim()); + if (!action) { + logger.channel()?.info(`Action not found: ${command}`); + return {exitCode: -1, stdout: '', stderr: `${command} not found in action list: ${this.actions.map(action => action.name)}`}; + } + + logger.channel()?.info(`Apply command action: ${command}`); + + return await action.handlerAction(args); } public loadCustomActions(workflowsDir: string): void { @@ -51,40 +156,7 @@ export default class ActionManager { customActionsInstance.parseActions(workflowsDir); for (const customAction of customActionsInstance.getActions()) { - const chatAction: ChatAction = { - name: customAction.name, - type: customAction.type, - description: customAction.description, - action: customAction.action, - handler: async (codeBlock: { [key: string]: string }) => { - // Implement the handler logic for the custom action - const tempDir = await createTempSubdirectory('devchat/context'); - const tempFile = path.join(tempDir, 'apply.json'); - - const contextMap = { - 'codeBlock': codeBlock, - 'workspaceDir': UiUtilWrapper.workspaceFoldersFirstPath(), - 'activeFile': UiUtilWrapper.activeFilePath(), - 'selectRang': UiUtilWrapper.selectRange(), - 'secectText': UiUtilWrapper.selectText(), - }; - - // Save contextMap to temp file - await UiUtilWrapper.writeFile(tempFile, JSON.stringify(contextMap)); - // replace ${contextFile} with tempFile for arg in handler - const handlerArgs = customAction.handler.map(arg => arg.replace('${contextFile}', tempFile)); - // run handler - const result = await runCommandAndWriteOutput(handlerArgs[0], handlerArgs.slice(1), undefined); - logger.channel()?.info(`Applied action: ${customAction.name}`); - logger.channel()?.info(` exit code:`, result.exitCode) - logger.channel()?.info(` stdout:`, result.stdout); - logger.channel()?.info(` stderr:`, result.stderr); - - // remove temp file - fs.unlinkSync(tempFile); - }, - }; - + const chatAction: Action = customAction; this.registerAction(chatAction); } } diff --git a/src/action/customAction.ts b/src/action/customAction.ts index 390dd02..e0a3aa9 100644 --- a/src/action/customAction.ts +++ b/src/action/customAction.ts @@ -1,63 +1,157 @@ import fs from 'fs'; import path from 'path'; import { logger } from '../util/logger'; +import { CommandResult, createTempSubdirectory, runCommandAndWriteOutput, runCommandStringAndWriteOutput } from '../util/commonUtil'; +import { UiUtilWrapper } from '../util/uiUtil'; export interface Action { - name: string; - description: string; - type: string[]; - action: string; - handler: string[]; + name: string; + description: string; + type: string[]; + action: string; + handler: string[]; + args: { "name": string, "description": string, "type": string, "as"?: string, "from": string }[]; + + handlerAction: (args: { [key: string]: string }) => Promise; } export class CustomActions { - private static instance: CustomActions | null = null; - private actions: Action[] = []; + private static instance: CustomActions | null = null; + private actions: Action[] = []; - private constructor() { - } + private constructor() { + } - public static getInstance(): CustomActions { - if (!CustomActions.instance) { - CustomActions.instance = new CustomActions(); - } - return CustomActions.instance; - } + public static getInstance(): CustomActions { + if (!CustomActions.instance) { + CustomActions.instance = new CustomActions(); + } + return CustomActions.instance; + } - public parseActions(workflowsDir: string): void { - this.actions = []; + public actionInstruction(): string { + let instruction = 'As an AI bot, you replay user with "command" block, don\'t replay any other words.\n' + + '"command" block is like this:\n' + + '``` command\n' + + '{\n' + + ' "command": "xxx",\n' + + ' "args" {\n' + + ' "var name": "xxx"\n' + + ' }\n' + + '}\n' + + '```\n' + + 'You can split task into small sub tasks, after each command I will give you the result of command executed. so, the next command can depend pre command\'s output.\n' + + '\n' + + 'Supported commands are:\n'; + let index = 1; + for (const action of this.actions) { + instruction += String(index) + ". " + this.getActionInstruction(action.name) + "\n"; + index += 1; + } - try { - const extensionDirs = fs.readdirSync(workflowsDir, { withFileTypes: true }) - .filter(dirent => dirent.isDirectory()) - .map(dirent => dirent.name); + instruction += 'Restriction for output:\n' + + '1. Only reponse "command" block.\n' + + '2. Don\'t include any other text exclude command.\n' + + '3. Only supported extension commands can be used to complete the response.\n' + + '4. When update file, old_content must include at least three lines.'; - for (const extensionDir of extensionDirs) { - const actionDir = path.join(workflowsDir, extensionDir, 'action'); - if (fs.existsSync(actionDir)) { - const actionSubDirs = fs.readdirSync(actionDir, { withFileTypes: true }) - .filter(dirent => dirent.isDirectory()) - .map(dirent => dirent.name); + return instruction; + } - for (const actionSubDir of actionSubDirs) { - const settingsPath = path.join(actionDir, actionSubDir, '_setting_.json'); - if (fs.existsSync(settingsPath)) { - const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); - const action: Action = { - name: settings.name, - description: settings.description, - type: settings.type, - action: settings.action, - handler: settings.handler.map((handler: string) => handler.replace('${CurDir}', path.join(actionDir, actionSubDir))) - }; - this.actions.push(action); - } - } - } - } - } catch (error) { - // Show error message - logger.channel()?.error(`Failed to parse actions due to error: ${error}`); + public saveActionInstructionFile(tarFile: string): void { + try { + fs.writeFileSync(tarFile, this.actionInstruction()); + } catch (error) { + logger.channel()?.error(`Failed to save action instruction file: ${error}`); + logger.channel()?.show(); + } + } + + public parseActions(workflowsDir: string): void { + this.actions = []; + + try { + const extensionDirs = fs.readdirSync(workflowsDir, { withFileTypes: true }) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name); + + for (const extensionDir of extensionDirs) { + const actionDir = path.join(workflowsDir, extensionDir, 'action'); + if (fs.existsSync(actionDir)) { + const actionSubDirs = fs.readdirSync(actionDir, { withFileTypes: true }) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name); + + for (const actionSubDir of actionSubDirs) { + const settingsPath = path.join(actionDir, actionSubDir, '_setting_.json'); + if (fs.existsSync(settingsPath)) { + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + const action: Action = { + name: settings.name, + description: settings.description, + type: settings.type, + action: settings.action, + args: settings.args, + handler: settings.handler.map((handler: string) => handler.replace('${CurDir}', path.join(actionDir, actionSubDir))), + + handlerAction: async (args: { [key: string]: string }) => { + // Implement the handler logic for the custom action + const tempDir = await createTempSubdirectory('devchat/context'); + const tempFile = path.join(tempDir, 'apply.json'); + + const contextMap = { + 'codeBlock': args, + 'workspaceDir': UiUtilWrapper.workspaceFoldersFirstPath(), + 'activeFile': UiUtilWrapper.activeFilePath(), + 'selectRang': UiUtilWrapper.selectRange(), + 'secectText': UiUtilWrapper.selectText(), + }; + + // Save contextMap to temp file + await UiUtilWrapper.writeFile(tempFile, JSON.stringify(contextMap)); + // replace ${contextFile} with tempFile for arg in handler + let handlerArgs = action.handler.map(arg => arg.replace('${contextFile}', tempFile)); + if (args !== undefined) { + // visit args, it is {[key: string]: string} + for (const arg in args) { + let argValue = args[arg]; + const argDefine = action.args.find(v => v.name === arg); + if (argDefine !== undefined && argDefine.as !== undefined) { + // save argValue to temp file + const tempFile = path.join(tempDir, argDefine.as); + await UiUtilWrapper.writeFile(tempFile, argValue); + argValue = tempFile; + } + // replace ${arg} with commandObj.args[arg] + handlerArgs = handlerArgs.map(v => { if (v === '${' + arg + '}') { return argValue; } else { return v; } }); + } + } + handlerArgs = handlerArgs.flat(); + + // run handler + let result: CommandResult = { exitCode: -1, stdout: '', stderr: '' }; + if (handlerArgs.length === 1) { + result = await runCommandStringAndWriteOutput(handlerArgs[0], undefined); + } else if (handlerArgs.length > 1) { + result = await runCommandAndWriteOutput(handlerArgs[0], handlerArgs.slice(1), undefined); + } + logger.channel()?.info(`Apply action: ${action.name} exit code:`, result.exitCode); + logger.channel()?.info(`stdout:`, result.stdout); + logger.channel()?.info(`stderr:`, result.stderr); + + // remove temp file + fs.unlinkSync(tempFile); + return result; + }, + }; + this.actions.push(action); + } + } + } + } + } catch (error) { + // Show error message + logger.channel()?.error(`Failed to parse actions: ${error}`); logger.channel()?.show(); } } @@ -65,4 +159,23 @@ export class CustomActions { public getActions(): Action[] { return this.actions; } + + // generate instruction for action + public getActionInstruction(actionName: string): string { + const action = this.actions.find(action => action.name.trim() === actionName.trim()); + if (!action) { + return ''; + } + + let instruction = `${action.name}: ${action.description}\n`; + // if args is not undefined and has values, then visit args + if (action.args !== undefined && action.args.length > 0) { + instruction += `Args:\n`; + for (const arg of action.args) { + instruction += ` name: ${arg.name} type: (${arg.type}) description: ${arg.description}\n`; + } + } + + return instruction; + } } \ No newline at end of file diff --git a/test/action/actionManager.test.ts b/test/action/actionManager.test.ts new file mode 100644 index 0000000..3ad8d6d --- /dev/null +++ b/test/action/actionManager.test.ts @@ -0,0 +1,69 @@ +import { expect } from 'chai'; +import 'mocha'; +import ActionManager from '../../src/action/actionManager'; +import { Action } from '../../src/action/customAction'; + +describe('ActionManager', () => { + const testAction: Action = { + name: 'testAction', + description: 'Test action for unit testing', + type: ['test'], + action: 'test', + handler: [], + args: [], + handlerAction: async () => ({ exitCode: 0, stdout: '', stderr: '' }), + }; + + it('should register and retrieve actions', () => { + const actionManager = ActionManager.getInstance(); + actionManager.registerAction(testAction); + const actionList = actionManager.getActionList(); + expect(actionList).to.contain(testAction); + }); + + it('should return an error for action with empty args', async () => { + const actionManager = ActionManager.getInstance(); + const testActionWithEmptyArgs: Action = { + ...testAction, + name: 'testActionWithEmptyArgs', + args: [], + }; + actionManager.registerAction(testActionWithEmptyArgs); + const actionName = 'testActionWithEmptyArgs'; + const content = { + command: 'test', + content: 'test content', + fileName: 'test.txt', + }; + const result = await actionManager.applyAction(actionName, content); + expect(result.exitCode).to.equal(-1); + expect(result.stdout).to.equal(''); + expect(result.stderr).to.equal('Action testActionWithEmptyArgs has no args'); + }); + it('should apply action with valid args correctly', async () => { + const actionManager = ActionManager.getInstance(); + const testActionWithArgs: Action = { + ...testAction, + name: 'testActionWithArgs', + args: [ + { + name: 'arg1', + description: 'arg1 description', + type: 'string', + from: 'content.fileName', + }, + ], + }; + actionManager.registerAction(testActionWithArgs); + const actionName = 'testActionWithArgs'; + const content = { + command: 'test', + content: 'test content', + fileName: 'test.txt', + }; + const result = await actionManager.applyAction(actionName, content); + expect(result.exitCode).to.equal(0); + expect(result.stdout).to.equal(''); + expect(result.stderr).to.equal(''); + }); +}); \ No newline at end of file diff --git a/test/action/customAction.test.ts b/test/action/customAction.test.ts new file mode 100644 index 0000000..9857e26 --- /dev/null +++ b/test/action/customAction.test.ts @@ -0,0 +1,30 @@ +import { expect } from 'chai'; +import 'mocha'; +import { CustomActions } from '../../src/action/customAction'; + +describe('CustomActions', () => { + const customActions = CustomActions.getInstance(); + + it('should return an empty action list', () => { + const actions = customActions.getActions(); + expect(actions).to.deep.equal([]); + }); + + it('should return a non-empty action instruction with actions', () => { + // Add a sample action to the customActions instance + customActions.getActions().push({ + name: 'sampleAction', + description: 'A sample action for testing', + type: ['test'], + action: 'sample', + handler: [], + args: [], + handlerAction: async (args: { [key: string]: string }) => { + return { exitCode: 0, stdout: '', stderr: '' }; + }, + }); + + const instruction = customActions.actionInstruction(); + expect(instruction).to.include('sampleAction: A sample action for testing'); + }); +}); \ No newline at end of file