kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
feat: add event emitting + terminal task output #34
commit
3c71bc7e31
|
@ -0,0 +1,22 @@
|
||||||
|
import 'dotenv/config'
|
||||||
|
import { OpenAIClient } from 'openai-fetch'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import { Agentic, TaskStatus } from '@/index'
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const openai = new OpenAIClient({ apiKey: process.env.OPENAI_API_KEY! })
|
||||||
|
const $ = new Agentic({ openai })
|
||||||
|
|
||||||
|
const ai = $.gpt4(`generate fake data`).output(
|
||||||
|
z.object({ foo: z.string(), bar: z.number() })
|
||||||
|
)
|
||||||
|
|
||||||
|
ai.eventEmitter.on(TaskStatus.COMPLETED, (event) => {
|
||||||
|
console.log('Task completed successfully:', event)
|
||||||
|
})
|
||||||
|
|
||||||
|
ai.call()
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
|
@ -45,6 +45,7 @@
|
||||||
"@types/json-schema": "^7.0.12",
|
"@types/json-schema": "^7.0.12",
|
||||||
"colorette": "^2.0.20",
|
"colorette": "^2.0.20",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
"expr-eval": "^2.0.2",
|
"expr-eval": "^2.0.2",
|
||||||
"handlebars": "^4.7.7",
|
"handlebars": "^4.7.7",
|
||||||
"is-relative-url": "^4.0.0",
|
"is-relative-url": "^4.0.0",
|
||||||
|
@ -62,6 +63,7 @@
|
||||||
"p-timeout": "^6.1.2",
|
"p-timeout": "^6.1.2",
|
||||||
"quick-lru": "^6.1.1",
|
"quick-lru": "^6.1.1",
|
||||||
"replicate": "^0.12.3",
|
"replicate": "^0.12.3",
|
||||||
|
"tree-model": "^1.0.7",
|
||||||
"ts-dedent": "^2.2.0",
|
"ts-dedent": "^2.2.0",
|
||||||
"type-fest": "^3.12.0",
|
"type-fest": "^3.12.0",
|
||||||
"zod": "^3.21.4",
|
"zod": "^3.21.4",
|
||||||
|
|
|
@ -1,17 +1,20 @@
|
||||||
|
import { EventEmitter } from 'eventemitter3'
|
||||||
import defaultKy from 'ky'
|
import defaultKy from 'ky'
|
||||||
import { SetOptional } from 'type-fest'
|
import { SetOptional } from 'type-fest'
|
||||||
|
|
||||||
import * as types from './types'
|
import * as types from './types'
|
||||||
import { DEFAULT_OPENAI_MODEL } from './constants'
|
import { DEFAULT_OPENAI_MODEL } from './constants'
|
||||||
|
import { TerminalTaskTracker } from './events'
|
||||||
import { HumanFeedbackOptions, HumanFeedbackType } from './human-feedback'
|
import { HumanFeedbackOptions, HumanFeedbackType } from './human-feedback'
|
||||||
import { HumanFeedbackMechanismCLI } from './human-feedback/cli'
|
import { HumanFeedbackMechanismCLI } from './human-feedback/cli'
|
||||||
import { OpenAIChatCompletion } from './llms/openai'
|
import { OpenAIChatCompletion } from './llms/openai'
|
||||||
import { defaultLogger } from './logger'
|
import { defaultLogger } from './logger'
|
||||||
import { defaultIDGeneratorFn, isFunction, isString } from './utils'
|
import { defaultIDGeneratorFn, isFunction, isString } from './utils'
|
||||||
|
|
||||||
export class Agentic {
|
export class Agentic extends EventEmitter {
|
||||||
protected _ky: types.KyInstance
|
protected _ky: types.KyInstance
|
||||||
protected _logger: types.Logger
|
protected _logger: types.Logger
|
||||||
|
protected _taskTracker: TerminalTaskTracker
|
||||||
|
|
||||||
protected _openai?: types.openai.OpenAIClient
|
protected _openai?: types.openai.OpenAIClient
|
||||||
protected _anthropic?: types.anthropic.Client
|
protected _anthropic?: types.anthropic.Client
|
||||||
|
@ -35,7 +38,10 @@ export class Agentic {
|
||||||
idGeneratorFn?: types.IDGeneratorFunction
|
idGeneratorFn?: types.IDGeneratorFunction
|
||||||
logger?: types.Logger
|
logger?: types.Logger
|
||||||
ky?: types.KyInstance
|
ky?: types.KyInstance
|
||||||
|
taskTracker?: TerminalTaskTracker
|
||||||
}) {
|
}) {
|
||||||
|
super()
|
||||||
|
|
||||||
// TODO: This is a bit hacky, but we're doing it to have a slightly nicer API
|
// TODO: This is a bit hacky, but we're doing it to have a slightly nicer API
|
||||||
// for the end developer when creating subclasses of `BaseTask` to use as
|
// for the end developer when creating subclasses of `BaseTask` to use as
|
||||||
// tools.
|
// tools.
|
||||||
|
@ -48,6 +54,7 @@ export class Agentic {
|
||||||
|
|
||||||
this._ky = opts.ky ?? defaultKy
|
this._ky = opts.ky ?? defaultKy
|
||||||
this._logger = opts.logger ?? defaultLogger
|
this._logger = opts.logger ?? defaultLogger
|
||||||
|
this._taskTracker = opts.taskTracker ?? new TerminalTaskTracker()
|
||||||
|
|
||||||
this._openaiModelDefaults = {
|
this._openaiModelDefaults = {
|
||||||
provider: 'openai',
|
provider: 'openai',
|
||||||
|
@ -98,6 +105,10 @@ export class Agentic {
|
||||||
return this._humanFeedbackDefaults
|
return this._humanFeedbackDefaults
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get taskTracker(): TerminalTaskTracker {
|
||||||
|
return this._taskTracker
|
||||||
|
}
|
||||||
|
|
||||||
public get idGeneratorFn(): types.IDGeneratorFunction {
|
public get idGeneratorFn(): types.IDGeneratorFunction {
|
||||||
return this._idGeneratorFn
|
return this._idGeneratorFn
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,3 +2,4 @@ export const DEFAULT_OPENAI_MODEL = 'gpt-3.5-turbo'
|
||||||
export const DEFAULT_ANTHROPIC_MODEL = 'claude-instant-v1'
|
export const DEFAULT_ANTHROPIC_MODEL = 'claude-instant-v1'
|
||||||
export const DEFAULT_BOT_NAME = 'Agentic Bot'
|
export const DEFAULT_BOT_NAME = 'Agentic Bot'
|
||||||
export const SKIP_HOOKS = Symbol('SKIP_HOOKS')
|
export const SKIP_HOOKS = Symbol('SKIP_HOOKS')
|
||||||
|
export const SPACE = ' '
|
||||||
|
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { defaultIDGeneratorFn } from '@/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload of an event.
|
||||||
|
*/
|
||||||
|
export interface EventPayload {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data required to create a new Event object.
|
||||||
|
*/
|
||||||
|
export interface EventData<T extends EventPayload> {
|
||||||
|
/**
|
||||||
|
* Event identifier
|
||||||
|
*/
|
||||||
|
id?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event timestamp
|
||||||
|
*/
|
||||||
|
timestamp?: Date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key-value pairs holding event data.
|
||||||
|
*/
|
||||||
|
payload?: T
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Version of the event.
|
||||||
|
*/
|
||||||
|
version?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event type.
|
||||||
|
*/
|
||||||
|
type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events that occur within the library (should be treated as immutable).
|
||||||
|
*/
|
||||||
|
export class Event<T extends EventPayload> {
|
||||||
|
public readonly id: string
|
||||||
|
public readonly timestamp: Date
|
||||||
|
public readonly payload?: T
|
||||||
|
public readonly version: number
|
||||||
|
|
||||||
|
constructor(data: EventData<T> = {}) {
|
||||||
|
this.id = defaultIDGeneratorFn()
|
||||||
|
this.timestamp = data.timestamp ?? new Date()
|
||||||
|
this.payload = data.payload
|
||||||
|
? JSON.parse(JSON.stringify(data.payload))
|
||||||
|
: undefined
|
||||||
|
this.version = data.version ?? 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a JSON string representation of an event back into an Event object.
|
||||||
|
*/
|
||||||
|
static fromJSON<T extends EventPayload>(json: string): Event<T> {
|
||||||
|
const data = JSON.parse(json)
|
||||||
|
data.timestamp = new Date(data.timestamp)
|
||||||
|
let Type
|
||||||
|
switch (data.type) {
|
||||||
|
case 'TaskEvent':
|
||||||
|
Type = TaskEvent<any, any>
|
||||||
|
break
|
||||||
|
case 'Event':
|
||||||
|
Type = Event
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown event type: ${data.type}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Type(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes an event into a JSON string.
|
||||||
|
*/
|
||||||
|
toJSON(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
id: this.id,
|
||||||
|
timestamp: this.timestamp.toISOString(),
|
||||||
|
payload: this.payload,
|
||||||
|
version: this.version,
|
||||||
|
type: this.constructor.name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a human-readable string representation of an event.
|
||||||
|
*/
|
||||||
|
toString(): string {
|
||||||
|
return `Event { id: ${
|
||||||
|
this.id
|
||||||
|
}, timestamp: ${this.timestamp.toISOString()}, payload: ${JSON.stringify(
|
||||||
|
this.payload
|
||||||
|
)} }`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload of a task event.
|
||||||
|
*/
|
||||||
|
export interface TaskEventPayload<TInput, TOutput> extends EventPayload {
|
||||||
|
taskName: string
|
||||||
|
taskId: string
|
||||||
|
taskStatus: TaskStatus
|
||||||
|
taskInputs?: TInput
|
||||||
|
taskOutput?: TOutput
|
||||||
|
parentTaskId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status of a task.
|
||||||
|
*/
|
||||||
|
export enum TaskStatus {
|
||||||
|
COMPLETED = 'COMPLETED',
|
||||||
|
FAILED = 'FAILED',
|
||||||
|
PENDING = 'PENDING',
|
||||||
|
RETRYING = 'RETRYING',
|
||||||
|
SKIPPED = 'SKIPPED',
|
||||||
|
RUNNING = 'RUNNING',
|
||||||
|
CANCELLED = 'CANCELLED'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events that occur within the library related to tasks.
|
||||||
|
*/
|
||||||
|
export class TaskEvent<TInput, TOutput> extends Event<
|
||||||
|
TaskEventPayload<TInput, TOutput>
|
||||||
|
> {
|
||||||
|
get name(): string {
|
||||||
|
return this.payload?.taskName ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
get taskId(): string {
|
||||||
|
return this.payload?.taskId ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
get status(): TaskStatus {
|
||||||
|
return this.payload?.taskStatus ?? TaskStatus.RUNNING
|
||||||
|
}
|
||||||
|
|
||||||
|
get inputs(): any {
|
||||||
|
return this.payload?.taskInputs ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
get output(): any {
|
||||||
|
return this.payload?.taskOutput ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
get parentTaskId(): string {
|
||||||
|
return this.payload?.parentTaskId ?? 'root'
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './event'
|
||||||
|
export * from './tracker'
|
|
@ -0,0 +1,32 @@
|
||||||
|
import isUnicodeSupported from 'is-unicode-supported'
|
||||||
|
|
||||||
|
const UNICODE_SYMBOLS = {
|
||||||
|
ARROW_RIGHT: '→',
|
||||||
|
CIRCLE: '●',
|
||||||
|
WARNING: '▲',
|
||||||
|
CROSS: '⨯',
|
||||||
|
SQUARE_SMALL_FILLED: '◼',
|
||||||
|
SPINNER: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
|
||||||
|
BAR_START: '┌',
|
||||||
|
BAR: '│',
|
||||||
|
BAR_END: '└',
|
||||||
|
ACTIVE: '◆',
|
||||||
|
LEFT_ARROW: '←',
|
||||||
|
RIGHT_ARROW: '→'
|
||||||
|
}
|
||||||
|
const ASCII_SYMBOLS = {
|
||||||
|
ARROW_RIGHT: '→',
|
||||||
|
CIRCLE: '•',
|
||||||
|
WARNING: '‼',
|
||||||
|
CROSS: '×',
|
||||||
|
SQUARE_SMALL_FILLED: '■',
|
||||||
|
SPINNER: ['-', '\\', '|', '/'],
|
||||||
|
BAR_START: 'T',
|
||||||
|
BAR: '|',
|
||||||
|
BAR_END: '—',
|
||||||
|
ACTIVE: '*',
|
||||||
|
LEFT_ARROW: '<',
|
||||||
|
RIGHT_ARROW: '>'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SYMBOLS = isUnicodeSupported() ? UNICODE_SYMBOLS : ASCII_SYMBOLS
|
|
@ -0,0 +1,422 @@
|
||||||
|
import process from 'node:process'
|
||||||
|
import readline from 'node:readline'
|
||||||
|
|
||||||
|
import { bgWhite, black, bold, cyan, gray, green, red, yellow } from 'colorette'
|
||||||
|
import TreeModel from 'tree-model'
|
||||||
|
|
||||||
|
import { SPACE } from '@/constants'
|
||||||
|
import { capitalize } from '@/utils'
|
||||||
|
|
||||||
|
import { TaskEvent, TaskStatus } from './event'
|
||||||
|
import { SYMBOLS } from './symbols'
|
||||||
|
|
||||||
|
export const MAGIC_STRING = '__INSIDE_TRACKER__' // a unique "magic" string that used to identify the output of the tracker
|
||||||
|
|
||||||
|
const TWO_SPACES = `${SPACE}${SPACE}`
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const RE_ANSI_ESCAPES = /^(\x1b\[[0-9;]*[ABCDHJK]|[\r\n])+$/ // cursor movement, screen clearing, etc.
|
||||||
|
|
||||||
|
const originalStdoutWrite = process.stdout.write
|
||||||
|
const originalStderrWrite = process.stderr.write
|
||||||
|
|
||||||
|
export interface TerminalTaskTrackerOptions {
|
||||||
|
spinnerInterval?: number
|
||||||
|
inactivityInterval?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TerminalTaskTracker {
|
||||||
|
protected _tree = new TreeModel()
|
||||||
|
protected _root = this._tree.parse({ id: 'root' })
|
||||||
|
protected _interval: NodeJS.Timeout | null = null
|
||||||
|
protected _inactivityTimeout: NodeJS.Timeout | null = null
|
||||||
|
protected _truncateOutput = false
|
||||||
|
protected _viewMode = 'tasks'
|
||||||
|
protected _outputs: Array<string | Uint8Array> = []
|
||||||
|
protected _renderingPaused = false
|
||||||
|
protected _isClosed = false
|
||||||
|
|
||||||
|
protected _spinnerInterval: number
|
||||||
|
protected _inactivityInterval: number
|
||||||
|
|
||||||
|
private _stdoutBuffer: string[] = []
|
||||||
|
private _stderrBuffer: string[] = []
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
spinnerInterval = 100,
|
||||||
|
inactivityInterval = 2_000
|
||||||
|
}: TerminalTaskTrackerOptions = {}) {
|
||||||
|
this._spinnerInterval = spinnerInterval
|
||||||
|
this._inactivityInterval = inactivityInterval
|
||||||
|
|
||||||
|
if (!process.stderr.isTTY) {
|
||||||
|
// If stderr is not a TTY, don't render any dynamic output...
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write = (buffer: string | Uint8Array) => {
|
||||||
|
if (buffer instanceof Uint8Array) {
|
||||||
|
buffer = Buffer.from(buffer).toString('utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._renderingPaused) {
|
||||||
|
this._stdoutBuffer.push(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalStdoutWrite.call(process.stdout, buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stderr.write = (buffer: string | Uint8Array) => {
|
||||||
|
if (buffer instanceof Uint8Array) {
|
||||||
|
buffer = Buffer.from(buffer).toString('utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof buffer === 'string' && buffer.startsWith(MAGIC_STRING)) {
|
||||||
|
// This write is from inside the tracker, remove the magic string and write to stderr:
|
||||||
|
return originalStderrWrite.call(
|
||||||
|
process.stderr,
|
||||||
|
buffer.replace(MAGIC_STRING, '')
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if (!this._renderingPaused && !RE_ANSI_ESCAPES.test(buffer)) {
|
||||||
|
// If an ANSI escape sequence is written to stderr, it will mess up the output, so we need to write it to stdout instead:
|
||||||
|
// This write is from outside the tracker, add it to stderrBuffer and write to stderr:
|
||||||
|
this._stderrBuffer.push(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalStderrWrite.call(process.stderr, buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeyPress = (str, key) => {
|
||||||
|
if (key.ctrl && key.name === 'c') {
|
||||||
|
process.exit()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.ctrl && key.name === 'e') {
|
||||||
|
this.toggleOutputTruncation()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.ctrl && key.name === 'right') {
|
||||||
|
this.toggleView('next')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.ctrl && key.name === 'left') {
|
||||||
|
this.toggleView('prev')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this._interval = setInterval(() => {
|
||||||
|
this.render()
|
||||||
|
}, this._spinnerInterval)
|
||||||
|
|
||||||
|
readline.emitKeypressEvents(process.stdin)
|
||||||
|
|
||||||
|
process.stdin.setRawMode(true)
|
||||||
|
|
||||||
|
process.stdin.on('keypress', this.handleKeyPress)
|
||||||
|
|
||||||
|
this.startInactivityTimeout()
|
||||||
|
|
||||||
|
this._isClosed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (this._isClosed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._interval) {
|
||||||
|
clearInterval(this._interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._inactivityTimeout) {
|
||||||
|
clearTimeout(this._inactivityTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdin.setRawMode(false)
|
||||||
|
|
||||||
|
// Remove the keypress listener:
|
||||||
|
process.stdin.off('keypress', this.handleKeyPress)
|
||||||
|
|
||||||
|
// Restore the original `process.stdout.write()` and `process.stderr.write()` functions:
|
||||||
|
process.stdout.write = originalStdoutWrite
|
||||||
|
process.stderr.write = originalStderrWrite
|
||||||
|
|
||||||
|
const finalLines = [
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
bgWhite(black(' Completed all tasks. ')),
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
bgWhite(black(' stderr: ')),
|
||||||
|
'',
|
||||||
|
this._stderrBuffer.join(''),
|
||||||
|
'',
|
||||||
|
bgWhite(black(' stdout: ')),
|
||||||
|
'',
|
||||||
|
this._stdoutBuffer.join(''),
|
||||||
|
''
|
||||||
|
]
|
||||||
|
|
||||||
|
process.stderr.write(finalLines.join('\n'))
|
||||||
|
|
||||||
|
// Pause the reading of stdin so that the Node.js process will exit once done:
|
||||||
|
process.stdin.pause()
|
||||||
|
|
||||||
|
this._isClosed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
this.clearAndSetCursorPosition()
|
||||||
|
this._renderingPaused = true
|
||||||
|
}
|
||||||
|
|
||||||
|
resume() {
|
||||||
|
this._renderingPaused = false
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
stringify(value: any): string {
|
||||||
|
if (this._truncateOutput) {
|
||||||
|
const json = JSON.stringify(value)
|
||||||
|
if (json.length < 40) {
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.slice(0, 20) + '...' + json.slice(-20)
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleOutputTruncation() {
|
||||||
|
this._truncateOutput = !this._truncateOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
startInactivityTimeout() {
|
||||||
|
this._inactivityTimeout = setTimeout(() => {
|
||||||
|
const unfinishedTasks = this._root.all((node) => {
|
||||||
|
return (
|
||||||
|
node.model.status == TaskStatus.RUNNING ||
|
||||||
|
node.model.status == TaskStatus.RETRYING ||
|
||||||
|
node.model.status == TaskStatus.PENDING
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (unfinishedTasks.length === 0) {
|
||||||
|
this.close()
|
||||||
|
} else {
|
||||||
|
this.startInactivityTimeout()
|
||||||
|
}
|
||||||
|
}, this._inactivityInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
addEvent<TInput, TOutput>(event: TaskEvent<TInput, TOutput>) {
|
||||||
|
const {
|
||||||
|
parentTaskId = 'root',
|
||||||
|
taskId: id,
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
inputs,
|
||||||
|
output
|
||||||
|
} = event
|
||||||
|
const parentNode = this._root.first(
|
||||||
|
(node) => node.model.id === parentTaskId
|
||||||
|
)
|
||||||
|
|
||||||
|
const existingEventNode = parentNode
|
||||||
|
? parentNode.first((node) => node.model.id === id)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (existingEventNode) {
|
||||||
|
existingEventNode.model.status = status
|
||||||
|
existingEventNode.model.output = output
|
||||||
|
} else {
|
||||||
|
const node = this._tree.parse({ id, name, status, inputs, output: null })
|
||||||
|
if (parentNode) {
|
||||||
|
parentNode.addChild(node)
|
||||||
|
} else {
|
||||||
|
this._root.addChild(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStatusSymbolColor(
|
||||||
|
status: TaskStatus
|
||||||
|
): [string, (text: string) => string] {
|
||||||
|
switch (status) {
|
||||||
|
case TaskStatus.COMPLETED:
|
||||||
|
return [SYMBOLS.CIRCLE, green]
|
||||||
|
case TaskStatus.FAILED:
|
||||||
|
return [SYMBOLS.CROSS, red]
|
||||||
|
case TaskStatus.RETRYING:
|
||||||
|
return [this.getSpinnerSymbol(), yellow]
|
||||||
|
case TaskStatus.RUNNING:
|
||||||
|
default:
|
||||||
|
return [this.getSpinnerSymbol(), cyan]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTree(id?: string, level = 0): string[] {
|
||||||
|
const indent = SPACE.repeat(level * 2)
|
||||||
|
let lines: string[] = []
|
||||||
|
|
||||||
|
const root = id
|
||||||
|
? this._root.first((node) => node.model.id === id)
|
||||||
|
: this._root
|
||||||
|
|
||||||
|
if (root?.children) {
|
||||||
|
root.children.forEach(
|
||||||
|
({ model: { id, name, status, output, inputs } }) => {
|
||||||
|
const [statusSymbol, color] = this.getStatusSymbolColor(status)
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
indent +
|
||||||
|
color(statusSymbol) +
|
||||||
|
SPACE +
|
||||||
|
bold(name) +
|
||||||
|
gray('(' + this.stringify(inputs) + ')')
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasChildren = root.hasChildren()
|
||||||
|
|
||||||
|
if (hasChildren) {
|
||||||
|
lines = lines.concat(
|
||||||
|
this.renderTree(id, level + 1).map((line, index, arr) => {
|
||||||
|
if (index === arr.length - 1) {
|
||||||
|
return indent + gray(SYMBOLS.BAR) + line
|
||||||
|
}
|
||||||
|
|
||||||
|
return indent + gray(SYMBOLS.BAR) + line
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = ''
|
||||||
|
if (hasChildren) {
|
||||||
|
line = indent + gray(SYMBOLS.BAR_END)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedOutput = this.stringify(output || '')
|
||||||
|
if (status === TaskStatus.COMPLETED) {
|
||||||
|
line +=
|
||||||
|
indent +
|
||||||
|
' ' +
|
||||||
|
gray(SYMBOLS.RIGHT_ARROW + SPACE + formattedOutput)
|
||||||
|
} else if (status === TaskStatus.FAILED) {
|
||||||
|
line +=
|
||||||
|
indent +
|
||||||
|
' ' +
|
||||||
|
gray(SYMBOLS.RIGHT_ARROW) +
|
||||||
|
SPACE +
|
||||||
|
red(formattedOutput)
|
||||||
|
} else if (status === TaskStatus.RETRYING) {
|
||||||
|
line +=
|
||||||
|
indent +
|
||||||
|
' ' +
|
||||||
|
yellow(SYMBOLS.WARNING) +
|
||||||
|
SPACE +
|
||||||
|
gray(formattedOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(line)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAndSetCursorPosition() {
|
||||||
|
process.stderr.cursorTo(0, 0)
|
||||||
|
process.stderr.clearScreenDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPreviousRender(linesCount: number) {
|
||||||
|
for (let i = 0; i < linesCount; i++) {
|
||||||
|
process.stderr.moveCursor(0, -1)
|
||||||
|
process.stderr.clearLine(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeWithMagicString(content: string | string[]) {
|
||||||
|
let output
|
||||||
|
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
if (content.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
output = content.join('\n')
|
||||||
|
} else {
|
||||||
|
output = content
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stderr.write(MAGIC_STRING + output)
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleView(direction: string) {
|
||||||
|
const viewModes = ['tasks', 'stdout', 'stderr']
|
||||||
|
const currentIdx = viewModes.indexOf(this._viewMode)
|
||||||
|
|
||||||
|
if (direction === 'next') {
|
||||||
|
this._viewMode = viewModes[(currentIdx + 1) % viewModes.length]
|
||||||
|
} else if (direction === 'prev') {
|
||||||
|
this._viewMode =
|
||||||
|
viewModes[(currentIdx - 1 + viewModes.length) % viewModes.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
getSpinnerSymbol(): string {
|
||||||
|
return SYMBOLS.SPINNER[
|
||||||
|
Math.floor(Date.now() / this._spinnerInterval) % SYMBOLS.SPINNER.length
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHeader() {
|
||||||
|
const commands = [
|
||||||
|
'ctrl+c: exit',
|
||||||
|
`ctrl+e: ${
|
||||||
|
this._truncateOutput ? TWO_SPACES + 'expand' : 'truncate'
|
||||||
|
} output`,
|
||||||
|
'ctrl+left/right: switch view'
|
||||||
|
].join(' | ')
|
||||||
|
|
||||||
|
const header = [
|
||||||
|
` Agentic - ${capitalize(this._viewMode)} View`,
|
||||||
|
' ' + commands + ' ',
|
||||||
|
'',
|
||||||
|
''
|
||||||
|
].join('\n')
|
||||||
|
this.writeWithMagicString(bgWhite(black(header)))
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this._renderingPaused) {
|
||||||
|
return // Do not render if paused
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearAndSetCursorPosition()
|
||||||
|
if (this._viewMode === 'tasks') {
|
||||||
|
const lines = this.renderTree('root')
|
||||||
|
this.clearPreviousRender(lines.length)
|
||||||
|
this.renderHeader()
|
||||||
|
this.writeWithMagicString(lines)
|
||||||
|
} else if (this._viewMode === 'stdout') {
|
||||||
|
this.clearPreviousRender(this._stdoutBuffer.length)
|
||||||
|
this.renderHeader()
|
||||||
|
this.writeWithMagicString(this._stdoutBuffer)
|
||||||
|
} else if (this._viewMode === 'stderr') {
|
||||||
|
this.clearPreviousRender(this._stderrBuffer.length)
|
||||||
|
this.renderHeader()
|
||||||
|
this.writeWithMagicString(this._stderrBuffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,10 @@
|
||||||
export * from './agentic'
|
export * from './agentic'
|
||||||
export * from './task'
|
|
||||||
export * from './constants'
|
export * from './constants'
|
||||||
export * from './errors'
|
export * from './errors'
|
||||||
export * from './tokenizer'
|
export * from './events'
|
||||||
export * from './human-feedback'
|
export * from './human-feedback'
|
||||||
|
export * from './task'
|
||||||
|
export * from './tokenizer'
|
||||||
|
|
||||||
export * from './llms'
|
export * from './llms'
|
||||||
export * from './services'
|
export * from './services'
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { cyan, green, magenta, red, yellow } from 'colorette'
|
import { cyan, green, magenta, red, yellow } from 'colorette'
|
||||||
import logger from 'debug'
|
import logger from 'debug'
|
||||||
|
|
||||||
|
import { SPACE } from '@/constants'
|
||||||
import { identity } from '@/utils'
|
import { identity } from '@/utils'
|
||||||
|
|
||||||
import { getEnv } from './env'
|
import { getEnv } from './env'
|
||||||
|
@ -57,7 +58,6 @@ if (LOG_LEVEL === undefined) {
|
||||||
|
|
||||||
const debug = logger('agentic')
|
const debug = logger('agentic')
|
||||||
|
|
||||||
const SPACE = ' '
|
|
||||||
const INDENT = SPACE.repeat(23)
|
const INDENT = SPACE.repeat(23)
|
||||||
|
|
||||||
// Override the default logger to add a timestamp and severity level to the logged arguments:
|
// Override the default logger to add a timestamp and severity level to the logged arguments:
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import EventEmitter from 'eventemitter3'
|
||||||
import pRetry, { FailedAttemptError } from 'p-retry'
|
import pRetry, { FailedAttemptError } from 'p-retry'
|
||||||
import QuickLRU from 'quick-lru'
|
import QuickLRU from 'quick-lru'
|
||||||
import { ZodType } from 'zod'
|
import { ZodType } from 'zod'
|
||||||
|
@ -6,6 +7,7 @@ import * as errors from './errors'
|
||||||
import * as types from './types'
|
import * as types from './types'
|
||||||
import type { Agentic } from './agentic'
|
import type { Agentic } from './agentic'
|
||||||
import { SKIP_HOOKS } from './constants'
|
import { SKIP_HOOKS } from './constants'
|
||||||
|
import { TaskEvent, TaskStatus } from './events'
|
||||||
import {
|
import {
|
||||||
HumanFeedbackMechanismCLI,
|
HumanFeedbackMechanismCLI,
|
||||||
HumanFeedbackOptions,
|
HumanFeedbackOptions,
|
||||||
|
@ -29,7 +31,7 @@ import { defaultIDGeneratorFn, isValidTaskIdentifier } from './utils'
|
||||||
export abstract class BaseTask<
|
export abstract class BaseTask<
|
||||||
TInput extends types.TaskInput = void,
|
TInput extends types.TaskInput = void,
|
||||||
TOutput extends types.TaskOutput = string
|
TOutput extends types.TaskOutput = string
|
||||||
> {
|
> extends EventEmitter {
|
||||||
protected _agentic: Agentic
|
protected _agentic: Agentic
|
||||||
protected _id: string
|
protected _id: string
|
||||||
|
|
||||||
|
@ -48,6 +50,8 @@ export abstract class BaseTask<
|
||||||
}> = []
|
}> = []
|
||||||
|
|
||||||
constructor(options: types.BaseTaskOptions = {}) {
|
constructor(options: types.BaseTaskOptions = {}) {
|
||||||
|
super()
|
||||||
|
|
||||||
this._agentic = options.agentic ?? globalThis.__agentic?.deref()
|
this._agentic = options.agentic ?? globalThis.__agentic?.deref()
|
||||||
|
|
||||||
this._timeoutMs = options.timeoutMs
|
this._timeoutMs = options.timeoutMs
|
||||||
|
@ -190,7 +194,9 @@ export abstract class BaseTask<
|
||||||
})
|
})
|
||||||
|
|
||||||
this.addAfterCallHook(async (output, ctx) => {
|
this.addAfterCallHook(async (output, ctx) => {
|
||||||
|
this._agentic.taskTracker.pause()
|
||||||
const feedback = await feedbackMechanism.interact(output)
|
const feedback = await feedbackMechanism.interact(output)
|
||||||
|
this._agentic.taskTracker.resume()
|
||||||
ctx.metadata = { ...ctx.metadata, feedback }
|
ctx.metadata = { ...ctx.metadata, feedback }
|
||||||
if (feedback.editedOutput) {
|
if (feedback.editedOutput) {
|
||||||
return feedback.editedOutput
|
return feedback.editedOutput
|
||||||
|
@ -272,6 +278,11 @@ export abstract class BaseTask<
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.emit(TaskStatus.RUNNING, {
|
||||||
|
taskInputs: input,
|
||||||
|
...ctx.metadata
|
||||||
|
})
|
||||||
|
|
||||||
for (const { hook: preHook } of this._preHooks) {
|
for (const { hook: preHook } of this._preHooks) {
|
||||||
const preHookResult = await preHook(ctx)
|
const preHookResult = await preHook(ctx)
|
||||||
if (preHookResult === SKIP_HOOKS) {
|
if (preHookResult === SKIP_HOOKS) {
|
||||||
|
@ -342,6 +353,12 @@ export abstract class BaseTask<
|
||||||
ctx.attemptNumber = err.attemptNumber + 1
|
ctx.attemptNumber = err.attemptNumber + 1
|
||||||
ctx.metadata.error = err
|
ctx.metadata.error = err
|
||||||
|
|
||||||
|
this.emit(TaskStatus.RETRYING, {
|
||||||
|
taskInputs: input,
|
||||||
|
taskOutput: err,
|
||||||
|
...ctx.metadata
|
||||||
|
})
|
||||||
|
|
||||||
if (err instanceof errors.ZodOutputValidationError) {
|
if (err instanceof errors.ZodOutputValidationError) {
|
||||||
ctx.retryMessage = err.message
|
ctx.retryMessage = err.message
|
||||||
return
|
return
|
||||||
|
@ -365,6 +382,12 @@ export abstract class BaseTask<
|
||||||
// task for now.
|
// task for now.
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
|
this.emit(TaskStatus.FAILED, {
|
||||||
|
taskInputs: input,
|
||||||
|
taskOutput: err,
|
||||||
|
...ctx.metadata
|
||||||
|
})
|
||||||
|
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -381,6 +404,12 @@ export abstract class BaseTask<
|
||||||
|
|
||||||
// ctx.tracker.setOutput(stringifyForDebugging(result, { maxLength: 100 }))
|
// ctx.tracker.setOutput(stringifyForDebugging(result, { maxLength: 100 }))
|
||||||
|
|
||||||
|
this.emit(TaskStatus.COMPLETED, {
|
||||||
|
taskInputs: input,
|
||||||
|
taskOutput: result,
|
||||||
|
...ctx.metadata
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
result,
|
result,
|
||||||
metadata: ctx.metadata
|
metadata: ctx.metadata
|
||||||
|
@ -397,4 +426,33 @@ export abstract class BaseTask<
|
||||||
// input: TInput,
|
// input: TInput,
|
||||||
// onProgress: types.ProgressFunction
|
// onProgress: types.ProgressFunction
|
||||||
// }): Promise<TOutput>
|
// }): Promise<TOutput>
|
||||||
|
|
||||||
|
on<T extends string | symbol>(
|
||||||
|
takStatus: T,
|
||||||
|
fn: (event: TaskEvent<TInput, TOutput>) => void,
|
||||||
|
context?: any
|
||||||
|
): this {
|
||||||
|
return super.on(takStatus, fn, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(taskStatus: string | symbol, payload: object = {}): boolean {
|
||||||
|
if (!Object.values(TaskStatus).includes(taskStatus as TaskStatus)) {
|
||||||
|
throw new Error(`Invalid task status: ${String(taskStatus)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, nameForModel } = this
|
||||||
|
const event = new TaskEvent<TInput, TOutput>({
|
||||||
|
payload: {
|
||||||
|
taskStatus: taskStatus as TaskStatus,
|
||||||
|
taskId: id,
|
||||||
|
taskName: nameForModel,
|
||||||
|
...payload
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this._agentic.taskTracker.addEvent(event)
|
||||||
|
|
||||||
|
this._agentic.emit(taskStatus, event)
|
||||||
|
|
||||||
|
return super.emit(taskStatus, event)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -254,3 +254,7 @@ export function isArray(value: any): value is any[] {
|
||||||
export function identity<T>(x: T): T {
|
export function identity<T>(x: T): T {
|
||||||
return x
|
return x
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function capitalize(str: string): string {
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
lockfileVersion: '6.0'
|
lockfileVersion: '6.1'
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
|
@ -125,6 +125,9 @@ importers:
|
||||||
debug:
|
debug:
|
||||||
specifier: ^4.3.4
|
specifier: ^4.3.4
|
||||||
version: 4.3.4
|
version: 4.3.4
|
||||||
|
eventemitter3:
|
||||||
|
specifier: ^5.0.1
|
||||||
|
version: 5.0.1
|
||||||
expr-eval:
|
expr-eval:
|
||||||
specifier: ^2.0.2
|
specifier: ^2.0.2
|
||||||
version: 2.0.2
|
version: 2.0.2
|
||||||
|
@ -176,6 +179,9 @@ importers:
|
||||||
replicate:
|
replicate:
|
||||||
specifier: ^0.12.3
|
specifier: ^0.12.3
|
||||||
version: 0.12.3
|
version: 0.12.3
|
||||||
|
tree-model:
|
||||||
|
specifier: ^1.0.7
|
||||||
|
version: 1.0.7
|
||||||
ts-dedent:
|
ts-dedent:
|
||||||
specifier: ^2.2.0
|
specifier: ^2.2.0
|
||||||
version: 2.2.0
|
version: 2.2.0
|
||||||
|
@ -3222,6 +3228,10 @@ packages:
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/eventemitter3@5.0.1:
|
||||||
|
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/execa@0.8.0:
|
/execa@0.8.0:
|
||||||
resolution: {integrity: sha512-zDWS+Rb1E8BlqqhALSt9kUhss8Qq4nN3iof3gsOdyINksElaPyNBtKUMTR62qhvgVWR0CqCX7sdnKe4MnUbFEA==}
|
resolution: {integrity: sha512-zDWS+Rb1E8BlqqhALSt9kUhss8Qq4nN3iof3gsOdyINksElaPyNBtKUMTR62qhvgVWR0CqCX7sdnKe4MnUbFEA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
@ -3359,6 +3369,10 @@ packages:
|
||||||
to-regex-range: 5.0.1
|
to-regex-range: 5.0.1
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/find-insert-index@0.0.1:
|
||||||
|
resolution: {integrity: sha512-eIqFuQzY7XwpAJ3sHWKFNGLx1nm3w/IhmFASETcx5sUuCaOUd3xDqRK/376SzXMVVJQaJUCPlS7L841T0xpFjQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/find-up@4.1.0:
|
/find-up@4.1.0:
|
||||||
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
@ -4834,6 +4848,10 @@ packages:
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/mergesort@0.0.1:
|
||||||
|
resolution: {integrity: sha512-WKghTBzqAvTt9rG5TWS78Dmk2kCCL9VkkX8Zi9kKfJ4iqYpvcGGpeYtkhPHa9NZAPLivZiZsdO/LBG3ENayDmQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/mermaid@10.2.3:
|
/mermaid@10.2.3:
|
||||||
resolution: {integrity: sha512-cMVE5s9PlQvOwfORkyVpr5beMsLdInrycAosdr+tpZ0WFjG4RJ/bUHST7aTgHNJbujHkdBRAm+N50P3puQOfPw==}
|
resolution: {integrity: sha512-cMVE5s9PlQvOwfORkyVpr5beMsLdInrycAosdr+tpZ0WFjG4RJ/bUHST7aTgHNJbujHkdBRAm+N50P3puQOfPw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -6928,6 +6946,13 @@ packages:
|
||||||
hasBin: true
|
hasBin: true
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/tree-model@1.0.7:
|
||||||
|
resolution: {integrity: sha512-oP4LUbCVtD2gcjcRaeI4L5hY60tHzB+AK/bthIJ2Pq1EUUOio5/xFzPWnGoBZlhtqpqbOkhFDzKIwKLOn0kccQ==}
|
||||||
|
dependencies:
|
||||||
|
find-insert-index: 0.0.1
|
||||||
|
mergesort: 0.0.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
/trim-lines@3.0.1:
|
/trim-lines@3.0.1:
|
||||||
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
|
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
Ładowanie…
Reference in New Issue