Compare commits

..

18 Commits

Author SHA1 Message Date
Simon 20e947a1c1 chore(version): bump version to 1.2.0 2026-02-11 20:09:23 +08:00
Simon 7c87c90258 fix(ext): fix multi-thread logic; extensive logging and error handling 2026-02-11 19:51:19 +08:00
Simon fcb9ec4e57 chore: clean up 2026-02-11 19:27:14 +08:00
Simon 1fcf931685 feat(core): add a observe phase in a step; improve abortSignal 2026-02-10 22:20:42 +08:00
Simon c71ffcec1c refactor(ext): tidy up background code 2026-02-10 21:59:58 +08:00
Simon 430be466fc feat(ext): add chat history 2026-02-10 18:43:54 +08:00
Simon 3f284cf2e6 chore(controller): remove link hint 2026-02-10 17:19:02 +08:00
Simon 6f0655478a fix(core): remove trimLines to fix indentation lost 2026-02-10 16:30:03 +08:00
Simon 7d5449967b feat(core): improve observation logic; mark states as private 2026-02-10 16:28:25 +08:00
Simon fd47888d1b chore(core): tidy up PageAgentCore members 2026-02-10 15:45:04 +08:00
Simon 10f470ce7d chore: improve package.json 2026-02-10 15:28:50 +08:00
Simon cb29db1cc4 Merge pull request #138 from alibaba/dependabot/npm_and_yarn/production-dependencies-58bd30f460
chore(deps): bump the production-dependencies group with 2 updates
2026-02-10 14:21:11 +08:00
Simon 8c08f639dd Merge pull request #139 from alibaba/dependabot/npm_and_yarn/development-dependencies-dee6027aab
chore(deps-dev): bump the development-dependencies group with 8 updates
2026-02-10 14:18:18 +08:00
dependabot[bot] 488f080e68 chore(deps-dev): bump the development-dependencies group with 8 updates
Bumps the development-dependencies group with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [@microsoft/api-extractor](https://github.com/microsoft/rushstack/tree/HEAD/apps/api-extractor) | `7.56.0` | `7.56.3` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.2.0` | `25.2.2` |
| [dotenv](https://github.com/motdotla/dotenv) | `17.2.3` | `17.2.4` |
| [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/HEAD/packages/plugins/eslint-plugin-react-dom) | `2.9.3` | `2.12.2` |
| [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/HEAD/packages/plugins/eslint-plugin-react-x) | `2.9.3` | `2.12.2` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.54.0` | `8.55.0` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.10` | `19.2.13` |
| [wxt](https://github.com/wxt-dev/wxt) | `0.20.13` | `0.20.14` |


Updates `@microsoft/api-extractor` from 7.56.0 to 7.56.3
- [Changelog](https://github.com/microsoft/rushstack/blob/main/apps/api-extractor/CHANGELOG.md)
- [Commits](https://github.com/microsoft/rushstack/commits/@microsoft/api-extractor_v7.56.3/apps/api-extractor)

Updates `@types/node` from 25.2.0 to 25.2.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `dotenv` from 17.2.3 to 17.2.4
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/commits)

Updates `eslint-plugin-react-dom` from 2.9.3 to 2.12.2
- [Release notes](https://github.com/Rel1cx/eslint-react/releases)
- [Changelog](https://github.com/Rel1cx/eslint-react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Rel1cx/eslint-react/commits/v2.12.2/packages/plugins/eslint-plugin-react-dom)

Updates `eslint-plugin-react-x` from 2.9.3 to 2.12.2
- [Release notes](https://github.com/Rel1cx/eslint-react/releases)
- [Changelog](https://github.com/Rel1cx/eslint-react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Rel1cx/eslint-react/commits/v2.12.2/packages/plugins/eslint-plugin-react-x)

Updates `typescript-eslint` from 8.54.0 to 8.55.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.55.0/packages/typescript-eslint)

Updates `@types/react` from 19.2.10 to 19.2.13
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `wxt` from 0.20.13 to 0.20.14
- [Release notes](https://github.com/wxt-dev/wxt/releases)
- [Commits](https://github.com/wxt-dev/wxt/compare/wxt-v0.20.13...wxt-v0.20.14)

---
updated-dependencies:
- dependency-name: "@microsoft/api-extractor"
  dependency-version: 7.56.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@types/node"
  dependency-version: 25.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: dotenv
  dependency-version: 17.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: eslint-plugin-react-dom
  dependency-version: 2.12.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: eslint-plugin-react-x
  dependency-version: 2.12.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.55.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: "@types/react"
  dependency-version: 19.2.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: wxt
  dependency-version: 0.20.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-09 22:25:35 +00:00
dependabot[bot] 6e07af422b chore(deps): bump the production-dependencies group with 2 updates
Bumps the production-dependencies group with 2 updates: [motion](https://github.com/motiondivision/motion) and [simple-icons](https://github.com/simple-icons/simple-icons).


Updates `motion` from 12.30.0 to 12.34.0
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.30.0...v12.34.0)

Updates `simple-icons` from 16.7.0 to 16.8.0
- [Release notes](https://github.com/simple-icons/simple-icons/releases)
- [Commits](https://github.com/simple-icons/simple-icons/compare/16.7.0...16.8.0)

---
updated-dependencies:
- dependency-name: motion
  dependency-version: 12.34.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: simple-icons
  dependency-version: 16.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-09 22:23:28 +00:00
Simon e9d721f6b9 chore(ext): bump extension version to 0.1.4 2026-02-09 21:12:11 +08:00
Simon 840162df8c feat: change observation timing 2026-02-09 21:05:29 +08:00
Simon 1517bc4868 feat: prune prompts 2026-02-09 21:04:39 +08:00
35 changed files with 1151 additions and 735 deletions
+3 -3
View File
@@ -40,7 +40,7 @@
```html ```html
<script <script
src="https://registry.npmmirror.com/page-agent/1.1.2/files/dist/iife/page-agent.demo.js" src="https://registry.npmmirror.com/page-agent/1.2.0/files/dist/iife/page-agent.demo.js"
crossorigin="true" crossorigin="true"
></script> ></script>
``` ```
@@ -50,8 +50,8 @@
| Mirrors | URL | | Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- | | ------- | ---------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.1.2/dist/iife/page-agent.demo.js | | Global | https://cdn.jsdelivr.net/npm/page-agent@1.2.0/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.1.2/files/dist/iife/page-agent.demo.js | | China | https://registry.npmmirror.com/page-agent/1.2.0/files/dist/iife/page-agent.demo.js |
### NPM 安装 ### NPM 安装
+3 -3
View File
@@ -40,7 +40,7 @@ Fastest way to try PageAgent with our free Demo LLM:
```html ```html
<script <script
src="https://cdn.jsdelivr.net/npm/page-agent@1.1.2/dist/iife/page-agent.demo.js" src="https://cdn.jsdelivr.net/npm/page-agent@1.2.0/dist/iife/page-agent.demo.js"
crossorigin="true" crossorigin="true"
></script> ></script>
``` ```
@@ -50,8 +50,8 @@ Fastest way to try PageAgent with our free Demo LLM:
| Mirrors | URL | | Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- | | ------- | ---------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.1.2/dist/iife/page-agent.demo.js | | Global | https://cdn.jsdelivr.net/npm/page-agent@1.2.0/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.1.2/files/dist/iife/page-agent.demo.js | | China | https://registry.npmmirror.com/page-agent/1.2.0/files/dist/iife/page-agent.demo.js |
### NPM Installation ### NPM Installation
+376 -376
View File
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -1,7 +1,7 @@
{ {
"name": "root", "name": "root",
"private": true, "private": true,
"version": "1.1.2", "version": "1.2.0",
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
"packages/page-controller", "packages/page-controller",
@@ -21,8 +21,7 @@
}, },
"homepage": "https://alibaba.github.io/page-agent/", "homepage": "https://alibaba.github.io/page-agent/",
"engines": { "engines": {
"node": ">=20.0.0", "node": "^20.19.0 || ^22.13.0 || >=24"
"npm": ">=10.0.0"
}, },
"scripts": { "scripts": {
"cleanup": "rm -rf packages/*/dist", "cleanup": "rm -rf packages/*/dist",
@@ -39,24 +38,26 @@
"@commitlint/cli": "^20.3.1", "@commitlint/cli": "^20.3.1",
"@commitlint/config-conventional": "^20.3.1", "@commitlint/config-conventional": "^20.3.1",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@microsoft/api-extractor": "^7.55.2", "@microsoft/api-extractor": "^7.56.3",
"@tailwindcss/vite": "^4.1.18",
"@trivago/prettier-plugin-sort-imports": "^6.0.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^25.0.9", "@types/node": "^25.2.2",
"@vitejs/plugin-react-swc": "^4.1.0",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.4",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-dom": "^2.7.2", "eslint-plugin-react-dom": "^2.12.2",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.0", "eslint-plugin-react-refresh": "^0.5.0",
"eslint-plugin-react-x": "^2.7.2", "eslint-plugin-react-x": "^2.12.2",
"globals": "^17.0.0", "globals": "^17.0.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.2.4", "lint-staged": "^16.2.4",
"prettier": "^3.8.0", "prettier": "^3.8.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.54.0", "typescript-eslint": "^8.55.0",
"unplugin-dts": "^1.0.0-beta.6", "unplugin-dts": "^1.0.0-beta.6",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-plugin-css-injected-by-js": "^3.5.2", "vite-plugin-css-injected-by-js": "^3.5.2",
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/core", "name": "@page-agent/core",
"private": false, "private": false,
"version": "1.1.2", "version": "1.2.0",
"type": "module", "type": "module",
"main": "./dist/esm/page-agent-core.js", "main": "./dist/esm/page-agent-core.js",
"module": "./dist/esm/page-agent-core.js", "module": "./dist/esm/page-agent-core.js",
@@ -45,7 +45,7 @@
"dependencies": { "dependencies": {
"chalk": "^5.6.2", "chalk": "^5.6.2",
"zod": "^4.3.5", "zod": "^4.3.5",
"@page-agent/llms": "1.1.2", "@page-agent/llms": "1.2.0",
"@page-agent/page-controller": "1.1.2" "@page-agent/page-controller": "1.2.0"
} }
} }
+113 -94
View File
@@ -3,7 +3,7 @@
* All rights reserved. * All rights reserved.
*/ */
import { InvokeError, LLM, type Tool } from '@page-agent/llms' import { InvokeError, LLM, type Tool } from '@page-agent/llms'
import type { PageController } from '@page-agent/page-controller' import type { BrowserState, PageController } from '@page-agent/page-controller'
import chalk from 'chalk' import chalk from 'chalk'
import * as zod from 'zod' import * as zod from 'zod'
@@ -21,16 +21,25 @@ import type {
MacroToolInput, MacroToolInput,
MacroToolResult, MacroToolResult,
} from './types' } from './types'
import { assert, normalizeResponse, trimLines, uid, waitFor } from './utils' import { assert, normalizeResponse, uid, waitFor } from './utils'
export { type PageAgentConfig } export { type PageAgentConfig }
export { tool, type PageAgentTool } from './tools' export { tool, type PageAgentTool } from './tools'
export type * from './types' export type * from './types'
/** /**
* AI agent for browser DOM automation. * AI agent for browser automation.
* *
* @remarks * @remarks
* ## Re-act Agent Loop
* - step
* - observe (gather information about current environment and context)
* - think (LLM calling)
* - reflection (evaluate history, generate memory, short-term planning)
* - action (give the action to approach the next goal)
* - act (execute the action)
* - loop
*
* ## Event System * ## Event System
* - `statuschange` - Agent status transitions (idle → running → completed/error) * - `statuschange` - Agent status transitions (idle → running → completed/error)
* - `historychange` - History events updated (persistent, part of agent memory) * - `historychange` - History events updated (persistent, part of agent memory)
@@ -49,14 +58,16 @@ export type * from './types'
* - Types: thinking, executing, executed, retrying, error * - Types: thinking, executing, executed, retrying, error
*/ */
export class PageAgentCore extends EventTarget { export class PageAgentCore extends EventTarget {
config: PageAgentConfig & { maxSteps: number } readonly id = uid()
id = uid() readonly config: PageAgentConfig & { maxSteps: number }
tools: typeof tools readonly tools: typeof tools
/** PageController for DOM operations */
readonly pageController: PageController
task = '' task = ''
taskId = '' taskId = ''
/** History events */
/** Agent execution status */ history: HistoricalEvent[] = []
#status: AgentStatus = 'idle'
/** /**
* Callback for when agent needs user input (ask_user tool) * Callback for when agent needs user input (ask_user tool)
@@ -65,23 +76,21 @@ export class PageAgentCore extends EventTarget {
*/ */
onAskUser?: (question: string) => Promise<string> onAskUser?: (question: string) => Promise<string>
#status: AgentStatus = 'idle'
#llm: LLM #llm: LLM
#abortController = new AbortController() #abortController = new AbortController()
#observations: string[] = []
/** PageController for DOM operations */ /** internal states during a single task execution */
pageController: PageController #states = {
/** Accumulated wait time in seconds */
/** Runtime states for tracking across steps */
states = {
/** Accumulated wait time in seconds, used by wait tool */
totalWaitTime: 0, totalWaitTime: 0,
/** Last known URL for detecting navigation */ /** For detecting navigation */
lastURL: '', lastURL: '',
/** Browser state */
browserState: null as BrowserState | null,
} }
/** History events */
history: HistoricalEvent[] = []
constructor(config: PageAgentConfig & { pageController: PageController }) { constructor(config: PageAgentConfig & { pageController: PageController }) {
super() super()
@@ -165,12 +174,13 @@ export class PageAgentCore extends EventTarget {
} }
/** /**
* Push a persistent observation to the history event stream. * Push a observation message to the history event stream.
* This will be visible in <agent_history> and remain in memory across steps. * This will be visible in <agent_history> and remain persistent in memory across steps.
* @experimental @internal
* @note history change will be emitted before next step starts
*/ */
pushObservation(content: string): void { pushObservation(content: string): void {
this.history.push({ type: 'observation', content }) this.#observations.push(content)
this.#emitHistoryChange()
} }
async execute(task: string): Promise<ExecutionResult> { async execute(task: string): Promise<ExecutionResult> {
@@ -202,11 +212,8 @@ export class PageAgentCore extends EventTarget {
this.#setStatus('running') this.#setStatus('running')
this.#emitHistoryChange() this.#emitHistoryChange()
// Reset states // Reset internal states
this.states = { this.#states = { totalWaitTime: 0, lastURL: '', browserState: null }
totalWaitTime: 0,
lastURL: '',
}
let step = 0 let step = 0
@@ -214,18 +221,16 @@ export class PageAgentCore extends EventTarget {
try { try {
console.group(`step: ${step}`) console.group(`step: ${step}`)
await this.#generateObservations(step)
await onBeforeStep?.(this, step) await onBeforeStep?.(this, step)
// abort // observe
if (this.#abortController.signal.aborted) throw new Error('AbortError')
// status console.log(chalk.blue.bold('👀 Observing...'))
console.log(chalk.blue('Thinking...'))
this.#emitActivity({ type: 'thinking' })
// invoke LLM this.#states.browserState = await this.pageController.getBrowserState()
await this.#handleObservations(step)
// assemble prompts
const messages = [ const messages = [
{ role: 'system' as const, content: this.#getSystemPrompt() }, { role: 'system' as const, content: this.#getSystemPrompt() },
@@ -234,12 +239,17 @@ export class PageAgentCore extends EventTarget {
const tools = { AgentOutput: this.#packMacroTool() } const tools = { AgentOutput: this.#packMacroTool() }
// invoke LLM
console.log(chalk.blue.bold('🧠 Thinking...'))
this.#emitActivity({ type: 'thinking' })
const result = await this.#llm.invoke(messages, tools, this.#abortController.signal, { const result = await this.#llm.invoke(messages, tools, this.#abortController.signal, {
toolChoiceName: 'AgentOutput', toolChoiceName: 'AgentOutput',
normalizeResponse, normalizeResponse,
}) })
// assemble history event // assemble history
const macroResult = result.toolResult as MacroToolResult const macroResult = result.toolResult as MacroToolResult
const input = macroResult.input const input = macroResult.input
@@ -271,7 +281,6 @@ export class PageAgentCore extends EventTarget {
await onAfterStep?.(this, this.history) await onAfterStep?.(this, this.history)
console.log(chalk.green('Step finished:'), actionName)
console.groupEnd() console.groupEnd()
// finish task if done // finish task if done
@@ -347,13 +356,13 @@ export class PageAgentCore extends EventTarget {
}) })
return { return {
description: 'You MUST call this tool every step. Outputs your reflections and next action.', description: 'You MUST call this tool every step!',
inputSchema: macroToolSchema as zod.ZodType<MacroToolInput>, inputSchema: macroToolSchema as zod.ZodType<MacroToolInput>,
execute: async (input: MacroToolInput): Promise<MacroToolResult> => { execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
// abort // abort
if (this.#abortController.signal.aborted) throw new Error('AbortError') if (this.#abortController.signal.aborted) throw new Error('AbortError')
console.log(chalk.blue.bold('MacroTool execute'), input) console.log(chalk.blue.bold('MacroTool input'), input)
const action = input.action const action = input.action
const toolName = Object.keys(action)[0] const toolName = Object.keys(action)[0]
@@ -374,7 +383,7 @@ export class PageAgentCore extends EventTarget {
// Find the corresponding tool // Find the corresponding tool
const tool = tools.get(toolName) const tool = tools.get(toolName)
assert(tool, `Tool ${toolName} not found. (@note should have been caught before this!!!)`) assert(tool, `Tool ${toolName} not found`)
console.log(chalk.blue.bold(`Executing tool: ${toolName}`), toolInput) console.log(chalk.blue.bold(`Executing tool: ${toolName}`), toolInput)
@@ -398,9 +407,11 @@ export class PageAgentCore extends EventTarget {
duration, duration,
}) })
// Reset wait time for non-wait tools // counting wait time
if (toolName !== 'wait') { if (toolName === 'wait') {
this.states.totalWaitTime = 0 this.#states.totalWaitTime += toolInput?.seconds || 0
} else {
this.#states.totalWaitTime = 0
} }
// Return structured result // Return structured result
@@ -430,17 +441,17 @@ export class PageAgentCore extends EventTarget {
} }
/** /**
* Get instructions from config and format as XML block * Get instructions from config
*/ */
async #getInstructions(): Promise<string> { async #getInstructions(): Promise<string> {
const { instructions } = this.config const { instructions } = this.config
if (!instructions) return '' if (!instructions) return ''
const systemInstructions = instructions.system?.trim() const systemInstructions = instructions.system?.trim()
const url = await this.pageController.getCurrentUrl()
let pageInstructions: string | undefined let pageInstructions: string | undefined
if (instructions.getPageInstructions) { const url = this.#states.browserState?.url || ''
if (instructions.getPageInstructions && url) {
try { try {
pageInstructions = instructions.getPageInstructions(url)?.trim() pageInstructions = instructions.getPageInstructions(url)?.trim()
} catch (error) { } catch (error) {
@@ -468,23 +479,28 @@ export class PageAgentCore extends EventTarget {
} }
/** /**
* Generate observations before each step * Generate system observations before each step
* - URL change detection
* - Too many steps warning
* @todo loop detection * @todo loop detection
* @todo console error * @todo console error
*/ */
async #generateObservations(stepCount: number): Promise<void> { async #handleObservations(step: number): Promise<void> {
// Accumulated wait time warning
if (this.#states.totalWaitTime >= 3) {
this.pushObservation(
`You have waited ${this.#states.totalWaitTime} seconds accumulatively. DO NOT wait any longer unless you have a good reason.`
)
}
// Detect URL change // Detect URL change
const currentURL = await this.pageController.getCurrentUrl() const currentURL = this.#states.browserState?.url || ''
if (currentURL !== this.states.lastURL) { if (currentURL !== this.#states.lastURL) {
this.pushObservation(`Page navigated to → ${currentURL}`) this.pushObservation(`Page navigated to → ${currentURL}`)
this.states.lastURL = currentURL this.#states.lastURL = currentURL
await waitFor(0.5) // wait for page to stabilize await waitFor(0.5) // wait for page to stabilize
} }
// Warn about remaining steps // Remaining steps warning
const remaining = this.config.maxSteps - stepCount const remaining = this.config.maxSteps - step
if (remaining === 5) { if (remaining === 5) {
this.pushObservation( this.pushObservation(
`⚠️ Only ${remaining} steps remaining. Consider wrapping up or calling done with partial results.` `⚠️ Only ${remaining} steps remaining. Consider wrapping up or calling done with partial results.`
@@ -494,12 +510,25 @@ export class PageAgentCore extends EventTarget {
`⚠️ Critical: Only ${remaining} steps left! You must finish the task or call done immediately.` `⚠️ Critical: Only ${remaining} steps left! You must finish the task or call done immediately.`
) )
} }
// Push observations to history and emit
if (this.#observations.length > 0) {
for (const content of this.#observations) {
this.history.push({ type: 'observation', content })
console.log(chalk.cyan('Observation:'), content)
}
this.#observations = []
this.#emitHistoryChange()
}
} }
async #assembleUserPrompt(): Promise<string> { async #assembleUserPrompt(): Promise<string> {
const browserState = this.#states.browserState!
let prompt = '' let prompt = ''
// <instructions> (optional) // <instructions> (optional)
prompt += await this.#getInstructions() prompt += await this.#getInstructions()
// <agent_state> // <agent_state>
@@ -509,38 +538,36 @@ export class PageAgentCore extends EventTarget {
const stepCount = this.history.filter((e) => e.type === 'step').length const stepCount = this.history.filter((e) => e.type === 'step').length
prompt += `<agent_state> prompt += '<agent_state>\n'
<user_request> prompt += '<user_request>\n'
${this.task} prompt += `${this.task}\n`
</user_request> prompt += '</user_request>\n'
<step_info> prompt += '<step_info>\n'
Step ${stepCount + 1} of ${this.config.maxSteps} max possible steps prompt += `Step ${stepCount + 1} of ${this.config.maxSteps} max possible steps\n`
Current date and time: ${new Date().toLocaleString()} prompt += `Current time: ${new Date().toLocaleString()}\n`
</step_info> prompt += '</step_info>\n'
</agent_state> prompt += '</agent_state>\n\n'
`
// <agent_history> // <agent_history>
// - <step_N> for steps // - <step_N> for steps
// - <sys> for observations and system messages // - <sys> for observations and system messages
prompt += '\n<agent_history>\n' prompt += '<agent_history>\n'
let stepIndex = 0 let stepIndex = 0
for (const event of this.history) { for (const event of this.history) {
if (event.type === 'step') { if (event.type === 'step') {
stepIndex++ stepIndex++
prompt += `<step_${stepIndex}> prompt += `<step_${stepIndex}>\n`
Evaluation of Previous Step: ${event.reflection.evaluation_previous_goal} prompt += `Evaluation of Previous Step: ${event.reflection.evaluation_previous_goal}\n`
Memory: ${event.reflection.memory} prompt += `Memory: ${event.reflection.memory}\n`
Next Goal: ${event.reflection.next_goal} prompt += `Next Goal: ${event.reflection.next_goal}\n`
Action Results: ${event.action.output} prompt += `Action Results: ${event.action.output}\n`
</step_${stepIndex}> prompt += `</step_${stepIndex}>\n`
`
} else if (event.type === 'observation') { } else if (event.type === 'observation') {
prompt += `<sys>${event.content}</sys>\n` prompt += `<sys>${event.content}</sys>\n`
} else if (event.type === 'user_takeover') { } else if (event.type === 'user_takeover') {
prompt += `<sys>User took over control and made changes to the page.</sys>\n` prompt += `<sys>User took over control and made changes to the page</sys>\n`
} else if (event.type === 'error') { } else if (event.type === 'error') {
// Error events are mainly for panel rendering, not included in LLM context // Error events are mainly for panel rendering, not included in LLM context
// to avoid polluting the agent's reasoning with transient errors // to avoid polluting the agent's reasoning with transient errors
@@ -551,9 +578,18 @@ export class PageAgentCore extends EventTarget {
// <browser_state> // <browser_state>
prompt += await this.#getBrowserState() let pageContent = browserState.content
if (this.config.transformPageContent) {
pageContent = await this.config.transformPageContent(pageContent)
}
return trimLines(prompt) prompt += '<browser_state>\n'
prompt += browserState.header + '\n'
prompt += pageContent + '\n'
prompt += browserState.footer + '\n\n'
prompt += '</browser_state>\n\n'
return prompt
} }
#onDone(success = true) { #onDone(success = true) {
@@ -563,23 +599,6 @@ export class PageAgentCore extends EventTarget {
this.#abortController.abort() this.#abortController.abort()
} }
async #getBrowserState(): Promise<string> {
const state = await this.pageController.getBrowserState()
let content = state.content
if (this.config.transformPageContent) {
content = await this.config.transformPageContent(content)
}
return trimLines(`<browser_state>
${state.header}
${content}
${state.footer}
</browser_state>
`)
}
dispose() { dispose() {
console.log('Disposing PageAgent...') console.log('Disposing PageAgent...')
this.pageController.dispose() this.pageController.dispose()
@@ -128,7 +128,6 @@ Exhibit the following reasoning patterns to successfully achieve the <user_reque
Here are examples of good output patterns. Use them as reference but never copy them directly. Here are examples of good output patterns. Use them as reference but never copy them directly.
<evaluation_examples> <evaluation_examples>
- Positive Examples:
"evaluation_previous_goal": "Successfully navigated to the product page and found the target information. Verdict: Success" "evaluation_previous_goal": "Successfully navigated to the product page and found the target information. Verdict: Success"
"evaluation_previous_goal": "Clicked the login button and user authentication form appeared. Verdict: Success" "evaluation_previous_goal": "Clicked the login button and user authentication form appeared. Verdict: Success"
</evaluation_examples> </evaluation_examples>
@@ -139,7 +138,6 @@ Here are examples of good output patterns. Use them as reference but never copy
<next_goal_examples> <next_goal_examples>
"next_goal": "Click on the 'Add to Cart' button to proceed with the purchase flow." "next_goal": "Click on the 'Add to Cart' button to proceed with the purchase flow."
"next_goal": "Extract details from the first item on the page."
</next_goal_examples> </next_goal_examples>
</examples> </examples>
+9 -18
View File
@@ -31,14 +31,13 @@ tools.set(
'done', 'done',
tool({ tool({
description: description:
'Complete task - provide a summary of results for the user. Set success=True if task completed successfully, false otherwise. Text should be your response to the user summarizing results.', 'Complete task. Text is your final response to the user — keep it concise unless the user explicitly asks for detail.',
inputSchema: zod.object({ inputSchema: zod.object({
text: zod.string(), text: zod.string(),
success: zod.boolean().default(true), success: zod.boolean().default(true),
}), }),
execute: async function (this: PageAgentCore, input) { execute: async function (this: PageAgentCore, input) {
// @note main loop will handle this one // @note main loop will handle this one
// this.onDone(input.text, input.success)
return Promise.resolve('Task completed') return Promise.resolve('Task completed')
}, },
}) })
@@ -47,25 +46,17 @@ tools.set(
tools.set( tools.set(
'wait', 'wait',
tool({ tool({
description: description: 'Wait for x seconds. Can be used to wait until the page or data is fully loaded.',
'Wait for x seconds. default 1s (max 10 seconds, min 1 second). This can be used to wait until the page or data is fully loaded.',
inputSchema: zod.object({ inputSchema: zod.object({
seconds: zod.number().min(1).max(10).default(1), seconds: zod.number().min(1).max(10).default(1),
}), }),
execute: async function (this: PageAgentCore, input) { execute: async function (this: PageAgentCore, input) {
// try to subtract LLM calling time from the actual wait time
const lastTimeUpdate = await this.pageController.getLastUpdateTime() const lastTimeUpdate = await this.pageController.getLastUpdateTime()
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000) const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000)
console.log(`actualWaitTime: ${actualWaitTime} seconds`) console.log(`actualWaitTime: ${actualWaitTime} seconds`)
await waitFor(actualWaitTime) await waitFor(actualWaitTime)
this.states.totalWaitTime += input.seconds
if (this.states.totalWaitTime >= 3) {
this.pushObservation(
`You have waited ${this.states.totalWaitTime} seconds accumulatively. Do NOT wait any longer unless you have a good reason.`
)
}
return `✅ Waited for ${input.seconds} seconds.` return `✅ Waited for ${input.seconds} seconds.`
}, },
}) })
@@ -106,7 +97,7 @@ tools.set(
tools.set( tools.set(
'input_text', 'input_text',
tool({ tool({
description: 'Click and input text into a input interactive element', description: 'Click and type text into an interactive input element',
inputSchema: zod.object({ inputSchema: zod.object({
index: zod.int().min(0), index: zod.int().min(0),
text: zod.string(), text: zod.string(),
@@ -140,8 +131,7 @@ tools.set(
tools.set( tools.set(
'scroll', 'scroll',
tool({ tool({
description: description: 'Scroll the page vertically. Use index for scroll elements (dropdowns/custom UI).',
'Scroll the page by specified number of pages (set down=True to scroll down, down=False to scroll up, num_pages=number of pages to scroll like 0.5 for half page, 1.0 for one page, etc.). Optional index parameter to scroll within a specific element or its scroll container (works well for dropdowns and custom UI components). Optional pixels parameter to scroll by a specific number of pixels instead of pages.',
inputSchema: zod.object({ inputSchema: zod.object({
down: zod.boolean().default(true), down: zod.boolean().default(true),
num_pages: zod.number().min(0).max(10).optional().default(0.1), num_pages: zod.number().min(0).max(10).optional().default(0.1),
@@ -158,11 +148,14 @@ tools.set(
}) })
) )
/**
* @todo Tables need a dedicated parser to extract structured data. This tool is useless.
*/
tools.set( tools.set(
'scroll_horizontally', 'scroll_horizontally',
tool({ tool({
description: description:
'Scroll the page or element horizontally (set right=True to scroll right, right=False to scroll left, pixels=number of pixels to scroll). Optional index parameter to scroll within a specific element or its scroll container (works well for wide tables).', 'Scroll the page horizontally, or within a specific element by index. Useful for wide tables.',
inputSchema: zod.object({ inputSchema: zod.object({
right: zod.boolean().default(true), right: zod.boolean().default(true),
pixels: zod.number().int().min(0), pixels: zod.number().int().min(0),
@@ -190,8 +183,6 @@ tools.set(
}) })
) )
// @todo get_dropdown_options
// @todo select_dropdown_option
// @todo send_keys // @todo send_keys
// @todo upload_file // @todo upload_file
// @todo go_back // @todo go_back
-31
View File
@@ -2,28 +2,6 @@ import chalk from 'chalk'
export { normalizeResponse } from './autoFixer' export { normalizeResponse } from './autoFixer'
/**
* Wait until condition becomes true
* @returns Returns when condition becomes true, throws otherwise
* @param timeout Timeout in milliseconds, default 0 means no timeout, throws error on timeout
*/
export async function waitUntil(check: () => boolean, timeout = 60 * 60_1000): Promise<boolean> {
if (check()) return true
return new Promise((resolve, reject) => {
const start = Date.now()
const interval = setInterval(() => {
if (check()) {
clearInterval(interval)
resolve(true)
} else if (Date.now() - start > timeout) {
clearInterval(interval)
reject(new Error('Timeout waiting for condition to become true'))
}
}, 100)
})
}
export async function waitFor(seconds: number): Promise<void> { export async function waitFor(seconds: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, seconds * 1000)) await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
} }
@@ -39,15 +17,6 @@ export function truncate(text: string, maxLength: number): string {
// //
export function trimLines(text: string): string {
return text
.split('\n')
.map((line) => line.trim())
.join('\n')
}
//
export function randomID(existingIDs?: string[]): string { export function randomID(existingIDs?: string[]): string {
let id = Math.random().toString(36).substring(2, 11) let id = Math.random().toString(36).substring(2, 11)
+22 -21
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/ext", "name": "@page-agent/ext",
"private": true, "private": true,
"version": "0.1.3", "version": "0.1.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "wxt", "dev": "wxt",
@@ -10,23 +10,6 @@
"postinstall": "wxt prepare" "postinstall": "wxt prepare"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@types/chrome": "^0.1.34",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.1",
"@vitejs/plugin-react-swc": "^4.1.0",
"@wxt-dev/module-react": "^1.1.5",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0",
"wxt": "^0.20.13"
},
"dependencies": {
"@page-agent/core": "1.1.2",
"@page-agent/llms": "1.1.2",
"@page-agent/page-controller": "1.1.2",
"@page-agent/ui": "1.1.2",
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
@@ -34,14 +17,32 @@
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@types/chrome": "^0.1.34",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.1",
"@wxt-dev/module-react": "^1.1.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"idb": "^8.0.3",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"motion": "^12.30.0", "motion": "^12.34.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"rough-notation": "^0.5.1", "rough-notation": "^0.5.1",
"simple-icons": "^16.7.0", "simple-icons": "^16.8.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0",
"wxt": "^0.20.14"
},
"dependencies": {
"@page-agent/core": "1.2.0",
"@page-agent/llms": "1.2.0",
"@page-agent/page-controller": "1.2.0",
"@page-agent/ui": "1.2.0",
"chalk": "^5.6.2",
"zod": "^4.3.5"
} }
} }
@@ -74,6 +74,11 @@ export class MultiPageAgent extends PageAgentCore {
}) })
}, },
onBeforeStep: async (agent) => {
// make sure the current tab is loaded before the step starts
await tabsController.waitUntilTabLoaded(tabsController.currentTabId!)
},
onDispose: () => { onDispose: () => {
if (heartBeatInterval) { if (heartBeatInterval) {
window.clearInterval(heartBeatInterval) window.clearInterval(heartBeatInterval)
@@ -8,13 +8,21 @@ export function handlePageControlMessage(
sender: chrome.runtime.MessageSender, sender: chrome.runtime.MessageSender,
sendResponse: (response: unknown) => void sendResponse: (response: unknown) => void
): true | undefined { ): true | undefined {
const PREFIX = '[RemotePageController.background]'
function debug(...messages: any[]) {
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
const { action, payload, targetTabId } = message const { action, payload, targetTabId } = message
if (action === 'get_my_tab_id') { if (action === 'get_my_tab_id') {
debug('get_my_tab_id', sender.tab?.id)
sendResponse({ tabId: sender.tab?.id || null }) sendResponse({ tabId: sender.tab?.id || null })
return return
} }
// proxy to content script
chrome.tabs chrome.tabs
.sendMessage(targetTabId, { .sendMessage(targetTabId, {
type: 'PAGE_CONTROL', type: 'PAGE_CONTROL',
@@ -25,6 +33,7 @@ export function handlePageControlMessage(
sendResponse(result) sendResponse(result)
}) })
.catch((error) => { .catch((error) => {
console.error(PREFIX, error)
sendResponse({ sendResponse({
success: false, success: false,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
@@ -12,10 +12,14 @@ export function initPageController() {
.then((response) => { .then((response) => {
return (response as { tabId: number | null }).tabId return (response as { tabId: number | null }).tabId
}) })
.catch((error) => {
console.error('[RemotePageController.ContentScript]: Failed to get my tab id', error)
return null
})
function getPC(): PageController { function getPC(): PageController {
if (!pageController) { if (!pageController) {
pageController = new PageController({ enableMask: false }) pageController = new PageController({ enableMask: false, viewportExpansion: 400 })
} }
return pageController return pageController
} }
@@ -2,13 +2,31 @@ import type { BrowserState } from '@page-agent/page-controller'
import type { TabsController } from './TabsController' import type { TabsController } from './TabsController'
const PREFIX = '[RemotePageController]'
function debug(...messages: any[]) {
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
function sendMessage(message: {
type: 'PAGE_CONTROL'
action: string
targetTabId: number
payload?: any
}): Promise<any> {
return chrome.runtime.sendMessage(message).catch((error) => {
console.error(PREFIX, message.action, error)
return null
})
}
/** /**
* Agent side page controller. * Agent side page controller.
* - live in the agent env (extension page or content script) * - live in the agent env (extension page or content script)
* - communicates with remote PageController via sw * - communicates with remote PageController via sw
*/ */
export class RemotePageController { export class RemotePageController {
private tabsController: TabsController tabsController: TabsController
constructor(tabsController: TabsController) { constructor(tabsController: TabsController) {
this.tabsController = tabsController this.tabsController = tabsController
@@ -18,46 +36,46 @@ export class RemotePageController {
return this.tabsController.currentTabId return this.tabsController.currentTabId
} }
async getCurrentUrl(): Promise<string> { private async getCurrentUrl(): Promise<string> {
if (!this.currentTabId) return '' if (!this.currentTabId) return ''
const { url } = await this.tabsController.getTabInfo(this.currentTabId) const { url } = await this.tabsController.getTabInfo(this.currentTabId)
return url || '' return url || ''
} }
async getCurrentTitle(): Promise<string> { private async getCurrentTitle(): Promise<string> {
if (!this.currentTabId) return '' if (!this.currentTabId) return ''
const { title } = await this.tabsController.getTabInfo(this.currentTabId) const { title } = await this.tabsController.getTabInfo(this.currentTabId)
return title || '' return title || ''
} }
get currentTabTitle(): Promise<string> {
return this.getCurrentTitle()
}
async getLastUpdateTime(): Promise<number> { async getLastUpdateTime(): Promise<number> {
if (!this.currentTabId) throw new Error('tabsController not initialized.') if (!this.currentTabId) throw new Error('tabsController not initialized.')
return sendMessage({
return await chrome.runtime.sendMessage({
type: 'PAGE_CONTROL', type: 'PAGE_CONTROL',
action: 'get_last_update_time', action: 'get_last_update_time',
targetTabId: this.currentTabId, targetTabId: this.currentTabId,
}) })
} }
// getBrowserState
async getBrowserState(): Promise<BrowserState> { async getBrowserState(): Promise<BrowserState> {
let browserState = {} as BrowserState if (!this.currentTabId) throw new Error('tabsController not initialized.')
if (!this.currentTabId || !isContentScriptAllowed(await this.currentTabUrl)) { let browserState = {} as BrowserState
debug('getBrowserState', this.currentTabId)
const currentUrl = await this.getCurrentUrl()
const currentTitle = await this.getCurrentTitle()
if (!this.currentTabId || !isContentScriptAllowed(currentUrl)) {
browserState = { browserState = {
url: await this.currentTabUrl, url: currentUrl,
title: await this.currentTabTitle, title: currentTitle,
header: '', header: '',
content: '(empty page)', content: '(empty page. either current page is not readable or not loaded yet.)',
footer: '', footer: '',
} }
} else { } else {
browserState = await chrome.runtime.sendMessage({ browserState = await sendMessage({
type: 'PAGE_CONTROL', type: 'PAGE_CONTROL',
action: 'get_browser_state', action: 'get_browser_state',
targetTabId: this.currentTabId, targetTabId: this.currentTabId,
@@ -67,61 +85,58 @@ export class RemotePageController {
const sum = await this.tabsController.summarizeTabs() const sum = await this.tabsController.summarizeTabs()
browserState.header = sum + '\n\n' + (browserState.header || '') browserState.header = sum + '\n\n' + (browserState.header || '')
debug('getBrowserState: success', this.currentTabId, browserState)
return browserState return browserState
} }
// updateTree
async updateTree(): Promise<void> { async updateTree(): Promise<void> {
if (!this.currentTabId || !isContentScriptAllowed(await this.currentTabUrl)) { if (!this.currentTabId || !isContentScriptAllowed(await this.getCurrentUrl())) {
return return
} }
await chrome.runtime.sendMessage({ await sendMessage({
type: 'PAGE_CONTROL', type: 'PAGE_CONTROL',
action: 'update_tree', action: 'update_tree',
targetTabId: this.currentTabId, targetTabId: this.currentTabId,
}) })
} }
// cleanUpHighlights
async cleanUpHighlights(): Promise<void> { async cleanUpHighlights(): Promise<void> {
if (!this.currentTabId || !isContentScriptAllowed(await this.currentTabUrl)) { if (!this.currentTabId || !isContentScriptAllowed(await this.getCurrentUrl())) {
return return
} }
await chrome.runtime.sendMessage({ await sendMessage({
type: 'PAGE_CONTROL', type: 'PAGE_CONTROL',
action: 'clean_up_highlights', action: 'clean_up_highlights',
targetTabId: this.currentTabId, targetTabId: this.currentTabId,
}) })
} }
// clickElement
async clickElement(...args: any[]): Promise<DomActionReturn> { async clickElement(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('click_element', args) const res = await this.remoteCallDomAction('click_element', args)
// @note may cause page navigation, wait for 1 second to ensure the page loading started
await new Promise((resolve) => setTimeout(resolve, 1000))
return res
} }
// inputText
async inputText(...args: any[]): Promise<DomActionReturn> { async inputText(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('input_text', args) return this.remoteCallDomAction('input_text', args)
} }
// selectOption
async selectOption(...args: any[]): Promise<DomActionReturn> { async selectOption(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('select_option', args) return this.remoteCallDomAction('select_option', args)
} }
// scroll
async scroll(...args: any[]): Promise<DomActionReturn> { async scroll(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('scroll', args) return this.remoteCallDomAction('scroll', args)
} }
// scrollHorizontally
async scrollHorizontally(...args: any[]): Promise<DomActionReturn> { async scrollHorizontally(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('scroll_horizontally', args) return this.remoteCallDomAction('scroll_horizontally', args)
} }
// executeJavascript
async executeJavascript(...args: any[]): Promise<DomActionReturn> { async executeJavascript(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('execute_javascript', args) return this.remoteCallDomAction('execute_javascript', args)
} }
@@ -133,35 +148,26 @@ export class RemotePageController {
/** @note Managed by content script via storage polling. */ /** @note Managed by content script via storage polling. */
dispose(): void {} dispose(): void {}
private async preCheck() {
if (!this.currentTabId) {
return 'RemotePageController not initialized.'
}
if (!isContentScriptAllowed(await this.currentTabUrl)) {
return 'Operation not allowed on this page. Use open_new_tab to navigate to a web page first.'
}
return null
}
private async remoteCallDomAction(action: string, payload: any[]): Promise<DomActionReturn> { private async remoteCallDomAction(action: string, payload: any[]): Promise<DomActionReturn> {
const preCheckError = await this.preCheck() if (!this.currentTabId) {
if (preCheckError) { return { success: false, message: 'RemotePageController not initialized.' }
return { success: false, message: preCheckError }
} }
return await chrome.runtime.sendMessage({ if (!isContentScriptAllowed(await this.getCurrentUrl())) {
return {
success: false,
message:
'Operation not allowed on this page. Use open_new_tab to navigate to a web page first.',
}
}
return sendMessage({
type: 'PAGE_CONTROL', type: 'PAGE_CONTROL',
action: action, action: action,
targetTabId: this.currentTabId!, targetTabId: this.currentTabId!,
payload, payload,
}) })
} }
private get currentTabUrl(): Promise<string> {
return this.getCurrentUrl()
}
} }
interface DomActionReturn { interface DomActionReturn {
@@ -3,6 +3,12 @@
*/ */
import type { TabAction } from './TabsController' import type { TabAction } from './TabsController'
const PREFIX = '[TabsController.background]'
function debug(...messages: any[]) {
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
export function handleTabControlMessage( export function handleTabControlMessage(
message: { type: 'TAB_CONTROL'; action: TabAction; payload: any }, message: { type: 'TAB_CONTROL'; action: TabAction; payload: any },
sender: chrome.runtime.MessageSender, sender: chrome.runtime.MessageSender,
@@ -12,10 +18,12 @@ export function handleTabControlMessage(
switch (action as TabAction) { switch (action as TabAction) {
case 'get_active_tab': { case 'get_active_tab': {
debug('get_active_tab')
chrome.tabs chrome.tabs
.query({ active: true, currentWindow: true }) .query({ active: true, currentWindow: true })
.then((tabs) => { .then((tabs) => {
const tabId = tabs.length > 0 ? tabs[0].id || null : null const tabId = tabs.length > 0 ? tabs[0].id || null : null
debug('get_active_tab: success', tabId)
sendResponse({ success: true, tabId }) sendResponse({ success: true, tabId })
}) })
.catch((error) => { .catch((error) => {
@@ -25,11 +33,12 @@ export function handleTabControlMessage(
} }
case 'get_tab_info': { case 'get_tab_info': {
debug('get_tab_info', payload)
chrome.tabs chrome.tabs
.get(payload.tabId) .get(payload.tabId)
.then((tab) => { .then((tab) => {
const result = { title: tab.title || '', url: tab.url || '' } debug('get_tab_info: success', tab)
sendResponse(result) sendResponse(tab)
}) })
.catch((error) => { .catch((error) => {
sendResponse({ error: error instanceof Error ? error.message : String(error) }) sendResponse({ error: error instanceof Error ? error.message : String(error) })
@@ -38,10 +47,11 @@ export function handleTabControlMessage(
} }
case 'open_new_tab': { case 'open_new_tab': {
debug('open_new_tab', payload)
chrome.tabs chrome.tabs
.create({ url: payload.url, active: false }) .create({ url: payload.url, active: false })
.then((newTab) => { .then((newTab) => {
// @todo: wait for tab to be fully loaded debug('open_new_tab: success', newTab)
sendResponse({ success: true, tabId: newTab.id, windowId: newTab.windowId }) sendResponse({ success: true, tabId: newTab.id, windowId: newTab.windowId })
}) })
.catch((error) => { .catch((error) => {
@@ -51,20 +61,22 @@ export function handleTabControlMessage(
} }
case 'create_tab_group': { case 'create_tab_group': {
debug('create_tab_group', payload)
chrome.tabs chrome.tabs
.group({ tabIds: payload.tabIds, createProperties: { windowId: payload.windowId } }) .group({ tabIds: payload.tabIds, createProperties: { windowId: payload.windowId } })
.then((groupId) => { .then((groupId) => {
console.log('Created tab group', groupId) debug('create_tab_group: success', groupId)
sendResponse({ success: true, groupId }) sendResponse({ success: true, groupId })
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to create tab group', error) console.error(PREFIX, 'Failed to create tab group', error)
sendResponse({ error: error instanceof Error ? error.message : String(error) }) sendResponse({ error: error instanceof Error ? error.message : String(error) })
}) })
return true // async response return true // async response
} }
case 'update_tab_group': { case 'update_tab_group': {
debug('update_tab_group', payload)
chrome.tabGroups chrome.tabGroups
.update(payload.groupId, payload.properties) .update(payload.groupId, payload.properties)
.then(() => { .then(() => {
@@ -77,6 +89,7 @@ export function handleTabControlMessage(
} }
case 'add_tab_to_group': { case 'add_tab_to_group': {
debug('add_tab_to_group', payload)
chrome.tabs chrome.tabs
.group({ tabIds: payload.tabId, groupId: payload.groupId }) .group({ tabIds: payload.tabId, groupId: payload.groupId })
.then(() => { .then(() => {
@@ -89,6 +102,7 @@ export function handleTabControlMessage(
} }
case 'close_tab': { case 'close_tab': {
debug('close_tab', payload)
chrome.tabs chrome.tabs
.remove(payload.tabId) .remove(payload.tabId)
.then(() => { .then(() => {
@@ -105,3 +119,42 @@ export function handleTabControlMessage(
return return
} }
} }
export function setupTabChangeEvents() {
console.log('[TabsController.background] setupTabChangeEvents')
chrome.tabs.onCreated.addListener((tab) => {
debug('onCreated', tab)
chrome.runtime
.sendMessage({ type: 'TAB_CHANGE', action: 'created', payload: { tab } })
.catch((error) => {
debug('onCreated error:', error)
})
})
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
debug('onRemoved', tabId, removeInfo)
chrome.runtime
.sendMessage({
type: 'TAB_CHANGE',
action: 'removed',
payload: { tabId, removeInfo },
})
.catch((error) => {
debug('onRemoved error:', error)
})
})
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
debug('onUpdated', tabId, changeInfo)
chrome.runtime
.sendMessage({
type: 'TAB_CHANGE',
action: 'updated',
payload: { tabId, changeInfo, tab },
})
.catch((error) => {
debug('onUpdated error:', error)
})
})
}
+122 -42
View File
@@ -1,3 +1,20 @@
const PREFIX = '[TabsController]'
function debug(...messages: any[]) {
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
function sendMessage(message: {
type: 'TAB_CONTROL'
action: TabAction
payload?: any
}): Promise<any> {
return chrome.runtime.sendMessage(message).catch((error) => {
console.error(PREFIX, message.action, error)
return null
})
}
/** /**
* Controller for managing browser tabs. * Controller for managing browser tabs.
* - live in the agent env (extension page or content script) * - live in the agent env (extension page or content script)
@@ -13,6 +30,8 @@ export class TabsController extends EventTarget {
private windowId: number | null = null private windowId: number | null = null
async init(task: string, includeInitialTab: boolean = true) { async init(task: string, includeInitialTab: boolean = true) {
debug('init', task, includeInitialTab)
this.task = task this.task = task
this.tabs = [] this.tabs = []
this.currentTabId = null this.currentTabId = null
@@ -20,7 +39,7 @@ export class TabsController extends EventTarget {
this.initialTabId = null this.initialTabId = null
this.windowId = null this.windowId = null
const result = await chrome.runtime.sendMessage({ const result = await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'get_active_tab', action: 'get_active_tab',
}) })
@@ -33,9 +52,20 @@ export class TabsController extends EventTarget {
if (includeInitialTab) { if (includeInitialTab) {
this.currentTabId = this.initialTabId this.currentTabId = this.initialTabId
// update tab status immediately
const info = await sendMessage({
type: 'TAB_CONTROL',
action: 'get_tab_info',
payload: { tabId: this.initialTabId },
})
this.tabs.push({ this.tabs.push({
id: result.tabId, id: result.tabId,
isInitial: true, isInitial: true,
url: info.url,
title: info.title,
status: info.status,
}) })
} }
@@ -70,6 +100,14 @@ export class TabsController extends EventTarget {
} }
} }
} }
} else if (message.action === 'updated') {
const { tabId, tab } = message.payload as { tabId: number; tab: chrome.tabs.Tab }
const targetTab = this.tabs.find((t) => t.id === tabId)
if (targetTab) {
targetTab.url = tab.url
targetTab.title = tab.title
targetTab.status = tab.status
}
} }
} }
@@ -80,8 +118,10 @@ export class TabsController extends EventTarget {
}) })
} }
async openNewTab(url: string): Promise<{ success: boolean; tabId: number; message: string }> { async openNewTab(url: string): Promise<string> {
const result = await chrome.runtime.sendMessage({ debug('openNewTab', url)
const result = await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'open_new_tab', action: 'open_new_tab',
payload: { url }, payload: { url },
@@ -104,7 +144,7 @@ export class TabsController extends EventTarget {
await this.switchToTab(tabId) await this.switchToTab(tabId)
if (!this.tabGroupId) { if (!this.tabGroupId) {
const result = await chrome.runtime.sendMessage({ const result = await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'create_tab_group', action: 'create_tab_group',
payload: { tabIds: [tabId], windowId: this.windowId }, payload: { tabIds: [tabId], windowId: this.windowId },
@@ -118,7 +158,7 @@ export class TabsController extends EventTarget {
this.tabGroupId = groupId this.tabGroupId = groupId
await chrome.runtime.sendMessage({ await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'update_tab_group', action: 'update_tab_group',
payload: { payload: {
@@ -131,57 +171,43 @@ export class TabsController extends EventTarget {
}, },
}) })
} else { } else {
await chrome.runtime.sendMessage({ await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'add_tab_to_group', action: 'add_tab_to_group',
payload: { tabId: result.tabId, groupId: this.tabGroupId }, payload: { tabId: result.tabId, groupId: this.tabGroupId },
}) })
} }
// wait for the new tab to be fully loaded await this.waitUntilTabLoaded(tabId)
// @todo
await new Promise((resolve) => setTimeout(resolve, 500))
return { return `✅ Opened new tab ID ${tabId} with URL ${url}`
success: true,
tabId,
message: `Opened new tab ID ${tabId} with URL ${url}`,
}
} }
async switchToTab(tabId: number): Promise<{ success: boolean; message: string }> { async switchToTab(tabId: number): Promise<string> {
debug('switchToTab', tabId)
const targetTab = this.tabs.find((t) => t.id === tabId) const targetTab = this.tabs.find((t) => t.id === tabId)
if (!targetTab) { if (!targetTab) {
return { throw new Error(`Tab ID ${tabId} not found in tab list.`)
success: false,
message: `Tab ID ${tabId} not found in tab list.`,
}
} }
await this.updateCurrentTabId(tabId) await this.updateCurrentTabId(tabId)
return { return `✅ Switched to tab ID ${tabId}.`
success: true,
message: `Switched to tab ID ${tabId}.`,
}
} }
async closeTab(tabId: number): Promise<{ success: boolean; message: string }> { async closeTab(tabId: number): Promise<string> {
debug('closeTab', tabId)
const targetTab = this.tabs.find((t) => t.id === tabId) const targetTab = this.tabs.find((t) => t.id === tabId)
if (!targetTab) { if (!targetTab) {
return { throw new Error(`Tab ID ${tabId} not found in tab list.`)
success: false,
message: `Tab ID ${tabId} not found in tab list.`,
}
} }
if (targetTab.isInitial) { if (targetTab.isInitial) {
return { throw new Error(`Cannot close the initial tab ID ${tabId}.`)
success: false,
message: `Cannot close the initial tab ID ${tabId}.`,
}
} }
const result = await chrome.runtime.sendMessage({ const result = await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'close_tab', action: 'close_tab',
payload: { tabId }, payload: { tabId },
@@ -198,29 +224,39 @@ export class TabsController extends EventTarget {
} }
} }
return { return `✅ Closed tab ID ${tabId}.`
success: true,
message: `Closed tab ID ${tabId}.`,
}
} else { } else {
return { throw new Error(`Failed to close tab ID ${tabId}: ${result.error}`)
success: false,
message: `Failed to close tab ID ${tabId}: ${result.error}`,
}
} }
} }
async updateCurrentTabId(tabId: number | null) { async updateCurrentTabId(tabId: number | null) {
debug('updateCurrentTabId', tabId)
this.currentTabId = tabId this.currentTabId = tabId
await chrome.storage.local.set({ currentTabId: tabId }) await chrome.storage.local.set({ currentTabId: tabId })
} }
async getTabInfo(tabId: number): Promise<{ title: string; url: string }> { async getTabInfo(tabId: number): Promise<{ title: string; url: string }> {
const result = await chrome.runtime.sendMessage({ // use cached tab info if available
const tabMeta = this.tabs.find((t) => t.id === tabId)
if (tabMeta && tabMeta.url && tabMeta.title) {
return { title: tabMeta.title, url: tabMeta.url }
}
// otherwise, pull the latest tab info from the background script
debug('getTabInfo: pulling from background script', tabId)
const result = await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'get_tab_info', action: 'get_tab_info',
payload: { tabId }, payload: { tabId },
}) })
if (tabMeta) {
tabMeta.url = result.url
tabMeta.title = result.title
}
return result return result
} }
@@ -239,6 +275,17 @@ export class TabsController extends EventTarget {
return summaries.join('\n') return summaries.join('\n')
} }
async waitUntilTabLoaded(tabId: number): Promise<void> {
const tab = this.tabs.find((t) => t.id === tabId)
if (!tab) throw new Error(`Tab ID ${tabId} not found in tab list.`)
if (tab.status === 'unloaded') throw new Error(`Tab ID ${tabId} is unloaded.`)
if (tab.status === 'complete') return
debug('waitUntilTabLoaded', tabId)
await waitUntil(() => tab.status === 'complete', 4_000)
}
dispose() { dispose() {
this.dispatchEvent(new Event('dispose')) this.dispatchEvent(new Event('dispose'))
} }
@@ -257,6 +304,9 @@ export type TabAction =
interface TabMeta { interface TabMeta {
id: number id: number
isInitial: boolean isInitial: boolean
url?: string
title?: string
status?: 'loading' | 'unloaded' | 'complete'
} }
const TAB_GROUP_COLORS = [ const TAB_GROUP_COLORS = [
@@ -275,3 +325,33 @@ type TabGroupColor = (typeof TAB_GROUP_COLORS)[number]
function randomColor(): TabGroupColor { function randomColor(): TabGroupColor {
return TAB_GROUP_COLORS[Math.floor(Math.random() * TAB_GROUP_COLORS.length)] return TAB_GROUP_COLORS[Math.floor(Math.random() * TAB_GROUP_COLORS.length)]
} }
/**
* Wait until condition becomes true
* @returns Returns when condition becomes true, throws otherwise
* @param timeoutMS Timeout in milliseconds, default 1 minutes, throws error on timeout
* @param error Error object to reject on timeout. If not provided, will resolve with false
*/
export async function waitUntil(
check: () => boolean | Promise<boolean>,
timeoutMS = 60_000,
error?: string
): Promise<boolean> {
if (await check()) return true
return new Promise((resolve, reject) => {
const start = Date.now()
const poll = async () => {
if (await check()) return resolve(true)
if (Date.now() - start > timeoutMS) {
if (error) {
return reject(new Error(error))
} else {
return resolve(false)
}
}
setTimeout(poll, 100)
}
setTimeout(poll, 100)
})
}
@@ -120,7 +120,6 @@ Exhibit the following reasoning patterns to successfully achieve the <user_reque
Here are examples of good output patterns. Use them as reference but never copy them directly. Here are examples of good output patterns. Use them as reference but never copy them directly.
<evaluation_examples> <evaluation_examples>
- Positive Examples:
"evaluation_previous_goal": "Successfully navigated to the product page and found the target information. Verdict: Success" "evaluation_previous_goal": "Successfully navigated to the product page and found the target information. Verdict: Success"
"evaluation_previous_goal": "Clicked the login button and user authentication form appeared. Verdict: Success" "evaluation_previous_goal": "Clicked the login button and user authentication form appeared. Verdict: Success"
</evaluation_examples> </evaluation_examples>
@@ -131,7 +130,6 @@ Here are examples of good output patterns. Use them as reference but never copy
<next_goal_examples> <next_goal_examples>
"next_goal": "Click on the 'Add to Cart' button to proceed with the purchase flow." "next_goal": "Click on the 'Add to Cart' button to proceed with the purchase flow."
"next_goal": "Extract details from the first item on the page."
</next_goal_examples> </next_goal_examples>
</examples> </examples>
+15 -4
View File
@@ -31,8 +31,11 @@ export function createTabTools(tabsController: TabsController): Record<string, T
}), }),
execute: async (input: unknown) => { execute: async (input: unknown) => {
const { url } = input as { url: string } const { url } = input as { url: string }
const result = await tabsController.openNewTab(url) try {
return result.message return await tabsController.openNewTab(url)
} catch (error) {
return `❌ Failed: ${error instanceof Error ? error.message : String(error)}`
}
}, },
}, },
@@ -44,7 +47,11 @@ export function createTabTools(tabsController: TabsController): Record<string, T
}), }),
execute: async (input: unknown) => { execute: async (input: unknown) => {
const { tab_id } = input as { tab_id: number } const { tab_id } = input as { tab_id: number }
return (await tabsController.switchToTab(tab_id)).message try {
return await tabsController.switchToTab(tab_id)
} catch (error) {
return `❌ Failed: ${error instanceof Error ? error.message : String(error)}`
}
}, },
}, },
@@ -56,7 +63,11 @@ export function createTabTools(tabsController: TabsController): Record<string, T
}), }),
execute: async (input: unknown) => { execute: async (input: unknown) => {
const { tab_id } = input as { tab_id: number } const { tab_id } = input as { tab_id: number }
return (await tabsController.closeTab(tab_id)).message try {
return await tabsController.closeTab(tab_id)
} catch (error) {
return `❌ Failed: ${error instanceof Error ? error.message : String(error)}`
}
}, },
}, },
} }
+1 -1
View File
@@ -75,7 +75,7 @@ export function useAgent(): UseAgentResult {
const execute = useCallback(async (task: string) => { const execute = useCallback(async (task: string) => {
const agent = agentRef.current const agent = agentRef.current
console.log('Fire task:', task, agent) console.log('🚀 [useAgent] start executing task:', task)
if (!agent) throw new Error('Agent not initialized') if (!agent) throw new Error('Agent not initialized')
setCurrentTask(task) setCurrentTask(task)
@@ -1,24 +1,13 @@
import { handlePageControlMessage } from '@/agent/RemotePageController.background' import { handlePageControlMessage } from '@/agent/RemotePageController.background'
import { handleTabControlMessage } from '@/agent/TabsController.background' import { handleTabControlMessage, setupTabChangeEvents } from '@/agent/TabsController.background'
chrome.runtime.onMessage.addListener((message, sender, sendResponse): true | undefined => {
if (message.type === 'TAB_CONTROL') {
return handleTabControlMessage(message, sender, sendResponse)
} else if (message.type === 'PAGE_CONTROL') {
return handlePageControlMessage(message, sender, sendResponse)
} else {
sendResponse({ error: 'Unknown message type' })
return
}
})
// ============================================================================
// Extension Setup
// ============================================================================
export default defineBackground(() => { export default defineBackground(() => {
console.log('[Background] Service Worker started') console.log('[Background] Service Worker started')
// tab change events
setupTabChangeEvents()
// generate user auth token // generate user auth token
chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => { chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => {
@@ -28,21 +17,20 @@ export default defineBackground(() => {
chrome.storage.local.set({ PageAgentExtUserAuthToken: userAuthToken }) chrome.storage.local.set({ PageAgentExtUserAuthToken: userAuthToken })
}) })
// message proxy
chrome.runtime.onMessage.addListener((message, sender, sendResponse): true | undefined => {
if (message.type === 'TAB_CONTROL') {
return handleTabControlMessage(message, sender, sendResponse)
} else if (message.type === 'PAGE_CONTROL') {
return handlePageControlMessage(message, sender, sendResponse)
} else {
sendResponse({ error: 'Unknown message type' })
return
}
})
// setup // setup
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {}) chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
// Tab change events
chrome.tabs.onCreated.addListener((tab) => {
chrome.runtime.sendMessage({ type: 'TAB_CHANGE', action: 'created', payload: { tab } })
})
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
chrome.runtime.sendMessage({
type: 'TAB_CHANGE',
action: 'removed',
payload: { tabId, removeInfo },
})
})
}) })
@@ -6,7 +6,7 @@ const DEBUG_PREFIX = '[Content]'
export default defineContentScript({ export default defineContentScript({
matches: ['<all_urls>'], matches: ['<all_urls>'],
runAt: 'document_idle', runAt: 'document_end',
main() { main() {
console.debug(`${DEBUG_PREFIX} Loaded on ${window.location.href}`) console.debug(`${DEBUG_PREFIX} Loaded on ${window.location.href}`)
@@ -1,4 +1,4 @@
import { Send, Settings, Square } from 'lucide-react' import { History, Send, Settings, Square } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -8,20 +8,47 @@ import {
InputGroupButton, InputGroupButton,
InputGroupTextarea, InputGroupTextarea,
} from '@/components/ui/input-group' } from '@/components/ui/input-group'
import { saveSession } from '@/lib/db'
import { useAgent } from '../../agent/useAgent' import { useAgent } from '../../agent/useAgent'
import { ConfigPanel } from './components/ConfigPanel' import { ConfigPanel } from './components/ConfigPanel'
import { HistoryDetail } from './components/HistoryDetail'
import { HistoryList } from './components/HistoryList'
import { ActivityCard, EventCard } from './components/cards' import { ActivityCard, EventCard } from './components/cards'
import { EmptyState, Logo, StatusDot } from './components/misc' import { EmptyState, Logo, StatusDot } from './components/misc'
type View =
| { name: 'chat' }
| { name: 'config' }
| { name: 'history' }
| { name: 'history-detail'; sessionId: string }
export default function App() { export default function App() {
const [showConfig, setShowConfig] = useState(false) const [view, setView] = useState<View>({ name: 'chat' })
const [inputValue, setInputValue] = useState('') const [inputValue, setInputValue] = useState('')
const historyRef = useRef<HTMLDivElement>(null) const historyRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const { status, history, activity, currentTask, config, execute, stop, configure } = useAgent() const { status, history, activity, currentTask, config, execute, stop, configure } = useAgent()
// Persist session when task finishes
const prevStatusRef = useRef(status)
useEffect(() => {
const prev = prevStatusRef.current
prevStatusRef.current = status
if (
prev === 'running' &&
(status === 'completed' || status === 'error') &&
history.length > 0 &&
currentTask
) {
saveSession({ task: currentTask, history, status }).catch((err) =>
console.error('[SidePanel] Failed to save session:', err)
)
}
}, [status, history, currentTask])
// Auto-scroll to bottom on new events // Auto-scroll to bottom on new events
useEffect(() => { useEffect(() => {
if (historyRef.current) { if (historyRef.current) {
@@ -56,19 +83,36 @@ export default function App() {
} }
} }
if (showConfig) { // --- View routing ---
if (view.name === 'config') {
return ( return (
<ConfigPanel <ConfigPanel
config={config} config={config}
onSave={async (newConfig) => { onSave={async (newConfig) => {
await configure(newConfig) await configure(newConfig)
setShowConfig(false) setView({ name: 'chat' })
}} }}
onClose={() => setShowConfig(false)} onClose={() => setView({ name: 'chat' })}
/> />
) )
} }
if (view.name === 'history') {
return (
<HistoryList
onSelect={(id) => setView({ name: 'history-detail', sessionId: id })}
onBack={() => setView({ name: 'chat' })}
/>
)
}
if (view.name === 'history-detail') {
return <HistoryDetail sessionId={view.sessionId} onBack={() => setView({ name: 'history' })} />
}
// --- Chat view ---
const isRunning = status === 'running' const isRunning = status === 'running'
const showEmptyState = !currentTask && history.length === 0 && !isRunning const showEmptyState = !currentTask && history.length === 0 && !isRunning
@@ -85,7 +129,15 @@ export default function App() {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
onClick={() => setShowConfig(true)} onClick={() => setView({ name: 'history' })}
className="cursor-pointer"
>
<History className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setView({ name: 'config' })}
className="cursor-pointer" className="cursor-pointer"
> >
<Settings className="size-3.5" /> <Settings className="size-3.5" />
@@ -0,0 +1,51 @@
import { ArrowLeft } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { type SessionRecord, getSession } from '@/lib/db'
import { EventCard } from './cards'
export function HistoryDetail({ sessionId, onBack }: { sessionId: string; onBack: () => void }) {
const [session, setSession] = useState<SessionRecord | null>(null)
useEffect(() => {
getSession(sessionId).then((s) => setSession(s ?? null))
}, [sessionId])
if (!session) {
return (
<div className="flex items-center justify-center h-screen text-xs text-muted-foreground">
Loading...
</div>
)
}
return (
<div className="flex flex-col h-screen bg-background">
{/* Header */}
<header className="flex items-center gap-2 border-b px-3 py-2">
<Button variant="ghost" size="icon-sm" onClick={onBack} className="cursor-pointer">
<ArrowLeft className="size-3.5" />
</Button>
<span className="text-sm font-medium truncate">History</span>
</header>
{/* Task */}
<div className="border-b px-3 py-2 bg-muted/30">
<div className="text-[10px] text-muted-foreground uppercase tracking-wide">Task</div>
<div className="text-xs font-medium" title={session.task}>
{session.task}
</div>
</div>
{/* Events (read-only) */}
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{session.history.map((event, index) => (
// eslint-disable-next-line react-x/no-array-index-key
<EventCard key={index} event={event} />
))}
</div>
</div>
)
}
@@ -0,0 +1,119 @@
import { ArrowLeft, CheckCircle, Trash2, XCircle } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { type SessionRecord, clearSessions, deleteSession, listSessions } from '@/lib/db'
function timeAgo(ts: number): string {
const seconds = Math.floor((Date.now() - ts) / 1000)
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
export function HistoryList({
onSelect,
onBack,
}: {
onSelect: (id: string) => void
onBack: () => void
}) {
const [sessions, setSessions] = useState<SessionRecord[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setSessions(await listSessions())
setLoading(false)
}, [])
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load()
}, [load])
const handleDelete = async (e: React.MouseEvent, id: string) => {
e.stopPropagation()
await deleteSession(id)
setSessions((prev) => prev.filter((s) => s.id !== id))
}
return (
<div className="flex flex-col h-screen bg-background">
{/* Header */}
<header className="flex items-center gap-2 border-b px-3 py-2">
<Button variant="ghost" size="icon-sm" onClick={onBack} className="cursor-pointer">
<ArrowLeft className="size-3.5" />
</Button>
<span className="text-sm font-medium flex-1">History</span>
{sessions.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
await clearSessions()
setSessions([])
}}
className="text-[10px] text-muted-foreground hover:text-destructive cursor-pointer h-6 px-2"
>
<Trash2 className="size-3 mr-1" />
Clear All
</Button>
)}
</header>
{/* List */}
<div className="flex-1 overflow-y-auto">
{loading && (
<div className="flex items-center justify-center h-32 text-xs text-muted-foreground">
Loading...
</div>
)}
{!loading && sessions.length === 0 && (
<div className="flex items-center justify-center h-32 text-xs text-muted-foreground">
No history yet
</div>
)}
{sessions.map((session) => (
<div
key={session.id}
role="button"
tabIndex={0}
onClick={() => onSelect(session.id)}
onKeyDown={(e) => e.key === 'Enter' && onSelect(session.id)}
className="w-full text-left px-3 py-2.5 border-b hover:bg-muted/50 transition-colors cursor-pointer flex items-start gap-2 group"
>
{/* Status icon */}
{session.status === 'completed' ? (
<CheckCircle className="size-3.5 text-green-500 shrink-0 mt-0.5" />
) : (
<XCircle className="size-3.5 text-destructive shrink-0 mt-0.5" />
)}
{/* Content */}
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{session.task}</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
{timeAgo(session.createdAt)} · {session.history.length} steps
</p>
</div>
{/* Delete */}
<button
type="button"
onClick={(e) => handleDelete(e, session.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:text-destructive cursor-pointer shrink-0"
>
<Trash2 className="size-3" />
</button>
</div>
))}
</div>
</div>
)
}
+70
View File
@@ -0,0 +1,70 @@
import type { HistoricalEvent } from '@page-agent/core'
import { type DBSchema, type IDBPDatabase, openDB } from 'idb'
const DB_NAME = 'page-agent-ext'
const DB_VERSION = 1
export interface SessionRecord {
id: string
task: string
history: HistoricalEvent[]
status: 'completed' | 'error'
createdAt: number
}
interface PageAgentDB extends DBSchema {
sessions: {
key: string
value: SessionRecord
indexes: { 'by-created': number }
}
}
let dbPromise: Promise<IDBPDatabase<PageAgentDB>> | null = null
function getDB() {
if (!dbPromise) {
dbPromise = openDB<PageAgentDB>(DB_NAME, DB_VERSION, {
upgrade(db) {
const store = db.createObjectStore('sessions', { keyPath: 'id' })
store.createIndex('by-created', 'createdAt')
},
})
}
return dbPromise
}
export async function saveSession(
session: Omit<SessionRecord, 'id' | 'createdAt'>
): Promise<SessionRecord> {
const db = await getDB()
const record: SessionRecord = {
...session,
id: crypto.randomUUID(),
createdAt: Date.now(),
}
await db.put('sessions', record)
return record
}
/** List sessions, newest first */
export async function listSessions(): Promise<SessionRecord[]> {
const db = await getDB()
const all = await db.getAllFromIndex('sessions', 'by-created')
return all.reverse()
}
export async function getSession(id: string): Promise<SessionRecord | undefined> {
const db = await getDB()
return db.get('sessions', id)
}
export async function deleteSession(id: string): Promise<void> {
const db = await getDB()
await db.delete('sessions', id)
}
export async function clearSessions(): Promise<void> {
const db = await getDB()
await db.clear('sessions')
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/llms", "name": "@page-agent/llms",
"version": "1.1.2", "version": "1.2.0",
"type": "module", "type": "module",
"main": "./dist/lib/page-agent-llms.js", "main": "./dist/lib/page-agent-llms.js",
"module": "./dist/lib/page-agent-llms.js", "module": "./dist/lib/page-agent-llms.js",
+3
View File
@@ -49,6 +49,9 @@ export class LLM extends EventTarget {
): Promise<InvokeResult> { ): Promise<InvokeResult> {
return await withRetry( return await withRetry(
async () => { async () => {
// in case user aborted before invoking
if (abortSignal.aborted) throw new Error('AbortError')
const result = await this.client.invoke(messages, tools, abortSignal, options) const result = await this.client.invoke(messages, tools, abortSignal, options)
return result return result
+5 -5
View File
@@ -1,7 +1,7 @@
{ {
"name": "page-agent", "name": "page-agent",
"private": false, "private": false,
"version": "1.1.2", "version": "1.2.0",
"type": "module", "type": "module",
"main": "./dist/esm/page-agent.js", "main": "./dist/esm/page-agent.js",
"module": "./dist/esm/page-agent.js", "module": "./dist/esm/page-agent.js",
@@ -44,10 +44,10 @@
"postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\"" "postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
}, },
"dependencies": { "dependencies": {
"@page-agent/core": "1.1.2", "@page-agent/core": "1.2.0",
"@page-agent/llms": "1.1.2", "@page-agent/llms": "1.2.0",
"@page-agent/page-controller": "1.1.2", "@page-agent/page-controller": "1.2.0",
"@page-agent/ui": "1.1.2", "@page-agent/ui": "1.2.0",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"zod": "^4.3.5" "zod": "^4.3.5"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/page-controller", "name": "@page-agent/page-controller",
"version": "1.1.2", "version": "1.2.0",
"type": "module", "type": "module",
"main": "./dist/lib/page-controller.js", "main": "./dist/lib/page-controller.js",
"module": "./dist/lib/page-controller.js", "module": "./dist/lib/page-controller.js",
@@ -248,7 +248,7 @@ export class PageController extends EventTarget {
if (element instanceof HTMLAnchorElement && element.target === '_blank') { if (element instanceof HTMLAnchorElement && element.target === '_blank') {
return { return {
success: true, success: true,
message: `✅ Clicked element (${elemText ?? index}). ⚠️ Link opens in a new tab. You are not capable of reading new tabs.`, message: `✅ Clicked element (${elemText ?? index}). ⚠️ Link opened in a new tab.`,
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/ui", "name": "@page-agent/ui",
"version": "1.1.2", "version": "1.2.0",
"type": "module", "type": "module",
"main": "./dist/lib/page-agent-ui.js", "main": "./dist/lib/page-agent-ui.js",
"module": "./dist/lib/page-agent-ui.js", "module": "./dist/lib/page-agent-ui.js",
-1
View File
@@ -1,3 +1,2 @@
export { Panel, type PanelConfig } from './panel/Panel' export { Panel, type PanelConfig } from './panel/Panel'
export type { AgentActivity, PanelAgentAdapter } from './panel/types'
export { I18n, type SupportedLanguage, type TranslationKey } from './i18n' export { I18n, type SupportedLanguage, type TranslationKey } from './i18n'
+11 -15
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/website", "name": "@page-agent/website",
"private": true, "private": true,
"version": "1.1.2", "version": "1.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
@@ -10,30 +10,26 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.1",
"@vitejs/plugin-react-swc": "^4.1.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0",
"wouter": "^3.9.0"
},
"dependencies": {
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"motion": "^12.30.0", "motion": "^12.34.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"rough-notation": "^0.5.1", "rough-notation": "^0.5.1",
"simple-icons": "^16.7.0", "simple-icons": "^16.8.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0",
"wouter": "^3.9.0"
} }
} }
+2 -2
View File
@@ -1,8 +1,8 @@
// Demo build (auto-init with demo LLM, for quick testing) // Demo build (auto-init with demo LLM, for quick testing)
export const CDN_DEMO_URL = export const CDN_DEMO_URL =
'https://cdn.jsdelivr.net/npm/page-agent@1.1.2/dist/iife/page-agent.demo.js' 'https://cdn.jsdelivr.net/npm/page-agent@1.2.0/dist/iife/page-agent.demo.js'
export const CDN_DEMO_CN_URL = export const CDN_DEMO_CN_URL =
'https://registry.npmmirror.com/page-agent/1.1.2/files/dist/iife/page-agent.demo.js' 'https://registry.npmmirror.com/page-agent/1.2.0/files/dist/iife/page-agent.demo.js'
// Demo LLM for website testing // Demo LLM for website testing
export const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM' export const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
@@ -376,13 +376,6 @@ const result = await agent.execute('Fill in the form with test data')`}
? '执行任务并返回结果。包含 success、data 和 history 字段。' ? '执行任务并返回结果。包含 success、data 和 history 字段。'
: 'Execute a task and return result. Contains success, data, and history fields.', : 'Execute a task and return result. Contains success, data, and history fields.',
}, },
{
name: 'pushObservation(content: string)',
type: 'void',
description: isZh
? '向历史流推送一个观察事件,会在下一步时被 LLM 看到'
: 'Push an observation to history stream, will be seen by LLM in next step',
},
{ {
name: 'dispose(reason?: string)', name: 'dispose(reason?: string)',
type: 'void', type: 'void',