diff --git a/packages/cali/src/cli.ts b/packages/cali/src/cli.ts index 840c6dd..6a1b6a7 100755 --- a/packages/cali/src/cli.ts +++ b/packages/cali/src/cli.ts @@ -3,24 +3,16 @@ import 'dotenv/config' import { createOpenAI } from '@ai-sdk/openai' -import { confirm, outro, select, spinner, text } from '@clack/prompts' +import { outro, spinner, text } from '@clack/prompts' import { CoreMessage, generateText } from 'ai' import * as tools from 'cali-tools' import chalk from 'chalk' import dedent from 'dedent' import { retro } from 'gradient-string' -import { z } from 'zod' import { reactNativePrompt } from './prompt.js' import { getApiKey } from './utils.js' -const MessageSchema = z.union([ - z.object({ type: z.literal('select'), content: z.string(), options: z.array(z.string()) }), - z.object({ type: z.literal('question'), content: z.string() }), - z.object({ type: z.literal('confirmation'), content: z.string() }), - z.object({ type: z.literal('end') }), -]) - console.clear() process.on('uncaughtException', (error) => { @@ -52,7 +44,7 @@ console.log() const AI_MODEL = process.env.AI_MODEL || 'gpt-4o' const openai = createOpenAI({ - apiKey: await getApiKey('OpenAI', 'OPENAI_API_K2EY'), + apiKey: await getApiKey('OpenAI', 'OPENAI_API_KEY'), }) async function startSession(): Promise { @@ -87,99 +79,89 @@ const s = spinner() while (true) { s.start(chalk.gray('Thinking...')) - const response = await generateText({ - model: openai(AI_MODEL), - system: reactNativePrompt, - tools, - maxSteps: 10, - messages, - onStepStart(toolCalls) { - if (toolCalls.length > 0) { - const message = `Executing: ${chalk.gray(toolCalls.map((toolCall) => toolCall.toolName).join(', '))}` - - let spinner = s.message - for (const toolCall of toolCalls) { - /** - * Certain tools call external helpers outside of our control that pipe output to our stdout. - * In such case, we stop the spinner to avoid glitches and display the output instead. - */ - if ( - [ - 'buildAndroidApp', - 'launchAndroidAppOnDevice', - 'installNpmPackage', - 'uninstallNpmPackage', - ].includes(toolCall.toolName) - ) { - spinner = s.stop - break + try { + const response = await generateText({ + model: openai(AI_MODEL), + system: reactNativePrompt, + tools, + maxSteps: 10, + messages, + onStepStart(toolCalls) { + if (toolCalls.length > 0) { + const message = `Executing: ${chalk.gray(toolCalls.map((toolCall) => toolCall.toolName).join(', '))}` + + let spinner = s.message + for (const toolCall of toolCalls) { + /** + * Certain tools call external helpers outside of our control that pipe output to our stdout. + * In such case, we stop the spinner to avoid glitches and display the output instead. + */ + if ( + [ + 'buildAndroidApp', + 'launchAndroidAppOnDevice', + 'installNpmPackage', + 'uninstallNpmPackage', + 'askQuestion', + 'confirmOperation', + 'presentOptions', + ].includes(toolCall.toolName) + ) { + spinner = s.stop + break + } } - } - - spinner(message) - } - }, - }) - const toolCalls = response.steps.flatMap((step) => - step.toolCalls.map((toolCall) => toolCall.toolName) - ) + spinner(message) + } + }, + }) - if (toolCalls.length > 0) { - s.stop(`Tools called: ${chalk.gray(toolCalls.join(', '))}`) - } else { - s.stop(chalk.gray('Done.')) - } + const toolCalls = response.steps.flatMap((step) => + step.toolCalls.map((toolCall) => toolCall.toolName) + ) - for (const step of response.steps) { - if (step.text.length > 0) { - messages.push({ role: 'assistant', content: step.text }) + if (toolCalls.length > 0) { + s.stop(`Tools called: ${chalk.gray(toolCalls.join(', '))}`) + } else { + s.stop(chalk.gray('Done.')) } - if (step.toolCalls.length > 0) { - messages.push({ role: 'assistant', content: step.toolCalls }) - } - if (step.toolResults.length > 0) { - // tbd: fix this upstream. for some reason, the tool does not include the type, - // against the spec. - for (const toolResult of step.toolResults) { - if (!toolResult.type) { - toolResult.type = 'tool-result' + + for (const step of response.steps) { + if (step.text.length > 0) { + messages.push({ role: 'assistant', content: step.text }) + } + if (step.toolCalls.length > 0) { + messages.push({ role: 'assistant', content: step.toolCalls }) + } + if (step.toolResults.length > 0) { + // tbd: fix this upstream. for some reason, the tool does not include the type, + // against the spec. + for (const toolResult of step.toolResults) { + if (!toolResult.type) { + toolResult.type = 'tool-result' + } } + messages.push({ role: 'tool', content: step.toolResults }) } - messages.push({ role: 'tool', content: step.toolResults }) } - } - // tbd: handle parsing errors - const data = MessageSchema.parse(JSON.parse(response.text)) - - const answer = await (() => { - switch (data.type) { - case 'select': - return select({ - message: data.content, - options: data.options.map((option) => ({ value: option, label: option })), - }) - case 'question': - return text({ - message: data.content, - validate: (value) => (value.length > 0 ? undefined : 'Please provide a valid answer.'), - }) - case 'confirmation': { - return confirm({ message: data.content }).then((answer) => { - return answer ? 'yes' : 'no' - }) - } - } - })() + const userResponse = await text({ + message: response.text, + validate: (value) => (value.length > 0 ? undefined : 'Please provide a valid answer.'), + }) - if (typeof answer !== 'string') { - messages = await startSession() - continue + if (typeof userResponse === 'string') { + messages.push({ role: 'user', content: userResponse.toString() }) + } else { + messages = await startSession() + } + } catch (e: unknown) { + if (e instanceof Error && e.message === 'UserCancelledOperation') { + messages = await startSession() + continue + } else { + throw e + } } - - messages.push({ - role: 'user', - content: answer as string, - }) } diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 183d3da..9dc5d2b 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -4,3 +4,4 @@ export * from './fs.js' export * from './git.js' export * from './npm.js' export * from './react-native.js' +export * from './user-interaction.js' diff --git a/packages/tools/src/user-interaction.ts b/packages/tools/src/user-interaction.ts new file mode 100644 index 0000000..701fc0c --- /dev/null +++ b/packages/tools/src/user-interaction.ts @@ -0,0 +1,60 @@ +import { confirm, isCancel, select, text } from '@clack/prompts' +import { tool } from 'ai' +import { z } from 'zod' + +export const askQuestion = tool({ + description: 'Ask user a question', + parameters: z.object({ + question: z.string().describe('What do you want to ask'), + }), + execute: async ({ question }) => { + const response = await text({ + message: question, + validate: (value) => (value.length > 0 ? undefined : 'Please provide a valid answer.'), + }) + + if (isCancel(response)) { + throw new Error('UserCancelledOperation') + } + + return response + }, +}) + +export const confirmOperation = tool({ + description: 'Interact with user to get a confirmation before action.', + parameters: z.object({ + confirmation: z.string().describe('What do you want to confirm with user'), + }), + execute: async ({ confirmation }) => { + const response = await confirm({ message: confirmation }).then((answer) => { + return answer ? 'yes' : 'no' + }) + + if (isCancel(response)) { + throw new Error('UserCancelledOperation') + } + + return response + }, +}) + +export const presentOptions = tool({ + description: 'Interact with user to present him with options selection', + parameters: z.object({ + description: z.string().describe('Describe the selection for user'), + options: z.array(z.string()).describe('Array with options for user'), + }), + execute: async ({ description, options }) => { + const response = await select({ + message: description, + options: options.map((option) => ({ value: option, label: option })), + }) + + if (isCancel(response)) { + throw new Error('UserCancelledOperation') + } + + return response + }, +})