diff --git a/README.md b/README.md index 6ee89555..f8500d18 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ Avatar URL: 'https://app.box.com/api/avatar/large/77777' # Command Topics +* [`box ai`](docs/ai.md) - Sends an AI request to supported LLMs and returns an answer * [`box autocomplete`](docs/autocomplete.md) - Display autocomplete installation instructions * [`box collaboration-allowlist`](docs/collaboration-allowlist.md) - List collaboration allowlist entries * [`box collaborations`](docs/collaborations.md) - Manage collaborations diff --git a/docs/ai.md b/docs/ai.md new file mode 100644 index 00000000..0d31bc11 --- /dev/null +++ b/docs/ai.md @@ -0,0 +1,78 @@ +`box ai` +======== + +Sends an AI request to supported LLMs and returns an answer + +* [`box ai:ask`](#box-aiask) +* [`box ai:text-gen`](#box-aitext-gen) + +## `box ai:ask` + +Sends an AI request to supported LLMs and returns an answer + +``` +USAGE + $ box ai:ask + +OPTIONS + -h, --help Show CLI help + -q, --quiet Suppress any non-error output to stderr + -s, --save Save report to default reports folder on disk + -t, --token=token Provide a token to perform this call + -v, --verbose Show verbose output, which can be helpful for debugging + -y, --yes Automatically respond yes to all confirmation prompts + --as-user=as-user Provide an ID for a user + --bulk-file-path=bulk-file-path File path to bulk .csv or .json objects + --csv Output formatted CSV + --fields=fields Comma separated list of fields to show + --items=items (required) The items for the AI request + --json Output formatted JSON + --no-color Turn off colors for logging + --prompt=prompt (required) The prompt for the AI request + --save-to-file-path=save-to-file-path Override default file path to save report + +EXAMPLE + box ai:ask --items=id=12345,type=file --prompt "What is the status of this document?" +``` + +_See code: [src/commands/ai/ask.js](https://github.com/box/boxcli/blob/v3.14.1/src/commands/ai/ask.js)_ + +## `box ai:text-gen` + +Sends an AI request to supported LLMs and returns an answer specifically focused on the creation of new text. + +``` +USAGE + $ box ai:text-gen + +OPTIONS + -h, --help Show CLI help + -q, --quiet Suppress any non-error output to stderr + -s, --save Save report to default reports folder on disk + -t, --token=token Provide a token to perform this call + -v, --verbose Show verbose output, which can be helpful for debugging + -y, --yes Automatically respond yes to all confirmation prompts + --as-user=as-user Provide an ID for a user + --bulk-file-path=bulk-file-path File path to bulk .csv or .json objects + --csv Output formatted CSV + --dialogue-history=dialogue-history The history of prompts and answers previously passed to the LLM. + --fields=fields Comma separated list of fields to show + + --items=items (required) The items to be processed by the LLM, often files. The array can + include exactly one element. + + --json Output formatted JSON + + --no-color Turn off colors for logging + + --prompt=prompt (required) The prompt for the AI request + + --save-to-file-path=save-to-file-path Override default file path to save report + +EXAMPLE + box ai:text-gen --dialogue-history=prompt="What is the status of this document?",answer="It is in + review",created-at="2024-07-09T11:29:46.835Z" --items=id=12345,type=file --prompt="What is the status of this + document?" +``` + +_See code: [src/commands/ai/text-gen.js](https://github.com/box/boxcli/blob/v3.14.1/src/commands/ai/text-gen.js)_ diff --git a/package.json b/package.json index 9de30de2..e7bc4622 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "@oclif/plugin-help": "^2.2.1", "@oclif/plugin-not-found": "^1.2.0", "archiver": "^3.0.0", - "box-node-sdk": "^3.5.0", + "box-node-sdk": "^3.7.0", "chalk": "^2.4.1", "cli-progress": "^2.1.0", "csv": "^6.3.3", diff --git a/src/commands/ai/ask.js b/src/commands/ai/ask.js new file mode 100644 index 00000000..38e4bfd1 --- /dev/null +++ b/src/commands/ai/ask.js @@ -0,0 +1,66 @@ +'use strict'; + +const BoxCommand = require('../../box-command'); +const { flags } = require('@oclif/command'); +const utils = require('../../util'); + +class AiAskCommand extends BoxCommand { + async run() { + const { flags, args } = this.parse(AiAskCommand); + let options = {}; + options.mode = flags.items.length > 1 ? 'multi_item_qa' : 'single_item_qa'; + + if (flags.prompt) { + options.prompt = flags.prompt; + } + if (flags.items) { + options.items = flags.items; + } + + let answer = await this.client.ai.ask({ + prompt: options.prompt, + items: options.items, + mode: options.mode + }); + await this.output(answer); + } +} + +AiAskCommand.description = 'Sends an AI request to supported LLMs and returns an answer'; +AiAskCommand.examples = ['box ai:ask --items=id=12345,type=file --prompt "What is the status of this document?"']; +AiAskCommand._endpoint = 'post_ai_ask'; + +AiAskCommand.flags = { + ...BoxCommand.flags, + prompt: flags.string({ + required: true, + description: 'The prompt for the AI request', + }), + items: flags.string({ + required: true, + description: 'The items for the AI request', + multiple: true, + parse(input) { + const item = { + id: '', + type: 'file' + }; + const obj = utils.parseStringToObject(input, ['id', 'type', 'content']); + for (const key in obj) { + if (key === 'id') { + item.id = obj[key]; + } else if (key === 'type') { + item.type = obj[key]; + } else if (key === 'content') { + item.content = obj[key]; + } else { + throw new Error(`Invalid item key ${key}`); + } + } + + return item; + } + }), +}; + +module.exports = AiAskCommand; diff --git a/src/commands/ai/text-gen.js b/src/commands/ai/text-gen.js new file mode 100644 index 00000000..c8e97ede --- /dev/null +++ b/src/commands/ai/text-gen.js @@ -0,0 +1,86 @@ +'use strict'; + +const BoxCommand = require('../../box-command'); +const { flags } = require('@oclif/command'); +const utils = require('../../util'); + +class AiTextGenCommand extends BoxCommand { + async run() { + const { flags, args } = this.parse(AiTextGenCommand); + let options = {}; + + if (flags['dialogue-history']) { + options.dialogueHistory = flags['dialogue-history']; + } + options.prompt = flags.prompt; + options.items = flags.items; + let answer = await this.client.ai.textGen({ + prompt: options.prompt, + items: options.items, + dialogue_history: options.dialogueHistory, + }); + + await this.output(answer); + } +} + +AiTextGenCommand.description = 'Sends an AI request to supported LLMs and returns an answer specifically focused on the creation of new text.'; +AiTextGenCommand.examples = ['box ai:text-gen --dialogue-history=prompt="What is the status of this document?",answer="It is in review",created-at="2024-07-09T11:29:46.835Z" --items=id=12345,type=file --prompt="What is the status of this document?"']; +AiTextGenCommand._endpoint = 'post_ai_text_gen'; + +AiTextGenCommand.flags = { + ...BoxCommand.flags, + + 'dialogue-history': flags.string({ + required: false, + description: 'The history of prompts and answers previously passed to the LLM.', + multiple: true, + parse(input) { + const record = {}; + const obj = utils.parseStringToObject(input, ['prompt', 'answer', 'created-at']); + for (const key in obj) { + if (key === 'prompt') { + record.prompt = obj[key]; + } else if (key === 'answer') { + record.answer = obj[key]; + } else if (key === 'created-at') { + record.created_at = BoxCommand.normalizeDateString(obj[key]); + } else { + throw new Error(`Invalid record key ${key}`); + } + } + + return record; + }, + }), + items: flags.string({ + required: true, + description: 'The items to be processed by the LLM, often files. The array can include exactly one element.', + multiple: true, + parse(input) { + const item = { + id: '', + type: 'file' + }; + const obj = utils.parseStringToObject(input, ['id', 'type', 'content']); + for (const key in obj) { + if (key === 'id') { + item.id = obj[key]; + } else if (key === 'type') { + item.type = obj[key]; + } else if (key === 'content') { + item.content = obj[key]; + } else { + throw new Error(`Invalid item key ${key}`); + } + } + return item; + } + }), + prompt: flags.string({ + required: true, + description: 'The prompt for the AI request', + }) +}; + +module.exports = AiTextGenCommand; diff --git a/src/util.js b/src/util.js index 2f0a6706..0e5bcad0 100644 --- a/src/util.js +++ b/src/util.js @@ -184,6 +184,50 @@ function parseMetadataString(input) { return op; } +/** + * Parse a string into a JSON object + * + * @param {string} inputString The string to parse + * @param {string[]} keys The keys to parse from the string + * @returns {Object} The parsed object + */ +function parseStringToObject(inputString, keys) { + const result = {}; + + while (inputString.length > 0) { + inputString = inputString.trim(); + let parsedKey = inputString.split('=')[0]; + inputString = inputString.substring(inputString.indexOf('=') + 1); + + // Find the next key or the end of the string + let nextKeyIndex = inputString.length; + for (let key of keys) { + let keyIndex = inputString.indexOf(key); + if (keyIndex !== -1 && keyIndex < nextKeyIndex) { + nextKeyIndex = keyIndex; + } + } + + let parsedValue = inputString.substring(0, nextKeyIndex).trim(); + if (parsedValue.endsWith(',') && nextKeyIndex !== inputString.length) { + parsedValue = parsedValue.substring(0, parsedValue.length - 1); + } + if (parsedValue.startsWith('"') && parsedValue.endsWith('"')) { + parsedValue = parsedValue.substring(1, parsedValue.length - 1); + } + + if (!keys.includes(parsedKey)) { + throw new BoxCLIError( + `Invalid key '${parsedKey}'. Valid keys are ${keys.join(', ')}` + ); + } + + result[parsedKey] = parsedValue; + inputString = inputString.substring(nextKeyIndex); + } + return result; +} + /** * Check if directory exists and creates it if shouldCreate flag was passed. * @@ -343,6 +387,7 @@ module.exports = { parseMetadataOp(value) { return parseMetadataString(value); }, + parseStringToObject, checkDir, readFileAsync, writeFileAsync, diff --git a/test/commands/ai.test.js b/test/commands/ai.test.js new file mode 100644 index 00000000..7dcca14c --- /dev/null +++ b/test/commands/ai.test.js @@ -0,0 +1,140 @@ +'use strict'; + +const { test } = require('@oclif/test'); +const assert = require('chai').assert; +const { TEST_API_ROOT, getFixture } = require('../helpers/test-helper'); + +describe('AI', () => { + describe('ai:ask', () => { + const expectedRequestBody = { + items: [ + { + id: '12345', + type: 'file', + content: 'one,two,three', + }, + ], + mode: 'single_item_qa', + prompt: 'What is the status of this document?', + }; + const expectedResponseBody = { + answer: + 'This document is currently in progress and being actively worked on.', + created_at: '2024-07-09T11:29:46.835Z', + completion_reason: 'done', + }; + const fixture = getFixture('ai/post_ai_ask_response'); + const yamlFixture = getFixture('ai/post_ai_ask_response_yaml.txt'); + + test + .nock(TEST_API_ROOT, (api) => + api + .post('/2.0/ai/ask', expectedRequestBody) + .reply(200, expectedResponseBody) + ) + .stdout() + .command([ + 'ai:ask', + '--items=content=one,two,three,id=12345,type=file', + '--prompt', + 'What is the status of this document?', + '--json', + '--token=test', + ]) + .it( + 'should send the correct request and output the response (JSON Output)', + (ctx) => { + assert.equal(ctx.stdout, fixture); + } + ); + + test + .nock(TEST_API_ROOT, (api) => + api + .post('/2.0/ai/ask', expectedRequestBody) + .reply(200, expectedResponseBody) + ) + .stdout() + .command([ + 'ai:ask', + '--items=content=one,two,three,id=12345,type=file', + '--prompt', + 'What is the status of this document?', + '--token=test', + ]) + .it( + 'should send the correct request and output the response (YAML Output)', + (ctx) => { + assert.equal(ctx.stdout, yamlFixture); + } + ); + }); + + describe('ai:text-gen', () => { + const expectedRequestBody = { + prompt: 'What is the status of this document?', + items: [{ id: '12345', type: 'file', content: 'one,two,three' }], + dialogue_history: [ + { + prompt: 'What is the status of this document, signatures?', + answer: 'It is in review, waiting for signatures.', + created_at: '2024-07-09T11:29:46+00:00' + }, + ], + }; + const expectedResponseBody = { + answer: 'The document is currently in review and awaiting approval.', + created_at: '2024-07-09T11:29:46.835Z', + completion_reason: 'done', + }; + const fixture = getFixture('ai/post_ai_text_gen_response'); + const yamlFixture = getFixture('ai/post_ai_text_gen_response_yaml.txt'); + + test + .nock(TEST_API_ROOT, (api) => + api + .post('/2.0/ai/text_gen', expectedRequestBody) + .reply(200, expectedResponseBody) + ) + .stdout() + .command([ + 'ai:text-gen', + '--dialogue-history', + 'prompt=What is the status of this document, signatures?,answer=It is in review, waiting for signatures.,created-at=2024-07-09T11:29:46.835Z', + '--items=content=one,two,three,id=12345,type=file', + '--prompt', + 'What is the status of this document?', + '--json', + '--token=test', + ]) + .it( + 'should send the correct request and output the response (JSON Output)', + (ctx) => { + assert.equal(ctx.stdout, fixture); + } + ); + + test + .nock(TEST_API_ROOT, (api) => + api + .post('/2.0/ai/text_gen', expectedRequestBody) + .reply(200, expectedResponseBody) + ) + .stdout() + .command([ + 'ai:text-gen', + '--dialogue-history', + 'prompt=What is the status of this document, signatures?,answer=It is in review, waiting for signatures.,created-at=2024-07-09T11:29:46.835Z', + '--items=content=one,two,three,id=12345,type=file', + '--prompt', + 'What is the status of this document?', + '--token=test', + ]) + .it( + 'should send the correct request and output the response (YAML Output)', + (ctx) => { + assert.equal(ctx.stdout, yamlFixture); + } + ); + }); +}); diff --git a/test/fixtures/ai/post_ai_ask_response.json b/test/fixtures/ai/post_ai_ask_response.json new file mode 100644 index 00000000..72dee510 --- /dev/null +++ b/test/fixtures/ai/post_ai_ask_response.json @@ -0,0 +1,5 @@ +{ + "answer": "This document is currently in progress and being actively worked on.", + "created_at": "2024-07-09T11:29:46.835Z", + "completion_reason": "done" +} diff --git a/test/fixtures/ai/post_ai_ask_response_yaml.txt b/test/fixtures/ai/post_ai_ask_response_yaml.txt new file mode 100644 index 00000000..b8f06251 --- /dev/null +++ b/test/fixtures/ai/post_ai_ask_response_yaml.txt @@ -0,0 +1,3 @@ +Answer: This document is currently in progress and being actively worked on. +Created At: '2024-07-09T11:29:46.835Z' +Completion Reason: done diff --git a/test/fixtures/ai/post_ai_text_gen_response.json b/test/fixtures/ai/post_ai_text_gen_response.json new file mode 100644 index 00000000..7c2c2a3f --- /dev/null +++ b/test/fixtures/ai/post_ai_text_gen_response.json @@ -0,0 +1,5 @@ +{ + "answer": "The document is currently in review and awaiting approval.", + "created_at": "2024-07-09T11:29:46.835Z", + "completion_reason": "done" +} diff --git a/test/fixtures/ai/post_ai_text_gen_response_yaml.txt b/test/fixtures/ai/post_ai_text_gen_response_yaml.txt new file mode 100644 index 00000000..9a90e4f3 --- /dev/null +++ b/test/fixtures/ai/post_ai_text_gen_response_yaml.txt @@ -0,0 +1,3 @@ +Answer: The document is currently in review and awaiting approval. +Created At: '2024-07-09T11:29:46.835Z' +Completion Reason: done