Compare commits

...

25 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
Simon 67f6a56a87 chore(version): bump version to 1.1.2 2026-02-09 17:50:34 +08:00
Simon 6f0ff1fd33 refactor: zod tree-shaking; better error handling in agent steps 2026-02-09 17:49:10 +08:00
Simon 6fdb8d03f2 docs: trouble shooting 2026-02-09 16:32:58 +08:00
Simon d00a8dcc21 feat: improve prompt; update model list 2026-02-09 15:50:20 +08:00
Simon 851e9ef158 docs: model list 2026-02-06 18:49:06 +08:00
Simon 8ea8ce9acd fix(llms): gpt-5-mini temperature 2026-02-06 17:19:06 +08:00
Simon 9bd4a47d35 feat(ext): add includeInitialTab option; change main world API 2026-02-04 19:22:06 +08:00
47 changed files with 1974 additions and 1062 deletions
+3 -3
View File
@@ -40,7 +40,7 @@
```html ```html
<script <script
src="https://registry.npmmirror.com/page-agent/1.1.0/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.0/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.0/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.0/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.0/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.0/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
+390 -379
View File
File diff suppressed because it is too large Load Diff
+12 -10
View File
@@ -1,7 +1,7 @@
{ {
"name": "root", "name": "root",
"private": true, "private": true,
"version": "1.1.0", "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,27 +38,30 @@
"@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",
"vite-bundle-analyzer": "^1.3.6"
}, },
"overrides": { "overrides": {
"typescript": "^5.9.3" "typescript": "^5.9.3"
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/core", "name": "@page-agent/core",
"private": false, "private": false,
"version": "1.1.0", "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.0", "@page-agent/llms": "1.2.0",
"@page-agent/page-controller": "1.1.0" "@page-agent/page-controller": "1.2.0"
} }
} }
+146 -124
View File
@@ -3,15 +3,15 @@
* 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 zod from 'zod' import * as zod from 'zod'
import { type PageAgentConfig } from './config' import { type PageAgentConfig } from './config'
import { DEFAULT_MAX_STEPS } from './config/constants' import { DEFAULT_MAX_STEPS } from './config/constants'
import SYSTEM_PROMPT from './prompts/system_prompt.md?raw' import SYSTEM_PROMPT from './prompts/system_prompt.md?raw'
import { tools } from './tools' import { tools } from './tools'
import { import type {
AgentActivity, AgentActivity,
AgentReflection, AgentReflection,
AgentStatus, AgentStatus,
@@ -21,17 +21,25 @@ import {
MacroToolInput, MacroToolInput,
MacroToolResult, MacroToolResult,
} from './types' } from './types'
import { normalizeResponse, trimLines, uid, waitFor } from './utils' import { assert, normalizeResponse, uid, waitFor } from './utils'
import { assert } from './utils/assert'
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)
@@ -50,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)
@@ -66,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()
@@ -166,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> {
@@ -203,30 +212,25 @@ 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: '',
}
try { let step = 0
let step = 0
while (true) { while (true) {
await this.#generateObservations(step) try {
console.group(`step: ${step}`)
await onBeforeStep?.(this, step) await onBeforeStep?.(this, step)
console.group(`step: ${step}`) // observe
// abort console.log(chalk.blue.bold('👀 Observing...'))
if (this.#abortController.signal.aborted) throw new Error('AbortError')
// Thinking this.#states.browserState = await this.pageController.getBrowserState()
console.log(chalk.blue('Thinking...')) await this.#handleObservations(step)
this.#emitActivity({ type: 'thinking' })
// invoke LLM // assemble prompts
const messages = [ const messages = [
{ role: 'system' as const, content: this.#getSystemPrompt() }, { role: 'system' as const, content: this.#getSystemPrompt() },
@@ -235,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
@@ -270,22 +279,12 @@ export class PageAgentCore extends EventTarget {
// //
console.log(chalk.green('Step finished:'), actionName)
console.groupEnd()
await onAfterStep?.(this, this.history) await onAfterStep?.(this, this.history)
step++ console.groupEnd()
if (step > this.config.maxSteps) {
this.#onDone(false) // finish task if done
const result: ExecutionResult = {
success: false,
data: 'Step count exceeded maximum limit',
history: this.history,
}
await onAfterTask?.(this, result)
return result
}
if (actionName === 'done') { if (actionName === 'done') {
const success = action.input?.success ?? false const success = action.input?.success ?? false
const text = action.input?.text || 'no text provided' const text = action.input?.text || 'no text provided'
@@ -299,19 +298,32 @@ export class PageAgentCore extends EventTarget {
await onAfterTask?.(this, result) await onAfterTask?.(this, result)
return result return result
} }
} catch (error: unknown) {
console.groupEnd() // to prevent nested groups
console.error('Task failed', error)
const errorMessage = String(error)
this.#emitActivity({ type: 'error', message: errorMessage })
this.#onDone(false)
const result: ExecutionResult = {
success: false,
data: errorMessage,
history: this.history,
}
await onAfterTask?.(this, result)
return result
} }
} catch (error: unknown) {
console.error('Task failed', error) step++
const errorMessage = String(error) if (step > this.config.maxSteps) {
this.#emitActivity({ type: 'error', message: errorMessage }) this.#onDone(false)
this.#onDone(false) const result: ExecutionResult = {
const result: ExecutionResult = { success: false,
success: false, data: 'Step count exceeded maximum limit',
data: errorMessage, history: this.history,
history: this.history, }
await onAfterTask?.(this, result)
return result
} }
await onAfterTask?.(this, result)
return result
} }
} }
@@ -344,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]
@@ -371,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)
@@ -395,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
@@ -427,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) {
@@ -465,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.`
@@ -491,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>
@@ -506,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
@@ -548,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) {
@@ -560,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()
+4 -6
View File
@@ -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,17 +138,16 @@ 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>
<output> <output>
You must ALWAYS respond with a valid JSON in this exact format:
{ {
"evaluation_previous_goal": "Concise one-sentence analysis of your last action. Clearly state success, failure, or uncertain.", "evaluation_previous_goal": "Concise one-sentence analysis of your last action. Clearly state success, failure, or uncertain.",
"memory": "1-3 concise sentences of specific memory of this step and overall progress. You should put here everything that will help you track progress in future steps. Like counting pages visited, items found, etc.", "memory": "1-3 concise sentences of specific memory of this step and overall progress. You should put here everything that will help you track progress in future steps. Like counting pages visited, items found, etc.",
"next_goal": "State the next immediate goal and action to achieve it, in one clear sentence." "next_goal": "State the next immediate goal and action to achieve it, in one clear sentence.",
"action":{"action": {// action-specific parameter}} "action":{
"Action name": {// Action parameters}
}
} }
</output> </output>
+11 -20
View File
@@ -2,7 +2,7 @@
* Internal tools for PageAgent. * Internal tools for PageAgent.
* @note Adapted from browser-use * @note Adapted from browser-use
*/ */
import zod, { type z } from 'zod' import * as zod from 'zod'
import type { PageAgentCore } from '../PageAgentCore' import type { PageAgentCore } from '../PageAgentCore'
import { waitFor } from '../utils' import { waitFor } from '../utils'
@@ -13,7 +13,7 @@ import { waitFor } from '../utils'
export interface PageAgentTool<TParams = any> { export interface PageAgentTool<TParams = any> {
// name: string // name: string
description: string description: string
inputSchema: z.ZodType<TParams> inputSchema: zod.ZodType<TParams>
execute: (this: PageAgentCore, args: TParams) => Promise<string> execute: (this: PageAgentCore, args: TParams) => Promise<string>
} }
@@ -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
-17
View File
@@ -1,17 +0,0 @@
import chalk from 'chalk'
/**
* Simple assertion function that throws an error if the condition is falsy
* @param condition - The condition to assert
* @param message - Optional error message
* @throws Error if condition is falsy
*/
export function assert(condition: unknown, message?: string, silent?: boolean): asserts condition {
if (!condition) {
const errorMessage = message ?? 'Assertion failed'
if (!silent) console.error(chalk.red(`❌ assert: ${errorMessage}`))
throw new Error(errorMessage)
}
}
+18 -31
View File
@@ -1,27 +1,7 @@
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))
} }
@@ -37,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)
@@ -85,3 +56,19 @@ export function uid() {
ids.push(id) ids.push(id)
return id return id
} }
/**
* Simple assertion function that throws an error if the condition is falsy
* @param condition - The condition to assert
* @param message - Optional error message
* @throws Error if condition is falsy
*/
export function assert(condition: unknown, message?: string, silent?: boolean): asserts condition {
if (!condition) {
const errorMessage = message ?? 'Assertion failed'
if (!silent) console.error(chalk.red(`❌ assert: ${errorMessage}`))
throw new Error(errorMessage)
}
}
+79 -71
View File
@@ -33,7 +33,6 @@ import type {
AgentStatus, AgentStatus,
ExecutionResult, ExecutionResult,
HistoricalEvent, HistoricalEvent,
LLMConfig,
} from '@page-agent/core' } from '@page-agent/core'
// Wait for extension injection (up to 1 second) // Wait for extension injection (up to 1 second)
@@ -48,18 +47,13 @@ async function waitForExtension(timeout = 1000): Promise<boolean> {
// Usage // Usage
if (await waitForExtension()) { if (await waitForExtension()) {
const result = await window.PAGE_AGENT_EXT!.execute( const result = await window.PAGE_AGENT_EXT!.execute('Click the login button', {
'Click the login button', baseURL: 'https://api.openai.com/v1',
{ apiKey: 'your-api-key',
baseURL: 'https://api.openai.com/v1', model: 'gpt-5.2',
apiKey: 'your-api-key', onStatusChange: (status) => console.log('Status:', status),
model: 'gpt-5.2', onActivity: (activity) => console.log('Activity:', activity),
}, })
{
onStatusChange: (status) => console.log('Status:', status),
onActivity: (activity) => console.log('Activity:', activity),
}
)
console.log('Result:', result) console.log('Result:', result)
} }
``` ```
@@ -76,7 +70,7 @@ Extension version string (e.g., `"1.0.0"`). This is exposed separately to allow
Main API namespace object containing: Main API namespace object containing:
#### `PAGE_AGENT_EXT.execute(task, llmConfig, hooks?)` #### `PAGE_AGENT_EXT.execute(task, config)`
Execute an agent task. Execute an agent task.
@@ -85,8 +79,7 @@ Execute an agent task.
| Name | Type | Required | Description | | Name | Type | Required | Description |
|------|------|----------|-------------| |------|------|----------|-------------|
| `task` | `string` | Yes | Task description | | `task` | `string` | Yes | Task description |
| `llmConfig` | `LLMConfig` | Yes | LLM configuration | | `config` | `ExecuteConfig` | Yes | Execution configuration (LLM settings, options, and event callbacks) |
| `hooks` | `ExecuteHooks` | No | Event callbacks |
**Returns:** `Promise<ExecutionResult>` **Returns:** `Promise<ExecutionResult>`
@@ -104,21 +97,26 @@ import type {
AgentStatus, AgentStatus,
ExecutionResult, ExecutionResult,
HistoricalEvent, HistoricalEvent,
LLMConfig,
} from '@page-agent/core' } from '@page-agent/core'
export interface ExecuteHooks { export interface ExecuteConfig {
baseURL: string
apiKey: string
model: string
/**
* Whether to include the initial tab (that holds this main world script) in the task.
* @default true
*/
includeInitialTab?: boolean
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void onDispose?: () => void
} }
export type Execute = ( export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
task: string,
llmConfig: LLMConfig,
hooks?: ExecuteHooks
) => Promise<ExecutionResult>
``` ```
### AgentStatus ### AgentStatus
@@ -164,16 +162,6 @@ type HistoricalEvent =
| { type: 'error'; message: string; rawResponse?: unknown } | { type: 'error'; message: string; rawResponse?: unknown }
``` ```
### LLMConfig
```typescript
interface LLMConfig {
baseURL: string // e.g. 'https://api.openai.com/v1'
apiKey: string
model: string // e.g. 'gpt-5.2'
}
```
### ExecutionResult ### ExecutionResult
```typescript ```typescript
@@ -205,44 +193,63 @@ if (result.success) {
} }
``` ```
### With Event Hooks ### Exclude Initial Tab
By default, the agent includes the initial tab (where the script runs) in the task. Set `includeInitialTab: false` to exclude it:
```typescript ```typescript
await window.PAGE_AGENT_EXT!.execute( const result = await window.PAGE_AGENT_EXT!.execute(
'Navigate to the settings page', 'Open a new tab and search for page-agent on GitHub',
llmConfig,
{ {
onStatusChange: (status) => { baseURL: 'https://api.openai.com/v1',
updateUI({ agentStatus: status }) apiKey: process.env.OPENAI_API_KEY!,
}, model: 'gpt-5.2',
onActivity: (activity) => { includeInitialTab: false, // Agent will open new tabs only
switch (activity.type) {
case 'thinking':
showSpinner('Agent is thinking...')
break
case 'executing':
showSpinner(`Executing: ${activity.tool}`)
break
case 'executed':
log(`${activity.tool} completed in ${activity.duration}ms`)
break
case 'error':
showError(activity.message)
break
}
},
onHistoryUpdate: (history) => {
renderHistory(history)
},
} }
) )
``` ```
### With Event Callbacks
```typescript
await window.PAGE_AGENT_EXT!.execute('Navigate to the settings page', {
baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-5.2',
onStatusChange: (status) => {
updateUI({ agentStatus: status })
},
onActivity: (activity) => {
switch (activity.type) {
case 'thinking':
showSpinner('Agent is thinking...')
break
case 'executing':
showSpinner(`Executing: ${activity.tool}`)
break
case 'executed':
log(`${activity.tool} completed in ${activity.duration}ms`)
break
case 'error':
showError(activity.message)
break
}
},
onHistoryUpdate: (history) => {
renderHistory(history)
},
})
```
### Stop Execution ### Stop Execution
```typescript ```typescript
// Start a task // Start a task
window.PAGE_AGENT_EXT!.execute('Scroll through all pages', llmConfig) window.PAGE_AGENT_EXT!.execute('Scroll through all pages', {
baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-5.2',
})
// Later, stop it // Later, stop it
window.PAGE_AGENT_EXT!.dispose() window.PAGE_AGENT_EXT!.dispose()
@@ -258,24 +265,25 @@ import type {
AgentStatus, AgentStatus,
ExecutionResult, ExecutionResult,
HistoricalEvent, HistoricalEvent,
LLMConfig,
} from '@page-agent/core' } from '@page-agent/core'
interface ExecuteConfig {
baseURL: string
apiKey: string
model: string
includeInitialTab?: boolean
onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
}
declare global { declare global {
interface Window { interface Window {
PAGE_AGENT_EXT_VERSION?: string PAGE_AGENT_EXT_VERSION?: string
PAGE_AGENT_EXT?: { PAGE_AGENT_EXT?: {
version: string version: string
execute: ( execute: (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
task: string,
llmConfig: LLMConfig,
hooks?: {
onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
}
) => Promise<ExecutionResult>
dispose: () => void dispose: () => void
} }
} }
+81 -73
View File
@@ -33,7 +33,6 @@ import type {
AgentStatus, AgentStatus,
ExecutionResult, ExecutionResult,
HistoricalEvent, HistoricalEvent,
LLMConfig,
} from '@page-agent/core' } from '@page-agent/core'
// 等待插件注入(最多 1 秒) // 等待插件注入(最多 1 秒)
@@ -48,18 +47,13 @@ async function waitForExtension(timeout = 1000): Promise<boolean> {
// 使用 // 使用
if (await waitForExtension()) { if (await waitForExtension()) {
const result = await window.PAGE_AGENT_EXT!.execute( const result = await window.PAGE_AGENT_EXT!.execute('点击登录按钮', {
'点击登录按钮', baseURL: 'https://api.openai.com/v1',
{ apiKey: 'your-api-key',
baseURL: 'https://api.openai.com/v1', model: 'gpt-5.2',
apiKey: 'your-api-key', onStatusChange: (status) => console.log('状态:', status),
model: 'gpt-5.2', onActivity: (activity) => console.log('活动:', activity),
}, })
{
onStatusChange: (status) => console.log('状态:', status),
onActivity: (activity) => console.log('活动:', activity),
}
)
console.log('结果:', result) console.log('结果:', result)
} }
``` ```
@@ -76,7 +70,7 @@ if (await waitForExtension()) {
主 API 命名空间对象,包含: 主 API 命名空间对象,包含:
#### `PAGE_AGENT_EXT.execute(task, llmConfig, hooks?)` #### `PAGE_AGENT_EXT.execute(task, config)`
执行 Agent 任务。 执行 Agent 任务。
@@ -85,8 +79,7 @@ if (await waitForExtension()) {
| 名称 | 类型 | 必填 | 说明 | | 名称 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| `task` | `string` | 是 | 任务描述 | | `task` | `string` | 是 | 任务描述 |
| `llmConfig` | `LLMConfig` | 是 | LLM 配置 | | `config` | `ExecuteConfig` | 是 | 执行配置(LLM 设置、选项和事件回调) |
| `hooks` | `ExecuteHooks` | 否 | 事件回调 |
**返回:** `Promise<ExecutionResult>` **返回:** `Promise<ExecutionResult>`
@@ -104,21 +97,26 @@ import type {
AgentStatus, AgentStatus,
ExecutionResult, ExecutionResult,
HistoricalEvent, HistoricalEvent,
LLMConfig,
} from '@page-agent/core' } from '@page-agent/core'
export interface ExecuteHooks { export interface ExecuteConfig {
baseURL: string
apiKey: string
model: string
/**
* 是否将初始标签页(运行此脚本的页面)包含在任务中。
* @default true
*/
includeInitialTab?: boolean
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void onDispose?: () => void
} }
export type Execute = ( export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
task: string,
llmConfig: LLMConfig,
hooks?: ExecuteHooks
) => Promise<ExecutionResult>
``` ```
### AgentStatus ### AgentStatus
@@ -164,16 +162,6 @@ type HistoricalEvent =
| { type: 'error'; message: string; rawResponse?: unknown } | { type: 'error'; message: string; rawResponse?: unknown }
``` ```
### LLMConfig
```typescript
interface LLMConfig {
baseURL: string // 例如 'https://api.openai.com/v1'
apiKey: string
model: string // 例如 'gpt-5.2'
}
```
### ExecutionResult ### ExecutionResult
```typescript ```typescript
@@ -205,44 +193,63 @@ if (result.success) {
} }
``` ```
### 排除初始标签页
默认情况下,Agent 会将初始标签页(运行脚本的页面)包含在任务中。设置 `includeInitialTab: false` 可以排除它:
```typescript
const result = await window.PAGE_AGENT_EXT!.execute(
'打开新标签页并在 GitHub 上搜索 page-agent',
{
baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-5.2',
includeInitialTab: false, // Agent 只会打开新标签页
}
)
```
### 使用事件回调 ### 使用事件回调
```typescript ```typescript
await window.PAGE_AGENT_EXT!.execute( await window.PAGE_AGENT_EXT!.execute('导航到设置页面', {
'导航到设置页面', baseURL: 'https://api.openai.com/v1',
llmConfig, apiKey: process.env.OPENAI_API_KEY!,
{ model: 'gpt-5.2',
onStatusChange: (status) => { onStatusChange: (status) => {
updateUI({ agentStatus: status }) updateUI({ agentStatus: status })
}, },
onActivity: (activity) => { onActivity: (activity) => {
switch (activity.type) { switch (activity.type) {
case 'thinking': case 'thinking':
showSpinner('Agent 正在思考...') showSpinner('Agent 正在思考...')
break break
case 'executing': case 'executing':
showSpinner(`正在执行: ${activity.tool}`) showSpinner(`正在执行: ${activity.tool}`)
break break
case 'executed': case 'executed':
log(`${activity.tool} 完成,耗时 ${activity.duration}ms`) log(`${activity.tool} 完成,耗时 ${activity.duration}ms`)
break break
case 'error': case 'error':
showError(activity.message) showError(activity.message)
break break
} }
}, },
onHistoryUpdate: (history) => { onHistoryUpdate: (history) => {
renderHistory(history) renderHistory(history)
}, },
} })
)
``` ```
### 停止执行 ### 停止执行
```typescript ```typescript
// 启动任务 // 启动任务
window.PAGE_AGENT_EXT!.execute('滚动浏览所有页面', llmConfig) window.PAGE_AGENT_EXT!.execute('滚动浏览所有页面', {
baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-5.2',
})
// 稍后停止 // 稍后停止
window.PAGE_AGENT_EXT!.dispose() window.PAGE_AGENT_EXT!.dispose()
@@ -258,24 +265,25 @@ import type {
AgentStatus, AgentStatus,
ExecutionResult, ExecutionResult,
HistoricalEvent, HistoricalEvent,
LLMConfig,
} from '@page-agent/core' } from '@page-agent/core'
interface ExecuteConfig {
baseURL: string
apiKey: string
model: string
includeInitialTab?: boolean
onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
}
declare global { declare global {
interface Window { interface Window {
PAGE_AGENT_EXT_VERSION?: string PAGE_AGENT_EXT_VERSION?: string
PAGE_AGENT_EXT?: { PAGE_AGENT_EXT?: {
version: string version: string
execute: ( execute: (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
task: string,
llmConfig: LLMConfig,
hooks?: {
onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
}
) => Promise<ExecutionResult>
dispose: () => void dispose: () => void
} }
} }
+22 -21
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/ext", "name": "@page-agent/ext",
"private": true, "private": true,
"version": "0.1.2", "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.0",
"@page-agent/llms": "1.1.0",
"@page-agent/page-controller": "1.1.0",
"@page-agent/ui": "1.1.0",
"@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"
} }
} }
+10 -2
View File
@@ -17,7 +17,7 @@ function detectLanguage(): 'en-US' | 'zh-CN' {
* - can be used from a side panel or a content script * - can be used from a side panel or a content script
*/ */
export class MultiPageAgent extends PageAgentCore { export class MultiPageAgent extends PageAgentCore {
constructor(config: Omit<PageAgentConfig, 'pageController'>) { constructor(config: Omit<PageAgentConfig, 'pageController'> & { includeInitialTab?: boolean }) {
// multi page controller // multi page controller
const tabsController = new TabsController() const tabsController = new TabsController()
const pageController = new RemotePageController(tabsController) const pageController = new RemotePageController(tabsController)
@@ -31,6 +31,9 @@ export class MultiPageAgent extends PageAgentCore {
`Default working language: **${targetLanguage}**` `Default working language: **${targetLanguage}**`
) )
// include initial tab for controlling
const includeInitialTab = config.includeInitialTab ?? true
/** /**
* When the agent is in side-panel and user closed the side-panel. * When the agent is in side-panel and user closed the side-panel.
* There is no chance for isAgentRunning to be set false. * There is no chance for isAgentRunning to be set false.
@@ -47,7 +50,7 @@ export class MultiPageAgent extends PageAgentCore {
customSystemPrompt: systemPrompt, customSystemPrompt: systemPrompt,
onBeforeTask: async (agent) => { onBeforeTask: async (agent) => {
await tabsController.init(agent.task) await tabsController.init(agent.task, includeInitialTab)
heartBeatInterval = window.setInterval(() => { heartBeatInterval = window.setInterval(() => {
chrome.storage.local.set({ chrome.storage.local.set({
@@ -71,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)
})
})
}
+135 -49
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)
@@ -12,7 +29,9 @@ export class TabsController extends EventTarget {
private task: string = '' private task: string = ''
private windowId: number | null = null private windowId: number | null = null
async init(task: string) { 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,23 +39,36 @@ 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',
}) })
this.initialTabId = result.tabId this.initialTabId = result.tabId
this.currentTabId = result.tabId
this.tabs.push({
id: result.tabId,
isInitial: true,
})
if (!this.initialTabId) { if (!this.initialTabId) {
throw new Error('Failed to get initial tab ID') throw new Error('Failed to get initial tab ID')
} }
if (includeInitialTab) {
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({
id: result.tabId,
isInitial: true,
url: info.url,
title: info.title,
status: info.status,
})
}
await this.updateCurrentTabId(this.currentTabId) await this.updateCurrentTabId(this.currentTabId)
const tabChangeHandler = (message: any): void => { const tabChangeHandler = (message: any): void => {
@@ -68,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
}
} }
} }
@@ -78,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 },
@@ -102,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 },
@@ -116,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: {
@@ -129,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 },
@@ -196,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
} }
@@ -230,9 +268,24 @@ export class TabsController extends EventTarget {
`| ${tab.id} | ${url} | ${title} | ${this.currentTabId === tab.id ? '✅' : ''} |` `| ${tab.id} | ${url} | ${title} | ${this.currentTabId === tab.id ? '✅' : ''} |`
) )
} }
if (!this.tabs.length) {
summaries.push('\nNo tabs available. Open a tab if needed.')
}
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'))
} }
@@ -251,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 = [
@@ -269,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,17 +130,16 @@ 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>
<output> <output>
You must ALWAYS respond with a valid JSON in this exact format:
{ {
"evaluation_previous_goal": "Concise one-sentence analysis of your last action. Clearly state success, failure, or uncertain.", "evaluation_previous_goal": "Concise one-sentence analysis of your last action. Clearly state success, failure, or uncertain.",
"memory": "1-3 concise sentences of specific memory of this step and overall progress. You should put here everything that will help you track progress in future steps. Like counting pages visited, items found, etc.", "memory": "1-3 concise sentences of specific memory of this step and overall progress. You should put here everything that will help you track progress in future steps. Like counting pages visited, items found, etc.",
"next_goal": "State the next immediate goal and action to achieve it, in one clear sentence." "next_goal": "State the next immediate goal and action to achieve it, in one clear sentence.",
"action":{"action_name": {// action-specific parameter}} "action":{
"Action name": {// Action parameters}
}
} }
</output> </output>
+16 -5
View File
@@ -6,7 +6,7 @@
* - switch_to_tab: Switch to an existing tab * - switch_to_tab: Switch to an existing tab
* - close_tab: Close a tab (optionally switch to another) * - close_tab: Close a tab (optionally switch to another)
*/ */
import zod from 'zod' import * as zod from 'zod'
import type { TabsController } from './TabsController' import type { TabsController } from './TabsController'
@@ -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}`)
@@ -69,11 +69,11 @@ async function exposeAgentToPage() {
} }
try { try {
const { task, llmConfig } = payload const { task, config } = payload
// create when used // create when used
multiPageAgent = new MultiPageAgent(llmConfig) multiPageAgent = new MultiPageAgent(config)
// events // events
@@ -1,19 +1,24 @@
import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core' import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core'
import type { LLMConfig } from '@page-agent/llms'
export interface ExecuteHooks { export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
export interface ExecuteConfig {
baseURL: string
apiKey: string
model: string
/**
* Whether to include the initial tab (that holds this main world script) in the task.
* @default true
*/
includeInitialTab?: boolean
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void onDispose?: () => void
} }
export type Execute = (
task: string,
llmConfig: LLMConfig,
hooks?: ExecuteHooks
) => Promise<ExecutionResult>
export default defineUnlistedScript(() => { export default defineUnlistedScript(() => {
let _lastId = 0 let _lastId = 0
function getId() { function getId() {
@@ -21,13 +26,13 @@ export default defineUnlistedScript(() => {
return _lastId return _lastId
} }
const execute: Execute = async (task, llmConfig, hooks) => { const execute: Execute = async (task, config) => {
if (typeof task !== 'string') throw new Error('Task must be a string') if (typeof task !== 'string') throw new Error('Task must be a string')
if (task.trim().length === 0) throw new Error('Task cannot be empty') if (task.trim().length === 0) throw new Error('Task cannot be empty')
if (!llmConfig) throw new Error('LLM config is required') if (!config) throw new Error('Config is required')
if (!llmConfig.baseURL) throw new Error('LLM config must have a baseURL') if (!config.baseURL) throw new Error('Config must have a baseURL')
if (!llmConfig.apiKey) throw new Error('LLM config must have an apiKey') if (!config.apiKey) throw new Error('Config must have an apiKey')
if (!llmConfig.model) throw new Error('LLM config must have a model') if (!config.model) throw new Error('Config must have a model')
const id = getId() const id = getId()
@@ -40,30 +45,31 @@ export default defineUnlistedScript(() => {
// events // events
if (data.action === 'status_change_event' && hooks?.onStatusChange) { if (data.action === 'status_change_event' && config.onStatusChange) {
hooks.onStatusChange(data.payload) config.onStatusChange(data.payload)
return return
} }
if (data.action === 'activity_event' && hooks?.onActivity) { if (data.action === 'activity_event' && config.onActivity) {
hooks.onActivity(data.payload) config.onActivity(data.payload)
return return
} }
if (data.action === 'history_change_event' && hooks?.onHistoryUpdate) { if (data.action === 'history_change_event' && config.onHistoryUpdate) {
hooks.onHistoryUpdate(data.payload) config.onHistoryUpdate(data.payload)
return return
} }
if (data.action === 'dispose_event' && hooks?.onDispose) { if (data.action === 'dispose_event' && config.onDispose) {
hooks.onDispose() config.onDispose()
window.removeEventListener('message', handleMessage)
return return
} }
// result
if (data.action !== 'execute_result') return if (data.action !== 'execute_result') return
// execute_result
window.removeEventListener('message', handleMessage) window.removeEventListener('message', handleMessage)
if (data.error) { if (data.error) {
@@ -73,6 +79,7 @@ export default defineUnlistedScript(() => {
} }
} }
// @note will be removed on dispose or result
window.addEventListener('message', handleMessage) window.addEventListener('message', handleMessage)
}) })
@@ -81,7 +88,15 @@ export default defineUnlistedScript(() => {
channel: 'PAGE_AGENT_EXT_REQUEST', channel: 'PAGE_AGENT_EXT_REQUEST',
id, id,
action: 'execute', action: 'execute',
payload: { task, llmConfig }, payload: {
task,
config: {
baseURL: config.baseURL,
apiKey: config.apiKey,
model: config.model,
includeInitialTab: config.includeInitialTab,
},
},
}, },
'*' '*'
) )
@@ -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.0", "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 -1
View File
@@ -2,7 +2,7 @@
* Utility functions for LLM integration * Utility functions for LLM integration
*/ */
import chalk from 'chalk' import chalk from 'chalk'
import { z } from 'zod' import * as z from 'zod'
import type { Tool } from './types' import type { Tool } from './types'
@@ -74,6 +74,10 @@ export function modelPatch(body: Record<string, any>) {
} else if (modelName.startsWith('gpt-51')) { } else if (modelName.startsWith('gpt-51')) {
debug('Applying GPT-51 patch: disable reasoning') debug('Applying GPT-51 patch: disable reasoning')
body.reasoning_effort = 'none' body.reasoning_effort = 'none'
} else if (modelName.startsWith('gpt-5-mini')) {
debug('Applying GPT-5-mini patch: set reasoning effort to low, temperature to 1')
body.reasoning_effort = 'low'
body.temperature = 1
} else if (modelName.startsWith('gpt-5')) { } else if (modelName.startsWith('gpt-5')) {
debug('Applying GPT-5 patch: set reasoning effort to low') debug('Applying GPT-5 patch: set reasoning effort to low')
body.reasoning_effort = 'low' body.reasoning_effort = 'low'
+6 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "page-agent", "name": "page-agent",
"private": false, "private": false,
"version": "1.1.0", "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,11 +44,11 @@
"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.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", "chalk": "^5.6.2",
"zod": "^4.3.5", "zod": "^4.3.5"
"@page-agent/llms": "1.1.0",
"@page-agent/page-controller": "1.1.0",
"@page-agent/core": "1.1.0",
"@page-agent/ui": "1.1.0"
} }
} }
+6 -2
View File
@@ -3,6 +3,7 @@ import { config as dotenvConfig } from 'dotenv'
import { dirname, resolve } from 'path' import { dirname, resolve } from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
// import { analyzer } from 'vite-bundle-analyzer'
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js' import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -14,8 +15,11 @@ dotenvConfig({ path: resolve(__dirname, '../../.env') })
// - alias all local packages so that they can be build in // - alias all local packages so that they can be build in
// - no external // - no external
// - no d.ts. dts does not work with monorepo aliasing // - no d.ts. dts does not work with monorepo aliasing
export default defineConfig(({ mode }) => ({ export default defineConfig(() => ({
plugins: [cssInjectedByJsPlugin({ relativeCSSInjection: true })], plugins: [
cssInjectedByJsPlugin({ relativeCSSInjection: true }),
// analyzer()
],
publicDir: false, publicDir: false,
esbuild: { esbuild: {
keepNames: true, keepNames: true,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/page-controller", "name": "@page-agent/page-controller",
"version": "1.1.0", "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.0", "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.0", "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.0/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.0/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'
@@ -30,6 +30,10 @@ export default function DocsLayout({ children }: DocsLayoutProps) {
{ title: isZh ? '概览' : 'Overview', path: '/introduction/overview' }, { title: isZh ? '概览' : 'Overview', path: '/introduction/overview' },
{ title: isZh ? '快速开始' : 'Quick Start', path: '/introduction/quick-start' }, { title: isZh ? '快速开始' : 'Quick Start', path: '/introduction/quick-start' },
{ title: isZh ? '使用限制' : 'Limitations', path: '/introduction/limitations' }, { title: isZh ? '使用限制' : 'Limitations', path: '/introduction/limitations' },
{
title: isZh ? '故障排查' : 'Troubleshooting',
path: '/introduction/troubleshooting',
},
{ {
title: '🚧 ' + (isZh ? '最佳实践' : 'Best Practices'), title: '🚧 ' + (isZh ? '最佳实践' : 'Best Practices'),
path: '/integration/best-practices', path: '/integration/best-practices',
@@ -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',
@@ -152,27 +152,24 @@ localStorage.setItem('PageAgentExtUserAuthToken', '<your-token-from-extension>')
</div> </div>
</section> </section>
<h3 className="text-xl font-semibold my-3"> <h3 className="text-xl font-semibold my-3">PAGE_AGENT_EXT.execute(task, config)</h3>
PAGE_AGENT_EXT.execute(task, llmConfig, hooks?)
</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4"> <p className="text-gray-600 dark:text-gray-300 mb-4">
{isZh {isZh
? '使用 LLM 配置执行任务。返回一个 Promise,在任务完成时 resolve。可选的 hooks 参数用于监听任务执行过程中的事件。' ? '使用配置执行任务。返回一个 Promise,在任务完成时 resolve。config 参数包含 LLM 设置、选项和事件回调。'
: 'Execute a task with LLM configuration. Returns a Promise that resolves when the task completes. Optional hooks parameter for listening to events during task execution.'} : 'Execute a task with configuration. Returns a Promise that resolves when the task completes. Config includes LLM settings, options, and event callbacks.'}
</p> </p>
<CodeEditor <CodeEditor
code={ code={
isZh isZh
? `// 使用 LLM 配置和 hooks 执行任务 ? `// 使用配置执行任务
const result = await window.PAGE_AGENT_EXT.execute( const result = await window.PAGE_AGENT_EXT.execute(
'在 GitHub 上搜索 "page-agent" 并打开第一个结果', '在 GitHub 上搜索 "page-agent" 并打开第一个结果',
{ {
baseURL: 'https://api.openai.com/v1', baseURL: 'https://api.openai.com/v1',
apiKey: 'your-api-key', apiKey: 'your-api-key',
model: 'gpt-5-2' model: 'gpt-5-2',
}, // includeInitialTab: false, // 设为 false 排除初始标签页
{
onStatusChange: status => console.log('状态变化:', status), onStatusChange: status => console.log('状态变化:', status),
onActivity: activity => console.log('活动:', activity), onActivity: activity => console.log('活动:', activity),
onHistoryUpdate: history => console.log('历史更新:', history), onHistoryUpdate: history => console.log('历史更新:', history),
@@ -181,15 +178,14 @@ const result = await window.PAGE_AGENT_EXT.execute(
) )
console.log(result) // 任务执行结果` console.log(result) // 任务执行结果`
: `// Execute a task with LLM configuration and hooks : `// Execute a task with configuration
const result = await window.PAGE_AGENT_EXT.execute( const result = await window.PAGE_AGENT_EXT.execute(
'Search for "page-agent" on GitHub and open the first result', 'Search for "page-agent" on GitHub and open the first result',
{ {
baseURL: 'https://api.openai.com/v1', baseURL: 'https://api.openai.com/v1',
apiKey: 'your-api-key', apiKey: 'your-api-key',
model: 'gpt-5-2' model: 'gpt-5-2',
}, // includeInitialTab: false, // Set to false to exclude initial tab
{
onStatusChange: status => console.log('Status change:', status), onStatusChange: status => console.log('Status change:', status),
onActivity: activity => console.log('Activity:', activity), onActivity: activity => console.log('Activity:', activity),
onHistoryUpdate: history => console.log('History update:', history), onHistoryUpdate: history => console.log('History update:', history),
@@ -221,41 +217,26 @@ window.PAGE_AGENT_EXT.dispose()`
/> />
</section> </section>
{/* LLM Config */} {/* ExecuteConfig */}
<section> <section>
<h2 className="text-2xl font-bold mb-4">{isZh ? 'LLM 配置' : 'LLM Configuration'}</h2> <h2 className="text-2xl font-bold mb-4">{isZh ? '执行配置' : 'Execute Configuration'}</h2>
<CodeEditor
code={
isZh
? `interface LLMConfig {
baseURL: string // LLM API 端点
apiKey: string // API 密钥
model: string // 模型名称
}`
: `interface LLMConfig {
baseURL: string // LLM API endpoint
apiKey: string // API key
model: string // Model name
}`
}
language="typescript"
/>
</section>
{/* Execute Hooks */}
<section>
<h2 className="text-2xl font-bold mb-4">{isZh ? 'Execute Hooks' : 'Execute Hooks'}</h2>
<p className="text-gray-600 dark:text-gray-300 mb-4"> <p className="text-gray-600 dark:text-gray-300 mb-4">
{isZh {isZh
? '通过 hooks 参数,你可以监听任务执行过程中的各种事件,实现实时更新 UI、日志记录等功能。' ? 'config 参数包含 LLM 设置、选项和事件回调,用于控制任务执行行为。'
: 'With hooks parameter, you can listen to various events during task execution for real-time UI updates, logging, and more.'} : 'The config parameter includes LLM settings, options, and event callbacks to control task execution behavior.'}
</p> </p>
<CodeEditor <CodeEditor
code={ code={
isZh isZh
? `interface ExecuteHooks { ? `interface ExecuteConfig {
baseURL: string // LLM API 端点
apiKey: string // API 密钥
model: string // 模型名称
// 是否将初始标签页包含在任务中,默认 true
includeInitialTab?: boolean
// Agent 状态变化时调用(idle, running, error, completed 等) // Agent 状态变化时调用(idle, running, error, completed 等)
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
@@ -268,7 +249,14 @@ window.PAGE_AGENT_EXT.dispose()`
// Agent 被停止时调用 // Agent 被停止时调用
onDispose?: () => void onDispose?: () => void
}` }`
: `interface ExecuteHooks { : `interface ExecuteConfig {
baseURL: string // LLM API endpoint
apiKey: string // API key
model: string // Model name
// Whether to include the initial tab in the task, default true
includeInitialTab?: boolean
// Called when agent status changes (idle, running, error, completed, etc.) // Called when agent status changes (idle, running, error, completed, etc.)
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
@@ -25,7 +25,7 @@ export default function CustomTools() {
</p> </p>
<CodeEditor <CodeEditor
code={`import zod from 'zod' code={`import * as zod from 'zod'
import { PageAgent, tool } from 'page-agent' import { PageAgent, tool } from 'page-agent'
// override internal tool // override internal tool
@@ -1,34 +1,49 @@
import { Fragment } from 'react'
import CodeEditor from '@/components/CodeEditor' import CodeEditor from '@/components/CodeEditor'
import { useLanguage } from '@/i18n/context' import { useLanguage } from '@/i18n/context'
// Recommended models: lightweight with excellent tool call capabilities const BASELINE = new Set([
const MODELS = { 'gpt-5.1',
recommended: [ 'claude-haiku-4.5',
'gpt-4.1-mini', 'gemini-3-flash',
'claude-haiku-4.5', 'deepseek-3.2',
'gemini-3-flash', 'qwen3-coder-next',
'deepseek-3.2', ])
'gpt-5.2',
'qwen-3-max', // Models grouped by brand, newest first
], const MODEL_GROUPS: Record<string, string[]> = {
verified: [ OpenAI: ['gpt-5.2', 'gpt-5.1', 'gpt-5', 'gpt-5-mini', 'gpt-4.1', 'gpt-4.1-mini'],
'qwen-3-plus', Google: ['gemini-3-pro', 'gemini-3-flash', 'gemini-2.5'],
'gpt-4.1', Qwen: ['qwen3-coder-next', 'qwen-3-max', 'qwen-3-plus', 'qwen3:14b (ollama)'],
'gpt-5', DeepSeek: ['deepseek-3.2'],
'gpt-5-mini', Anthropic: [
'grok-4', 'claude-opus-4.6',
'grok-code-fast',
'claude-sonnet-3.5',
'claude-sonnet-4.5',
'claude-opus-4.5', 'claude-opus-4.5',
'gemini-2.5', 'claude-sonnet-4.5',
'gemini-3-pro', 'claude-haiku-4.5',
'claude-sonnet-3.5',
], ],
xAI: ['grok-4.1-fast', 'grok-4', 'grok-code-fast'],
MoonshotAI: ['kimi-k2.5'],
'Z.AI': ['glm-4.7'],
} }
const ModelBadge = ({ model, baseline }: { model: string; baseline?: boolean }) => (
<div
className={`px-3 py-1.5 rounded-md text-xs font-medium font-mono transition-colors ${
baseline
? 'bg-emerald-500 text-white shadow-sm'
: 'bg-white/80 dark:bg-gray-800/80 text-gray-800 dark:text-gray-200 border border-gray-300 dark:border-gray-600'
}`}
>
{model}
{baseline && <span className="ml-1"></span>}
</div>
)
export default function Models() { export default function Models() {
const { isZh } = useLanguage() const { isZh } = useLanguage()
const allModels = [...MODELS.recommended, ...MODELS.verified]
return ( return (
<div className="max-w-4xl"> <div className="max-w-4xl">
@@ -45,28 +60,23 @@ export default function Models() {
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4"> <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
{isZh {isZh
? '推荐使用 ToolCall 能力强的轻量级模型。' ? '推荐使用 ToolCall 能力强的轻量级模型。'
: 'Recommended: Lightweight models with strong ToolCall capabilities.'} : 'Recommended: Fast, lightweight models with strong ToolCall capabilities.'}
</p> </p>
<div className="bg-linear-to-br from-emerald-50 to-cyan-50 dark:from-emerald-950/30 dark:to-cyan-950/30 rounded-xl p-6 border border-emerald-200/50 dark:border-emerald-800/50"> <div className="bg-linear-to-br from-emerald-50 to-cyan-50 dark:from-emerald-950/30 dark:to-cyan-950/30 rounded-xl p-6 border border-emerald-200/50 dark:border-emerald-800/50">
<div className="flex flex-wrap gap-2"> <div className="grid grid-cols-[5rem_1fr] gap-x-3 gap-y-3 items-start">
{allModels.map((model) => { {Object.entries(MODEL_GROUPS).map(([brand, models]) => (
const isRecommended = MODELS.recommended.includes(model) <Fragment key={brand}>
return ( <span className="text-xs font-semibold text-gray-500 dark:text-gray-400 pt-2">
<div {brand}
key={model} </span>
className={`px-3 py-1.5 rounded-md text-sm font-medium font-mono transition-colors ${ <div className="flex flex-wrap gap-2">
isRecommended {models.map((model) => (
? 'bg-emerald-500 text-white shadow-sm' <ModelBadge key={model} model={model} baseline={BASELINE.has(model)} />
: 'bg-white/80 dark:bg-gray-800/80 text-gray-800 dark:text-gray-200 border border-gray-300 dark:border-gray-600' ))}
}`}
>
{model}
{isRecommended && <span className="ml-1"></span>}
</div> </div>
) </Fragment>
})} ))}
</div> </div>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-5"> baseline models</p>
</div> </div>
</section> </section>
@@ -20,6 +20,7 @@ import Limitations from './introduction/limitations/page'
// Introduction // Introduction
import Overview from './introduction/overview/page' import Overview from './introduction/overview/page'
import QuickStart from './introduction/quick-start/page' import QuickStart from './introduction/quick-start/page'
import Troubleshooting from './introduction/troubleshooting/page'
function DocsPage({ children }: { children: React.ReactNode }) { function DocsPage({ children }: { children: React.ReactNode }) {
return ( return (
@@ -51,6 +52,11 @@ export default function DocsRouter() {
<Limitations /> <Limitations />
</DocsPage> </DocsPage>
</Route> </Route>
<Route path="/introduction/troubleshooting">
<DocsPage>
<Troubleshooting />
</DocsPage>
</Route>
{/* Features */} {/* Features */}
<Route path="/features/custom-tools"> <Route path="/features/custom-tools">
@@ -0,0 +1,424 @@
import { useEffect, useRef, useState } from 'react'
import { Link } from 'wouter'
import CodeEditor from '@/components/CodeEditor'
import { useLanguage } from '@/i18n/context'
// ---------------------------------------------------------------------------
// Data: each section is a typed object for easy extension
// ---------------------------------------------------------------------------
interface TroubleshootingSection {
id: string
title: { en: string; zh: string }
symptom: { en: string; zh: string }
color: 'red' | 'amber' | 'orange' | 'violet'
content: (isZh: boolean) => React.ReactNode
}
const SECTIONS: TroubleshootingSection[] = [
{
id: 'format-errors',
title: { en: 'Model Response Format Errors', zh: '模型返回格式错误' },
symptom: {
en: 'The model returns malformed tool calls, plain text, or unexpected JSON instead of structured actions.',
zh: '模型返回了格式错误的 tool call、纯文本或非预期的 JSON,而非结构化的操作指令。',
},
color: 'amber',
content: FormatErrorsContent,
},
{
id: 'low-success-rate',
title: { en: 'Low Task Success Rate', zh: '任务成功率低' },
symptom: {
en: 'The agent appears to understand the task but frequently fails to complete it, or produces incorrect results.',
zh: 'Agent 似乎理解了任务,但频繁执行失败或产生不正确的结果。',
},
color: 'amber',
content: LowSuccessRateContent,
},
{
id: 'wrong-element',
title: { en: "Can't Hit Target Elements", zh: '无法点击目标元素' },
symptom: {
en: 'The agent repeatedly retries but keeps interacting with the wrong element, or fails to locate the correct one.',
zh: 'Agent 反复重试,但始终点击在错误的元素上,或无法定位到正确的目标元素。',
},
color: 'amber',
content: WrongElementContent,
},
{
id: 'api-errors',
title: { en: 'API Request Errors', zh: 'API 请求错误' },
symptom: {
en: 'HTTP 400 Bad Request or similar errors when calling the LLM API.',
zh: '调用 LLM API 时出现 HTTP 400 Bad Request 或类似的参数错误。',
},
color: 'amber',
content: ApiErrorsContent,
},
]
// ---------------------------------------------------------------------------
// Section content components
// ---------------------------------------------------------------------------
function FormatErrorsContent(isZh: boolean) {
return (
<ol className="list-decimal pl-5 space-y-4 text-gray-700 dark:text-gray-300">
<li>
<strong>{isZh ? '确认模型是否支持' : 'Verify model compatibility'}</strong>
<p className="mt-1">
{isZh
? '并非所有模型都能正确处理 page-agent 的 tool 定义。请查看'
: 'Not all models can handle page-agent tool definitions correctly. Check the '}
<Link
href="/features/models"
className="text-blue-600 dark:text-blue-400 underline underline-offset-2"
>
{isZh ? '已测试模型列表' : 'tested models list'}
</Link>
{isZh ? '。' : '.'}
</p>
</li>
<li>
<strong>
{isZh ? '检查代理/网关的参数转发' : 'Check proxy/gateway parameter forwarding'}
</strong>
<p className="mt-1">
{isZh
? '如果使用了 API 代理或网关,请确保请求中的 '
: 'If using an API proxy or gateway, make sure the '}
<code>tools</code>
{isZh
? ' 字段被完整、无修改地转发给模型供应商。部分代理可能会剥离或修改此字段。'
: ' parameter is forwarded to the model provider intact. Some proxies may strip or alter this field.'}
</p>
</li>
<li>
<strong>{isZh ? '寻求社区帮助' : 'Get community help'}</strong>
<p className="mt-1">
{isZh ? (
<>
{' '}
<a
href="https://github.com/alibaba/page-agent/discussions"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 underline underline-offset-2"
>
GitHub Discussions
</a>{' '}
</>
) : (
<>
If the above steps don't help, join the{' '}
<a
href="https://github.com/alibaba/page-agent/discussions"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 underline underline-offset-2"
>
GitHub Discussions
</a>{' '}
with your model name and error details.
</>
)}
</p>
</li>
</ol>
)
}
function LowSuccessRateContent(isZh: boolean) {
return (
<>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 italic">
{isZh
? ''
: 'Follow this diagnostic funnel from simplest to most advanced:'}
</p>
<ol className="list-decimal pl-5 space-y-4 text-gray-700 dark:text-gray-300">
<li>
<strong>{isZh ? '' : 'Start with a simple instruction'}</strong>
<p className="mt-1">
{isZh
? '"点击登录按钮" Agent '
: 'Give a concrete, single-step instruction (e.g. "click the login button"). If even simple actions fail, the issue is likely not model capability.'}
</p>
</li>
<li>
<strong>{isZh ? '' : 'Try the strongest model available'}</strong>
<p className="mt-1">
{isZh
? ''
: "Switch to the most capable model you have access to, to isolate whether it's a model intelligence issue."}
</p>
</li>
<li>
<strong>{isZh ? '优化指令质量' : 'Improve instruction quality'}</strong>
<p className="mt-1">
{isZh
? '给出尽可能具体的指令。对于复杂任务,建议使用另一个 LLM 来预先拆分和细化用户的需求,然后逐步执行。'
: "Be as specific as possible. For complex tasks, consider using another LLM to decompose and refine the user's request before execution."}
</p>
</li>
<li>
<strong>{isZh ? '' : 'Provide sufficient context'}</strong>
<p className="mt-1">
{isZh
? ' instructions Agent '
: 'Use the instructions config to inject website descriptions, key terminology, and background context to help the agent understand the page.'}
</p>
</li>
<li>
<strong>{isZh ? ' HTML ' : 'Check HTML sanitization output'}</strong>
<p className="mt-1">
{isZh
? '使 HTML'
: 'Inspect the sanitized HTML in dev tools to confirm that key information, text, and interactive elements are preserved correctly.'}
</p>
</li>
</ol>
</>
)
}
function WrongElementContent(isZh: boolean) {
return (
<ol className="list-decimal pl-5 space-y-4 text-gray-700 dark:text-gray-300">
<li>
<strong>{isZh ? '' : 'Understand the reality'}</strong>
<p className="mt-1">
{isZh
? ' HTML accessibility DOM '
: 'Not all websites provide proper semantic HTML and accessibility labels. For such sites, DOM sanitization may not produce good enough results.'}
</p>
</li>
<li>
<strong>{isZh ? '' : 'Check target element type'}</strong>
<p className="mt-1">
{isZh
? 'Canvas'
: 'Verify if the target is an image, Canvas, or requires complex interactions (drag-and-drop, coordinate-based clicking). These are beyond current capabilities.'}
</p>
</li>
<li>
<strong>{isZh ? ' HTML' : 'Inspect sanitized HTML'}</strong>
<p className="mt-1">
{isZh
? ''
: 'Look for missing key information or unnumbered interactive elements in the sanitized output.'}
</p>
</li>
<li>
<strong>{isZh ? ' accessibility ' : 'Inject accessibility improvements'}</strong>
<p className="mt-1">
{isZh
? ' aria-label accessibility DOM '
: 'Inject scripts to add aria-labels, semantic attributes, and other a11y improvements to enhance DOM sanitization quality.'}
</p>
</li>
<li>
<strong>{isZh ? ' Tool' : 'Build a custom Tool'}</strong>
<p className="mt-1">
{isZh ? (
<>
对于特定的、持续难以操作的元素,考虑开发{' '}
<Link
href="/features/custom-tools"
className="text-blue-600 dark:text-blue-400 underline underline-offset-2"
>
自定义 Tool
</Link>{' '}
来直接操作这些元素。
</>
) : (
<>
For consistently difficult elements, consider building a{' '}
<Link
href="/features/custom-tools"
className="text-blue-600 dark:text-blue-400 underline underline-offset-2"
>
custom Tool
</Link>{' '}
to interact with them directly.
</>
)}
</p>
</li>
</ol>
)
}
function ApiErrorsContent(isZh: boolean) {
return (
<div className="space-y-4 text-gray-700 dark:text-gray-300">
<p>
{isZh
? ' LLM 使 OpenAI '
: 'Some LLM providers use parameter formats that are not fully compatible with the OpenAI spec, causing request validation failures.'}
</p>
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4">
<p className="font-medium mb-2">
{isZh ? '使 customFetch' : 'Solution: use customFetch'}
</p>
<p className="text-sm mb-3">
{isZh
? ' customFetch '
: 'Use the customFetch config to intercept requests and adapt parameters before sending them to the target provider.'}
</p>
<CodeEditor
code={`const agent = new PageAgent({
// ...
customFetch: async (url, init) => {
const body = JSON.parse(init.body)
// Adapt parameters for your provider
delete body.stream_options
return fetch(url, { ...init, body: JSON.stringify(body) })
},
})`}
/>
</div>
<p className="text-sm">
{isZh ? ' ' : 'See '}
<Link
href="/advanced/page-agent-core"
className="text-blue-600 dark:text-blue-400 underline underline-offset-2"
>
PageAgentCore API
</Link>
{isZh ? ' customFetch ' : ' for full customFetch documentation.'}
</p>
</div>
)
}
// ---------------------------------------------------------------------------
// Color mapping for symptom callouts
// ---------------------------------------------------------------------------
const SYMPTOM_COLORS = {
red: 'border-red-400 bg-red-50 dark:bg-red-900/15 text-red-800 dark:text-red-200',
amber: 'border-amber-400 bg-amber-50 dark:bg-amber-900/15 text-amber-800 dark:text-amber-200',
orange:
'border-orange-400 bg-orange-50 dark:bg-orange-900/15 text-orange-800 dark:text-orange-200',
violet:
'border-violet-400 bg-violet-50 dark:bg-violet-900/15 text-violet-800 dark:text-violet-200',
} as const
// ---------------------------------------------------------------------------
// Right-side TOC with IntersectionObserver
// ---------------------------------------------------------------------------
function useActiveSection(ids: string[]) {
const [activeId, setActiveId] = useState(ids[0])
const observerRef = useRef<IntersectionObserver | null>(null)
useEffect(() => {
observerRef.current?.disconnect()
const visibleEntries = new Map<string, number>()
observerRef.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
visibleEntries.set(entry.target.id, entry.intersectionRatio)
} else {
visibleEntries.delete(entry.target.id)
}
}
// Pick the first visible section in document order
const firstVisible = ids.find((id) => visibleEntries.has(id))
if (firstVisible) setActiveId(firstVisible)
},
{ rootMargin: '-80px 0px -60% 0px', threshold: [0, 0.25] }
)
for (const id of ids) {
const el = document.getElementById(id)
if (el) observerRef.current.observe(el)
}
return () => observerRef.current?.disconnect()
}, [ids])
return activeId
}
// ---------------------------------------------------------------------------
// Page component
// ---------------------------------------------------------------------------
export default function TroubleshootingPage() {
const { isZh } = useLanguage()
const sectionIds = SECTIONS.map((s) => s.id)
const activeId = useActiveSection(sectionIds)
return (
<div className="max-w-5xl mx-auto">
{/* Header */}
<div className="mb-10">
<h1 className="text-4xl font-bold mb-4 text-gray-900 dark:text-white">Troubleshooting</h1>
</div>
{/* Two-column: content + TOC */}
<div className="flex gap-8">
{/* Main content */}
<div className="flex-1 min-w-0 space-y-12">
{SECTIONS.map((section) => (
<section key={section.id} id={section.id} className="scroll-mt-24">
<h2 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">
{isZh ? section.title.zh : section.title.en}
</h2>
{/* Symptom callout */}
<div
className={`border-l-4 px-4 py-3 rounded-r-lg mb-6 ${SYMPTOM_COLORS[section.color]}`}
>
<span className="text-xs font-semibold uppercase tracking-wider opacity-70">
{isZh ? '' : 'Symptom'}
</span>
<p className="mt-1 text-sm">{isZh ? section.symptom.zh : section.symptom.en}</p>
</div>
{/* Diagnostic steps */}
<div className="prose-sm">{section.content(isZh)}</div>
</section>
))}
</div>
{/* Right TOC */}
<aside className="hidden lg:block w-48 shrink-0">
<div className="sticky top-24">
<h4 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
{isZh ? '' : 'On this page'}
</h4>
<nav className="space-y-1">
{SECTIONS.map((section) => (
<button
key={section.id}
type="button"
onClick={() =>
document
.getElementById(section.id)
?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
className={`block text-sm py-1 text-left transition-colors ${
activeId === section.id
? 'text-blue-600 dark:text-blue-400 font-medium'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
{isZh ? section.title.zh : section.title.en}
</button>
))}
</nav>
</div>
</aside>
</div>
</div>
)
}