feat!: redo Panel; decouple Panel from Agent

BREAKING CHANGES: Agent and Panel API Changes
This commit is contained in:
Simon
2026-01-17 23:14:26 +08:00
parent 8abe3afed9
commit 1ede1d9911
13 changed files with 565 additions and 466 deletions

View File

@@ -56,9 +56,9 @@ export class LLM extends EventTarget {
// retry settings
{
maxRetries: this.config.maxRetries,
onRetry: (current: number) => {
onRetry: (attempt: number) => {
this.dispatchEvent(
new CustomEvent('retry', { detail: { current, max: this.config.maxRetries } })
new CustomEvent('retry', { detail: { attempt, maxAttempts: this.config.maxRetries } })
)
},
onError: (error: Error) => {
@@ -73,15 +73,15 @@ async function withRetry<T>(
fn: () => Promise<T>,
settings: {
maxRetries: number
onRetry: (retries: number) => void
onRetry: (attempt: number) => void
onError: (error: Error) => void
}
): Promise<T> {
let retries = 0
let attempt = 0
let lastError: Error | null = null
while (retries <= settings.maxRetries) {
if (retries > 0) {
settings.onRetry(retries)
while (attempt <= settings.maxRetries) {
if (attempt > 0) {
settings.onRetry(attempt)
await new Promise((resolve) => setTimeout(resolve, 100))
}
@@ -98,7 +98,7 @@ async function withRetry<T>(
if (error instanceof InvokeError && !error.retryable) throw error
lastError = error as Error
retries++
attempt++
await new Promise((resolve) => setTimeout(resolve, 100))
}

View File

@@ -4,7 +4,6 @@
*/
import { LLM, type Tool } from '@page-agent/llms'
import { PageController } from '@page-agent/page-controller'
import { Panel } from '@page-agent/ui'
import chalk from 'chalk'
import zod from 'zod'
@@ -85,30 +84,69 @@ export interface UserTakeoverEvent {
type: 'user_takeover'
}
/**
* Error event (retry or error from LLM)
*/
export interface ErrorEvent {
type: 'error'
errorType: 'retry' | 'error'
message: string
attempt?: number
maxAttempts?: number
}
/**
* Union type for all history events
*/
export type HistoryEvent = AgentStep | ObservationEvent | UserTakeoverEvent
export type HistoricalEvent = AgentStep | ObservationEvent | UserTakeoverEvent | ErrorEvent
/**
* Agent execution status
*/
export type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
/**
* Agent activity - transient state for immediate UI feedback.
*
* Unlike historical events (which are persisted), activities are ephemeral
* and represent "what the agent is doing right now". UI components should
* listen to 'activity' events to show real-time feedback.
*
* Note: There is no 'idle' activity - absence of activity events means idle.
*/
export type AgentActivity =
| { type: 'thinking' }
| { type: 'executing'; tool: string; input: unknown }
| { type: 'executed'; tool: string; input: unknown; output: string; duration: number }
| { type: 'retrying'; attempt: number; maxAttempts: number }
| { type: 'error'; message: string }
export interface ExecutionResult {
success: boolean
data: string
history: HistoryEvent[]
history: HistoricalEvent[]
}
export class PageAgent extends EventTarget {
config: PageAgentConfig
id = uid()
panel: Panel | null = null
tools: typeof tools
disposed = false
task = ''
taskId = ''
/** Agent execution status */
#status: AgentStatus = 'idle'
/**
* Callback for when agent needs user input (ask_user tool)
* If not set, ask_user tool will be disabled
* @example onAskUser: (q) => window.prompt(q) || ''
*/
onAskUser?: (question: string) => Promise<string>
#llm: LLM
#abortController = new AbortController()
#llmRetryListener: ((e: Event) => void) | null = null
#llmErrorListener: ((e: Event) => void) | null = null
#beforeUnloadListener: ((e: Event) => void) | null = null
/** PageController for DOM operations */
@@ -123,24 +161,13 @@ export class PageAgent extends EventTarget {
}
/** History events */
history: HistoryEvent[] = []
history: HistoricalEvent[] = []
constructor(config: PageAgentConfig) {
super()
this.config = config
this.#llm = new LLM(this.config)
// Conditionally initialize Panel
if (this.config.enablePanel !== false) {
this.panel = new Panel({
language: this.config.language,
onExecuteTask: (task) => this.execute(task),
onStop: () => this.dispose(),
promptForNextTask: this.config.promptForNextTask,
})
}
this.tools = new Map(tools)
// Initialize PageController with config (mask enabled by default)
@@ -149,17 +176,32 @@ export class PageAgent extends EventTarget {
enableMask: this.config.enableMask ?? true,
})
// Listen to LLM events
this.#llmRetryListener = (e) => {
const { current, max } = (e as CustomEvent).detail
this.panel?.update({ type: 'retry', current, max })
}
this.#llmErrorListener = (e) => {
// Listen to LLM retry events
this.#llm.addEventListener('retry', (e) => {
const { attempt, maxAttempts } = (e as CustomEvent).detail
this.emitActivity({ type: 'retrying', attempt, maxAttempts })
// Also push to history for panel rendering
this.history.push({
type: 'error',
errorType: 'retry',
message: `LLM retry attempt ${attempt} of ${maxAttempts}`,
attempt,
maxAttempts,
})
this.#emitHistoryChange()
})
this.#llm.addEventListener('error', (e) => {
const { error } = (e as CustomEvent).detail
this.panel?.update({ type: 'error', message: `step failed: ${error.message}` })
}
this.#llm.addEventListener('retry', this.#llmRetryListener)
this.#llm.addEventListener('error', this.#llmErrorListener)
const message = String(error)
this.emitActivity({ type: 'error', message })
// Also push to history for panel rendering
this.history.push({
type: 'error',
errorType: 'error',
message,
})
this.#emitHistoryChange()
})
if (this.config.customTools) {
for (const [name, tool] of Object.entries(this.config.customTools)) {
@@ -175,24 +217,50 @@ export class PageAgent extends EventTarget {
this.tools.delete('execute_javascript')
}
// Disable ask_user tool if enableAskUser is false or if panel is disabled
if (this.config.enableAskUser === false || this.config.enablePanel === false) {
this.tools.delete('ask_user')
}
this.#beforeUnloadListener = (e) => {
if (!this.disposed) this.dispose('PAGE_UNLOADING')
}
window.addEventListener('beforeunload', this.#beforeUnloadListener)
}
/** Get current agent status */
get status(): AgentStatus {
return this.#status
}
/** Emit statuschange event */
#emitStatusChange(): void {
this.dispatchEvent(new Event('statuschange'))
}
/** Emit historychange event */
#emitHistoryChange(): void {
this.dispatchEvent(new Event('historychange'))
}
/**
* Emit activity event - for transient UI feedback
* @param activity - Current agent activity
*/
emitActivity(activity: AgentActivity): void {
this.dispatchEvent(new CustomEvent('activity', { detail: activity }))
}
/** Update status and emit event */
#setStatus(status: AgentStatus): void {
if (this.#status !== status) {
this.#status = status
this.#emitStatusChange()
}
}
/**
* Push a persistent observation to the history event stream.
* This will be visible in <agent_history> and remain in memory across steps.
*/
pushObservation(content: string): void {
this.history.push({ type: 'observation', content })
this.panel?.update({ type: 'observation', content })
this.#emitHistoryChange()
}
async execute(task: string): Promise<ExecutionResult> {
@@ -200,6 +268,11 @@ export class PageAgent extends EventTarget {
this.task = task
this.taskId = uid()
// Disable ask_user tool if onAskUser is not set
if (!this.onAskUser) {
this.tools.delete('ask_user')
}
const onBeforeStep = this.config.onBeforeStep || (() => void 0)
const onAfterStep = this.config.onAfterStep || (() => void 0)
const onBeforeTask = this.config.onBeforeTask || (() => void 0)
@@ -207,20 +280,17 @@ export class PageAgent extends EventTarget {
await onBeforeTask.call(this)
// Show mask and panel
// Show mask
this.pageController.showMask()
this.panel?.show()
this.panel?.reset()
this.panel?.update({ type: 'input', task: this.task })
if (this.#abortController) {
this.#abortController.abort()
this.#abortController = new AbortController()
}
this.history = []
this.#setStatus('running')
this.#emitHistoryChange()
// Reset states
this.states = {
@@ -241,9 +311,9 @@ export class PageAgent extends EventTarget {
// abort
if (this.#abortController.signal.aborted) throw new Error('AbortError')
// Update status to thinking
// Thinking
console.log(chalk.blue('Thinking...'))
this.panel?.update({ type: 'thinking' })
this.emitActivity({ type: 'thinking' })
const result = await this.#llm.invoke(
[
@@ -285,6 +355,7 @@ export class PageAgent extends EventTarget {
action,
usage: result.usage,
} as AgentStep)
this.#emitHistoryChange()
console.log(chalk.green('Step finished:'), actionName)
console.groupEnd()
@@ -318,10 +389,12 @@ export class PageAgent extends EventTarget {
}
} catch (error: unknown) {
console.error('Task failed', error)
this.#onDone(String(error), false)
const errorMessage = String(error)
this.emitActivity({ type: 'error', message: errorMessage })
this.#onDone(errorMessage, false)
const result: ExecutionResult = {
success: false,
data: String(error),
data: errorMessage,
history: this.history,
}
await onAfterTask.call(this, result)
@@ -381,7 +454,6 @@ export class PageAgent extends EventTarget {
if (reflectionText) {
console.log(reflectionText)
this.panel?.update({ type: 'thinking', text: reflectionText })
}
// Find the corresponding tool
@@ -389,7 +461,9 @@ export class PageAgent extends EventTarget {
assert(tool, `Tool ${toolName} not found. (@note should have been caught before this!!!)`)
console.log(chalk.blue.bold(`Executing tool: ${toolName}`), toolInput)
this.panel?.update({ type: 'toolExecuting', toolName, args: toolInput })
// Emit executing activity
this.emitActivity({ type: 'executing', tool: toolName, input: toolInput })
const startTime = Date.now()
@@ -399,23 +473,20 @@ export class PageAgent extends EventTarget {
const duration = Date.now() - startTime
console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result)
// Emit executed activity
this.emitActivity({
type: 'executed',
tool: toolName,
input: toolInput,
output: result,
duration,
})
// Reset wait time for non-wait tools
if (toolName !== 'wait') {
this.states.totalWaitTime = 0
}
// Briefly display execution result
this.panel?.update({
type: 'toolCompleted',
toolName,
args: toolInput,
result,
duration,
})
// Wait a moment to let user see the result
await new Promise((resolve) => setTimeout(resolve, 100))
// Return structured result
return {
input,
@@ -551,6 +622,9 @@ export class PageAgent extends EventTarget {
prompt += `<sys>${event.content}</sys>\n`
} else if (event.type === 'user_takeover') {
prompt += `<sys>User took over control and made changes to the page.</sys>\n`
} else if (event.type === 'error') {
// Error events are mainly for panel rendering, not included in LLM context
// to avoid polluting the agent's reasoning with transient errors
}
}
@@ -565,19 +639,8 @@ export class PageAgent extends EventTarget {
#onDone(text: string, success = true) {
this.pageController.cleanUpHighlights()
// Update panel status
if (success) {
this.panel?.update({ type: 'output', text })
} else {
this.panel?.update({ type: 'error', message: text })
}
// Task completed
this.panel?.update({ type: 'completed' })
this.pageController.hideMask()
this.#setStatus(success ? 'completed' : 'error')
this.#abortController.abort()
}
@@ -604,26 +667,18 @@ export class PageAgent extends EventTarget {
console.log('Disposing PageAgent...')
this.disposed = true
this.pageController.dispose()
this.panel?.dispose()
this.history = []
this.#abortController.abort(reason ?? 'PageAgent disposed')
// Clean up LLM event listeners
if (this.#llmRetryListener) {
this.#llm.removeEventListener('retry', this.#llmRetryListener)
this.#llmRetryListener = null
}
if (this.#llmErrorListener) {
this.#llm.removeEventListener('error', this.#llmErrorListener)
this.#llmErrorListener = null
}
// Clean up window event listeners
if (this.#beforeUnloadListener) {
window.removeEventListener('beforeunload', this.#beforeUnloadListener)
this.#beforeUnloadListener = null
}
// Emit dispose event for UI cleanup
this.dispatchEvent(new Event('dispose'))
this.config.onDispose?.call(this, reason)
}
}

View File

@@ -1,37 +1,18 @@
import type { LLMConfig } from '@page-agent/llms'
import type { PageControllerConfig } from '@page-agent/page-controller'
import type { SupportedLanguage } from '@page-agent/ui'
import type { ExecutionResult, HistoryEvent, PageAgent } from '../PageAgent'
import type { PageAgentTool } from '../tools'
export type { LLMConfig }
/** Supported UI languages */
export type SupportedLanguage = 'en-US' | 'zh-CN'
export interface AgentConfig {
// theme?: 'light' | 'dark'
language?: SupportedLanguage
/**
* Whether to prompt for next task after task completion
* @default true
*/
promptForNextTask?: boolean
/**
* Enable the UI panel for visual feedback and user interaction
* When disabled, the panel will not be created and all UI operations will be skipped.
* Useful for automated testing or when integrating PageAgent as a library.
* @default true
*/
enablePanel?: boolean
/**
* Enable the ask_user tool for agent to ask questions
* When disabled, the agent cannot ask user questions during execution.
* @default true
*/
enableAskUser?: boolean
/**
* Custom tools to extend PageAgent capabilities
* @experimental

View File

@@ -1,6 +1,8 @@
/**
* Auto-run entry for page-agent.js. Insert this script into your page to get page-agent functionality.
*/
import { Panel } from '@page-agent/ui'
import { PageAgent, type PageAgentConfig } from './PageAgent'
// Clean up existing instances to prevent multiple injections from bookmarklet
@@ -24,6 +26,8 @@ const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
// @todo give a switch to disable auto-init
setTimeout(() => {
const currentScript = document.currentScript as HTMLScriptElement | null
let config: PageAgentConfig
if (currentScript) {
console.log('🚀 page-agent.js detected current script:', currentScript.src)
const url = new URL(currentScript.src)
@@ -31,23 +35,22 @@ setTimeout(() => {
const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL
const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY
const language = (url.searchParams.get('lang') as 'zh-CN' | 'en-US') || 'zh-CN'
const config: PageAgentConfig = { model, baseURL, apiKey, language }
window.pageAgent = new PageAgent(config)
config = { model, baseURL, apiKey, language }
} else {
console.log('🚀 page-agent.js no current script detected, using default demo config')
const config: PageAgentConfig = {
// model: DEMO_MODEL,
// baseURL: DEMO_BASE_URL,
// apiKey: DEMO_API_KEY,
config = {
model: import.meta.env.LLM_MODEL_NAME ? import.meta.env.LLM_MODEL_NAME : DEMO_MODEL,
baseURL: import.meta.env.LLM_BASE_URL ? import.meta.env.LLM_BASE_URL : DEMO_BASE_URL,
apiKey: import.meta.env.LLM_API_KEY ? import.meta.env.LLM_API_KEY : DEMO_API_KEY,
}
window.pageAgent = new PageAgent(config)
}
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
// Create agent
window.pageAgent = new PageAgent(config)
window.pageAgent.panel!.show() // Show panel
// Create and bind Panel
const panel = new Panel(window.pageAgent, { language: config.language })
panel.show()
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
})

View File

@@ -80,11 +80,11 @@ tools.set(
question: zod.string(),
}),
execute: async function (this: PageAgent, input) {
if (!this.panel) {
throw new Error('ask_user tool requires panel to be enabled')
if (!this.onAskUser) {
throw new Error('ask_user tool requires onAskUser callback to be set')
}
const answer = await this.panel.askUser(input.question)
return `✅ Received user answer: ${answer}`
const answer = await this.onAskUser(input.question)
return `User answered: ${answer}`
},
})
)

View File

@@ -357,6 +357,11 @@
background: linear-gradient(135deg, rgba(147, 51, 234, 0.1), rgba(147, 51, 234, 0.05));
}
&.question {
border-left-color: rgb(255, 159, 67);
background: linear-gradient(135deg, rgba(255, 159, 67, 0.15), rgba(255, 159, 67, 0.08));
}
/* 突出显示 done 成功结果 */
&.doneSuccess {
background: linear-gradient(
@@ -439,7 +444,7 @@
.historyContent {
display: flex;
align-items: center;
align-items: flex-start;
gap: 8px;
word-break: break-all;
@@ -453,6 +458,12 @@
line-height: 1;
transition: all 0.3s ease;
}
.reflectionLines {
display: flex;
flex-direction: column;
gap: 4px;
}
}
.historyMeta {

View File

@@ -1,6 +1,7 @@
import { type Step, UIState } from './UIState'
import { createCard, createReflectionLines, formatTime } from './cards'
import { I18n, type SupportedLanguage } from './i18n'
import { escapeHtml, truncate } from './utils'
import type { AgentActivity, PanelAgentAdapter } from './types'
import { truncate } from './utils'
import styles from './Panel.module.css'
@@ -9,8 +10,6 @@ import styles from './Panel.module.css'
*/
export interface PanelConfig {
language?: SupportedLanguage
onExecuteTask: (task: string) => void
onStop: () => void
/**
* Whether to prompt for next task after task completion
* @default true
@@ -18,24 +17,15 @@ export interface PanelConfig {
promptForNextTask?: boolean
}
/**
* Semantic update types - Panel handles i18n internally
*/
export type PanelUpdate =
| { type: 'thinking'; text?: string } // text is optional, defaults to i18n thinking text
| { type: 'input'; task: string }
| { type: 'question'; question: string }
| { type: 'userAnswer'; input: string }
| { type: 'retry'; current: number; max: number }
| { type: 'error'; message: string }
| { type: 'output'; text: string }
| { type: 'completed' }
| { type: 'toolExecuting'; toolName: string; args: any }
| { type: 'toolCompleted'; toolName: string; args: any; result?: string; duration?: number }
| { type: 'observation'; content: string }
/**
* 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
@@ -47,9 +37,9 @@ export class Panel {
#inputSection: HTMLElement
#taskInput: HTMLInputElement
#state = new UIState()
#isExpanded = false
#agent: PanelAgentAdapter
#config: PanelConfig
#isExpanded = false
#i18n: I18n
#userAnswerResolver: ((input: string) => void) | null = null
#isWaitingForUserAnswer: boolean = false
@@ -57,13 +47,30 @@ export class Panel {
#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
}
constructor(config: PanelConfig) {
/**
* 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}`)!
@@ -73,6 +80,12 @@ export class Panel {
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()
@@ -81,24 +94,98 @@ export class Panel {
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()
}
/**
* Ask for user input
* Handle agent activity - transient state for immediate UI feedback
* Activity events are NOT persisted in history, only used for header bar updates
*/
async askUser(question: string): Promise<string> {
#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
// Update state to `running`
this.#updateInternal({
type: 'output',
displayText: this.#i18n.t('ui.panel.question', { question }),
}) // Expand history panel
// 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'))
})
}
@@ -119,10 +206,9 @@ export class Panel {
}
reset(): void {
this.#state.reset()
this.#statusText.textContent = this.#i18n.t('ui.panel.ready')
this.#updateStatusIndicator('thinking')
this.#updateHistory()
this.#renderHistory()
this.#collapse()
// Reset user input state
this.#isWaitingForUserAnswer = false
@@ -140,17 +226,16 @@ export class Panel {
}
/**
* Update panel with semantic data - i18n handled internally
*/
update(data: PanelUpdate): void {
const stepData = this.#toStepData(data)
this.#updateInternal(stepData)
}
/**
* Dispose panel
* 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()
@@ -158,69 +243,21 @@ export class Panel {
// ========== Private methods ==========
/**
* Convert semantic update to step data with i18n
*/
#toStepData(data: PanelUpdate): Omit<Step, 'id' | 'stepNumber' | 'timestamp'> {
switch (data.type) {
case 'thinking':
return { type: 'thinking', displayText: data.text ?? this.#i18n.t('ui.panel.thinking') }
case 'input':
return { type: 'input', displayText: data.task }
case 'question':
return {
type: 'output',
displayText: this.#i18n.t('ui.panel.question', { question: data.question }),
}
case 'userAnswer':
return {
type: 'input',
displayText: this.#i18n.t('ui.panel.userAnswer', { input: data.input }),
}
case 'retry':
return { type: 'retry', displayText: `retry-ing (${data.current} / ${data.max})` }
case 'error':
return { type: 'error', displayText: data.message }
case 'output':
return { type: 'output', displayText: data.text }
case 'completed':
return { type: 'completed', displayText: this.#i18n.t('ui.panel.taskCompleted') }
case 'toolExecuting':
return {
type: 'tool_executing',
toolName: data.toolName,
toolArgs: data.args,
displayText: this.#getToolExecutingText(data.toolName, data.args),
}
case 'toolCompleted': {
const displayText = this.#getToolCompletedText(data.toolName, data.args)
if (!displayText) return { type: 'tool_executing', displayText: '' } // will be filtered
return {
type: 'tool_executing',
toolName: data.toolName,
toolArgs: data.args,
toolResult: data.result,
displayText,
duration: data.duration,
}
}
case 'observation':
return { type: 'observation', displayText: data.content }
}
}
#getToolExecutingText(toolName: string, args: any): string {
#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: args.index })
return this.#i18n.t('ui.tools.clicking', { index: a.index })
case 'input_text':
return this.#i18n.t('ui.tools.inputting', { index: args.index })
return this.#i18n.t('ui.tools.inputting', { index: a.index })
case 'select_dropdown_option':
return this.#i18n.t('ui.tools.selecting', { text: args.text })
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: args.seconds })
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:
@@ -228,67 +265,11 @@ export class Panel {
}
}
#getToolCompletedText(toolName: string, args: any): string | null {
switch (toolName) {
case 'click_element_by_index':
return this.#i18n.t('ui.tools.clicked', { index: args.index })
case 'input_text':
return this.#i18n.t('ui.tools.inputted', { text: args.text })
case 'select_dropdown_option':
return this.#i18n.t('ui.tools.selected', { text: args.text })
case 'scroll':
return this.#i18n.t('ui.tools.scrolled')
case 'wait':
return this.#i18n.t('ui.tools.waited')
case 'done':
return null
default:
return null
}
}
/**
* Update status (internal)
*/
#updateInternal(stepData: Omit<Step, 'id' | 'stepNumber' | 'timestamp'>): void {
// Skip empty displayText (filtered toolCompleted for 'done')
if (!stepData.displayText) return
const step = this.#state.addStep(stepData)
// Queue header text update (will be processed by periodic check)
const headerText = truncate(step.displayText, 20)
this.#pendingHeaderText = headerText
this.#updateStatusIndicator(step.type)
this.#updateHistory()
// Auto-expand history after task completion
if (step.type === 'completed' || step.type === 'error') {
if (!this.#isExpanded) {
this.#expand()
}
}
// Control input area display based on status
if (this.#shouldShowInputArea()) {
this.#showInputArea()
} else {
this.#hideInputArea()
}
}
/**
* Stop Agent
*/
#stopAgent(): void {
// Update status display
this.#updateInternal({
type: 'error',
displayText: this.#i18n.t('ui.panel.taskTerminated'),
})
this.#config.onStop()
this.#agent.dispose()
}
/**
@@ -305,7 +286,8 @@ export class Panel {
// Handle user input mode
this.#handleUserAnswer(input)
} else {
this.#config.onExecuteTask(input)
// Execute task via agent
this.#agent.execute(input)
}
}
@@ -313,10 +295,11 @@ export class Panel {
* Handle user answer
*/
#handleUserAnswer(input: string): void {
// Add user input to history
this.#updateInternal({
type: 'input',
displayText: this.#i18n.t('ui.panel.userAnswer', { input }),
// 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
@@ -357,13 +340,13 @@ export class Panel {
// Always show input area if waiting for user input
if (this.#isWaitingForUserAnswer) return true
const steps = this.#state.getAllSteps()
if (steps.length === 0) {
const history = this.#agent.history
if (history.length === 0) {
return true // Initial state
}
const lastStep = steps[steps.length - 1]
const isTaskEnded = lastStep.type === 'completed' || lastStep.type === 'error'
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) {
@@ -383,13 +366,12 @@ export class Panel {
<div class="${styles.background}"></div>
<div class="${styles.historySectionWrapper}">
<div class="${styles.historySection}">
${this.#createHistoryItem({
id: 'placeholder',
stepNumber: 0,
timestamp: new Date(),
type: 'thinking',
displayText: this.#i18n.t('ui.panel.waitingPlaceholder'),
})}
<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}">
@@ -544,7 +526,9 @@ export class Panel {
}, 150) // Half the duration of fade out animation
}
#updateStatusIndicator(type: Step['type']): void {
#updateStatusIndicator(
type: 'thinking' | 'executing' | 'executed' | 'retrying' | 'completed' | 'error'
): void {
// Clear all status classes
this.#indicator.className = styles.indicator
@@ -552,12 +536,6 @@ export class Panel {
this.#indicator.classList.add(styles[type])
}
#updateHistory(): void {
const steps = this.#state.getAllSteps()
this.#historySection.innerHTML = steps.map((step) => this.#createHistoryItem(step)).join('')
this.#scrollToBottom()
}
#scrollToBottom(): void {
// Execute in next event loop to ensure DOM update completion
setTimeout(() => {
@@ -565,71 +543,107 @@ export class Panel {
}, 0)
}
#createHistoryItem(step: Step): string {
const time = step.timestamp.toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
/**
* 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[] = []
let typeClass = ''
let statusIcon = ''
// Set styles and icons based on step type
if (step.type === 'completed') {
// Check if this is a result from done tool
if (step.toolName === 'done') {
// Judge success or failure based on result
const failureKeyword = this.#i18n.t('ui.tools.resultFailure')
const errorKeyword = this.#i18n.t('ui.tools.resultError')
const isSuccess =
!step.toolResult ||
(!step.toolResult.includes(failureKeyword) && !step.toolResult.includes(errorKeyword))
typeClass = isSuccess ? styles.doneSuccess : styles.doneError
statusIcon = isSuccess ? '🎉' : '❌'
} else {
typeClass = styles.completed
statusIcon = '✅'
}
} else if (step.type === 'error') {
typeClass = styles.error
statusIcon = '❌'
} else if (step.type === 'tool_executing') {
statusIcon = '🔨'
} else if (step.type === 'output') {
typeClass = styles.output
statusIcon = '🤖'
} else if (step.type === 'input') {
typeClass = styles.input
statusIcon = '🎯'
} else if (step.type === 'retry') {
typeClass = styles.retry
statusIcon = '🔄'
} else if (step.type === 'observation') {
typeClass = styles.observation
statusIcon = '👁️'
} else {
statusIcon = '🧠'
// 1. Task card (always first)
const task = this.#agent.task
if (task) {
items.push(this.#createTaskCard(task))
}
const durationText = step.duration ? ` · ${step.duration}ms` : ''
const stepLabel = this.#i18n.t('ui.panel.step', {
number: step.stepNumber.toString(),
// 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: durationText || '', // Explicitly pass empty string to replace template
duration: '',
})
return `
<div class="${styles.historyItem} ${typeClass}">
<div class="${styles.historyContent}">
<span class="${styles.statusIcon}">${statusIcon}</span>
<span>${escapeHtml(step.displayText)}</span>
</div>
<div class="${styles.historyMeta}">
${stepLabel}
</div>
</div>
`
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
}
}

View File

@@ -1,101 +0,0 @@
/**
* Agent execution state management
*/
export interface Step {
id: string
stepNumber: number
timestamp: Date
type:
| 'thinking'
| 'tool_executing'
| 'completed'
| 'error'
| 'output'
| 'input'
| 'retry'
| 'observation'
// Tool execution related
toolName?: string
toolArgs?: any
toolResult?: any
// Display data
displayText: string
duration?: number
}
export type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
export class UIState {
private steps: Step[] = []
private currentStep: Step | null = null
private status: AgentStatus = 'idle'
private stepCounter = 0
addStep(stepData: Omit<Step, 'id' | 'stepNumber' | 'timestamp'>): Step {
const step: Step = {
id: this.generateId(),
stepNumber: ++this.stepCounter,
timestamp: new Date(),
...stepData,
}
this.steps.push(step)
this.currentStep = step
// Update overall status
this.updateStatus(step.type)
return step
}
updateCurrentStep(updates: Partial<Step>): Step | null {
if (!this.currentStep) return null
Object.assign(this.currentStep, updates)
return this.currentStep
}
getCurrentStep(): Step | null {
return this.currentStep
}
getAllSteps(): Step[] {
return [...this.steps]
}
getStatus(): AgentStatus {
return this.status
}
reset(): void {
this.steps = []
this.currentStep = null
this.status = 'idle'
this.stepCounter = 0
}
private updateStatus(stepType: Step['type']): void {
switch (stepType) {
case 'thinking':
case 'tool_executing':
case 'output':
case 'input':
case 'retry':
this.status = 'running'
break
case 'completed':
this.status = 'completed'
break
case 'error':
this.status = 'error'
break
}
}
private generateId(): string {
return `step_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`
}
}

62
packages/ui/src/cards.ts Normal file
View File

@@ -0,0 +1,62 @@
/**
* Card HTML generation utilities for Panel
*/
import { escapeHtml } from './utils'
import styles from './Panel.module.css'
type CardType = 'default' | 'input' | 'output' | 'question' | 'observation'
interface CardOptions {
icon: string
content: string | string[]
meta?: string
type?: CardType
}
/** Create a single history card */
export function createCard({ icon, content, meta, type }: CardOptions): string {
const typeClass = type ? styles[type] : ''
const contentHtml = Array.isArray(content)
? `<div class="${styles.reflectionLines}">${content.join('')}</div>`
: `<span>${escapeHtml(content)}</span>`
return `
<div class="${styles.historyItem} ${typeClass}">
<div class="${styles.historyContent}">
<span class="${styles.statusIcon}">${icon}</span>
${contentHtml}
</div>
${meta ? `<div class="${styles.historyMeta}">${meta}</div>` : ''}
</div>
`
}
/** Format timestamp for cards */
export function formatTime(): string {
return new Date().toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
/** Create reflection lines from reflection object */
export function createReflectionLines(reflection: {
evaluation_previous_goal?: string
memory?: string
next_goal?: string
}): string[] {
const lines: string[] = []
if (reflection.evaluation_previous_goal) {
lines.push(`<div>🔍 ${escapeHtml(reflection.evaluation_previous_goal)}</div>`)
}
if (reflection.memory) {
lines.push(`<div>💾 ${escapeHtml(reflection.memory)}</div>`)
}
if (reflection.next_goal) {
lines.push(`<div>🎯 ${escapeHtml(reflection.next_goal)}</div>`)
}
return lines
}

View File

@@ -22,6 +22,7 @@ const enUS = {
selecting: 'Selecting option "{{text}}"...',
scrolling: 'Scrolling page...',
waiting: 'Waiting {{seconds}} seconds...',
askingUser: 'Asking user...',
done: 'Task done',
clicked: '🖱️ Clicked element [{{index}}]',
inputted: '⌨️ Inputted text "{{text}}"',
@@ -68,6 +69,7 @@ const zhCN = {
selecting: '正在选择选项 "{{text}}"...',
scrolling: '正在滚动页面...',
waiting: '等待 {{seconds}} 秒...',
askingUser: '正在询问用户...',
done: '结束任务',
clicked: '🖱️ 已点击元素 [{{index}}]',
inputted: '⌨️ 已输入文本 "{{text}}"',

View File

@@ -1,3 +1,3 @@
export { Panel, type PanelConfig, type PanelUpdate } from './Panel'
export { UIState, type Step, type AgentStatus } from './UIState'
export { Panel, type PanelConfig } from './Panel'
export type { AgentActivity, PanelAgentAdapter } from './types'
export { I18n, type SupportedLanguage, type TranslationKey } from './i18n'

67
packages/ui/src/types.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* Agent activity - transient state for immediate UI feedback.
*
* Unlike historical events (which are persisted), activities are ephemeral
* and represent "what the agent is doing right now". UI components should
* listen to 'activity' events to show real-time feedback.
*
* Note: There is no 'idle' activity - absence of activity events means idle.
*
* Events dispatched: CustomEvent<AgentActivity>
*/
export type AgentActivity =
| { type: 'thinking' }
| { type: 'executing'; tool: string; input: unknown }
| { type: 'executed'; tool: string; input: unknown; output: string; duration: number }
| { type: 'retrying'; attempt: number; maxAttempts: number }
| { type: 'error'; message: string }
/**
* Minimal interface that Panel expects from an agent.
* Panel does not depend on PageAgent directly - it only requires this interface.
* This enables decoupling and allows any agent implementation to work with Panel.
*
* Events:
* - 'statuschange': Agent status changed (idle/running/completed/error)
* - 'historychange': Historical events updated (persisted)
* - 'activity': Transient activity for immediate UI feedback (thinking/executing/etc)
* - 'dispose': Agent is being disposed
*/
export interface PanelAgentAdapter extends EventTarget {
/** Current agent status */
readonly status: 'idle' | 'running' | 'completed' | 'error'
/** History of agent events */
readonly history: readonly {
type: 'step' | 'observation' | 'user_takeover'
/** For 'step' type */
reflection?: {
evaluation_previous_goal?: string
memory?: string
next_goal?: string
}
/** For 'step' type */
action?: {
name: string
input: unknown
output: string
}
/** For 'observation' type */
content?: string
}[]
/** Current task being executed */
readonly task: string
/**
* Callback for when agent needs user input.
* Panel will set this to handle user questions via its UI.
*/
onAskUser?: (question: string) => Promise<string>
/** Execute a task */
execute(task: string): Promise<unknown>
/** Dispose the agent */
dispose(): void
}

View File

@@ -1,4 +1,5 @@
/* eslint-disable react-dom/no-dangerously-set-innerhtml */
import { Panel } from '@page-agent/ui'
import { Bot, Box, MessageSquare, PlayCircle, Shield, Sparkles, Users, Zap } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -94,6 +95,10 @@ export default function HomePage() {
// promptForNextTask: false,
// enablePanel: false,
})
// Create and bind Panel
const panel = new Panel(win.pageAgent, { language: i18n.language as any })
panel.show()
}
const result = await win.pageAgent.execute(task)