chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
+17
View File
@@ -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')
}
+169
View File
@@ -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()
})
}
+161
View File
@@ -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()
}
}
+112
View File
@@ -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)
}
}
+102
View File
@@ -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)
})
+230
View File
@@ -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,
},
})
},
}),
],
})
+33
View 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)
}