Compare commits

...

104 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
Simon eede819ea8 chore(version): bump version to 1.6.1 2026-03-22 03:27:51 +08:00
Simon ce3047428a chore(mcp): opt descriptions 2026-03-22 03:27:17 +08:00
Simon 3456f45590 docs: MCP 2026-03-22 03:21:33 +08:00
Simon 4f71f065df Merge pull request #310 from zfangqijun/chore-export-actions
chore(page-controller): export actions as internal methods
2026-03-22 01:02:56 +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
zfangqijun cd2d33a9f6 chore(page-controller): export actions as internal methods 2026-03-21 20:17:01 +08:00
Simon 80e96d0b9e docs: limitations doc; homepage details 2026-03-21 02:53:00 +08:00
Simon 2c3b27e337 docs: changelog 2026-03-21 02:33:50 +08:00
Simon b6691b4732 chore(version): bump version to 1.6.0 2026-03-21 02:24:42 +08:00
Simon a557a85d28 Merge pull request #325 from alibaba/fix/action-into-iframes
fix(PageController): same-origin iframe actions
2026-03-21 02:20:13 +08:00
Simon eeb5b6a5af fix(clickElement): reorder iframe scroll and click actions 2026-03-21 02:18:42 +08:00
Simon ad31e2b750 chore: clarify current type handling in isHTMLElement 2026-03-21 02:16:51 +08:00
Simon e98d80b6a0 fix(PageController): same-origin iframe actions 2026-03-21 01:46:09 +08:00
Simon 93b1e52376 Merge pull request #322 from alibaba/feat/optional-toolchoice 2026-03-20 17:43:44 +08:00
Simon 66a85c0dd3 feat: option to disable named tool choice 2026-03-20 17:40:16 +08:00
Simon 50ce56a4f6 Merge pull request #321 from alibaba:chore/ext-rm-keydown
chore(ext): rm keydown event on history
2026-03-20 17:14:38 +08:00
Simon 5aeb40abac chore(ext): rm keydown event on history 2026-03-20 17:13:42 +08:00
Simon 8c06cdc898 Merge pull request #313 from Adonis0123/feature/239-history-export
feat(extension): export history sessions as json
2026-03-20 17:01:59 +08:00
Simon dfde89aa70 Merge branch 'main' into pr/Adonis0123/313 2026-03-20 16:58:13 +08:00
Simon 49b3316a5f Merge pull request #314 from Adonis0123/feat/issue-304-rerun-history-task
feat(ext): rerun tasks from history
2026-03-20 16:39:09 +08:00
Simon c35f367b39 feat(ext): style adjust; rm rerunDisabled 2026-03-20 16:36:23 +08:00
Simon 3459836a14 fix(PageController): lint error 2026-03-20 16:10:43 +08:00
Simon b7b5b6db30 Merge pull request #319 from alibaba/fix/missing-pointerout
fix(PageController): add `mouseleave` event
2026-03-20 16:05:37 +08:00
Simon 05d16313c7 fix(PageController): add mouseleave event 2026-03-20 15:54:36 +08:00
Simon a3a96d85d5 Merge pull request #210 from voidborne-d/fix/contenteditable-fallback
fix: add execCommand fallback for contenteditable input
2026-03-20 15:38:06 +08:00
Simon c89042f142 chore: wording 2026-03-20 15:36:10 +08:00
voidborne-d 2e18bd862d refactor: use const for planASucceeded, clarify LinkedIn comment 2026-03-20 07:08:45 +00:00
adonis 0bc47a997d feat(ext): rerun tasks from history 2026-03-19 23:54:08 +08:00
adonis bcc7dfea2d feat(extension): export history sessions as json 2026-03-19 23:46:58 +08:00
Simon ba242d3a1b Merge pull request #311 from alibaba/feat/llms-ak-always-optional
feat: optional AK
2026-03-19 20:08:15 +08:00
Simon fe82b11285 feat: AK optional 2026-03-19 19:50:05 +08:00
Simon bdf79b7c10 chore(version): bump version to 1.5.11 2026-03-18 20:45:19 +08:00
Simon 24eefdef23 chore(version): bump version to 1.5.10 2026-03-18 20:36:29 +08:00
Simon 252f550c04 style(ext): fix ai-motion overflow; wording; launcher info 2026-03-18 20:25:54 +08:00
Simon 628c249d45 fix(ext): init without initialTab throws error 2026-03-18 19:36:02 +08:00
Simon 61d598142d feat(ext): ask user approval for MCP task 2026-03-18 19:14:44 +08:00
Simon c0510b2235 Merge pull request #294 from octo-patch/feature/upgrade-minimax-m27
feat: upgrade MiniMax default model to M2.7
2026-03-18 15:21:19 +08:00
Octopus ca0005c825 feat: upgrade MiniMax default model to M2.7
- Add MiniMax-M2.7 and MiniMax-M2.7-highspeed to model list
- Set MiniMax-M2.7 as default model in code example
- Keep all previous models as alternatives
2026-03-18 02:12:58 -05:00
d 🔹 2f92a9cb32 fix: add execCommand fallback for contenteditable input (#168)
When typing into contenteditable elements (e.g. LinkedIn post editor),
the synthetic event approach (Plan A) may fail silently — the events
fire but the editor's internal state doesn't update, leaving the
element empty.

This adds an automatic fallback: after Plan A, we verify the text was
actually inserted by checking element.innerText. If it wasn't, we
fall back to execCommand('insertText') which integrates natively with
most rich-text editors including LinkedIn, Quill, and Slate.js.

The fallback uses proper Selection/Range API to select-all before
replacing, and preserves the undo stack since execCommand is handled
by the browser natively.

Fixes #168
2026-03-11 17:07:22 +00:00
71 changed files with 2031 additions and 1142 deletions
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
run: npm run build:website run: npm run build:website
- name: Setup Pages - name: Setup Pages
uses: actions/configure-pages@v5 uses: actions/configure-pages@v6
- name: Upload artifact - name: Upload artifact
uses: actions/upload-pages-artifact@v4 uses: actions/upload-pages-artifact@v4
@@ -37,4 +37,4 @@ jobs:
- name: Deploy to GitHub Pages - name: Deploy to GitHub Pages
id: deployment 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. ♥️ We welcome contributions from everyone.
## 🚀 Quick Start 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).
### 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.
## 🤝 How to Contribute ## 🤝 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 - Use the GitHub issue tracker to report bugs or request features
- Search existing issues before creating new ones - Search existing issues before creating new ones
@@ -46,147 +17,24 @@ This is a **monorepo** with npm workspaces containing **4 main packages**:
### Code Contributions ### Code Contributions
1. **Fork and Clone** 1. Follow existing code style and patterns
2. Update documentation as needed
```bash 3. Add JSDoc for public APIs
git clone https://github.com/your-username/page-agent.git 4. Build and lint everything
cd page-agent 5. Test in our demo website, and on other websites if applicable
``` 6. Include screenshots for UI changes
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
### Vibe Coding with AI ### 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**. - 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. - Make sure your AI references `AGENTS.md` and `website/AGENTS.md` for better quality.
- Vibe coding is **NOT** allowed for the core lib!!!
- NEVER try to vibe coding the MV3 extension!!! It is HELL.
- Review anything AI wrote before make a commit. You are the author of anything you commit. NOT AI. - 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 ## 🚫 What We Don't Accept
- Breaking changes and large PRs without prior discussion - Breaking changes and large PRs without prior discussion
- Heavy dependencies to core libs - Heavy dependencies to core libs
- Contributions without proper testing
- Code that doesn't follow project conventions
- Dependencies or code with licenses incompatible with MIT - Dependencies or code with licenses incompatible with MIT
- Bot or AI-generated pull requests without meaningful human involvement - 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. 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! 🎉 Thank you for helping make PageAgent better! 🎉
+22 -22
View File
@@ -25,15 +25,16 @@ The GUI Agent Living in Your Webpage. Control web interfaces with natural langua
- **📖 Text-based DOM manipulation** - **📖 Text-based DOM manipulation**
- No screenshots. No multi-modal LLMs or special permissions needed. - No screenshots. No multi-modal LLMs or special permissions needed.
- **🧠 Bring your own LLMs** - **🧠 Bring your own LLMs**
- **🎨 Pretty UI with human-in-the-loop**
- **🐙 Optional [chrome extension](https://alibaba.github.io/page-agent/docs/features/chrome-extension) for multi-page tasks.** - **🐙 Optional [chrome extension](https://alibaba.github.io/page-agent/docs/features/chrome-extension) for multi-page tasks.**
- And an [MCP Server (Beta)](https://alibaba.github.io/page-agent/docs/features/mcp-server) to control it from outside
## 💡 Use Cases ## 💡 Use Cases
- **SaaS AI Copilot** — Ship an AI copilot in your product in lines of code. No backend rewrite. - **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. - **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. - **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 ## 🚀 Quick Start
@@ -49,8 +50,8 @@ Fastest way to try PageAgent with our free Demo LLM:
| Mirrors | URL | | Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- | | ------- | ---------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.5.9/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.5.9/files/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 ### 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). 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 ## 🤝 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 ## 👏 Acknowledgments
@@ -97,23 +112,8 @@ Licensed under the MIT License
We gratefully acknowledge the browser-use project and its contributors for their We gratefully acknowledge the browser-use project and its contributors for their
excellent work on web automation and DOM interaction patterns that helped make excellent work on web automation and DOM interaction patterns that helped make
this project possible. 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!** **⭐ 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>
+25 -36
View File
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.6.0] - 2026-03-21
### Features
- **Beta MCP support** - New `@page-agent/mcp` package lets MCP clients such as Claude Desktop and Copilot control the browser through the Page Agent extension
- **Better iframe handling** - Same-origin iframe elements are handled more reliably during DOM extraction and actions
- **Extension history workflows** - Users can rerun past tasks, export history sessions as JSON, and approve MCP-triggered tasks before execution
### Improvements
- **Unified versioning across packages** - The extension now follows the root workspace version. Changelog entries are no longer split into a separate extension version section
- **Configurable `stepDelay`** - Agent pacing between steps is now configurable via `stepDelay`
- **Optional API key** - `apiKey` can now be omitted for compatible deployments that do not require one
- **Optional named tool choice** - Tool invocation can disable named tool choice for providers that behave better without it
- **Better rich-text input support** - Improved `contenteditable` handling with better event dispatching and `execCommand` fallback for more editors
- **More flexible DOM extraction** - `includeAttributes` now supports wildcards, `contenteditable` is included by default, and heuristically interactive elements expose more useful attributes
- **MiniMax model support** - Added MiniMax compatibility, with the default recommendation updated to `MiniMax-M2.7`
### Bug Fixes
- Fixed Safari issues when `requestIdleCallback` is unavailable
- Avoid throwing when `webgl2` initialization fails
- Improved OpenAI-compatible request patches for GPT-5.4 chat tools and MiniMax temperature/tool-call compatibility
- Fixed several UI polish issues in the extension and website, including cursor and layout regressions
## [1.5.1] - 2026-03-05 ## [1.5.1] - 2026-03-05
### Breaking Changes ### Breaking Changes
@@ -153,42 +178,6 @@ PageAgent is now ready for production use. The API is stable and breaking change
- **Ask User Tool** - Agent can ask users for clarification - **Ask User Tool** - Agent can ask users for clarification
- **i18n Support** - English and Chinese localization - **i18n Support** - English and Chinese localization
### Configuration
```typescript
// Version 1.0.0
interface PageAgentConfig {
// LLM Configuration (required)
baseURL: string
apiKey: string
model: string
temperature?: number
maxRetries?: number
customFetch?: typeof fetch
// Agent Configuration
language?: 'en-US' | 'zh-CN'
maxSteps?: number // default: 20
customTools?: Record<string, PageAgentTool> // experimental
instructions?: InstructionsConfig
transformPageContent?: (content: string) => string | Promise<string>
experimentalScriptExecutionTool?: boolean // default: false
// Lifecycle Hooks (experimental)
onBeforeTask?: (agent, result) => void
onAfterTask?: (agent, result) => void
onBeforeStep?: (agent, stepCount) => void
onAfterStep?: (agent, history) => void
onDispose?: (agent, reason?) => void
// Page Controller Configuration
enableMask?: boolean // default: true
viewportExpansion?: number
interactiveBlacklist?: Element[]
interactiveWhitelist?: Element[]
}
```
### Packages ### Packages
| Package | Description | | Package | Description |
+16 -24
View File
@@ -20,20 +20,20 @@
## ✨ Features ## ✨ Features
- **🎯 轻松集成** - **🎯 轻松集成**
- 无需 `浏览器插件` / `Python` / `无头浏览器` - 无需 `浏览器插件` / `Python` / `无头浏览器`,纯页面内 JavaScript
- 纯页面内 JavaScript,一切都在你的网页中完成。
- **📖 基于文本的 DOM 操作** - **📖 基于文本的 DOM 操作**
- 无需截图,无需多模态模型或特殊权限 - 无需截图,无需多模态模型或特殊权限
- **🧠 用你自己的 LLM** - **🧠 自备 LLM**
- **🎨 精美 UI,支持人机协同** - 🐙 可选的 [Chrome 扩展](https://alibaba.github.io/page-agent/docs/features/chrome-extension),支持跨页面任务
- **🐙 可选的 [Chrome 扩展](https://alibaba.github.io/page-agent/docs/features/chrome-extension),支持跨页面任务。** - [MCP Server (Beta)](https://alibaba.github.io/page-agent/docs/features/mcp-server)
## 💡 应用场景 ## 💡 应用场景
- **SaaS AI 副驾驶** — 几行代码为你的产品加上 AI 副驾驶,无需重写后端。 - **SaaS AI Copilot** — 几行代码为你的产品加上 AI 副驾驶,无需重写后端。
- **智能表单填写** — 把 20 次点击变成一句话。ERP、CRM、管理后台的最佳拍档。 - **智能表单填写** — 把 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 加入浏览器控制能力。
## 🚀 快速开始 ## 🚀 快速开始
@@ -49,8 +49,8 @@
| Mirrors | URL | | Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- | | ------- | ---------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.5.9/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.5.9/files/dist/iife/page-agent.demo.js | | China | https://registry.npmmirror.com/page-agent/1.7.1/files/dist/iife/page-agent.demo.js |
### NPM 安装 ### NPM 安装
@@ -75,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)** 的优秀工作构建。 本项目基于 **[`browser-use`](https://github.com/browser-use/browser-use)** 的优秀工作构建。
@@ -95,12 +97,9 @@ Licensed under the MIT License
We gratefully acknowledge the browser-use project and its contributors for their We gratefully acknowledge the browser-use project and its contributors for their
excellent work on web automation and DOM interaction patterns that helped make excellent work on web automation and DOM interaction patterns that helped make
this project possible. 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) [MIT License](../LICENSE)
@@ -108,10 +107,3 @@ file and in the node_modules directory after installation.
**⭐ 如果觉得 PageAgent 有用或有趣,请给项目点个星!** **⭐ 如果觉得 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 -261
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "root", "name": "root",
"private": true, "private": true,
"version": "1.5.9", "version": "1.7.1",
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
"packages/page-controller", "packages/page-controller",
@@ -42,7 +42,7 @@
"@commitlint/config-conventional": "^20.5.0", "@commitlint/config-conventional": "^20.5.0",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@microsoft/api-extractor": "^7.57.7", "@microsoft/api-extractor": "^7.57.7",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.2",
"@trivago/prettier-plugin-sort-imports": "^6.0.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@vitejs/plugin-react-swc": "^4.3.0", "@vitejs/plugin-react-swc": "^4.3.0",
@@ -60,7 +60,7 @@
"lint-staged": "^16.4.0", "lint-staged": "^16.4.0",
"prettier": "^3.8.0", "prettier": "^3.8.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.57.1", "typescript-eslint": "^8.58.0",
"unplugin-dts": "^1.0.0-beta.6", "unplugin-dts": "^1.0.0-beta.6",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-plugin-css-injected-by-js": "^4.0.1", "vite-plugin-css-injected-by-js": "^4.0.1",
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/core", "name": "@page-agent/core",
"private": false, "private": false,
"version": "1.5.9", "version": "1.7.1",
"type": "module", "type": "module",
"main": "./dist/esm/page-agent-core.js", "main": "./dist/esm/page-agent-core.js",
"module": "./dist/esm/page-agent-core.js", "module": "./dist/esm/page-agent-core.js",
@@ -44,8 +44,8 @@
}, },
"dependencies": { "dependencies": {
"chalk": "^5.6.2", "chalk": "^5.6.2",
"@page-agent/llms": "1.5.9", "@page-agent/llms": "1.7.1",
"@page-agent/page-controller": "1.5.9" "@page-agent/page-controller": "1.7.1"
}, },
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.0 || ^4.0.0" "zod": "^3.25.0 || ^4.0.0"
+3 -2
View File
@@ -131,7 +131,8 @@ tools.set(
tools.set( tools.set(
'scroll', 'scroll',
tool({ 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({ inputSchema: z.object({
down: z.boolean().default(true), down: z.boolean().default(true),
num_pages: z.number().min(0).max(10).optional().default(0.1), num_pages: z.number().min(0).max(10).optional().default(0.1),
@@ -155,7 +156,7 @@ tools.set(
'scroll_horizontally', 'scroll_horizontally',
tool({ tool({
description: 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({ inputSchema: z.object({
right: z.boolean().default(true), right: z.boolean().default(true),
pixels: z.number().int().min(0), pixels: z.number().int().min(0),
+15 -2
View File
@@ -115,12 +115,21 @@ import type {
export interface ExecuteConfig { export interface ExecuteConfig {
baseURL: string baseURL: string
apiKey: string
model: string 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. // Include the initial tab where page JS starts. Default: true.
includeInitialTab?: boolean 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 onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void
@@ -205,9 +214,13 @@ import type {
interface ExecuteConfig { interface ExecuteConfig {
baseURL: string baseURL: string
apiKey: string
model: string model: string
apiKey?: string
systemInstruction?: string
includeInitialTab?: boolean includeInitialTab?: boolean
experimentalIncludeAllTabs?: boolean
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void
+10 -11
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/ext", "name": "@page-agent/ext",
"private": true, "private": true,
"version": "1.5.9", "version": "1.7.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "wxt", "dev": "wxt",
@@ -16,32 +16,31 @@
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8", "@types/chrome": "^0.1.38",
"@types/chrome": "^0.1.37",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.1", "@types/react-dom": "^19.2.1",
"@wxt-dev/module-react": "^1.2.2", "@wxt-dev/module-react": "^1.2.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"idb": "^8.0.3", "idb": "^8.0.3",
"lucide-react": "^0.577.0", "lucide-react": "^1.7.0",
"motion": "^12.37.0", "motion": "^12.38.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"rough-notation": "^0.5.1", "rough-notation": "^0.5.1",
"simple-icons": "^16.12.0", "simple-icons": "^16.14.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"wxt": "^0.20.19" "wxt": "^0.20.20"
}, },
"dependencies": { "dependencies": {
"@page-agent/core": "1.5.9", "@page-agent/core": "1.7.1",
"@page-agent/llms": "1.5.9", "@page-agent/llms": "1.7.1",
"@page-agent/page-controller": "1.5.9", "@page-agent/page-controller": "1.7.1",
"@page-agent/ui": "1.5.9", "@page-agent/ui": "1.7.1",
"ai-motion": "^0.4.8", "ai-motion": "^0.4.8",
"chalk": "^5.6.2" "chalk": "^5.6.2"
}, },
@@ -11,13 +11,18 @@ function detectLanguage(): 'en-US' | 'zh-CN' {
return lang.startsWith('zh') ? 'zh-CN' : 'en-US' return lang.startsWith('zh') ? 'zh-CN' : 'en-US'
} }
interface MultiPageAgentConfig extends AgentConfig {
includeInitialTab?: boolean
experimentalIncludeAllTabs?: boolean
}
/** /**
* MultiPageAgent * MultiPageAgent
* - use with extension * - use with extension
* - can be used from a side panel or a content script * - can be used from a side panel or a content script
*/ */
export class MultiPageAgent extends PageAgentCore { export class MultiPageAgent extends PageAgentCore {
constructor(config: AgentConfig & { includeInitialTab?: boolean }) { constructor(config: MultiPageAgentConfig) {
// multi page controller // multi page controller
const tabsController = new TabsController() const tabsController = new TabsController()
const pageController = new RemotePageController(tabsController) const pageController = new RemotePageController(tabsController)
@@ -31,8 +36,8 @@ export class MultiPageAgent extends PageAgentCore {
`Default working language: **${targetLanguage}**` `Default working language: **${targetLanguage}**`
) )
// include initial tab for controlling
const includeInitialTab = config.includeInitialTab ?? true const includeInitialTab = config.includeInitialTab ?? true
const experimentalIncludeAllTabs = config.experimentalIncludeAllTabs ?? false
/** /**
* When the agent is in side-panel and user closed the side-panel. * When the agent is in side-panel and user closed the side-panel.
@@ -50,7 +55,7 @@ export class MultiPageAgent extends PageAgentCore {
customSystemPrompt: systemPrompt, customSystemPrompt: systemPrompt,
onBeforeTask: async (agent) => { onBeforeTask: async (agent) => {
await tabsController.init(agent.task, includeInitialTab) await tabsController.init(agent.task, { includeInitialTab, experimentalIncludeAllTabs })
heartBeatInterval = window.setInterval(() => { heartBeatInterval = window.setInterval(() => {
chrome.storage.local.set({ chrome.storage.local.set({
@@ -75,6 +80,7 @@ export class MultiPageAgent extends PageAgentCore {
}, },
onBeforeStep: async (agent) => { onBeforeStep: async (agent) => {
if (!tabsController.currentTabId) return
// make sure the current tab is loaded before the step starts // make sure the current tab is loaded before the step starts
await tabsController.waitUntilTabLoaded(tabsController.currentTabId!) await tabsController.waitUntilTabLoaded(tabsController.currentTabId!)
}, },
@@ -10,9 +10,7 @@ export function handlePageControlMessage(
): true | undefined { ): true | undefined {
const PREFIX = '[RemotePageController.background]' const PREFIX = '[RemotePageController.background]'
function debug(...messages: any[]) { const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`)
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
const { action, payload, targetTabId } = message const { action, payload, targetTabId } = message
@@ -19,7 +19,10 @@ export function initPageController() {
function getPC(): PageController { function getPC(): PageController {
if (!pageController) { if (!pageController) {
pageController = new PageController({ enableMask: false, viewportExpansion: 400 }) pageController = new PageController({
enableMask: false,
viewportExpansion: 400,
})
} }
return pageController return pageController
} }
@@ -4,9 +4,7 @@ import type { TabsController } from './TabsController'
const PREFIX = '[RemotePageController]' const PREFIX = '[RemotePageController]'
function debug(...messages: any[]) { const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`)
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
function sendMessage(message: { function sendMessage(message: {
type: 'PAGE_CONTROL' type: 'PAGE_CONTROL'
@@ -58,8 +56,6 @@ export class RemotePageController {
} }
async getBrowserState(): Promise<BrowserState> { async getBrowserState(): Promise<BrowserState> {
if (!this.currentTabId) throw new Error('tabsController not initialized.')
let browserState = {} as BrowserState let browserState = {} as BrowserState
debug('getBrowserState', this.currentTabId) debug('getBrowserState', this.currentTabId)
@@ -178,7 +174,7 @@ interface DomActionReturn {
/** /**
* Check if a URL can run content scripts. * Check if a URL can run content scripts.
*/ */
function isContentScriptAllowed(url: string | undefined): boolean { export function isContentScriptAllowed(url: string | undefined): boolean {
if (!url) return false if (!url) return false
const restrictedPatterns = [ const restrictedPatterns = [
@@ -5,9 +5,7 @@ import type { TabAction } from './TabsController'
const PREFIX = '[TabsController.background]' const PREFIX = '[TabsController.background]'
function debug(...messages: any[]) { const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`)
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
export function handleTabControlMessage( export function handleTabControlMessage(
message: { type: 'TAB_CONTROL'; action: TabAction; payload: any }, message: { type: 'TAB_CONTROL'; action: TabAction; payload: any },
@@ -20,11 +18,10 @@ export function handleTabControlMessage(
case 'get_active_tab': { case 'get_active_tab': {
debug('get_active_tab') debug('get_active_tab')
chrome.tabs chrome.tabs
.query({ active: true, currentWindow: true }) .query({ active: true })
.then((tabs) => { .then((tabs) => {
const tabId = tabs.length > 0 ? tabs[0].id || null : null debug('get_active_tab: success', tabs)
debug('get_active_tab: success', tabId) sendResponse({ success: true, tab: tabs[0] })
sendResponse({ success: true, tabId })
}) })
.catch((error) => { .catch((error) => {
sendResponse({ error: error instanceof Error ? error.message : String(error) }) sendResponse({ error: error instanceof Error ? error.message : String(error) })
@@ -63,7 +60,7 @@ export function handleTabControlMessage(
case 'create_tab_group': { case 'create_tab_group': {
debug('create_tab_group', payload) debug('create_tab_group', payload)
chrome.tabs chrome.tabs
.group({ tabIds: payload.tabIds }) .group({ tabIds: payload.tabIds, createProperties: { windowId: payload.windowId } })
.then((groupId) => { .then((groupId) => {
debug('create_tab_group: success', groupId) debug('create_tab_group: success', groupId)
sendResponse({ success: true, groupId }) sendResponse({ success: true, groupId })
@@ -114,47 +111,59 @@ export function handleTabControlMessage(
return true // async response 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: default:
sendResponse({ error: `Unknown action: ${action}` }) sendResponse({ error: `Unknown action: ${action}` })
return return
} }
} }
export function setupTabChangeEvents() { const tabEventPorts = new Set<chrome.runtime.Port>()
console.log('[TabsController.background] setupTabChangeEvents')
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) => { chrome.tabs.onCreated.addListener((tab) => {
debug('onCreated', tab) broadcastTabEvent({ action: 'created', payload: { tab } })
chrome.runtime
.sendMessage({ type: 'TAB_CHANGE', action: 'created', payload: { tab } })
.catch((error) => {
debug('onCreated error:', error)
})
}) })
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => { chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
debug('onRemoved', tabId, removeInfo) broadcastTabEvent({ action: 'removed', payload: { tabId, removeInfo } })
chrome.runtime
.sendMessage({
type: 'TAB_CHANGE',
action: 'removed',
payload: { tabId, removeInfo },
})
.catch((error) => {
debug('onRemoved error:', error)
})
}) })
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
debug('onUpdated', tabId, changeInfo) broadcastTabEvent({ action: 'updated', payload: { tabId, changeInfo, tab } })
chrome.runtime
.sendMessage({
type: 'TAB_CHANGE',
action: 'updated',
payload: { tabId, changeInfo, tab },
})
.catch((error) => {
debug('onUpdated error:', error)
})
}) })
} }
+147 -72
View File
@@ -1,8 +1,8 @@
import { isContentScriptAllowed } from './RemotePageController'
const PREFIX = '[TabsController]' const PREFIX = '[TabsController]'
function debug(...messages: any[]) { const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`)
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
function sendMessage(message: { function sendMessage(message: {
type: 'TAB_CONTROL' type: 'TAB_CONTROL'
@@ -20,101 +20,102 @@ function sendMessage(message: {
* - live in the agent env (extension page or content script) * - live in the agent env (extension page or content script)
* - no chrome apis. call sw for tab operations * - no chrome apis. call sw for tab operations
*/ */
export class TabsController extends EventTarget { export class TabsController {
currentTabId: number | null = null 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 tabs: TabMeta[] = []
private initialTabId: number | null = null private initialTabId: number | null = null
private tabGroupId: number | null = null private tabGroupId: number | null = null
private experimentalIncludeAllTabs = false
private task: string = '' private task: string = ''
async init(task: string, includeInitialTab: boolean = true) { async init(task: string, options: TabsInitOptions = {}) {
debug('init', task, includeInitialTab) 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.currentTabId = null
this.disposed = false
this.port = null
this.portRetries = 0
this.windowId = null
this.tabs = []
this.tabGroupId = null this.tabGroupId = null
this.initialTabId = null this.initialTabId = null
this.experimentalIncludeAllTabs = experimentalIncludeAllTabs
this.task = task
const result = await sendMessage({ const activeTabResult = await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'get_active_tab', action: 'get_active_tab',
}) })
this.initialTabId = result.tabId this.initialTabId = activeTabResult.tab?.id
this.windowId = activeTabResult.tab?.windowId
if (!this.initialTabId) { if (!this.initialTabId || !this.windowId) {
throw new Error('Failed to get initial tab ID') if (activeTabResult.error) {
throw new Error(activeTabResult.error)
} else {
throw new Error('Failed to get active tab')
}
} }
if (includeInitialTab) { this.connectTabEvents()
this.currentTabId = this.initialTabId
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({ const info = await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'get_tab_info', action: 'get_tab_info',
payload: { tabId: this.initialTabId }, payload: { tabId: this.initialTabId },
}) })
this.tabs.push({ if (isContentScriptAllowed(info.url) && !info.pinned) {
id: result.tabId, this.currentTabId = this.initialTabId
isInitial: true,
url: info.url,
title: info.title,
status: info.status,
})
await this.createTabGroup([this.initialTabId]) this.addTab({
id: this.initialTabId,
isInitial: true,
url: info.url,
title: info.title,
status: info.status,
})
await this.createTabGroup([this.initialTabId])
}
} }
await this.updateCurrentTabId(this.currentTabId) 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> { async openNewTab(url: string): Promise<string> {
@@ -132,7 +133,7 @@ export class TabsController extends EventTarget {
const tabId = result.tabId as number const tabId = result.tabId as number
this.tabs.push({ this.addTab({
id: tabId, id: tabId,
isInitial: false, isInitial: false,
}) })
@@ -205,7 +206,7 @@ export class TabsController extends EventTarget {
const result = await sendMessage({ const result = await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'create_tab_group', action: 'create_tab_group',
payload: { tabIds }, payload: { tabIds, windowId: this.windowId },
}) })
if (!result?.success) { if (!result?.success) {
@@ -228,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) { async updateCurrentTabId(tabId: number | null) {
debug('updateCurrentTabId', tabId) debug('updateCurrentTabId', tabId)
@@ -284,9 +290,77 @@ export class TabsController extends EventTarget {
await waitUntil(() => tab.status === 'complete', 4_000) 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 = export type TabAction =
@@ -298,6 +372,7 @@ export type TabAction =
| 'add_tab_to_group' | 'add_tab_to_group'
| 'close_tab' | 'close_tab'
| 'get_tab_title' | 'get_tab_title'
| 'get_window_tabs'
interface TabMeta { interface TabMeta {
id: number id: number
+2 -2
View File
@@ -3,12 +3,12 @@ import type { LLMConfig } from '@page-agent/llms'
// Demo LLM for testing // Demo LLM for testing
export const DEMO_MODEL = 'qwen3.5-plus' export const DEMO_MODEL = 'qwen3.5-plus'
export const DEMO_BASE_URL = 'https://page-ag-testing-ohftxirgbn.cn-shanghai.fcapp.run' export const DEMO_BASE_URL = 'https://page-ag-testing-ohftxirgbn.cn-shanghai.fcapp.run'
export const DEMO_API_KEY = 'NA' // export const DEMO_API_KEY = 'NA'
export const DEMO_CONFIG: LLMConfig = { export const DEMO_CONFIG: LLMConfig = {
apiKey: DEMO_API_KEY,
baseURL: DEMO_BASE_URL, baseURL: DEMO_BASE_URL,
model: DEMO_MODEL, model: DEMO_MODEL,
// apiKey: DEMO_API_KEY,
} }
/** Legacy testing endpoints that should be auto-migrated to DEMO_BASE_URL */ /** Legacy testing endpoints that should be auto-migrated to DEMO_BASE_URL */
+11 -1
View File
@@ -21,6 +21,8 @@ export interface AdvancedConfig {
maxSteps?: number maxSteps?: number
systemInstruction?: string systemInstruction?: string
experimentalLlmsTxt?: boolean experimentalLlmsTxt?: boolean
experimentalIncludeAllTabs?: boolean
disableNamedToolChoice?: boolean
} }
export interface ExtConfig extends LLMConfig, AdvancedConfig { export interface ExtConfig extends LLMConfig, AdvancedConfig {
@@ -124,6 +126,8 @@ export function useAgent(): UseAgentResult {
maxSteps, maxSteps,
systemInstruction, systemInstruction,
experimentalLlmsTxt, experimentalLlmsTxt,
experimentalIncludeAllTabs,
disableNamedToolChoice,
...llmConfig ...llmConfig
}: ExtConfig) => { }: ExtConfig) => {
await chrome.storage.local.set({ llmConfig }) await chrome.storage.local.set({ llmConfig })
@@ -132,7 +136,13 @@ export function useAgent(): UseAgentResult {
} else { } else {
await chrome.storage.local.remove('language') await chrome.storage.local.remove('language')
} }
const advancedConfig: AdvancedConfig = { maxSteps, systemInstruction, experimentalLlmsTxt } const advancedConfig: AdvancedConfig = {
maxSteps,
systemInstruction,
experimentalLlmsTxt,
experimentalIncludeAllTabs,
disableNamedToolChoice,
}
await chrome.storage.local.set({ advancedConfig }) await chrome.storage.local.set({ advancedConfig })
setConfig({ ...llmConfig, ...advancedConfig, language }) setConfig({ ...llmConfig, ...advancedConfig, language })
}, },
@@ -1,18 +1,20 @@
import { import {
ChevronDown,
Copy, Copy,
CornerUpLeft, CornerUpLeft,
ExternalLink,
Eye, Eye,
EyeOff, EyeOff,
FoldVertical,
HatGlasses, HatGlasses,
Home, Home,
Loader2, Loader2,
Scale, Scale,
UnfoldVertical,
} from 'lucide-react' } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { siGithub } from 'simple-icons' import { siGithub } from 'simple-icons'
import { DEMO_API_KEY, DEMO_BASE_URL, DEMO_MODEL, isTestingEndpoint } from '@/agent/constants' import { DEMO_BASE_URL, DEMO_MODEL, isTestingEndpoint } from '@/agent/constants'
import type { ExtConfig, LanguagePreference } from '@/agent/useAgent' import type { ExtConfig, LanguagePreference } from '@/agent/useAgent'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -25,30 +27,38 @@ interface ConfigPanelProps {
} }
export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) { export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
const [apiKey, setApiKey] = useState(config?.apiKey || DEMO_API_KEY)
const [baseURL, setBaseURL] = useState(config?.baseURL || DEMO_BASE_URL) const [baseURL, setBaseURL] = useState(config?.baseURL || DEMO_BASE_URL)
const [model, setModel] = useState(config?.model || DEMO_MODEL) const [model, setModel] = useState(config?.model || DEMO_MODEL)
const [apiKey, setApiKey] = useState(config?.apiKey)
const [language, setLanguage] = useState<LanguagePreference>(config?.language) 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 [systemInstruction, setSystemInstruction] = useState(config?.systemInstruction ?? '')
const [experimentalLlmsTxt, setExperimentalLlmsTxt] = useState( const [experimentalLlmsTxt, setExperimentalLlmsTxt] = useState(
config?.experimentalLlmsTxt ?? false config?.experimentalLlmsTxt ?? false
) )
const [experimentalIncludeAllTabs, setExperimentalIncludeAllTabs] = useState(
config?.experimentalIncludeAllTabs ?? false
)
const [disableNamedToolChoice, setDisableNamedToolChoice] = useState(
config?.disableNamedToolChoice ?? false
)
const [advancedOpen, setAdvancedOpen] = useState(false) const [advancedOpen, setAdvancedOpen] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [userAuthToken, setUserAuthToken] = useState<string>('') const [userAuthToken, setUserAuthToken] = useState('')
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [showToken, setShowToken] = useState(false) const [showToken, setShowToken] = useState(false)
const [showApiKey, setShowApiKey] = useState(false) const [showApiKey, setShowApiKey] = useState(false)
useEffect(() => { useEffect(() => {
setApiKey(config?.apiKey || DEMO_API_KEY)
setBaseURL(config?.baseURL || DEMO_BASE_URL) setBaseURL(config?.baseURL || DEMO_BASE_URL)
setModel(config?.model || DEMO_MODEL) setModel(config?.model || DEMO_MODEL)
setApiKey(config?.apiKey)
setLanguage(config?.language) setLanguage(config?.language)
setMaxSteps(config?.maxSteps) setMaxSteps(config?.maxSteps)
setSystemInstruction(config?.systemInstruction ?? '') setSystemInstruction(config?.systemInstruction ?? '')
setExperimentalLlmsTxt(config?.experimentalLlmsTxt ?? false) setExperimentalLlmsTxt(config?.experimentalLlmsTxt ?? false)
setExperimentalIncludeAllTabs(config?.experimentalIncludeAllTabs ?? false)
setDisableNamedToolChoice(config?.disableNamedToolChoice ?? false)
}, [config]) }, [config])
// Poll for user auth token every second until found // Poll for user auth token every second until found
@@ -94,6 +104,8 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
maxSteps: maxSteps || undefined, maxSteps: maxSteps || undefined,
systemInstruction: systemInstruction || undefined, systemInstruction: systemInstruction || undefined,
experimentalLlmsTxt, experimentalLlmsTxt,
experimentalIncludeAllTabs,
disableNamedToolChoice,
}) })
} finally { } finally {
setSaving(false) setSaving(false)
@@ -153,6 +165,16 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
</div> </div>
</div> </div>
{/* Hub link */}
<a
href="/hub.html"
target="_blank"
className="flex items-center justify-between p-3 rounded-md border bg-muted/50 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
>
Manage Page Agent Hub
<ExternalLink className="size-3" />
</a>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Base URL</label> <label className="text-xs text-muted-foreground">Base URL</label>
<Input <Input
@@ -167,7 +189,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
{isTestingEndpoint(baseURL) && ( {isTestingEndpoint(baseURL) && (
<div className="p-2.5 rounded-md border border-amber-500/30 bg-amber-500/5 text-[11px] text-muted-foreground leading-relaxed"> <div className="p-2.5 rounded-md border border-amber-500/30 bg-amber-500/5 text-[11px] text-muted-foreground leading-relaxed">
<Scale className="size-3 inline-block mr-1 -mt-0.5 text-amber-600" /> <Scale className="size-3 inline-block mr-1 -mt-0.5 text-amber-600" />
You are using the free testing API. By using this service you agree to the{' '} You are using our testing API. By using this you agree to the{' '}
<a <a
href="https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md" href="https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md"
target="_blank" target="_blank"
@@ -176,14 +198,13 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
> >
Terms of Use & Privacy Policy Terms of Use & Privacy Policy
</a> </a>
. No sensitive data. No guaranteed availability.
</div> </div>
)} )}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Model</label> <label className="text-xs text-muted-foreground">Model</label>
<Input <Input
placeholder="gpt-5.2" placeholder="gpt-5.1"
value={model} value={model}
onChange={(e) => setModel(e.target.value)} onChange={(e) => setModel(e.target.value)}
className="text-xs h-8" className="text-xs h-8"
@@ -195,7 +216,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Input <Input
type={showApiKey ? 'text' : 'password'} type={showApiKey ? 'text' : 'password'}
placeholder="sk-..." // placeholder="sk-..."
value={apiKey} value={apiKey}
onChange={(e) => setApiKey(e.target.value)} onChange={(e) => setApiKey(e.target.value)}
className="text-xs h-8" className="text-xs h-8"
@@ -212,7 +233,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Language</label> <label className="text-xs text-muted-foreground">Response Language</label>
<select <select
value={language ?? ''} value={language ?? ''}
onChange={(e) => setLanguage((e.target.value || undefined) as LanguagePreference)} onChange={(e) => setLanguage((e.target.value || undefined) as LanguagePreference)}
@@ -231,10 +252,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground cursor-pointer mt-1 font-bold" className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground cursor-pointer mt-1 font-bold"
> >
Advanced Advanced
<ChevronDown {advancedOpen ? <FoldVertical className="size-3" /> : <UnfoldVertical className="size-3" />}
className="size-3 transition-transform"
style={{ transform: advancedOpen ? 'rotate(0deg)' : 'rotate(90deg)' }}
/>
</button> </button>
{advancedOpen && ( {advancedOpen && (
@@ -263,10 +281,23 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
/> />
</div> </div>
<label className="flex items-center justify-between cursor-pointer">
<span className="text-xs text-muted-foreground">Disable named tool_choice</span>
<Switch checked={disableNamedToolChoice} onCheckedChange={setDisableNamedToolChoice} />
</label>
<label className="flex items-center justify-between cursor-pointer"> <label className="flex items-center justify-between cursor-pointer">
<span className="text-xs text-muted-foreground">Experimental llms.txt support</span> <span className="text-xs text-muted-foreground">Experimental llms.txt support</span>
<Switch checked={experimentalLlmsTxt} onCheckedChange={setExperimentalLlmsTxt} /> <Switch checked={experimentalLlmsTxt} onCheckedChange={setExperimentalLlmsTxt} />
</label> </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>
</> </>
)} )}
@@ -1,12 +1,20 @@
import { ArrowLeft } from 'lucide-react' import { ArrowLeft, RotateCcw, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { type SessionRecord, getSession } from '@/lib/db' import { type SessionRecord, deleteSession, getSession } from '@/lib/db'
import { EventCard } from './cards' import { EventCard } from './cards'
export function HistoryDetail({ sessionId, onBack }: { sessionId: string; onBack: () => void }) { export function HistoryDetail({
sessionId,
onBack,
onRerun,
}: {
sessionId: string
onBack: () => void
onRerun: (task: string) => void
}) {
const [session, setSession] = useState<SessionRecord | null>(null) const [session, setSession] = useState<SessionRecord | null>(null)
useEffect(() => { useEffect(() => {
@@ -37,6 +45,27 @@ export function HistoryDetail({ sessionId, onBack }: { sessionId: string; onBack
<div className="text-xs font-medium" title={session.task}> <div className="text-xs font-medium" title={session.task}>
{session.task} {session.task}
</div> </div>
<div className="mt-2 flex items-center gap-2">
<button
type="button"
onClick={() => onRerun(session.task)}
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
<RotateCcw className="size-3" />
Run again
</button>
<button
type="button"
onClick={async () => {
await deleteSession(sessionId)
onBack()
}}
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-destructive transition-colors cursor-pointer"
>
<Trash2 className="size-3" />
Delete
</button>
</div>
</div> </div>
{/* Events (read-only) */} {/* Events (read-only) */}
@@ -1,8 +1,9 @@
import { ArrowLeft, CheckCircle, Trash2, XCircle } from 'lucide-react' import { ArrowDownToLine, ArrowLeft, CheckCircle, RotateCcw, Trash2, XCircle } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { type SessionRecord, clearSessions, deleteSession, listSessions } from '@/lib/db' import { type SessionRecord, clearSessions, deleteSession, listSessions } from '@/lib/db'
import { downloadHistoryExport } from '@/lib/history-export'
function timeAgo(ts: number): string { function timeAgo(ts: number): string {
const seconds = Math.floor((Date.now() - ts) / 1000) const seconds = Math.floor((Date.now() - ts) / 1000)
@@ -18,9 +19,11 @@ function timeAgo(ts: number): string {
export function HistoryList({ export function HistoryList({
onSelect, onSelect,
onBack, onBack,
onRerun,
}: { }: {
onSelect: (id: string) => void onSelect: (id: string) => void
onBack: () => void onBack: () => void
onRerun: (task: string) => void
}) { }) {
const [sessions, setSessions] = useState<SessionRecord[]>([]) const [sessions, setSessions] = useState<SessionRecord[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -41,6 +44,16 @@ export function HistoryList({
setSessions((prev) => prev.filter((s) => s.id !== id)) setSessions((prev) => prev.filter((s) => s.id !== id))
} }
const handleExport = (e: React.MouseEvent, session: SessionRecord) => {
e.stopPropagation()
downloadHistoryExport(session.task, session.createdAt, session.history)
}
const handleRerun = (e: React.MouseEvent, task: string) => {
e.stopPropagation()
onRerun(task)
}
return ( return (
<div className="flex flex-col h-screen bg-background"> <div className="flex flex-col h-screen bg-background">
{/* Header */} {/* Header */}
@@ -85,7 +98,6 @@ export function HistoryList({
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => onSelect(session.id)} onClick={() => onSelect(session.id)}
onKeyDown={(e) => e.key === 'Enter' && onSelect(session.id)}
className="w-full text-left px-3 py-2.5 border-b hover:bg-muted/50 transition-colors cursor-pointer flex items-start gap-2 group" className="w-full text-left px-3 py-2.5 border-b hover:bg-muted/50 transition-colors cursor-pointer flex items-start gap-2 group"
> >
{/* Status icon */} {/* Status icon */}
@@ -98,19 +110,41 @@ export function HistoryList({
{/* Content */} {/* Content */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{session.task}</p> <p className="text-xs font-medium truncate">{session.task}</p>
<p className="text-[10px] text-muted-foreground mt-0.5"> <div className="flex items-center mt-0.5">
{timeAgo(session.createdAt)} · {session.history.length} steps <p className="text-[10px] text-muted-foreground">
</p> {timeAgo(session.createdAt)} · {session.history.length} steps
</p>
<div className="flex items-center gap-0.5 ml-auto opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={(e) => handleRerun(e, session.task)}
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
title="Run task again"
aria-label={`Run history task again: ${session.task}`}
>
<RotateCcw className="size-3" />
</button>
<button
type="button"
onClick={(e) => handleExport(e, session)}
className="p-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
title="Export history JSON"
aria-label={`Export history for ${session.task}`}
>
<ArrowDownToLine className="size-3" />
</button>
<button
type="button"
onClick={(e) => handleDelete(e, session.id)}
className="p-0.5 text-muted-foreground hover:text-destructive transition-colors cursor-pointer"
title="Delete history"
aria-label={`Delete history for ${session.task}`}
>
<Trash2 className="size-3" />
</button>
</div>
</div>
</div> </div>
{/* Delete */}
<button
type="button"
onClick={(e) => handleDelete(e, session.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:text-destructive cursor-pointer shrink-0"
>
<Trash2 className="size-3" />
</button>
</div> </div>
))} ))}
</div> </div>
+2 -1
View File
@@ -84,7 +84,7 @@ export function MotionOverlay({ active }: { active: boolean }) {
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className="pointer-events-none absolute inset-0 z-10 opacity-60" className="pointer-events-none absolute inset-0 z-10 opacity-60 overflow-hidden"
style={{ display: active ? undefined : 'none' }} style={{ display: active ? undefined : 'none' }}
/> />
) )
@@ -111,6 +111,7 @@ export function EmptyState() {
]} ]}
cursorStyle="underscore" cursorStyle="underscore"
loop loop
startOnView={false}
typeSpeed={20} typeSpeed={20}
deleteSpeed={10} deleteSpeed={10}
pauseDelay={3000} pauseDelay={3000}
@@ -8,7 +8,7 @@ function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimi
<SwitchPrimitive.Root <SwitchPrimitive.Root
data-slot="switch" data-slot="switch"
className={cn( className={cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50', 'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className className
)} )}
{...props} {...props}
@@ -1,12 +1,12 @@
import { handlePageControlMessage } from '@/agent/RemotePageController.background' import { handlePageControlMessage } from '@/agent/RemotePageController.background'
import { handleTabControlMessage, setupTabChangeEvents } from '@/agent/TabsController.background' import { handleTabControlMessage, setupTabEventsPort } from '@/agent/TabsController.background'
export default defineBackground(() => { export default defineBackground(() => {
console.log('[Background] Service Worker started') console.log('[Background] Service Worker started')
// tab change events // tab change events
setupTabChangeEvents() setupTabEventsPort()
// generate user auth token // generate user auth token
@@ -46,6 +46,8 @@ async function exposeAgentToPage() {
let multiPageAgent: InstanceType<typeof MultiPageAgent> | null = null let multiPageAgent: InstanceType<typeof MultiPageAgent> | null = null
window.addEventListener('message', async (e) => { window.addEventListener('message', async (e) => {
if (e.source !== window) return
const data = e.data const data = e.data
if (typeof data !== 'object' || data === null) return if (typeof data !== 'object' || data === null) return
if (data.channel !== 'PAGE_AGENT_EXT_REQUEST') return if (data.channel !== 'PAGE_AGENT_EXT_REQUEST') return
@@ -70,11 +72,15 @@ async function exposeAgentToPage() {
try { try {
const { task, config } = payload const { task, config } = payload
const { systemInstruction, ...agentConfig } = config
// Dispose old instance before creating new one // Dispose old instance before creating new one
multiPageAgent?.dispose() multiPageAgent?.dispose()
multiPageAgent = new MultiPageAgent(config) multiPageAgent = new MultiPageAgent({
...agentConfig,
instructions: systemInstruction ? { system: systemInstruction } : undefined,
})
// events // events
+155 -56
View File
@@ -1,10 +1,11 @@
import { Plug, PlugZap, Square, Unplug } from 'lucide-react' import { FoldVertical, Plug, PlugZap, Square, UnfoldVertical, Unplug } from 'lucide-react'
import { useEffect, useRef } from 'react' import { useEffect, useRef, useState } from 'react'
import { useAgent } from '@/agent/useAgent' import { useAgent } from '@/agent/useAgent'
import { ActivityCard, EventCard } from '@/components/cards' import { ActivityCard, EventCard } from '@/components/cards'
import { Logo, MotionOverlay, StatusDot } from '@/components/misc' import { Logo, MotionOverlay, StatusDot } from '@/components/misc'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { useHubWs } from './hub-ws' import { useHubWs } from './hub-ws'
@@ -25,33 +26,72 @@ export default function App() {
const wsLabel = { const wsLabel = {
connected: 'Connected', connected: 'Connected',
connecting: 'Connecting…', connecting: 'Connecting…',
disconnected: new URLSearchParams(location.search).get('ws') ? 'Disconnected' : 'Standalone', disconnected: new URLSearchParams(location.search).get('ws') ? 'Disconnected' : 'No connection',
}[wsState] }[wsState]
return ( return (
<div className="flex h-screen bg-background"> <div className="flex h-screen bg-background">
<MotionOverlay active={isRunning} />
{/* Left — Protocol docs */} {/* Left — Protocol docs */}
<aside className="w-80 shrink-0 border-r flex flex-col bg-muted/20"> <aside className="w-80 shrink-0 border-r flex flex-col bg-muted/20">
<div className="flex items-center gap-2 px-5 py-4 border-b"> <a
href="https://alibaba.github.io/page-agent/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-5 h-12 border-b hover:bg-muted/30 transition-colors"
>
<Logo className="size-5" /> <Logo className="size-5" />
<span className="text-sm font-semibold tracking-tight">Page Agent Hub</span> <span className="text-sm font-semibold tracking-tight">Page Agent Hub</span>
<span className="text-[9px] font-medium uppercase tracking-wider text-amber-600 bg-amber-500/10 border border-amber-500/30 rounded px-1.5 py-0.5">
Beta
</span>
</a>
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-6">
<div className="text-xs text-muted-foreground leading-relaxed space-y-2">
<p>
Page Agent Hub lets local apps (e.g. MCP servers) control the Page Agent extension via
WebSocket.
</p>
<p>
Check out the official{' '}
<a
href="https://github.com/alibaba/page-agent/tree/main/packages/mcp"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
MCP server package
</a>
.
</p>
</div>
<HubConfig />
<ProtocolDocsCollapsible />
</div> </div>
<div className="flex-1 overflow-y-auto px-5 py-4"> <div className="border-t px-5 py-3 text-[10px] text-muted-foreground/60 flex items-center justify-between">
<ProtocolDocs /> <span className="font-mono">v{__VERSION__}</span>
</div> <span>
Built with by{' '}
<div className="border-t px-5 py-3 text-[11px] text-muted-foreground/60"> <a
Connect via <code className="text-[10px]">hub.html?ws=PORT</code> href="https://github.com/gaomeng1900"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
@Simon
</a>
</span>
</div> </div>
</aside> </aside>
{/* Right — Live session */} {/* Right — Live session */}
<main className="flex-1 flex flex-col min-w-0"> <main className="flex-1 flex flex-col min-w-0 relative">
{/* Header bar */} <MotionOverlay active={isRunning} />
<header className="flex items-center justify-between border-b px-5 py-3">
<header className="flex items-center justify-between border-b px-5 h-12">
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<WsIcon className="size-3.5" /> <WsIcon className="size-3.5" />
<span>{wsLabel}</span> <span>{wsLabel}</span>
@@ -104,49 +144,108 @@ export default function App() {
) )
} }
function ProtocolDocs() { function HubConfig() {
const [allowAll, setAllowAll] = useState(false)
useEffect(() => {
chrome.storage.local.get('allowAllHubConnection').then((r) => {
setAllowAll(r.allowAllHubConnection === true)
})
}, [])
const toggle = (checked: boolean) => {
setAllowAll(checked)
chrome.storage.local.set({ allowAllHubConnection: checked })
}
return ( return (
<div className="space-y-5 text-xs text-muted-foreground"> <div>
<section> <h3 className="text-[11px] font-semibold text-foreground/80 uppercase tracking-wider mb-2">
<h3 className="text-[11px] font-semibold text-foreground/80 uppercase tracking-wider mb-2"> Config
Caller Hub </h3>
</h3> <div className="group/hub relative">
<pre className="bg-muted/50 rounded-md p-3 font-mono text-[10px] leading-relaxed whitespace-pre-wrap"> <label
{`{ type: "execute", task: string, config?: object } className={`flex items-center justify-between p-3 rounded-md border cursor-pointer text-xs ${allowAll ? 'bg-amber-500/10 border-amber-500/30 text-amber-600' : 'bg-muted/50 text-muted-foreground'}`}
{ type: "stop" }`} >
</pre> Auto-approve connections
</section> <Switch
checked={allowAll}
onCheckedChange={toggle}
className={allowAll ? 'data-[state=checked]:bg-amber-500' : ''}
/>
</label>
<section> {/* hide with invisible absolute opacity-0*/}
<h3 className="text-[11px] font-semibold text-foreground/80 uppercase tracking-wider mb-2"> <div className="group-hover/hub:visible group-hover/hub:opacity-100 transition-opacity duration-150 left-0 right-0 top-full z-10 pt-2">
Hub Caller <div className="relative p-2.5 rounded-md border border-border bg-background/60 backdrop-blur-md shadow-2xl text-muted-foreground text-xs leading-relaxed">
</h3> <div className="absolute -top-1.5 left-5 size-3 rotate-45 rounded-[1px] border-l border-t border-border bg-background/60 backdrop-blur-md" />
<pre className="bg-muted/50 rounded-md p-3 font-mono text-[10px] leading-relaxed whitespace-pre-wrap"> By default, each connection requires your approval before running tasks. <br />
{`{ type: "ready" } Enable this to skip per-session approval.
{ type: "result", success: boolean, data: string } <br />
{ type: "error", message: string }`} <span className="font-semibold">* Use with caution!</span>
</pre> </div>
</section> </div>
</div>
<section> </div>
<h3 className="text-[11px] font-semibold text-foreground/80 uppercase tracking-wider mb-2"> )
Flow }
</h3>
<ol className="list-decimal list-inside space-y-1 text-[11px] leading-relaxed"> function ProtocolDocsCollapsible() {
<li>Hub opens WS to caller's server</li> const [open, setOpen] = useState(false)
<li>
Sends <code className="text-[10px]">ready</code> return (
</li> <div>
<li> <button
Caller sends <code className="text-[10px]">execute</code> with task type="button"
</li> onClick={() => setOpen(!open)}
<li>Hub runs agent, streams events</li> className="flex items-center gap-1 text-[11px] font-semibold text-foreground/80 uppercase tracking-wider cursor-pointer"
<li> >
Hub sends <code className="text-[10px]">result</code> or{' '} Docs
<code className="text-[10px]">error</code> {open ? <FoldVertical className="size-3" /> : <UnfoldVertical className="size-3" />}
</li> </button>
</ol>
</section> {open && (
<div className="mt-3 space-y-4 text-xs text-muted-foreground">
<p className="text-[10px]">
Connect via <code className="text-[10px]">hub.html?ws=PORT</code>
</p>
<section>
<h4 className="text-[11px] font-medium text-foreground/60 mb-1.5">Flow</h4>
<ol className="list-decimal list-inside space-y-1 text-[11px] leading-relaxed">
<li>Hub opens WS to caller's server</li>
<li>
Sends <code className="text-[10px]">ready</code>
</li>
<li>
Caller sends <code className="text-[10px]">execute</code> with task
</li>
<li>Hub runs agent, streams events</li>
<li>
Hub sends <code className="text-[10px]">result</code> or{' '}
<code className="text-[10px]">error</code>
</li>
</ol>
</section>
<section>
<h4 className="text-[11px] font-medium text-foreground/60 mb-1.5">Caller Hub</h4>
<pre className="bg-muted/50 rounded-md p-3 font-mono text-[10px] leading-relaxed whitespace-pre-wrap">
{`{ type: "execute", task: string, config?: object }
{ type: "stop" }`}
</pre>
</section>
<section>
<h4 className="text-[11px] font-medium text-foreground/60 mb-1.5">Hub Caller</h4>
<pre className="bg-muted/50 rounded-md p-3 font-mono text-[10px] leading-relaxed whitespace-pre-wrap">
{`{ type: "ready" }
{ type: "result", success: boolean, data: string }
{ type: "error", message: string }`}
</pre>
</section>
</div>
)}
</div> </div>
) )
} }
@@ -70,6 +70,7 @@ export class HubWs {
#ws: WebSocket | null = null #ws: WebSocket | null = null
#state: HubWsState = 'disconnected' #state: HubWsState = 'disconnected'
#busy = false #busy = false
#approved = false
#handlers: HubWsHandlers #handlers: HubWsHandlers
#port: number #port: number
#onStateChange: (state: HubWsState) => void #onStateChange: (state: HubWsState) => void
@@ -103,6 +104,7 @@ export class HubWs {
ws.addEventListener('close', () => { ws.addEventListener('close', () => {
this.#ws = null this.#ws = null
this.#busy = false this.#busy = false
this.#approved = false
this.#setState('disconnected') this.#setState('disconnected')
}) })
@@ -115,6 +117,7 @@ export class HubWs {
this.#ws?.close() this.#ws?.close()
this.#ws = null this.#ws = null
this.#busy = false this.#busy = false
this.#approved = false
this.#setState('disconnected') this.#setState('disconnected')
} }
@@ -130,7 +133,7 @@ export class HubWs {
} }
} }
#handleMessage(raw: string) { async #handleMessage(raw: string) {
let msg: InboundMessage let msg: InboundMessage
try { try {
msg = JSON.parse(raw) msg = JSON.parse(raw)
@@ -138,6 +141,11 @@ export class HubWs {
return return
} }
if (!(await this.#checkApproval())) {
this.#send({ type: 'error', message: 'User denied the connection request.' })
return
}
switch (msg.type) { switch (msg.type) {
case 'execute': case 'execute':
this.#handleExecute(msg) this.#handleExecute(msg)
@@ -148,6 +156,22 @@ export class HubWs {
} }
} }
async #checkApproval(): Promise<boolean> {
if (this.#approved) return true
const { allowAllHubConnection } = await chrome.storage.local.get('allowAllHubConnection')
if (allowAllHubConnection === true) {
this.#approved = true
return true
}
const ok = window.confirm(
'An external application is requesting to control your browser via Page Agent Ext.\nAllow this session?'
)
if (ok) this.#approved = true
return ok
}
async #handleExecute(msg: ExecuteMessage) { async #handleExecute(msg: ExecuteMessage) {
if (this.#busy) { if (this.#busy) {
this.#send({ type: 'error', message: 'Hub is busy with another task' }) this.#send({ type: 'error', message: 'Hub is busy with another task' })
@@ -4,8 +4,14 @@ export type Execute = (task: string, config: ExecuteConfig) => Promise<Execution
export interface ExecuteConfig { export interface ExecuteConfig {
baseURL: string baseURL: string
apiKey: string
model: string 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. * Whether to include the initial tab (that holds this main world script) in the task.
@@ -13,6 +19,9 @@ export interface ExecuteConfig {
*/ */
includeInitialTab?: boolean includeInitialTab?: boolean
/** Control all unpinned tabs in the window instead of only the tab group. */
experimentalIncludeAllTabs?: boolean
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void
@@ -30,13 +39,14 @@ export default defineUnlistedScript(() => {
if (task.trim().length === 0) throw new Error('Task cannot be empty') if (task.trim().length === 0) throw new Error('Task cannot be empty')
if (!config) throw new Error('Config is required') if (!config) throw new Error('Config is required')
if (!config.baseURL) throw new Error('Config must have a baseURL') if (!config.baseURL) throw new Error('Config must have a baseURL')
if (!config.apiKey) throw new Error('Config must have an apiKey')
if (!config.model) throw new Error('Config must have a model') if (!config.model) throw new Error('Config must have a model')
const id = getId() const id = getId()
const promise = new Promise<ExecutionResult>((resolve, reject) => { const promise = new Promise<ExecutionResult>((resolve, reject) => {
function handleMessage(e: MessageEvent) { function handleMessage(e: MessageEvent) {
if (e.source !== window) return
const data = e.data const data = e.data
if (typeof data !== 'object' || data === null) return if (typeof data !== 'object' || data === null) return
if (data.channel !== 'PAGE_AGENT_EXT_RESPONSE') return if (data.channel !== 'PAGE_AGENT_EXT_RESPONSE') return
@@ -85,9 +95,11 @@ export default defineUnlistedScript(() => {
task, task,
config: { config: {
baseURL: config.baseURL, baseURL: config.baseURL,
apiKey: config.apiKey,
model: config.model, model: config.model,
apiKey: config.apiKey,
systemInstruction: config.systemInstruction,
includeInitialTab: config.includeInitialTab, includeInitialTab: config.includeInitialTab,
experimentalIncludeAllTabs: config.experimentalIncludeAllTabs,
}, },
}, },
}, },
@@ -56,19 +56,27 @@ export default function App() {
} }
}, [history, activity]) }, [history, activity])
const handleSubmit = useCallback( const runTask = useCallback(
(e?: React.SyntheticEvent) => { (task: string) => {
e?.preventDefault() const normalizedTask = task.trim()
if (!inputValue.trim() || status === 'running') return if (!normalizedTask || status === 'running') return
const taskToExecute = inputValue.trim()
setInputValue('') setInputValue('')
setView({ name: 'chat' })
execute(taskToExecute).catch((error) => { execute(normalizedTask).catch((error) => {
console.error('[SidePanel] Failed to execute task:', error) console.error('[SidePanel] Failed to execute task:', error)
}) })
}, },
[inputValue, status, execute] [execute, status]
)
const handleSubmit = useCallback(
(e?: React.SyntheticEvent) => {
e?.preventDefault()
runTask(inputValue)
},
[inputValue, runTask]
) )
const handleStop = useCallback(() => { const handleStop = useCallback(() => {
@@ -103,12 +111,19 @@ export default function App() {
<HistoryList <HistoryList
onSelect={(id) => setView({ name: 'history-detail', sessionId: id })} onSelect={(id) => setView({ name: 'history-detail', sessionId: id })}
onBack={() => setView({ name: 'chat' })} onBack={() => setView({ name: 'chat' })}
onRerun={runTask}
/> />
) )
} }
if (view.name === 'history-detail') { if (view.name === 'history-detail') {
return <HistoryDetail sessionId={view.sessionId} onBack={() => setView({ name: 'history' })} /> return (
<HistoryDetail
sessionId={view.sessionId}
onBack={() => setView({ name: 'history' })}
onRerun={runTask}
/>
)
} }
// --- Chat view --- // --- Chat view ---
@@ -0,0 +1,60 @@
import type { HistoricalEvent } from '@page-agent/core'
const EXPORT_FILE_PREFIX = 'page-agent-history'
const MAX_TASK_SLUG_LENGTH = 40
export function serializeHistoryExport(history: HistoricalEvent[]): string {
return `${JSON.stringify(history, null, 2)}\n`
}
export function buildHistoryExportFilename(task: string, createdAt: number): string {
const taskSlug = sanitizeTaskForFilename(task)
const timestamp = formatTimestampForFilename(createdAt)
return taskSlug
? `${EXPORT_FILE_PREFIX}-${taskSlug}-${timestamp}.json`
: `${EXPORT_FILE_PREFIX}-${timestamp}.json`
}
export function downloadHistoryExport(
task: string,
createdAt: number,
history: HistoricalEvent[]
): void {
const filename = buildHistoryExportFilename(task, createdAt)
const content = serializeHistoryExport(history)
const blob = new Blob([content], { type: 'application/json;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}
function sanitizeTaskForFilename(task: string): string {
return task
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, MAX_TASK_SLUG_LENGTH)
}
function formatTimestampForFilename(createdAt: number): string {
const date = new Date(createdAt)
const year = date.getFullYear()
const month = pad(date.getMonth() + 1)
const day = pad(date.getDate())
const hours = pad(date.getHours())
const minutes = pad(date.getMinutes())
const seconds = pad(date.getSeconds())
return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`
}
function pad(value: number): string {
return value.toString().padStart(2, '0')
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/llms", "name": "@page-agent/llms",
"version": "1.5.9", "version": "1.7.1",
"type": "module", "type": "module",
"main": "./dist/lib/page-agent-llms.js", "main": "./dist/lib/page-agent-llms.js",
"module": "./dist/lib/page-agent-llms.js", "module": "./dist/lib/page-agent-llms.js",
+8 -5
View File
@@ -29,16 +29,19 @@ export class OpenAIClient implements LLMClient {
const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t)) const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t))
// Build request body // Build request body
let toolChoice: unknown = 'required'
if (options?.toolChoiceName && !this.config.disableNamedToolChoice) {
toolChoice = { type: 'function', function: { name: options.toolChoiceName } }
}
const requestBody: Record<string, unknown> = { const requestBody: Record<string, unknown> = {
model: this.config.model, model: this.config.model,
temperature: this.config.temperature, temperature: this.config.temperature,
messages, messages,
tools: openaiTools, tools: openaiTools,
parallel_tool_calls: false, parallel_tool_calls: false,
// Require tool call: specific tool if provided, otherwise any tool tool_choice: toolChoice,
tool_choice: options?.toolChoiceName
? { type: 'function', function: { name: options.toolChoiceName } }
: 'required',
} }
modelPatch(requestBody) modelPatch(requestBody)
@@ -50,7 +53,7 @@ export class OpenAIClient implements LLMClient {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${this.config.apiKey}`, ...(this.config.apiKey && { Authorization: `Bearer ${this.config.apiKey}` }),
}, },
body: JSON.stringify(requestBody), body: JSON.stringify(requestBody),
signal: abortSignal, signal: abortSignal,
+4 -3
View File
@@ -8,19 +8,20 @@ export type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool }
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> { export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
// Runtime validation as defensive programming (types already guarantee these) // Runtime validation as defensive programming (types already guarantee these)
if (!config.baseURL || !config.apiKey || !config.model) { if (!config.baseURL || !config.model) {
throw new Error( throw new Error(
'[PageAgent] LLM configuration required. Please provide: baseURL, apiKey, model. ' + '[PageAgent] LLM configuration required. Please provide: baseURL, model. ' +
'See: https://alibaba.github.io/page-agent/docs/features/models' 'See: https://alibaba.github.io/page-agent/docs/features/models'
) )
} }
return { return {
baseURL: config.baseURL, baseURL: config.baseURL,
apiKey: config.apiKey,
model: config.model, model: config.model,
apiKey: config.apiKey || '',
temperature: config.temperature ?? DEFAULT_TEMPERATURE, temperature: config.temperature ?? DEFAULT_TEMPERATURE,
maxRetries: config.maxRetries ?? LLM_MAX_RETRIES, maxRetries: config.maxRetries ?? LLM_MAX_RETRIES,
disableNamedToolChoice: config.disableNamedToolChoice ?? false,
customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound
} }
} }
+7 -1
View File
@@ -89,12 +89,18 @@ export interface InvokeResult<TResult = unknown> {
*/ */
export interface LLMConfig { export interface LLMConfig {
baseURL: string baseURL: string
apiKey: string
model: string model: string
apiKey?: string
temperature?: number temperature?: number
maxRetries?: number maxRetries?: number
/**
* remove the tool_choice field from the request.
* @note fix "Invalid tool_choice type: 'object'" for some LLMs.
*/
disableNamedToolChoice?: boolean
/** /**
* Custom fetch function for LLM API requests. * Custom fetch function for LLM API requests.
* Use this to customize headers, credentials, proxy, etc. * Use this to customize headers, credentials, proxy, etc.
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/mcp", "name": "@page-agent/mcp",
"private": true, "private": false,
"version": "1.5.9", "version": "1.7.1",
"type": "module", "type": "module",
"bin": { "bin": {
"page-agent-mcp": "src/index.js" "page-agent-mcp": "src/index.js"
@@ -28,8 +28,8 @@
"node": ">=20" "node": ">=20"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1", "@modelcontextprotocol/sdk": "^1.29.0",
"ws": "^8.19.0", "ws": "^8.20.0",
"zod": "^4.3.5" "zod": "^4.3.5"
} }
} }
+11 -8
View File
@@ -12,9 +12,9 @@ const port = parseInt(env.PORT || '38401')
/** @type {Record<string, string>} */ /** @type {Record<string, string>} */
const llmConfig = {} const llmConfig = {}
if (env.LLM_API_KEY) llmConfig.apiKey = env.LLM_API_KEY
if (env.LLM_BASE_URL) llmConfig.baseURL = env.LLM_BASE_URL if (env.LLM_BASE_URL) llmConfig.baseURL = env.LLM_BASE_URL
if (env.LLM_MODEL_NAME) llmConfig.model = env.LLM_MODEL_NAME if (env.LLM_MODEL_NAME) llmConfig.model = env.LLM_MODEL_NAME
if (env.LLM_API_KEY) llmConfig.apiKey = env.LLM_API_KEY
// --- Hub bridge (HTTP + WebSocket) --- // --- Hub bridge (HTTP + WebSocket) ---
@@ -35,11 +35,14 @@ const mcpServer = new McpServer({ name: 'page-agent', version: '1.5.8' })
mcpServer.registerTool( mcpServer.registerTool(
'execute_task', 'execute_task',
{ {
description: description: "Execute a task in user's browser.",
'Execute a browser automation task described in natural language. ' + inputSchema: {
'The Page Agent extension will control the browser to complete the task. ' + task: z
'Blocks until the task is complete.', .string()
inputSchema: { task: z.string().describe('Task description in natural language') }, .describe(
'Task description. Give specific instructions for the task. Steps preferable. And the information you want to get after the task is done.'
),
},
}, },
async ({ task }) => { async ({ task }) => {
try { try {
@@ -50,7 +53,7 @@ mcpServer.registerTool(
{ {
type: 'text', type: 'text',
text: result.success text: result.success
? `Task completed successfully.\n\n${result.data}` ? `Task completed.\n\n${result.data}`
: `Task failed.\n\n${result.data}`, : `Task failed.\n\n${result.data}`,
}, },
], ],
@@ -67,7 +70,7 @@ mcpServer.registerTool(
mcpServer.registerTool( mcpServer.registerTool(
'get_status', 'get_status',
{ {
description: 'Check the current status of the Page Agent hub connection and agent.', description: 'Check the current status of the Page Agent hub.',
}, },
async () => ({ async () => ({
content: [ content: [
+148 -40
View File
@@ -1,9 +1,10 @@
<!doctype html> <!doctype html>
<html lang="en"> <html>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Page Agent Connecting</title> <link rel="icon" href="https://img.alicdn.com/imgextra/i1/O1CN01mRGret1QrKiu7CFJI_!!6000000002029-2-tps-64-64.png" />
<title>Page Agent MCP Launcher</title>
<style> <style>
* { * {
margin: 0; margin: 0;
@@ -15,7 +16,7 @@
system-ui, system-ui,
-apple-system, -apple-system,
sans-serif; sans-serif;
background: #0a0a0a; background: #09090b;
color: #e5e5e5; color: #e5e5e5;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -24,23 +25,37 @@
} }
.card { .card {
text-align: center; text-align: center;
max-width: 420px; max-width: 480px;
padding: 3rem 2rem; padding: 3rem 2rem;
} }
.logo {
width: 72px;
height: 72px;
border-radius: 18px;
margin-bottom: 1rem;
}
.badge {
font-size: 0.6875rem;
font-weight: 500;
color: #52525b;
letter-spacing: 0.05em;
text-transform: uppercase;
margin-bottom: 1.5rem;
}
h1 { h1 {
font-size: 1.25rem; font-size: 1.35rem;
font-weight: 600; font-weight: 600;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
p { .sub {
font-size: 0.875rem; font-size: 0.875rem;
color: #a3a3a3; color: #a1a1aa;
line-height: 1.6; line-height: 1.7;
} }
.spinner { .spinner {
width: 32px; width: 32px;
height: 32px; height: 32px;
border: 3px solid #333; border: 3px solid #27272a;
border-top-color: #fff; border-top-color: #fff;
border-radius: 50%; border-radius: 50%;
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
@@ -58,72 +73,165 @@
a:hover { a:hover {
text-decoration: underline; text-decoration: underline;
} }
.install { .install {
display: none; display: none;
} }
.install.show { .install.show {
display: block; display: block;
} }
.btn {
display: inline-block; .tips {
margin-top: 1rem; margin-top: 1.75rem;
padding: 0.5rem 1.25rem; text-align: left;
background: #18181b;
border: 1px solid #27272a;
border-radius: 12px;
padding: 1.25rem 1.5rem;
}
.tips li {
font-size: 0.8125rem;
color: #a1a1aa;
line-height: 1.7;
margin-left: 1rem;
}
.tips li + li {
margin-top: 0.35rem;
}
.store-btn {
display: inline-flex;
align-items: center;
gap: 0.625rem;
margin-top: 1.5rem;
padding: 0.625rem 1.5rem;
background: #2563eb; background: #2563eb;
color: #fff; color: #fff;
border-radius: 0.5rem; border-radius: 10px;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
transition: background 0.15s;
} }
.btn:hover { .store-btn:hover {
background: #1d4ed8; background: #1d4ed8;
text-decoration: none; text-decoration: none;
} }
.store-btn img {
width: 20px;
height: 20px;
}
.links {
margin-top: 1.75rem;
display: flex;
justify-content: center;
gap: 1.5rem;
font-size: 0.8125rem;
}
.links a {
color: #71717a;
transition: color 0.15s;
}
.links a:hover {
color: #a1a1aa;
text-decoration: none;
}
</style> </style>
</head> </head>
<body> <body>
<div class="card"> <div class="card">
<img
class="logo"
src="https://img.alicdn.com/imgextra/i3/O1CN01JPT4Fj1FJTfmHfNxO_!!6000000000466-49-tps-512-512.webp"
alt="Page Agent"
/>
<div class="badge">Page Agent MCP Launcher</div>
<div id="connecting"> <div id="connecting">
<div class="spinner"></div> <div class="spinner"></div>
<h1>Connecting to Page Agent</h1> <h1 data-i18n="connecting_title">Connecting to Page Agent</h1>
<p>Opening the hub in your browser...</p> <p class="sub" data-i18n="connecting_sub">Opening the hub in your browser</p>
</div> </div>
<div id="install" class="install"> <div id="install" class="install">
<h1>Extension Not Found</h1> <h1 data-i18n="install_title">Extension Required</h1>
<p>The Page Agent browser extension is required.<br />Install it and try again.</p> <p class="sub" data-i18n="install_sub">
<a class="btn" href="__STORE_URL__" target="_blank">Install Extension</a> Page Agent requires the latest browser extension to work.
<p style="margin-top: 1.5rem; font-size: 0.75rem">
After installing, restart the MCP server or refresh this page.
</p> </p>
<a class="store-btn" href="__STORE_URL__" target="_blank">
<img
src="https://img.alicdn.com/imgextra/i3/O1CN01JpW0Vo1sR3FpiZKFM_!!6000000005762-55-tps-192-192.svg"
alt=""
/>
<span data-i18n="install_btn">Install from Chrome Web Store</span>
</a>
<ul class="tips">
<li data-i18n="tip_outdated">
If the extension is outdated, please update it to the latest version.
</li>
<li data-i18n="tip_other_browser">
If the extension is not installed in this browser, open this page from the
browser that has it installed.
</li>
<li data-i18n="tip_refresh">Refresh this page after installing or updating.</li>
</ul>
</div>
<div class="links">
<a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank" data-i18n="link_docs">Docs</a>
<a href="https://github.com/alibaba/page-agent/issues" target="_blank" data-i18n="link_issues">Report an Issue</a>
</div> </div>
</div> </div>
<script>
;(function () {
var EXT_ID = '__EXT_ID__'
var wsPort = __WS_PORT__
function showInstall() { <script>
{
const EXT_ID = '__EXT_ID__'
const wsPort = __WS_PORT__
const zh = {
connecting_title: '正在连接 Page Agent',
connecting_sub: '正在浏览器中打开 Hub…',
install_title: '需要安装浏览器插件',
install_sub: 'Page Agent 需要安装最新版浏览器插件才能运行。',
install_btn: '从 Chrome 应用商店安装',
tip_outdated: '如果插件版本过旧,请更新到最新版本。',
tip_other_browser:
'如果该浏览器中未安装插件,请从装有插件的浏览器打开此页面。',
tip_refresh: '安装或更新后,请刷新此页面。',
link_docs: '文档',
link_issues: '问题反馈',
}
if (/^zh\b/i.test(navigator.language)) {
document.querySelectorAll('[data-i18n]').forEach((el) => {
const key = el.getAttribute('data-i18n')
if (zh[key]) el.textContent = zh[key]
})
}
const showInstall = () => {
document.getElementById('connecting').style.display = 'none' document.getElementById('connecting').style.display = 'none'
document.getElementById('install').classList.add('show') document.getElementById('install').classList.add('show')
} }
try { try {
if (typeof chrome === 'undefined' || !chrome.runtime || !chrome.runtime.sendMessage) { if (!globalThis.chrome?.runtime?.sendMessage) {
showInstall() showInstall()
return } else {
} chrome.runtime.sendMessage(
chrome.runtime.sendMessage( EXT_ID,
EXT_ID, { type: 'OPEN_HUB', wsPort },
{ type: 'OPEN_HUB', wsPort: wsPort }, (response) => {
function (response) { if (chrome.runtime.lastError || !response?.ok) showInstall()
if (chrome.runtime.lastError || !response || !response.ok) {
showInstall()
} }
} )
) }
} catch (e) { } catch {
showInstall() showInstall()
} }
})() }
</script> </script>
</body> </body>
</html> </html>
+5 -5
View File
@@ -1,7 +1,7 @@
{ {
"name": "page-agent", "name": "page-agent",
"private": false, "private": false,
"version": "1.5.9", "version": "1.7.1",
"type": "module", "type": "module",
"main": "./dist/esm/page-agent.js", "main": "./dist/esm/page-agent.js",
"module": "./dist/esm/page-agent.js", "module": "./dist/esm/page-agent.js",
@@ -44,10 +44,10 @@
"postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\"" "postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
}, },
"dependencies": { "dependencies": {
"@page-agent/core": "1.5.9", "@page-agent/core": "1.7.1",
"@page-agent/llms": "1.5.9", "@page-agent/llms": "1.7.1",
"@page-agent/page-controller": "1.5.9", "@page-agent/page-controller": "1.7.1",
"@page-agent/ui": "1.5.9", "@page-agent/ui": "1.7.1",
"chalk": "^5.6.2" "chalk": "^5.6.2"
}, },
"peerDependencies": { "peerDependencies": {
+3 -2
View File
@@ -4,11 +4,11 @@
*/ */
import { type AgentConfig, PageAgentCore } from '@page-agent/core' import { type AgentConfig, PageAgentCore } from '@page-agent/core'
import { PageController, type PageControllerConfig } from '@page-agent/page-controller' 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 * from '@page-agent/core'
export type PageAgentConfig = AgentConfig & PageControllerConfig export type PageAgentConfig = AgentConfig & PageControllerConfig & Omit<PanelConfig, 'language'>
export class PageAgent extends PageAgentCore { export class PageAgent extends PageAgentCore {
panel: Panel panel: Panel
@@ -23,6 +23,7 @@ export class PageAgent extends PageAgentCore {
this.panel = new Panel(this, { this.panel = new Panel(this, {
language: config.language, 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_BASE_URL = 'https://page-ag-testing-ohftxirgbn.cn-shanghai.fcapp.run'
const DEMO_API_KEY = 'NA' const DEMO_API_KEY = 'NA'
const currentScript = document.currentScript as HTMLScriptElement | null
// in case document.x is not ready yet // in case document.x is not ready yet
setTimeout(() => { setTimeout(() => {
const currentScript = document.currentScript as HTMLScriptElement | null
let config: PageAgentConfig let config: PageAgentConfig
if (currentScript) { if (currentScript) {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/page-controller", "name": "@page-agent/page-controller",
"version": "1.5.9", "version": "1.7.1",
"type": "module", "type": "module",
"main": "./dist/lib/page-controller.js", "main": "./dist/lib/page-controller.js",
"module": "./dist/lib/page-controller.js", "module": "./dist/lib/page-controller.js",
+13 -5
View File
@@ -18,6 +18,7 @@ import * as dom from './dom'
import type { FlatDomTree, InteractiveElementDomNode } from './dom/dom_tree/type' import type { FlatDomTree, InteractiveElementDomNode } from './dom/dom_tree/type'
import { getPageInfo } from './dom/getPageInfo' import { getPageInfo } from './dom/getPageInfo'
import { patchReact } from './patches/react' import { patchReact } from './patches/react'
import { isAnchorElement } from './utils'
/** /**
* Configuration for PageController * Configuration for PageController
@@ -192,7 +193,11 @@ export class PageController extends EventTarget {
interactiveBlacklist: blacklist, 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.clear()
this.selectorMap = dom.getSelectorMap(this.flatTree) this.selectorMap = dom.getSelectorMap(this.flatTree)
@@ -217,6 +222,7 @@ export class PageController extends EventTarget {
* Clean up all element highlights * Clean up all element highlights
*/ */
async cleanUpHighlights(): Promise<void> { async cleanUpHighlights(): Promise<void> {
console.log('[PageController] cleanUpHighlights')
dom.cleanUpHighlights() dom.cleanUpHighlights()
} }
@@ -243,7 +249,7 @@ export class PageController extends EventTarget {
await clickElement(element) await clickElement(element)
// Handle links that open in new tabs // Handle links that open in new tabs
if (element instanceof HTMLAnchorElement && element.target === '_blank') { if (isAnchorElement(element) && element.target === '_blank') {
return { return {
success: true, success: true,
message: `✅ Clicked element (${elemText ?? index}). ⚠️ Link opened in a new tab.`, message: `✅ Clicked element (${elemText ?? index}). ⚠️ Link opened in a new tab.`,
@@ -320,11 +326,11 @@ export class PageController extends EventTarget {
this.assertIndexed() 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 element = index !== undefined ? getElementByIndex(this.selectorMap, index) : null
const message = await scrollVertically(down, scrollAmount, element) const message = await scrollVertically(scrollAmount, element)
return { return {
success: true, success: true,
@@ -355,7 +361,7 @@ export class PageController extends EventTarget {
const element = index !== undefined ? getElementByIndex(this.selectorMap, index) : null const element = index !== undefined ? getElementByIndex(this.selectorMap, index) : null
const message = await scrollHorizontally(right, scrollAmount, element) const message = await scrollHorizontally(scrollAmount, element)
return { return {
success: true, success: true,
@@ -423,3 +429,5 @@ export class PageController extends EventTarget {
this.mask = null this.mask = null
} }
} }
export * from './actions'
+158 -93
View File
@@ -3,27 +3,22 @@
* All rights reserved. * All rights reserved.
*/ */
import type { InteractiveElementDomNode } from './dom/dom_tree/type' import type { InteractiveElementDomNode } from './dom/dom_tree/type'
import {
// ======= general utils ======= clickPointer,
disablePassThrough,
async function waitFor(seconds: number): Promise<void> { enablePassThrough,
await new Promise((resolve) => setTimeout(resolve, seconds * 1000)) getNativeValueSetter,
} isHTMLElement,
isInputElement,
// ======= dom utils ======= isSelectElement,
isTextAreaElement,
export async function movePointerToElement(element: HTMLElement) { movePointerToElement,
const rect = element.getBoundingClientRect() waitFor,
const x = rect.left + rect.width / 2 } from './utils'
const y = rect.top + rect.height / 2
window.dispatchEvent(new CustomEvent('PageAgent::MovePointerTo', { detail: { x, y } }))
await waitFor(0.3)
}
/** /**
* Get the HTMLElement by index from a selectorMap. * Get the HTMLElement by index from a selectorMap.
* @private Internal method, subject to change at any time.
*/ */
export function getElementByIndex( export function getElementByIndex(
selectorMap: Map<number, InteractiveElementDomNode>, selectorMap: Map<number, InteractiveElementDomNode>,
@@ -39,7 +34,7 @@ export function getElementByIndex(
throw new Error(`Element at index ${index} does not have a reference`) throw new Error(`Element at index ${index} does not have a reference`)
} }
if (!(element instanceof HTMLElement)) { if (!isHTMLElement(element)) {
throw new Error(`Element at index ${index} is not an HTMLElement`) throw new Error(`Element at index ${index} is not an HTMLElement`)
} }
@@ -50,64 +45,92 @@ let lastClickedElement: HTMLElement | null = null
function blurLastClickedElement() { function blurLastClickedElement() {
if (lastClickedElement) { 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.blur()
lastClickedElement.dispatchEvent(
new MouseEvent('mouseout', { bubbles: true, cancelable: true })
)
lastClickedElement = null 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) { export async function clickElement(element: HTMLElement) {
blurLastClickedElement() blurLastClickedElement()
lastClickedElement = element lastClickedElement = element
await scrollIntoViewIfNeeded(element) await scrollIntoViewIfNeeded(element)
await movePointerToElement(element) const frame = element.ownerDocument.defaultView?.frameElement
window.dispatchEvent(new CustomEvent('PageAgent::ClickPointer')) if (frame) await scrollIntoViewIfNeeded(frame)
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) await waitFor(0.1)
// hover it // Hit-test to find the deepest element at click coordinates, matching
element.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true, cancelable: true })) // real browser behavior where events target the innermost element.
element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true })) // @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 const pointerOpts = {
element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })) 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 // Hover — pointer events first, then mouse events (spec order)
element.focus() 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 })) // Press
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) target.dispatchEvent(new PointerEvent('pointerdown', pointerOpts))
target.dispatchEvent(new MouseEvent('mousedown', mouseOpts))
// dispatch a click event // Focus is not part of the standard pointer/mouse event sequence
// element.click() // "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)
} }
// eslint-disable-next-line @typescript-eslint/unbound-method /**
const nativeInputValueSetter = Object.getOwnPropertyDescriptor( * @private Internal method, subject to change at any time.
window.HTMLInputElement.prototype, */
'value'
)!.set!
// eslint-disable-next-line @typescript-eslint/unbound-method
const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
)!.set!
export async function inputTextElement(element: HTMLElement, text: string) { export async function inputTextElement(element: HTMLElement, text: string) {
const isContentEditable = element.isContentEditable const isContentEditable = element.isContentEditable
if ( if (!isInputElement(element) && !isTextAreaElement(element) && !isContentEditable) {
!(element instanceof HTMLInputElement) &&
!(element instanceof HTMLTextAreaElement) &&
!isContentEditable
) {
throw new Error('Element is not an input, textarea, or contenteditable') throw new Error('Element is not an input, textarea, or contenteditable')
} }
@@ -119,9 +142,12 @@ export async function inputTextElement(element: HTMLElement, text: string) {
// - Monaco/CodeMirror: Require direct JS instance access. No universal way to obtain. // - Monaco/CodeMirror: Require direct JS instance access. No universal way to obtain.
// - Draft.js: Not responsive to synthetic/execCommand/Range/DataTransfer. Unmaintained. // - Draft.js: Not responsive to synthetic/execCommand/Range/DataTransfer. Unmaintained.
// //
// Strategy: Try Plan A (synthetic events) first, then verify and fall back
// to Plan B (execCommand) if the text wasn't actually inserted.
//
// Plan A: Dispatch synthetic events // Plan A: Dispatch synthetic events
// Works: LinkedIn, React contenteditable, Quill. // Works: React contenteditable, Quill.
// Fails: Slate.js // Fails: Slate.js, some contenteditable editors that ignore synthetic events.
// Sequence: beforeinput -> mutation -> input -> change -> blur // Sequence: beforeinput -> mutation -> input -> change -> blur
// Dispatch beforeinput + mutation + input for clearing // Dispatch beforeinput + mutation + input for clearing
@@ -164,22 +190,37 @@ export async function inputTextElement(element: HTMLElement, text: string) {
) )
} }
// Verify Plan A worked by checking if the text was actually inserted
const planASucceeded = element.innerText.trim() === text.trim()
if (!planASucceeded) {
// Plan B: execCommand fallback (deprecated but widely supported)
// Works: Quill, Slate.js, react contenteditable components.
// This approach integrates with the browser's undo stack and is handled
// natively by most rich-text editors.
element.focus()
// Select all existing content and delete it
const doc = element.ownerDocument
const selection = (doc.defaultView || window).getSelection()
const range = doc.createRange()
range.selectNodeContents(element)
selection?.removeAllRanges()
selection?.addRange(range)
// eslint-disable-next-line @typescript-eslint/no-deprecated
doc.execCommand('delete', false)
// eslint-disable-next-line @typescript-eslint/no-deprecated
doc.execCommand('insertText', false, text)
}
// Dispatch change event (for good measure) // Dispatch change event (for good measure)
element.dispatchEvent(new Event('change', { bubbles: true })) element.dispatchEvent(new Event('change', { bubbles: true }))
// Trigger blur for validation // Trigger blur for validation
element.blur() element.blur()
// Plan B: execCommand (deprecated but works better for some editors)
// Works: LinkedIn, Quill, Slate.js, react contenteditable components
//
// document.execCommand('selectAll')
// document.execCommand('delete')
// document.execCommand('insertText', false, text)
} else if (element instanceof HTMLTextAreaElement) {
nativeTextAreaValueSetter.call(element, text)
} else { } else {
nativeInputValueSetter.call(element, text) getNativeValueSetter(element as HTMLInputElement | HTMLTextAreaElement).call(element, text)
} }
// Only dispatch shared input event for non-contenteditable (contenteditable has its own) // Only dispatch shared input event for non-contenteditable (contenteditable has its own)
@@ -194,9 +235,10 @@ export async function inputTextElement(element: HTMLElement, text: string) {
/** /**
* @todo browser-use version is very complex and supports menu tags, need to follow up * @todo browser-use version is very complex and supports menu tags, need to follow up
* @private Internal method, subject to change at any time.
*/ */
export async function selectOptionElement(selectElement: HTMLSelectElement, optionText: string) { export async function selectOptionElement(selectElement: HTMLSelectElement, optionText: string) {
if (!(selectElement instanceof HTMLSelectElement)) { if (!isSelectElement(selectElement)) {
throw new Error('Element is not a select element') throw new Error('Element is not a select element')
} }
@@ -213,11 +255,14 @@ export async function selectOptionElement(selectElement: HTMLSelectElement, opti
await waitFor(0.1) // Wait to ensure change event processing completes await waitFor(0.1) // Wait to ensure change event processing completes
} }
interface ScrollableElement extends HTMLElement { interface ScrollableElement extends Element {
scrollIntoViewIfNeeded?: (centerIfNeeded?: boolean) => void scrollIntoViewIfNeeded?: (centerIfNeeded?: boolean) => void
} }
export async function scrollIntoViewIfNeeded(element: HTMLElement) { /**
* @private Internal method, subject to change at any time.
*/
export async function scrollIntoViewIfNeeded(element: Element) {
const el = element as ScrollableElement const el = element as ScrollableElement
if (typeof el.scrollIntoViewIfNeeded === 'function') { if (typeof el.scrollIntoViewIfNeeded === 'function') {
el.scrollIntoViewIfNeeded() el.scrollIntoViewIfNeeded()
@@ -229,11 +274,7 @@ export async function scrollIntoViewIfNeeded(element: HTMLElement) {
} }
} }
export async function scrollVertically( export async function scrollVertically(scroll_amount: number, element?: HTMLElement | null) {
down: boolean,
scroll_amount: number,
element?: HTMLElement | null
) {
// Element-specific scrolling if element is provided // Element-specific scrolling if element is provided
if (element) { if (element) {
const targetElement = element const targetElement = element
@@ -246,7 +287,10 @@ export async function scrollVertically(
while (currentElement && attempts < 10) { while (currentElement && attempts < 10) {
const computedStyle = window.getComputedStyle(currentElement) 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 const canScrollVertically = currentElement.scrollHeight > currentElement.clientHeight
if (hasScrollableY && canScrollVertically) { if (hasScrollableY && canScrollVertically) {
@@ -298,9 +342,20 @@ export async function scrollVertically(
el.scrollHeight > el.clientHeight && el.scrollHeight > el.clientHeight &&
bigEnough(el) 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 let el: HTMLElement | null = document.activeElement as HTMLElement | null
while (el && !canScroll(el) && el !== document.body) el = el.parentElement 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 = canScroll(el)
? el ? el
: Array.from(document.querySelectorAll<HTMLElement>('*')).find(canScroll) || : Array.from(document.querySelectorAll<HTMLElement>('*')).find(canScroll) ||
@@ -331,6 +386,10 @@ export async function scrollVertically(
return `✅ Scrolled page by ${scrolled}px.` return `✅ Scrolled page by ${scrolled}px.`
} else { } else {
// Container scroll // Container scroll
const warningMsg = `The document is not scrollable. Falling back to container scroll.`
console.log(`[PageController] ${warningMsg}`)
const scrollBefore = el!.scrollTop const scrollBefore = el!.scrollTop
const scrollMax = el!.scrollHeight - el!.clientHeight const scrollMax = el!.scrollHeight - el!.clientHeight
@@ -342,26 +401,22 @@ export async function scrollVertically(
if (Math.abs(scrolled) < 1) { if (Math.abs(scrolled) < 1) {
return dy > 0 return dy > 0
? `⚠️ Already at the bottom of container (${el!.tagName}), cannot scroll down further.` ? `⚠️ ${warningMsg} 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 top of container (${el!.tagName}), cannot scroll up further.`
} }
const reachedBottom = dy > 0 && scrollAfter >= scrollMax - 1 const reachedBottom = dy > 0 && scrollAfter >= scrollMax - 1
const reachedTop = dy < 0 && scrollAfter <= 1 const reachedTop = dy < 0 && scrollAfter <= 1
if (reachedBottom) 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) if (reachedTop)
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the top.` return ` ${warningMsg} 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.`
} }
} }
export async function scrollHorizontally( export async function scrollHorizontally(scroll_amount: number, element?: HTMLElement | null) {
right: boolean,
scroll_amount: number,
element?: HTMLElement | null
) {
// Element-specific scrolling if element is provided // Element-specific scrolling if element is provided
if (element) { if (element) {
const targetElement = element const targetElement = element
@@ -370,11 +425,14 @@ export async function scrollHorizontally(
let scrolledElement: HTMLElement | null = null let scrolledElement: HTMLElement | null = null
let scrollDelta = 0 let scrollDelta = 0
let attempts = 0 let attempts = 0
const dx = right ? scroll_amount : -scroll_amount const dx = scroll_amount
while (currentElement && attempts < 10) { while (currentElement && attempts < 10) {
const computedStyle = window.getComputedStyle(currentElement) 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 const canScrollHorizontally = currentElement.scrollWidth > currentElement.clientWidth
if (hasScrollableX && canScrollHorizontally) { if (hasScrollableX && canScrollHorizontally) {
@@ -418,7 +476,8 @@ export async function scrollHorizontally(
// Page-level scrolling (default or fallback) // 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 bigEnough = (el: HTMLElement) => el.clientWidth >= window.innerWidth * 0.5
const canScroll = (el: HTMLElement | null) => const canScroll = (el: HTMLElement | null) =>
el && el &&
@@ -426,6 +485,9 @@ export async function scrollHorizontally(
el.scrollWidth > el.clientWidth && el.scrollWidth > el.clientWidth &&
bigEnough(el) 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 let el: HTMLElement | null = document.activeElement as HTMLElement | null
while (el && !canScroll(el) && el !== document.body) el = el.parentElement while (el && !canScroll(el) && el !== document.body) el = el.parentElement
@@ -460,6 +522,9 @@ export async function scrollHorizontally(
return `✅ Scrolled page horizontally by ${scrolled}px.` return `✅ Scrolled page horizontally by ${scrolled}px.`
} else { } else {
// Container scroll // Container scroll
const warningMsg = `The document is not scrollable. Falling back to container scroll.`
console.log(`[PageController] ${warningMsg}`)
const scrollBefore = el!.scrollLeft const scrollBefore = el!.scrollLeft
const scrollMax = el!.scrollWidth - el!.clientWidth const scrollMax = el!.scrollWidth - el!.clientWidth
@@ -471,17 +536,17 @@ export async function scrollHorizontally(
if (Math.abs(scrolled) < 1) { if (Math.abs(scrolled) < 1) {
return dx > 0 return dx > 0
? `⚠️ Already at the right edge of container (${el!.tagName}), cannot scroll right further.` ? `⚠️ ${warningMsg} 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 left edge of container (${el!.tagName}), cannot scroll left further.`
} }
const reachedRight = dx > 0 && scrollAfter >= scrollMax - 1 const reachedRight = dx > 0 && scrollAfter >= scrollMax - 1
const reachedLeft = dx < 0 && scrollAfter <= 1 const reachedLeft = dx < 0 && scrollAfter <= 1
if (reachedRight) 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) if (reachedLeft)
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the left edge.` return ` ${warningMsg} 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}) horizontally by ${scrolled}px.`
} }
} }
@@ -18,6 +18,7 @@
* @edit improve `sampleRect`, filter out rects with 0 area * @edit improve `sampleRect`, filter out rects with 0 area
* @edit exclude aria-hidden elements * @edit exclude aria-hidden elements
* @edit make sure attributes exist for interactive candidates. * @edit make sure attributes exist for interactive candidates.
* @edit fix "aria-*" attributes check
*/ */
export default ( export default (
@@ -502,11 +503,16 @@ export default (
const overflowX = style.overflowX const overflowX = style.overflowX
const overflowY = style.overflowY 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 scrollableX = overflowX === 'auto' || overflowX === 'scroll'
const scrollableY = overflowY === 'auto' || overflowY === 'scroll' const scrollableY = overflowY === 'auto' || overflowY === 'scroll'
if (!scrollableX && !scrollableY) { if (!scrollableX && !scrollableY && !hasScrollbarSignal) {
return null // Not scrollable in any direction return null // Not scrollable in any direction
} }
@@ -520,11 +526,11 @@ export default (
return null // Not scrollable return null // Not scrollable
} }
if (!scrollableY && scrollWidth < threshold) { if (!scrollableY && !hasScrollbarSignal && scrollWidth < threshold) {
return null // Not scrollable horizontally return null // Not scrollable horizontally
} }
if (!scrollableX && scrollHeight < threshold) { if (!scrollableX && !hasScrollbarSignal && scrollHeight < threshold) {
return null // Not scrollable vertically return null // Not scrollable vertically
} }
@@ -546,6 +552,8 @@ export default (
scrollData: scrollData, scrollData: scrollData,
}) })
console.log('scrollData!!!', scrollData)
return scrollData return scrollData
} }
@@ -1143,6 +1151,31 @@ export default (
* @param {HTMLElement} element - The element to check. * @param {HTMLElement} element - The element to check.
* @returns {boolean} Whether the element is an interactive candidate. * @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) { function isInteractiveCandidate(element) {
if (!element || element.nodeType !== Node.ELEMENT_NODE) return false if (!element || element.nodeType !== Node.ELEMENT_NODE) return false
@@ -1167,7 +1200,7 @@ export default (
element.hasAttribute('onclick') || element.hasAttribute('onclick') ||
element.hasAttribute('role') || element.hasAttribute('role') ||
element.hasAttribute('tabindex') || element.hasAttribute('tabindex') ||
element.hasAttribute('aria-') || hasInteractiveAria(element) ||
element.hasAttribute('data-action') || element.hasAttribute('data-action') ||
element.getAttribute('contenteditable') === 'true' element.getAttribute('contenteditable') === 'true'
@@ -1185,8 +1218,9 @@ export default (
'details', 'details',
'label', 'label',
'option', 'option',
'li',
]) ])
const INTERACTIVE_ROLES = new Set([ const DISTINCT_INTERACTIVE_ROLES = new Set([
'button', 'button',
'link', 'link',
'menuitem', 'menuitem',
@@ -1202,6 +1236,9 @@ export default (
'searchbox', 'searchbox',
'textbox', 'textbox',
'listbox', 'listbox',
'listitem',
'treeitem',
'row',
'option', 'option',
'scrollbar', 'scrollbar',
]) ])
@@ -1278,7 +1315,7 @@ export default (
return true return true
} }
// Check interactive roles // Check interactive roles
if (role && INTERACTIVE_ROLES.has(role)) { if (role && DISTINCT_INTERACTIVE_ROLES.has(role)) {
return true return true
} }
// Check contenteditable // Check contenteditable
@@ -1297,6 +1334,10 @@ export default (
if (element.hasAttribute('onclick') || typeof element.onclick === 'function') { if (element.hasAttribute('onclick') || typeof element.onclick === 'function') {
return true return true
} }
// ARIA state attributes imply the element manages its own interaction state
if (hasInteractiveAria(element)) {
return true
}
// return false // return false
@@ -1352,6 +1393,12 @@ export default (
return true 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, // Default to false: if it's interactive but doesn't match above,
// assume it triggers the same action as the parent. // assume it triggers the same action as the parent.
return false return false
+48 -3
View File
@@ -28,8 +28,27 @@ export interface DomConfig {
includeAttributes?: string[] includeAttributes?: string[]
highlightOpacity?: number highlightOpacity?: number
highlightLabelOpacity?: 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 * @todo
*/ */
export function flatTreeToString(flatTree: FlatDomTree, includeAttributes?: string[]): string { export function flatTreeToString(
flatTree: FlatDomTree,
includeAttributes: string[] = [],
keepSemanticTags = false
): string {
const DEFAULT_INCLUDE_ATTRIBUTES = [ const DEFAULT_INCLUDE_ATTRIBUTES = [
'title', 'title',
'type', 'type',
@@ -203,7 +226,7 @@ export function flatTreeToString(flatTree: FlatDomTree, includeAttributes?: stri
'contenteditable', 'contenteditable',
] ]
const includeAttrs = [...(includeAttributes || []), ...DEFAULT_INCLUDE_ATTRIBUTES] const includeAttrs = [...includeAttributes, ...DEFAULT_INCLUDE_ATTRIBUTES]
// Helper function to cap text length // Helper function to cap text length
const capTextLength = (text: string, maxLength: number): string => { const capTextLength = (text: string, maxLength: number): string => {
@@ -294,6 +317,8 @@ export function flatTreeToString(flatTree: FlatDomTree, includeAttributes?: stri
const depthStr = '\t'.repeat(depth) const depthStr = '\t'.repeat(depth)
if (node.type === 'element') { if (node.type === 'element') {
const isSemantic = keepSemanticTags && node.tagName && SEMANTIC_TAGS.has(node.tagName)
// Add element with highlight_index // Add element with highlight_index
if (node.highlightIndex !== undefined) { if (node.highlightIndex !== undefined) {
nextDepth += 1 nextDepth += 1
@@ -391,10 +416,30 @@ export function flatTreeToString(flatTree: FlatDomTree, includeAttributes?: stri
result.push(line) 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) { for (const child of node.children) {
processNode(child, nextDepth, result) 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') { } else if (node.type === 'text') {
// Add text only if it doesn't have a highlighted parent // Add text only if it doesn't have a highlighted parent
if (hasParentWithHighlightIndex(node)) { if (hasParentWithHighlightIndex(node)) {
@@ -5,7 +5,7 @@ import { isPageDark } from './checkDarkMode'
import styles from './SimulatorMask.module.css' import styles from './SimulatorMask.module.css'
import cursorStyles from './cursor.module.css' import cursorStyles from './cursor.module.css'
export class SimulatorMask { export class SimulatorMask extends EventTarget {
shown: boolean = false shown: boolean = false
wrapper = document.createElement('div') wrapper = document.createElement('div')
motion: Motion | null = null motion: Motion | null = null
@@ -19,6 +19,8 @@ export class SimulatorMask {
#targetCursorY = 0 #targetCursorY = 0
constructor() { constructor() {
super()
this.wrapper.id = 'page-agent-runtime_simulator-mask' this.wrapper.id = 'page-agent-runtime_simulator-mask'
this.wrapper.className = styles.wrapper this.wrapper.className = styles.wrapper
this.wrapper.setAttribute('data-browser-use-ignore', 'true') this.wrapper.setAttribute('data-browser-use-ignore', 'true')
@@ -74,13 +76,34 @@ export class SimulatorMask {
this.#moveCursorToTarget() 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 const { x, y } = (event as CustomEvent).detail
this.setCursorPosition(x, y) this.setCursorPosition(x, y)
}) }
const clickPointerListener = () => {
window.addEventListener('PageAgent::ClickPointer', (event: Event) => {
this.triggerClickAnimation() 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() { dispose() {
console.log('dispose SimulatorMask')
this.motion?.dispose() this.motion?.dispose()
this.wrapper.remove() this.wrapper.remove()
this.dispatchEvent(new Event('dispose'))
} }
} }
@@ -0,0 +1,80 @@
// ======= type guards =======
// @note instanceof fails for elements inside iframes
export function isHTMLElement(el: unknown): el is HTMLElement {
// @todo either specify to HTMLElement or allow Element here.
return !!el && (el as Node).nodeType === 1
}
export function isInputElement(el: Element): el is HTMLInputElement {
return el?.nodeType === 1 && el.tagName === 'INPUT'
}
export function isTextAreaElement(el: Element): el is HTMLTextAreaElement {
return el?.nodeType === 1 && el.tagName === 'TEXTAREA'
}
export function isSelectElement(el: Element): el is HTMLSelectElement {
return el?.nodeType === 1 && el.tagName === 'SELECT'
}
export function isAnchorElement(el: Element): el is HTMLAnchorElement {
return el?.nodeType === 1 && el.tagName === 'A'
}
// ======= iframe helpers =======
/** Iframe offset for translating element coordinates to top-frame viewport. */
export function getIframeOffset(element: HTMLElement): { x: number; y: number } {
const frame = element.ownerDocument.defaultView?.frameElement as HTMLElement | null
if (!frame) return { x: 0, y: 0 }
const rect = frame.getBoundingClientRect()
return { x: rect.left, y: rect.top }
}
/**
* Get native value setter from the element's own prototype (iframe-safe).
* @note for React
*/
export function getNativeValueSetter(element: HTMLInputElement | HTMLTextAreaElement) {
// eslint-disable-next-line @typescript-eslint/unbound-method
return Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element) as object, 'value')!
.set as (v: string) => void
}
// ======= general utils =======
export async function waitFor(seconds: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
}
// ======= mask events =======
/**
* 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)
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", "name": "@page-agent/ui",
"version": "1.5.9", "version": "1.7.1",
"type": "module", "type": "module",
"main": "./dist/lib/page-agent-ui.js", "main": "./dist/lib/page-agent-ui.js",
"module": "./dist/lib/page-agent-ui.js", "module": "./dist/lib/page-agent-ui.js",
+2 -1
View File
@@ -369,6 +369,7 @@ export class Panel {
} }
#createWrapper(): HTMLElement { #createWrapper(): HTMLElement {
const taskInputMaxLength = 1000
const wrapper = document.createElement('div') const wrapper = document.createElement('div')
wrapper.id = 'page-agent-runtime_agent-panel' wrapper.id = 'page-agent-runtime_agent-panel'
wrapper.className = styles.wrapper wrapper.className = styles.wrapper
@@ -406,7 +407,7 @@ export class Panel {
<input <input
type="text" type="text"
class="${styles.taskInput}" class="${styles.taskInput}"
maxlength="200" maxlength="${taskInputMaxLength}"
/> />
</div> </div>
</div> </div>
+2 -1
View File
@@ -85,7 +85,7 @@ Uses wouter browser routing with base path for GitHub Pages deployment at `https
```tsx ```tsx
// main.tsx // main.tsx
<Router base="/page-agent"> <Router base="/page-agent">
<PagesRouter /> <PagesRouter />
</Router> </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` 2. Add route in `src/pages/docs/index.tsx`
3. Add navigation item in `src/pages/docs/Layout.tsx` 3. Add navigation item in `src/pages/docs/Layout.tsx`
4. Add path to `SPA_ROUTES` in `vite.config.js` 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 ## Configuration Files
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/website", "name": "@page-agent/website",
"private": true, "private": true,
"version": "1.5.9", "version": "1.7.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
@@ -19,13 +19,13 @@
"@types/react-dom": "^19.2.1", "@types/react-dom": "^19.2.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.577.0", "lucide-react": "^1.7.0",
"motion": "^12.37.0", "motion": "^12.38.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"rough-notation": "^0.5.1", "rough-notation": "^0.5.1",
"simple-icons": "^16.12.0", "simple-icons": "^16.14.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
+1 -1
View File
@@ -18,7 +18,7 @@ export default function Header() {
return ( return (
<> <>
<header <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" role="banner"
> >
<div className="max-w-7xl mx-auto px-6 py-4"> <div className="max-w-7xl mx-auto px-6 py-4">
@@ -8,8 +8,8 @@ export default function LanguageSwitcher() {
const dropdownRef = useRef<HTMLDivElement>(null) const dropdownRef = useRef<HTMLDivElement>(null)
const languages = [ const languages = [
{ code: 'zh-CN' as const, label: '中文' },
{ code: 'en-US' as const, label: 'English' }, { code: 'en-US' as const, label: 'English' },
{ code: 'zh-CN' as const, label: '中文' },
] ]
const currentLanguage = languages.find((lang) => lang.code === language) || languages[0] const currentLanguage = languages.find((lang) => lang.code === language) || languages[0]
+3 -3
View File
@@ -1,10 +1,10 @@
// Demo build (auto-init with demo LLM, for quick testing) // Demo build (auto-init with demo LLM, for quick testing)
export const CDN_DEMO_URL = export const CDN_DEMO_URL =
'https://cdn.jsdelivr.net/npm/page-agent@1.5.9/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 = export const CDN_DEMO_CN_URL =
'https://registry.npmmirror.com/page-agent/1.5.9/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) // Demo LLM for website testing (homepage quick trial uses flash)
export const DEMO_MODEL = 'qwen3.5-flash' export const DEMO_MODEL = 'qwen3.5-flash'
export const DEMO_BASE_URL = 'https://page-ag-testing-ohftxirgbn.cn-shanghai.fcapp.run' export const DEMO_BASE_URL = 'https://page-ag-testing-ohftxirgbn.cn-shanghai.fcapp.run'
export const DEMO_API_KEY = 'NA' // export const DEMO_API_KEY = ''
@@ -45,6 +45,7 @@ export default function DocsLayout({ children }: DocsLayoutProps) {
{ title: isZh ? '知识注入' : 'Instructions', path: '/features/custom-instructions' }, { title: isZh ? '知识注入' : 'Instructions', path: '/features/custom-instructions' },
{ title: isZh ? '数据脱敏' : 'Data Masking', path: '/features/data-masking' }, { title: isZh ? '数据脱敏' : 'Data Masking', path: '/features/data-masking' },
{ title: isZh ? 'Chrome 扩展' : 'Chrome Extension', path: '/features/chrome-extension' }, { title: isZh ? 'Chrome 扩展' : 'Chrome Extension', path: '/features/chrome-extension' },
{ title: 'MCP Server (Beta)', path: '/features/mcp-server' },
{ {
title: isZh ? '接入第三方 Agent' : 'Third-party Agent', title: isZh ? '接入第三方 Agent' : 'Third-party Agent',
path: '/features/third-party-agent', path: '/features/third-party-agent',
@@ -129,12 +129,6 @@ const result = await agent.execute('Fill in the form with test data')`}
? 'LLM API 的基础 URL(如 https://api.openai.com/v1' ? 'LLM API 的基础 URL(如 https://api.openai.com/v1'
: 'Base URL of the LLM API (e.g., https://api.openai.com/v1)', : 'Base URL of the LLM API (e.g., https://api.openai.com/v1)',
}, },
{
name: 'apiKey',
type: 'string',
required: true,
description: isZh ? 'API 密钥' : 'API key for authentication',
},
{ {
name: 'model', name: 'model',
type: 'string', type: 'string',
@@ -143,6 +137,12 @@ const result = await agent.execute('Fill in the form with test data')`}
? '模型名称(如 gpt-5.2, anthropic/claude-4.5-haiku' ? '模型名称(如 gpt-5.2, anthropic/claude-4.5-haiku'
: 'Model name (e.g., gpt-5.2, anthropic/claude-4.5-haiku)', : 'Model name (e.g., gpt-5.2, anthropic/claude-4.5-haiku)',
}, },
{
name: 'apiKey',
type: 'string',
required: false,
description: 'LLM AK',
},
{ {
name: 'temperature', name: 'temperature',
type: 'number', type: 'number',
@@ -156,6 +156,14 @@ const result = await agent.execute('Fill in the form with test data')`}
defaultValue: '3', defaultValue: '3',
description: isZh ? 'API 调用失败时的最大重试次数' : 'Maximum retries on API failure', description: isZh ? 'API 调用失败时的最大重试次数' : 'Maximum retries on API failure',
}, },
{
name: 'disableNamedToolChoice',
type: 'boolean',
defaultValue: 'false',
description: isZh
? '禁用命名 tool_choice,始终使用 "required" 字符串。适用于不支持 tool_choice 对象格式的 LLM 服务。'
: 'Disable named tool_choice, always use "required" string. For LLM services that don\'t support the object format of tool_choice.',
},
{ {
name: 'customFetch', name: 'customFetch',
type: 'typeof fetch', type: 'typeof fetch',
@@ -100,7 +100,7 @@ console.log(result.history) // Full execution history`}
> >
AgentConfig AgentConfig
</Link>{' '} </Link>{' '}
{' '} PanelConfig {' '}
<Link <Link
href="/advanced/page-controller#configuration" href="/advanced/page-controller#configuration"
className="text-blue-600 dark:text-blue-400 hover:underline" className="text-blue-600 dark:text-blue-400 hover:underline"
@@ -125,7 +125,7 @@ console.log(result.history) // Full execution history`}
> >
AgentConfig AgentConfig
</Link>{' '} </Link>{' '}
and{' '} , PanelConfig, and{' '}
<Link <Link
href="/advanced/page-controller#configuration" href="/advanced/page-controller#configuration"
className="text-blue-600 dark:text-blue-400 hover:underline" 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 等。' ? '在 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.', : '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> </section>
@@ -187,39 +187,7 @@ localStorage.setItem('PageAgentExtUserAuthToken', '<your-token-from-extension>')
</p> </p>
<CodeEditor <CodeEditor
code={ code={`import type {
isZh
? `import type {
AgentActivity,
AgentStatus,
ExecutionResult,
HistoricalEvent
} from '@page-agent/core'
interface ExecuteConfig {
baseURL: string // LLM API 端点
apiKey: string // API 密钥
model: string // 模型名称
includeInitialTab?: boolean
onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
}
type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
declare global {
interface Window {
PAGE_AGENT_EXT_VERSION?: string
PAGE_AGENT_EXT?: {
version: string
execute: Execute
stop: () => void
}
}
}`
: `import type {
AgentActivity, AgentActivity,
AgentStatus, AgentStatus,
ExecutionResult, ExecutionResult,
@@ -228,10 +196,12 @@ declare global {
interface ExecuteConfig { interface ExecuteConfig {
baseURL: string // LLM API endpoint baseURL: string // LLM API endpoint
apiKey: string // API key
model: string // Model name model: string // Model name
apiKey?: string // LLM AK
systemInstruction?: string // Global system-level instructions
includeInitialTab?: boolean includeInitialTab?: boolean
experimentalIncludeAllTabs?: boolean // Control all unpinned tabs in the window
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void
@@ -248,8 +218,7 @@ declare global {
stop: () => void stop: () => void
} }
} }
}` }`}
}
language="typescript" language="typescript"
/> />
@@ -266,6 +235,7 @@ const result = await window.PAGE_AGENT_EXT.execute(
apiKey: 'your-api-key', apiKey: 'your-api-key',
model: 'gpt-5.2', model: 'gpt-5.2',
// includeInitialTab: false, // 设为 false 排除初始标签页 // includeInitialTab: false, // 设为 false 排除初始标签页
// experimentalIncludeAllTabs: true, // 控制窗口内所有非固定标签页
onStatusChange: status => console.log('状态变化:', status), onStatusChange: status => console.log('状态变化:', status),
onActivity: activity => console.log('活动:', activity), onActivity: activity => console.log('活动:', activity),
onHistoryUpdate: history => console.log('历史更新:', history) onHistoryUpdate: history => console.log('历史更新:', history)
@@ -281,6 +251,7 @@ const result = await window.PAGE_AGENT_EXT.execute(
apiKey: 'your-api-key', apiKey: 'your-api-key',
model: 'gpt-5.2', model: 'gpt-5.2',
// includeInitialTab: false, // Set to false to exclude initial tab // 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), onStatusChange: status => console.log('Status change:', status),
onActivity: activity => console.log('Activity:', activity), onActivity: activity => console.log('Activity:', activity),
onHistoryUpdate: history => console.log('History update:', history) onHistoryUpdate: history => console.log('History update:', history)
@@ -0,0 +1,70 @@
import BetaNotice from '@/components/BetaNotice'
import CodeEditor from '@/components/CodeEditor'
import { Heading } from '@/components/Heading'
export default function McpServerPage() {
return (
<div>
<h1 className="text-4xl font-bold mb-6">MCP Server (Beta)</h1>
<BetaNotice />
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
Use the MCP server to let your local agent send natural-language browser tasks to Page Agent
Ext.
</p>
<section className="mb-10">
<Heading id="quick-start" className="text-2xl font-bold mb-4">
How to use
</Heading>
<div className="space-y-4">
<div className="p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-900 dark:text-blue-200 leading-7">
1. Install Page Agent Ext in Chrome.
<br />
2. Add the MCP server to your local agent client.
<br />
3. Start the client and approve the Hub connection in the browser when prompted.
<br />
4. Ask your agent to do something in the browser. The client will call execute_task
for you.
</p>
</div>
<CodeEditor
code={`{
"mcpServers": {
"page-agent": {
"command": "npx",
"args": ["-y", "@page-agent/mcp"],
"env": {
"LLM_BASE_URL": "https://api.openai.com/v1",
"LLM_API_KEY": "sk-xxx",
"LLM_MODEL_NAME": "gpt-5.2"
}
}
}
}`}
language="json"
/>
</div>
</section>
<section className="mb-10">
<Heading id="the-hub" className="text-2xl font-bold mb-4">
The Hub
</Heading>
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">
The Hub is the control center for communication between Page Agent Ext and external
callers.
</p>
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">
When the MCP server starts, it opens a local launcher page. The launcher asks the
extension to open the Hub tab, and the Hub receives tasks from your local agent. MCP uses
this path, but the Hub itself is the extension's general external communication entry
point.
</p>
</section>
</div>
)
}
@@ -16,12 +16,12 @@ const BASELINE = new Set([
// Models grouped by brand, newest first // Models grouped by brand, newest first
const MODEL_GROUPS: Record<string, string[]> = { const MODEL_GROUPS: Record<string, string[]> = {
Qwen: [ Qwen: [
'qwen3.6-plus',
'qwen3.5-plus', 'qwen3.5-plus',
'qwen3.5-flash', 'qwen3.5-flash',
'qwen3-coder-next', 'qwen3-coder-next',
'qwen-3-max', 'qwen-3-max',
'qwen-3-plus', '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'], 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'], DeepSeek: ['deepseek-3.2'],
@@ -33,8 +33,8 @@ const MODEL_GROUPS: Record<string, string[]> = {
'claude-haiku-4.5', 'claude-haiku-4.5',
'claude-sonnet-3.5', 'claude-sonnet-3.5',
], ],
MiniMax: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'],
xAI: ['grok-4.1-fast', 'grok-4', 'grok-code-fast'], xAI: ['grok-4.1-fast', 'grok-4', 'grok-code-fast'],
MiniMax: ['MiniMax-M2.5', 'MiniMax-M2.5-highspeed'],
MoonshotAI: ['kimi-k2.5'], MoonshotAI: ['kimi-k2.5'],
'Z.AI': ['glm-5', 'glm-4.7'], 'Z.AI': ['glm-5', 'glm-4.7'],
} }
@@ -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.'} : 'Supports models that comply with OpenAI API specification and support tool calls, including public cloud services and private deployments.'}
</p> </p>
{/* Models Section */}
<section className="mb-10"> <section className="mb-10">
<Heading id="tested-models" className="text-2xl font-semibold mb-3"> <Heading id="tested-models" className="text-2xl font-semibold mb-3">
{isZh ? '已测试模型' : 'Tested Models'} {isZh ? '已测试模型' : 'Tested Models'}
@@ -87,9 +86,10 @@ export default function Models() {
</div> </div>
</section> </section>
{/* Tips Section */}
<section className="mb-10"> <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"> <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"> <ul className="text-sm text-gray-700 dark:text-gray-300 space-y-2 list-disc pl-5">
<li> <li>
@@ -111,7 +111,6 @@ export default function Models() {
</div> </div>
</section> </section>
{/* Configuration Section */}
<section className="mb-10"> <section className="mb-10">
<Heading id="configuration">{isZh ? '配置方式' : 'Configuration'}</Heading> <Heading id="configuration">{isZh ? '配置方式' : 'Configuration'}</Heading>
<CodeEditor <CodeEditor
@@ -121,26 +120,50 @@ const pageAgent = new PageAgent({
apiKey: 'your-api-key', apiKey: 'your-api-key',
model: 'qwen3.5-plus' model: 'qwen3.5-plus'
}); });
// MiniMax
const pageAgent = new PageAgent({
baseURL: 'https://api.minimax.io/v1',
apiKey: 'your-minimax-api-key',
model: 'MiniMax-M2.5'
});
// Self-hosted models (e.g., Ollama)
const pageAgent = new PageAgent({
baseURL: 'http://localhost:11434/v1',
apiKey: 'NA',
model: 'qwen3:14b'
});
`} `}
/> />
</section> </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"> <section className="mb-10">
<Heading id="free-testing-api">{isZh ? '免费测试接口' : 'Free Testing API'}</Heading> <Heading id="free-testing-api">{isZh ? '免费测试接口' : 'Free Testing API'}</Heading>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4"> <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
@@ -182,116 +205,143 @@ const pageAgent = new PageAgent({
</a> </a>
</p> </p>
<CodeEditor <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_BASE_URL="https://page-ag-testing-ohftxirgbn.cn-shanghai.fcapp.run"
LLM_MODEL_NAME="qwen3.5-plus" LLM_MODEL_NAME="qwen3.5-plus"`}
LLM_API_KEY="NA"`}
/> />
</div> </div>
</section> </section>
{/* Ollama Section */}
<section className="mb-10"> <section className="mb-10">
<Heading id="ollama">Ollama</Heading> <Heading id="local-runtimes">{isZh ? '本地运行时' : 'Local Runtimes'}</Heading>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4"> <p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
{isZh {isZh
? '已在 Ollama 0.15 + qwen3:14b (RTX3090 24GB) 上测试通过。' ? '通过 Ollama、LM Studio 等本地 OpenAI-compatible 运行时接入 PageAgent,实现离线或局域网部署。'
: 'Tested on Ollama 0.15 with qwen3:14b (RTX3090 24GB).'} : 'Use local OpenAI-compatible runtimes such as Ollama and LM Studio with PageAgent for offline or LAN deployments.'}
</p> </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"> <div className="space-y-10">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-3"> <section>
{isZh ? '建议启动参数' : 'Recommended Startup'} <Heading id="requirements" level={3}>
</h3> Requirements
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3"> </Heading>
{isZh <div className="mt-4 p-5 bg-amber-50 dark:bg-amber-950/20 rounded-xl border border-amber-200 dark:border-amber-800">
? '启动 Ollama 时建议配置以下环境变量:扩大上下文窗口、允许跨域访问、监听所有网络接口。' <ul className="text-sm text-gray-700 dark:text-gray-300 space-y-2 list-disc pl-5">
: 'Start Ollama with these environment variables: larger context window, allow cross-origin access, and listen on all interfaces.'} <li>
</p> {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"> <section>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">macOS / Linux</p> <Heading id="local-configuration" level={3}>
{isZh ? '基础配置' : 'Basic Configuration'}
</Heading>
<CodeEditor <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"> // Or connect to LM Studio
Windows (PowerShell) 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> </p>
<CodeEditor <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 className="mt-4 p-4 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-800">
</div> <h3 className="font-semibold text-amber-900 dark:text-amber-200 mb-2">
</section> {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 */} <div className="mt-4">
<section className="mb-10"> <h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-3">
<Heading id="production-authentication" className="text-2xl font-semibold mb-4"> {isZh ? '建议启动参数' : 'Recommended Startup'}
{isZh ? '🔐 生产环境鉴权' : '🔐 Production Authentication'} </h3>
</Heading> <p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3"> {isZh
{isZh ? '启动 Ollama 时建议同时放大上下文窗口并开启跨域访问。'
? '如果你只是将它用作个人助手,可以直接连接你的 LLM 服务。' : 'When starting Ollama, increase the context window and enable cross-origin access.'}
: 'If you only use it as a personal assistant, you can connect to your LLM service directly.'} </p>
</p>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3"> <div className="space-y-2">
{isZh ? ( <p className="text-xs font-medium text-gray-500 dark:text-gray-400">
<> macOS / Linux
Web LLM 使{' '} </p>
<code>customFetch</code> Cookie <CodeEditor
</> code={`OLLAMA_CONTEXT_LENGTH=64000 OLLAMA_HOST=0.0.0.0:11434 OLLAMA_ORIGINS="*" ollama serve`}
) : ( />
<>
If you plan to integrate it into your web app, it's better to have a backend proxy for <p className="text-xs font-medium text-gray-500 dark:text-gray-400 pt-2">
the LLM and use <code>customFetch</code> to authenticate the request with cookies or Windows (PowerShell)
other methods: </p>
</> <CodeEditor
)} code={`$env:OLLAMA_CONTEXT_LENGTH=64000; $env:OLLAMA_HOST="0.0.0.0:11434"; $env:OLLAMA_ORIGINS="*"; ollama serve`}
</p> />
<CodeEditor </div>
code={`const agent = new PageAgent({ </div>
baseURL: '/api/llm-proxy', </section>
apiKey: 'NA',
model: 'gpt-5.1', <section>
customFetch: (url, init) => <Heading id="lm-studio" level={3}>
fetch(url, { ...init, credentials: 'include' }), LM Studio
});`} </Heading>
/> <CodeEditor
<div className="mt-4 bg-yellow-50 dark:bg-yellow-950/20 border-l-4 border-yellow-500 p-4 rounded-r-lg"> code={`LLM_BASE_URL="http://127.0.0.1:1234/v1"
<p className="text-sm font-semibold text-yellow-900 dark:text-yellow-200"> LLM_MODEL_NAME="qwen/qwen3.5-27b"`}
{isZh />
? '⚠️ 永远不要把真实的 LLM API Key 提交到前端代码中' <div className="mt-4 p-4 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-800">
: '⚠️ NEVER commit real LLM API keys to your frontend code'} <h3 className="font-semibold text-amber-900 dark:text-amber-200 mb-2">
</p> {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> </div>
</section> </section>
</div> </div>
@@ -13,6 +13,7 @@ import ChromeExtension from './features/chrome-extension/page'
import Instructions from './features/custom-instructions/page' import Instructions from './features/custom-instructions/page'
import CustomTools from './features/custom-tools/page' import CustomTools from './features/custom-tools/page'
import DataMasking from './features/data-masking/page' import DataMasking from './features/data-masking/page'
import McpServerPage from './features/mcp-server/page'
import Models from './features/models/page' import Models from './features/models/page'
import ThirdPartyAgent from './features/third-party-agent/page' import ThirdPartyAgent from './features/third-party-agent/page'
import Limitations from './introduction/limitations/page' import Limitations from './introduction/limitations/page'
@@ -80,6 +81,11 @@ export default function DocsRouter() {
<ChromeExtension /> <ChromeExtension />
</DocsPage> </DocsPage>
</Route> </Route>
<Route path="/features/mcp-server">
<DocsPage>
<McpServerPage />
</DocsPage>
</Route>
<Route path="/features/third-party-agent"> <Route path="/features/third-party-agent">
<DocsPage> <DocsPage>
<ThirdPartyAgent /> <ThirdPartyAgent />
@@ -97,6 +97,7 @@ export default function LimitationsPage() {
isZh ? '点击、文本输入、选择' : 'Click, text input, select', isZh ? '点击、文本输入、选择' : 'Click, text input, select',
isZh ? '页面滚动(垂直 / 水平)' : 'Scroll (vertical / horizontal)', isZh ? '页面滚动(垂直 / 水平)' : 'Scroll (vertical / horizontal)',
isZh ? '表单提交、焦点切换' : 'Form submit, focus', isZh ? '表单提交、焦点切换' : 'Form submit, focus',
isZh ? '同源 iframe(仅单层)' : 'Same-origin iframe (single level only)',
isZh ? '执行 JavaScript(可选)' : 'Execute JavaScript (opt-in)', isZh ? '执行 JavaScript(可选)' : 'Execute JavaScript (opt-in)',
].map((text) => ( ].map((text) => (
<li key={text} className="flex items-center text-gray-700 dark:text-gray-300"> <li key={text} className="flex items-center text-gray-700 dark:text-gray-300">
@@ -115,6 +116,7 @@ export default function LimitationsPage() {
isZh ? '悬停、拖拽、右键菜单' : 'Hover, drag & drop, right-click', isZh ? '悬停、拖拽、右键菜单' : 'Hover, drag & drop, right-click',
isZh ? '键盘快捷键' : 'Keyboard shortcuts', isZh ? '键盘快捷键' : 'Keyboard shortcuts',
isZh ? '坐标定位操作' : 'Position-based control', isZh ? '坐标定位操作' : 'Position-based control',
isZh ? '嵌套 iframe、跨域 iframe' : 'Nested iframes, cross-origin iframes',
isZh ? '绘图操作' : 'Drawing', isZh ? '绘图操作' : 'Drawing',
isZh isZh
? 'Monaco、CodeMirror 等需要通过 JS 实例控制的编辑器' ? 'Monaco、CodeMirror 等需要通过 JS 实例控制的编辑器'
@@ -274,10 +274,12 @@ function ApiErrorsContent(isZh: boolean) {
code={`const agent = new PageAgent({ code={`const agent = new PageAgent({
// ... // ...
customFetch: async (url, init) => { customFetch: async (url, init) => {
const body = JSON.parse(init.body)
// Adapt parameters for your provider // Adapt parameters for your provider
delete body.stream_options const body = JSON.parse(init.body)
return fetch(url, { ...init, body: JSON.stringify(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 opacity: number
}[] = [ }[] = [
{ name: 'OpenAI', color: '#10b981', x: 18, y: 22, size: 1.5, opacity: 1 }, { 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: 'Claude', color: '#f97316', x: 58, y: 15, size: 1.35, opacity: 0.95 },
{ name: 'Qwen', color: '#8b5cf6', x: 38, y: 50, size: 1.8, opacity: 0.9 }, { name: 'Qwen', color: '#8b5cf6', x: 35, y: 50, size: 1.8, opacity: 0.9 },
{ name: 'Gemini', color: '#3b82f6', x: 68, y: 48, size: 1.2, opacity: 0.85 }, { name: 'Gemini', color: '#3b82f6', x: 72, y: 48, size: 1.2, opacity: 0.85 },
{ name: 'DeepSeek', color: '#06b6d4', x: 10, y: 65, size: 1.1, opacity: 0.8 }, { 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: '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: '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: '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 }, { name: 'LLaMA', color: '#60a5fa', x: 88, y: 70, size: 0.8, opacity: 0.45 },
@@ -10,7 +10,7 @@ import { Particles } from '../../components/ui/particles'
import { import {
CDN_DEMO_CN_URL, CDN_DEMO_CN_URL,
CDN_DEMO_URL, CDN_DEMO_URL,
DEMO_API_KEY, // DEMO_API_KEY,
DEMO_BASE_URL, DEMO_BASE_URL,
DEMO_MODEL, DEMO_MODEL,
} from '../../constants' } from '../../constants'
@@ -94,7 +94,7 @@ export default function HeroSection() {
apiKey: apiKey:
import.meta.env.DEV && import.meta.env.LLM_API_KEY import.meta.env.DEV && import.meta.env.LLM_API_KEY
? import.meta.env.LLM_API_KEY ? import.meta.env.LLM_API_KEY
: DEMO_API_KEY, : undefined,
}) })
} }
@@ -103,10 +103,7 @@ export default function HeroSection() {
} }
return ( return (
<section <section className="relative px-6 pt-18 pb-14 lg:pb-20 lg:pt-24" aria-labelledby="hero-heading">
className="relative px-6 pt-24 py-20 pb-18 lg:py-22 lg:pt-28 overflow-hidden"
aria-labelledby="hero-heading"
>
<div className="max-w-7xl mx-auto text-center"> <div className="max-w-7xl mx-auto text-center">
{/* Background Pattern + Particles */} {/* Background Pattern + Particles */}
<div className="absolute inset-0 opacity-30" aria-hidden="true"> <div className="absolute inset-0 opacity-30" aria-hidden="true">
@@ -122,7 +119,7 @@ export default function HeroSection() {
/> />
<div className="relative z-10"> <div className="relative z-10">
<div className="inline-flex items-center px-4 py-2 mb-8 text-sm font-medium bg-white/90 dark:bg-gray-800/90 rounded-full shadow-lg border border-gray-200 dark:border-gray-700"> <div className="inline-flex items-center px-4 py-2 mb-4 text-sm font-medium bg-white/90 dark:bg-gray-800/90 rounded-full shadow-lg border border-gray-200 dark:border-gray-700">
<span <span
className="w-2 h-2 bg-blue-500 rounded-full mr-2 animate-pulse" className="w-2 h-2 bg-blue-500 rounded-full mr-2 animate-pulse"
aria-hidden="true" aria-hidden="true"
@@ -134,7 +131,7 @@ export default function HeroSection() {
<h1 <h1
id="hero-heading" id="hero-heading"
className="text-5xl lg:text-7xl font-bold mb-14 mt-8 bg-linear-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent pb-1" className="text-5xl lg:text-7xl font-bold mb-10 mt-8 bg-linear-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent pb-1"
> >
{isZh ? ( {isZh ? (
<> <>
@@ -58,6 +58,22 @@ export default function OneMoreThingSection() {
</Link> </Link>
</div> </div>
<div className="mb-10 rounded-2xl border border-blue-200/70 dark:border-blue-800/70 bg-linear-to-r from-blue-50 to-white dark:from-blue-950/30 dark:to-gray-900 px-5 py-4 max-w-3xl mx-auto text-left sm:text-center">
<p className="text-sm text-gray-700 dark:text-gray-300 leading-7">
{isZh
? '从 Claude Desktop、Copilot 或其他本地 Agent 直接发起浏览器任务?'
: 'Using Claude Desktop, Copilot, or another local agent? Connect it to the extension with the MCP server.'}
</p>
<p>
<Link
href="/docs/features/mcp-server"
className="font-medium text-blue-700 dark:text-blue-300 underline underline-offset-4"
>
{isZh ? '查看 MCP 文档' : 'Read the MCP docs'}
</Link>
</p>
</div>
<div className="grid sm:grid-cols-3 gap-5 text-left max-w-3xl mx-auto"> <div className="grid sm:grid-cols-3 gap-5 text-left max-w-3xl mx-auto">
{[ {[
{ {
@@ -67,16 +83,16 @@ export default function OneMoreThingSection() {
: 'Run tasks across multiple pages and tabs without being limited to a single page context', : 'Run tasks across multiple pages and tabs without being limited to a single page context',
}, },
{ {
title: isZh ? '页面发起控制' : 'Control from Your Page', title: isZh ? '页面发起控制' : 'Control from a WebPage',
desc: isZh desc: isZh
? '在页面 JS 中发起任务,驱动整个浏览器完成跨标签操作' ? '在页面 JS 中发起任务,驱动整个浏览器完成跨标签操作'
: 'Trigger tasks from page JS to drive the entire browser across tabs', : 'Trigger tasks from in-page JS to drive the entire browser across tabs',
}, },
{ {
title: isZh ? '外部发起任务' : 'External Triggers', title: isZh ? '外部发起任务' : 'External Caller',
desc: isZh desc: isZh
? '页面 JS、本地 Agent 或云端 Agent 均可通过扩展发起任务' ? '页面 JS、本地 Agent 或云端 Agent 均可通过扩展发起任务'
: 'Page JS, local agents, or cloud agents can trigger tasks through the extension', : 'Local agents and cloud agents can control user browser through the extension',
}, },
].map((item) => ( ].map((item) => (
<MagicCard <MagicCard
+2
View File
@@ -26,7 +26,9 @@ const SPA_ROUTES = [
'docs/features/data-masking', 'docs/features/data-masking',
'docs/features/custom-instructions', 'docs/features/custom-instructions',
'docs/features/models', 'docs/features/models',
'docs/features/local-llms',
'docs/features/chrome-extension', 'docs/features/chrome-extension',
'docs/features/mcp-server',
'docs/features/third-party-agent', 'docs/features/third-party-agent',
'docs/advanced/page-agent', 'docs/advanced/page-agent',
'docs/advanced/page-agent-core', 'docs/advanced/page-agent-core',