Compare commits

...

46 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
Simon 71ca554108 feat(ext): use PAGE_AGENT_EXT namespace; add viber instructions 2026-02-03 19:09:37 +08:00
Simon 648a0c1bda feat(ext): expose ext meta data to main world 2026-02-03 16:39:24 +08:00
Simon 1a0422f99d Merge pull request #135 from alibaba/dependabot/npm_and_yarn/development-dependencies-ad640cb911
chore(deps-dev): bump eslint-plugin-react-refresh from 0.4.26 to 0.5.0 in the development-dependencies group across 1 directory
2026-02-03 14:20:04 +08:00
dependabot[bot] 2f72c677a0 chore(deps-dev): bump eslint-plugin-react-refresh
Bumps the development-dependencies group with 1 update in the / directory: [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh).


Updates `eslint-plugin-react-refresh` from 0.4.26 to 0.5.0
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.4.26...v0.5.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 06:00:00 +00:00
Simon 18019d390a Merge pull request #132 from alibaba/dependabot/npm_and_yarn/production-dependencies-8604a495b2
chore(deps): bump the production-dependencies group with 2 updates
2026-02-03 13:48:12 +08:00
Simon bb3605b3df chore: clean up dep 2026-02-03 13:39:47 +08:00
Simon 2b3bb8ab9e chore(ext): rm React.FormEvent 2026-02-03 13:18:03 +08:00
dependabot[bot] 7a09bc559d 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.29.2 to 12.30.0
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.29.2...v12.30.0)

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

---
updated-dependencies:
- dependency-name: motion
  dependency-version: 12.30.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: simple-icons
  dependency-version: 16.7.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-03 01:15:58 +00:00
Simon a834d37f28 fix(ext): inject versions 2026-02-02 21:11:23 +08:00
Simon 0c3be817c9 style(ext): attribution; dark mode 2026-02-02 21:07:31 +08:00
Simon b8999fdb7c style(ext): improve config panel 2026-02-02 19:51:48 +08:00
Simon 9ba95e96b6 feat(ext): detect user lang 2026-02-02 19:29:30 +08:00
Simon 4459026297 feat: set default lang to english; fix ext lang setting 2026-02-02 19:17:15 +08:00
Simon 98d3016395 style(ext): remove raw section from ResultCard 2026-02-02 19:16:07 +08:00
Simon 15ea488719 fix(ext): save config to storage 2026-02-02 18:48:16 +08:00
Simon 5b2a11c0b2 chore(version): bump version to 1.1.0 2026-02-02 17:28:40 +08:00
Simon c5c6340eb6 chore: ext version 2026-02-02 17:27:58 +08:00
Simon 74aea50875 feat: rename include_attributes to includeAttributes 2026-02-02 17:24:08 +08:00
Simon f86bb03745 Merge branch 'main' of https://github.com/alibaba/page-agent 2026-02-02 16:53:45 +08:00
Simon f6b4447ef7 docs: tips about Ollama 2026-02-02 16:51:51 +08:00
Simon d8ec4cf1ab feat: better action key in prompt example 2026-02-02 13:39:04 +08:00
56 changed files with 3090 additions and 1662 deletions
+10
View File
@@ -126,6 +126,16 @@ If your lame AI assistant does not support [AGENTS.md](https://agents.md/). Add
LLM_BASE_URL=https://api.your-llm-provider.com/v1
```
- **Ollama example** (tested on 0.15 + qwen3:14b, RTX3090 24GB):
```env
LLM_BASE_URL="http://localhost:11434/v1"
LLM_API_KEY="NA"
LLM_MODEL_NAME="qwen3:14b"
```
> ⚠️ Add `*` to `OLLAMA_ORIGINS` (403). Models < 10B unlikely strong enough. Requires tool_call support. Set context length > 15k (default 4k will NOT work): `$env:OLLAMA_CONTEXT_LENGTH=64000; ollama serve`
- Restart the dev server to load new env vars
- If not provided, the demo will the free testing proxy by default
+3 -3
View File
@@ -40,7 +40,7 @@
```html
<script
src="https://registry.npmmirror.com/page-agent/1.0.1/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"
></script>
```
@@ -50,8 +50,8 @@
| Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.0.1/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.0.1/files/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.2.0/files/dist/iife/page-agent.demo.js |
### NPM 安装
+3 -3
View File
@@ -40,7 +40,7 @@ Fastest way to try PageAgent with our free Demo LLM:
```html
<script
src="https://cdn.jsdelivr.net/npm/page-agent@1.0.1/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"
></script>
```
@@ -50,8 +50,8 @@ Fastest way to try PageAgent with our free Demo LLM:
| Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.0.1/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.0.1/files/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.2.0/files/dist/iife/page-agent.demo.js |
### NPM Installation
+810 -1076
View File
File diff suppressed because it is too large Load Diff
+14 -11
View File
@@ -1,7 +1,7 @@
{
"name": "root",
"private": true,
"version": "1.0.1",
"version": "1.2.0",
"type": "module",
"workspaces": [
"packages/page-controller",
@@ -21,8 +21,7 @@
},
"homepage": "https://alibaba.github.io/page-agent/",
"engines": {
"node": ">=20.0.0",
"npm": ">=10.0.0"
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"scripts": {
"cleanup": "rm -rf packages/*/dist",
@@ -39,26 +38,30 @@
"@commitlint/cli": "^20.3.1",
"@commitlint/config-conventional": "^20.3.1",
"@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",
"@types/node": "^25.0.9",
"@types/node": "^25.2.2",
"@vitejs/plugin-react-swc": "^4.1.0",
"chalk": "^5.6.2",
"concurrently": "^9.2.1",
"dotenv": "^17.2.3",
"dotenv": "^17.2.4",
"eslint": "^9.39.2",
"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-refresh": "^0.4.26",
"eslint-plugin-react-x": "^2.7.2",
"eslint-plugin-react-refresh": "^0.5.0",
"eslint-plugin-react-x": "^2.12.2",
"globals": "^17.0.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.4",
"prettier": "^3.8.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",
"typescript-eslint": "^8.55.0",
"unplugin-dts": "^1.0.0-beta.6",
"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": {
"typescript": "^5.9.3"
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@page-agent/core",
"private": false,
"version": "1.0.1",
"version": "1.2.0",
"type": "module",
"main": "./dist/esm/page-agent-core.js",
"module": "./dist/esm/page-agent-core.js",
@@ -45,7 +45,7 @@
"dependencies": {
"chalk": "^5.6.2",
"zod": "^4.3.5",
"@page-agent/llms": "1.0.1",
"@page-agent/page-controller": "1.0.1"
"@page-agent/llms": "1.2.0",
"@page-agent/page-controller": "1.2.0"
}
}
+147 -127
View File
@@ -3,15 +3,15 @@
* All rights reserved.
*/
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 zod from 'zod'
import * as zod from 'zod'
import { type PageAgentConfig } from './config'
import { DEFAULT_MAX_STEPS } from './config/constants'
import SYSTEM_PROMPT from './prompts/system_prompt.md?raw'
import { tools } from './tools'
import {
import type {
AgentActivity,
AgentReflection,
AgentStatus,
@@ -21,17 +21,25 @@ import {
MacroToolInput,
MacroToolResult,
} from './types'
import { normalizeResponse, trimLines, uid, waitFor } from './utils'
import { assert } from './utils/assert'
import { assert, normalizeResponse, uid, waitFor } from './utils'
export { type PageAgentConfig }
export { tool, type PageAgentTool } from './tools'
export type * from './types'
/**
* AI agent for browser DOM automation.
* AI agent for browser automation.
*
* @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
* - `statuschange` - Agent status transitions (idle → running → completed/error)
* - `historychange` - History events updated (persistent, part of agent memory)
@@ -50,14 +58,16 @@ export type * from './types'
* - Types: thinking, executing, executed, retrying, error
*/
export class PageAgentCore extends EventTarget {
config: PageAgentConfig & { maxSteps: number }
id = uid()
tools: typeof tools
readonly id = uid()
readonly config: PageAgentConfig & { maxSteps: number }
readonly tools: typeof tools
/** PageController for DOM operations */
readonly pageController: PageController
task = ''
taskId = ''
/** Agent execution status */
#status: AgentStatus = 'idle'
/** History events */
history: HistoricalEvent[] = []
/**
* Callback for when agent needs user input (ask_user tool)
@@ -66,23 +76,21 @@ export class PageAgentCore extends EventTarget {
*/
onAskUser?: (question: string) => Promise<string>
#status: AgentStatus = 'idle'
#llm: LLM
#abortController = new AbortController()
#observations: string[] = []
/** PageController for DOM operations */
pageController: PageController
/** Runtime states for tracking across steps */
states = {
/** Accumulated wait time in seconds, used by wait tool */
/** internal states during a single task execution */
#states = {
/** Accumulated wait time in seconds */
totalWaitTime: 0,
/** Last known URL for detecting navigation */
/** For detecting navigation */
lastURL: '',
/** Browser state */
browserState: null as BrowserState | null,
}
/** History events */
history: HistoricalEvent[] = []
constructor(config: PageAgentConfig & { pageController: PageController }) {
super()
@@ -166,12 +174,13 @@ export class PageAgentCore extends EventTarget {
}
/**
* Push a persistent observation to the history event stream.
* This will be visible in <agent_history> and remain in memory across steps.
* Push a observation message to the history event stream.
* 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 {
this.history.push({ type: 'observation', content })
this.#emitHistoryChange()
this.#observations.push(content)
}
async execute(task: string): Promise<ExecutionResult> {
@@ -203,30 +212,25 @@ export class PageAgentCore extends EventTarget {
this.#setStatus('running')
this.#emitHistoryChange()
// Reset states
this.states = {
totalWaitTime: 0,
lastURL: '',
}
// Reset internal states
this.#states = { totalWaitTime: 0, lastURL: '', browserState: null }
try {
let step = 0
let step = 0
while (true) {
await this.#generateObservations(step)
while (true) {
try {
console.group(`step: ${step}`)
await onBeforeStep?.(this, step)
console.group(`step: ${step}`)
// observe
// abort
if (this.#abortController.signal.aborted) throw new Error('AbortError')
console.log(chalk.blue.bold('👀 Observing...'))
// Thinking
console.log(chalk.blue('Thinking...'))
this.#emitActivity({ type: 'thinking' })
this.#states.browserState = await this.pageController.getBrowserState()
await this.#handleObservations(step)
// invoke LLM
// assemble prompts
const messages = [
{ role: 'system' as const, content: this.#getSystemPrompt() },
@@ -235,12 +239,17 @@ export class PageAgentCore extends EventTarget {
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, {
toolChoiceName: 'AgentOutput',
normalizeResponse,
})
// assemble history event
// assemble history
const macroResult = result.toolResult as MacroToolResult
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)
step++
if (step > this.config.maxSteps) {
this.#onDone(false)
const result: ExecutionResult = {
success: false,
data: 'Step count exceeded maximum limit',
history: this.history,
}
await onAfterTask?.(this, result)
return result
}
console.groupEnd()
// finish task if done
if (actionName === 'done') {
const success = action.input?.success ?? false
const text = action.input?.text || 'no text provided'
@@ -299,19 +298,32 @@ export class PageAgentCore extends EventTarget {
await onAfterTask?.(this, 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)
const errorMessage = String(error)
this.#emitActivity({ type: 'error', message: errorMessage })
this.#onDone(false)
const result: ExecutionResult = {
success: false,
data: errorMessage,
history: this.history,
step++
if (step > this.config.maxSteps) {
this.#onDone(false)
const result: ExecutionResult = {
success: false,
data: 'Step count exceeded maximum limit',
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 {
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>,
execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
// abort
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 toolName = Object.keys(action)[0]
@@ -371,7 +383,7 @@ export class PageAgentCore extends EventTarget {
// Find the corresponding tool
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)
@@ -395,9 +407,11 @@ export class PageAgentCore extends EventTarget {
duration,
})
// Reset wait time for non-wait tools
if (toolName !== 'wait') {
this.states.totalWaitTime = 0
// counting wait time
if (toolName === 'wait') {
this.#states.totalWaitTime += toolInput?.seconds || 0
} else {
this.#states.totalWaitTime = 0
}
// Return structured result
@@ -417,10 +431,8 @@ export class PageAgentCore extends EventTarget {
return this.config.customSystemPrompt
}
let systemPrompt = SYSTEM_PROMPT
const targetLanguage = this.config.language === 'zh-CN' ? '中文' : 'English'
systemPrompt = systemPrompt.replace(
const systemPrompt = SYSTEM_PROMPT.replace(
/Default working language: \*\*.*?\*\*/,
`Default working language: **${targetLanguage}**`
)
@@ -429,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> {
const { instructions } = this.config
if (!instructions) return ''
const systemInstructions = instructions.system?.trim()
const url = await this.pageController.getCurrentUrl()
let pageInstructions: string | undefined
if (instructions.getPageInstructions) {
const url = this.#states.browserState?.url || ''
if (instructions.getPageInstructions && url) {
try {
pageInstructions = instructions.getPageInstructions(url)?.trim()
} catch (error) {
@@ -467,23 +479,28 @@ export class PageAgentCore extends EventTarget {
}
/**
* Generate observations before each step
* - URL change detection
* - Too many steps warning
* Generate system observations before each step
* @todo loop detection
* @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
const currentURL = await this.pageController.getCurrentUrl()
if (currentURL !== this.states.lastURL) {
const currentURL = this.#states.browserState?.url || ''
if (currentURL !== this.#states.lastURL) {
this.pushObservation(`Page navigated to → ${currentURL}`)
this.states.lastURL = currentURL
this.#states.lastURL = currentURL
await waitFor(0.5) // wait for page to stabilize
}
// Warn about remaining steps
const remaining = this.config.maxSteps - stepCount
// Remaining steps warning
const remaining = this.config.maxSteps - step
if (remaining === 5) {
this.pushObservation(
`⚠️ Only ${remaining} steps remaining. Consider wrapping up or calling done with partial results.`
@@ -493,12 +510,25 @@ export class PageAgentCore extends EventTarget {
`⚠️ 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> {
const browserState = this.#states.browserState!
let prompt = ''
// <instructions> (optional)
prompt += await this.#getInstructions()
// <agent_state>
@@ -508,38 +538,36 @@ export class PageAgentCore extends EventTarget {
const stepCount = this.history.filter((e) => e.type === 'step').length
prompt += `<agent_state>
<user_request>
${this.task}
</user_request>
<step_info>
Step ${stepCount + 1} of ${this.config.maxSteps} max possible steps
Current date and time: ${new Date().toLocaleString()}
</step_info>
</agent_state>
`
prompt += '<agent_state>\n'
prompt += '<user_request>\n'
prompt += `${this.task}\n`
prompt += '</user_request>\n'
prompt += '<step_info>\n'
prompt += `Step ${stepCount + 1} of ${this.config.maxSteps} max possible steps\n`
prompt += `Current time: ${new Date().toLocaleString()}\n`
prompt += '</step_info>\n'
prompt += '</agent_state>\n\n'
// <agent_history>
// - <step_N> for steps
// - <sys> for observations and system messages
prompt += '\n<agent_history>\n'
prompt += '<agent_history>\n'
let stepIndex = 0
for (const event of this.history) {
if (event.type === 'step') {
stepIndex++
prompt += `<step_${stepIndex}>
Evaluation of Previous Step: ${event.reflection.evaluation_previous_goal}
Memory: ${event.reflection.memory}
Next Goal: ${event.reflection.next_goal}
Action Results: ${event.action.output}
</step_${stepIndex}>
`
prompt += `<step_${stepIndex}>\n`
prompt += `Evaluation of Previous Step: ${event.reflection.evaluation_previous_goal}\n`
prompt += `Memory: ${event.reflection.memory}\n`
prompt += `Next Goal: ${event.reflection.next_goal}\n`
prompt += `Action Results: ${event.action.output}\n`
prompt += `</step_${stepIndex}>\n`
} else if (event.type === 'observation') {
prompt += `<sys>${event.content}</sys>\n`
} else if (event.type === 'user_takeover') {
prompt += `<sys>User took over control and made changes to the page.</sys>\n`
prompt += `<sys>User took over control and made changes to the page</sys>\n`
} else if (event.type === 'error') {
// Error events are mainly for panel rendering, not included in LLM context
// to avoid polluting the agent's reasoning with transient errors
@@ -550,9 +578,18 @@ export class PageAgentCore extends EventTarget {
// <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) {
@@ -562,23 +599,6 @@ export class PageAgentCore extends EventTarget {
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() {
console.log('Disposing PageAgent...')
this.pageController.dispose()
+5 -7
View File
@@ -10,7 +10,7 @@ You excel at following tasks:
</intro>
<language_settings>
- Default working language: **中文**
- Default working language: **English**
- Use the language that user is using. Return in user's language.
</language_settings>
@@ -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.
<evaluation_examples>
- Positive Examples:
"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_examples>
@@ -139,17 +138,16 @@ Here are examples of good output patterns. Use them as reference but never copy
<next_goal_examples>
"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>
</examples>
<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.",
"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."
"action":{"one_action_name": {// action-specific parameter}}
"next_goal": "State the next immediate goal and action to achieve it, in one clear sentence.",
"action":{
"Action name": {// Action parameters}
}
}
</output>
+11 -20
View File
@@ -2,7 +2,7 @@
* Internal tools for PageAgent.
* @note Adapted from browser-use
*/
import zod, { type z } from 'zod'
import * as zod from 'zod'
import type { PageAgentCore } from '../PageAgentCore'
import { waitFor } from '../utils'
@@ -13,7 +13,7 @@ import { waitFor } from '../utils'
export interface PageAgentTool<TParams = any> {
// name: string
description: string
inputSchema: z.ZodType<TParams>
inputSchema: zod.ZodType<TParams>
execute: (this: PageAgentCore, args: TParams) => Promise<string>
}
@@ -31,14 +31,13 @@ tools.set(
'done',
tool({
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({
text: zod.string(),
success: zod.boolean().default(true),
}),
execute: async function (this: PageAgentCore, input) {
// @note main loop will handle this one
// this.onDone(input.text, input.success)
return Promise.resolve('Task completed')
},
})
@@ -47,25 +46,17 @@ tools.set(
tools.set(
'wait',
tool({
description:
'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.',
description: 'Wait for x seconds. Can be used to wait until the page or data is fully loaded.',
inputSchema: zod.object({
seconds: zod.number().min(1).max(10).default(1),
}),
execute: async function (this: PageAgentCore, input) {
// try to subtract LLM calling time from the actual wait time
const lastTimeUpdate = await this.pageController.getLastUpdateTime()
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000)
console.log(`actualWaitTime: ${actualWaitTime} seconds`)
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.`
},
})
@@ -106,7 +97,7 @@ tools.set(
tools.set(
'input_text',
tool({
description: 'Click and input text into a input interactive element',
description: 'Click and type text into an interactive input element',
inputSchema: zod.object({
index: zod.int().min(0),
text: zod.string(),
@@ -140,8 +131,7 @@ tools.set(
tools.set(
'scroll',
tool({
description:
'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.',
description: 'Scroll the page vertically. Use index for scroll elements (dropdowns/custom UI).',
inputSchema: zod.object({
down: zod.boolean().default(true),
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(
'scroll_horizontally',
tool({
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({
right: zod.boolean().default(true),
pixels: zod.number().int().min(0),
@@ -190,8 +183,6 @@ tools.set(
})
)
// @todo get_dropdown_options
// @todo select_dropdown_option
// @todo send_keys
// @todo upload_file
// @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'
/**
* 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> {
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 {
let id = Math.random().toString(36).substring(2, 11)
@@ -85,3 +56,19 @@ export function uid() {
ids.push(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)
}
}
+291
View File
@@ -0,0 +1,291 @@
# Page Agent Extension API
This document describes how to integrate the Page Agent browser extension into your web application.
## Installation
### 1. Install the browser extension
Install the Page Agent extension from the Chrome Web Store.
### 2. Install type definitions (recommended)
```bash
npm install @page-agent/core --save-dev
```
### 3. Set up authentication
The extension only injects APIs when it detects a valid token in `localStorage`.
1. Open the extension's side panel to get your authorization token
2. Set the token in your page:
```typescript
localStorage.setItem('PageAgentExtUserAuthToken', 'your-token')
```
## Quick Start
```typescript
import type {
AgentActivity,
AgentStatus,
ExecutionResult,
HistoricalEvent,
} from '@page-agent/core'
// Wait for extension injection (up to 1 second)
async function waitForExtension(timeout = 1000): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeout) {
if (window.PAGE_AGENT_EXT) return true
await new Promise((r) => setTimeout(r, 100))
}
return false
}
// Usage
if (await waitForExtension()) {
const result = await window.PAGE_AGENT_EXT!.execute('Click the login button', {
baseURL: 'https://api.openai.com/v1',
apiKey: 'your-api-key',
model: 'gpt-5.2',
onStatusChange: (status) => console.log('Status:', status),
onActivity: (activity) => console.log('Activity:', activity),
})
console.log('Result:', result)
}
```
## Global API
The extension injects the following APIs into the `window` object:
### `window.PAGE_AGENT_EXT_VERSION`
Extension version string (e.g., `"1.0.0"`). This is exposed separately to allow version checking before accessing the main API object.
### `window.PAGE_AGENT_EXT`
Main API namespace object containing:
#### `PAGE_AGENT_EXT.execute(task, config)`
Execute an agent task.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `task` | `string` | Yes | Task description |
| `config` | `ExecuteConfig` | Yes | Execution configuration (LLM settings, options, and event callbacks) |
**Returns:** `Promise<ExecutionResult>`
#### `PAGE_AGENT_EXT.dispose()`
Stop and destroy the current running agent.
## Types
Install `@page-agent/core` for full type definitions:
```typescript
import type {
AgentActivity,
AgentStatus,
ExecutionResult,
HistoricalEvent,
} from '@page-agent/core'
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
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
}
export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
```
### AgentStatus
```typescript
type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
```
| Status | Description |
|--------|-------------|
| `idle` | Agent is idle, ready to execute |
| `running` | Agent is executing a task |
| `completed` | Task completed successfully |
| `error` | Task failed with an error |
### AgentActivity
```typescript
type AgentActivity =
| { type: 'thinking' }
| { type: 'executing'; tool: string; input: unknown }
| { type: 'executed'; tool: string; input: unknown; output: string; duration: number }
| { type: 'retrying'; attempt: number; maxAttempts: number }
| { type: 'error'; message: string }
```
| Type | Description |
|------|-------------|
| `thinking` | Agent is analyzing the page and planning |
| `executing` | Agent is executing a tool action |
| `executed` | Tool execution completed |
| `retrying` | Retrying after a failure |
| `error` | An error occurred |
### HistoricalEvent
```typescript
type HistoricalEvent =
| { type: 'step'; stepIndex: number; reflection: AgentReflection; action: Action }
| { type: 'observation'; content: string }
| { type: 'user_takeover' }
| { type: 'retry'; message: string; attempt: number; maxAttempts: number }
| { type: 'error'; message: string; rawResponse?: unknown }
```
### ExecutionResult
```typescript
interface ExecutionResult {
success: boolean
data: string
history: HistoricalEvent[]
}
```
## Usage Examples
### Basic Execution
```typescript
const result = await window.PAGE_AGENT_EXT!.execute(
'Fill in the email field with test@example.com and click Submit',
{
baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-5.2',
}
)
if (result.success) {
console.log('Task completed:', result.data)
} else {
console.error('Task failed')
}
```
### 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
const result = await window.PAGE_AGENT_EXT!.execute(
'Open a new tab and search for page-agent on GitHub',
{
baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-5.2',
includeInitialTab: false, // Agent will open new tabs only
}
)
```
### 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
```typescript
// Start a task
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
window.PAGE_AGENT_EXT!.dispose()
```
## Window Type Declaration
If not using `@page-agent/core`, add this to your project:
```typescript
import type {
AgentActivity,
AgentStatus,
ExecutionResult,
HistoricalEvent,
} 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 {
interface Window {
PAGE_AGENT_EXT_VERSION?: string
PAGE_AGENT_EXT?: {
version: string
execute: (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
dispose: () => void
}
}
}
```
+291
View File
@@ -0,0 +1,291 @@
# Page Agent 浏览器插件 API
本文档介绍如何在网页应用中接入 Page Agent 浏览器插件。
## 安装
### 1. 安装浏览器插件
从 Chrome 应用商店安装 Page Agent 插件。
### 2. 安装类型定义(推荐)
```bash
npm install @page-agent/core --save-dev
```
### 3. 配置认证
插件在页面加载后检测 `localStorage` 中的 token,匹配时才会注入 API。
1. 打开插件的侧边栏面板,获取授权 token
2. 在页面中设置 token
```typescript
localStorage.setItem('PageAgentExtUserAuthToken', 'your-token')
```
## 快速开始
```typescript
import type {
AgentActivity,
AgentStatus,
ExecutionResult,
HistoricalEvent,
} from '@page-agent/core'
// 等待插件注入(最多 1 秒)
async function waitForExtension(timeout = 1000): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeout) {
if (window.PAGE_AGENT_EXT) return true
await new Promise((r) => setTimeout(r, 100))
}
return false
}
// 使用
if (await waitForExtension()) {
const result = await window.PAGE_AGENT_EXT!.execute('点击登录按钮', {
baseURL: 'https://api.openai.com/v1',
apiKey: 'your-api-key',
model: 'gpt-5.2',
onStatusChange: (status) => console.log('状态:', status),
onActivity: (activity) => console.log('活动:', activity),
})
console.log('结果:', result)
}
```
## 全局 API
插件在 `window` 对象上注入以下 API
### `window.PAGE_AGENT_EXT_VERSION`
插件版本号字符串(例如 `"1.0.0"`)。单独暴露版本号,方便在访问主 API 对象前进行版本检查。
### `window.PAGE_AGENT_EXT`
主 API 命名空间对象,包含:
#### `PAGE_AGENT_EXT.execute(task, config)`
执行 Agent 任务。
**参数:**
| 名称 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `task` | `string` | 是 | 任务描述 |
| `config` | `ExecuteConfig` | 是 | 执行配置(LLM 设置、选项和事件回调) |
**返回:** `Promise<ExecutionResult>`
#### `PAGE_AGENT_EXT.dispose()`
停止并销毁当前运行的 Agent。
## 类型定义
安装 `@page-agent/core` 获取完整类型:
```typescript
import type {
AgentActivity,
AgentStatus,
ExecutionResult,
HistoricalEvent,
} from '@page-agent/core'
export interface ExecuteConfig {
baseURL: string
apiKey: string
model: string
/**
* 是否将初始标签页(运行此脚本的页面)包含在任务中。
* @default true
*/
includeInitialTab?: boolean
onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
}
export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
```
### AgentStatus
```typescript
type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
```
| 状态 | 说明 |
|------|------|
| `idle` | 空闲,准备执行 |
| `running` | 正在执行任务 |
| `completed` | 任务成功完成 |
| `error` | 任务执行失败 |
### AgentActivity
```typescript
type AgentActivity =
| { type: 'thinking' }
| { type: 'executing'; tool: string; input: unknown }
| { type: 'executed'; tool: string; input: unknown; output: string; duration: number }
| { type: 'retrying'; attempt: number; maxAttempts: number }
| { type: 'error'; message: string }
```
| 类型 | 说明 |
|------|------|
| `thinking` | Agent 正在分析页面并规划 |
| `executing` | 正在执行工具操作 |
| `executed` | 工具执行完成 |
| `retrying` | 失败后重试 |
| `error` | 发生错误 |
### HistoricalEvent
```typescript
type HistoricalEvent =
| { type: 'step'; stepIndex: number; reflection: AgentReflection; action: Action }
| { type: 'observation'; content: string }
| { type: 'user_takeover' }
| { type: 'retry'; message: string; attempt: number; maxAttempts: number }
| { type: 'error'; message: string; rawResponse?: unknown }
```
### ExecutionResult
```typescript
interface ExecutionResult {
success: boolean
data: string
history: HistoricalEvent[]
}
```
## 使用示例
### 基础执行
```typescript
const result = await window.PAGE_AGENT_EXT!.execute(
'在邮箱输入框填入 test@example.com 然后点击提交',
{
baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-5.2',
}
)
if (result.success) {
console.log('任务完成:', result.data)
} else {
console.error('任务失败')
}
```
### 排除初始标签页
默认情况下,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
await window.PAGE_AGENT_EXT!.execute('导航到设置页面', {
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 正在思考...')
break
case 'executing':
showSpinner(`正在执行: ${activity.tool}`)
break
case 'executed':
log(`${activity.tool} 完成,耗时 ${activity.duration}ms`)
break
case 'error':
showError(activity.message)
break
}
},
onHistoryUpdate: (history) => {
renderHistory(history)
},
})
```
### 停止执行
```typescript
// 启动任务
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/core`,可以添加以下声明:
```typescript
import type {
AgentActivity,
AgentStatus,
ExecutionResult,
HistoricalEvent,
} 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 {
interface Window {
PAGE_AGENT_EXT_VERSION?: string
PAGE_AGENT_EXT?: {
version: string
execute: (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
dispose: () => void
}
}
}
```
+22 -25
View File
@@ -1,7 +1,7 @@
{
"name": "@page-agent/ext",
"private": true,
"version": "0.1.0-b4",
"version": "0.1.4",
"type": "module",
"scripts": {
"dev": "wxt",
@@ -10,27 +10,6 @@
"postinstall": "wxt prepare"
},
"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",
"i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^16.5.2",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0",
"wouter": "^3.9.0",
"wxt": "^0.20.13"
},
"dependencies": {
"@page-agent/core": "1.0.1",
"@page-agent/llms": "1.0.1",
"@page-agent/page-controller": "1.0.1",
"@page-agent/ui": "1.0.1",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8",
@@ -38,14 +17,32 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@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",
"clsx": "^2.1.1",
"idb": "^8.0.3",
"lucide-react": "^0.563.0",
"motion": "^12.29.2",
"motion": "^12.34.0",
"next-themes": "^0.4.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"rough-notation": "^0.5.1",
"simple-icons": "^16.5.0",
"simple-icons": "^16.8.0",
"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"
}
}
+26 -3
View File
@@ -5,17 +5,35 @@ import { TabsController } from './TabsController'
import SYSTEM_PROMPT from './system_prompt.md?raw'
import { createTabTools } from './tabTools'
/** Detect user language from browser settings */
function detectLanguage(): 'en-US' | 'zh-CN' {
const lang = navigator.language || navigator.languages?.[0] || 'en-US'
return lang.startsWith('zh') ? 'zh-CN' : 'en-US'
}
/**
* MultiPageAgent
* - use with extension
* - can be used from a side panel or a content script
*/
export class MultiPageAgent extends PageAgentCore {
constructor(config: Omit<PageAgentConfig, 'pageController'>) {
constructor(config: Omit<PageAgentConfig, 'pageController'> & { includeInitialTab?: boolean }) {
// multi page controller
const tabsController = new TabsController()
const pageController = new RemotePageController(tabsController)
const customTools = createTabTools(tabsController)
// system prompt - auto-detect language if not specified
const language = config.language ?? detectLanguage()
const targetLanguage = language === 'zh-CN' ? '中文' : 'English'
const systemPrompt = SYSTEM_PROMPT.replace(
/Default working language: \*\*.*?\*\*/,
`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.
* There is no chance for isAgentRunning to be set false.
@@ -29,10 +47,10 @@ export class MultiPageAgent extends PageAgentCore {
...config,
pageController: pageController as any,
customTools: customTools,
customSystemPrompt: SYSTEM_PROMPT,
customSystemPrompt: systemPrompt,
onBeforeTask: async (agent) => {
await tabsController.init(agent.task)
await tabsController.init(agent.task, includeInitialTab)
heartBeatInterval = window.setInterval(() => {
chrome.storage.local.set({
@@ -56,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: () => {
if (heartBeatInterval) {
window.clearInterval(heartBeatInterval)
@@ -8,13 +8,21 @@ export function handlePageControlMessage(
sender: chrome.runtime.MessageSender,
sendResponse: (response: unknown) => void
): true | undefined {
const PREFIX = '[RemotePageController.background]'
function debug(...messages: any[]) {
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
const { action, payload, targetTabId } = message
if (action === 'get_my_tab_id') {
debug('get_my_tab_id', sender.tab?.id)
sendResponse({ tabId: sender.tab?.id || null })
return
}
// proxy to content script
chrome.tabs
.sendMessage(targetTabId, {
type: 'PAGE_CONTROL',
@@ -25,6 +33,7 @@ export function handlePageControlMessage(
sendResponse(result)
})
.catch((error) => {
console.error(PREFIX, error)
sendResponse({
success: false,
error: error instanceof Error ? error.message : String(error),
@@ -12,10 +12,14 @@ export function initPageController() {
.then((response) => {
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 {
if (!pageController) {
pageController = new PageController({ enableMask: false })
pageController = new PageController({ enableMask: false, viewportExpansion: 400 })
}
return pageController
}
@@ -2,13 +2,31 @@ import type { BrowserState } from '@page-agent/page-controller'
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.
* - live in the agent env (extension page or content script)
* - communicates with remote PageController via sw
*/
export class RemotePageController {
private tabsController: TabsController
tabsController: TabsController
constructor(tabsController: TabsController) {
this.tabsController = tabsController
@@ -18,46 +36,46 @@ export class RemotePageController {
return this.tabsController.currentTabId
}
async getCurrentUrl(): Promise<string> {
private async getCurrentUrl(): Promise<string> {
if (!this.currentTabId) return ''
const { url } = await this.tabsController.getTabInfo(this.currentTabId)
return url || ''
}
async getCurrentTitle(): Promise<string> {
private async getCurrentTitle(): Promise<string> {
if (!this.currentTabId) return ''
const { title } = await this.tabsController.getTabInfo(this.currentTabId)
return title || ''
}
get currentTabTitle(): Promise<string> {
return this.getCurrentTitle()
}
async getLastUpdateTime(): Promise<number> {
if (!this.currentTabId) throw new Error('tabsController not initialized.')
return await chrome.runtime.sendMessage({
return sendMessage({
type: 'PAGE_CONTROL',
action: 'get_last_update_time',
targetTabId: this.currentTabId,
})
}
// getBrowserState
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 = {
url: await this.currentTabUrl,
title: await this.currentTabTitle,
url: currentUrl,
title: currentTitle,
header: '',
content: '(empty page)',
content: '(empty page. either current page is not readable or not loaded yet.)',
footer: '',
}
} else {
browserState = await chrome.runtime.sendMessage({
browserState = await sendMessage({
type: 'PAGE_CONTROL',
action: 'get_browser_state',
targetTabId: this.currentTabId,
@@ -67,61 +85,58 @@ export class RemotePageController {
const sum = await this.tabsController.summarizeTabs()
browserState.header = sum + '\n\n' + (browserState.header || '')
debug('getBrowserState: success', this.currentTabId, browserState)
return browserState
}
// updateTree
async updateTree(): Promise<void> {
if (!this.currentTabId || !isContentScriptAllowed(await this.currentTabUrl)) {
if (!this.currentTabId || !isContentScriptAllowed(await this.getCurrentUrl())) {
return
}
await chrome.runtime.sendMessage({
await sendMessage({
type: 'PAGE_CONTROL',
action: 'update_tree',
targetTabId: this.currentTabId,
})
}
// cleanUpHighlights
async cleanUpHighlights(): Promise<void> {
if (!this.currentTabId || !isContentScriptAllowed(await this.currentTabUrl)) {
if (!this.currentTabId || !isContentScriptAllowed(await this.getCurrentUrl())) {
return
}
await chrome.runtime.sendMessage({
await sendMessage({
type: 'PAGE_CONTROL',
action: 'clean_up_highlights',
targetTabId: this.currentTabId,
})
}
// clickElement
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> {
return this.remoteCallDomAction('input_text', args)
}
// selectOption
async selectOption(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('select_option', args)
}
// scroll
async scroll(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('scroll', args)
}
// scrollHorizontally
async scrollHorizontally(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('scroll_horizontally', args)
}
// executeJavascript
async executeJavascript(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('execute_javascript', args)
}
@@ -133,35 +148,26 @@ export class RemotePageController {
/** @note Managed by content script via storage polling. */
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> {
const preCheckError = await this.preCheck()
if (preCheckError) {
return { success: false, message: preCheckError }
if (!this.currentTabId) {
return { success: false, message: 'RemotePageController not initialized.' }
}
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',
action: action,
targetTabId: this.currentTabId!,
payload,
})
}
private get currentTabUrl(): Promise<string> {
return this.getCurrentUrl()
}
}
interface DomActionReturn {
@@ -3,6 +3,12 @@
*/
import type { TabAction } from './TabsController'
const PREFIX = '[TabsController.background]'
function debug(...messages: any[]) {
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
export function handleTabControlMessage(
message: { type: 'TAB_CONTROL'; action: TabAction; payload: any },
sender: chrome.runtime.MessageSender,
@@ -12,10 +18,12 @@ export function handleTabControlMessage(
switch (action as TabAction) {
case 'get_active_tab': {
debug('get_active_tab')
chrome.tabs
.query({ active: true, currentWindow: true })
.then((tabs) => {
const tabId = tabs.length > 0 ? tabs[0].id || null : null
debug('get_active_tab: success', tabId)
sendResponse({ success: true, tabId })
})
.catch((error) => {
@@ -25,11 +33,12 @@ export function handleTabControlMessage(
}
case 'get_tab_info': {
debug('get_tab_info', payload)
chrome.tabs
.get(payload.tabId)
.then((tab) => {
const result = { title: tab.title || '', url: tab.url || '' }
sendResponse(result)
debug('get_tab_info: success', tab)
sendResponse(tab)
})
.catch((error) => {
sendResponse({ error: error instanceof Error ? error.message : String(error) })
@@ -38,10 +47,11 @@ export function handleTabControlMessage(
}
case 'open_new_tab': {
debug('open_new_tab', payload)
chrome.tabs
.create({ url: payload.url, active: false })
.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 })
})
.catch((error) => {
@@ -51,20 +61,22 @@ export function handleTabControlMessage(
}
case 'create_tab_group': {
debug('create_tab_group', payload)
chrome.tabs
.group({ tabIds: payload.tabIds, createProperties: { windowId: payload.windowId } })
.then((groupId) => {
console.log('Created tab group', groupId)
debug('create_tab_group: success', groupId)
sendResponse({ success: true, groupId })
})
.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) })
})
return true // async response
}
case 'update_tab_group': {
debug('update_tab_group', payload)
chrome.tabGroups
.update(payload.groupId, payload.properties)
.then(() => {
@@ -77,6 +89,7 @@ export function handleTabControlMessage(
}
case 'add_tab_to_group': {
debug('add_tab_to_group', payload)
chrome.tabs
.group({ tabIds: payload.tabId, groupId: payload.groupId })
.then(() => {
@@ -89,6 +102,7 @@ export function handleTabControlMessage(
}
case 'close_tab': {
debug('close_tab', payload)
chrome.tabs
.remove(payload.tabId)
.then(() => {
@@ -105,3 +119,42 @@ export function handleTabControlMessage(
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.
* - live in the agent env (extension page or content script)
@@ -12,7 +29,9 @@ export class TabsController extends EventTarget {
private task: string = ''
private windowId: number | null = null
async init(task: string) {
async init(task: string, includeInitialTab: boolean = true) {
debug('init', task, includeInitialTab)
this.task = task
this.tabs = []
this.currentTabId = null
@@ -20,23 +39,36 @@ export class TabsController extends EventTarget {
this.initialTabId = null
this.windowId = null
const result = await chrome.runtime.sendMessage({
const result = await sendMessage({
type: 'TAB_CONTROL',
action: 'get_active_tab',
})
this.initialTabId = result.tabId
this.currentTabId = result.tabId
this.tabs.push({
id: result.tabId,
isInitial: true,
})
if (!this.initialTabId) {
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)
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 }> {
const result = await chrome.runtime.sendMessage({
async openNewTab(url: string): Promise<string> {
debug('openNewTab', url)
const result = await sendMessage({
type: 'TAB_CONTROL',
action: 'open_new_tab',
payload: { url },
@@ -102,7 +144,7 @@ export class TabsController extends EventTarget {
await this.switchToTab(tabId)
if (!this.tabGroupId) {
const result = await chrome.runtime.sendMessage({
const result = await sendMessage({
type: 'TAB_CONTROL',
action: 'create_tab_group',
payload: { tabIds: [tabId], windowId: this.windowId },
@@ -116,7 +158,7 @@ export class TabsController extends EventTarget {
this.tabGroupId = groupId
await chrome.runtime.sendMessage({
await sendMessage({
type: 'TAB_CONTROL',
action: 'update_tab_group',
payload: {
@@ -129,57 +171,43 @@ export class TabsController extends EventTarget {
},
})
} else {
await chrome.runtime.sendMessage({
await sendMessage({
type: 'TAB_CONTROL',
action: 'add_tab_to_group',
payload: { tabId: result.tabId, groupId: this.tabGroupId },
})
}
// wait for the new tab to be fully loaded
// @todo
await new Promise((resolve) => setTimeout(resolve, 500))
await this.waitUntilTabLoaded(tabId)
return {
success: true,
tabId,
message: `Opened new tab ID ${tabId} with URL ${url}`,
}
return `✅ 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)
if (!targetTab) {
return {
success: false,
message: `Tab ID ${tabId} not found in tab list.`,
}
throw new Error(`Tab ID ${tabId} not found in tab list.`)
}
await this.updateCurrentTabId(tabId)
return {
success: true,
message: `Switched to tab ID ${tabId}.`,
}
return `✅ 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)
if (!targetTab) {
return {
success: false,
message: `Tab ID ${tabId} not found in tab list.`,
}
throw new Error(`Tab ID ${tabId} not found in tab list.`)
}
if (targetTab.isInitial) {
return {
success: false,
message: `Cannot close the initial tab ID ${tabId}.`,
}
throw new Error(`Cannot close the initial tab ID ${tabId}.`)
}
const result = await chrome.runtime.sendMessage({
const result = await sendMessage({
type: 'TAB_CONTROL',
action: 'close_tab',
payload: { tabId },
@@ -196,29 +224,39 @@ export class TabsController extends EventTarget {
}
}
return {
success: true,
message: `Closed tab ID ${tabId}.`,
}
return `✅ Closed tab ID ${tabId}.`
} else {
return {
success: false,
message: `Failed to close tab ID ${tabId}: ${result.error}`,
}
throw new Error(`Failed to close tab ID ${tabId}: ${result.error}`)
}
}
async updateCurrentTabId(tabId: number | null) {
debug('updateCurrentTabId', tabId)
this.currentTabId = tabId
await chrome.storage.local.set({ currentTabId: tabId })
}
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',
action: 'get_tab_info',
payload: { tabId },
})
if (tabMeta) {
tabMeta.url = result.url
tabMeta.title = result.title
}
return result
}
@@ -230,9 +268,24 @@ export class TabsController extends EventTarget {
`| ${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')
}
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() {
this.dispatchEvent(new Event('dispose'))
}
@@ -251,6 +304,9 @@ export type TabAction =
interface TabMeta {
id: number
isInitial: boolean
url?: string
title?: string
status?: 'loading' | 'unloaded' | 'complete'
}
const TAB_GROUP_COLORS = [
@@ -269,3 +325,33 @@ type TabGroupColor = (typeof TAB_GROUP_COLORS)[number]
function randomColor(): TabGroupColor {
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)
})
}
@@ -10,7 +10,7 @@ You excel at following tasks:
</intro>
<language_settings>
- Default working language: **中文**
- Default working language: **English**
- Use the language that user is using. Return in user's language.
</language_settings>
@@ -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.
<evaluation_examples>
- Positive Examples:
"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_examples>
@@ -131,17 +130,16 @@ Here are examples of good output patterns. Use them as reference but never copy
<next_goal_examples>
"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>
</examples>
<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.",
"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."
"action":{"one_action_name": {// action-specific parameter}}
"next_goal": "State the next immediate goal and action to achieve it, in one clear sentence.",
"action":{
"Action name": {// Action parameters}
}
}
</output>
+16 -5
View File
@@ -6,7 +6,7 @@
* - switch_to_tab: Switch to an existing tab
* - close_tab: Close a tab (optionally switch to another)
*/
import zod from 'zod'
import * as zod from 'zod'
import type { TabsController } from './TabsController'
@@ -31,8 +31,11 @@ export function createTabTools(tabsController: TabsController): Record<string, T
}),
execute: async (input: unknown) => {
const { url } = input as { url: string }
const result = await tabsController.openNewTab(url)
return result.message
try {
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) => {
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) => {
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)}`
}
},
},
}
+2 -1
View File
@@ -75,7 +75,7 @@ export function useAgent(): UseAgentResult {
const execute = useCallback(async (task: string) => {
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')
setCurrentTask(task)
@@ -88,6 +88,7 @@ export function useAgent(): UseAgentResult {
}, [])
const configure = useCallback(async (newConfig: LLMConfig) => {
await chrome.storage.local.set({ llmConfig: newConfig })
setConfig(newConfig)
}, [])
+1 -1
View File
@@ -40,7 +40,7 @@
}
.dark {
--background: oklch(0.145 0 0);
--background: oklch(0.19 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
@@ -1,24 +1,13 @@
import { handlePageControlMessage } from '@/agent/RemotePageController.background'
import { handleTabControlMessage } 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
// ============================================================================
import { handleTabControlMessage, setupTabChangeEvents } from '@/agent/TabsController.background'
export default defineBackground(() => {
console.log('[Background] Service Worker started')
// tab change events
setupTabChangeEvents()
// generate user auth token
chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => {
@@ -28,21 +17,20 @@ export default defineBackground(() => {
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
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({
matches: ['<all_urls>'],
runAt: 'document_idle',
runAt: 'document_end',
main() {
console.debug(`${DEBUG_PREFIX} Loaded on ${window.location.href}`)
@@ -69,11 +69,11 @@ async function exposeAgentToPage() {
}
try {
const { task, llmConfig } = payload
const { task, config } = payload
// create when used
multiPageAgent = new MultiPageAgent(llmConfig)
multiPageAgent = new MultiPageAgent(config)
// events
@@ -1,35 +1,38 @@
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
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
}
export type Execute = (
task: string,
llmConfig: LLMConfig,
hooks?: ExecuteHooks
) => Promise<ExecutionResult>
export default defineUnlistedScript(() => {
const w = window as any
let _lastId = 0
function getId() {
_lastId += 1
return _lastId
}
w.execute = async (task: string, llmConfig: LLMConfig, hooks?: ExecuteHooks) => {
const execute: Execute = async (task, config) => {
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 (!llmConfig) throw new Error('LLM config is required')
if (!llmConfig.baseURL) throw new Error('LLM config must have a baseURL')
if (!llmConfig.apiKey) throw new Error('LLM config must have an apiKey')
if (!llmConfig.model) throw new Error('LLM config must have a model')
if (!config) throw new Error('Config is required')
if (!config.baseURL) throw new Error('Config must have a baseURL')
if (!config.apiKey) throw new Error('Config must have an apiKey')
if (!config.model) throw new Error('Config must have a model')
const id = getId()
@@ -42,30 +45,31 @@ export default defineUnlistedScript(() => {
// events
if (data.action === 'status_change_event' && hooks?.onStatusChange) {
hooks.onStatusChange(data.payload)
if (data.action === 'status_change_event' && config.onStatusChange) {
config.onStatusChange(data.payload)
return
}
if (data.action === 'activity_event' && hooks?.onActivity) {
hooks.onActivity(data.payload)
if (data.action === 'activity_event' && config.onActivity) {
config.onActivity(data.payload)
return
}
if (data.action === 'history_change_event' && hooks?.onHistoryUpdate) {
hooks.onHistoryUpdate(data.payload)
if (data.action === 'history_change_event' && config.onHistoryUpdate) {
config.onHistoryUpdate(data.payload)
return
}
if (data.action === 'dispose_event' && hooks?.onDispose) {
hooks.onDispose()
if (data.action === 'dispose_event' && config.onDispose) {
config.onDispose()
window.removeEventListener('message', handleMessage)
return
}
// result
if (data.action !== 'execute_result') return
// execute_result
window.removeEventListener('message', handleMessage)
if (data.error) {
@@ -75,6 +79,7 @@ export default defineUnlistedScript(() => {
}
}
// @note will be removed on dispose or result
window.addEventListener('message', handleMessage)
})
@@ -83,7 +88,15 @@ export default defineUnlistedScript(() => {
channel: 'PAGE_AGENT_EXT_REQUEST',
id,
action: 'execute',
payload: { task, llmConfig },
payload: {
task,
config: {
baseURL: config.baseURL,
apiKey: config.apiKey,
model: config.model,
includeInitialTab: config.includeInitialTab,
},
},
},
'*'
)
@@ -91,7 +104,7 @@ export default defineUnlistedScript(() => {
return promise
}
w.dispose = () => {
const dispose = () => {
const id = getId()
window.postMessage(
@@ -103,4 +116,11 @@ export default defineUnlistedScript(() => {
'*'
)
}
;(window as any).PAGE_AGENT_EXT_VERSION = __EXT_VERSION__
;(window as any).PAGE_AGENT_EXT = {
version: __EXT_VERSION__,
execute,
dispose,
}
})
@@ -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 { Button } from '@/components/ui/button'
@@ -8,20 +8,47 @@ import {
InputGroupButton,
InputGroupTextarea,
} from '@/components/ui/input-group'
import { saveSession } from '@/lib/db'
import { useAgent } from '../../agent/useAgent'
import { ConfigPanel } from './components/ConfigPanel'
import { HistoryDetail } from './components/HistoryDetail'
import { HistoryList } from './components/HistoryList'
import { ActivityCard, EventCard } from './components/cards'
import { EmptyState, Logo, StatusDot } from './components/misc'
type View =
| { name: 'chat' }
| { name: 'config' }
| { name: 'history' }
| { name: 'history-detail'; sessionId: string }
export default function App() {
const [showConfig, setShowConfig] = useState(false)
const [view, setView] = useState<View>({ name: 'chat' })
const [inputValue, setInputValue] = useState('')
const historyRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
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
useEffect(() => {
if (historyRef.current) {
@@ -30,7 +57,7 @@ export default function App() {
}, [history, activity])
const handleSubmit = useCallback(
(e?: React.FormEvent) => {
(e?: React.SyntheticEvent) => {
e?.preventDefault()
if (!inputValue.trim() || status === 'running') return
@@ -56,19 +83,36 @@ export default function App() {
}
}
if (showConfig) {
// --- View routing ---
if (view.name === 'config') {
return (
<ConfigPanel
config={config}
onSave={async (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 showEmptyState = !currentTask && history.length === 0 && !isRunning
@@ -82,7 +126,20 @@ export default function App() {
</div>
<div className="flex items-center gap-3">
<StatusDot status={status} />
<Button variant="ghost" size="icon-sm" onClick={() => setShowConfig(true)}>
<Button
variant="ghost"
size="icon-sm"
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"
>
<Settings className="size-3.5" />
</Button>
</div>
@@ -142,7 +199,7 @@ export default function App() {
variant="default"
onClick={() => handleSubmit()}
disabled={!inputValue.trim()}
className="size-7"
className="size-7 cursor-pointer"
>
<Send className="size-3" />
</InputGroupButton>
@@ -1,6 +1,7 @@
import type { LLMConfig } from '@page-agent/llms'
import { Copy, Loader2 } from 'lucide-react'
import { Copy, CornerUpLeft, Eye, EyeOff, HatGlasses, Home, Loader2, Scale } from 'lucide-react'
import { useEffect, useState } from 'react'
import { siGithub } from 'simple-icons'
import { DEMO_API_KEY, DEMO_BASE_URL, DEMO_MODEL } from '@/agent/constants'
import { Button } from '@/components/ui/button'
@@ -19,6 +20,8 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
const [saving, setSaving] = useState(false)
const [userAuthToken, setUserAuthToken] = useState<string>('')
const [copied, setCopied] = useState(false)
const [showToken, setShowToken] = useState(false)
const [showApiKey, setShowApiKey] = useState(false)
// Update local state when config prop changes
useEffect(() => {
@@ -69,8 +72,18 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
}
return (
<div className="flex flex-col gap-4 p-4">
<h2 className="text-base font-semibold">Settings</h2>
<div className="flex flex-col gap-4 p-4 relative">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold">Settings</h2>
<Button
variant="ghost"
size="icon-sm"
onClick={onClose}
className="absolute top-2 right-3 cursor-pointer"
>
<CornerUpLeft className="size-3.5" />
</Button>
</div>
{/* User Auth Token Section */}
<div className="flex flex-col gap-1.5 p-3 bg-muted/50 rounded-md border">
@@ -81,9 +94,24 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
<div className="flex gap-2 items-center">
<Input
readOnly
value={userAuthToken || 'Loading...'}
value={
userAuthToken
? showToken
? userAuthToken
: `${userAuthToken.slice(0, 4)}${''.repeat(userAuthToken.length - 8)}${userAuthToken.slice(-4)}`
: 'Loading...'
}
className="text-xs h-8 font-mono bg-background"
/>
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 cursor-pointer"
onClick={() => setShowToken(!showToken)}
disabled={!userAuthToken}
>
{showToken ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
</Button>
<Button
variant="outline"
size="icon"
@@ -118,23 +146,98 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">API Key</label>
<Input
type="password"
placeholder="sk-..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="text-xs h-8"
/>
<div className="flex gap-2 items-center">
<Input
type={showApiKey ? 'text' : 'password'}
placeholder="sk-..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="text-xs h-8"
/>
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 cursor-pointer"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
</Button>
</div>
</div>
<div className="flex gap-2 mt-2">
<Button variant="outline" onClick={onClose} className="flex-1 h-8 text-xs">
<Button variant="outline" onClick={onClose} className="flex-1 h-8 text-xs cursor-pointer">
Cancel
</Button>
<Button onClick={handleSave} disabled={saving} className="flex-1 h-8 text-xs">
<Button
onClick={handleSave}
disabled={saving}
className="flex-1 h-8 text-xs cursor-pointer"
>
{saving ? <Loader2 className="size-3 animate-spin" /> : 'Save'}
</Button>
</div>
{/* Footer */}
<div className="mt-4 mb-4 pt-4 border-t border-border/50 flex gap-2 justify-between text-[10px] text-muted-foreground">
<div className="flex flex-col justify-between">
<a
href="https://github.com/alibaba/page-agent"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 hover:text-foreground"
>
<svg role="img" viewBox="0 0 24 24" className="size-3 fill-current">
<path d={siGithub.path} />
</svg>
<span>Source Code</span>
</a>
<a
href="https://alibaba.github.io/page-agent/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 hover:text-foreground"
>
<Home className="size-3" />
<span>Home Page</span>
</a>
<a
href="https://github.com/alibaba/page-agent/blob/main/packages/extension/PRIVACY.md"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 hover:text-foreground"
>
<HatGlasses className="size-3" />
<span>Privacy Policy</span>
</a>
</div>
<div className="flex flex-col items-end">
<span>
Extension <span className="font-mono">v{__EXT_VERSION__}</span>
</span>
<span>
PageAgent <span className="font-mono">v{__CORE_VERSION__}</span>
</span>
</div>
</div>
{/* attribute */}
<div className="text-[10px] text-muted-foreground bg-background fixed bottom-0 w-full flex justify-around">
<span className="leading-loose">
Built with ♥️ by{' '}
<a
href="https://github.com/gaomeng1900"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
@Simon
</a>
</span>
</div>
</div>
)
}
@@ -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>
)
}
@@ -315,9 +315,7 @@ export function EventCard({ event }: { event: HistoricalEvent }) {
<ResultCard
success={input?.success ?? true}
text={input?.text || event.action.output || ''}
>
<RawSection rawRequest={event.rawRequest} rawResponse={event.rawResponse} />
</ResultCard>
/>
</>
)
}
@@ -6,6 +6,16 @@ import { ErrorBoundary } from './components/ErrorBoundary'
import '@/assets/index.css'
// Sync dark mode with system preference
const syncDarkMode = () => {
document.documentElement.classList.toggle(
'dark',
matchMedia('(prefers-color-scheme: dark)').matches
)
}
syncDarkMode()
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncDarkMode)
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ErrorBoundary>
+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')
}
+2
View File
@@ -0,0 +1,2 @@
declare const __EXT_VERSION__: string
declare const __CORE_VERSION__: string
+7 -1
View File
@@ -1,10 +1,12 @@
import tailwindcss from '@tailwindcss/vite'
import { mkdirSync } from 'node:fs'
import { mkdirSync, readFileSync } from 'node:fs'
import { defineConfig } from 'wxt'
const chromeProfile = '.wxt/chrome-data'
mkdirSync(chromeProfile, { recursive: true })
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))
// See https://wxt.dev/api/config.html
export default defineConfig({
srcDir: 'src',
@@ -16,6 +18,10 @@ export default defineConfig({
},
vite: () => ({
plugins: [tailwindcss()],
define: {
__EXT_VERSION__: JSON.stringify(pkg.version),
__CORE_VERSION__: JSON.stringify(pkg.dependencies['@page-agent/core']),
},
optimizeDeps: {
force: true,
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@page-agent/llms",
"version": "1.0.1",
"version": "1.2.0",
"type": "module",
"main": "./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> {
return await withRetry(
async () => {
// in case user aborted before invoking
if (abortSignal.aborted) throw new Error('AbortError')
const result = await this.client.invoke(messages, tools, abortSignal, options)
return result
+5 -1
View File
@@ -2,7 +2,7 @@
* Utility functions for LLM integration
*/
import chalk from 'chalk'
import { z } from 'zod'
import * as z from 'zod'
import type { Tool } from './types'
@@ -74,6 +74,10 @@ export function modelPatch(body: Record<string, any>) {
} else if (modelName.startsWith('gpt-51')) {
debug('Applying GPT-51 patch: disable reasoning')
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')) {
debug('Applying GPT-5 patch: set reasoning effort to low')
body.reasoning_effort = 'low'
+6 -6
View File
@@ -1,7 +1,7 @@
{
"name": "page-agent",
"private": false,
"version": "1.0.1",
"version": "1.2.0",
"type": "module",
"main": "./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{}})\""
},
"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",
"@page-agent/llms": "1.0.1",
"@page-agent/page-controller": "1.0.1",
"@page-agent/core": "1.0.1",
"@page-agent/ui": "1.0.1"
"zod": "^4.3.5"
}
}
+6 -2
View File
@@ -3,6 +3,7 @@ import { config as dotenvConfig } from 'dotenv'
import { dirname, resolve } from 'path'
import { fileURLToPath } from 'url'
import { defineConfig } from 'vite'
// import { analyzer } from 'vite-bundle-analyzer'
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
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
// - no external
// - no d.ts. dts does not work with monorepo aliasing
export default defineConfig(({ mode }) => ({
plugins: [cssInjectedByJsPlugin({ relativeCSSInjection: true })],
export default defineConfig(() => ({
plugins: [
cssInjectedByJsPlugin({ relativeCSSInjection: true }),
// analyzer()
],
publicDir: false,
esbuild: {
keepNames: true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@page-agent/page-controller",
"version": "1.0.1",
"version": "1.2.0",
"type": "module",
"main": "./dist/lib/page-controller.js",
"module": "./dist/lib/page-controller.js",
@@ -194,7 +194,7 @@ export class PageController extends EventTarget {
interactiveBlacklist: blacklist,
})
this.simplifiedHTML = dom.flatTreeToString(this.flatTree, this.config.include_attributes)
this.simplifiedHTML = dom.flatTreeToString(this.flatTree, this.config.includeAttributes)
this.selectorMap.clear()
this.selectorMap = dom.getSelectorMap(this.flatTree)
@@ -248,7 +248,7 @@ export class PageController extends EventTarget {
if (element instanceof HTMLAnchorElement && element.target === '_blank') {
return {
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.`,
}
}
+3 -3
View File
@@ -10,7 +10,7 @@ import {
export interface DomConfig {
interactiveBlacklist?: (Element | (() => Element))[]
interactiveWhitelist?: (Element | (() => Element))[]
include_attributes?: string[]
includeAttributes?: string[]
highlightOpacity?: number
highlightLabelOpacity?: number
}
@@ -117,7 +117,7 @@ interface TreeNode {
*
* @todo
*/
export function flatTreeToString(flatTree: FlatDomTree, include_attributes?: string[]): string {
export function flatTreeToString(flatTree: FlatDomTree, includeAttributes?: string[]): string {
const DEFAULT_INCLUDE_ATTRIBUTES = [
'title',
'type',
@@ -146,7 +146,7 @@ export function flatTreeToString(flatTree: FlatDomTree, include_attributes?: str
'aria-owns',
]
const includeAttrs = [...(include_attributes || []), ...DEFAULT_INCLUDE_ATTRIBUTES]
const includeAttrs = [...(includeAttributes || []), ...DEFAULT_INCLUDE_ATTRIBUTES]
// Helper function to cap text length
const capTextLength = (text: string, maxLength: number): string => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@page-agent/ui",
"version": "1.0.1",
"version": "1.2.0",
"type": "module",
"main": "./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 type { AgentActivity, PanelAgentAdapter } from './panel/types'
export { I18n, type SupportedLanguage, type TranslationKey } from './i18n'
+11 -15
View File
@@ -1,7 +1,7 @@
{
"name": "@page-agent/website",
"private": true,
"version": "1.0.1",
"version": "1.2.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
@@ -10,30 +10,26 @@
"typecheck": "tsc --noEmit"
},
"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-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"motion": "^12.29.2",
"motion": "^12.34.0",
"next-themes": "^0.4.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"rough-notation": "^0.5.1",
"simple-icons": "^16.6.0",
"simple-icons": "^16.8.0",
"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)
export const CDN_DEMO_URL =
'https://cdn.jsdelivr.net/npm/page-agent@1.0.1/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 =
'https://registry.npmmirror.com/page-agent/1.0.1/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
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 ? '快速开始' : 'Quick Start', path: '/introduction/quick-start' },
{ title: isZh ? '使用限制' : 'Limitations', path: '/introduction/limitations' },
{
title: isZh ? '故障排查' : 'Troubleshooting',
path: '/introduction/troubleshooting',
},
{
title: '🚧 ' + (isZh ? '最佳实践' : 'Best Practices'),
path: '/integration/best-practices',
@@ -307,7 +307,7 @@ const result = await agent.execute('Fill in the form with test data')`}
: 'Elements to force include for interaction',
},
{
name: 'include_attributes',
name: 'includeAttributes',
type: 'string[]',
description: isZh
? '在 DOM 提取中包含的额外属性'
@@ -376,13 +376,6 @@ const result = await agent.execute('Fill in the form with test data')`}
? '执行任务并返回结果。包含 success、data 和 history 字段。'
: '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)',
type: 'void',
@@ -103,13 +103,13 @@ export default function ChromeExtension() {
isZh
? `// 1. 用户安装扩展并在扩展设置中配置 auth token
// 2. 你的页面读取相同的 token 并存入 localStorage
// 3. Token 匹配后,扩展会暴露 window.execute() 和 window.dispose()
// 3. Token 匹配后,扩展会暴露 window.PAGE_AGENT_EXT 对象
// ⚠️ 请在扩展弹窗中查看你的 auth token,然后填入下方
localStorage.setItem('PageAgentExtUserAuthToken', '<从扩展中获取的-token>')`
: `// 1. User installs extension and sets an auth token in extension settings
// 2. Your page reads the same token and stores it in localStorage
// 3. After token match, extension exposes window.execute() and window.dispose()
// 3. After token match, extension exposes window.PAGE_AGENT_EXT object
// ⚠️ Check your extension popup for the auth token
localStorage.setItem('PageAgentExtUserAuthToken', '<your-token-from-extension>')`
@@ -122,25 +122,54 @@ localStorage.setItem('PageAgentExtUserAuthToken', '<your-token-from-extension>')
<section>
<h2 className="text-2xl font-bold mb-4">{isZh ? 'API 参考' : 'API Reference'}</h2>
<h3 className="text-xl font-semibold mb-3">window.execute(task, llmConfig, hooks?)</h3>
{/* AI Assistant Instructions */}
<section className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<h3 className="text-lg font-semibold text-purple-900 dark:text-purple-300 mb-2">
🤖 {isZh ? '给 AI 编程助手的文档' : 'Instructions for Your AI Assistant'}
</h3>
<p className="text-gray-600 dark:text-gray-300 mb-3 text-sm">
{isZh
? '如果你在使用 AI 编程助手(如 Cursor、GitHub Copilot),可以将以下文档链接提供给它,让它更好地理解和使用 Page Agent 扩展 API'
: 'If you are using an AI coding assistant (like Cursor, GitHub Copilot), share these documentation links with it for better understanding of Page Agent Extension API:'}
</p>
<div className="space-y-2">
<a
href="https://github.com/alibaba/page-agent/blob/main/packages/extension/docs/extension_api.md"
target="_blank"
rel="noopener noreferrer"
className="block text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 hover:underline"
>
📄 {isZh ? '英文版 API 文档' : 'API Documentation (English)'}
</a>
<a
href="https://github.com/alibaba/page-agent/blob/main/packages/extension/docs/extension_api_zh.md"
target="_blank"
rel="noopener noreferrer"
className="block text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 hover:underline"
>
📄 {isZh ? '中文版 API 文档' : 'API Documentation (Chinese)'}
</a>
</div>
</section>
<h3 className="text-xl font-semibold my-3">PAGE_AGENT_EXT.execute(task, config)</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4">
{isZh
? '使用 LLM 配置执行任务。返回一个 Promise,在任务完成时 resolve。可选的 hooks 参数用于监听任务执行过程中的事件。'
: '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.'}
? '使用配置执行任务。返回一个 Promise,在任务完成时 resolve。config 参数包含 LLM 设置、选项和事件回调。'
: 'Execute a task with configuration. Returns a Promise that resolves when the task completes. Config includes LLM settings, options, and event callbacks.'}
</p>
<CodeEditor
code={
isZh
? `// 使用 LLM 配置和 hooks 执行任务
const result = await window.execute(
? `// 使用配置执行任务
const result = await window.PAGE_AGENT_EXT.execute(
'在 GitHub 上搜索 "page-agent" 并打开第一个结果',
{
baseURL: 'https://api.openai.com/v1',
apiKey: 'your-api-key',
model: 'gpt-5-2'
},
{
model: 'gpt-5-2',
// includeInitialTab: false, // 设为 false 排除初始标签页
onStatusChange: status => console.log('状态变化:', status),
onActivity: activity => console.log('活动:', activity),
onHistoryUpdate: history => console.log('历史更新:', history),
@@ -149,15 +178,14 @@ const result = await window.execute(
)
console.log(result) // 任务执行结果`
: `// Execute a task with LLM configuration and hooks
const result = await window.execute(
: `// Execute a task with configuration
const result = await window.PAGE_AGENT_EXT.execute(
'Search for "page-agent" on GitHub and open the first result',
{
baseURL: 'https://api.openai.com/v1',
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),
onActivity: activity => console.log('Activity:', activity),
onHistoryUpdate: history => console.log('History update:', history),
@@ -170,7 +198,7 @@ console.log(result) // Task execution result`
language="javascript"
/>
<h3 className="text-xl font-semibold mt-6 mb-3">window.dispose()</h3>
<h3 className="text-xl font-semibold mt-6 mb-3">PAGE_AGENT_EXT.dispose()</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4">
{isZh
? '停止当前正在运行的任务。停止后 Agent 可以重新使用。'
@@ -181,49 +209,34 @@ console.log(result) // Task execution result`
code={
isZh
? `// 停止当前任务
window.dispose()`
window.PAGE_AGENT_EXT.dispose()`
: `// Stop current task execution
window.dispose()`
window.PAGE_AGENT_EXT.dispose()`
}
language="javascript"
/>
</section>
{/* LLM Config */}
{/* ExecuteConfig */}
<section>
<h2 className="text-2xl font-bold mb-4">{isZh ? 'LLM 配置' : 'LLM 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>
<h2 className="text-2xl font-bold mb-4">{isZh ? '执行配置' : 'Execute Configuration'}</h2>
<p className="text-gray-600 dark:text-gray-300 mb-4">
{isZh
? '通过 hooks 参数,你可以监听任务执行过程中的各种事件,实现实时更新 UI、日志记录等功能。'
: 'With hooks parameter, you can listen to various events during task execution for real-time UI updates, logging, and more.'}
? 'config 参数包含 LLM 设置、选项和事件回调,用于控制任务执行行为。'
: 'The config parameter includes LLM settings, options, and event callbacks to control task execution behavior.'}
</p>
<CodeEditor
code={
isZh
? `interface ExecuteHooks {
? `interface ExecuteConfig {
baseURL: string // LLM API 端点
apiKey: string // API 密钥
model: string // 模型名称
// 是否将初始标签页包含在任务中,默认 true
includeInitialTab?: boolean
// Agent 状态变化时调用(idle, running, error, completed 等)
onStatusChange?: (status: AgentStatus) => void
@@ -236,7 +249,14 @@ window.dispose()`
// Agent 被停止时调用
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.)
onStatusChange?: (status: AgentStatus) => void
@@ -25,7 +25,7 @@ export default function CustomTools() {
</p>
<CodeEditor
code={`import zod from 'zod'
code={`import * as zod from 'zod'
import { PageAgent, tool } from 'page-agent'
// override internal tool
@@ -1,34 +1,49 @@
import { Fragment } from 'react'
import CodeEditor from '@/components/CodeEditor'
import { useLanguage } from '@/i18n/context'
// Recommended models: lightweight with excellent tool call capabilities
const MODELS = {
recommended: [
'gpt-4.1-mini',
'claude-haiku-4.5',
'gemini-3-flash',
'deepseek-3.2',
'gpt-5.2',
'qwen-3-max',
],
verified: [
'qwen-3-plus',
'gpt-4.1',
'gpt-5',
'gpt-5-mini',
'grok-4',
'grok-code-fast',
'claude-sonnet-3.5',
'claude-sonnet-4.5',
const BASELINE = new Set([
'gpt-5.1',
'claude-haiku-4.5',
'gemini-3-flash',
'deepseek-3.2',
'qwen3-coder-next',
])
// Models grouped by brand, newest first
const MODEL_GROUPS: Record<string, string[]> = {
OpenAI: ['gpt-5.2', 'gpt-5.1', 'gpt-5', 'gpt-5-mini', 'gpt-4.1', 'gpt-4.1-mini'],
Google: ['gemini-3-pro', 'gemini-3-flash', 'gemini-2.5'],
Qwen: ['qwen3-coder-next', 'qwen-3-max', 'qwen-3-plus', 'qwen3:14b (ollama)'],
DeepSeek: ['deepseek-3.2'],
Anthropic: [
'claude-opus-4.6',
'claude-opus-4.5',
'gemini-2.5',
'gemini-3-pro',
'claude-sonnet-4.5',
'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() {
const { isZh } = useLanguage()
const allModels = [...MODELS.recommended, ...MODELS.verified]
return (
<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">
{isZh
? '推荐使用 ToolCall 能力强的轻量级模型。'
: 'Recommended: Lightweight models with strong ToolCall capabilities.'}
: 'Recommended: Fast, lightweight models with strong ToolCall capabilities.'}
</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="flex flex-wrap gap-2">
{allModels.map((model) => {
const isRecommended = MODELS.recommended.includes(model)
return (
<div
key={model}
className={`px-3 py-1.5 rounded-md text-sm font-medium font-mono transition-colors ${
isRecommended
? '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}
{isRecommended && <span className="ml-1"></span>}
<div className="grid grid-cols-[5rem_1fr] gap-x-3 gap-y-3 items-start">
{Object.entries(MODEL_GROUPS).map(([brand, models]) => (
<Fragment key={brand}>
<span className="text-xs font-semibold text-gray-500 dark:text-gray-400 pt-2">
{brand}
</span>
<div className="flex flex-wrap gap-2">
{models.map((model) => (
<ModelBadge key={model} model={model} baseline={BASELINE.has(model)} />
))}
</div>
)
})}
</Fragment>
))}
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-5"> baseline models</p>
</div>
</section>
@@ -136,8 +146,8 @@ const pageAgent = new PageAgent({
// Self-hosted models (e.g., Ollama)
const pageAgent = new PageAgent({
baseURL: 'http://localhost:11434/v1',
apiKey: 'N/A', // Ollama typically accepts any value
model: 'qwen3:latest'
apiKey: 'NA',
model: 'qwen3:14b'
});
// Free testing endpoint
@@ -149,6 +159,51 @@ const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
`}
/>
</section>
{/* Ollama Section */}
<section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">Ollama</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
{isZh
? '已在 Ollama 0.15 + qwen3:14b (RTX3090 24GB) 上测试通过。'
: 'Tested on Ollama 0.15 with qwen3:14b (RTX3090 24GB).'}
</p>
<CodeEditor
code={`LLM_BASE_URL="http://localhost:11434/v1"
LLM_API_KEY="NA"
LLM_MODEL_NAME="qwen3:14b"`}
/>
<div className="mt-4 p-4 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-800">
<h3 className="font-semibold text-amber-900 dark:text-amber-200 mb-2">
{isZh ? '⚠️ 注意事项' : '⚠️ Important Notes'}
</h3>
<ul className="text-sm text-gray-700 dark:text-gray-300 space-y-2 list-disc pl-5">
<li>
{isZh
? '确保 OLLAMA_ORIGINS 设置为 * 以避免 403 错误'
: 'Add * to OLLAMA_ORIGINS to avoid 403 errors'}
</li>
<li>
{isZh
? '小于 10B 参数的模型通常效果不佳'
: 'Models smaller than 10B are unlikely to be strong enough'}
</li>
<li>{isZh ? '需要支持 tool_call 的模型' : 'Requires tool_call capable models'}</li>
<li>
{isZh
? '确保上下文长度大于输入 token 数,否则 Ollama 会静默截断 prompt。普通页面约需 15k token,随步骤增加。默认 4k 上下文长度无法正常工作'
: 'Ensure context length exceeds input tokens, or Ollama will silently truncate prompts. ~15k tokens for a typical page, increases with steps. Default 4k context length will NOT work'}
<ul className="text-sm text-gray-700 dark:text-gray-300 space-y-2 list-disc pl-5">
<li>
<code className="text-xs bg-gray-200 dark:bg-gray-700 px-1 rounded">
$env:OLLAMA_CONTEXT_LENGTH=64000; ollama serve
</code>
</li>
</ul>
</li>
</ul>
</div>
</section>
</div>
)
}
@@ -20,6 +20,7 @@ import Limitations from './introduction/limitations/page'
// Introduction
import Overview from './introduction/overview/page'
import QuickStart from './introduction/quick-start/page'
import Troubleshooting from './introduction/troubleshooting/page'
function DocsPage({ children }: { children: React.ReactNode }) {
return (
@@ -51,6 +52,11 @@ export default function DocsRouter() {
<Limitations />
</DocsPage>
</Route>
<Route path="/introduction/troubleshooting">
<DocsPage>
<Troubleshooting />
</DocsPage>
</Route>
{/* Features */}
<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>
)
}
+1 -1
View File
@@ -8,7 +8,7 @@
"skipLibCheck": true,
"allowJs": true,
// "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
// "baseUrl": "src",
"baseUrl": ".",
"outDir": "dist",