chore: ui folder structure
This commit is contained in:
649
packages/ui/src/panel/Panel.ts
Normal file
649
packages/ui/src/panel/Panel.ts
Normal file
@@ -0,0 +1,649 @@
|
||||
import { I18n, type SupportedLanguage } from '../i18n'
|
||||
import { truncate } from '../utils'
|
||||
import { createCard, createReflectionLines, formatTime } from './cards'
|
||||
import type { AgentActivity, PanelAgentAdapter } from './types'
|
||||
|
||||
import styles from './Panel.module.css'
|
||||
|
||||
/**
|
||||
* Panel configuration
|
||||
*/
|
||||
export interface PanelConfig {
|
||||
language?: SupportedLanguage
|
||||
/**
|
||||
* Whether to prompt for next task after task completion
|
||||
* @default true
|
||||
*/
|
||||
promptForNextTask?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent control panel
|
||||
*
|
||||
* Architecture:
|
||||
* - History list: renders directly from agent.history (historical events)
|
||||
* - Header bar: shows activity events (transient state) and agent status
|
||||
*
|
||||
* This separation ensures data consistency - history is the single source of truth
|
||||
* for what has been done, while activity shows what is happening now.
|
||||
*/
|
||||
export class Panel {
|
||||
#wrapper: HTMLElement
|
||||
#indicator: HTMLElement
|
||||
#statusText: HTMLElement
|
||||
#historySection: HTMLElement
|
||||
#expandButton: HTMLElement
|
||||
#stopButton: HTMLElement
|
||||
#inputSection: HTMLElement
|
||||
#taskInput: HTMLInputElement
|
||||
|
||||
#agent: PanelAgentAdapter
|
||||
#config: PanelConfig
|
||||
#isExpanded = false
|
||||
#i18n: I18n
|
||||
#userAnswerResolver: ((input: string) => void) | null = null
|
||||
#isWaitingForUserAnswer: boolean = false
|
||||
#headerUpdateTimer: ReturnType<typeof setInterval> | null = null
|
||||
#pendingHeaderText: string | null = null
|
||||
#isAnimating = false
|
||||
|
||||
// Event handlers (bound for removal)
|
||||
#onStatusChange = () => this.#handleStatusChange()
|
||||
#onHistoryChange = () => this.#handleHistoryChange()
|
||||
#onActivity = (e: Event) => this.#handleActivity((e as CustomEvent<AgentActivity>).detail)
|
||||
#onAgentDispose = () => this.dispose()
|
||||
|
||||
get wrapper(): HTMLElement {
|
||||
return this.#wrapper
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Panel bound to an agent
|
||||
* @param agent - Agent instance that implements PanelAgentAdapter
|
||||
* @param config - Optional panel configuration
|
||||
*/
|
||||
constructor(agent: PanelAgentAdapter, config: PanelConfig = {}) {
|
||||
this.#agent = agent
|
||||
this.#config = config
|
||||
this.#i18n = new I18n(config.language ?? 'en-US')
|
||||
|
||||
// Set up askUser callback on agent
|
||||
this.#agent.onAskUser = (question) => this.#askUser(question)
|
||||
|
||||
// Create UI elements
|
||||
this.#wrapper = this.#createWrapper()
|
||||
this.#indicator = this.#wrapper.querySelector(`.${styles.indicator}`)!
|
||||
this.#statusText = this.#wrapper.querySelector(`.${styles.statusText}`)!
|
||||
this.#historySection = this.#wrapper.querySelector(`.${styles.historySection}`)!
|
||||
this.#expandButton = this.#wrapper.querySelector(`.${styles.expandButton}`)!
|
||||
this.#stopButton = this.#wrapper.querySelector(`.${styles.stopButton}`)!
|
||||
this.#inputSection = this.#wrapper.querySelector(`.${styles.inputSectionWrapper}`)!
|
||||
this.#taskInput = this.#wrapper.querySelector(`.${styles.taskInput}`)!
|
||||
|
||||
// Listen to agent events
|
||||
this.#agent.addEventListener('statuschange', this.#onStatusChange)
|
||||
this.#agent.addEventListener('historychange', this.#onHistoryChange)
|
||||
this.#agent.addEventListener('activity', this.#onActivity)
|
||||
this.#agent.addEventListener('dispose', this.#onAgentDispose)
|
||||
|
||||
this.#setupEventListeners()
|
||||
this.#startHeaderUpdateLoop()
|
||||
|
||||
this.#showInputArea()
|
||||
|
||||
this.hide() // Start hidden
|
||||
}
|
||||
|
||||
// ========== Agent event handlers ==========
|
||||
|
||||
/** Handle agent status change */
|
||||
#handleStatusChange(): void {
|
||||
const status = this.#agent.status
|
||||
|
||||
// Map agent status to UI indicator type
|
||||
const indicatorType =
|
||||
status === 'running' ? 'thinking' : status === 'idle' ? 'thinking' : status
|
||||
this.#updateStatusIndicator(indicatorType)
|
||||
|
||||
// Show/hide based on status
|
||||
if (status === 'running') {
|
||||
this.show()
|
||||
this.#hideInputArea() // Hide input while running
|
||||
}
|
||||
|
||||
// Handle completion
|
||||
if (status === 'completed' || status === 'error') {
|
||||
if (!this.#isExpanded) {
|
||||
this.#expand()
|
||||
}
|
||||
if (this.#shouldShowInputArea()) {
|
||||
this.#showInputArea()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle agent history change - re-render history list from agent.history */
|
||||
#handleHistoryChange(): void {
|
||||
this.#renderHistory()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle agent activity - transient state for immediate UI feedback
|
||||
* Activity events are NOT persisted in history, only used for header bar updates
|
||||
*/
|
||||
#handleActivity(activity: AgentActivity): void {
|
||||
switch (activity.type) {
|
||||
case 'thinking':
|
||||
this.#pendingHeaderText = this.#i18n.t('ui.panel.thinking')
|
||||
this.#updateStatusIndicator('thinking')
|
||||
break
|
||||
|
||||
case 'executing':
|
||||
this.#pendingHeaderText = this.#getToolExecutingText(activity.tool, activity.input)
|
||||
this.#updateStatusIndicator('executing')
|
||||
break
|
||||
|
||||
case 'executed':
|
||||
this.#pendingHeaderText = truncate(activity.output, 50)
|
||||
break
|
||||
|
||||
case 'retrying':
|
||||
this.#pendingHeaderText = `Retrying (${activity.attempt}/${activity.maxAttempts})`
|
||||
this.#updateStatusIndicator('retrying')
|
||||
break
|
||||
|
||||
case 'error':
|
||||
this.#pendingHeaderText = truncate(activity.message, 50)
|
||||
this.#updateStatusIndicator('error')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for user input (internal, called by agent via onAskUser)
|
||||
*/
|
||||
#askUser(question: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
// Set `waiting for user answer` state
|
||||
this.#isWaitingForUserAnswer = true
|
||||
this.#userAnswerResolver = resolve
|
||||
|
||||
// Expand history panel
|
||||
if (!this.#isExpanded) {
|
||||
this.#expand()
|
||||
}
|
||||
|
||||
// Add temporary question card so user can see the full question
|
||||
const tempCard = document.createElement('div')
|
||||
tempCard.innerHTML = createCard({
|
||||
icon: '❓',
|
||||
content: `Question: ${question}`,
|
||||
meta: formatTime(),
|
||||
type: 'question',
|
||||
})
|
||||
const cardElement = tempCard.firstElementChild as HTMLElement
|
||||
cardElement.setAttribute('data-temp-card', 'true')
|
||||
this.#historySection.appendChild(cardElement)
|
||||
this.#scrollToBottom()
|
||||
|
||||
this.#showInputArea(this.#i18n.t('ui.panel.userAnswerPrompt'))
|
||||
})
|
||||
}
|
||||
|
||||
// ========== Public control methods ==========
|
||||
|
||||
show(): void {
|
||||
this.wrapper.style.display = 'block'
|
||||
void this.wrapper.offsetHeight
|
||||
this.wrapper.style.opacity = '1'
|
||||
this.wrapper.style.transform = 'translateX(-50%) translateY(0)'
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.wrapper.style.opacity = '0'
|
||||
this.wrapper.style.transform = 'translateX(-50%) translateY(20px)'
|
||||
this.wrapper.style.display = 'none'
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.#statusText.textContent = this.#i18n.t('ui.panel.ready')
|
||||
this.#updateStatusIndicator('thinking')
|
||||
this.#renderHistory()
|
||||
this.#collapse()
|
||||
// Reset user input state
|
||||
this.#isWaitingForUserAnswer = false
|
||||
this.#userAnswerResolver = null
|
||||
// Show input area
|
||||
this.#showInputArea()
|
||||
}
|
||||
|
||||
expand(): void {
|
||||
this.#expand()
|
||||
}
|
||||
|
||||
collapse(): void {
|
||||
this.#collapse()
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose panel and clean up event listeners
|
||||
*/
|
||||
dispose(): void {
|
||||
// Remove agent event listeners
|
||||
this.#agent.removeEventListener('statuschange', this.#onStatusChange)
|
||||
this.#agent.removeEventListener('historychange', this.#onHistoryChange)
|
||||
this.#agent.removeEventListener('activity', this.#onActivity)
|
||||
this.#agent.removeEventListener('dispose', this.#onAgentDispose)
|
||||
|
||||
// Clean up UI
|
||||
this.#isWaitingForUserAnswer = false
|
||||
this.#stopHeaderUpdateLoop()
|
||||
this.wrapper.remove()
|
||||
}
|
||||
|
||||
// ========== Private methods ==========
|
||||
|
||||
#getToolExecutingText(toolName: string, args: unknown): string {
|
||||
const a = args as Record<string, string | number>
|
||||
switch (toolName) {
|
||||
case 'click_element_by_index':
|
||||
return this.#i18n.t('ui.tools.clicking', { index: a.index })
|
||||
case 'input_text':
|
||||
return this.#i18n.t('ui.tools.inputting', { index: a.index })
|
||||
case 'select_dropdown_option':
|
||||
return this.#i18n.t('ui.tools.selecting', { text: a.text })
|
||||
case 'scroll':
|
||||
return this.#i18n.t('ui.tools.scrolling')
|
||||
case 'wait':
|
||||
return this.#i18n.t('ui.tools.waiting', { seconds: a.seconds })
|
||||
case 'ask_user':
|
||||
return this.#i18n.t('ui.tools.askingUser')
|
||||
case 'done':
|
||||
return this.#i18n.t('ui.tools.done')
|
||||
default:
|
||||
return this.#i18n.t('ui.tools.executing', { toolName })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop Agent
|
||||
*/
|
||||
#stopAgent(): void {
|
||||
this.#agent.dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit task
|
||||
*/
|
||||
#submitTask() {
|
||||
const input = this.#taskInput.value.trim()
|
||||
if (!input) return
|
||||
|
||||
// Hide input area
|
||||
this.#hideInputArea()
|
||||
|
||||
if (this.#isWaitingForUserAnswer) {
|
||||
// Handle user input mode
|
||||
this.#handleUserAnswer(input)
|
||||
} else {
|
||||
// Execute task via agent
|
||||
this.#agent.execute(input)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle user answer
|
||||
*/
|
||||
#handleUserAnswer(input: string): void {
|
||||
// Remove temporary question cards (only direct children for safety)
|
||||
Array.from(this.#historySection.children).forEach((child) => {
|
||||
if (child.getAttribute('data-temp-card') === 'true') {
|
||||
child.remove()
|
||||
}
|
||||
})
|
||||
|
||||
// Reset state
|
||||
this.#isWaitingForUserAnswer = false
|
||||
|
||||
// Call resolver to return user input
|
||||
if (this.#userAnswerResolver) {
|
||||
this.#userAnswerResolver(input)
|
||||
this.#userAnswerResolver = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show input area
|
||||
*/
|
||||
#showInputArea(placeholder?: string): void {
|
||||
// Clear input field
|
||||
this.#taskInput.value = ''
|
||||
this.#taskInput.placeholder = placeholder || this.#i18n.t('ui.panel.taskInput')
|
||||
this.#inputSection.classList.remove(styles.hidden)
|
||||
// Focus on input field
|
||||
setTimeout(() => {
|
||||
this.#taskInput.focus()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide input area
|
||||
*/
|
||||
#hideInputArea(): void {
|
||||
this.#inputSection.classList.add(styles.hidden)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if input area should be shown
|
||||
*/
|
||||
#shouldShowInputArea(): boolean {
|
||||
// Always show input area if waiting for user input
|
||||
if (this.#isWaitingForUserAnswer) return true
|
||||
|
||||
const history = this.#agent.history
|
||||
if (history.length === 0) {
|
||||
return true // Initial state
|
||||
}
|
||||
|
||||
const status = this.#agent.status
|
||||
const isTaskEnded = status === 'completed' || status === 'error'
|
||||
|
||||
// Only show input area after task completion if configured to do so
|
||||
if (isTaskEnded) {
|
||||
return this.#config.promptForNextTask ?? true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
#createWrapper(): HTMLElement {
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.id = 'page-agent-runtime_agent-panel'
|
||||
wrapper.className = styles.wrapper
|
||||
wrapper.setAttribute('data-browser-use-ignore', 'true')
|
||||
|
||||
wrapper.innerHTML = `
|
||||
<div class="${styles.background}"></div>
|
||||
<div class="${styles.historySectionWrapper}">
|
||||
<div class="${styles.historySection}">
|
||||
<div class="${styles.historyItem}">
|
||||
<div class="${styles.historyContent}">
|
||||
<span class="${styles.statusIcon}">🧠</span>
|
||||
<span>${this.#i18n.t('ui.panel.waitingPlaceholder')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="${styles.header}">
|
||||
<div class="${styles.statusSection}">
|
||||
<div class="${styles.indicator} ${styles.thinking}"></div>
|
||||
<div class="${styles.statusText}">${this.#i18n.t('ui.panel.ready')}</div>
|
||||
</div>
|
||||
<div class="${styles.controls}">
|
||||
<button class="${styles.controlButton} ${styles.expandButton}" title="${this.#i18n.t('ui.panel.expand')}">
|
||||
▼
|
||||
</button>
|
||||
<button class="${styles.controlButton} ${styles.stopButton}" title="${this.#i18n.t('ui.panel.stop')}">
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="${styles.inputSectionWrapper} ${styles.hidden}">
|
||||
<div class="${styles.inputSection}">
|
||||
<input
|
||||
type="text"
|
||||
class="${styles.taskInput}"
|
||||
maxlength="200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
document.body.appendChild(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
#setupEventListeners(): void {
|
||||
// Click header area to expand/collapse
|
||||
const header = this.wrapper.querySelector(`.${styles.header}`)!
|
||||
header.addEventListener('click', (e) => {
|
||||
// Don't trigger expand/collapse if clicking on buttons
|
||||
if ((e.target as HTMLElement).closest(`.${styles.controlButton}`)) {
|
||||
return
|
||||
}
|
||||
this.#toggle()
|
||||
})
|
||||
|
||||
// Expand button
|
||||
this.#expandButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
this.#toggle()
|
||||
})
|
||||
|
||||
// Stop button
|
||||
this.#stopButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
this.#stopAgent()
|
||||
})
|
||||
|
||||
// Submit on Enter key in input field
|
||||
this.#taskInput.addEventListener('keydown', (e) => {
|
||||
if (e.isComposing) return // Ignore IME composition keys
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
this.#submitTask()
|
||||
}
|
||||
})
|
||||
|
||||
// Prevent input area click event bubbling
|
||||
this.#inputSection.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
})
|
||||
}
|
||||
|
||||
#toggle(): void {
|
||||
if (this.#isExpanded) {
|
||||
this.#collapse()
|
||||
} else {
|
||||
this.#expand()
|
||||
}
|
||||
}
|
||||
|
||||
#expand(): void {
|
||||
this.#isExpanded = true
|
||||
this.wrapper.classList.add(styles.expanded)
|
||||
this.#expandButton.textContent = '▲'
|
||||
}
|
||||
|
||||
#collapse(): void {
|
||||
this.#isExpanded = false
|
||||
this.wrapper.classList.remove(styles.expanded)
|
||||
this.#expandButton.textContent = '▼'
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic header update loop
|
||||
*/
|
||||
#startHeaderUpdateLoop(): void {
|
||||
// Check every 450ms (same as total animation duration)
|
||||
this.#headerUpdateTimer = setInterval(() => {
|
||||
this.#checkAndUpdateHeader()
|
||||
}, 450)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic header update loop
|
||||
*/
|
||||
#stopHeaderUpdateLoop(): void {
|
||||
if (this.#headerUpdateTimer) {
|
||||
clearInterval(this.#headerUpdateTimer)
|
||||
this.#headerUpdateTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if header needs update and trigger animation if not currently animating
|
||||
*/
|
||||
#checkAndUpdateHeader(): void {
|
||||
// If no pending text or currently animating, skip
|
||||
if (!this.#pendingHeaderText || this.#isAnimating) {
|
||||
return
|
||||
}
|
||||
|
||||
// If text is already displayed, clear pending and skip
|
||||
if (this.#statusText.textContent === this.#pendingHeaderText) {
|
||||
this.#pendingHeaderText = null
|
||||
return
|
||||
}
|
||||
|
||||
// Start animation
|
||||
const textToShow = this.#pendingHeaderText
|
||||
this.#pendingHeaderText = null
|
||||
this.#animateTextChange(textToShow)
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate text change with fade out/in effect
|
||||
*/
|
||||
#animateTextChange(newText: string): void {
|
||||
this.#isAnimating = true
|
||||
|
||||
// Fade out current text
|
||||
this.#statusText.classList.add(styles.fadeOut)
|
||||
|
||||
setTimeout(() => {
|
||||
// Update text content
|
||||
this.#statusText.textContent = newText
|
||||
|
||||
// Fade in new text
|
||||
this.#statusText.classList.remove(styles.fadeOut)
|
||||
this.#statusText.classList.add(styles.fadeIn)
|
||||
|
||||
setTimeout(() => {
|
||||
this.#statusText.classList.remove(styles.fadeIn)
|
||||
this.#isAnimating = false
|
||||
}, 300)
|
||||
}, 150) // Half the duration of fade out animation
|
||||
}
|
||||
|
||||
#updateStatusIndicator(
|
||||
type: 'thinking' | 'executing' | 'executed' | 'retrying' | 'completed' | 'error'
|
||||
): void {
|
||||
// Clear all status classes
|
||||
this.#indicator.className = styles.indicator
|
||||
|
||||
// Add corresponding status class
|
||||
this.#indicator.classList.add(styles[type])
|
||||
}
|
||||
|
||||
#scrollToBottom(): void {
|
||||
// Execute in next event loop to ensure DOM update completion
|
||||
setTimeout(() => {
|
||||
this.#historySection.scrollTop = this.#historySection.scrollHeight
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render history directly from agent.history
|
||||
*
|
||||
* Renders:
|
||||
* 1. Task (first item, from agent.task)
|
||||
* 2. Reflection cards (evaluation, memory, next_goal)
|
||||
* 3. Tool execution with output
|
||||
* 4. Observations
|
||||
*/
|
||||
#renderHistory(): void {
|
||||
const items: string[] = []
|
||||
|
||||
// 1. Task card (always first)
|
||||
const task = this.#agent.task
|
||||
if (task) {
|
||||
items.push(this.#createTaskCard(task))
|
||||
}
|
||||
|
||||
// 2. Render each history event
|
||||
const history = this.#agent.history
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
const event = history[i]
|
||||
items.push(...this.#createHistoryCards(event, i + 1))
|
||||
}
|
||||
|
||||
this.#historySection.innerHTML = items.join('')
|
||||
this.#scrollToBottom()
|
||||
}
|
||||
|
||||
#createTaskCard(task: string): string {
|
||||
return createCard({ icon: '🎯', content: task, type: 'input' })
|
||||
}
|
||||
|
||||
/** Create cards for a history event */
|
||||
#createHistoryCards(event: PanelAgentAdapter['history'][number], stepNumber: number): string[] {
|
||||
const cards: string[] = []
|
||||
const time = formatTime()
|
||||
const meta = this.#i18n.t('ui.panel.step', {
|
||||
number: stepNumber.toString(),
|
||||
time,
|
||||
duration: '',
|
||||
})
|
||||
|
||||
if (event.type === 'step') {
|
||||
// Reflection card
|
||||
if (event.reflection) {
|
||||
const lines = createReflectionLines(event.reflection)
|
||||
if (lines.length > 0) {
|
||||
cards.push(createCard({ icon: '🧠', content: lines, meta }))
|
||||
}
|
||||
}
|
||||
|
||||
// Action card
|
||||
const action = event.action
|
||||
if (action) {
|
||||
cards.push(...this.#createActionCards(action, meta))
|
||||
}
|
||||
} else if (event.type === 'observation') {
|
||||
cards.push(
|
||||
createCard({ icon: '👁️', content: event.content || '', meta, type: 'observation' })
|
||||
)
|
||||
} else if (event.type === 'user_takeover') {
|
||||
cards.push(createCard({ icon: '👤', content: 'User takeover', meta, type: 'input' }))
|
||||
}
|
||||
|
||||
return cards
|
||||
}
|
||||
|
||||
/** Create cards for an action */
|
||||
#createActionCards(
|
||||
action: { name: string; input: unknown; output: string },
|
||||
meta: string
|
||||
): string[] {
|
||||
const cards: string[] = []
|
||||
|
||||
if (action.name === 'done') {
|
||||
const input = action.input as { text?: string }
|
||||
const text = input.text || action.output || ''
|
||||
if (text) {
|
||||
cards.push(createCard({ icon: '🤖', content: text, meta, type: 'output' }))
|
||||
}
|
||||
} else if (action.name === 'ask_user') {
|
||||
const input = action.input as { question?: string }
|
||||
const answer = action.output.replace(/^User answered:\s*/i, '')
|
||||
cards.push(
|
||||
createCard({
|
||||
icon: '❓',
|
||||
content: `Question: ${input.question || ''}`,
|
||||
meta,
|
||||
type: 'question',
|
||||
})
|
||||
)
|
||||
cards.push(createCard({ icon: '💬', content: `Answer: ${answer}`, meta, type: 'input' }))
|
||||
} else {
|
||||
const toolText = this.#getToolExecutingText(action.name, action.input)
|
||||
cards.push(createCard({ icon: '🔨', content: toolText, meta }))
|
||||
if (action.output?.length > 0) {
|
||||
cards.push(createCard({ icon: '🔨', content: action.output, meta, type: 'output' }))
|
||||
}
|
||||
}
|
||||
|
||||
return cards
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user