import chalk from 'chalk' import { Chat, CitationsManager, Component, DefaultComponents, Exit, ListenExit, isComponent, type ExecutionResult, type IterationStatus, type IterationStatuses, type RenderedComponent, } from 'llmz' import { prompt } from './buttons' type TranscriptItem = { role: 'assistant' | 'user' content: string name?: string } export class CLIChat extends Chat { private _controller = new AbortController() public transcript: TranscriptItem[] = [] private _buttons: string[] = [] public turns = 0 public status?: IterationStatus public result?: ExecutionResult public citations: CitationsManager = new CitationsManager() public renderers: Array<{ component: Component render: (component: RenderedComponent) => Promise }> = [] public constructor() { super({ components: () => [DefaultComponents.Text, DefaultComponents.Button, ...this.renderers.map((r) => r.component)], transcript: () => this.transcript, handler: (input) => this._sendMessage(input), }) } public onExecutionDone(_result: ExecutionResult): void { this.result = _result this.status = _result.iterations.at(-1)?.status } public async iterate() { if (this._controller.signal.aborted) { return false } if (this.hasExitedWith(ListenExit)) { await this.prompt() return true } if (this.turns++ > 100) { console.warn(chalk.yellow('⚠️ Too many turns, stopping the chat to prevent infinite loop')) return false } if (!this.result) { return true } return false } public hasExited(this: this): this is this & { status: IterationStatuses.ExitSuccess } { return this.status?.type === 'exit_success' } public hasExitedWith(this: this, exit: Exit): this is { status: IterationStatuses.ExitSuccess } & this { return this.status?.type === 'exit_success' && this.status.exit_success.exit_name === exit.name } public prompt = async (msg: string = chalk.gray('(your reply) ')) => { const reply = await prompt(msg, this._buttons) this._buttons = [] this.turns = 0 if (reply?.trim().length) { this.transcript.push({ role: 'user', content: reply }) console.log(`${chalk.bold('👤 User:')} ${reply}`) } else { this.transcript.push({ role: 'user', content: '[silence] (user did not answer)' }) } } private async _sendMessage(input: RenderedComponent) { let text = '' let children: any[] = [input] if (isComponent(input, DefaultComponents.Text)) { children = input.children } for (const child of children) { if (isComponent(child, DefaultComponents.Button) && child.props?.label) { this._buttons.push(child.props.label) } else if ( typeof child === 'string' || typeof child === 'number' || typeof child === 'boolean' || typeof child === 'bigint' ) { text += '\n' + child } else { this.transcript.push({ role: 'assistant', content: JSON.stringify(child, null, 2), }) const renderer = this.renderers.find((r) => isComponent(child, r.component)) if (renderer) { await renderer.render(child) } else { console.log(chalk.bold('🤖 Agent: ') + chalk.gray(' ' + JSON.stringify(child))) } } } text = text.trim() if (text.length > 0) { const sources: string[] = [] const { cleaned } = this.citations.extractCitations(text, (citation) => { const idx = chalk.bgGreenBright.black.bold(` ${sources.length + 1} `) sources.push(`${idx}: ${JSON.stringify(citation.source)}`) return `${idx}` }) ?? { cleaned: text, citations: [] } const buttonsStr = this._buttons.length > 0 ? `\n\n${chalk.bold('Buttons:')} ${this._buttons.join(', ')}` : '' this.transcript.push({ role: 'assistant', content: cleaned + buttonsStr }) console.log(`${chalk.bold('🤖 Agent:')} ${cleaned}`) if (sources.length) { console.log(chalk.dim('Citations')) console.log(chalk.dim('=========')) console.log(chalk.dim(sources.join('\n'))) } } } public registerComponent< T extends Component, Props extends Record = T extends Component ? T['propsType'] : never, >(component: T, render: (component: RenderedComponent) => Promise): void { if (this.renderers.some((r) => r.component.definition.name === component.definition.name)) { throw new Error(`Component ${component.definition.name} is already registered`) } this.renderers.push({ component, render: render as any }) } public stop() { this._controller.abort() } }