chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
export function box(lines: string[], width: number | 'fit' = 'fit'): string {
|
||||
if (width === 'fit') {
|
||||
width = Math.max(...lines.map((line) => line.length)) + 2 // +2 for padding
|
||||
}
|
||||
|
||||
const top = `╔${'═'.repeat(width)}╗`
|
||||
const sep = `╠${'═'.repeat(width)}╣`
|
||||
const bottom = `╚${'═'.repeat(width)}╝`
|
||||
|
||||
const paddedLines = lines.map((line) => {
|
||||
const raw = line.replace(/\x1b\[[0-9;]*m/g, '') // Strip ANSI for length
|
||||
const padding = width - raw.length
|
||||
return `║${line}${' '.repeat(Math.max(0, padding))}║`
|
||||
})
|
||||
|
||||
return [top, ...paddedLines.slice(0, 1), sep, ...paddedLines.slice(1), bottom].join('\n')
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import chalk from 'chalk'
|
||||
import * as readline from 'readline'
|
||||
|
||||
export async function prompt(message: string = '', quickReplies: string[] = []): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const stdin = process.stdin
|
||||
const stdout = process.stdout
|
||||
|
||||
// Enable raw mode and keypress events
|
||||
stdin.setRawMode(true)
|
||||
readline.emitKeypressEvents(stdin)
|
||||
|
||||
let buffer = ''
|
||||
let selIndex = -1
|
||||
const reservedLines = quickReplies.length + (quickReplies.length > 0 ? 2 : 1) // +1 for hint line if we have replies
|
||||
|
||||
// Reserve lines by printing newlines
|
||||
for (let i = 0; i < reservedLines - 1; i++) {
|
||||
// Changed from reservedLines to reservedLines - 1
|
||||
stdout.write('\n')
|
||||
}
|
||||
|
||||
function drawChoices() {
|
||||
// Move cursor up to start of our reserved block
|
||||
readline.moveCursor(stdout, 0, -reservedLines + 1)
|
||||
readline.cursorTo(stdout, 0)
|
||||
|
||||
// Clear and redraw each choice line
|
||||
for (let i = 0; i < quickReplies.length; i++) {
|
||||
readline.clearLine(stdout, 0)
|
||||
if (i === selIndex && buffer === quickReplies[i]) {
|
||||
// Highlighted selection - looks like a selected button
|
||||
stdout.write(chalk.cyan.bold('❯ ') + chalk.cyan.inverse(` ${quickReplies[i]} `))
|
||||
} else {
|
||||
// Normal button style
|
||||
stdout.write(chalk.gray(' ') + chalk.bgBlack.gray(`[ ${quickReplies[i]} ]`))
|
||||
}
|
||||
if (i < quickReplies.length - 1) {
|
||||
stdout.write('\n')
|
||||
}
|
||||
}
|
||||
|
||||
// Move cursor back to input line
|
||||
if (quickReplies.length > 0) {
|
||||
stdout.write('\n')
|
||||
readline.clearLine(stdout, 0)
|
||||
stdout.write(chalk.gray.dim(' ↑↓ Navigate'))
|
||||
stdout.write('\n')
|
||||
}
|
||||
|
||||
// Position cursor at input line
|
||||
readline.clearLine(stdout, 0)
|
||||
stdout.write(chalk.bold('> ') + message + buffer + chalk.inverse(' '))
|
||||
}
|
||||
|
||||
function updateInputOnly() {
|
||||
// Just update the input line
|
||||
readline.cursorTo(stdout, 0)
|
||||
readline.clearLine(stdout, 0)
|
||||
stdout.write(chalk.bold('> ') + message + buffer + chalk.inverse(' '))
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
stdin.setRawMode(false)
|
||||
stdin.removeListener('keypress', onKeypress)
|
||||
}
|
||||
|
||||
function onKeypress(str: string, key: readline.Key) {
|
||||
if (!key) return
|
||||
|
||||
// Handle Ctrl+C
|
||||
if (key.ctrl && key.name === 'c') {
|
||||
cleanup()
|
||||
process.exit()
|
||||
}
|
||||
|
||||
// Handle Enter
|
||||
if (key.name === 'return' || key.name === 'enter') {
|
||||
cleanup()
|
||||
// Clear all reserved lines (buttons + hint + input)
|
||||
readline.moveCursor(stdout, 0, -reservedLines + 1)
|
||||
readline.cursorTo(stdout, 0)
|
||||
for (let i = 0; i < reservedLines; i++) {
|
||||
readline.clearLine(stdout, 0)
|
||||
if (i < reservedLines - 1) {
|
||||
readline.moveCursor(stdout, 0, 1)
|
||||
}
|
||||
}
|
||||
// Move cursor back up to eliminate blank lines
|
||||
readline.moveCursor(stdout, 0, -(reservedLines - 1))
|
||||
resolve(buffer)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Up/Down arrows - FULL REDRAW (button states change)
|
||||
if (key.name === 'up') {
|
||||
if (quickReplies.length === 0) return
|
||||
|
||||
if (selIndex === -1) {
|
||||
selIndex = quickReplies.length - 1
|
||||
} else {
|
||||
selIndex = (selIndex - 1 + quickReplies.length) % quickReplies.length
|
||||
}
|
||||
buffer = quickReplies[selIndex]
|
||||
drawChoices() // Full redraw because button highlighting changed
|
||||
return
|
||||
}
|
||||
|
||||
if (key.name === 'down') {
|
||||
if (quickReplies.length === 0) return
|
||||
|
||||
if (selIndex === -1) {
|
||||
selIndex = 0
|
||||
} else {
|
||||
selIndex = (selIndex + 1) % quickReplies.length
|
||||
}
|
||||
buffer = quickReplies[selIndex]
|
||||
drawChoices() // Full redraw because button highlighting changed
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Backspace - check if we need to redraw choices
|
||||
if (key.name === 'backspace') {
|
||||
if (buffer.length > 0) {
|
||||
const oldBuffer = buffer
|
||||
buffer = buffer.slice(0, -1)
|
||||
|
||||
// Check if button states need to change
|
||||
const wasSelected = quickReplies.includes(oldBuffer)
|
||||
const nowSelected = quickReplies.includes(buffer)
|
||||
|
||||
if (wasSelected || nowSelected) {
|
||||
selIndex = -1
|
||||
drawChoices() // Full redraw because button states changed
|
||||
} else {
|
||||
selIndex = -1
|
||||
updateInputOnly() // Just update input
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle normal character input
|
||||
if (str && str.length === 1 && !key.ctrl && !key.meta) {
|
||||
const oldBuffer = buffer
|
||||
buffer += str
|
||||
|
||||
// Check if button states need to change
|
||||
const wasSelected = quickReplies.includes(oldBuffer)
|
||||
const nowSelected = quickReplies.includes(buffer)
|
||||
|
||||
if (wasSelected || nowSelected) {
|
||||
selIndex = -1
|
||||
drawChoices() // Full redraw because button states changed
|
||||
} else {
|
||||
selIndex = -1
|
||||
updateInputOnly() // Just update input - much faster!
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Set up keypress listener
|
||||
stdin.on('keypress', onKeypress)
|
||||
|
||||
// Initial render
|
||||
drawChoices()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
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<void>
|
||||
}> = []
|
||||
|
||||
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<R>(this: this, exit: Exit<R>): this is { status: IterationStatuses.ExitSuccess<R> } & 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('<unknown component> ' + 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<string, any> = T extends Component ? T['propsType'] : never,
|
||||
>(component: T, render: (component: RenderedComponent<Props>) => Promise<void>): 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import chalk from 'chalk'
|
||||
import type { Trace } from 'llmz'
|
||||
|
||||
const indentLines = (str: string, length: number) => {
|
||||
const indent = ' '.repeat(length)
|
||||
return str
|
||||
.split('\n')
|
||||
.map((line) => (line.trim() ? `${indent}${line}` : line))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export const ellipsis = (str: string, maxLength: number = process.stdout.columns - 10) => {
|
||||
if (str.length <= maxLength) return str
|
||||
return str.slice(0, maxLength - 3) + '...'
|
||||
}
|
||||
|
||||
export const lightToolTrace = (trace: Trace) => {
|
||||
if (trace.type === 'tool_call' && trace.tool_name !== 'Message') {
|
||||
const input = trace.input ? ellipsis(JSON.stringify(trace.input)) : ''
|
||||
if (trace.success) {
|
||||
const output = trace.output ? ellipsis(JSON.stringify(trace.output)) : ''
|
||||
console.log(
|
||||
chalk.bold.greenBright('🔧 Tool Call: ') +
|
||||
chalk.underline(trace.tool_name) +
|
||||
'\n ↖ ' +
|
||||
chalk.dim(input) +
|
||||
'\n ↪ ' +
|
||||
chalk.dim.greenBright(output)
|
||||
)
|
||||
} else {
|
||||
const error = trace.error ? ellipsis(JSON.stringify(trace.error)) : ''
|
||||
console.log(
|
||||
chalk.bold.redBright('🔧 Tool Call: ') +
|
||||
chalk.underline(trace.tool_name) +
|
||||
'\n ↖ ' +
|
||||
chalk.dim(input) +
|
||||
'\n ↪ ' +
|
||||
chalk.dim.redBright(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const printTrace = (
|
||||
trace: Trace,
|
||||
types: Trace['type'][] = [
|
||||
'abort_signal',
|
||||
'comment',
|
||||
'llm_call_success',
|
||||
'property',
|
||||
'think_signal',
|
||||
'tool_call',
|
||||
'yield',
|
||||
'log',
|
||||
]
|
||||
) => {
|
||||
if (!types.includes(trace.type)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (trace.type === 'tool_call' && trace.tool_name !== 'Message') {
|
||||
if (trace.success) {
|
||||
let input = trace.input ? JSON.stringify(trace.input, null, 2) : ''
|
||||
let output = trace.output ? JSON.stringify(trace.output, null, 2) : ''
|
||||
|
||||
if (input.length + output.length <= 200) {
|
||||
input = input.replace(/(\n|\s){1,}/g, ' ')
|
||||
output = output.replace(/(\n|\s){1,}/g, ' ')
|
||||
|
||||
console.log(
|
||||
`${chalk.green('🔨 Tool Call:')} ${chalk.bold(trace.tool_name)} ${chalk.white(input)} ${chalk.gray('=>')} ${chalk.white(output || '<void>')}`
|
||||
)
|
||||
} else {
|
||||
if (input.length) {
|
||||
console.log(chalk.white(indentLines('Input: ' + input, 4)))
|
||||
}
|
||||
|
||||
if (output.length) {
|
||||
console.log(chalk.white(indentLines('Output: ' + output, 4)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`${chalk.red('🔨 Tool Call:')} ${trace.tool_name}`)
|
||||
console.log(chalk.white(indentLines(`Input: ${JSON.stringify(trace.input, null, 2)}`, 4)))
|
||||
console.log(chalk.red(indentLines(`Error: ${trace.error}`, 4)))
|
||||
}
|
||||
}
|
||||
|
||||
if (trace.type === 'think_signal') {
|
||||
console.log(`${chalk.white('💭 Thinking...')}`)
|
||||
}
|
||||
|
||||
if (trace.type === 'comment') {
|
||||
console.log(`${chalk.white('💬 Comment:')} ${chalk.dim(trace.comment)}`)
|
||||
}
|
||||
|
||||
if (trace.type === 'llm_call_success') {
|
||||
console.log(`${chalk.green('🤖 LLM Call:')} ${trace.model}`)
|
||||
console.log(chalk.white(indentLines(trace.code, 4)))
|
||||
}
|
||||
|
||||
if (trace.type === 'property') {
|
||||
console.log(`${chalk.white('🔍 Property:')} ${trace.property}`)
|
||||
console.log(
|
||||
chalk.white(indentLines(`${trace.object}.${trace.property} => ${JSON.stringify(trace.value, null, 2)}`, 4))
|
||||
)
|
||||
}
|
||||
|
||||
if (trace.type === 'log') {
|
||||
console.log(`${chalk.white('⌨️ Log:')} ${chalk.dim(trace.message)}`, ...trace.args)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import chalk from 'chalk'
|
||||
|
||||
// Spinner frames
|
||||
const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
|
||||
interface LoadingState {
|
||||
intervalId: ReturnType<typeof setInterval> | null
|
||||
frameIndex: number
|
||||
message: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const state: LoadingState = {
|
||||
intervalId: null,
|
||||
frameIndex: 0,
|
||||
message: '',
|
||||
isActive: false,
|
||||
}
|
||||
|
||||
function updateSpinner(): void {
|
||||
if (!state.isActive) return
|
||||
|
||||
// Clear the current line
|
||||
process.stdout.write('\r')
|
||||
process.stdout.write(' '.repeat(100)) // Clear with spaces
|
||||
process.stdout.write('\r')
|
||||
|
||||
// Display the spinner with message
|
||||
const frame = chalk.cyan(spinnerFrames[state.frameIndex])
|
||||
const message = state.message ? ` ${state.message}` : ''
|
||||
process.stdout.write(`${frame}${message}`)
|
||||
|
||||
// Update frame index
|
||||
state.frameIndex = (state.frameIndex + 1) % spinnerFrames.length
|
||||
}
|
||||
|
||||
function startSpinner(message: string): void {
|
||||
// If already running, just update the message
|
||||
if (state.isActive) {
|
||||
state.message = message
|
||||
return
|
||||
}
|
||||
|
||||
state.isActive = true
|
||||
state.message = message
|
||||
state.frameIndex = 0
|
||||
|
||||
// Hide cursor
|
||||
process.stdout.write('\x1b[?25l')
|
||||
|
||||
// Start the animation
|
||||
updateSpinner()
|
||||
state.intervalId = setInterval(updateSpinner, 80)
|
||||
}
|
||||
|
||||
function stopSpinner(): void {
|
||||
if (!state.isActive) return
|
||||
|
||||
state.isActive = false
|
||||
|
||||
// Clear the interval
|
||||
if (state.intervalId) {
|
||||
clearInterval(state.intervalId)
|
||||
state.intervalId = null
|
||||
}
|
||||
|
||||
// Clear the line
|
||||
process.stdout.write('\r')
|
||||
process.stdout.write(' '.repeat(100))
|
||||
process.stdout.write('\r')
|
||||
|
||||
// Show cursor
|
||||
process.stdout.write('\x1b[?25h')
|
||||
}
|
||||
|
||||
/**
|
||||
* Control the loading spinner
|
||||
* @param show - true to show/update spinner, false to hide it
|
||||
* @param message - message to display with the spinner (optional)
|
||||
*/
|
||||
export function loading(show: boolean, message?: string): void {
|
||||
if (show) {
|
||||
startSpinner(message || 'Loading...')
|
||||
} else {
|
||||
stopSpinner()
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on process exit
|
||||
process.on('exit', () => {
|
||||
stopSpinner()
|
||||
})
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
stopSpinner()
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
stopSpinner()
|
||||
process.exit(0)
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
import chalk from 'chalk'
|
||||
|
||||
export interface Task<T = any> {
|
||||
id: string
|
||||
title: string
|
||||
status: 'pending' | 'in-progress' | 'done'
|
||||
preview?: string // Optional preview for completed tasks
|
||||
statusText?: string // Generic status description
|
||||
value?: T
|
||||
}
|
||||
|
||||
export interface TaskGroup<T> {
|
||||
task: Task<T>
|
||||
subtasks: Task<T>[]
|
||||
}
|
||||
|
||||
// Animation frames for in-progress indicator
|
||||
const progressFrames = ['◐', '◓', '◑', '◒']
|
||||
let frameIndex = 0
|
||||
|
||||
export function displayTaskList(taskGroups: TaskGroup<any>[]): void {
|
||||
console.log() // Add some spacing
|
||||
|
||||
taskGroups.forEach((group, groupIndex) => {
|
||||
// Display main task
|
||||
displaySingleTask(group.task, groupIndex + 1, 0)
|
||||
|
||||
// Determine if this group should be expanded
|
||||
const shouldExpand = shouldExpandGroup(group)
|
||||
|
||||
if (shouldExpand) {
|
||||
// Display subtasks with indentation
|
||||
group.subtasks.forEach((subtask, subtaskIndex) => {
|
||||
displaySingleTask(subtask, subtaskIndex + 1, 1, true)
|
||||
})
|
||||
} else {
|
||||
// Show collapsed indicator with appropriate color
|
||||
const collapsedCount = group.subtasks.length
|
||||
if (collapsedCount > 0) {
|
||||
const indent = ' '.repeat(1)
|
||||
const allCompleted = group.subtasks.every((subtask) => subtask.status === 'done')
|
||||
const collapsedColor = allCompleted ? chalk.green : chalk.gray
|
||||
const collapsedText = collapsedColor(
|
||||
`${indent}└─ ${collapsedCount} subtask${collapsedCount > 1 ? 's' : ''} (collapsed)`
|
||||
)
|
||||
console.log(collapsedText)
|
||||
}
|
||||
}
|
||||
|
||||
// Add spacing between task groups
|
||||
if (groupIndex < taskGroups.length - 1) {
|
||||
console.log()
|
||||
}
|
||||
})
|
||||
|
||||
// Display progress bar at the bottom
|
||||
displayProgressBar(taskGroups)
|
||||
|
||||
console.log() // Add spacing after the list
|
||||
}
|
||||
|
||||
// Display progress bar showing overall completion
|
||||
function displayProgressBar(taskGroups: TaskGroup<any>[]): void {
|
||||
// Calculate total tasks and completed tasks
|
||||
let totalTasks = 0
|
||||
let completedTasks = 0
|
||||
|
||||
taskGroups.forEach((group) => {
|
||||
// Count main task
|
||||
totalTasks++
|
||||
if (group.task.status === 'done') completedTasks++
|
||||
|
||||
// Count subtasks
|
||||
group.subtasks.forEach((subtask) => {
|
||||
totalTasks++
|
||||
if (subtask.status === 'done') completedTasks++
|
||||
})
|
||||
})
|
||||
|
||||
const percentage = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
|
||||
const progressWidth = 30
|
||||
const filledWidth = Math.round((percentage / 100) * progressWidth)
|
||||
const emptyWidth = progressWidth - filledWidth
|
||||
|
||||
const filledBar = chalk.green('█'.repeat(filledWidth))
|
||||
const emptyBar = chalk.gray('░'.repeat(emptyWidth))
|
||||
const progressBar = `[${filledBar}${emptyBar}]`
|
||||
|
||||
const statusText = `${completedTasks}/${totalTasks} tasks completed (${percentage}%)`
|
||||
|
||||
console.log()
|
||||
console.log(`${progressBar} ${statusText}`)
|
||||
}
|
||||
|
||||
// Determine if a task group should be expanded
|
||||
function shouldExpandGroup(group: TaskGroup<any>): boolean {
|
||||
// Expand if main task is in-progress
|
||||
if (group.task.status === 'in-progress') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Expand if any subtask is in-progress
|
||||
if (group.subtasks.some((subtask) => subtask.status === 'in-progress')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Collapse if all tasks are pending or all are done
|
||||
return false
|
||||
}
|
||||
|
||||
function displaySingleTask(task: Task, number: number, indentLevel: number, isSubtask: boolean = false): void {
|
||||
// Create indentation
|
||||
const indent = ' '.repeat(indentLevel)
|
||||
const prefix = isSubtask ? '└─' : ''
|
||||
|
||||
// Status indicator with color coding
|
||||
let statusIcon: string
|
||||
let statusColor: (text: string) => string
|
||||
|
||||
switch (task.status) {
|
||||
case 'pending':
|
||||
statusIcon = '○'
|
||||
statusColor = chalk.gray
|
||||
break
|
||||
case 'in-progress':
|
||||
statusIcon = progressFrames[frameIndex % progressFrames.length]
|
||||
statusColor = chalk.yellow
|
||||
break
|
||||
case 'done':
|
||||
statusIcon = '●'
|
||||
statusColor = chalk.green
|
||||
break
|
||||
}
|
||||
|
||||
// Task number formatting
|
||||
const taskNumber = chalk.dim(`${number}.`)
|
||||
|
||||
// Title formatting
|
||||
const title = task.status === 'done' ? chalk.dim(task.title) : task.title
|
||||
|
||||
// Status text formatting - keep all status on same line
|
||||
let statusInfo = ''
|
||||
if (task.statusText) {
|
||||
if (task.status === 'in-progress') {
|
||||
statusInfo = ` ${chalk.dim(`[ ${task.statusText} ]`)}`
|
||||
} else if (task.status === 'done') {
|
||||
statusInfo = chalk.dim(` ${task.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Main task line
|
||||
console.log(`${indent}${prefix} ${taskNumber} ${statusColor(statusIcon)} ${title}${statusInfo}`)
|
||||
}
|
||||
|
||||
// Animated display function that updates in-progress indicators
|
||||
export function displayTaskListAnimated(taskGroups: TaskGroup<any>[], updateInterval: number = 500): () => void {
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const updateDisplay = () => {
|
||||
// Clear the entire screen and move cursor to top
|
||||
process.stdout.write('\x1b[2J') // Clear entire screen
|
||||
process.stdout.write('\x1b[H') // Move cursor to home position (0,0)
|
||||
|
||||
// Increment animation frame
|
||||
frameIndex = (frameIndex + 1) % progressFrames.length
|
||||
|
||||
// Redraw the task list
|
||||
displayTaskList(taskGroups)
|
||||
|
||||
const allDone = taskGroups.every((group) => group.task.status === 'done')
|
||||
if (allDone) {
|
||||
clearInterval(intervalId!)
|
||||
}
|
||||
}
|
||||
|
||||
// Initial display - clear screen first
|
||||
process.stdout.write('\x1b[2J') // Clear entire screen
|
||||
process.stdout.write('\x1b[H') // Move cursor to home position
|
||||
displayTaskList(taskGroups)
|
||||
|
||||
intervalId = setInterval(updateDisplay, updateInterval)
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function updateTaskStatus<T>({
|
||||
status,
|
||||
taskGroups,
|
||||
taskId,
|
||||
preview,
|
||||
statusText,
|
||||
value,
|
||||
}: {
|
||||
taskGroups: TaskGroup<T>[]
|
||||
taskId: string
|
||||
status: Task['status']
|
||||
preview?: string
|
||||
statusText?: string
|
||||
value?: T
|
||||
}) {
|
||||
for (const group of taskGroups) {
|
||||
if (group.task.id === taskId) {
|
||||
group.task.status = status
|
||||
if (preview) group.task.preview = preview
|
||||
if (statusText) group.task.statusText = statusText
|
||||
if (value !== undefined) group.task.value = value
|
||||
}
|
||||
for (const subtask of group.subtasks) {
|
||||
if (subtask.id === taskId) {
|
||||
subtask.status = status
|
||||
if (preview) subtask.preview = preview
|
||||
if (statusText) subtask.statusText = statusText
|
||||
if (value !== undefined) subtask.value = value
|
||||
}
|
||||
}
|
||||
|
||||
if (group.task.status !== 'done' && group.subtasks.every((sub) => sub.status === 'done')) {
|
||||
group.task.status = 'done' // Mark main task as done if all subtasks are done
|
||||
}
|
||||
|
||||
if (group.task.status !== 'pending' && group.subtasks.some((sub) => sub.status === 'in-progress')) {
|
||||
group.task.status = 'in-progress' // Mark main task as in-progress if any subtask is in-progress
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { type Client } from '@botpress/client'
|
||||
import { z } from '@bpinternal/zui'
|
||||
import { Tool } from 'llmz'
|
||||
|
||||
/** Extract the full content & the metadata of the specified pages as markdown. */
|
||||
export const browsePages = (client: Client) =>
|
||||
new Tool({
|
||||
name: 'browser_browsePages',
|
||||
description: 'Extract the full content & the metadata of the specified pages as markdown.',
|
||||
input: z
|
||||
.object({
|
||||
urls: z.array(z.string()),
|
||||
waitFor: z
|
||||
.default(
|
||||
z
|
||||
.number()
|
||||
.describe(
|
||||
'Time to wait before extracting the content (in milliseconds). Set this value higher for dynamic pages.'
|
||||
),
|
||||
350
|
||||
)
|
||||
.describe(
|
||||
'Time to wait before extracting the content (in milliseconds). Set this value higher for dynamic pages.'
|
||||
),
|
||||
})
|
||||
.catchall(z.never()),
|
||||
output: z
|
||||
.object({
|
||||
results: z.array(
|
||||
z
|
||||
.object({
|
||||
url: z.string(),
|
||||
content: z.string(),
|
||||
favicon: z.optional(z.string()),
|
||||
title: z.optional(z.string()),
|
||||
description: z.optional(z.string()),
|
||||
})
|
||||
.catchall(z.never())
|
||||
),
|
||||
})
|
||||
.catchall(z.never()),
|
||||
handler: async (input) => {
|
||||
return client.callAction({ type: 'browser:browsePages', input }).then(({ output }) => output) as any
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { type Client } from '@botpress/client'
|
||||
import { z } from '@bpinternal/zui'
|
||||
import { Tool } from 'llmz'
|
||||
|
||||
/** Capture a screenshot of the specified page. */
|
||||
export const captureScreenshot = (client: Client) =>
|
||||
new Tool({
|
||||
name: 'browser_captureScreenshot',
|
||||
description: 'Capture a screenshot of the specified page.',
|
||||
input: z
|
||||
.object({
|
||||
url: z.string(),
|
||||
})
|
||||
.catchall(z.never()),
|
||||
output: z
|
||||
.object({
|
||||
imageUrl: z.string(),
|
||||
})
|
||||
.catchall(z.never()),
|
||||
handler: async (input) => {
|
||||
return client.callAction({ type: 'browser:captureScreenshot', input }).then(({ output }) => output) as any
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './browsePages'
|
||||
export * from './captureScreenshot'
|
||||
export * from './webSearch'
|
||||
@@ -0,0 +1,102 @@
|
||||
import { type Client } from '@botpress/client'
|
||||
import { z } from '@bpinternal/zui'
|
||||
import { Tool } from 'llmz'
|
||||
|
||||
/** Search information on the web. You need to browse to that page to get the full content of the page. */
|
||||
export const webSearch = (client: Client) =>
|
||||
new Tool({
|
||||
name: 'browser_webSearch',
|
||||
description: 'Search information on the web. You need to browse to that page to get the full content of the page.',
|
||||
input: z
|
||||
.object({
|
||||
query: z.string().min(1, undefined).max(1000, undefined).describe('What are we searching for?'),
|
||||
includeSites: z
|
||||
.optional(
|
||||
z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.regex(/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/, undefined)
|
||||
.min(3, undefined)
|
||||
.max(50, undefined)
|
||||
)
|
||||
.max(20, undefined)
|
||||
.describe('Include only these domains in the search (max 20)')
|
||||
)
|
||||
.describe('Include only these domains in the search (max 20)'),
|
||||
excludeSites: z
|
||||
.optional(
|
||||
z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.regex(/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/, undefined)
|
||||
.min(3, undefined)
|
||||
.max(50, undefined)
|
||||
)
|
||||
.max(20, undefined)
|
||||
.describe('Exclude these domains from the search (max 20)')
|
||||
)
|
||||
.describe('Exclude these domains from the search (max 20)'),
|
||||
count: z
|
||||
.default(
|
||||
z
|
||||
.number()
|
||||
.min(1, undefined)
|
||||
.max(20, undefined)
|
||||
.describe('Number of search results to return (default: 10)'),
|
||||
10
|
||||
)
|
||||
.describe('Number of search results to return (default: 10)'),
|
||||
freshness: z
|
||||
.optional(z.enum(['Day', 'Week', 'Month']).describe('Only consider results from the last day, week or month'))
|
||||
.describe('Only consider results from the last day, week or month'),
|
||||
browsePages: z
|
||||
|
||||
.boolean()
|
||||
// .default(false)
|
||||
.describe('Whether to browse to the pages to get the full content'),
|
||||
})
|
||||
.catchall(z.never()),
|
||||
output: z
|
||||
.object({
|
||||
results: z.array(
|
||||
z
|
||||
.object({
|
||||
name: z.string().describe('Title of the page'),
|
||||
url: z.string().describe('URL of the page'),
|
||||
snippet: z.string().describe('A short summary of the page'),
|
||||
links: z
|
||||
.optional(
|
||||
z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
})
|
||||
.catchall(z.never())
|
||||
)
|
||||
.describe('Useful links on the page')
|
||||
)
|
||||
.describe('Useful links on the page'),
|
||||
page: z.optional(
|
||||
z
|
||||
.object({
|
||||
url: z.string(),
|
||||
content: z.string(),
|
||||
favicon: z.optional(z.string()),
|
||||
title: z.optional(z.string()),
|
||||
description: z.optional(z.string()),
|
||||
})
|
||||
.catchall(z.never())
|
||||
),
|
||||
})
|
||||
.catchall(z.never())
|
||||
),
|
||||
})
|
||||
.catchall(z.never()),
|
||||
handler: async (input) => {
|
||||
return client.callAction({ type: 'browser:webSearch', input }).then(({ output }) => output) as any
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Client } from '@botpress/client'
|
||||
import { z } from '@bpinternal/zui'
|
||||
import { ObjectInstance, Tool } from 'llmz'
|
||||
|
||||
const sanitizePath = (path: string) => {
|
||||
// Remove leading/trailing slashes and replace multiple slashes with a single slash
|
||||
path = path.replace(/(^\/+|\/+$)/g, '').replace(/\/{2,}/g, '/')
|
||||
// Allow only alphanumeric, underscore, hyphen, and slashes
|
||||
path = path.replace(/[^a-zA-Z0-9_\-\\/\\.]/g, '')
|
||||
// Ensure the path does not start with a slash
|
||||
path = path.startsWith('/') ? path : `/${path}`
|
||||
// Ensure the path does not end with a slash
|
||||
path = path.endsWith('/') ? path.slice(0, -1) : path
|
||||
// Ensure the path does not contain consecutive slashes
|
||||
path = path.replace(/\/{2,}/g, '/')
|
||||
// Ensure the path does not contain a leading dot
|
||||
path = path.replace(/^\.\//, '')
|
||||
// Ensure the path does not contain a trailing dot
|
||||
path = path.replace(/\/\.$/, '')
|
||||
|
||||
return path.toLowerCase().trim()
|
||||
}
|
||||
|
||||
const getPathFolder = (path: string) => {
|
||||
const parts = path.split('/')
|
||||
parts.pop() // Remove the last part (file name)
|
||||
return parts.join('/') || '/'
|
||||
}
|
||||
|
||||
const parseFilePath = (path: string) => {
|
||||
if (!path || typeof path !== 'string') {
|
||||
throw new Error(`Invalid file path: "${path}". Path must be a non-empty string.`)
|
||||
}
|
||||
|
||||
if (path.length > 255) {
|
||||
throw new Error(`Invalid file path: "${path}". Path length exceeds 255 characters.`)
|
||||
}
|
||||
|
||||
const sanitizedPath = sanitizePath(path)
|
||||
|
||||
if (!sanitizedPath.length) {
|
||||
throw new Error(`Invalid file path: "${path}". Path cannot be empty.`)
|
||||
}
|
||||
|
||||
const folder = getPathFolder(sanitizedPath)
|
||||
const fileName = sanitizedPath.split('/').pop()?.trim() || ''
|
||||
|
||||
if (!fileName.length) {
|
||||
throw new Error(`Invalid file path: "${path}". File name cannot be empty.`)
|
||||
}
|
||||
|
||||
if (fileName.length && !fileName.includes('.')) {
|
||||
throw new Error(`Invalid file path: "${fileName}". File name must contain an extension.`)
|
||||
}
|
||||
|
||||
return {
|
||||
path: sanitizedPath,
|
||||
folder,
|
||||
file: fileName,
|
||||
}
|
||||
}
|
||||
|
||||
export const makeFileSystem = (client: Client) =>
|
||||
new ObjectInstance({
|
||||
name: 'fs',
|
||||
description: 'File system operations',
|
||||
tools: [
|
||||
new Tool({
|
||||
name: 'readFile',
|
||||
description: 'Read a file from the file system',
|
||||
input: z.string().describe('File path to read'),
|
||||
output: z.string().describe('File content'),
|
||||
handler: async (path) => {
|
||||
const { path: key } = parseFilePath(path)
|
||||
|
||||
const { file } = await client.getFile({
|
||||
id: key,
|
||||
})
|
||||
|
||||
return await fetch(file.url).then((res) => res.text())
|
||||
},
|
||||
}),
|
||||
|
||||
new Tool({
|
||||
name: 'writeFile',
|
||||
description: 'Write a file to the file system',
|
||||
input: z.object({
|
||||
path: z.string().describe('File path to write'),
|
||||
content: z.string().describe('File content to write'),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const { path, file, folder } = parseFilePath(input.path)
|
||||
|
||||
await client.uploadFile({
|
||||
key: path,
|
||||
content: input.content,
|
||||
accessPolicies: ['public_content'],
|
||||
publicContentImmediatelyAccessible: true,
|
||||
tags: {
|
||||
purpose: 'file-system',
|
||||
folder: folder || '/',
|
||||
name: file,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
new Tool({
|
||||
name: 'listFiles',
|
||||
description: 'Write a file to the file system',
|
||||
input: z.string().describe('Folder path to list files from').default('/'),
|
||||
output: z
|
||||
.array(
|
||||
z.object({
|
||||
folder: z.string().describe('Folder path'),
|
||||
file: z.string().describe('File name'),
|
||||
createdAt: z.string().describe('File creation date'),
|
||||
updatedAt: z.string().describe('File last update date'),
|
||||
size: z.number().describe('File size in bytes'),
|
||||
url: z.string().url().describe('File URL'),
|
||||
contentType: z.string().describe('File content type'),
|
||||
})
|
||||
)
|
||||
.describe('List of files in the folder'),
|
||||
handler: async (path) => {
|
||||
const folder = sanitizePath(path || '/')
|
||||
|
||||
const files = await client.list
|
||||
.files({
|
||||
sortDirection: 'desc',
|
||||
sortField: 'updatedAt',
|
||||
tags: {
|
||||
purpose: 'file-system',
|
||||
folder,
|
||||
},
|
||||
})
|
||||
.collect({ limit: 1000 })
|
||||
|
||||
return files.map((file) => ({
|
||||
folder: file.tags?.folder || '/',
|
||||
file: file.tags?.name || file.key,
|
||||
createdAt: file.createdAt,
|
||||
updatedAt: file.updatedAt,
|
||||
size: file.size,
|
||||
url: file.url,
|
||||
contentType: file.contentType,
|
||||
}))
|
||||
},
|
||||
}),
|
||||
|
||||
new Tool({
|
||||
name: 'deleteFile',
|
||||
description: 'Delete a file from the file system',
|
||||
input: z.string().describe('File path to delete'),
|
||||
output: z.boolean().describe('True if file was deleted successfully'),
|
||||
handler: async (path) => {
|
||||
const { path: key } = parseFilePath(path)
|
||||
await client.deleteFile({ id: key })
|
||||
return true
|
||||
},
|
||||
}),
|
||||
|
||||
new Tool({
|
||||
name: 'moveFile',
|
||||
description: 'Rename a file in the file system',
|
||||
input: z.object({
|
||||
before: z.string().describe('Current file path'),
|
||||
after: z.string().describe('New file path'),
|
||||
}),
|
||||
handler: async ({ before, after }) => {
|
||||
const { path: oldKey } = parseFilePath(before)
|
||||
const { path: newKey, file, folder } = parseFilePath(after)
|
||||
|
||||
await client.copyFile({
|
||||
idOrKey: oldKey,
|
||||
destinationKey: newKey,
|
||||
overwrite: true,
|
||||
})
|
||||
|
||||
await client.deleteFile({ id: oldKey })
|
||||
|
||||
await client.updateFileMetadata({
|
||||
id: newKey,
|
||||
tags: {
|
||||
purpose: 'file-system',
|
||||
folder: folder || '/',
|
||||
name: file,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import Zai from '@botpress/zai'
|
||||
|
||||
export const withRetry = (zai: Zai.Zai) => {
|
||||
const methods: (keyof Zai.Zai)[] = ['check', 'extract', 'filter', 'label', 'learn', 'rewrite', 'summarize', 'text']
|
||||
|
||||
return methods.reduce<Zai.Zai>((acc, key) => {
|
||||
const original = zai[key]
|
||||
if (typeof original === 'function') {
|
||||
const retryable = async (...args: any[]) => {
|
||||
const maxAttempts = 10
|
||||
let attempt = 0
|
||||
let lastError
|
||||
|
||||
while (attempt < maxAttempts) {
|
||||
try {
|
||||
return await (original as any).bind(zai)(...args)
|
||||
} catch (err) {
|
||||
lastError = err
|
||||
attempt++
|
||||
if (attempt < maxAttempts) {
|
||||
await new Promise((r) => setTimeout(r, 250 * 2 ** attempt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
acc[key] = retryable as any
|
||||
}
|
||||
return acc
|
||||
}, {} as Zai.Zai)
|
||||
}
|
||||
Reference in New Issue
Block a user