Compare commits

...

66 Commits

Author SHA1 Message Date
Simon cb55daf47d chore(version): bump version to 1.7.1 2026-04-04 01:37:49 +08:00
Simon 43415d3139 feat(website): sticky header 2026-04-04 01:34:54 +08:00
Simon 9cc9edf17e docs: lm studio (#398)
* docs(page): lm studio doc
* docs: update documentation and improve model section
2026-04-04 01:15:41 +08:00
Simon e290e2f67a Merge pull request #396 from alibaba/fix/interactive-aria-attrs-detection
fix(controller): detect elements with ARIA state attrs as interactive
2026-04-03 20:22:09 +08:00
Simon 4272939217 fix(controller): treat interactive with aria as distinct 2026-04-03 20:21:11 +08:00
Lubrsy 148bdb6839 fix: recognize role="listitem" as interactive element (#203)
* fix: recognize role="listitem" as interactive element
* fix: DISTINCT_INTERACTIVE_ROLES

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Simon <10131203+gaomeng1900@users.noreply.github.com>
2026-04-03 20:09:48 +08:00
Simon 68cee4cc64 Merge pull request #395 from alibaba/feat/controller-keep-semantic-tags
feat(controller): add experimental `keepSemanticTags` config
2026-04-03 19:07:36 +08:00
Simon 7e9027167d feat(controller): add keepSemanticTags config to keep the semantic structure of the page 2026-04-03 19:01:54 +08:00
Simon 6630774dc9 Merge pull request #390 from alibaba/fix/controller-improve-scrolling
feat(controller): improve scroll container detection and tool guidance
2026-04-03 17:29:38 +08:00
Simon 0ca1c8de0b feat(controller): consider hasScrollbarSignal when detecting scrollable 2026-04-03 16:09:35 +08:00
Simon 85a33ac1a4 feat(controller): improve scroll action 2026-04-02 22:05:47 +08:00
Simon b8fd1aaebc Merge pull request #389 from alibaba/fix/ext-postmessage-source-guard 2026-04-02 19:06:06 +08:00
Simon 13d952dc9d fix(ext): guard postMessage listeners against iframe sources
Add `e.source !== window` check to both content script and main-world
script message handlers, preventing iframes from injecting or
intercepting extension bridge messages.
2026-04-02 18:53:59 +08:00
Simon 1f2f5a44d3 Merge pull request #332 from mvanhorn/fix/scroll-direction-pixels
fix(page-controller): apply scroll direction to pixels parameter
2026-04-02 18:36:42 +08:00
Simon 8159aa58a6 Merge branch 'main' into fix/scroll-direction-pixels 2026-04-02 18:31:56 +08:00
Simon 2bdb3be81c Merge pull request #356 from lgy2020/fix/aria-attribute-detection
fix(isInteractiveCandidate): use hasAttribute with known aria list to detect aria- attributes
2026-04-02 17:41:36 +08:00
Simon bde630f55d chore(controller): add @edit mark 2026-04-02 17:38:02 +08:00
Simon 3efef0ec42 fix(controller): clean up INTERACTIVE_ARIA_ATTRS 2026-04-02 17:33:27 +08:00
Simon 1141287733 Merge pull request #386 from alibaba/feat/ext-execute-system-instruction
feat(ext): add `systemInstruction` to ExecuteConfig
2026-04-02 16:37:29 +08:00
Simon ec08b05da1 feat(ext): add systemInstruction to ExecuteConfig
Expose a serializable `systemInstruction` string field on the
page-facing ExecuteConfig, mapped to `instructions.system` when
creating MultiPageAgent. Functions cannot cross the postMessage
boundary, so this flat string field replaces the object form.

Closes #359
2026-04-02 16:34:57 +08:00
Simon 5ea2edd13c Merge pull request #384 from Anyexyz/main
fix: extract currentScript outside setTimeout to avoid null pointer
2026-04-02 16:20:17 +08:00
Anyexyz 1999bbeda9 fix: extract currentScript outside setTimeout to avoid null pointer 2026-04-02 16:17:46 +08:00
Simon b3ec571341 Merge pull request #385 from alibaba/docs/website-models-qwen3.6-plus
docs(website): add qwen3.6-plus to models page
2026-04-02 15:33:23 +08:00
Simon d0de8ad838 docs(website): add qwen3.6-plus to models page 2026-04-02 15:30:58 +08:00
Simon 0402a6be03 fix(tabs): error message typo 2026-03-31 21:04:53 +08:00
Simon 6823e04ca9 chore(version): bump version to 1.7.0 2026-03-31 20:45:12 +08:00
Simon 9ae1527201 Merge pull request #378 from alibaba/fix/controller-click-action-robust 2026-03-31 20:41:27 +08:00
Simon 2b20b48dff chore(controller): reuse pointer xy 2026-03-31 20:27:04 +08:00
Simon 296459924a feat(controller): enhance click action with elementFromPoint 2026-03-31 20:02:39 +08:00
Simon 8eee3b27e2 feat(controller): fix SimulatorMast mem leak; add passthrough events 2026-03-31 19:59:57 +08:00
Simon 32d6f0c74b fix(controller): click action robust 2026-03-31 17:41:49 +08:00
Simon 6e4094565a Merge pull request #369 from alibaba/dependabot/github_actions/github-actions-e5be0e1c7a
chore(deps): bump the github-actions group with 2 updates
2026-03-31 15:11:09 +08:00
Simon da2b7b5a8f Merge pull request #370 from alibaba/dependabot/npm_and_yarn/production-dependencies-90c90c5d9d
chore(deps): bump @modelcontextprotocol/sdk from 1.27.1 to 1.29.0 in the production-dependencies group
2026-03-31 15:09:37 +08:00
Simon 3207c3ffc1 Merge pull request #371 from alibaba/dependabot/npm_and_yarn/development-dependencies-003fb0370a 2026-03-31 12:22:35 +08:00
dependabot[bot] 784627a7c1 chore(deps-dev): bump the development-dependencies group with 3 updates
Bumps the development-dependencies group with 3 updates: [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint), [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) and [simple-icons](https://github.com/simple-icons/simple-icons).


Updates `typescript-eslint` from 8.57.2 to 8.58.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.58.0/packages/typescript-eslint)

Updates `lucide-react` from 1.0.1 to 1.7.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.7.0/packages/lucide-react)

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

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.58.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: lucide-react
  dependency-version: 1.7.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: simple-icons
  dependency-version: 16.14.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 20:22:53 +00:00
dependabot[bot] 46d6c02c95 chore(deps): bump @modelcontextprotocol/sdk
Bumps the production-dependencies group with 1 update: [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk).


Updates `@modelcontextprotocol/sdk` from 1.27.1 to 1.29.0
- [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/v1.27.1...v1.29.0)

---
updated-dependencies:
- dependency-name: "@modelcontextprotocol/sdk"
  dependency-version: 1.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 20:21:25 +00:00
dependabot[bot] 6d46719fe8 chore(deps): bump the github-actions group with 2 updates
Bumps the github-actions group with 2 updates: [actions/configure-pages](https://github.com/actions/configure-pages) and [actions/deploy-pages](https://github.com/actions/deploy-pages).


Updates `actions/configure-pages` from 5 to 6
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v5...v6)

Updates `actions/deploy-pages` from 4 to 5
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/configure-pages
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/deploy-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 20:21:07 +00:00
liuguiyuan f2b6c9dfd2 fix: use hasAttribute with known aria list for interactive candidate detection
Replace broken el.hasAttribute("aria-") with a curated list of 27
aria attributes checked via hasAttribute. Each check is O(1).

WAI-ARIA 1.2 defines ~50 aria attributes total per MDN.
Of these ~27 appear on interactive elements such as buttons,
inputs, sliders, and dialogs. The remaining ~20 are structural
container attributes like aria-live, aria-colcount, and
aria-rowspan that only appear on non-interactive containers.
Checking them would not change results.
2026-03-31 00:57:44 +08:00
Simon c465fd8aa4 chore(version): bump version to 1.6.3 2026-03-30 22:29:03 +08:00
Simon 1eef785a61 fix(ext): tabs deduplication 2026-03-30 22:18:55 +08:00
Simon 0b4eb6b49a docs(ext): experimentalIncludeAllTabs 2026-03-30 22:08:09 +08:00
Simon cba54967b8 Merge pull request #365 from alibaba/docs/simplify-contributing
docs: simplify docs
2026-03-30 22:02:11 +08:00
Simon 23dc3b7f7e Merge pull request #363 from alibaba/feat/ext-controll-all-tabs
fix(ext): MultiPageAgent inside content script can not detect new tabs
feat(ext): experimentalIncludeAllTabs - control all window tabs
2026-03-30 21:59:56 +08:00
Simon 49b137981c fix(ext): tab events do not work for content scripts 2026-03-30 21:40:16 +08:00
Simon cad033d63b feat(ext): add experimentalIncludeAllTabs UI 2026-03-30 20:25:04 +08:00
Simon 312952ec41 fix(ext): multi window errors 2026-03-30 20:24:24 +08:00
Simon 52edd78cd4 chore(ext): improve debug logging 2026-03-30 19:48:52 +08:00
Simon a7c9999d83 chore: mv security file 2026-03-28 19:21:20 +08:00
Simon 7f8ddde748 docs: move setup details to dev-guide; simplify readme and contributing 2026-03-28 19:11:54 +08:00
Simon e1fede1194 feat(ext): option to control all tabs 2026-03-27 20:18:13 +08:00
Simon 2322d6c46b docs: add awesome section 2026-03-27 19:32:04 +08:00
Simon ac72f55731 docs: simplify readme 2026-03-27 19:23:31 +08:00
Simon 5485e852bd Merge pull request #362 from 1245040330/fix-typing-animation
feat(extension): 禁用 EmptyState 组件的自动开始动画
2026-03-27 15:50:09 +08:00
1245040330 48fb65d00b feat(extension): 禁用 EmptyState 组件的自动开始动画
- 在 EmptyState 组件的动画配置中添加了 startOnView=false 属性
- 防止动画在视图加载时自动开始播放
- 优化了用户体验,避免不必要的动画干扰
2026-03-27 15:26:13 +08:00
Simon 75bc69c0c0 chore(version): bump version to 1.6.2 2026-03-25 01:05:20 +08:00
Simon 0d9b9e4b9a Merge pull request #341 from alibaba/dependabot/npm_and_yarn/development-dependencies-adf3994b6c
chore(deps-dev): bump the development-dependencies group with 7 updates
2026-03-25 00:56:05 +08:00
Simon 78a31ef5d1 fix: tslint 2026-03-25 00:50:29 +08:00
dependabot[bot] 98005e323a chore(deps-dev): bump the development-dependencies group with 7 updates
Bumps the development-dependencies group with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.2.1` | `4.2.2` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.57.1` | `8.57.2` |
| [@types/chrome](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/chrome) | `0.1.37` | `0.1.38` |
| [motion](https://github.com/motiondivision/motion) | `12.37.0` | `12.38.0` |
| [simple-icons](https://github.com/simple-icons/simple-icons) | `16.12.0` | `16.13.0` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.2.1` | `4.2.2` |
| [wxt](https://github.com/wxt-dev/wxt) | `0.20.19` | `0.20.20` |


Updates `@tailwindcss/vite` from 4.2.1 to 4.2.2
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/@tailwindcss-vite)

Updates `typescript-eslint` from 8.57.1 to 8.57.2
- [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.57.2/packages/typescript-eslint)

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

Updates `motion` from 12.37.0 to 12.38.0
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.37.0...v12.38.0)

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

Updates `tailwindcss` from 4.2.1 to 4.2.2
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/tailwindcss)

Updates `wxt` from 0.20.19 to 0.20.20
- [Release notes](https://github.com/wxt-dev/wxt/releases)
- [Commits](https://github.com/wxt-dev/wxt/compare/wxt-v0.20.19...wxt-v0.20.20)

---
updated-dependencies:
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.57.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@types/chrome"
  dependency-version: 0.1.38
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: motion
  dependency-version: 12.38.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: simple-icons
  dependency-version: 16.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: tailwindcss
  dependency-version: 4.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: wxt
  dependency-version: 0.20.20
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-24 16:28:24 +00:00
Simon f3c253c044 Merge pull request #340 from alibaba/dependabot/npm_and_yarn/production-dependencies-699df778d8
chore(deps): bump ws from 8.19.0 to 8.20.0 in the production-dependencies group
2026-03-25 00:25:53 +08:00
Simon 02f63ec04d Merge pull request #342 from alibaba/dependabot/npm_and_yarn/lucide-react-1.0.1
chore(deps-dev): bump lucide-react from 0.577.0 to 1.0.1
2026-03-25 00:25:41 +08:00
Ryanba 583f757f0a fix(ui): set task input max length to 1000 (#292) 2026-03-25 00:23:59 +08:00
Simon 53c9149d06 docs: add maintainer note 2026-03-24 17:42:56 +08:00
dependabot[bot] 64a90e3b3a chore(deps-dev): bump lucide-react from 0.577.0 to 1.0.1
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.577.0 to 1.0.1.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.0.1/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-23 19:17:32 +00:00
dependabot[bot] abfa903c9c chore(deps): bump ws in the production-dependencies group
Bumps the production-dependencies group with 1 update: [ws](https://github.com/websockets/ws).


Updates `ws` from 8.19.0 to 8.20.0
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.19.0...8.20.0)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 8.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-23 19:15:07 +00:00
Simon 6dd0ceab73 chore: README.md 2026-03-22 12:49:10 +08:00
Matt Van Horn 005bc8ab44 fix(page-controller): apply scroll direction to pixels parameter
Two bugs in the scroll direction logic:

1. Vertical scroll with `pixels` ignores the `down` boolean because the
   `??` operator bypasses the direction multiplier when pixels is provided.
   Fix: move the direction multiplier outside the `??` so it applies to
   both the pixels and numPages paths.

2. Horizontal scroll with `pixels` applies direction twice - once in
   PageController.ts and again in actions.ts - causing a double negation
   that reverses the intended direction. Fix: remove the redundant
   direction logic from actions.ts since PageController already signs
   the scroll amount.

Also removes the now-unused `down` and `right` parameters from the
scrollVertically() and scrollHorizontally() action functions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:38:32 -07:00
49 changed files with 1176 additions and 830 deletions
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
run: npm run build:website
- name: Setup Pages
uses: actions/configure-pages@v5
uses: actions/configure-pages@v6
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
@@ -37,4 +37,4 @@ jobs:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
+13 -171
View File
@@ -2,42 +2,13 @@
♥️ We welcome contributions from everyone.
## 🚀 Quick Start
### Development Setup
1. **Prerequisites**
- `macOS` / `Linux` / `WSL`
- `node.js >= 20` with `npm >= 10`
- An editor that supports `ts/eslint/prettier`
- Make sure `eslint`, `prettier` and `commitlint` work well. Un-linted code won't pass the CI.
2. **Setup**
```bash
npm i
npm start # Start demo and documentation site
npm run build # Build libs and website
```
### Project Structure
This is a **monorepo** with npm workspaces containing **4 main packages**:
- **Page Agent** (`packages/page-agent/`) - Main entry with built-in UI Panel, published as `page-agent` on npm
- **Core** (`packages/core/`) - Core agent logic without UI (npm: `@page-agent/core`)
- **Extension** (`packages/extension/`) - Chrome extension for multi-page tasks and browser-level automation
- **Website** (`packages/website/`) - React documentation and landing page. Also as demo and test page for the core lib. private package `@page-agent/website`
> We use a simplified monorepo solution with `native npm-workspace + ts reference + vite alias`. No fancy tooling. Hoisting is required.
>
> - When developing. Use alias so that we don't have to pre-build.
> - When bundling. Use external and disable ts `paths` alias.
> - When bundling `IIFE` and `Website`. Bundle everything together.
For local development workflows, setup, local LLM config, extension development, testing on other websites, and more details, see [docs/developer-guide.md](docs/developer-guide.md).
## 🤝 How to Contribute
### Reporting Issues
> **[Maintainer's Note](https://github.com/alibaba/page-agent/issues/349)**
### Opening Issues
- Use the GitHub issue tracker to report bugs or request features
- Search existing issues before creating new ones
@@ -46,147 +17,24 @@ This is a **monorepo** with npm workspaces containing **4 main packages**:
### Code Contributions
1. **Fork and Clone**
```bash
git clone https://github.com/your-username/page-agent.git
cd page-agent
```
2. **Create Feature Branch**
```bash
git checkout -b feat/your-feature-name
```
3. **Make Changes**
- Follow existing code style and patterns
- Add tests for new functionality
- Update documentation as needed
4. **Test Your Changes**
- Build and lint everything.
- Test in our demo website
- Test it on other websites if applicable
- `@TODO: test suite`
5. **Commit and Push**
```bash
git add .
git commit -m "feat: add awesome feature"
git push origin feat/your-feature-name
```
6. **Create Pull Request**
- Provide clear description of changes
- Link related issues
- Include screenshots for UI changes
## 📝 Code Style
### General Guidelines
- Use TypeScript for type safety
- Follow existing naming conventions
- Write meaningful commit messages
- Keep functions small and focused
- Add JSDoc for public APIs
1. Follow existing code style and patterns
2. Update documentation as needed
3. Add JSDoc for public APIs
4. Build and lint everything
5. Test in our demo website, and on other websites if applicable
6. Include screenshots for UI changes
### Vibe Coding with AI
> [Vibe coding](https://en.wikipedia.org/wiki/Vibe_coding)
- Vibe coding is **NOT** allowed for the core lib or the extension!!!
- Vibe coding is **RECOMMENDED** when maintaining **the demo, the website, the UI and tests**.
- We have a [website/AGENTS.md](packages/website/AGENTS.md) for that.
- Vibe coding is **NOT** allowed for the core lib!!!
- NEVER try to vibe coding the MV3 extension!!! It is HELL.
- Make sure your AI references `AGENTS.md` and `website/AGENTS.md` for better quality.
- Review anything AI wrote before make a commit. You are the author of anything you commit. NOT AI.
If your AI assistant does not support [AGENTS.md](https://agents.md/). Add an alias for it:
- claude-code (`CLAUDE.md`)
```markdown
@AGENTS.md
```
- antigravity (`.agent/rules/alias.md`)
```markdown
---
trigger: always_on
---
@../../AGENTS.md
```
## 🔧 Development Workflows
### Test With Your Own LLM API
- Create a `.env` file in the repo root with your LLM API config
```env
LLM_MODEL_NAME=gpt-5.2
LLM_API_KEY=your-api-key
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"
```
> @see https://alibaba.github.io/page-agent/docs/features/models#ollama for configuration
- **Restart the dev server** to load new env vars
- If not provided, the demo will use the free testing proxy by default. By using it, you agree to its [terms](https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md).
### Extension Development
```bash
# make sure you ran `npm run build:libs` first
# and every time you changed the core libs
npm run dev -w @page-agent/ext
npm run zip -w @page-agent/ext
```
- Update `packages/extension/docs/extension_api.md` for API integration details
### Testing on Other Websites
- Start and serve a local `iife` script
```bash
npm run dev:demo # Serving IIFE with auto rebuild at http://localhost:5174/page-agent.demo.js
```
- Add a new bookmark
```javascript
javascript:(function(){var s=document.createElement('script');s.src=`http://localhost:5174/page-agent.demo.js?t=${Math.random()}`;s.onload=()=>console.log(%27PageAgent ready!%27);document.head.appendChild(s);})();
```
- Click the bookmark on any page to load Page-Agent
> Warning: AK in your local `.env` will be inlined in the iife script. Be very careful when you distribute the script.
### Adding Documentation
Ask an AI to help you add documentation to the `website/` package. Follow the existing style.
> Our AGENTS.md file and guardrails are designed for this purpose. But please be careful and review anything AI generated.
## 🚫 What We Don't Accept
- Breaking changes and large PRs without prior discussion
- Heavy dependencies to core libs
- Contributions without proper testing
- Code that doesn't follow project conventions
- Dependencies or code with licenses incompatible with MIT
- Bot or AI-generated pull requests without meaningful human involvement
@@ -194,12 +42,6 @@ Ask an AI to help you add documentation to the `website/` package. Follow the ex
By contributing to this project, you agree that your contributions will be licensed under the MIT License.
> CLA is optional.
## 💬 Questions?
- Open a GitHub issue for technical questions
- Check existing documentation and issues first
- Be respectful and constructive in discussions
---
Thank you for helping make PageAgent better! 🎉
+22 -22
View File
@@ -26,14 +26,15 @@ The GUI Agent Living in Your Webpage. Control web interfaces with natural langua
- No screenshots. No multi-modal LLMs or special permissions needed.
- **🧠 Bring your own LLMs**
- **🐙 Optional [chrome extension](https://alibaba.github.io/page-agent/docs/features/chrome-extension) for multi-page tasks.**
- [MCP Server (Beta)](https://alibaba.github.io/page-agent/docs/features/mcp-server)
- And an [MCP Server (Beta)](https://alibaba.github.io/page-agent/docs/features/mcp-server) to control it from outside
## 💡 Use Cases
- **SaaS AI Copilot** — Ship an AI copilot in your product in lines of code. No backend rewrite.
- **Smart Form Filling** — Turn 20-click workflows into one sentence. Perfect for ERP, CRM, and admin systems.
- **Accessibility** — Make any web app accessible through natural language. Voice commands, screen readers, zero barrier.
- **Multi-page Agent** — Extend your own agent's reach across browser tabs with the optional [chrome extension](https://alibaba.github.io/page-agent/docs/features/chrome-extension).
- **Multi-page Agent** — Extend your own web agent's reach across browser tabs [chrome extension](https://alibaba.github.io/page-agent/docs/features/chrome-extension).
- **MCP** - Allow your agent clients to control your browser.
## 🚀 Quick Start
@@ -49,8 +50,8 @@ Fastest way to try PageAgent with our free Demo LLM:
| Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.6.1/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.6.1/files/dist/iife/page-agent.demo.js |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.7.1/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.7.1/files/dist/iife/page-agent.demo.js |
### NPM Installation
@@ -73,13 +74,27 @@ await agent.execute('Click the login button')
For more programmatic usage, see [📖 Documentations](https://alibaba.github.io/page-agent/docs/introduction/overview).
## 🌟 Awesome Page Agent
Built something cool with PageAgent? Add it here! Open a PR to share your project.
> These are community projects — not maintained or endorsed by us. Use at your own discretion.
| Project | Description |
| -------- | ----------------------------------------------------------- |
| _Yours?_ | [Open a PR](https://github.com/alibaba/page-agent/pulls) 🙌 |
## 🤝 Contributing
We welcome contributions from the community! Follow our instructions in [CONTRIBUTING.md](CONTRIBUTING.md) for setup and guidelines.
We welcome contributions from the community! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines and [docs/developer-guide.md](docs/developer-guide.md) for local development workflows.
Please read [Code of Conduct](docs/CODE_OF_CONDUCT.md) before contributing.
Please read the [maintainer's note](https://github.com/alibaba/page-agent/issues/349) on principles and current state.
Contributions generated entirely by bots or agents without substantial human involvement will not be accepted, and bot accounts may be blocked.
Contributions generated entirely by **bots or AI** without substantial human involvement will **not be accepted**.
## ⚖️ License
[MIT License](LICENSE)
## 👏 Acknowledgments
@@ -97,23 +112,8 @@ Licensed under the MIT License
We gratefully acknowledge the browser-use project and its contributors for their
excellent work on web automation and DOM interaction patterns that helped make
this project possible.
Third-party dependencies and their licenses can be found in the package.json
file and in the node_modules directory after installation.
```
## 📄 License
[MIT License](LICENSE)
---
**⭐ Star this repo if you find PageAgent helpful!**
<a href="https://www.star-history.com/?repos=alibaba%2Fpage-agent&type=date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&theme=dark&legend=top-left&v=7" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&legend=top-left&v=7" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&legend=top-left&v=7" />
</picture>
</a>
+10 -17
View File
@@ -32,7 +32,8 @@
- **SaaS AI Copilot** — 几行代码为你的产品加上 AI 副驾驶,无需重写后端。
- **智能表单填写** — 把 20 次点击变成一句话。ERP、CRM、管理后台的最佳拍档。
- **无障碍增强** — 用自然语言让任何网页无障碍。语音指令、屏幕阅读器,零门槛。
- **跨页面 Agent** — 通过可选的 [Chrome 扩展](https://alibaba.github.io/page-agent/docs/features/chrome-extension),让你自己的 Agent 跨标签页工作。
- **跨页面 Agent** — 通过可选的 [Chrome 扩展](https://alibaba.github.io/page-agent/docs/features/chrome-extension),让你自己的 Web Agent 跨标签页工作。
- 通过 MCP 为现有 Agent 加入浏览器控制能力。
## 🚀 快速开始
@@ -48,8 +49,8 @@
| Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.6.1/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.6.1/files/dist/iife/page-agent.demo.js |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.7.1/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.7.1/files/dist/iife/page-agent.demo.js |
### NPM 安装
@@ -74,11 +75,13 @@ await agent.execute('点击登录按钮')
## 🤝 贡献
欢迎社区贡献!请参阅 [CONTRIBUTING.md](../CONTRIBUTING.md) 了解安装与贡献指南。请在贡献前阅读[行为准则](CODE_OF_CONDUCT.md)。
欢迎社区贡献!请参阅 [CONTRIBUTING.md](../CONTRIBUTING.md) 了解安装与贡献指南。
我们不接受未经实质性人类参与、完全由 Bot 或 Agent 自动生成的代码,机器人账号可能被禁止参与互动
提交 issue 或 PR 之前,请先阅读[作者声明](https://github.com/alibaba/page-agent/issues/349)和[行为准则](CODE_OF_CONDUCT.md)
## 👏 致谢
我们不接受未经实质性人类参与、完全由 Bot 或 Agent 自动生成的代码。
## 👏 声明与致谢
本项目基于 **[`browser-use`](https://github.com/browser-use/browser-use)** 的优秀工作构建。
@@ -94,12 +97,9 @@ Licensed under the MIT License
We gratefully acknowledge the browser-use project and its contributors for their
excellent work on web automation and DOM interaction patterns that helped make
this project possible.
Third-party dependencies and their licenses can be found in the package.json
file and in the node_modules directory after installation.
```
## 📄 许可证
## ⚖️ 许可证
[MIT License](../LICENSE)
@@ -107,10 +107,3 @@ file and in the node_modules directory after installation.
**⭐ 如果觉得 PageAgent 有用或有趣,请给项目点个星!**
<a href="https://www.star-history.com/?repos=alibaba%2Fpage-agent&type=date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&theme=dark&legend=top-left&v=7" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&legend=top-left&v=7" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&legend=top-left&v=7" />
</picture>
</a>
View File
+117
View File
@@ -0,0 +1,117 @@
# Developer Guide
This file is for local development workflows.
For contribution rules and expectations, see [../CONTRIBUTING.md](../CONTRIBUTING.md).
## 🚀 Quick Start
### Development Setup
1. **Prerequisites**
- `macOS` / `Linux` / `WSL`
- `node.js >= 20` with `npm >= 10`
- An editor that supports `ts/eslint/prettier`
- Make sure `eslint`, `prettier` and `commitlint` work well. Un-linted code won't pass the CI.
2. **Setup**
```bash
npm i
npm start # Start demo and documentation site
npm run build # Build libs and website
```
## 📦 Project Structure
This is a **monorepo** with npm workspaces containing **4 main packages**:
- **Page Agent** (`packages/page-agent/`) - Main entry with built-in UI Panel, published as `page-agent` on npm
- **Core** (`packages/core/`) - Core agent logic without UI (npm: `@page-agent/core`)
- **Extension** (`packages/extension/`) - Chrome extension for multi-page tasks and browser-level automation
- **Website** (`packages/website/`) - React documentation and landing page. Also as demo and test page for the core lib. private package `@page-agent/website`
> We use a simplified monorepo solution with `native npm-workspace + ts reference + vite alias`. No fancy tooling. Hoisting is required.
>
> - When developing. Use alias so that we don't have to pre-build.
> - When bundling. Use external and disable ts `paths` alias.
> - When bundling `IIFE` and `Website`. Bundle everything together.
## 🤖 AGENTS.md Alias
If your AI assistant does not support [AGENTS.md](https://agents.md/). Add an alias for it:
- claude-code (`CLAUDE.md`)
```markdown
@AGENTS.md
```
- antigravity (`.agent/rules/alias.md`)
```markdown
---
trigger: always_on
---
@../../AGENTS.md
```
## 🔧 Development Workflows
### Test With Your Own LLM API
- Create a `.env` file in the repo root with your LLM API config
```env
LLM_MODEL_NAME=gpt-5.2
LLM_API_KEY=your-api-key
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"
```
> @see https://alibaba.github.io/page-agent/docs/features/models#ollama for configuration
- **Restart the dev server** to load new env vars
- If not provided, the demo will use the free testing proxy by default. By using it, you agree to its [terms](./terms-and-privacy.md).
### Extension Development
```bash
# make sure you ran `npm run build:libs` first and every time you changed the core libs
npm run dev -w @page-agent/ext
npm run zip -w @page-agent/ext
```
- Update `packages/extension/docs/extension_api.md` for API integration details
### Testing on Other Websites
- Start and serve a local `iife` script
```bash
npm run dev:demo # Serving IIFE with auto rebuild at http://localhost:5174/page-agent.demo.js
```
- Add a new bookmark
```javascript
javascript:(function(){var s=document.createElement('script');s.src=`http://localhost:5174/page-agent.demo.js?t=${Math.random()}`;s.onload=()=>console.log(%27PageAgent ready!%27);document.head.appendChild(s);})();
```
- Click the bookmark on any page to load Page-Agent
> Warning: AK in your local `.env` will be inlined in the iife script. Be very careful when you distribute the script.
### Adding Documentation
Ask an AI to help you add documentation to the `website/` package. Follow the existing style.
> Our AGENTS.md file and guardrails are designed for this purpose. But please be careful and review anything AI generated.
+260 -260
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "root",
"private": true,
"version": "1.6.1",
"version": "1.7.1",
"type": "module",
"workspaces": [
"packages/page-controller",
@@ -42,7 +42,7 @@
"@commitlint/config-conventional": "^20.5.0",
"@eslint/js": "^9.39.2",
"@microsoft/api-extractor": "^7.57.7",
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/vite": "^4.2.2",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^25.5.0",
"@vitejs/plugin-react-swc": "^4.3.0",
@@ -60,7 +60,7 @@
"lint-staged": "^16.4.0",
"prettier": "^3.8.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.1",
"typescript-eslint": "^8.58.0",
"unplugin-dts": "^1.0.0-beta.6",
"vite": "^7.3.1",
"vite-plugin-css-injected-by-js": "^4.0.1",
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@page-agent/core",
"private": false,
"version": "1.6.1",
"version": "1.7.1",
"type": "module",
"main": "./dist/esm/page-agent-core.js",
"module": "./dist/esm/page-agent-core.js",
@@ -44,8 +44,8 @@
},
"dependencies": {
"chalk": "^5.6.2",
"@page-agent/llms": "1.6.1",
"@page-agent/page-controller": "1.6.1"
"@page-agent/llms": "1.7.1",
"@page-agent/page-controller": "1.7.1"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
+3 -2
View File
@@ -131,7 +131,8 @@ tools.set(
tools.set(
'scroll',
tool({
description: 'Scroll the page vertically. Use index for scroll elements (dropdowns/custom UI).',
description:
'Scroll vertically. Without index: scrolls the document. With index: scrolls the container at that index (or its nearest scrollable ancestor). Use index of a data-scrollable element to scroll a specific area.',
inputSchema: z.object({
down: z.boolean().default(true),
num_pages: z.number().min(0).max(10).optional().default(0.1),
@@ -155,7 +156,7 @@ tools.set(
'scroll_horizontally',
tool({
description:
'Scroll the page horizontally, or within a specific element by index. Useful for wide tables.',
'Scroll horizontally. Without index: scrolls the document. With index: scrolls the container at that index (or its nearest scrollable ancestor). Use index of a data-scrollable element to scroll a specific area.',
inputSchema: z.object({
right: z.boolean().default(true),
pixels: z.number().int().min(0),
+13
View File
@@ -118,9 +118,18 @@ export interface ExecuteConfig {
model: string
apiKey?: string
// Global system-level instructions for the agent.
// Equivalent to AgentConfig.instructions.system.
systemInstruction?: string
// Include the initial tab where page JS starts. Default: true.
includeInitialTab?: boolean
// Control all unpinned tabs in the window instead of only the tab group.
// When enabled, agent sees and can switch to every non-pinned tab.
// Default: false. Experimental.
experimentalIncludeAllTabs?: boolean
onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
@@ -207,7 +216,11 @@ interface ExecuteConfig {
baseURL: string
model: string
apiKey?: string
systemInstruction?: string
includeInitialTab?: boolean
experimentalIncludeAllTabs?: boolean
onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
+10 -10
View File
@@ -1,7 +1,7 @@
{
"name": "@page-agent/ext",
"private": true,
"version": "1.6.1",
"version": "1.7.1",
"type": "module",
"scripts": {
"dev": "wxt",
@@ -16,31 +16,31 @@
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@types/chrome": "^0.1.37",
"@types/chrome": "^0.1.38",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.1",
"@wxt-dev/module-react": "^1.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"idb": "^8.0.3",
"lucide-react": "^0.577.0",
"motion": "^12.37.0",
"lucide-react": "^1.7.0",
"motion": "^12.38.0",
"next-themes": "^0.4.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"rough-notation": "^0.5.1",
"simple-icons": "^16.12.0",
"simple-icons": "^16.14.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0",
"wxt": "^0.20.19"
"wxt": "^0.20.20"
},
"dependencies": {
"@page-agent/core": "1.6.1",
"@page-agent/llms": "1.6.1",
"@page-agent/page-controller": "1.6.1",
"@page-agent/ui": "1.6.1",
"@page-agent/core": "1.7.1",
"@page-agent/llms": "1.7.1",
"@page-agent/page-controller": "1.7.1",
"@page-agent/ui": "1.7.1",
"ai-motion": "^0.4.8",
"chalk": "^5.6.2"
},
@@ -11,13 +11,18 @@ function detectLanguage(): 'en-US' | 'zh-CN' {
return lang.startsWith('zh') ? 'zh-CN' : 'en-US'
}
interface MultiPageAgentConfig extends AgentConfig {
includeInitialTab?: boolean
experimentalIncludeAllTabs?: boolean
}
/**
* MultiPageAgent
* - use with extension
* - can be used from a side panel or a content script
*/
export class MultiPageAgent extends PageAgentCore {
constructor(config: AgentConfig & { includeInitialTab?: boolean }) {
constructor(config: MultiPageAgentConfig) {
// multi page controller
const tabsController = new TabsController()
const pageController = new RemotePageController(tabsController)
@@ -31,8 +36,8 @@ export class MultiPageAgent extends PageAgentCore {
`Default working language: **${targetLanguage}**`
)
// include initial tab for controlling
const includeInitialTab = config.includeInitialTab ?? true
const experimentalIncludeAllTabs = config.experimentalIncludeAllTabs ?? false
/**
* When the agent is in side-panel and user closed the side-panel.
@@ -50,7 +55,7 @@ export class MultiPageAgent extends PageAgentCore {
customSystemPrompt: systemPrompt,
onBeforeTask: async (agent) => {
await tabsController.init(agent.task, includeInitialTab)
await tabsController.init(agent.task, { includeInitialTab, experimentalIncludeAllTabs })
heartBeatInterval = window.setInterval(() => {
chrome.storage.local.set({
@@ -10,9 +10,7 @@ export function handlePageControlMessage(
): true | undefined {
const PREFIX = '[RemotePageController.background]'
function debug(...messages: any[]) {
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`)
const { action, payload, targetTabId } = message
@@ -19,7 +19,10 @@ export function initPageController() {
function getPC(): PageController {
if (!pageController) {
pageController = new PageController({ enableMask: false, viewportExpansion: 400 })
pageController = new PageController({
enableMask: false,
viewportExpansion: 400,
})
}
return pageController
}
@@ -4,9 +4,7 @@ import type { TabsController } from './TabsController'
const PREFIX = '[RemotePageController]'
function debug(...messages: any[]) {
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`)
function sendMessage(message: {
type: 'PAGE_CONTROL'
@@ -5,9 +5,7 @@ import type { TabAction } from './TabsController'
const PREFIX = '[TabsController.background]'
function debug(...messages: any[]) {
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`)
export function handleTabControlMessage(
message: { type: 'TAB_CONTROL'; action: TabAction; payload: any },
@@ -20,11 +18,10 @@ export function handleTabControlMessage(
case 'get_active_tab': {
debug('get_active_tab')
chrome.tabs
.query({ active: true, currentWindow: true })
.query({ active: true })
.then((tabs) => {
const tabId = tabs.length > 0 ? tabs[0].id || null : null
debug('get_active_tab: success', tabId)
sendResponse({ success: true, tabId })
debug('get_active_tab: success', tabs)
sendResponse({ success: true, tab: tabs[0] })
})
.catch((error) => {
sendResponse({ error: error instanceof Error ? error.message : String(error) })
@@ -63,7 +60,7 @@ export function handleTabControlMessage(
case 'create_tab_group': {
debug('create_tab_group', payload)
chrome.tabs
.group({ tabIds: payload.tabIds })
.group({ tabIds: payload.tabIds, createProperties: { windowId: payload.windowId } })
.then((groupId) => {
debug('create_tab_group: success', groupId)
sendResponse({ success: true, groupId })
@@ -114,47 +111,59 @@ export function handleTabControlMessage(
return true // async response
}
case 'get_window_tabs': {
debug('get_window_tabs', payload)
chrome.tabs
.query({ windowId: payload.windowId })
.then((tabs) => {
sendResponse({ success: true, tabs })
})
.catch((error) => {
sendResponse({ error: error instanceof Error ? error.message : String(error) })
})
return true
}
default:
sendResponse({ error: `Unknown action: ${action}` })
return
}
}
export function setupTabChangeEvents() {
console.log('[TabsController.background] setupTabChangeEvents')
const tabEventPorts = new Set<chrome.runtime.Port>()
function broadcastTabEvent(message: object) {
for (const port of tabEventPorts) {
port.postMessage(message)
}
}
/**
* Port-based tab events: agents connect via `chrome.runtime.connect({ name: 'tab-events' })`
* and receive tab change events through the port. Works for both extension pages and content scripts.
*/
export function setupTabEventsPort() {
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== 'tab-events') return
debug('port connected', port.sender?.tab?.id ?? port.sender?.url)
tabEventPorts.add(port)
port.onDisconnect.addListener(() => {
debug('port disconnected')
tabEventPorts.delete(port)
})
})
chrome.tabs.onCreated.addListener((tab) => {
debug('onCreated', tab)
chrome.runtime
.sendMessage({ type: 'TAB_CHANGE', action: 'created', payload: { tab } })
.catch((error) => {
debug('onCreated error:', error)
})
broadcastTabEvent({ action: 'created', payload: { tab } })
})
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)
})
broadcastTabEvent({ action: 'removed', payload: { tabId, removeInfo } })
})
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)
})
broadcastTabEvent({ action: 'updated', payload: { tabId, changeInfo, tab } })
})
}
+137 -66
View File
@@ -2,9 +2,7 @@ import { isContentScriptAllowed } from './RemotePageController'
const PREFIX = '[TabsController]'
function debug(...messages: any[]) {
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`)
function sendMessage(message: {
type: 'TAB_CONTROL'
@@ -22,46 +20,91 @@ function sendMessage(message: {
* - live in the agent env (extension page or content script)
* - no chrome apis. call sw for tab operations
*/
export class TabsController extends EventTarget {
export class TabsController {
currentTabId: number | null = null
private disposed = false
private port: chrome.runtime.Port | null = null
private portRetries = 0
private windowId: number | null = null
private tabs: TabMeta[] = []
private initialTabId: number | null = null
private tabGroupId: number | null = null
private experimentalIncludeAllTabs = false
private task: string = ''
async init(task: string, includeInitialTab: boolean = true) {
debug('init', task, includeInitialTab)
async init(task: string, options: TabsInitOptions = {}) {
const { includeInitialTab = true, experimentalIncludeAllTabs = false } = options
debug('init', task, options)
if (this.disposed) {
throw new Error('TabsController already disposed')
}
this.task = task
this.tabs = []
this.currentTabId = null
this.disposed = false
this.port = null
this.portRetries = 0
this.windowId = null
this.tabs = []
this.tabGroupId = null
this.initialTabId = null
this.experimentalIncludeAllTabs = experimentalIncludeAllTabs
this.task = task
const result = await sendMessage({
const activeTabResult = await sendMessage({
type: 'TAB_CONTROL',
action: 'get_active_tab',
})
this.initialTabId = result.tabId
this.initialTabId = activeTabResult.tab?.id
this.windowId = activeTabResult.tab?.windowId
if (!this.initialTabId) {
throw new Error('Failed to get initial tab ID')
if (!this.initialTabId || !this.windowId) {
if (activeTabResult.error) {
throw new Error(activeTabResult.error)
} else {
throw new Error('Failed to get active tab')
}
}
if (includeInitialTab) {
this.connectTabEvents()
if (experimentalIncludeAllTabs) {
const allTabs = await sendMessage({
type: 'TAB_CONTROL',
action: 'get_window_tabs',
payload: { windowId: this.windowId },
})
for (const tab of allTabs.tabs as chrome.tabs.Tab[]) {
if (tab.id && !tab.pinned && isContentScriptAllowed(tab.url)) {
this.addTab({
id: tab.id,
isInitial: tab.id === this.initialTabId,
url: tab.url,
title: tab.title,
status: tab.status,
})
}
}
if (this.tabs.find((t) => t.id === this.initialTabId)) {
this.currentTabId = this.initialTabId
await this.createTabGroup([this.initialTabId])
}
} else if (includeInitialTab) {
const info = await sendMessage({
type: 'TAB_CONTROL',
action: 'get_tab_info',
payload: { tabId: this.initialTabId },
})
if (isContentScriptAllowed(info.url)) {
if (isContentScriptAllowed(info.url) && !info.pinned) {
this.currentTabId = this.initialTabId
this.tabs.push({
id: result.tabId,
this.addTab({
id: this.initialTabId,
isInitial: true,
url: info.url,
title: info.title,
@@ -73,52 +116,6 @@ export class TabsController extends EventTarget {
}
await this.updateCurrentTabId(this.currentTabId)
const tabChangeHandler = (message: any): void => {
if (message.type !== 'TAB_CHANGE') {
// throw new Error(`[TabsController]: Invalid message type: ${message.type}`)
return
}
if (message.action === 'created') {
const tab = message.payload.tab as chrome.tabs.Tab
if (tab.groupId === this.tabGroupId && tab.id != null) {
// Tab created in our controlled group
if (!this.tabs.find((t) => t.id === tab.id)) {
this.tabs.push({ id: tab.id, isInitial: false })
}
this.switchToTab(tab.id)
}
} else if (message.action === 'removed') {
const { tabId } = message.payload as { tabId: number }
const targetTab = this.tabs.find((t) => t.id === tabId)
if (targetTab) {
this.tabs = this.tabs.filter((t) => t.id !== tabId)
if (this.currentTabId === tabId) {
const newCurrentTab = this.tabs[this.tabs.length - 1] || null
if (newCurrentTab) {
this.switchToTab(newCurrentTab.id)
} else {
this.updateCurrentTabId(null)
}
}
}
} 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
}
}
}
chrome.runtime.onMessage.addListener(tabChangeHandler)
this.addEventListener('dispose', () => {
chrome.runtime.onMessage.removeListener(tabChangeHandler)
})
}
async openNewTab(url: string): Promise<string> {
@@ -136,7 +133,7 @@ export class TabsController extends EventTarget {
const tabId = result.tabId as number
this.tabs.push({
this.addTab({
id: tabId,
isInitial: false,
})
@@ -209,7 +206,7 @@ export class TabsController extends EventTarget {
const result = await sendMessage({
type: 'TAB_CONTROL',
action: 'create_tab_group',
payload: { tabIds },
payload: { tabIds, windowId: this.windowId },
})
if (!result?.success) {
@@ -232,6 +229,11 @@ export class TabsController extends EventTarget {
})
}
private addTab(meta: TabMeta) {
if (this.tabs.find((t) => t.id === meta.id)) return
this.tabs.push(meta)
}
async updateCurrentTabId(tabId: number | null) {
debug('updateCurrentTabId', tabId)
@@ -288,9 +290,77 @@ export class TabsController extends EventTarget {
await waitUntil(() => tab.status === 'complete', 4_000)
}
dispose() {
this.dispatchEvent(new Event('dispose'))
/**
* Connect to background SW via port to receive tab change events.
*
* @note Port is 1:1 (runtime.connect → background SW has no frames),
* so onDisconnect fires exactly once and we can safely reconnect.
* Reconnection may miss events during the gap.
* TODO: refresh this.tabs from background after reconnect to stay consistent.
*/
private connectTabEvents() {
this.port = chrome.runtime.connect({ name: 'tab-events' })
this.port.onMessage.addListener((message: any) => {
if (this.disposed) return
this.portRetries = 0
if (message.action === 'created') {
const tab = message.payload.tab as chrome.tabs.Tab
const shouldTrack = this.experimentalIncludeAllTabs || tab.groupId === this.tabGroupId
if (shouldTrack && tab.id != null) {
this.addTab({ id: tab.id, isInitial: false })
this.switchToTab(tab.id)
}
} else if (message.action === 'removed') {
const { tabId } = message.payload as { tabId: number }
const targetTab = this.tabs.find((t) => t.id === tabId)
if (targetTab) {
this.tabs = this.tabs.filter((t) => t.id !== tabId)
if (this.currentTabId === tabId) {
const newCurrentTab = this.tabs[this.tabs.length - 1] || null
if (newCurrentTab) {
this.switchToTab(newCurrentTab.id)
} else {
this.updateCurrentTabId(null)
}
}
}
} 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
}
}
})
this.port.onDisconnect.addListener(() => {
this.port = null
if (this.disposed) return
if (this.portRetries >= 7) {
console.error(PREFIX, 'tab events port failed after 7 retries, giving up')
return
}
debug('port disconnected, reconnecting...')
this.portRetries++
this.connectTabEvents()
})
}
dispose() {
debug('dispose')
this.disposed = true
this.port?.disconnect()
this.port = null
}
}
export interface TabsInitOptions {
includeInitialTab?: boolean
experimentalIncludeAllTabs?: boolean
}
export type TabAction =
@@ -302,6 +372,7 @@ export type TabAction =
| 'add_tab_to_group'
| 'close_tab'
| 'get_tab_title'
| 'get_window_tabs'
interface TabMeta {
id: number
+3
View File
@@ -21,6 +21,7 @@ export interface AdvancedConfig {
maxSteps?: number
systemInstruction?: string
experimentalLlmsTxt?: boolean
experimentalIncludeAllTabs?: boolean
disableNamedToolChoice?: boolean
}
@@ -125,6 +126,7 @@ export function useAgent(): UseAgentResult {
maxSteps,
systemInstruction,
experimentalLlmsTxt,
experimentalIncludeAllTabs,
disableNamedToolChoice,
...llmConfig
}: ExtConfig) => {
@@ -138,6 +140,7 @@ export function useAgent(): UseAgentResult {
maxSteps,
systemInstruction,
experimentalLlmsTxt,
experimentalIncludeAllTabs,
disableNamedToolChoice,
}
await chrome.storage.local.set({ advancedConfig })
@@ -31,17 +31,20 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
const [model, setModel] = useState(config?.model || DEMO_MODEL)
const [apiKey, setApiKey] = useState(config?.apiKey)
const [language, setLanguage] = useState<LanguagePreference>(config?.language)
const [maxSteps, setMaxSteps] = useState<number | undefined>(config?.maxSteps)
const [maxSteps, setMaxSteps] = useState(config?.maxSteps)
const [systemInstruction, setSystemInstruction] = useState(config?.systemInstruction ?? '')
const [experimentalLlmsTxt, setExperimentalLlmsTxt] = useState(
config?.experimentalLlmsTxt ?? false
)
const [experimentalIncludeAllTabs, setExperimentalIncludeAllTabs] = useState(
config?.experimentalIncludeAllTabs ?? false
)
const [disableNamedToolChoice, setDisableNamedToolChoice] = useState(
config?.disableNamedToolChoice ?? false
)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [userAuthToken, setUserAuthToken] = useState<string>('')
const [userAuthToken, setUserAuthToken] = useState('')
const [copied, setCopied] = useState(false)
const [showToken, setShowToken] = useState(false)
const [showApiKey, setShowApiKey] = useState(false)
@@ -54,6 +57,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
setMaxSteps(config?.maxSteps)
setSystemInstruction(config?.systemInstruction ?? '')
setExperimentalLlmsTxt(config?.experimentalLlmsTxt ?? false)
setExperimentalIncludeAllTabs(config?.experimentalIncludeAllTabs ?? false)
setDisableNamedToolChoice(config?.disableNamedToolChoice ?? false)
}, [config])
@@ -100,6 +104,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
maxSteps: maxSteps || undefined,
systemInstruction: systemInstruction || undefined,
experimentalLlmsTxt,
experimentalIncludeAllTabs,
disableNamedToolChoice,
})
} finally {
@@ -285,6 +290,14 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
<span className="text-xs text-muted-foreground">Experimental llms.txt support</span>
<Switch checked={experimentalLlmsTxt} onCheckedChange={setExperimentalLlmsTxt} />
</label>
<label className="flex items-center justify-between cursor-pointer">
<span className="text-xs text-muted-foreground">Experimental include all tabs</span>
<Switch
checked={experimentalIncludeAllTabs}
onCheckedChange={setExperimentalIncludeAllTabs}
/>
</label>
</>
)}
@@ -111,6 +111,7 @@ export function EmptyState() {
]}
cursorStyle="underscore"
loop
startOnView={false}
typeSpeed={20}
deleteSpeed={10}
pauseDelay={3000}
@@ -1,12 +1,12 @@
import { handlePageControlMessage } from '@/agent/RemotePageController.background'
import { handleTabControlMessage, setupTabChangeEvents } from '@/agent/TabsController.background'
import { handleTabControlMessage, setupTabEventsPort } from '@/agent/TabsController.background'
export default defineBackground(() => {
console.log('[Background] Service Worker started')
// tab change events
setupTabChangeEvents()
setupTabEventsPort()
// generate user auth token
@@ -46,6 +46,8 @@ async function exposeAgentToPage() {
let multiPageAgent: InstanceType<typeof MultiPageAgent> | null = null
window.addEventListener('message', async (e) => {
if (e.source !== window) return
const data = e.data
if (typeof data !== 'object' || data === null) return
if (data.channel !== 'PAGE_AGENT_EXT_REQUEST') return
@@ -70,11 +72,15 @@ async function exposeAgentToPage() {
try {
const { task, config } = payload
const { systemInstruction, ...agentConfig } = config
// Dispose old instance before creating new one
multiPageAgent?.dispose()
multiPageAgent = new MultiPageAgent(config)
multiPageAgent = new MultiPageAgent({
...agentConfig,
instructions: systemInstruction ? { system: systemInstruction } : undefined,
})
// events
@@ -7,12 +7,21 @@ export interface ExecuteConfig {
model: string
apiKey?: string
/**
* Global system-level instructions for the agent.
* Equivalent to `AgentConfig.instructions.system`.
*/
systemInstruction?: string
/**
* Whether to include the initial tab (that holds this main world script) in the task.
* @default true
*/
includeInitialTab?: boolean
/** Control all unpinned tabs in the window instead of only the tab group. */
experimentalIncludeAllTabs?: boolean
onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
@@ -36,6 +45,8 @@ export default defineUnlistedScript(() => {
const promise = new Promise<ExecutionResult>((resolve, reject) => {
function handleMessage(e: MessageEvent) {
if (e.source !== window) return
const data = e.data
if (typeof data !== 'object' || data === null) return
if (data.channel !== 'PAGE_AGENT_EXT_RESPONSE') return
@@ -86,7 +97,9 @@ export default defineUnlistedScript(() => {
baseURL: config.baseURL,
model: config.model,
apiKey: config.apiKey,
systemInstruction: config.systemInstruction,
includeInitialTab: config.includeInitialTab,
experimentalIncludeAllTabs: config.experimentalIncludeAllTabs,
},
},
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@page-agent/llms",
"version": "1.6.1",
"version": "1.7.1",
"type": "module",
"main": "./dist/lib/page-agent-llms.js",
"module": "./dist/lib/page-agent-llms.js",
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@page-agent/mcp",
"private": false,
"version": "1.6.1",
"version": "1.7.1",
"type": "module",
"bin": {
"page-agent-mcp": "src/index.js"
@@ -28,8 +28,8 @@
"node": ">=20"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"ws": "^8.19.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"ws": "^8.20.0",
"zod": "^4.3.5"
}
}
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "page-agent",
"private": false,
"version": "1.6.1",
"version": "1.7.1",
"type": "module",
"main": "./dist/esm/page-agent.js",
"module": "./dist/esm/page-agent.js",
@@ -44,10 +44,10 @@
"postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
},
"dependencies": {
"@page-agent/core": "1.6.1",
"@page-agent/llms": "1.6.1",
"@page-agent/page-controller": "1.6.1",
"@page-agent/ui": "1.6.1",
"@page-agent/core": "1.7.1",
"@page-agent/llms": "1.7.1",
"@page-agent/page-controller": "1.7.1",
"@page-agent/ui": "1.7.1",
"chalk": "^5.6.2"
},
"peerDependencies": {
+3 -2
View File
@@ -4,11 +4,11 @@
*/
import { type AgentConfig, PageAgentCore } from '@page-agent/core'
import { PageController, type PageControllerConfig } from '@page-agent/page-controller'
import { Panel } from '@page-agent/ui'
import { Panel, type PanelConfig } from '@page-agent/ui'
export * from '@page-agent/core'
export type PageAgentConfig = AgentConfig & PageControllerConfig
export type PageAgentConfig = AgentConfig & PageControllerConfig & Omit<PanelConfig, 'language'>
export class PageAgent extends PageAgentCore {
panel: Panel
@@ -23,6 +23,7 @@ export class PageAgent extends PageAgentCore {
this.panel = new Panel(this, {
language: config.language,
promptForNextTask: config.promptForNextTask,
})
}
}
+2 -1
View File
@@ -17,9 +17,10 @@ const DEMO_MODEL = 'qwen3.5-plus'
const DEMO_BASE_URL = 'https://page-ag-testing-ohftxirgbn.cn-shanghai.fcapp.run'
const DEMO_API_KEY = 'NA'
const currentScript = document.currentScript as HTMLScriptElement | null
// in case document.x is not ready yet
setTimeout(() => {
const currentScript = document.currentScript as HTMLScriptElement | null
let config: PageAgentConfig
if (currentScript) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@page-agent/page-controller",
"version": "1.6.1",
"version": "1.7.1",
"type": "module",
"main": "./dist/lib/page-controller.js",
"module": "./dist/lib/page-controller.js",
@@ -193,7 +193,11 @@ export class PageController extends EventTarget {
interactiveBlacklist: blacklist,
})
this.simplifiedHTML = dom.flatTreeToString(this.flatTree, this.config.includeAttributes)
this.simplifiedHTML = dom.flatTreeToString(
this.flatTree,
this.config.includeAttributes,
this.config.keepSemanticTags
)
this.selectorMap.clear()
this.selectorMap = dom.getSelectorMap(this.flatTree)
@@ -218,6 +222,7 @@ export class PageController extends EventTarget {
* Clean up all element highlights
*/
async cleanUpHighlights(): Promise<void> {
console.log('[PageController] cleanUpHighlights')
dom.cleanUpHighlights()
}
@@ -321,11 +326,11 @@ export class PageController extends EventTarget {
this.assertIndexed()
const scrollAmount = pixels ?? numPages * (down ? 1 : -1) * window.innerHeight
const scrollAmount = (pixels ?? numPages * window.innerHeight) * (down ? 1 : -1)
const element = index !== undefined ? getElementByIndex(this.selectorMap, index) : null
const message = await scrollVertically(down, scrollAmount, element)
const message = await scrollVertically(scrollAmount, element)
return {
success: true,
@@ -356,7 +361,7 @@ export class PageController extends EventTarget {
const element = index !== undefined ? getElementByIndex(this.selectorMap, index) : null
const message = await scrollHorizontally(right, scrollAmount, element)
const message = await scrollHorizontally(scrollAmount, element)
return {
success: true,
+100 -52
View File
@@ -4,6 +4,9 @@
*/
import type { InteractiveElementDomNode } from './dom/dom_tree/type'
import {
clickPointer,
disablePassThrough,
enablePassThrough,
getNativeValueSetter,
isHTMLElement,
isInputElement,
@@ -42,19 +45,20 @@ let lastClickedElement: HTMLElement | null = null
function blurLastClickedElement() {
if (lastClickedElement) {
lastClickedElement.dispatchEvent(new PointerEvent('pointerout', { bubbles: true }))
lastClickedElement.dispatchEvent(new PointerEvent('pointerleave', { bubbles: false }))
lastClickedElement.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }))
lastClickedElement.dispatchEvent(new MouseEvent('mouseleave', { bubbles: false }))
lastClickedElement.blur()
lastClickedElement.dispatchEvent(
new MouseEvent('mouseout', { bubbles: true, cancelable: true })
)
lastClickedElement.dispatchEvent(
new MouseEvent('mouseleave', { bubbles: false, cancelable: true })
)
lastClickedElement = null
}
}
/**
* Simulate a click on the element
* Simulate a full click following W3C Pointer Events + UI Events spec order:
* pointerover/enter → mouseover/enter → pointerdown → mousedown → [focus] →
* pointerup → mouseup → click
*
* @private Internal method, subject to change at any time.
*/
export async function clickElement(element: HTMLElement) {
@@ -63,32 +67,62 @@ export async function clickElement(element: HTMLElement) {
lastClickedElement = element
await scrollIntoViewIfNeeded(element)
// Scroll the iframe element itself into view if needed
const frame = element.ownerDocument.defaultView?.frameElement
if (frame) await scrollIntoViewIfNeeded(frame)
await movePointerToElement(element)
window.dispatchEvent(new CustomEvent('PageAgent::ClickPointer'))
const rect = element.getBoundingClientRect()
const x = rect.left + rect.width / 2
const y = rect.top + rect.height / 2
await movePointerToElement(element, x, y)
await clickPointer()
await waitFor(0.1)
// hover it
element.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true, cancelable: true }))
element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true }))
// Hit-test to find the deepest element at click coordinates, matching
// real browser behavior where events target the innermost element.
// @note This may hit a element in the blacklist
// TODO: This is a temporary workaround. Should have been handled during dom extraction.
const doc = element.ownerDocument
await enablePassThrough()
const hitTarget = doc.elementFromPoint(x, y)
await disablePassThrough()
const target =
hitTarget instanceof HTMLElement && element.contains(hitTarget) ? hitTarget : element
// dispatch a sequence of events to ensure all listeners are triggered
element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }))
const pointerOpts = {
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
pointerType: 'mouse',
}
const mouseOpts = { bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0 }
// focus it to ensure it gets the click event
element.focus()
// Hover — pointer events first, then mouse events (spec order)
target.dispatchEvent(new PointerEvent('pointerover', pointerOpts))
target.dispatchEvent(new PointerEvent('pointerenter', { ...pointerOpts, bubbles: false }))
target.dispatchEvent(new MouseEvent('mouseover', mouseOpts))
target.dispatchEvent(new MouseEvent('mouseenter', { ...mouseOpts, bubbles: false }))
element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }))
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
// Press
target.dispatchEvent(new PointerEvent('pointerdown', pointerOpts))
target.dispatchEvent(new MouseEvent('mousedown', mouseOpts))
// dispatch a click event
// element.click()
// Focus is not part of the standard pointer/mouse event sequence
// "undefined and varies between user agents".
// We focus the original element (nearest focusable ancestor), not the hit-test target, matching browser behavior.
element.focus({ preventScroll: true })
await waitFor(0.2) // Wait to ensure click event processing completes
// Release
target.dispatchEvent(new PointerEvent('pointerup', pointerOpts))
target.dispatchEvent(new MouseEvent('mouseup', mouseOpts))
// Click — activation behavior (navigation, form submit, etc.) triggers
// via bubbling from target up to the interactive ancestor.
target.click()
await waitFor(0.2)
}
/**
@@ -240,14 +274,7 @@ export async function scrollIntoViewIfNeeded(element: Element) {
}
}
/**
* @private Internal method, subject to change at any time.
*/
export async function scrollVertically(
down: boolean,
scroll_amount: number,
element?: HTMLElement | null
) {
export async function scrollVertically(scroll_amount: number, element?: HTMLElement | null) {
// Element-specific scrolling if element is provided
if (element) {
const targetElement = element
@@ -260,7 +287,10 @@ export async function scrollVertically(
while (currentElement && attempts < 10) {
const computedStyle = window.getComputedStyle(currentElement)
const hasScrollableY = /(auto|scroll|overlay)/.test(computedStyle.overflowY)
const hasScrollableY =
/(auto|scroll|overlay)/.test(computedStyle.overflowY) ||
(computedStyle.scrollbarWidth && computedStyle.scrollbarWidth !== 'auto') ||
(computedStyle.scrollbarGutter && computedStyle.scrollbarGutter !== 'auto')
const canScrollVertically = currentElement.scrollHeight > currentElement.clientHeight
if (hasScrollableY && canScrollVertically) {
@@ -312,9 +342,20 @@ export async function scrollVertically(
el.scrollHeight > el.clientHeight &&
bigEnough(el)
// @deprecated Heuristic container search.
// Unreliable in multi-panel layouts. Should guide LLMs to use indexed scroll for consistency.
// TODO: remove this fallback
// try to find the nearest scrollable container
// document.activeElement is usually body.
// After a successful element.focus(), activeElement become the nearest focusable parent
let el: HTMLElement | null = document.activeElement as HTMLElement | null
while (el && !canScroll(el) && el !== document.body) el = el.parentElement
// Something is wrong if it falls back to global '*' search
// TODO: Return error message instead of global '*' search
el = canScroll(el)
? el
: Array.from(document.querySelectorAll<HTMLElement>('*')).find(canScroll) ||
@@ -345,6 +386,10 @@ export async function scrollVertically(
return `✅ Scrolled page by ${scrolled}px.`
} else {
// Container scroll
const warningMsg = `The document is not scrollable. Falling back to container scroll.`
console.log(`[PageController] ${warningMsg}`)
const scrollBefore = el!.scrollTop
const scrollMax = el!.scrollHeight - el!.clientHeight
@@ -356,29 +401,22 @@ export async function scrollVertically(
if (Math.abs(scrolled) < 1) {
return dy > 0
? `⚠️ Already at the bottom of container (${el!.tagName}), cannot scroll down further.`
: `⚠️ Already at the top of container (${el!.tagName}), cannot scroll up further.`
? `⚠️ ${warningMsg} Already at the bottom of container (${el!.tagName}), cannot scroll down further.`
: `⚠️ ${warningMsg} Already at the top of container (${el!.tagName}), cannot scroll up further.`
}
const reachedBottom = dy > 0 && scrollAfter >= scrollMax - 1
const reachedTop = dy < 0 && scrollAfter <= 1
if (reachedBottom)
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the bottom.`
return ` ${warningMsg} Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the bottom.`
if (reachedTop)
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the top.`
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px.`
return ` ${warningMsg} Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the top.`
return ` ${warningMsg} Scrolled container (${el!.tagName}) by ${scrolled}px.`
}
}
/**
* @private Internal method, subject to change at any time.
*/
export async function scrollHorizontally(
right: boolean,
scroll_amount: number,
element?: HTMLElement | null
) {
export async function scrollHorizontally(scroll_amount: number, element?: HTMLElement | null) {
// Element-specific scrolling if element is provided
if (element) {
const targetElement = element
@@ -387,11 +425,14 @@ export async function scrollHorizontally(
let scrolledElement: HTMLElement | null = null
let scrollDelta = 0
let attempts = 0
const dx = right ? scroll_amount : -scroll_amount
const dx = scroll_amount
while (currentElement && attempts < 10) {
const computedStyle = window.getComputedStyle(currentElement)
const hasScrollableX = /(auto|scroll|overlay)/.test(computedStyle.overflowX)
const hasScrollableX =
/(auto|scroll|overlay)/.test(computedStyle.overflowX) ||
(computedStyle.scrollbarWidth && computedStyle.scrollbarWidth !== 'auto') ||
(computedStyle.scrollbarGutter && computedStyle.scrollbarGutter !== 'auto')
const canScrollHorizontally = currentElement.scrollWidth > currentElement.clientWidth
if (hasScrollableX && canScrollHorizontally) {
@@ -435,7 +476,8 @@ export async function scrollHorizontally(
// Page-level scrolling (default or fallback)
const dx = right ? scroll_amount : -scroll_amount
const dx = scroll_amount
const bigEnough = (el: HTMLElement) => el.clientWidth >= window.innerWidth * 0.5
const canScroll = (el: HTMLElement | null) =>
el &&
@@ -443,6 +485,9 @@ export async function scrollHorizontally(
el.scrollWidth > el.clientWidth &&
bigEnough(el)
// @deprecated Same heuristic container search as scrollVertically.
// TODO: Remove once LLMs reliably use indexed scrolling via data-scrollable.
let el: HTMLElement | null = document.activeElement as HTMLElement | null
while (el && !canScroll(el) && el !== document.body) el = el.parentElement
@@ -477,6 +522,9 @@ export async function scrollHorizontally(
return `✅ Scrolled page horizontally by ${scrolled}px.`
} else {
// Container scroll
const warningMsg = `The document is not scrollable. Falling back to container scroll.`
console.log(`[PageController] ${warningMsg}`)
const scrollBefore = el!.scrollLeft
const scrollMax = el!.scrollWidth - el!.clientWidth
@@ -488,17 +536,17 @@ export async function scrollHorizontally(
if (Math.abs(scrolled) < 1) {
return dx > 0
? `⚠️ Already at the right edge of container (${el!.tagName}), cannot scroll right further.`
: `⚠️ Already at the left edge of container (${el!.tagName}), cannot scroll left further.`
? `⚠️ ${warningMsg} Already at the right edge of container (${el!.tagName}), cannot scroll right further.`
: `⚠️ ${warningMsg} Already at the left edge of container (${el!.tagName}), cannot scroll left further.`
}
const reachedRight = dx > 0 && scrollAfter >= scrollMax - 1
const reachedLeft = dx < 0 && scrollAfter <= 1
if (reachedRight)
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the right edge.`
return ` ${warningMsg} Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the right edge.`
if (reachedLeft)
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the left edge.`
return `✅ Scrolled container (${el!.tagName}) horizontally by ${scrolled}px.`
return ` ${warningMsg} Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the left edge.`
return ` ${warningMsg} Scrolled container (${el!.tagName}) horizontally by ${scrolled}px.`
}
}
@@ -18,6 +18,7 @@
* @edit improve `sampleRect`, filter out rects with 0 area
* @edit exclude aria-hidden elements
* @edit make sure attributes exist for interactive candidates.
* @edit fix "aria-*" attributes check
*/
export default (
@@ -502,11 +503,16 @@ export default (
const overflowX = style.overflowX
const overflowY = style.overflowY
// Check scrollable distances
// scrollbar-width/scrollbar-gutter are only set on elements designed to scroll;
// their presence signals scroll intent even when overflow is hidden (e.g. overflow: auto on :hover)
const hasScrollbarSignal =
(style.scrollbarWidth && style.scrollbarWidth !== 'auto') ||
(style.scrollbarGutter && style.scrollbarGutter !== 'auto')
const scrollableX = overflowX === 'auto' || overflowX === 'scroll'
const scrollableY = overflowY === 'auto' || overflowY === 'scroll'
if (!scrollableX && !scrollableY) {
if (!scrollableX && !scrollableY && !hasScrollbarSignal) {
return null // Not scrollable in any direction
}
@@ -520,11 +526,11 @@ export default (
return null // Not scrollable
}
if (!scrollableY && scrollWidth < threshold) {
if (!scrollableY && !hasScrollbarSignal && scrollWidth < threshold) {
return null // Not scrollable horizontally
}
if (!scrollableX && scrollHeight < threshold) {
if (!scrollableX && !hasScrollbarSignal && scrollHeight < threshold) {
return null // Not scrollable vertically
}
@@ -546,6 +552,8 @@ export default (
scrollData: scrollData,
})
console.log('scrollData!!!', scrollData)
return scrollData
}
@@ -1143,6 +1151,31 @@ export default (
* @param {HTMLElement} element - The element to check.
* @returns {boolean} Whether the element is an interactive candidate.
*/
// @edit fix "aria-*" attributes check
const INTERACTIVE_ARIA_ATTRS = [
'aria-expanded',
'aria-checked',
'aria-selected',
'aria-pressed',
'aria-haspopup',
'aria-controls',
'aria-owns',
'aria-activedescendant',
'aria-valuenow',
'aria-valuetext',
'aria-valuemax',
'aria-valuemin',
'aria-autocomplete',
]
function hasInteractiveAria(el) {
for (let i = 0; i < INTERACTIVE_ARIA_ATTRS.length; i++) {
if (el.hasAttribute(INTERACTIVE_ARIA_ATTRS[i])) return true
}
return false
}
function isInteractiveCandidate(element) {
if (!element || element.nodeType !== Node.ELEMENT_NODE) return false
@@ -1167,7 +1200,7 @@ export default (
element.hasAttribute('onclick') ||
element.hasAttribute('role') ||
element.hasAttribute('tabindex') ||
element.hasAttribute('aria-') ||
hasInteractiveAria(element) ||
element.hasAttribute('data-action') ||
element.getAttribute('contenteditable') === 'true'
@@ -1185,8 +1218,9 @@ export default (
'details',
'label',
'option',
'li',
])
const INTERACTIVE_ROLES = new Set([
const DISTINCT_INTERACTIVE_ROLES = new Set([
'button',
'link',
'menuitem',
@@ -1202,6 +1236,9 @@ export default (
'searchbox',
'textbox',
'listbox',
'listitem',
'treeitem',
'row',
'option',
'scrollbar',
])
@@ -1278,7 +1315,7 @@ export default (
return true
}
// Check interactive roles
if (role && INTERACTIVE_ROLES.has(role)) {
if (role && DISTINCT_INTERACTIVE_ROLES.has(role)) {
return true
}
// Check contenteditable
@@ -1297,6 +1334,10 @@ export default (
if (element.hasAttribute('onclick') || typeof element.onclick === 'function') {
return true
}
// ARIA state attributes imply the element manages its own interaction state
if (hasInteractiveAria(element)) {
return true
}
// return false
@@ -1352,6 +1393,12 @@ export default (
return true
}
// Scrollable containers are always distinct — the LLM needs their index for targeted scrolling.
// Check extraData (already set by isScrollableElement in isInteractiveElement) to avoid redundant layout reads.
if (extraData.get(element)?.scrollable) {
return true
}
// Default to false: if it's interactive but doesn't match above,
// assume it triggers the same action as the parent.
return false
+48 -3
View File
@@ -28,8 +28,27 @@ export interface DomConfig {
includeAttributes?: string[]
highlightOpacity?: number
highlightLabelOpacity?: number
/**
* Preserve semantic landmark tags in dehydrated output even if not interactive
* @note maybe confusing for LLM combining with page scrolling, use with caution
**/
keepSemanticTags?: boolean
}
// TODO: corresponding roles
const SEMANTIC_TAGS = new Set([
'nav',
'menu',
// 'main',
'header',
'footer',
'aside',
// 'article',
// 'form',
'dialog',
])
/**
* 用于检测可交互元素是否是新出现的。
*/
@@ -171,7 +190,11 @@ interface TreeNode {
*
* @todo 数据脱敏过滤器
*/
export function flatTreeToString(flatTree: FlatDomTree, includeAttributes?: string[]): string {
export function flatTreeToString(
flatTree: FlatDomTree,
includeAttributes: string[] = [],
keepSemanticTags = false
): string {
const DEFAULT_INCLUDE_ATTRIBUTES = [
'title',
'type',
@@ -203,7 +226,7 @@ export function flatTreeToString(flatTree: FlatDomTree, includeAttributes?: stri
'contenteditable',
]
const includeAttrs = [...(includeAttributes || []), ...DEFAULT_INCLUDE_ATTRIBUTES]
const includeAttrs = [...includeAttributes, ...DEFAULT_INCLUDE_ATTRIBUTES]
// Helper function to cap text length
const capTextLength = (text: string, maxLength: number): string => {
@@ -294,6 +317,8 @@ export function flatTreeToString(flatTree: FlatDomTree, includeAttributes?: stri
const depthStr = '\t'.repeat(depth)
if (node.type === 'element') {
const isSemantic = keepSemanticTags && node.tagName && SEMANTIC_TAGS.has(node.tagName)
// Add element with highlight_index
if (node.highlightIndex !== undefined) {
nextDepth += 1
@@ -391,10 +416,30 @@ export function flatTreeToString(flatTree: FlatDomTree, includeAttributes?: stri
result.push(line)
}
// Process children regardless
// special treatment for semantic tags
// even if they are not interactive, we can keep them for clear context
const emitSemantic = isSemantic && node.highlightIndex === undefined
// to check if this tag is empty
const mark = emitSemantic ? result.length : -1
if (emitSemantic) {
result.push(`${depthStr}<${node.tagName}>`)
nextDepth += 1
}
for (const child of node.children) {
processNode(child, nextDepth, result)
}
if (emitSemantic) {
// empty tag should be removed
if (result.length === mark + 1) {
result.pop()
} else {
result.push(`${depthStr}</${node.tagName}>`)
}
}
} else if (node.type === 'text') {
// Add text only if it doesn't have a highlighted parent
if (hasParentWithHighlightIndex(node)) {
@@ -5,7 +5,7 @@ import { isPageDark } from './checkDarkMode'
import styles from './SimulatorMask.module.css'
import cursorStyles from './cursor.module.css'
export class SimulatorMask {
export class SimulatorMask extends EventTarget {
shown: boolean = false
wrapper = document.createElement('div')
motion: Motion | null = null
@@ -19,6 +19,8 @@ export class SimulatorMask {
#targetCursorY = 0
constructor() {
super()
this.wrapper.id = 'page-agent-runtime_simulator-mask'
this.wrapper.className = styles.wrapper
this.wrapper.setAttribute('data-browser-use-ignore', 'true')
@@ -74,13 +76,34 @@ export class SimulatorMask {
this.#moveCursorToTarget()
window.addEventListener('PageAgent::MovePointerTo', (event: Event) => {
// global events
// @note Mask should be isolated from the rest of the code.
// Global events are easier to manage and cleanup.
const movePointerToListener = (event: Event) => {
const { x, y } = (event as CustomEvent).detail
this.setCursorPosition(x, y)
})
window.addEventListener('PageAgent::ClickPointer', (event: Event) => {
}
const clickPointerListener = () => {
this.triggerClickAnimation()
}
const enablePassThroughListener = () => {
this.wrapper.style.pointerEvents = 'none'
}
const disablePassThroughListener = () => {
this.wrapper.style.pointerEvents = 'auto'
}
window.addEventListener('PageAgent::MovePointerTo', movePointerToListener)
window.addEventListener('PageAgent::ClickPointer', clickPointerListener)
window.addEventListener('PageAgent::EnablePassThrough', enablePassThroughListener)
window.addEventListener('PageAgent::DisablePassThrough', disablePassThroughListener)
this.addEventListener('dispose', () => {
window.removeEventListener('PageAgent::MovePointerTo', movePointerToListener)
window.removeEventListener('PageAgent::ClickPointer', clickPointerListener)
window.removeEventListener('PageAgent::EnablePassThrough', enablePassThroughListener)
window.removeEventListener('PageAgent::DisablePassThrough', disablePassThroughListener)
})
}
@@ -177,7 +200,9 @@ export class SimulatorMask {
}
dispose() {
console.log('dispose SimulatorMask')
this.motion?.dispose()
this.wrapper.remove()
this.dispatchEvent(new Event('dispose'))
}
}
+24 -6
View File
@@ -48,15 +48,33 @@ export async function waitFor(seconds: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
}
// ======= dom utils =======
// ======= mask events =======
export async function movePointerToElement(element: HTMLElement) {
const rect = element.getBoundingClientRect()
/**
* Move the visual pointer to a position within an element.
* @param x - x coordinate in the element's document viewport
* @param y - y coordinate in the element's document viewport
*/
export async function movePointerToElement(element: HTMLElement, x: number, y: number) {
const offset = getIframeOffset(element)
const x = rect.left + rect.width / 2 + offset.x
const y = rect.top + rect.height / 2 + offset.y
window.dispatchEvent(new CustomEvent('PageAgent::MovePointerTo', { detail: { x, y } }))
window.dispatchEvent(
new CustomEvent('PageAgent::MovePointerTo', {
detail: { x: x + offset.x, y: y + offset.y },
})
)
await waitFor(0.3)
}
export async function clickPointer() {
window.dispatchEvent(new CustomEvent('PageAgent::ClickPointer'))
}
export async function enablePassThrough() {
window.dispatchEvent(new CustomEvent('PageAgent::EnablePassThrough'))
}
export async function disablePassThrough() {
window.dispatchEvent(new CustomEvent('PageAgent::DisablePassThrough'))
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@page-agent/ui",
"version": "1.6.1",
"version": "1.7.1",
"type": "module",
"main": "./dist/lib/page-agent-ui.js",
"module": "./dist/lib/page-agent-ui.js",
+2 -1
View File
@@ -369,6 +369,7 @@ export class Panel {
}
#createWrapper(): HTMLElement {
const taskInputMaxLength = 1000
const wrapper = document.createElement('div')
wrapper.id = 'page-agent-runtime_agent-panel'
wrapper.className = styles.wrapper
@@ -406,7 +407,7 @@ export class Panel {
<input
type="text"
class="${styles.taskInput}"
maxlength="200"
maxlength="${taskInputMaxLength}"
/>
</div>
</div>
+2 -1
View File
@@ -85,7 +85,7 @@ Uses wouter browser routing with base path for GitHub Pages deployment at `https
```tsx
// main.tsx
<Router base="/page-agent">
<PagesRouter />
<PagesRouter />
</Router>
```
@@ -109,6 +109,7 @@ Instead of `404.html` redirects, the build copies `index.html` into every route
2. Add route in `src/pages/docs/index.tsx`
3. Add navigation item in `src/pages/docs/Layout.tsx`
4. Add path to `SPA_ROUTES` in `vite.config.js`
5. Keep `<slug>` consistent across the folder name, import path, route path, sidebar link, and any related cross-links. If the public route changes, rename the folder too.
## Configuration Files
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@page-agent/website",
"private": true,
"version": "1.6.1",
"version": "1.7.1",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
@@ -19,13 +19,13 @@
"@types/react-dom": "^19.2.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"motion": "^12.37.0",
"lucide-react": "^1.7.0",
"motion": "^12.38.0",
"next-themes": "^0.4.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"rough-notation": "^0.5.1",
"simple-icons": "^16.12.0",
"simple-icons": "^16.14.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.14",
+1 -1
View File
@@ -18,7 +18,7 @@ export default function Header() {
return (
<>
<header
className="relative z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-gray-200 dark:border-gray-700"
className="sticky top-0 z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-gray-200 dark:border-gray-700"
role="banner"
>
<div className="max-w-7xl mx-auto px-6 py-4">
+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.6.1/dist/iife/page-agent.demo.js'
'https://cdn.jsdelivr.net/npm/page-agent@1.7.1/dist/iife/page-agent.demo.js'
export const CDN_DEMO_CN_URL =
'https://registry.npmmirror.com/page-agent/1.6.1/files/dist/iife/page-agent.demo.js'
'https://registry.npmmirror.com/page-agent/1.7.1/files/dist/iife/page-agent.demo.js'
// Demo LLM for website testing (homepage quick trial uses flash)
export const DEMO_MODEL = 'qwen3.5-flash'
@@ -100,7 +100,7 @@ console.log(result.history) // Full execution history`}
>
AgentConfig
</Link>{' '}
{' '}
PanelConfig {' '}
<Link
href="/advanced/page-controller#configuration"
className="text-blue-600 dark:text-blue-400 hover:underline"
@@ -125,7 +125,7 @@ console.log(result.history) // Full execution history`}
>
AgentConfig
</Link>{' '}
and{' '}
, PanelConfig, and{' '}
<Link
href="/advanced/page-controller#configuration"
className="text-blue-600 dark:text-blue-400 hover:underline"
@@ -108,6 +108,14 @@ const agent = new PageAgentCore({
? '在 DOM 提取中包含的额外 HTML 属性。支持通配符 *(如 data-* 匹配所有 data- 开头的属性)。默认已包含常见属性如 role, aria-label 等。'
: 'Additional HTML attributes to include in DOM extraction. Supports wildcard * (e.g. data-* matches all data- prefixed attributes). Common attributes like role, aria-label are included by default.',
},
{
name: 'keepSemanticTags',
type: 'boolean',
defaultValue: 'false',
description: isZh
? '在简化输出中保留语义标签(如 nav, main, header, footer, aside 等),即使它们不可交互。帮助 LLM 理解页面结构。'
: 'Preserve semantic landmark tags (e.g. nav, main, header, footer, aside) in dehydrated output even if not interactive. Helps LLM understand page structure.',
},
]}
/>
</section>
@@ -199,7 +199,9 @@ interface ExecuteConfig {
model: string // Model name
apiKey?: string // LLM AK
systemInstruction?: string // Global system-level instructions
includeInitialTab?: boolean
experimentalIncludeAllTabs?: boolean // Control all unpinned tabs in the window
onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
@@ -233,6 +235,7 @@ const result = await window.PAGE_AGENT_EXT.execute(
apiKey: 'your-api-key',
model: 'gpt-5.2',
// includeInitialTab: false, // 设为 false 排除初始标签页
// experimentalIncludeAllTabs: true, // 控制窗口内所有非固定标签页
onStatusChange: status => console.log('状态变化:', status),
onActivity: activity => console.log('活动:', activity),
onHistoryUpdate: history => console.log('历史更新:', history)
@@ -248,6 +251,7 @@ const result = await window.PAGE_AGENT_EXT.execute(
apiKey: 'your-api-key',
model: 'gpt-5.2',
// includeInitialTab: false, // Set to false to exclude initial tab
// experimentalIncludeAllTabs: true, // Control all unpinned tabs in the window
onStatusChange: status => console.log('Status change:', status),
onActivity: activity => console.log('Activity:', activity),
onHistoryUpdate: history => console.log('History update:', history)
@@ -16,12 +16,12 @@ const BASELINE = new Set([
// Models grouped by brand, newest first
const MODEL_GROUPS: Record<string, string[]> = {
Qwen: [
'qwen3.6-plus',
'qwen3.5-plus',
'qwen3.5-flash',
'qwen3-coder-next',
'qwen-3-max',
'qwen-3-plus',
'qwen3:14b (ollama)',
],
OpenAI: ['gpt-5.4', 'gpt-5.2', 'gpt-5.1', 'gpt-5', 'gpt-5-mini', 'gpt-4.1', 'gpt-4.1-mini'],
DeepSeek: ['deepseek-3.2'],
@@ -64,7 +64,6 @@ export default function Models() {
: 'Supports models that comply with OpenAI API specification and support tool calls, including public cloud services and private deployments.'}
</p>
{/* Models Section */}
<section className="mb-10">
<Heading id="tested-models" className="text-2xl font-semibold mb-3">
{isZh ? '已测试模型' : 'Tested Models'}
@@ -87,9 +86,10 @@ export default function Models() {
</div>
</section>
{/* Tips Section */}
<section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">{isZh ? '提示' : 'Tips'}</h2>
<Heading id="tips" level={3}>
Tips
</Heading>
<div className="p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg border border-blue-200 dark:border-blue-800">
<ul className="text-sm text-gray-700 dark:text-gray-300 space-y-2 list-disc pl-5">
<li>
@@ -111,7 +111,6 @@ export default function Models() {
</div>
</section>
{/* Configuration Section */}
<section className="mb-10">
<Heading id="configuration">{isZh ? '配置方式' : 'Configuration'}</Heading>
<CodeEditor
@@ -121,25 +120,50 @@ const pageAgent = new PageAgent({
apiKey: 'your-api-key',
model: 'qwen3.5-plus'
});
// MiniMax
const pageAgent = new PageAgent({
baseURL: 'https://api.minimax.io/v1',
apiKey: 'your-minimax-api-key',
model: 'MiniMax-M2.7'
});
// Self-hosted models (e.g., Ollama) — no apiKey needed
const pageAgent = new PageAgent({
baseURL: 'http://localhost:11434/v1',
model: 'qwen3:14b'
});
`}
/>
</section>
{/* Free Testing API Section */}
<section className="mb-10">
<Heading id="production-authentication" className="text-2xl font-semibold mb-4">
{isZh ? '🔐 生产环境鉴权' : '🔐 Production Authentication'}
</Heading>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
{isZh
? '如果你只是将它用作个人助手,可以直接连接你的 LLM 服务。'
: 'If you only use it as a personal assistant, you can connect to your LLM service directly.'}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
{isZh ? (
<>
Web LLM 使{' '}
<code>customFetch</code> Cookie
</>
) : (
<>
If you plan to integrate it into your web app, it's better to have a backend proxy for
the LLM and use <code>customFetch</code> to authenticate the request with cookies or
other methods:
</>
)}
</p>
<CodeEditor
code={`const agent = new PageAgent({
baseURL: '/api/llm-proxy',
model: 'gpt-5.1',
customFetch: (url, init) =>
fetch(url, { ...init, credentials: 'include' }),
});`}
/>
<div className="mt-4 bg-yellow-50 dark:bg-yellow-950/20 border-l-4 border-yellow-500 p-4 rounded-r-lg">
<p className="text-sm font-semibold text-yellow-900 dark:text-yellow-200">
{isZh
? ' LLM API Key '
: ' NEVER commit real LLM API keys to your frontend code'}
</p>
</div>
</section>
<section className="mb-10">
<Heading id="free-testing-api">{isZh ? '' : 'Free Testing API'}</Heading>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
@@ -181,115 +205,143 @@ const pageAgent = new PageAgent({
</a>
</p>
<CodeEditor
code={`# qwen3.5-plus (default for demos) or qwen3.5-flash (lighter)
code={`# qwen3.5-plus / qwen3.5-flash
LLM_BASE_URL="https://page-ag-testing-ohftxirgbn.cn-shanghai.fcapp.run"
LLM_MODEL_NAME="qwen3.5-plus"
LLM_API_KEY="NA"`}
LLM_MODEL_NAME="qwen3.5-plus"`}
/>
</div>
</section>
{/* Ollama Section */}
<section className="mb-10">
<Heading id="ollama">Ollama</Heading>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
<Heading id="local-runtimes">{isZh ? '' : 'Local Runtimes'}</Heading>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
{isZh
? '已在 Ollama 0.15 + qwen3:14b (RTX3090 24GB) 上测试通过。'
: 'Tested on Ollama 0.15 with qwen3:14b (RTX3090 24GB).'}
? ' OllamaLM Studio OpenAI-compatible PageAgent线'
: 'Use local OpenAI-compatible runtimes such as Ollama and LM Studio with PageAgent for offline or LAN deployments.'}
</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'}
</li>
</ul>
</div>
<div className="mt-4">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-3">
{isZh ? '建议启动参数' : 'Recommended Startup'}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
{isZh
? '启动 Ollama 时建议配置以下环境变量:扩大上下文窗口、允许跨域访问、监听所有网络接口。'
: 'Start Ollama with these environment variables: larger context window, allow cross-origin access, and listen on all interfaces.'}
</p>
<div className="space-y-10">
<section>
<Heading id="requirements" level={3}>
Requirements
</Heading>
<div className="mt-4 p-5 bg-amber-50 dark:bg-amber-950/20 rounded-xl border border-amber-200 dark:border-amber-800">
<ul className="text-sm text-gray-700 dark:text-gray-300 space-y-2 list-disc pl-5">
<li>
{isZh
? ' CORS LLM '
: 'Enable CORS, otherwise the browser cannot call your local LLM endpoint directly.'}
</li>
<li>
{isZh
? ' context length content length 8000 15k token 4k '
: 'Set context length or content length to at least 8000. A typical page often needs around 15k tokens, so the default 4k usually truncates prompts.'}
</li>
<li>
{isZh ? ' tool_call ' : 'Use a model with tool_call support.'}
</li>
<li>
{isZh
? ' 10B '
: 'Models smaller than 10B are usually not strong enough.'}
</li>
</ul>
</div>
</section>
<div className="space-y-2">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">macOS / Linux</p>
<section>
<Heading id="local-configuration" level={3}>
{isZh ? '' : 'Basic Configuration'}
</Heading>
<CodeEditor
code={`OLLAMA_CONTEXT_LENGTH=64000 OLLAMA_HOST=0.0.0.0:11434 OLLAMA_ORIGINS="*" ollama serve`}
/>
code={`// Local OpenAI-compatible runtime - no apiKey needed
const pageAgent = new PageAgent({
baseURL: 'http://localhost:11434/v1',
model: 'qwen3:14b'
});
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 pt-2">
Windows (PowerShell)
// Or connect to LM Studio
const lmStudioAgent = new PageAgent({
baseURL: 'http://127.0.0.1:1234/v1',
model: 'qwen/qwen3.5-27b'
});
`}
/>
</section>
<section>
<Heading id="ollama" level={3}>
Ollama
</Heading>
<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={`$env:OLLAMA_CONTEXT_LENGTH=64000; $env:OLLAMA_HOST="0.0.0.0:11434"; $env:OLLAMA_ORIGINS="*"; ollama serve`}
code={`LLM_BASE_URL="http://localhost:11434/v1"
LLM_MODEL_NAME="qwen3:14b"`}
/>
</div>
</div>
</section>
<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>
<p className="text-sm text-gray-700 dark:text-gray-300">
{isZh
? '如果浏览器侧请求失败,优先检查 Ollama 是否已按上面的要求开启 CORS。'
: 'If browser-side requests fail, check whether Ollama has CORS enabled as required above.'}
</p>
</div>
{/* Production Authentication */}
<section className="mb-10">
<Heading id="production-authentication" className="text-2xl font-semibold mb-4">
{isZh ? '🔐 生产环境鉴权' : '🔐 Production Authentication'}
</Heading>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
{isZh
? '如果你只是将它用作个人助手,可以直接连接你的 LLM 服务。'
: 'If you only use it as a personal assistant, you can connect to your LLM service directly.'}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
{isZh ? (
<>
Web LLM 使{' '}
<code>customFetch</code> Cookie
</>
) : (
<>
If you plan to integrate it into your web app, it's better to have a backend proxy for
the LLM and use <code>customFetch</code> to authenticate the request with cookies or
other methods:
</>
)}
</p>
<CodeEditor
code={`const agent = new PageAgent({
baseURL: '/api/llm-proxy',
model: 'gpt-5.1',
customFetch: (url, init) =>
fetch(url, { ...init, credentials: 'include' }),
});`}
/>
<div className="mt-4 bg-yellow-50 dark:bg-yellow-950/20 border-l-4 border-yellow-500 p-4 rounded-r-lg">
<p className="text-sm font-semibold text-yellow-900 dark:text-yellow-200">
{isZh
? ' LLM API Key '
: ' NEVER commit real LLM API keys to your frontend code'}
</p>
<div className="mt-4">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-3">
{isZh ? '建议启动参数' : 'Recommended Startup'}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
{isZh
? '启动 Ollama 时建议同时放大上下文窗口并开启跨域访问。'
: 'When starting Ollama, increase the context window and enable cross-origin access.'}
</p>
<div className="space-y-2">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
macOS / Linux
</p>
<CodeEditor
code={`OLLAMA_CONTEXT_LENGTH=64000 OLLAMA_HOST=0.0.0.0:11434 OLLAMA_ORIGINS="*" ollama serve`}
/>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 pt-2">
Windows (PowerShell)
</p>
<CodeEditor
code={`$env:OLLAMA_CONTEXT_LENGTH=64000; $env:OLLAMA_HOST="0.0.0.0:11434"; $env:OLLAMA_ORIGINS="*"; ollama serve`}
/>
</div>
</div>
</section>
<section>
<Heading id="lm-studio" level={3}>
LM Studio
</Heading>
<CodeEditor
code={`LLM_BASE_URL="http://127.0.0.1:1234/v1"
LLM_MODEL_NAME="qwen/qwen3.5-27b"`}
/>
<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
? 'Agent 必须启用 disableNamedToolChoice,否则 tool_choice 参数会报错。'
: 'Enable disableNamedToolChoice in the agent config, otherwise the tool_choice parameter may fail.'}
</li>
</ul>
</div>
</section>
</div>
</section>
</div>
@@ -274,10 +274,12 @@ function ApiErrorsContent(isZh: boolean) {
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) })
const body = JSON.parse(init.body)
delete body.tool_choice
const bodyStr = JSON.stringify(body)
return fetch(url, { ...init, body: bodyStr })
},
})`}
/>
@@ -16,12 +16,13 @@ const LLM_CLOUD: {
opacity: number
}[] = [
{ name: 'OpenAI', color: '#10b981', x: 18, y: 22, size: 1.5, opacity: 1 },
{ name: 'Claude', color: '#f97316', x: 62, y: 15, size: 1.35, opacity: 0.95 },
{ name: 'Qwen', color: '#8b5cf6', x: 38, y: 50, size: 1.8, opacity: 0.9 },
{ name: 'Gemini', color: '#3b82f6', x: 68, y: 48, size: 1.2, opacity: 0.85 },
{ name: 'DeepSeek', color: '#06b6d4', x: 10, y: 65, size: 1.1, opacity: 0.8 },
{ name: 'Claude', color: '#f97316', x: 58, y: 15, size: 1.35, opacity: 0.95 },
{ name: 'Qwen', color: '#8b5cf6', x: 35, y: 50, size: 1.8, opacity: 0.9 },
{ name: 'Gemini', color: '#3b82f6', x: 72, y: 48, size: 1.2, opacity: 0.85 },
{ name: 'DeepSeek', color: '#06b6d4', x: 13, y: 65, size: 1.1, opacity: 0.8 },
{ name: 'Grok', color: '#f43f5e', x: 52, y: 78, size: 1.0, opacity: 0.75 },
{ name: 'Ollama', color: '#9ca3af', x: 82, y: 25, size: 1.1, opacity: 0.8 },
{ name: 'Ollama', color: '#9ca3af', x: 84, y: 25, size: 1.1, opacity: 0.8 },
{ name: 'LM Studio', color: '#4338ca', x: 49, y: 36, size: 0.8, opacity: 0.9 },
{ name: 'Kimi', color: '#14b8a6', x: 30, y: 82, size: 0.85, opacity: 0.6 },
{ name: 'GLM', color: '#f59e0b', x: 70, y: 72, size: 0.85, opacity: 0.55 },
{ name: 'LLaMA', color: '#60a5fa', x: 88, y: 70, size: 0.8, opacity: 0.45 },
+1
View File
@@ -26,6 +26,7 @@ const SPA_ROUTES = [
'docs/features/data-masking',
'docs/features/custom-instructions',
'docs/features/models',
'docs/features/local-llms',
'docs/features/chrome-extension',
'docs/features/mcp-server',
'docs/features/third-party-agent',