Merge pull request #72 from alibaba/refactor/package_ui

refactor(agent): move `ui` to a dedicated package.
This commit is contained in:
Simon
2025-12-15 18:32:45 +08:00
committed by GitHub
32 changed files with 437 additions and 266 deletions

View File

@@ -107,19 +107,6 @@ DOM element references and internal state (selectorMap, elementTextMap) are enca
3. **LLM Processing**: AI model returns action plans (in page-agent)
4. **Indexed Operations**: PageAgent calls PageController methods by element index
### Event Bus Communication
Use `src/utils/bus.ts` for decoupled PageAgent ↔ UI communication:
```typescript
// Emit from PageAgent
getEventBus().emit('panel:show')
getEventBus().emit('panel:update', { status: 'thinking' })
// Listen in UI components
getEventBus().on('panel:show', () => panel.show())
```
### Hash Routing Requirement
Uses wouter with `useHashLocation` for static hosting:
@@ -147,7 +134,6 @@ Query params configure `PageAgentConfig` automatically in `src/entry.ts`.
| `src/PageAgent.ts` | ⭐ Main AI agent class orchestrating tools and LLM |
| `src/entry.ts` | CDN/UMD entry point with auto-initialization |
| `src/tools/` | Tool definitions that call PageController methods |
| `src/utils/bus.ts` | Type-safe event bus for decoupled communication |
| `src/ui/` | UI components (Panel, SimulatorMask) with CSS modules |
| `src/llms/` | LLM integration and communication layer |
| `vite.config.js` | Library build configuration (ES + UMD) |
@@ -194,11 +180,6 @@ Query params configure `PageAgentConfig` automatically in `src/entry.ts`.
2. Expose via async method in `PageController.ts`
3. Export from `packages/page-controller/src/index.ts`
### New UI Component
1. Create in `packages/page-agent/src/ui/` with colocated CSS modules
2. Use event bus for PageAgent communication
## Code Standards
### TypeScript
@@ -233,5 +214,4 @@ Query params configure `PageAgentConfig` automatically in `src/entry.ts`.
1. Check `packages/page-agent/dist/lib/page-agent.umd.js` builds correctly
2. Test CDN injection with query params
3. Verify event bus communications are properly typed
4. Use `packages/website/src/test-pages/` for isolated testing

44
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"license": "MIT",
"workspaces": [
"packages/page-controller",
"packages/ui",
"packages/page-agent",
"packages/website"
],
@@ -72,7 +73,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1560,6 +1560,10 @@
"resolved": "packages/page-controller",
"link": true
},
"node_modules/@page-agent/ui": {
"resolved": "packages/ui",
"link": true
},
"node_modules/@page-agent/website": {
"resolved": "packages/website",
"link": true
@@ -2680,7 +2684,6 @@
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -2691,7 +2694,6 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2752,7 +2754,6 @@
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.48.1",
"@typescript-eslint/types": "8.48.1",
@@ -3016,7 +3017,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3214,7 +3214,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -3529,7 +3528,6 @@
"integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"env-paths": "^2.2.1",
"import-fresh": "^3.3.0",
@@ -3743,7 +3741,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -3808,7 +3805,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4562,7 +4558,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -4993,6 +4988,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -5014,6 +5010,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -5035,6 +5032,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -5056,6 +5054,7 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -5077,6 +5076,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -5098,6 +5098,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -5119,6 +5120,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -5140,6 +5142,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -5161,6 +5164,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -5182,6 +5186,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -5203,6 +5208,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -5817,7 +5823,6 @@
"integrity": "sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -5861,7 +5866,6 @@
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5998,7 +6002,6 @@
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -6326,7 +6329,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6422,7 +6424,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6632,7 +6633,6 @@
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -6736,7 +6736,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6980,7 +6979,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz",
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -7003,7 +7001,7 @@
"license": "MIT",
"dependencies": {
"@page-agent/page-controller": "0.0.7",
"ai-motion": "^0.4.7",
"@page-agent/ui": "0.0.7",
"chalk": "^5.6.2",
"zod": "^4.1.12"
}
@@ -7013,6 +7011,14 @@
"version": "0.0.7",
"license": "MIT"
},
"packages/ui": {
"name": "@page-agent/ui",
"version": "0.0.7",
"license": "MIT",
"dependencies": {
"ai-motion": "^0.4.7"
}
},
"packages/website": {
"name": "@page-agent/website",
"version": "0.0.7",

View File

@@ -5,6 +5,7 @@
"type": "module",
"workspaces": [
"packages/page-controller",
"packages/ui",
"packages/page-agent",
"packages/website"
],

View File

@@ -14,9 +14,7 @@
}
},
"files": [
"dist/",
"README.md",
"LICENSE"
"dist/"
],
"description": "GUI agent for web applications - add intelligent automation to any webpage with a single script",
"keywords": [
@@ -47,9 +45,9 @@
"postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
},
"dependencies": {
"ai-motion": "^0.4.7",
"chalk": "^5.6.2",
"zod": "^4.1.12",
"@page-agent/page-controller": "0.0.7"
"@page-agent/page-controller": "0.0.7",
"@page-agent/ui": "0.0.7"
}
}

View File

@@ -3,20 +3,17 @@
* All rights reserved.
*/
import { PageController } from '@page-agent/page-controller'
import { Panel, SimulatorMask } from '@page-agent/ui'
import chalk from 'chalk'
import zod from 'zod'
import type { PageAgentConfig } from './config'
import { MAX_STEPS } from './config/constants'
import { I18n } from './i18n'
import { LLM, type Tool } from './llms'
import SYSTEM_PROMPT from './prompts/system_prompt.md?raw'
import { tools } from './tools'
import { Panel, getToolCompletedText, getToolExecutingText } from './ui/Panel'
import { SimulatorMask } from './ui/SimulatorMask'
import { trimLines, uid, waitUntil } from './utils'
import { assert } from './utils/assert'
import { getEventBus } from './utils/bus'
export type { PageAgentConfig }
export { tool, type PageAgentTool } from './tools'
@@ -71,8 +68,6 @@ export interface ExecutionResult {
export class PageAgent extends EventTarget {
config: PageAgentConfig
id = uid()
bus = getEventBus(this.id)
i18n: I18n
panel: Panel
tools: typeof tools
paused = false
@@ -83,6 +78,9 @@ export class PageAgent extends EventTarget {
#llm: LLM
#totalWaitTime = 0
#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 */
pageController: PageController
@@ -96,14 +94,34 @@ export class PageAgent extends EventTarget {
super()
this.config = config
this.#llm = new LLM(this.config, this.id)
this.i18n = new I18n(this.config.language)
this.panel = new Panel(this)
this.#llm = new LLM(this.config)
this.panel = new Panel({
language: this.config.language,
onExecuteTask: (task) => this.execute(task),
onStop: () => this.dispose(),
onPauseToggle: () => {
this.paused = !this.paused
return this.paused
},
getPaused: () => this.paused,
})
this.tools = new Map(tools)
// Initialize PageController with config
this.pageController = new PageController(this.config)
// Listen to LLM events
this.#llmRetryListener = (e) => {
const { current, max } = (e as CustomEvent).detail
this.panel.update({ type: 'retry', current, max })
}
this.#llmErrorListener = (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)
if (this.config.customTools) {
for (const [name, tool] of Object.entries(this.config.customTools)) {
if (tool === null) {
@@ -118,9 +136,10 @@ export class PageAgent extends EventTarget {
this.tools.delete('execute_javascript')
}
window.addEventListener('beforeunload', (e) => {
this.#beforeUnloadListener = (e) => {
if (!this.disposed) this.dispose('PAGE_UNLOADING')
})
}
window.addEventListener('beforeunload', this.#beforeUnloadListener)
}
/**
@@ -141,13 +160,10 @@ export class PageAgent extends EventTarget {
// Show mask and panel
this.mask.show()
this.bus.emit('panel:show')
this.bus.emit('panel:reset')
this.panel.show()
this.panel.reset()
this.bus.emit('panel:update', {
type: 'input',
displayText: this.task,
})
this.panel.update({ type: 'input', task: this.task })
if (this.#abortController) {
this.#abortController.abort()
@@ -171,10 +187,7 @@ export class PageAgent extends EventTarget {
// Update status to thinking
console.log(chalk.blue('Thinking...'))
this.bus.emit('panel:update', {
type: 'thinking',
displayText: this.i18n.t('ui.panel.thinking'),
})
this.panel.update({ type: 'thinking' })
const result = await this.#llm.invoke(
[
@@ -304,22 +317,14 @@ export class PageAgent extends EventTarget {
`)
console.log(brain)
this.bus.emit('panel:update', {
type: 'thinking',
displayText: brain,
})
this.panel.update({ type: 'thinking', text: brain })
// Find the corresponding tool
const tool = tools.get(toolName)
assert(tool, `Tool ${toolName} not found. (@note should have been caught before this!!!)`)
console.log(chalk.blue.bold(`Executing tool: ${toolName}`), toolInput)
this.bus.emit('panel:update', {
type: 'tool_executing',
toolName,
toolArgs: toolInput,
displayText: getToolExecutingText(toolName, toolInput, this.i18n),
})
this.panel.update({ type: 'toolExecuting', toolName, args: toolInput })
const startTime = Date.now()
@@ -341,14 +346,11 @@ export class PageAgent extends EventTarget {
}
// Briefly display execution result
const displayResult = getToolCompletedText(toolName, toolInput, this.i18n)
if (displayResult)
this.bus.emit('panel:update', {
type: 'tool_executing',
this.panel.update({
type: 'toolCompleted',
toolName,
toolArgs: toolInput,
toolResult: result,
displayText: displayResult,
args: toolInput,
result,
duration,
})
@@ -426,16 +428,14 @@ export class PageAgent extends EventTarget {
this.pageController.cleanUpHighlights()
// Update panel status
this.bus.emit('panel:update', {
type: success ? 'output' : 'error',
displayText: text,
})
if (success) {
this.panel.update({ type: 'output', text })
} else {
this.panel.update({ type: 'error', message: text })
}
// Task completed
this.bus.emit('panel:update', {
type: 'completed',
displayText: this.i18n.t('ui.panel.taskCompleted'),
})
this.panel.update({ type: 'completed' })
this.mask.hide()
@@ -497,6 +497,22 @@ export class PageAgent extends EventTarget {
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
}
this.config.onDispose?.call(this, reason)
}
}

View File

@@ -1,7 +1,7 @@
import type { PageControllerConfig } from '@page-agent/page-controller'
import type { SupportedLanguage } from '@page-agent/ui'
import type { AgentHistory, ExecutionResult, PageAgent } from '../PageAgent'
import type { SupportedLanguage } from '../i18n'
import type { PageAgentTool } from '../tools'
import {
DEFAULT_API_KEY,

View File

@@ -33,24 +33,19 @@
*/
import type { LLMConfig } from '../config'
import { parseLLMConfig } from '../config'
import { EventBus, getEventBus } from '../utils/bus'
import { OpenAIClient } from './OpenAILenientClient'
import { InvokeError } from './errors'
import type { InvokeResult, LLMClient, Message, Tool } from './types'
export type { Message, Tool, InvokeResult, LLMClient }
export class LLM {
export class LLM extends EventTarget {
config: Required<LLMConfig>
id: string
client: LLMClient
#bus: EventBus
constructor(config: LLMConfig, id: string) {
constructor(config: LLMConfig) {
super()
this.config = parseLLMConfig(config)
this.id = id
this.#bus = getEventBus(id)
// Default to OpenAI client
this.client = new OpenAIClient({
@@ -81,17 +76,13 @@ export class LLM {
// retry settings
{
maxRetries: this.config.maxRetries,
onRetry: (retries: number) => {
this.#bus.emit('panel:update', {
type: 'retry',
displayText: `retry-ing (${retries} / ${this.config.maxRetries})`,
})
onRetry: (current: number) => {
this.dispatchEvent(
new CustomEvent('retry', { detail: { current, max: this.config.maxRetries } })
)
},
onError: (error: Error) => {
this.#bus.emit('panel:update', {
type: 'error',
displayText: `step failed: ${(error as Error).message}`,
})
this.dispatchEvent(new CustomEvent('error', { detail: { error } }))
},
}
)

View File

@@ -37,4 +37,4 @@ if (currentScript) {
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
window.pageAgent.bus.emit('panel:show') // Show panel
window.pageAgent.panel.show() // Show panel

View File

@@ -1,27 +1,8 @@
/**
* Type-safe event bus for decoupling PageAgent and Panel
*/
import type { Step } from '../ui/UIState'
/**
* Event mapping definitions
* @note Event bus callbacks must be repeatable without errors
*/
export interface PageAgentEventMap {
// Panel control events
// call panel.show()
'panel:show': { params: undefined }
// call panel.hide()
'panel:hide': { params: undefined }
// call panel.reset()
'panel:reset': { params: undefined }
// call panel.update()
'panel:update': { params: Omit<Step, 'id' | 'stepNumber' | 'timestamp'> }
// call panel.expand()
'panel:expand': { params: undefined }
// call panel.collapse()
'panel:collapse': { params: undefined }
// PageAgent status events
// 'agent:execute': { params: { task: string } }
// 'agent:done': { params: { text: string; success: boolean } }
@@ -31,8 +12,7 @@ export interface PageAgentEventMap {
// 'agent:error': { params: { error: string | Error } }
// Task status change events
// 'task:start': { params: { task: string } }
// 'task:step': { params: Omit<AgentStep, 'id' | 'stepNumber' | 'timestamp'> }
'task:start': { params: { task: string } }
// 'task:complete': { params: { text: string; success: boolean } }
// 'task:error': { params: { error: string | Error } }

View File

@@ -61,6 +61,7 @@ const umdConfig = {
resolve: {
alias: {
'@page-agent/page-controller': resolve(__dirname, '../page-controller/src/PageController.ts'),
'@page-agent/ui': resolve(__dirname, '../ui/src/index.ts'),
},
},
build: {

View File

@@ -13,9 +13,7 @@
}
},
"files": [
"dist/",
"README.md",
"LICENSE"
"dist/"
],
"description": "Page controller for page-agent - DOM operations and element interactions",
"keywords": [

43
packages/ui/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "@page-agent/ui",
"version": "0.0.7",
"type": "module",
"main": "./dist/lib/page-agent-ui.js",
"module": "./dist/lib/page-agent-ui.js",
"types": "./dist/lib/index.d.ts",
"exports": {
".": {
"types": "./dist/lib/index.d.ts",
"import": "./dist/lib/page-agent-ui.js",
"default": "./dist/lib/page-agent-ui.js"
}
},
"files": [
"dist/"
],
"description": "UI components for page-agent - Panel, SimulatorMask, and i18n",
"keywords": [
"page-agent",
"ui",
"panel",
"i18n"
],
"author": "Simon<gaomeng1900>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/alibaba/page-agent.git",
"directory": "packages/ui"
},
"homepage": "https://alibaba.github.io/page-agent/",
"scripts": {
"build": "vite build",
"build:watch": "vite build --watch",
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
},
"dependencies": {
"ai-motion": "^0.4.7"
}
}

View File

@@ -1,11 +1,35 @@
import type { PageAgent } from '../PageAgent'
import type { I18n } from '../i18n'
import { truncate } from '../utils'
import type { EventBus } from '../utils/bus'
import { type Step, UIState } from './UIState'
import { I18n, type SupportedLanguage } from './i18n'
import { truncate } from './utils'
import styles from './Panel.module.css'
/**
* Panel configuration
*/
export interface PanelConfig {
language?: SupportedLanguage
onExecuteTask: (task: string) => void
onStop: () => void
onPauseToggle: () => boolean // returns new paused state
getPaused: () => 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 }
/**
* Agent control panel
*/
@@ -19,11 +43,11 @@ export class Panel {
#stopButton: HTMLElement
#inputSection: HTMLElement
#taskInput: HTMLInputElement
#bus: EventBus
#state = new UIState()
#isExpanded = false
#pageAgent: PageAgent
#config: PanelConfig
#i18n: I18n
#userAnswerResolver: ((input: string) => void) | null = null
#isWaitingForUserAnswer: boolean = false
#headerUpdateTimer: ReturnType<typeof setInterval> | null = null
@@ -34,9 +58,9 @@ export class Panel {
return this.#wrapper
}
constructor(pageAgent: PageAgent) {
this.#pageAgent = pageAgent
this.#bus = pageAgent.bus
constructor(config: PanelConfig) {
this.#config = config
this.#i18n = new I18n(config.language ?? 'en-US')
this.#wrapper = this.#createWrapper()
this.#indicator = this.#wrapper.querySelector(`.${styles.indicator}`)!
this.#statusText = this.#wrapper.querySelector(`.${styles.statusText}`)!
@@ -49,16 +73,8 @@ export class Panel {
this.#setupEventListeners()
this.#startHeaderUpdateLoop()
// this.#expand() // debug
this.#showInputArea()
this.#bus.on('panel:show', () => this.#show())
this.#bus.on('panel:hide', () => this.#hide())
this.#bus.on('panel:reset', () => this.#reset())
this.#bus.on('panel:update', (stepData) => this.#update(stepData))
this.#bus.on('panel:expand', () => this.#expand())
this.#bus.on('panel:collapse', () => this.#collapse())
}
/**
@@ -71,18 +87,67 @@ export class Panel {
this.#userAnswerResolver = resolve
// Update state to `running`
this.#update({
this.#updateInternal({
type: 'output',
displayText: this.#pageAgent.i18n.t('ui.panel.question', { question }),
displayText: this.#i18n.t('ui.panel.question', { question }),
}) // Expand history panel
if (!this.#isExpanded) {
this.#expand()
}
this.#showInputArea(this.#pageAgent.i18n.t('ui.panel.userAnswerPrompt'))
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.#state.reset()
this.#statusText.textContent = this.#i18n.t('ui.panel.ready')
this.#updateStatusIndicator('thinking')
this.#updateHistory()
this.#collapse()
// Reset pause state via callback
if (this.#config.getPaused()) {
this.#config.onPauseToggle()
}
this.#updatePauseButton()
// Reset user input state
this.#isWaitingForUserAnswer = false
this.#userAnswerResolver = null
// Show input area
this.#showInputArea()
}
expand(): void {
this.#expand()
}
collapse(): void {
this.#collapse()
}
/**
* Update panel with semantic data - i18n handled internally
*/
update(data: PanelUpdate): void {
const stepData = this.#toStepData(data)
this.#updateInternal(stepData)
}
/**
* Dispose panel
*/
@@ -92,10 +157,102 @@ export class Panel {
this.wrapper.remove()
}
// ========== Private methods ==========
/**
* Update status
* Convert semantic update to step data with i18n
*/
#update(stepData: Omit<Step, 'id' | 'stepNumber' | 'timestamp'>): void {
#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,
}
}
}
}
#getToolExecutingText(toolName: string, args: any): string {
switch (toolName) {
case 'click_element_by_index':
return this.#i18n.t('ui.tools.clicking', { index: args.index })
case 'input_text':
return this.#i18n.t('ui.tools.inputting', { index: args.index })
case 'select_dropdown_option':
return this.#i18n.t('ui.tools.selecting', { text: args.text })
case 'scroll':
return this.#i18n.t('ui.tools.scrolling')
case 'wait':
return this.#i18n.t('ui.tools.waiting', { seconds: args.seconds })
case 'done':
return this.#i18n.t('ui.tools.done')
default:
return this.#i18n.t('ui.tools.executing', { toolName })
}
}
#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)
@@ -120,59 +277,20 @@ export class Panel {
}
}
/**
* Show panel
*/
#show(): void {
this.wrapper.style.display = 'block'
// Force reflow to trigger animation
void this.wrapper.offsetHeight
this.wrapper.style.opacity = '1'
this.wrapper.style.transform = 'translateX(-50%) translateY(0)'
}
/**
* Hide panel
*/
#hide(): void {
this.wrapper.style.opacity = '0'
this.wrapper.style.transform = 'translateX(-50%) translateY(20px)'
this.wrapper.style.display = 'none'
}
/**
* Reset state
*/
#reset(): void {
this.#state.reset()
this.#statusText.textContent = this.#pageAgent.i18n.t('ui.panel.ready')
this.#updateStatusIndicator('thinking')
this.#updateHistory()
this.#collapse()
// Reset pause state
this.#pageAgent.paused = false
this.#updatePauseButton()
// Reset user input state
this.#isWaitingForUserAnswer = false
this.#userAnswerResolver = null
// Show input area
this.#showInputArea()
}
/**
* Toggle pause state
*/
#togglePause(): void {
this.#pageAgent.paused = !this.#pageAgent.paused
const paused = this.#config.onPauseToggle()
this.#updatePauseButton()
// Update status display
if (this.#pageAgent.paused) {
this.#statusText.textContent = this.#pageAgent.i18n.t('ui.panel.paused')
this.#updateStatusIndicator('thinking') // Use existing thinking state
if (paused) {
this.#statusText.textContent = this.#i18n.t('ui.panel.paused')
this.#updateStatusIndicator('thinking')
} else {
this.#statusText.textContent = this.#pageAgent.i18n.t('ui.panel.continueExecution')
this.#updateStatusIndicator('tool_executing') // Restore to execution state
this.#statusText.textContent = this.#i18n.t('ui.panel.continueExecution')
this.#updateStatusIndicator('tool_executing')
}
}
@@ -180,13 +298,14 @@ export class Panel {
* Update pause button state
*/
#updatePauseButton(): void {
if (this.#pageAgent.paused) {
const paused = this.#config.getPaused()
if (paused) {
this.#pauseButton.textContent = '▶'
this.#pauseButton.title = this.#pageAgent.i18n.t('ui.panel.continue')
this.#pauseButton.title = this.#i18n.t('ui.panel.continue')
this.#pauseButton.classList.add(styles.paused)
} else {
this.#pauseButton.textContent = '⏸︎'
this.#pauseButton.title = this.#pageAgent.i18n.t('ui.panel.pause')
this.#pauseButton.title = this.#i18n.t('ui.panel.pause')
this.#pauseButton.classList.remove(styles.paused)
}
}
@@ -196,12 +315,12 @@ export class Panel {
*/
#stopAgent(): void {
// Update status display
this.#update({
this.#updateInternal({
type: 'error',
displayText: this.#pageAgent.i18n.t('ui.panel.taskTerminated'),
displayText: this.#i18n.t('ui.panel.taskTerminated'),
})
this.#pageAgent.dispose()
this.#config.onStop()
}
/**
@@ -218,7 +337,7 @@ export class Panel {
// Handle user input mode
this.#handleUserAnswer(input)
} else {
this.#pageAgent.execute(input)
this.#config.onExecuteTask(input)
}
}
@@ -227,9 +346,9 @@ export class Panel {
*/
#handleUserAnswer(input: string): void {
// Add user input to history
this.#update({
this.#updateInternal({
type: 'input',
displayText: this.#pageAgent.i18n.t('ui.panel.userAnswer', { input }),
displayText: this.#i18n.t('ui.panel.userAnswer', { input }),
})
// Reset state
@@ -248,7 +367,7 @@ export class Panel {
#showInputArea(placeholder?: string): void {
// Clear input field
this.#taskInput.value = ''
this.#taskInput.placeholder = placeholder || this.#pageAgent.i18n.t('ui.panel.taskInput')
this.#taskInput.placeholder = placeholder || this.#i18n.t('ui.panel.taskInput')
this.#inputSection.classList.remove(styles.hidden)
// Focus on input field
setTimeout(() => {
@@ -294,23 +413,23 @@ export class Panel {
stepNumber: 0,
timestamp: new Date(),
type: 'thinking',
displayText: this.#pageAgent.i18n.t('ui.panel.waitingPlaceholder'),
displayText: this.#i18n.t('ui.panel.waitingPlaceholder'),
})}
</div>
</div>
<div class="${styles.header}">
<div class="${styles.statusSection}">
<div class="${styles.indicator} ${styles.thinking}"></div>
<div class="${styles.statusText}">${this.#pageAgent.i18n.t('ui.panel.ready')}</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.#pageAgent.i18n.t('ui.panel.expand')}">
<button class="${styles.controlButton} ${styles.expandButton}" title="${this.#i18n.t('ui.panel.expand')}">
</button>
<button class="${styles.controlButton} ${styles.pauseButton}" title="${this.#pageAgent.i18n.t('ui.panel.pause')}">
<button class="${styles.controlButton} ${styles.pauseButton}" title="${this.#i18n.t('ui.panel.pause')}">
</button>
<button class="${styles.controlButton} ${styles.stopButton}" title="${this.#pageAgent.i18n.t('ui.panel.stop')}">
<button class="${styles.controlButton} ${styles.stopButton}" title="${this.#i18n.t('ui.panel.stop')}">
X
</button>
</div>
@@ -501,8 +620,8 @@ export class Panel {
// Check if this is a result from done tool
if (step.toolName === 'done') {
// Judge success or failure based on result
const failureKeyword = this.#pageAgent.i18n.t('ui.tools.resultFailure')
const errorKeyword = this.#pageAgent.i18n.t('ui.tools.resultError')
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))
@@ -531,7 +650,7 @@ export class Panel {
}
const durationText = step.duration ? ` · ${step.duration}ms` : ''
const stepLabel = this.#pageAgent.i18n.t('ui.panel.step', {
const stepLabel = this.#i18n.t('ui.panel.step', {
number: step.stepNumber.toString(),
time,
duration: durationText || '', // Explicitly pass empty string to replace template
@@ -550,47 +669,3 @@ export class Panel {
`
}
}
/**
* Get display text for tool execution
*/
export function getToolExecutingText(toolName: string, args: any, i18n: I18n): string {
switch (toolName) {
case 'click_element_by_index':
return i18n.t('ui.tools.clicking', { index: args.index })
case 'input_text':
return i18n.t('ui.tools.inputting', { index: args.index })
case 'select_dropdown_option':
return i18n.t('ui.tools.selecting', { text: args.text })
case 'scroll':
return i18n.t('ui.tools.scrolling')
case 'wait':
return i18n.t('ui.tools.waiting', { seconds: args.seconds })
case 'done':
return i18n.t('ui.tools.done')
default:
return i18n.t('ui.tools.executing', { toolName })
}
}
/**
* Get display text for tool completion
*/
export function getToolCompletedText(toolName: string, args: any, i18n: I18n): string | null {
switch (toolName) {
case 'click_element_by_index':
return i18n.t('ui.tools.clicked', { index: args.index })
case 'input_text':
return i18n.t('ui.tools.inputted', { text: args.text })
case 'select_dropdown_option':
return i18n.t('ui.tools.selected', { text: args.text })
case 'scroll':
return i18n.t('ui.tools.scrolled')
case 'wait':
return i18n.t('ui.tools.waited')
case 'done':
return null
default:
return null
}
}

View File

@@ -1,6 +1,6 @@
import { Motion } from 'ai-motion'
import { isPageDark } from '../utils/checkDarkMode'
import { isPageDark } from './checkDarkMode'
import styles from './SimulatorMask.module.css'
import cursorStyles from './cursor.module.css'

6
packages/ui/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}

4
packages/ui/src/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export { Panel, type PanelConfig, type PanelUpdate } from './Panel'
export { SimulatorMask } from './SimulatorMask'
export { UIState, type Step, type AgentStatus } from './UIState'
export { I18n, type SupportedLanguage, type TranslationKey } from './i18n'

6
packages/ui/src/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
export function truncate(text: string, maxLength: number): string {
if (text.length > maxLength) {
return text.substring(0, maxLength) + '...'
}
return text
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
// @workaround DTS bug
// dts do not work with monorepo path mapping
// disable path mapping for it
"paths": {}
}
}

13
packages/ui/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
"noEmit": false,
"allowImportingTsExtensions": false,
"baseUrl": ".",
"outDir": "dist"
},
"include": ["**/*.ts", "**/*.js"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,41 @@
// @ts-check
import chalk from 'chalk'
import { dirname, resolve } from 'path'
import dts from 'unplugin-dts/vite'
import { fileURLToPath } from 'url'
import { defineConfig } from 'vite'
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
const __dirname = dirname(fileURLToPath(import.meta.url))
console.log(chalk.cyan(`📦 Building @page-agent/ui`))
export default defineConfig({
clearScreen: false,
plugins: [
dts({ tsconfigPath: './tsconfig.dts.json', bundleTypes: true }),
cssInjectedByJsPlugin({ relativeCSSInjection: true }),
],
publicDir: false,
esbuild: {
keepNames: true,
},
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'PageAgentUI',
fileName: 'page-agent-ui',
formats: ['es'],
},
outDir: resolve(__dirname, 'dist', 'lib'),
rollupOptions: {
external: ['ai-motion'],
},
minify: false,
sourcemap: true,
cssCodeSplit: true,
},
define: {
'process.env.NODE_ENV': '"production"',
},
})

View File

@@ -20,6 +20,7 @@ export default defineConfig({
// Monorepo packages (always bundle local code instead of npm versions)
'@page-agent/page-controller': resolve(__dirname, '../page-controller/src/PageController.ts'),
'@page-agent/ui': resolve(__dirname, '../ui/src/index.ts'),
'page-agent': resolve(__dirname, '../page-agent/src/PageAgent.ts'),
},
},

View File

@@ -2,6 +2,7 @@
"extends": "./tsconfig.base.json",
"references": [
{ "path": "./packages/page-controller" },
{ "path": "./packages/ui" },
{ "path": "./packages/page-agent" },
{ "path": "./packages/website" }
],