Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 76 additions & 94 deletions packages/cali/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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<CoreMessage[]> {
Expand Down Expand Up @@ -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,
})
}
1 change: 1 addition & 0 deletions packages/tools/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
60 changes: 60 additions & 0 deletions packages/tools/src/user-interaction.ts
Original file line number Diff line number Diff line change
@@ -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
},
})