Compare commits

...

121 Commits

Author SHA1 Message Date
Simon f73831036d chore(version): bump version to 1.11.0 2026-07-03 23:57:03 +08:00
Simon f8af56032a chore(ui): remove legacy motion-css code (#583)
Unused CSS motion experiment with known performance issues; no references remain.
2026-07-03 23:51:29 +08:00
Erwann Robin 60fe4c4c84 chore: set the bookmark script to English locale (#580)
* chore: set the bookmark script to English locale

* chore: adapt the booklet to the locale

* refactor(demo): improve naming to better distinguish mirror and lang

---------

Co-authored-by: Simon <10131203+gaomeng1900@users.noreply.github.com>
2026-07-03 22:49:12 +08:00
Simon 4ce6d9e5c7 Merge pull request #581 from alibaba/refactor/llms
refactor(llms): rewrite model patching and deprecate temperature
2026-07-03 20:37:43 +08:00
Simon 9a75ead6ad fix: do not enable OpenRouter reasoning by default 2026-07-03 20:30:57 +08:00
Simon 10276c6e67 fix: new URL should always be in a try block 2026-07-03 20:14:11 +08:00
Simon 5aeb67f6d6 feat(llms): update model list; update patch 2026-07-03 03:04:43 +08:00
Simon 556bc7448a chore(llms): add real-api test for all supported models 2026-07-03 02:29:06 +08:00
Simon 04b84b6e8a fix(llms): model compatibility problem 2026-07-03 02:26:01 +08:00
Simon 2163a55cc7 docs: remove models that can not be tested anymore from the list 2026-07-03 02:25:03 +08:00
Simon a47e12c0e0 fix: model patch for GPT and Claude 2026-07-02 22:11:28 +08:00
Simon d1c3dc811e docs: update model list 2026-07-02 21:51:55 +08:00
Simon 8ad719744a feat: rewrite llm config and model patch 2026-07-02 21:31:44 +08:00
Simon 83fee37fb4 fix: ci 2026-07-02 03:13:39 +08:00
Simon b41535ef5e Merge pull request #579 from alibaba/chore/cspell-vscode-config
chore: move cSpell config to .vscode/cspell.json
2026-07-02 03:09:00 +08:00
Simon 8ad1174f20 chore: move cSpell config to .vscode/cspell.json
Centralize project dictionary and ignore paths; fix minor prompt typo and GA tag spell-check noise.
2026-07-02 03:05:38 +08:00
Simon 026171715c Merge pull request #578 from alibaba/fix/wrong-active-tab-multi-window
fix: wrong active tab/window in multi-window scenarios
2026-07-02 02:43:04 +08:00
Simon 8b902e1e10 docs(ext): add limitations section 2026-07-02 02:32:33 +08:00
Simon 1f45e74378 fix(extension): resolve active tab/window per caller context 2026-07-02 02:11:32 +08:00
Simon 585e7a4900 chore: update deps 2026-07-01 17:09:57 +08:00
Simon 04a397c19e Merge pull request #577 from alibaba/dependabot/npm_and_yarn/development-dependencies-333bd1d898
chore(deps-dev): bump the development-dependencies group with 16 updates
2026-06-30 19:08:33 +08:00
Simon ae4276c624 fix: code style 2026-06-30 19:00:58 +08:00
dependabot[bot] 7579def667 chore(deps-dev): bump the development-dependencies group with 16 updates
Bumps the development-dependencies group with 16 updates:

| Package | From | To |
| --- | --- | --- |
| [@commitlint/cli](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/cli) | `21.0.2` | `21.1.0` |
| [@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/config-conventional) | `21.0.2` | `21.1.0` |
| [@eslint-react/eslint-plugin](https://github.com/Rel1cx/eslint-react/tree/HEAD/plugins/eslint-plugin) | `5.9.2` | `5.10.0` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.1` | `4.3.2` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.0.0` | `26.0.1` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.2` | `6.0.3` |
| [eslint](https://github.com/eslint/eslint) | `10.5.0` | `10.6.0` |
| [prettier](https://github.com/prettier/prettier) | `3.8.4` | `3.9.3` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.62.0` | `8.62.1` |
| [unplugin-dts](https://github.com/qmhc/unplugin-dts/tree/HEAD/packages/unplugin-dts) | `1.0.2` | `1.0.3` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.16` | `8.1.0` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.21.0` | `1.22.0` |
| [motion](https://github.com/motiondivision/motion) | `12.40.0` | `12.42.0` |
| [simple-icons](https://github.com/simple-icons/simple-icons) | `16.24.0` | `16.24.1` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.1` | `4.3.2` |
| [wxt](https://github.com/wxt-dev/wxt) | `0.20.26` | `0.20.27` |


Updates `@commitlint/cli` from 21.0.2 to 21.1.0
- [Release notes](https://github.com/conventional-changelog/commitlint/releases)
- [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/cli/CHANGELOG.md)
- [Commits](https://github.com/conventional-changelog/commitlint/commits/v21.1.0/@commitlint/cli)

Updates `@commitlint/config-conventional` from 21.0.2 to 21.1.0
- [Release notes](https://github.com/conventional-changelog/commitlint/releases)
- [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/config-conventional/CHANGELOG.md)
- [Commits](https://github.com/conventional-changelog/commitlint/commits/v21.1.0/@commitlint/config-conventional)

Updates `@eslint-react/eslint-plugin` from 5.9.2 to 5.10.0
- [Release notes](https://github.com/Rel1cx/eslint-react/releases)
- [Changelog](https://github.com/Rel1cx/eslint-react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Rel1cx/eslint-react/commits/v5.10.0/plugins/eslint-plugin)

Updates `@tailwindcss/vite` from 4.3.1 to 4.3.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.3.2/packages/@tailwindcss-vite)

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

Updates `@vitejs/plugin-react` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.3/packages/plugin-react)

Updates `eslint` from 10.5.0 to 10.6.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.5.0...v10.6.0)

Updates `prettier` from 3.8.4 to 3.9.3
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.4...3.9.3)

Updates `typescript-eslint` from 8.62.0 to 8.62.1
- [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.62.1/packages/typescript-eslint)

Updates `unplugin-dts` from 1.0.2 to 1.0.3
- [Release notes](https://github.com/qmhc/unplugin-dts/releases)
- [Changelog](https://github.com/qmhc/unplugin-dts/blob/main/packages/unplugin-dts/CHANGELOG.md)
- [Commits](https://github.com/qmhc/unplugin-dts/commits/unplugin-dts@1.0.3/packages/unplugin-dts)

Updates `vite` from 8.0.16 to 8.1.0
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.1.0/packages/vite)

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

Updates `motion` from 12.40.0 to 12.42.0
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.40.0...v12.42.0)

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

Updates `tailwindcss` from 4.3.1 to 4.3.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.3.2/packages/tailwindcss)

Updates `wxt` from 0.20.26 to 0.20.27
- [Release notes](https://github.com/wxt-dev/wxt/releases)
- [Commits](https://github.com/wxt-dev/wxt/compare/wxt-v0.20.26...wxt-v0.20.27)

---
updated-dependencies:
- dependency-name: "@commitlint/cli"
  dependency-version: 21.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: "@commitlint/config-conventional"
  dependency-version: 21.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: "@eslint-react/eslint-plugin"
  dependency-version: 5.10.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@types/node"
  dependency-version: 26.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: eslint
  dependency-version: 10.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: prettier
  dependency-version: 3.9.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.62.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: unplugin-dts
  dependency-version: 1.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: vite
  dependency-version: 8.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: lucide-react
  dependency-version: 1.22.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: motion
  dependency-version: 12.42.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: simple-icons
  dependency-version: 16.24.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: tailwindcss
  dependency-version: 4.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: wxt
  dependency-version: 0.20.27
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 00:43:38 +00:00
Simon 187d5deccb Merge pull request #568 from itxaiohanglover/fix/chat-latest-reasoning-effort
fix(llms): skip reasoning_effort patch for chat-latest models
2026-06-26 01:07:30 +08:00
Simon fb221b4535 Merge pull request #573 from alibaba/feat/assets-repo
fix(assets): migrate img.alicdn.com URLs to page-agent.github.io
2026-06-26 01:02:50 +08:00
Simon 5058c96a78 fix(assets): migrate img.alicdn.com URLs to page-agent.github.io 2026-06-26 00:56:37 +08:00
Simon 2b679d85cb fix(llms): omitting both reasoning_effort and temperature for chat-latest 2026-06-26 00:12:34 +08:00
artboy 57f64347ac fix(llms): skip reasoning_effort patch for *-chat-latest models
normalizeModelName reduces gpt-5.2-chat-latest to gpt-52-chat-latest,
which matches the gpt-52 prefix and gets reasoning_effort='none'.
But chat-latest models don't support reasoning_effort, causing
HTTP 400.

Fix: check for 'chat-latest' before applying reasoning_effort patches.

Fixes #563
2026-06-23 14:26:45 +08:00
Simon fb4b8fd067 Merge pull request #565 from alibaba/dependabot/github_actions/github-actions-640176b5ab
chore(deps): bump actions/checkout from 6 to 7 in the github-actions group
2026-06-23 14:24:52 +08:00
Simon 2bed20cd85 Merge pull request #566 from alibaba/dependabot/npm_and_yarn/development-dependencies-1075e1fe2e
chore(deps-dev): bump the development-dependencies group with 8 updates
2026-06-23 14:24:36 +08:00
Simon c48c7c524e Merge pull request #567 from alibaba/dependabot/npm_and_yarn/development-major-3bc154b33b
chore(deps-dev): bump @types/node from 25.9.3 to 26.0.0 in the development-major group
2026-06-23 14:24:20 +08:00
dependabot[bot] 32f27e73e3 chore(deps-dev): bump @types/node in the development-major group
Bumps the development-major group with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


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

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: development-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-23 00:30:20 +00:00
dependabot[bot] 71738f1966 chore(deps-dev): bump the development-dependencies group with 8 updates
Bumps the development-dependencies group with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [@eslint-react/eslint-plugin](https://github.com/Rel1cx/eslint-react/tree/HEAD/plugins/eslint-plugin) | `5.9.0` | `5.9.2` |
| [globals](https://github.com/sindresorhus/globals) | `17.6.0` | `17.7.0` |
| [happy-dom](https://github.com/capricorn86/happy-dom) | `20.10.4` | `20.10.6` |
| [lint-staged](https://github.com/lint-staged/lint-staged) | `17.0.7` | `17.0.8` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.61.1` | `8.62.0` |
| [@types/chrome](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/chrome) | `0.1.43` | `0.2.0` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.18.0` | `1.21.0` |
| [simple-icons](https://github.com/simple-icons/simple-icons) | `16.23.0` | `16.24.0` |


Updates `@eslint-react/eslint-plugin` from 5.9.0 to 5.9.2
- [Release notes](https://github.com/Rel1cx/eslint-react/releases)
- [Changelog](https://github.com/Rel1cx/eslint-react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Rel1cx/eslint-react/commits/v5.9.2/plugins/eslint-plugin)

Updates `globals` from 17.6.0 to 17.7.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.6.0...v17.7.0)

Updates `happy-dom` from 20.10.4 to 20.10.6
- [Release notes](https://github.com/capricorn86/happy-dom/releases)
- [Commits](https://github.com/capricorn86/happy-dom/compare/v20.10.4...v20.10.6)

Updates `lint-staged` from 17.0.7 to 17.0.8
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.7...v17.0.8)

Updates `typescript-eslint` from 8.61.1 to 8.62.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.62.0/packages/typescript-eslint)

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

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

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

---
updated-dependencies:
- dependency-name: "@eslint-react/eslint-plugin"
  dependency-version: 5.9.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: globals
  dependency-version: 17.7.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: happy-dom
  dependency-version: 20.10.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: lint-staged
  dependency-version: 17.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.62.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: "@types/chrome"
  dependency-version: 0.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: lucide-react
  dependency-version: 1.21.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: simple-icons
  dependency-version: 16.24.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-23 00:29:32 +00:00
dependabot[bot] e44455ba80 chore(deps): bump actions/checkout in the github-actions group
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-23 00:27:34 +00:00
Simon d2ee260d82 Merge pull request #560 from alibaba/dependabot/npm_and_yarn/development-dependencies-d854c95bb8
chore(deps-dev): bump the development-dependencies group across 1 directory with 17 updates
2026-06-17 20:53:39 +08:00
dependabot[bot] 1f738bc41c chore(deps-dev): bump the development-dependencies group across 1 directory with 17 updates
Bumps the development-dependencies group with 16 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@eslint-react/eslint-plugin](https://github.com/Rel1cx/eslint-react/tree/HEAD/plugins/eslint-plugin) | `5.8.16` | `5.9.0` |
| [@microsoft/api-extractor](https://github.com/microsoft/rushstack/tree/HEAD/apps/api-extractor) | `7.58.8` | `7.58.9` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.0` | `4.3.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.2` | `25.9.3` |
| [eslint](https://github.com/eslint/eslint) | `10.4.1` | `10.5.0` |
| [happy-dom](https://github.com/capricorn86/happy-dom) | `20.10.2` | `20.10.4` |
| [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.8.4` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.61.0` | `8.61.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` |
| [@radix-ui/react-hover-card](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/hover-card) | `1.1.16` | `1.1.17` |
| [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.9` | `2.1.10` |
| [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.9` | `1.1.10` |
| [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.2.5` | `1.3.0` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.3.0` | `1.3.1` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.17.0` | `1.18.0` |
| [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.9` | `1.2.10` |



Updates `@eslint-react/eslint-plugin` from 5.8.16 to 5.9.0
- [Release notes](https://github.com/Rel1cx/eslint-react/releases)
- [Changelog](https://github.com/Rel1cx/eslint-react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Rel1cx/eslint-react/commits/v5.9.0/plugins/eslint-plugin)

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

Updates `@tailwindcss/vite` from 4.3.0 to 4.3.1
- [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.3.1/packages/@tailwindcss-vite)

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

Updates `eslint` from 10.4.1 to 10.5.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.4.1...v10.5.0)

Updates `happy-dom` from 20.10.2 to 20.10.4
- [Release notes](https://github.com/capricorn86/happy-dom/releases)
- [Commits](https://github.com/capricorn86/happy-dom/compare/v20.10.2...v20.10.4)

Updates `prettier` from 3.8.3 to 3.8.4
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4)

Updates `typescript-eslint` from 8.61.0 to 8.61.1
- [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.61.1/packages/typescript-eslint)

Updates `vitest` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/HEAD/packages/vitest)

Updates `@radix-ui/react-hover-card` from 1.1.16 to 1.1.17
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/hover-card/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/hover-card)

Updates `@radix-ui/react-label` from 2.1.9 to 2.1.10
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/label/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/label)

Updates `@radix-ui/react-separator` from 1.1.9 to 1.1.10
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/separator/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/separator)

Updates `@radix-ui/react-slot` from 1.2.5 to 1.3.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot)

Updates `@radix-ui/react-switch` from 1.3.0 to 1.3.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch)

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

Updates `tailwindcss` from 4.3.0 to 4.3.1
- [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.3.1/packages/tailwindcss)

Updates `@radix-ui/react-tooltip` from 1.2.9 to 1.2.10
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tooltip/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tooltip)

---
updated-dependencies:
- dependency-name: "@eslint-react/eslint-plugin"
  dependency-version: 5.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: "@microsoft/api-extractor"
  dependency-version: 7.58.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@types/node"
  dependency-version: 25.9.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: eslint
  dependency-version: 10.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: happy-dom
  dependency-version: 20.10.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: prettier
  dependency-version: 3.8.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.61.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: vitest
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@radix-ui/react-hover-card"
  dependency-version: 1.1.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@radix-ui/react-label"
  dependency-version: 2.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@radix-ui/react-separator"
  dependency-version: 1.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@radix-ui/react-slot"
  dependency-version: 1.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: "@radix-ui/react-switch"
  dependency-version: 1.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: lucide-react
  dependency-version: 1.18.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: tailwindcss
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@radix-ui/react-tooltip"
  dependency-version: 1.2.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-16 05:00:12 +00:00
Simon 2802ab3dea Merge pull request #559 from alibaba/fix/hoist-happy-dom
fix(deps): hoist happy-dom to root devDependencies
2026-06-16 12:33:51 +08:00
Simon e6a2e07640 fix(deps): hoist happy-dom to root devDependencies 2026-06-16 12:30:55 +08:00
Simon 235fa1bc26 chore: fix lint error caused CI fail 2026-06-15 22:59:34 +08:00
Simon c04c02723d chore(version): bump version to 1.10.0 2026-06-15 17:31:54 +08:00
Simon 77468e4ab4 chore(version): bump version to 1.9.1 2026-06-15 17:22:10 +08:00
Simon 7f9cb61fc2 Merge pull request #557 from alibaba/feat/remove-nav-back-instructions
feat: remove instructions about nav back
2026-06-15 16:55:21 +08:00
Simon 76fea5b285 feat: remove instructions about nav back 2026-06-15 16:41:02 +08:00
Simon a796453c6e chore: rm go_back todo 2026-06-12 19:10:18 +08:00
Simon a1b0dd90eb Merge pull request #511 from linked-danis/fix/report-actual-wait-time
Report actual wait duration
2026-06-12 17:49:43 +08:00
Simon fb8c42daa4 feat: simplify waiting response to reduce LLM cognitive load 2026-06-12 17:44:21 +08:00
Simon 8eaa784a8a fix: github demo video broken 2026-06-11 21:40:01 +08:00
Simon 7dffaa4718 Merge pull request #512 from linked-danis/fix/scroll-predicate-types
Fix scroll predicate types (boolean | null -> boolean)
2026-06-11 21:22:31 +08:00
Simon 0f3f639497 Merge pull request #552 from alibaba/test/page-controller-happy-dom
test(page-controller): add happy-dom env and basic tests
2026-06-11 21:14:28 +08:00
Simon 67b1874836 Merge pull request #551 from alibaba/fix/stale-current-tab-id-mask-flash
fix(extension): clear currentTabId in storage on TabsController.init
2026-06-11 21:13:31 +08:00
Simon 56c09a9ae9 test(page-controller): add happy-dom env and basic tests 2026-06-11 21:10:05 +08:00
Simon e65c7c9601 Merge pull request #545 from alibaba/feat/execute-js-abort-signal
feat(core): make execute_javascript honor AbortSignal
2026-06-11 20:45:53 +08:00
Simon a646da80f5 Merge pull request #549 from alibaba/refactor/agent-lifecycle-state-machine
refactor(core)!: rework agent run lifecycle and status semantics
2026-06-11 20:37:37 +08:00
Simon d3797109bf fix(extension): clear currentTabId in storage on TabsController.init
Reset currentTabId via updateCurrentTabId(null) so the storage projection
stays consistent with isAgentRunning. Prevents the previous run's tab from
briefly flashing the mask when a new run starts within the heartbeat window.

Closes #550
2026-06-11 20:37:19 +08:00
Simon 4e881f7eb0 chore: add notes for a pending design decision 2026-06-11 20:31:01 +08:00
Simon 768b07e28c feat: disable ScriptExecutionTool for MultiPageAgent 2026-06-11 20:16:09 +08:00
Simon 4828f9f726 chore: update tests 2026-06-11 20:06:35 +08:00
Simon edb769b826 fix(ext): handle stopped lifecycle state 2026-06-11 19:53:30 +08:00
Simon e270ba15b5 fix(ext): clear stale activity on any non-running status
The side panel kept showing the last activity card when a run settled as
`stopped`; clear it for every terminal status instead of an allowlist.
2026-06-11 19:15:58 +08:00
Simon c19891926b refactor(ext): drive heartbeat and running flag from statuschange
Project agent status into chrome.storage via a statuschange listener
instead of pairing setup/teardown across lifecycle hooks. A throwing hook
can no longer leak the heartbeat or strand isAgentRunning, and rejected
concurrent execute() calls never touch the active run's state.
2026-06-11 19:15:48 +08:00
Simon 0438bf6265 fix(core): harden run settlement edge cases from review
- install #running before the `running` statuschange fires, so a listener
  calling stop() immediately awaits the current run
- await async mask/highlight cleanup before settling: once settled, the
  agent must be safely reusable
- make the inter-step delay abortable so stop() settles promptly; abort
  during the delay is classified as `stopped`
2026-06-11 19:15:27 +08:00
Simon 255e8fc861 chore: update docs 2026-06-11 17:29:54 +08:00
Simon 4690aefec5 refactor(core): decouple run settling from terminal status transition
Resolve #running before the terminal statuschange so the settle signal can
never be lost to re-entrant listeners. Hooks keep middleware semantics:
a throwing hook fails the run; integrations that don't want this should
suppress errors in their own hooks. Also make suppress() async-aware so
rejected promises (e.g. showMask) are actually caught.
2026-06-11 17:21:11 +08:00
Simon 8f9a637bdb refactor(core): suppress mask/highlight errors instead of failing the run
Visual feedback failures (showMask, hideMask, cleanUpHighlights) are
non-critical; log them instead of aborting the task or masking the
original error during teardown.
2026-06-11 16:26:42 +08:00
Simon c2d6a864f8 refactor(core): settle terminal status only after run cleanup completes
Defer the terminal statuschange to the outer finally via settleRun, closing
the window where a listener could re-enter execute() during teardown. Also
check abort at step start so aborts during stepDelay settle as `stopped`.
2026-06-11 16:26:05 +08:00
Simon 1deaa2147e fix: mke sure taskResult and onAfterStep wont get lost 2026-06-11 15:39:35 +08:00
Simon 052a302a08 refactor(core)!: rework agent run lifecycle and status semantics
BREAKING CHANGE: stop() is now async and resolves after the run fully
settles; status decouples from task outcome (new 'stopped' state, LLM
self-reported failure now ends as 'completed'). Lifecycle hooks re-throw
instead of being folded into the result; agent errors go to history.
Adds agent.lastResult.
2026-06-11 14:33:12 +08:00
Simon 9ed0a09194 feat(core): make execute_javascript honor AbortSignal
Expose the task AbortSignal as `signal` in the script scope so cooperative
code can cancel promptly, and re-check signal.throwIfAborted() after the
script settles to discard stale results.

Closes #537.
2026-06-09 21:30:27 +08:00
Simon 73810b3ed8 Merge pull request #544 from alibaba/feat/refine-dark-mode-check
feat(page-controller): refine dark mode detection heuristics
2026-06-09 20:43:15 +08:00
Simon 01c9cc9ea7 feat(page-controller): refine dark mode detection heuristics 2026-06-09 20:33:14 +08:00
Simon a7beabbbe2 Merge pull request #543 from alibaba/test/page-agent-core-lifecycle
test(core): add PageAgentCore lifecycle tests
2026-06-09 20:32:23 +08:00
Simon b3ff0a1a25 Merge pull request #373 from tageniu/feat/dark-mode-detect
Implement TODO: enhance dark mode detection
2026-06-09 20:18:33 +08:00
Simon daae28db34 test(core): add PageAgentCore lifecycle tests 2026-06-09 17:07:10 +08:00
Simon 797c238862 Merge pull request #534 from 64johnlee/chore/remove-debug-console-logs
chore(page-controller): remove accidental debug console.log calls
2026-06-09 15:07:19 +08:00
Simon 270791bb54 feat: add .codex to .gitignore 2026-06-09 14:50:21 +08:00
Simon 8ec460f431 Merge pull request #542 from alibaba/dependabot/npm_and_yarn/development-dependencies-a28b716e5c 2026-06-09 04:03:38 +08:00
dependabot[bot] 8c3a5f8188 chore(deps-dev): bump the development-dependencies group with 13 updates
Bumps the development-dependencies group with 13 updates:

| Package | From | To |
| --- | --- | --- |
| [@eslint-react/eslint-plugin](https://github.com/Rel1cx/eslint-react/tree/HEAD/plugins/eslint-plugin) | `5.8.10` | `5.8.16` |
| [@microsoft/api-extractor](https://github.com/microsoft/rushstack/tree/HEAD/apps/api-extractor) | `7.58.7` | `7.58.8` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.1` | `25.9.2` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.60.1` | `8.61.0` |
| [@radix-ui/react-hover-card](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/hover-card) | `1.1.15` | `1.1.16` |
| [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.8` | `2.1.9` |
| [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.8` | `1.1.9` |
| [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.2.4` | `1.2.5` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.2.6` | `1.3.0` |
| [@types/chrome](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/chrome) | `0.1.42` | `0.1.43` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.16` | `19.2.17` |
| [simple-icons](https://github.com/simple-icons/simple-icons) | `16.22.0` | `16.23.0` |
| [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.8` | `1.2.9` |


Updates `@eslint-react/eslint-plugin` from 5.8.10 to 5.8.16
- [Release notes](https://github.com/Rel1cx/eslint-react/releases)
- [Changelog](https://github.com/Rel1cx/eslint-react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Rel1cx/eslint-react/commits/v5.8.16/plugins/eslint-plugin)

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

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

Updates `typescript-eslint` from 8.60.1 to 8.61.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.61.0/packages/typescript-eslint)

Updates `@radix-ui/react-hover-card` from 1.1.15 to 1.1.16
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/hover-card/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/hover-card)

Updates `@radix-ui/react-label` from 2.1.8 to 2.1.9
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/label/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/label)

Updates `@radix-ui/react-separator` from 1.1.8 to 1.1.9
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/separator/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/separator)

Updates `@radix-ui/react-slot` from 1.2.4 to 1.2.5
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot)

Updates `@radix-ui/react-switch` from 1.2.6 to 1.3.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch)

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

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

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

Updates `@radix-ui/react-tooltip` from 1.2.8 to 1.2.9
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tooltip/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tooltip)

---
updated-dependencies:
- dependency-name: "@eslint-react/eslint-plugin"
  dependency-version: 5.8.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@microsoft/api-extractor"
  dependency-version: 7.58.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@types/node"
  dependency-version: 25.9.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.61.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: "@radix-ui/react-hover-card"
  dependency-version: 1.1.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@radix-ui/react-label"
  dependency-version: 2.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@radix-ui/react-separator"
  dependency-version: 1.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@radix-ui/react-slot"
  dependency-version: 1.2.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@radix-ui/react-switch"
  dependency-version: 1.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: "@types/chrome"
  dependency-version: 0.1.43
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@types/react"
  dependency-version: 19.2.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: simple-icons
  dependency-version: 16.23.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: "@radix-ui/react-tooltip"
  dependency-version: 1.2.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-08 19:34:16 +00:00
Simon 4ee773bef9 chore(changelog): update for version 1.9.0 release 2026-06-08 22:58:56 +08:00
Simon 3a2f0a41ec chore(version): bump version to 1.9.0 2026-06-08 22:42:46 +08:00
Simon 64bb1f1a56 Merge pull request #541 from alibaba/docs/update-model-list
docs: update model list
2026-06-08 22:37:23 +08:00
Simon 2afcf13fbb feat: update model list 2026-06-08 22:34:33 +08:00
Simon f617728fc3 Merge pull request #540 from alibaba/feat/claude-opus-4-8
feat(llms): add Claude Opus 4.8 support
2026-06-08 22:27:53 +08:00
Simon 9376abfe2a Merge pull request #539 from alibaba/docs/abort-and-concurrency
docs(website): document ctx.signal abort contract and execute() concurrency
2026-06-08 22:26:19 +08:00
Simon 2f343bcc56 feat(llms): add Claude Opus 4.8 support
Strip temperature for claude-opus-4-8, add tests, list model on website.
2026-06-08 22:23:56 +08:00
Simon 4108f67079 docs(website): document ctx.signal abort contract and execute() concurrency 2026-06-08 22:19:25 +08:00
Simon 0ba3bbd67f Merge pull request #536 from alibaba/fix/prevent-concurrent-execute
fix(core): prevent concurrent execute() calls
2026-06-08 22:04:19 +08:00
Simon 552987eb56 fix(core): prevent concurrent execute() calls 2026-06-08 21:58:16 +08:00
Simon 99ef08ac79 Merge pull request #532 from alibaba/feat/rubost-abort
feat: robust aborting
2026-06-08 21:53:05 +08:00
Simon 4445bec08a fix: abort may affect onAfterTask; clean up while loop 2026-06-08 19:46:19 +08:00
Simon 6530f0ef40 chore: cleanup; rm get abortSignal 2026-06-08 17:40:44 +08:00
Simon f8676a5cc2 fix: throw unhonored AbortError & rm detection code for it 2026-06-08 17:27:05 +08:00
64JohnLee 7b623c5b22 chore(page-controller): remove accidental debug console.log calls
Two debug log statements were left in production code:

- `isScrollableElement()` logged `scrollData!!!` for every scrollable
  element found during DOM tree construction. Because the DOM is rebuilt
  on every agent action, this fired repeatedly and also triggered
  unnecessary JSON serialisation of scroll metrics on the hot path.

- `SimulatorMask.dispose()` logged 'dispose SimulatorMask' every time
  the highlight overlay was torn down.

Neither had a structured prefix or was gated behind a debug flag.
Removing both silences console noise for end users and removes the
serialisation overhead in the scroll-detection hot path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 16:47:04 +08:00
Simon 029f64e4bc Merge branch 'feat/rubost-abort' of https://github.com/alibaba/page-agent into feat/rubost-abort 2026-06-05 21:25:07 +08:00
Simon 78b6e2ad3c feat: all sync tools should respect aborting 2026-06-05 21:23:18 +08:00
Simon 112f9fff39 docs: planning 2026-06-05 20:27:30 +08:00
Simon 1c82030a21 Merge pull request #531 from alibaba/chore/vitest-llms
chore(llms): add vitest unit tests
2026-06-04 21:05:19 +08:00
Simon f378972ef8 docs: fix test workflow docs 2026-06-04 20:43:09 +08:00
Simon 68ae73d4e6 chore(llms): add vitest unit tests for llms package 2026-06-04 20:27:33 +08:00
Simon 874b302860 Merge pull request #530 from alibaba/fix/dts
fix: dts bug hotifx
2026-06-04 17:51:00 +08:00
Simon 90ff96ca15 fix: dts global declarations should not be bundled 2026-06-04 17:44:59 +08:00
Simon 3c0d55fa33 Merge pull request #527 from alibaba/fix/abort-error-contract
improve error handling
2026-06-03 23:40:48 +08:00
Simon 9891e02215 fix(llms): handle AbortError during .json() 2026-06-03 23:31:01 +08:00
Simon 823195ad94 fix(llms): add error handling for invalid JSON responses and schema validation 2026-06-03 23:14:12 +08:00
Simon fd12fb9f1b refactor(llms): split AbortError out of InvokeError 2026-06-03 23:00:33 +08:00
Simon fb8de08c39 fix(llms): fix abort handling and clean up retry logic 2026-06-03 22:44:54 +08:00
Simon 738a62e96c Merge pull request #526 from alibaba/chore/typo
chore: typo
2026-06-03 20:22:17 +08:00
Simon e7b93efda0 chore: typo
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 20:20:37 +08:00
Simon 0b6f0a6068 Merge pull request #524 from alibaba/dependabot/npm_and_yarn/development-major-a28a93d02a
chore(deps-dev): bump concurrently from 9.2.1 to 10.0.3 in the development-major group
2026-06-03 15:12:41 +08:00
Simon cc16d39e00 Merge pull request #525 from alibaba/fix/wxt-audit-overrides
fix: temporarily patch WXT audit vulnerabilities
2026-06-03 15:11:51 +08:00
Simon 7eceff26ef fix: patch WXT audit vulnerabilities 2026-06-03 15:05:34 +08:00
dependabot[bot] 61a791f759 chore(deps-dev): bump concurrently in the development-major group
Bumps the development-major group with 1 update: [concurrently](https://github.com/open-cli-tools/concurrently).


Updates `concurrently` from 9.2.1 to 10.0.3
- [Release notes](https://github.com/open-cli-tools/concurrently/releases)
- [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v10.0.3)

---
updated-dependencies:
- dependency-name: concurrently
  dependency-version: 10.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: development-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 06:05:01 +00:00
Simon 6ecd6e61f6 Merge pull request #506 from tolerious/fix/fix-bug-505
fix(website): normalize trailing slash in sidebar location comparison
2026-06-02 17:56:30 +08:00
Simon 1e13385f1f Merge pull request #520 from alibaba/chore/upgrade-deps
chore: upgrade deps
2026-06-02 17:16:54 +08:00
Simon dec9fe8b3d chore: update node version and lockfile 2026-06-02 17:11:18 +08:00
Simon ebd8ec5ae7 chore: upgrade deps 2026-05-28 16:06:15 +08:00
linked-danis e525e4c6dd fix: report actual wait duration 2026-05-18 15:01:06 +02:00
linked-danis 903c2a736c fix: return booleans from scroll predicates 2026-05-18 15:00:52 +02:00
Toly 482190777c fix(website): normalize trailing slash in sidebar location comparison 2026-05-16 21:03:51 +08:00
Simon c6f09375f8 fix(controller): isMainContentDark too aggressive 2026-04-20 20:31:50 -04:00
tageniu cc33297cc4 fix(controller): check html and body data attrs independently
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-20 20:31:45 -04:00
Shijie Sun 8556745645 feat(page-controller): enhance dark mode detection
Resolve the @TODO in checkDarkMode.ts by adding 3 new detection
strategies and expanding data-attribute coverage:

- Check data-color-mode, data-bs-theme, data-color-scheme attributes
- Read CSS color-scheme property and <meta name="color-scheme"> tag
- Inspect background of SPA containers (#app, #root, #__next, etc.)
- Detect light text color as a dark-theme signal
2026-04-20 20:14:07 -04:00
81 changed files with 4622 additions and 3048 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
node-version: [24] node-version: [24]
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
id-token: write id-token: write
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
+1
View File
@@ -40,3 +40,4 @@ package.json.bak
.claude .claude
.cursor .cursor
.gemini .gemini
.codex
+82
View File
@@ -0,0 +1,82 @@
{
"version": "0.2",
"language": "en",
"globRoot": "..",
"ignorePaths": [
"**/node_modules/**",
"**/dist/**",
"**/dist-ssr/**",
"**/.output/**",
"**/.wxt/**",
"**/*.svg"
],
"words": [
"activedescendant",
"adex",
"agentactivity",
"agentic",
"akldabonmimlicnjlflnapfeklbfemhj",
"aliyun",
"Bailian",
"Bento",
"BYOK",
"Chromewebstore",
"contenteditable",
"deepseek",
"executionresult",
"fieldsets",
"gaomeng",
"Googlechrome",
"Grabbable",
"Gregor",
"haspopup",
"highspeed",
"historicalevent",
"historychange",
"HITL",
"hrefs",
"IDBP",
"innerhtml",
"instructionsconfig",
"kimi",
"languagedetector",
"Lian",
"llms",
"magicui",
"maxlength",
"menuitemcheckbox",
"menuitemradio",
"modelcontextprotocol",
"multipageagent",
"nesw",
"npmmirror",
"Ollama",
"onwarn",
"opensource",
"openrouter",
"pageagent",
"pageagentcore",
"pageagentext",
"postpublish",
"qwen",
"reactid",
"reactroot",
"retryable",
"shadcn",
"sidepanel",
"spinbutton",
"staticity",
"statuschange",
"Supabase",
"toggleable",
"trivago",
"Uncapitalize",
"unparseable",
"valuenow",
"valuetext",
"WCAG",
"webgl",
"wouter",
"Zunic"
]
}
-19
View File
@@ -1,23 +1,4 @@
{ {
"cSpell.words": [
"contenteditable",
"deepseek",
"historychange",
"HITL",
"innerhtml",
"languagedetector",
"llms",
"magicui",
"npmmirror",
"onwarn",
"opensource",
"qwen",
"retryable",
"shadcn",
"sidepanel",
"statuschange",
"wouter"
],
"files.exclude": { "files.exclude": {
"packages/*/node_modules": true "packages/*/node_modules": true
}, },
+15
View File
@@ -23,6 +23,7 @@ npm run build # Build all packages
npm run build:libs # Build all libraries npm run build:libs # Build all libraries
npm run build:ext # Build and zip the extension package npm run build:ext # Build and zip the extension package
npm run typecheck # Typecheck all packages npm run typecheck # Typecheck all packages
npm run test # Run unit tests across all workspaces
npm run lint # ESLint npm run lint # ESLint
``` ```
@@ -125,6 +126,20 @@ const pageInfo = await this.pageController.getPageInfo()
2. Expose via async method in `PageController.ts` 2. Expose via async method in `PageController.ts`
3. Export from `packages/page-controller/src/index.ts` 3. Export from `packages/page-controller/src/index.ts`
## Testing
- **Framework**: Vitest (unit tests only for now; future E2E goes to `packages/e2e/` with Playwright)
- **Location**: co-located, `src/foo.test.ts` next to `src/foo.ts`
- **Coverage today**: `packages/llms` only — other packages will follow incrementally
- **Adding tests to a new package**: create `vitest.config.ts` in the package and add a `"test": "vitest run"` script. Root `npm test` and `node scripts/ci.js` pick it up through npm workspaces.
- **Template**: See @page-agent/llms
```bash
npm test # all packages with a test script
npm test -w @page-agent/llms # single package
cd packages/llms && npx vitest # watch mode in one package
```
## Code Standards ## Code Standards
- Explicit typing for exported/public APIs - Explicit typing for exported/public APIs
+9 -7
View File
@@ -1,8 +1,8 @@
# Page Agent # Page Agent
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://img.alicdn.com/imgextra/i4/O1CN01qKig1P1FnhpFKNdi6_!!6000000000532-2-tps-1280-256.png"> <source media="(prefers-color-scheme: dark)" srcset="https://page-agent.github.io/assets/readme/banner-dark.png">
<img alt="Page Agent Banner" src="https://img.alicdn.com/imgextra/i1/O1CN01NCMKXj1Gn4tkFTsxf_!!6000000000666-2-tps-1280-256.png"> <img alt="Page Agent Banner" src="https://page-agent.github.io/assets/readme/banner-light.png">
</picture> </picture>
[![License: MIT](https://img.shields.io/badge/License-MIT-auto.svg)](https://opensource.org/licenses/MIT) [![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg)](http://www.typescriptlang.org/) [![Bundle Size](https://img.shields.io/bundlephobia/minzip/page-agent)](https://bundlephobia.com/package/page-agent) [![Downloads](https://img.shields.io/npm/dt/page-agent.svg)](https://www.npmjs.com/package/page-agent) [![GitHub stars](https://img.shields.io/github/stars/alibaba/page-agent.svg)](https://github.com/alibaba/page-agent) [![License: MIT](https://img.shields.io/badge/License-MIT-auto.svg)](https://opensource.org/licenses/MIT) [![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg)](http://www.typescriptlang.org/) [![Bundle Size](https://img.shields.io/bundlephobia/minzip/page-agent)](https://bundlephobia.com/package/page-agent) [![Downloads](https://img.shields.io/npm/dt/page-agent.svg)](https://www.npmjs.com/package/page-agent) [![GitHub stars](https://img.shields.io/github/stars/alibaba/page-agent.svg)](https://github.com/alibaba/page-agent)
@@ -13,7 +13,9 @@ The GUI Agent Living in Your Webpage. Control web interfaces with natural langua
<a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank"><b>📖 Docs</b></a> | <a href="https://news.ycombinator.com/item?id=47264138" target="_blank"><b>📢 HN Discussion</b></a> | <a href="https://x.com/simonluvramen" target="_blank"><b>𝕏 Follow on X</b></a> <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank"><b>📖 Docs</b></a> | <a href="https://news.ycombinator.com/item?id=47264138" target="_blank"><b>📢 HN Discussion</b></a> | <a href="https://x.com/simonluvramen" target="_blank"><b>𝕏 Follow on X</b></a>
<video id="demo-video" src="https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2" controls crossorigin muted></video> <!-- demo video -->
https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2
--- ---
@@ -48,10 +50,10 @@ Fastest way to try PageAgent with our free Demo LLM:
> **⚠️ For technical evaluation only.** This demo CDN uses our free [testing LLM API](https://alibaba.github.io/page-agent/docs/features/models#free-testing-api). By using it, you agree to its [terms](https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md). > **⚠️ For technical evaluation only.** This demo CDN uses our free [testing LLM API](https://alibaba.github.io/page-agent/docs/features/models#free-testing-api). By using it, you agree to its [terms](https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md).
| Mirrors | URL | | Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- | | ------- | ----------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.8.2/dist/iife/page-agent.demo.js | | Global | https://cdn.jsdelivr.net/npm/page-agent@1.11.0/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.8.2/files/dist/iife/page-agent.demo.js | | China | https://registry.npmmirror.com/page-agent/1.11.0/files/dist/iife/page-agent.demo.js |
Add `?autoInit=false` to load the script without creating the demo agent automatically. You can then instantiate it with `new window.PageAgent(...)`. Add `?autoInit=false` to load the script without creating the demo agent automatically. You can then instantiate it with `new window.PageAgent(...)`.
+93
View File
@@ -5,6 +5,99 @@ 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.10.0] - 2026-06-15
### Breaking Changes
- **Agent run lifecycle rework** - `stop()` is now async and resolves only after the run fully settles. Run status is decoupled from task outcome: a new `stopped` state was added, and LLM self-reported failures now end as `completed`. Lifecycle hooks re-throw instead of folding errors into the result, agent errors are recorded in history, and `agent.lastResult` was added.
### Features
- **Abortable JavaScript execution** - `execute_javascript` now honors the `AbortSignal`.
- **Leaner agent prompts** - Simplified the waiting-response flow and removed navigation-back instructions to reduce LLM cognitive load.
- **MultiPageAgent safety** - Disabled `ScriptExecutionTool` for `MultiPageAgent`.
### Improvements
- **Dark mode detection** - Refined detection heuristics and made `isMainContentDark` less aggressive by checking `html` and `body` data attributes independently.
- **Extension lifecycle robustness** - Drove heartbeat and running state from status changes, cleared stale activity on any non-running status, handled the stopped lifecycle state, and cleared `currentTabId` on `TabsController.init`.
### Bug Fixes
- **Accurate wait reporting** - Wait steps now report the actual wait duration.
- **Scroll predicates** - Scroll predicates now return booleans.
- **Docs** - Fixed the broken demo video on GitHub.
## [1.9.0] - 2026-06-08
### Features
- **Robust abort handling** - Rewrote the aborting system; sync tools, loop execution, and LLM clients now correctly respect task abort signals (`ctx.signal`). Also decoupled `AbortError` from `InvokeError` in `@page-agent/llms`.
- **Claude Opus 4.8 support** - Added support for Claude Opus 4.8 model.
### Improvements
- **Concurrency guard** - Prevented concurrent `execute()` calls on a single PageAgent/Core instance to avoid race conditions.
- **Model recommendations refresh** - Updated default and tested model list recommendations.
- **Test coverage** - Added comprehensive Vitest unit tests for the `@page-agent/llms` package.
- **Improved documentation** - Added website documentation for the `ctx.signal` abort contract and `execute()` concurrency rules.
### Bug Fixes
- **DTS bundle fix** - Fixed a packaging bug where global type declarations were incorrectly bundled into `.d.ts` outputs.
- **Website sidebar fix** - Normalized trailing slashes in the website's sidebar location comparison.
## [1.8.2] - 2026-05-11
### Features
- **IIFE demo control** - Added `showPanel` and `autoInit` switches to the IIFE CDN script to control whether the UI panel automatically displays or initializes on load.
### Improvements
- **Build toolchain modernization** - Upgraded build infrastructure to Vite 8.
### Bug Fixes
- **TypeScript `InvokeErrorType` fix** - Separated the value and type space for `InvokeErrorType` to resolve TypeScript compilation issues.
- **Website chunking fix** - Restored working code-splitting with `manualChunks` for the documentation website.
## [1.8.1] - 2026-04-27
### Features
- **GPT-5.4 & Qwen 3.6 support** - Added support for `gpt-5.4` and `qwen3.6-max/flash` in the recommended LLM list.
- **Custom LLM request hook** - Added a `transformRequestBody` hook to allow custom modification of payloads before sending requests to LLM providers.
### Improvements
- **Accessibility (a11y) enhancements** - Added descriptive accessible labels to `ConfigPanel` input fields and icon buttons in compliance with WCAG 4.1.2.
- **UI polish** - Improved `HistoryList` loading and empty states, and added helpful tooltips for actions.
- **Prompt caching guidance** - Added website documentation for prompt caching optimization.
- **Build speedups** - Added parallel build scripts to accelerate local development compilation.
### Bug Fixes
- **DeepSeek tool choice fix** - Disabled explicit `tool_choice` for DeepSeek models to avoid API compatibility errors.
- **MCP version advertising** - MCP server now correctly advertises its package version.
## [1.8.0] - 2026-04-15
### Breaking Changes
- **TypeScript 6 & ESLint 10 upgrade** - Major toolchain modernization. Upgraded the entire monorepo to TypeScript 6 and ESLint 10 with source-first monorepo resolution (library exports resolve to source files directly during local development).
### Improvements
- **MCP security hardening** - Bound the MCP HTTP + WebSocket server to `localhost` only.
- **Extension UI refinement** - Made the history panel height responsive to the viewport and improved the result card readability by increasing font size.
### Bug Fixes
- **SimulatorMask memory leak** - Fixed a memory leak by ensuring the `requestAnimationFrame` loop is cancelled when disposing the SimulatorMask.
- **Autofixer format fix** - Corrected the fallback action format for `autoFixer` when waiting.
- **IIFE scope protection** - Fixed a name collision in IIFE builds by preventing global helper function re-declarations.
## [1.7.1] - 2026-04-04 ## [1.7.1] - 2026-04-04
### Features ### Features
+9 -7
View File
@@ -1,8 +1,8 @@
# Page Agent # Page Agent
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://img.alicdn.com/imgextra/i4/O1CN01qKig1P1FnhpFKNdi6_!!6000000000532-2-tps-1280-256.png"> <source media="(prefers-color-scheme: dark)" srcset="https://page-agent.github.io/assets/readme/banner-dark.png">
<img alt="Page Agent Banner" src="https://img.alicdn.com/imgextra/i1/O1CN01NCMKXj1Gn4tkFTsxf_!!6000000000666-2-tps-1280-256.png"> <img alt="Page Agent Banner" src="https://page-agent.github.io/assets/readme/banner-light.png">
</picture> </picture>
[![License: MIT](https://img.shields.io/badge/License-MIT-auto.svg)](https://opensource.org/licenses/MIT) [![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg)](http://www.typescriptlang.org/) [![Bundle Size](https://img.shields.io/bundlephobia/minzip/page-agent)](https://bundlephobia.com/package/page-agent) [![Downloads](https://img.shields.io/npm/dt/page-agent.svg)](https://www.npmjs.com/package/page-agent) [![GitHub stars](https://img.shields.io/github/stars/alibaba/page-agent.svg)](https://github.com/alibaba/page-agent) [![License: MIT](https://img.shields.io/badge/License-MIT-auto.svg)](https://opensource.org/licenses/MIT) [![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg)](http://www.typescriptlang.org/) [![Bundle Size](https://img.shields.io/bundlephobia/minzip/page-agent)](https://bundlephobia.com/package/page-agent) [![Downloads](https://img.shields.io/npm/dt/page-agent.svg)](https://www.npmjs.com/package/page-agent) [![GitHub stars](https://img.shields.io/github/stars/alibaba/page-agent.svg)](https://github.com/alibaba/page-agent)
@@ -13,7 +13,9 @@
<a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank"><b>📖 Docs</b></a> | <a href="https://news.ycombinator.com/item?id=47264138" target="_blank"><b>📢 HN Discussion</b></a> | <a href="https://x.com/simonluvramen" target="_blank"><b>𝕏 Follow on X</b></a> <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank"><b>📖 Docs</b></a> | <a href="https://news.ycombinator.com/item?id=47264138" target="_blank"><b>📢 HN Discussion</b></a> | <a href="https://x.com/simonluvramen" target="_blank"><b>𝕏 Follow on X</b></a>
<video id="demo-video" src="https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2" controls crossorigin muted></video> <!-- demo video -->
https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2
--- ---
@@ -47,10 +49,10 @@
> **⚠️ 仅用于技术评估。** 该 Demo CDN 使用了免费的[测试 LLM API](https://alibaba.github.io/page-agent/docs/features/models#free-testing-api),使用即表示您同意其[条款](https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md)。 > **⚠️ 仅用于技术评估。** 该 Demo CDN 使用了免费的[测试 LLM API](https://alibaba.github.io/page-agent/docs/features/models#free-testing-api),使用即表示您同意其[条款](https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md)。
| Mirrors | URL | | Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- | | ------- | ----------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.8.2/dist/iife/page-agent.demo.js | | Global | https://cdn.jsdelivr.net/npm/page-agent@1.11.0/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.8.2/files/dist/iife/page-agent.demo.js | | China | https://registry.npmmirror.com/page-agent/1.11.0/files/dist/iife/page-agent.demo.js |
在 URL 后添加 `?autoInit=false` 可只加载脚本,不自动创建 Demo Agent;之后可通过 `new window.PageAgent(...)` 手动初始化。 在 URL 后添加 `?autoInit=false` 可只加载脚本,不自动创建 Demo Agent;之后可通过 `new window.PageAgent(...)` 手动初始化。
+1 -1
View File
@@ -91,7 +91,7 @@ npm run build:ext
- Add a new bookmark - Add a new bookmark
```javascript ```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);})(); javascript:(function(){var s=document.createElement('script');s.src=`http://localhost:5174/page-agent.demo.js?lang=en-US&t=${Math.random()}`;s.onload=()=>console.log(%27PageAgent ready!%27);document.head.appendChild(s);})();
``` ```
- Click the bookmark on any page to load Page-Agent - Click the bookmark on any page to load Page-Agent
+1
View File
@@ -53,6 +53,7 @@ export default defineConfig([
'@typescript-eslint/use-unknown-in-catch-callback-variable': 'off', '@typescript-eslint/use-unknown-in-catch-callback-variable': 'off',
'@typescript-eslint/no-unnecessary-type-parameters': 'off', '@typescript-eslint/no-unnecessary-type-parameters': 'off',
'@typescript-eslint/require-await': 'off', '@typescript-eslint/require-await': 'off',
'@typescript-eslint/no-deprecated': 'off',
'@eslint-react/dom-no-missing-button-type': 'off', '@eslint-react/dom-no-missing-button-type': 'off',
'@eslint-react/no-nested-component-definitions': 'off', '@eslint-react/no-nested-component-definitions': 'off',
'@eslint-react/no-array-index-key': 'off', '@eslint-react/no-array-index-key': 'off',
+2270 -1891
View File
File diff suppressed because it is too large Load Diff
+23 -20
View File
@@ -1,7 +1,7 @@
{ {
"name": "root", "name": "root",
"private": true, "private": true,
"version": "1.8.2", "version": "1.11.0",
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
"packages/page-controller", "packages/page-controller",
@@ -22,7 +22,7 @@
}, },
"homepage": "https://alibaba.github.io/page-agent/", "homepage": "https://alibaba.github.io/page-agent/",
"engines": { "engines": {
"node": "^22.13.0 || >=24", "node": "^22.22.1 || >=24",
"npm": "^11.6.3" "npm": "^11.6.3"
}, },
"scripts": { "scripts": {
@@ -36,37 +36,40 @@
"version": "node scripts/sync-version.js", "version": "node scripts/sync-version.js",
"postpublish": "npm run postpublish --workspaces --if-present", "postpublish": "npm run postpublish --workspaces --if-present",
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json && tsc --noEmit -p packages/extension/tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.typecheck.json && tsc --noEmit -p packages/extension/tsconfig.json",
"test": "npm test --workspaces --if-present",
"lint": "eslint .", "lint": "eslint .",
"ci": "node scripts/ci.js", "ci": "node scripts/ci.js",
"cleanup": "rm -rf packages/*/dist && rm -rf packages/*/.output", "cleanup": "rm -rf packages/*/dist && rm -rf packages/*/.output",
"prepare": "husky || true" "prepare": "husky || true"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^20.5.3", "@commitlint/cli": "^21.2.0",
"@commitlint/config-conventional": "^20.5.3", "@commitlint/config-conventional": "^21.2.0",
"@eslint-react/eslint-plugin": "^4.2.3", "@eslint-react/eslint-plugin": "^5.10.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@microsoft/api-extractor": "^7.58.7", "@microsoft/api-extractor": "^7.58.9",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.2",
"@trivago/prettier-plugin-sort-imports": "^6.0.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^25.6.0", "@types/node": "^26.0.1",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.3",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"concurrently": "^9.2.1", "concurrently": "^10.0.3",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"eslint": "^10.2.0", "eslint": "^10.6.0",
"globals": "^17.5.0", "globals": "^17.7.0",
"happy-dom": "^20.10.6",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.4.0", "lint-staged": "^17.0.8",
"prettier": "^3.8.2", "prettier": "^3.9.4",
"typescript": "^6.0.2", "typescript": "^6.0.3",
"typescript-eslint": "^8.59.2", "typescript-eslint": "^8.62.1",
"unplugin-dts": "^1.0.0", "unplugin-dts": "^1.0.3",
"vite": "^8.0.12", "vite": "^8.1.2",
"vite-plugin-css-injected-by-js": "^5.0.1" "vite-plugin-css-injected-by-js": "^5.0.1",
"vitest": "^4.1.9"
}, },
"overrides": { "overrides": {
"typescript": "^6.0.2" "typescript": "^6.0.3"
}, },
"lint-staged": { "lint-staged": {
"*.{js,ts,cjs,cts,mjs,mts}": [ "*.{js,ts,cjs,cts,mjs,mts}": [
+4 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/core", "name": "@page-agent/core",
"private": false, "private": false,
"version": "1.8.2", "version": "1.11.0",
"type": "module", "type": "module",
"main": "./src/PageAgentCore.ts", "main": "./src/PageAgentCore.ts",
"types": "./src/PageAgentCore.ts", "types": "./src/PageAgentCore.ts",
@@ -48,14 +48,15 @@
"homepage": "https://alibaba.github.io/page-agent/", "homepage": "https://alibaba.github.io/page-agent/",
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"test": "vitest run",
"dev:iife": "concurrently \"vite build --config vite.iife.config.js --watch\" \"npx serve dist/iife -p 5174\"", "dev:iife": "concurrently \"vite build --config vite.iife.config.js --watch\" \"npx serve dist/iife -p 5174\"",
"prepublishOnly": "node ../../scripts/pre-publish.js", "prepublishOnly": "node ../../scripts/pre-publish.js",
"postpublish": "node ../../scripts/post-publish.js" "postpublish": "node ../../scripts/post-publish.js"
}, },
"dependencies": { "dependencies": {
"chalk": "^5.6.2", "chalk": "^5.6.2",
"@page-agent/llms": "1.8.2", "@page-agent/llms": "1.11.0",
"@page-agent/page-controller": "1.8.2" "@page-agent/page-controller": "1.11.0"
}, },
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.0 || ^4.0.0" "zod": "^3.25.0 || ^4.0.0"
+365
View File
@@ -0,0 +1,365 @@
import type { BrowserState, PageController } from '@page-agent/page-controller'
import { describe, expect, it, vi } from 'vitest'
import * as z from 'zod/v4'
import { PageAgentCore, tool } from './PageAgentCore'
import type { ExecutionResult } from './types'
type TestFetch = (...args: Parameters<typeof globalThis.fetch>) => Promise<Response>
function agentResponse(args: unknown): Response {
return new Response(
JSON.stringify({
choices: [
{
finish_reason: 'tool_calls',
message: {
tool_calls: [
{
function: {
name: 'AgentOutput',
arguments: JSON.stringify(args),
},
},
],
},
},
],
usage: {},
})
)
}
function createPageController(): PageController {
const browserState: BrowserState = {
url: 'https://example.test/',
title: 'Test page',
header: '',
content: '',
footer: '',
}
return {
showMask: vi.fn(async () => {}),
hideMask: vi.fn(),
cleanUpHighlights: vi.fn(),
getLastUpdateTime: vi.fn(() => Date.now()),
getBrowserState: vi.fn(async () => browserState),
dispose: vi.fn(),
} as unknown as PageController
}
function createAgent(
customFetch: TestFetch,
options: Partial<ConstructorParameters<typeof PageAgentCore>[0]> = {}
): PageAgentCore {
return new PageAgentCore({
baseURL: 'https://llm.test',
model: 'test-model',
maxRetries: 0,
stepDelay: 0,
customFetch,
customSystemPrompt: 'test',
pageController: createPageController(),
...options,
})
}
function createFetchMock() {
return vi.fn<TestFetch>()
}
function onceActivity(
agent: PageAgentCore,
predicate: (detail: unknown) => boolean
): Promise<void> {
return new Promise((resolve) => {
const onActivity = (event: Event) => {
if (!predicate((event as CustomEvent).detail)) return
agent.removeEventListener('activity', onActivity)
resolve()
}
agent.addEventListener('activity', onActivity)
})
}
function isExecutingTool(detail: unknown, toolName: string): boolean {
return (
typeof detail === 'object' &&
detail !== null &&
'type' in detail &&
'tool' in detail &&
detail.type === 'executing' &&
detail.tool === toolName
)
}
function doneResponse(text: string, success = true): Response {
return agentResponse({ action: { done: { text, success } } })
}
function waitResponse(seconds = 10): Response {
return agentResponse({ action: { wait: { seconds } } })
}
/**
* Start a task that blocks on `wait`, returning once the tool is executing.
* The running promise is wrapped so awaiting this helper does not await the task.
*/
async function startBlockedTask(
agent: PageAgentCore,
task = 'first'
): Promise<{ result: Promise<ExecutionResult> }> {
const waitStarted = onceActivity(agent, (detail) => isExecutingTool(detail, 'wait'))
const result = agent.execute(task)
await waitStarted
return { result }
}
describe.concurrent('PageAgentCore lifecycle', () => {
describe('normal execution', () => {
it('runs a task to natural completion', async () => {
const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('all done'))
const agent = createAgent(fetchMock)
const result = await agent.execute('do something')
expect(result).toMatchObject({ success: true, data: 'all done' })
expect(agent.status).toBe('completed')
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('completes (not errors) when the LLM reports task failure', async () => {
const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('gave up', false))
const agent = createAgent(fetchMock)
const result = await agent.execute('do something')
expect(result).toMatchObject({ success: false, data: 'gave up' })
expect(agent.status).toBe('completed')
})
it('throws when a task is already running', async () => {
const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse())
const agent = createAgent(fetchMock)
const { result } = await startBlockedTask(agent)
await expect(agent.execute('second')).rejects.toThrow('A task is already running.')
await agent.stop()
await result
})
})
describe('stop', () => {
it('aborts the running task and keeps the agent reusable', async () => {
const fetchMock = createFetchMock()
.mockResolvedValueOnce(waitResponse())
.mockResolvedValueOnce(doneResponse('second task'))
const agent = createAgent(fetchMock)
const { result: firstTask } = await startBlockedTask(agent)
await agent.stop()
expect(agent.status).toBe('stopped')
await expect(firstTask).resolves.toMatchObject({ success: false, data: 'Task aborted' })
const secondTask = await agent.execute('second')
expect(secondTask).toMatchObject({ success: true, data: 'second task' })
expect(agent.status).toBe('completed')
})
it('resolves only after the run has fully settled', async () => {
const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse())
const agent = createAgent(fetchMock)
const { result } = await startBlockedTask(agent)
await agent.stop()
expect(agent.status).toBe('stopped')
await expect(result).resolves.toMatchObject({ success: false })
})
it('is a no-op when no task is running', async () => {
const agent = createAgent(createFetchMock())
await expect(agent.stop()).resolves.toBeUndefined()
await expect(agent.stop()).resolves.toBeUndefined()
expect(agent.status).toBe('idle')
})
})
describe('dispose', () => {
it('aborts the running task and blocks further execution', async () => {
const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse())
const agent = createAgent(fetchMock)
const { result: task } = await startBlockedTask(agent)
agent.dispose()
await expect(task).resolves.toMatchObject({ success: false, data: 'Task aborted' })
expect(agent.disposed).toBe(true)
await expect(agent.execute('again')).rejects.toThrow('has been disposed')
})
it('is idempotent', () => {
const agent = createAgent(createFetchMock())
expect(() => {
agent.dispose()
agent.dispose()
}).not.toThrow()
expect(agent.disposed).toBe(true)
})
})
describe('error handling', () => {
it('fails the task when the network request rejects', async () => {
const fetchMock = createFetchMock().mockRejectedValue(new Error('network down'))
const agent = createAgent(fetchMock)
const result = await agent.execute('do something')
expect(result.success).toBe(false)
expect(agent.status).toBe('error')
})
it('fails the task when a tool throws', async () => {
const fetchMock = createFetchMock().mockResolvedValue(agentResponse({ action: { boom: {} } }))
const agent = createAgent(fetchMock, {
customTools: {
boom: tool({
description: 'Always throws.',
inputSchema: z.object({}),
execute: async () => {
throw new Error('tool exploded')
},
}),
},
})
const result = await agent.execute('trigger tool error')
expect(result.success).toBe(false)
expect(agent.status).toBe('error')
})
it('re-throws and sets error status when onBeforeTask throws', async () => {
const agent = createAgent(createFetchMock(), {
onBeforeTask: async () => {
throw new Error('setup failed')
},
})
await expect(agent.execute('do something')).rejects.toThrow('setup failed')
expect(agent.status).toBe('error')
expect(agent.history.some((e) => e.type === 'error')).toBe(false)
})
it('re-throws and sets error status when onAfterTask throws', async () => {
const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('all done'))
const agent = createAgent(fetchMock, {
onAfterTask: async () => {
throw new Error('teardown failed')
},
})
await expect(agent.execute('do something')).rejects.toThrow('teardown failed')
expect(agent.status).toBe('error')
})
it('stays reusable after onBeforeTask throws', async () => {
const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('second'))
let failOnce = true
const agent = createAgent(fetchMock, {
onBeforeTask: async () => {
if (failOnce) {
failOnce = false
throw new Error('setup failed')
}
},
})
await expect(agent.execute('first')).rejects.toThrow('setup failed')
const result = await agent.execute('second')
expect(result).toMatchObject({ success: true, data: 'second' })
})
it('re-throws and sets error status when onBeforeStep throws', async () => {
const agent = createAgent(createFetchMock(), {
onBeforeStep: async () => {
throw new Error('before step failed')
},
})
await expect(agent.execute('do something')).rejects.toThrow('before step failed')
expect(agent.status).toBe('error')
expect(agent.history.some((e) => e.type === 'error')).toBe(false)
})
it('re-throws and sets error status when onAfterStep throws', async () => {
const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('all done'))
const agent = createAgent(fetchMock, {
onAfterStep: async () => {
throw new Error('after step failed')
},
})
await expect(agent.execute('do something')).rejects.toThrow('after step failed')
expect(agent.status).toBe('error')
expect(agent.history.some((e) => e.type === 'error')).toBe(false)
})
})
describe('cancellation edge cases', () => {
it('rejects a new task while a stop is still settling', async () => {
const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse())
const agent = createAgent(fetchMock)
const { result: firstTask } = await startBlockedTask(agent)
const stopped = agent.stop()
await expect(agent.execute('too early')).rejects.toThrow('A task is already running.')
await stopped
await expect(firstTask).resolves.toMatchObject({ success: false, data: 'Task aborted' })
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('discards a custom tool result that resolves after stop', async () => {
let resolveTool!: () => void
let notifyToolStarted!: () => void
const toolFinished = new Promise<void>((resolve) => {
resolveTool = resolve
})
const toolStarted = new Promise<void>((resolve) => {
notifyToolStarted = resolve
})
const fetchMock = createFetchMock().mockResolvedValue(
agentResponse({ action: { slow_tool: {} } })
)
const agent = createAgent(fetchMock, {
customTools: {
slow_tool: tool({
description: 'A tool that deliberately ignores cancellation.',
inputSchema: z.object({}),
execute: async () => {
notifyToolStarted()
await toolFinished
return 'ignored stop'
},
}),
},
})
const task = agent.execute('run slow tool')
await toolStarted
const stopped = agent.stop()
resolveTool()
await stopped
await expect(task).resolves.toMatchObject({ success: false, data: 'Task aborted' })
expect(agent.status).toBe('stopped')
})
})
})
+174 -154
View File
@@ -21,7 +21,7 @@ import type {
MacroToolInput, MacroToolInput,
MacroToolResult, MacroToolResult,
} from './types' } from './types'
import { assert, fetchLlmsTxt, normalizeResponse, uid, waitFor } from './utils' import { assert, fetchLlmsTxt, normalizeResponse, suppress, uid, waitFor } from './utils'
export { tool, type PageAgentTool } from './tools' export { tool, type PageAgentTool } from './tools'
export type * from './types' export type * from './types'
@@ -42,7 +42,7 @@ export type PageAgentCoreConfig = AgentConfig & { pageController: PageController
* - loop * - loop
* *
* ## Event System * ## Event System
* - `statuschange` - Agent status transitions (idle → running → completed/error) * - `statuschange` - Agent status transitions (idle → running → completed/error/stopped)
* - `historychange` - History events updated (persistent, part of agent memory) * - `historychange` - History events updated (persistent, part of agent memory)
* - `activity` - Real-time activity feedback (transient, for UI only) * - `activity` - Real-time activity feedback (transient, for UI only)
* - `dispose` - Agent cleanup triggered * - `dispose` - Agent cleanup triggered
@@ -73,17 +73,28 @@ export class PageAgentCore extends EventTarget {
disposed = false disposed = false
/** /**
* Callback for when agent needs user input (ask_user tool) * Called when the agent needs to ask the user questions.
* If not set, ask_user tool will be disabled * If unset, the `ask_user` tool will be disabled.
* Implementations should reject the promise when `signal` aborts.
* @example onAskUser: (q) => window.prompt(q) || '' * @example onAskUser: (q) => window.prompt(q) || ''
*/ */
onAskUser?: (question: string) => Promise<string> onAskUser?: (question: string, options?: { signal: AbortSignal }) => Promise<string>
#status: AgentStatus = 'idle' #status: AgentStatus = 'idle'
#llm: LLM #llm: LLM
/**
* Task cancellation primitive: its signal reaches the LLM fetch, tools
* (via `ctx.signal`) and async callbacks. Aborted only by `stop`/`dispose`
* (during a task) or task setup, always WITHOUT a reason so `signal.reason`
* stays a standard `AbortError`.
*/
#abortController = new AbortController() #abortController = new AbortController()
#observations: string[] = [] #observations: string[] = []
/** Resolves when the current run has fully settled. Awaited by `stop()`. */
#running: Promise<void> = Promise.resolve()
#lastResult: ExecutionResult | null = null
/** internal states during a single task execution */ /** internal states during a single task execution */
#states = { #states = {
/** Accumulated wait time in seconds */ /** Accumulated wait time in seconds */
@@ -103,11 +114,14 @@ export class PageAgentCore extends EventTarget {
this.tools = new Map(tools) this.tools = new Map(tools)
this.pageController = config.pageController this.pageController = config.pageController
// Listen to LLM retry events
this.#llm.addEventListener('retry', (e) => { this.#llm.addEventListener('retry', (e) => {
const { attempt, maxAttempts } = (e as CustomEvent).detail const { attempt, maxAttempts, lastError } = (e as CustomEvent).detail
this.#emitActivity({ type: 'retrying', attempt, maxAttempts }) this.#emitActivity({ type: 'retrying', attempt, maxAttempts })
// Also push to history for panel rendering this.history.push({
type: 'error',
message: String(lastError),
rawResponse: (lastError as InvokeError).rawResponse,
})
this.history.push({ this.history.push({
type: 'retry', type: 'retry',
message: `LLM retry attempt ${attempt} of ${maxAttempts}`, message: `LLM retry attempt ${attempt} of ${maxAttempts}`,
@@ -116,19 +130,6 @@ export class PageAgentCore extends EventTarget {
}) })
this.#emitHistoryChange() this.#emitHistoryChange()
}) })
this.#llm.addEventListener('error', (e) => {
const error = (e as CustomEvent).detail.error as Error | InvokeError
if ((error as any)?.rawError?.name === 'AbortError') return
const message = String(error)
this.#emitActivity({ type: 'error', message })
// Also push to history for panel rendering
this.history.push({
type: 'error',
message,
rawResponse: (error as InvokeError).rawResponse,
})
this.#emitHistoryChange()
})
if (this.config.customTools) { if (this.config.customTools) {
for (const [name, tool] of Object.entries(this.config.customTools)) { for (const [name, tool] of Object.entries(this.config.customTools)) {
@@ -150,13 +151,19 @@ export class PageAgentCore extends EventTarget {
return this.#status return this.#status
} }
/** Result of the most recent run, or `null` before the first run completes. */
get lastResult(): ExecutionResult | null {
return this.#lastResult
}
/** Emit statuschange event */ /** Emit statuschange event */
#emitStatusChange(): void { #emitStatusChange(): void {
this.dispatchEvent(new Event('statuschange')) this.dispatchEvent(new Event('statuschange'))
} }
/** Emit historychange event */ /** Emit historychange event */
#emitHistoryChange(): void { #emitHistoryChange(pushHistoricalEvent?: HistoricalEvent): void {
if (pushHistoricalEvent) this.history.push(pushHistoricalEvent)
this.dispatchEvent(new Event('historychange')) this.dispatchEvent(new Event('historychange'))
} }
@@ -186,165 +193,184 @@ export class PageAgentCore extends EventTarget {
this.#observations.push(content) this.#observations.push(content)
} }
/** Stop the current task. Agent remains reusable. */ /**
stop() { * Stop the current task and wait until the run has fully settled (including lifecycle hooks).
this.pageController.cleanUpHighlights() * @note never await .stop() in a lifecycle hook.
this.pageController.hideMask() */
async stop(): Promise<void> {
if (this.#status !== 'running') return
this.#abortController.abort() this.#abortController.abort()
await this.#running
} }
/**
* external errors (pre-checks/config/hooks) will threw;
* agent errors will be caught and added to history, and return a failed result
*/
async execute(task: string): Promise<ExecutionResult> { async execute(task: string): Promise<ExecutionResult> {
// pre-checks
if (this.disposed) throw new Error('PageAgent has been disposed. Create a new instance.') if (this.disposed) throw new Error('PageAgent has been disposed. Create a new instance.')
if (this.#status === 'running') throw new Error('A task is already running.')
if (!task) throw new Error('Task is required') if (!task) throw new Error('Task is required')
this.task = task this.task = task
this.taskId = uid() this.taskId = uid()
this.history = []
this.#observations = []
this.#states = { totalWaitTime: 0, lastURL: '', browserState: null }
this.#abortController = new AbortController()
const signal = this.#abortController.signal
let resolveRunning!: () => void
this.#running = new Promise<void>((r) => (resolveRunning = r))
this.#setStatus('running')
this.#emitHistoryChange()
// Disable ask_user tool if onAskUser is not set // Disable ask_user tool if onAskUser is not set
if (!this.onAskUser) { if (!this.onAskUser) this.tools.delete('ask_user')
this.tools.delete('ask_user')
}
const onBeforeStep = this.config.onBeforeStep const onBeforeStep = this.config.onBeforeStep
const onAfterStep = this.config.onAfterStep const onAfterStep = this.config.onAfterStep
const onBeforeTask = this.config.onBeforeTask const onBeforeTask = this.config.onBeforeTask
const onAfterTask = this.config.onAfterTask const onAfterTask = this.config.onAfterTask
const stepDelay = this.config.stepDelay ?? 0.4
await onBeforeTask?.(this) const maxSteps = this.config.maxSteps
// Show mask
await this.pageController.showMask()
if (this.#abortController) {
this.#abortController.abort()
this.#abortController = new AbortController()
}
this.history = []
this.#setStatus('running')
this.#emitHistoryChange()
this.#observations = []
// Reset internal states
this.#states = { totalWaitTime: 0, lastURL: '', browserState: null }
let step = 0 let step = 0
let taskResult: ExecutionResult
let finalStatus: AgentStatus = 'error'
while (true) { await suppress(() => this.pageController.showMask())
try {
console.group(`step: ${step}`)
// graceful exit
try {
await onBeforeTask?.(this)
while (true) {
await onBeforeStep?.(this, step) await onBeforeStep?.(this, step)
// observe // handle internal agent errors
try {
console.group(`step: ${step}`)
console.log(chalk.blue.bold('👀 Observing...')) // @note It's convenient to treat stepDelay as part of the next step.
// Maybe move it to a dedicated try block for better semantics?
if (step > 0) await waitFor(stepDelay, signal)
this.#states.browserState = await this.pageController.getBrowserState() signal.throwIfAborted()
await this.#handleObservations(step)
// assemble prompts // observe
const messages = [ console.log(chalk.blue.bold('👀 Observing...'))
{ role: 'system' as const, content: this.#getSystemPrompt() },
{ role: 'user' as const, content: await this.#assembleUserPrompt() },
]
const macroTool = { AgentOutput: this.#packMacroTool() } this.#states.browserState = await this.pageController.getBrowserState()
await this.#handleObservations(step)
// invoke LLM // assemble prompts
console.log(chalk.blue.bold('🧠 Thinking...')) const messages = [
this.#emitActivity({ type: 'thinking' }) { role: 'system' as const, content: this.#getSystemPrompt() },
{ role: 'user' as const, content: await this.#assembleUserPrompt() },
]
const result = await this.#llm.invoke(messages, macroTool, this.#abortController.signal, { const macroTool = { AgentOutput: this.#packMacroTool() }
toolChoiceName: 'AgentOutput',
normalizeResponse: (res) => normalizeResponse(res, this.tools),
})
// assemble history // invoke LLM
const macroResult = result.toolResult as MacroToolResult console.log(chalk.blue.bold('🧠 Thinking...'))
const input = macroResult.input this.#emitActivity({ type: 'thinking' })
const output = macroResult.output
const reflection: Partial<AgentReflection> = {
evaluation_previous_goal: input.evaluation_previous_goal,
memory: input.memory,
next_goal: input.next_goal,
}
const actionName = Object.keys(input.action)[0]
const action: AgentStepEvent['action'] = {
name: actionName,
input: input.action[actionName],
output: output,
}
this.history.push({ const result = await this.#llm.invoke(messages, macroTool, signal, {
type: 'step', toolChoiceName: 'AgentOutput',
stepIndex: step, normalizeResponse: (res) => normalizeResponse(res, this.tools),
reflection, })
action,
usage: result.usage,
rawResponse: result.rawResponse,
rawRequest: result.rawRequest,
} as AgentStepEvent)
this.#emitHistoryChange()
// // assemble history
await onAfterStep?.(this, this.history) const macroResult = result.toolResult as MacroToolResult
const input = macroResult.input
console.groupEnd() const output = macroResult.output
const reflection: Partial<AgentReflection> = {
// finish task if done evaluation_previous_goal: input.evaluation_previous_goal,
memory: input.memory,
if (actionName === 'done') { next_goal: input.next_goal,
const success = action.input?.success ?? false }
const text = action.input?.text || 'no text provided' const actionName = Object.keys(input.action)[0]
console.log(chalk.green.bold('Task completed'), success, text) const action: AgentStepEvent['action'] = {
this.#onDone(success) name: actionName,
const result: ExecutionResult = { input: input.action[actionName],
success, output: output,
data: text,
history: this.history,
} }
await onAfterTask?.(this, result)
return result
}
} catch (error: unknown) {
console.groupEnd() // to prevent nested groups
const isAbortError = (error as any)?.rawError?.name === 'AbortError'
console.error('Task failed', error) this.#emitHistoryChange({
const errorMessage = isAbortError ? 'Task stopped' : String(error) type: 'step',
this.#emitActivity({ type: 'error', message: errorMessage }) stepIndex: step,
this.history.push({ type: 'error', message: errorMessage, rawResponse: error }) reflection,
this.#emitHistoryChange() action,
this.#onDone(false) usage: result.usage,
const result: ExecutionResult = { rawResponse: result.rawResponse,
success: false, rawRequest: result.rawRequest,
data: errorMessage, })
history: this.history,
}
await onAfterTask?.(this, result)
return result
}
step++ if (actionName === 'done') {
if (step > this.config.maxSteps) { const success = action.input?.success ?? false
const errorMessage = 'Step count exceeded maximum limit' const data = action.input?.text || 'no text provided'
this.history.push({ type: 'error', message: errorMessage }) console.log(chalk.green.bold('Task completed'), success, data)
this.#emitHistoryChange() taskResult = { success, data, history: this.history }
this.#onDone(false) this.#lastResult = taskResult
const result: ExecutionResult = { finalStatus = 'completed'
success: false, break
data: errorMessage, }
history: this.history, } catch (error: unknown) {
} // catch block must not throw error. otherwise the error may be overridden if finally block also throws error.
await onAfterTask?.(this, result)
return result
}
await waitFor(this.config.stepDelay ?? 0.4) const isAbortError = (error as any)?.name === 'AbortError'
if (!isAbortError) console.error('Task failed', error)
const message = isAbortError ? 'Task aborted' : String(error)
this.#emitActivity({ type: 'error', message: message })
this.#emitHistoryChange({ type: 'error', message: message, rawResponse: error })
taskResult = { success: false, data: message, history: this.history }
this.#lastResult = taskResult
finalStatus = isAbortError ? 'stopped' : 'error'
break
} finally {
// finally block runs before the break above.
console.groupEnd()
// @note hook may throw error.
// which will override the `break` above and be handled as an external error.
// as expected.
await onAfterStep?.(this, this.history)
}
step++
if (step > maxSteps) {
const message = 'Step count exceeded maximum limit'
console.error(message)
this.#emitActivity({ type: 'error', message: message })
this.#emitHistoryChange({ type: 'error', message: message })
taskResult = { success: false, data: message, history: this.history }
this.#lastResult = taskResult
finalStatus = 'error'
break
}
} // while
await onAfterTask?.(this, taskResult)
return taskResult
} catch (error) {
this.#emitActivity({ type: 'error', message: String(error) })
finalStatus = 'error'
throw error
} finally {
await suppress(() => this.pageController.cleanUpHighlights())
await suppress(() => this.pageController.hideMask())
this.#abortController.abort()
resolveRunning()
this.#setStatus(finalStatus)
} }
} }
@@ -378,8 +404,8 @@ export class PageAgentCore extends EventTarget {
description: 'You MUST call this tool every step!', description: 'You MUST call this tool every step!',
inputSchema: macroToolSchema as z.ZodType<MacroToolInput>, inputSchema: macroToolSchema as z.ZodType<MacroToolInput>,
execute: async (input: MacroToolInput): Promise<MacroToolResult> => { execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
// abort const signal = this.#abortController.signal
if (this.#abortController.signal.aborted) throw new Error('AbortError') signal.throwIfAborted()
console.log(chalk.blue.bold('MacroTool input'), input) console.log(chalk.blue.bold('MacroTool input'), input)
const action = input.action const action = input.action
@@ -411,8 +437,9 @@ export class PageAgentCore extends EventTarget {
const startTime = Date.now() const startTime = Date.now()
// Execute tool, bind `this` to PageAgent const result = await tool.execute.bind(this)(toolInput, { signal })
const result = await tool.execute.bind(this)(toolInput) // Enforce abort even if the tool ignored the signal and resolved normally.
signal.throwIfAborted()
const duration = Date.now() - startTime const duration = Date.now() - startTime
console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result) console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result)
@@ -619,13 +646,6 @@ export class PageAgentCore extends EventTarget {
return prompt return prompt
} }
#onDone(success = true) {
this.pageController.cleanUpHighlights()
this.pageController.hideMask() // No await - fire and forget
this.#setStatus(success ? 'completed' : 'error')
this.#abortController.abort()
}
dispose() { dispose() {
console.log('Disposing PageAgent...') console.log('Disposing PageAgent...')
this.disposed = true this.disposed = true
+1 -2
View File
@@ -37,7 +37,7 @@ and system messages wrapped in <sys> tag.
<user_request> <user_request>
USER REQUEST: This is your ultimate objective and always remains visible. USER REQUEST: This is your ultimate objective and always remains visible.
- This has the highest priority. Make the user happy. - This has the highest priority. Make the user happy.
- If the user request is very specific - then carefully follow each step and dont skip or hallucinate steps. - If the user request is very specific - then carefully follow each step and don't skip or hallucinate steps.
- If the task is open ended you can plan yourself how to get it done. - If the task is open ended you can plan yourself how to get it done.
</user_request> </user_request>
@@ -70,7 +70,6 @@ Strictly follow these rules while using the browser and navigating the web:
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages). - You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
- All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed. - All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed.
- If a captcha appears, tell user you can not solve captcha. Finish the task and ask user to solve it. - If a captcha appears, tell user you can not solve captcha. Finish the task and ask user to solve it.
- If expected elements are missing, try scrolling, or navigating back.
- If the page is not fully loaded, use the `wait` action. - If the page is not fully loaded, use the `wait` action.
- Do not repeat one action for more than 3 times unless some conditions changed. - Do not repeat one action for more than 3 times unless some conditions changed.
- If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field. - If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field.
+23 -11
View File
@@ -7,6 +7,14 @@ import * as z from 'zod/v4'
import type { PageAgentCore } from '../PageAgentCore' import type { PageAgentCore } from '../PageAgentCore'
import { waitFor } from '../utils' import { waitFor } from '../utils'
/**
* Per-invocation context passed to every tool execution.
* Tools MUST honor `signal` to support cooperative cancellation.
*/
export interface ToolContext {
signal: AbortSignal
}
/** /**
* Internal tool definition that has access to PageAgent `this` context * Internal tool definition that has access to PageAgent `this` context
*/ */
@@ -14,7 +22,7 @@ export interface PageAgentTool<TParams = any> {
// name: string // name: string
description: string description: string
inputSchema: z.ZodType<TParams> inputSchema: z.ZodType<TParams>
execute: (this: PageAgentCore, args: TParams) => Promise<string> execute: (this: PageAgentCore, args: TParams, ctx: ToolContext) => Promise<string>
} }
export function tool<TParams>(options: PageAgentTool<TParams>): PageAgentTool<TParams> { export function tool<TParams>(options: PageAgentTool<TParams>): PageAgentTool<TParams> {
@@ -50,14 +58,16 @@ tools.set(
inputSchema: z.object({ inputSchema: z.object({
seconds: z.number().min(1).max(10).default(1), seconds: z.number().min(1).max(10).default(1),
}), }),
execute: async function (this: PageAgentCore, input) { execute: async function (this: PageAgentCore, input, { signal }) {
// try to subtract LLM calling time from the actual wait time // try to subtract LLM calling time from the actual wait time
const lastTimeUpdate = await this.pageController.getLastUpdateTime() const lastTimeUpdate = await this.pageController.getLastUpdateTime()
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000) const secondsSinceLastUpdate = (Date.now() - lastTimeUpdate) / 1000
const actualWaitTime = Math.max(0, input.seconds - secondsSinceLastUpdate)
console.log(`actualWaitTime: ${actualWaitTime} seconds`) console.log(`actualWaitTime: ${actualWaitTime} seconds`)
await waitFor(actualWaitTime) await waitFor(actualWaitTime, signal)
return `✅ Waited for ${input.seconds} seconds.` const waitedSeconds = (secondsSinceLastUpdate + actualWaitTime).toFixed(2)
return `✅ Waited for ${waitedSeconds} seconds.`
}, },
}) })
) )
@@ -70,11 +80,11 @@ tools.set(
inputSchema: z.object({ inputSchema: z.object({
question: z.string(), question: z.string(),
}), }),
execute: async function (this: PageAgentCore, input) { execute: async function (this: PageAgentCore, input, { signal }) {
if (!this.onAskUser) { if (!this.onAskUser) {
throw new Error('ask_user tool requires onAskUser callback to be set') throw new Error('ask_user tool requires onAskUser callback to be set')
} }
const answer = await this.onAskUser(input.question) const answer = await this.onAskUser(input.question, { signal })
return `User answered: ${answer}` return `User answered: ${answer}`
}, },
}) })
@@ -173,12 +183,15 @@ tools.set(
'execute_javascript', 'execute_javascript',
tool({ tool({
description: description:
'Execute JavaScript code on the current page. Supports async/await syntax. Use with caution!', 'Execute JavaScript code on the current page. Supports async/await syntax. Use with caution! ' +
'An `AbortSignal` named `signal` is available in scope: long-running async code MUST honor it ' +
'(e.g. `await fetch(url, { signal })`, or `signal.throwIfAborted()` in loops)',
inputSchema: z.object({ inputSchema: z.object({
script: z.string(), script: z.string(),
}), }),
execute: async function (this: PageAgentCore, input) { execute: async function (this: PageAgentCore, input, { signal }) {
const result = await this.pageController.executeJavascript(input.script) const result = await this.pageController.executeJavascript(input.script, signal)
signal.throwIfAborted()
return result.message return result.message
}, },
}) })
@@ -186,5 +199,4 @@ tools.set(
// @todo send_keys // @todo send_keys
// @todo upload_file // @todo upload_file
// @todo go_back
// @todo extract_structured_data // @todo extract_structured_data
+3 -7
View File
@@ -255,16 +255,12 @@ export interface AgentErrorEvent {
* Union type for all history events * Union type for all history events
*/ */
export type HistoricalEvent = export type HistoricalEvent =
| AgentStepEvent AgentStepEvent | ObservationEvent | UserTakeoverEvent | RetryEvent | AgentErrorEvent
| ObservationEvent
| UserTakeoverEvent
| RetryEvent
| AgentErrorEvent
/** /**
* Agent execution status * Agent lifecycle status.
*/ */
export type AgentStatus = 'idle' | 'running' | 'completed' | 'error' export type AgentStatus = 'idle' | 'running' | 'completed' | 'error' | 'stopped'
/** /**
* Agent activity - transient state for immediate UI feedback. * Agent activity - transient state for immediate UI feedback.
+34 -2
View File
@@ -2,8 +2,28 @@ import chalk from 'chalk'
export * from './autoFixer' export * from './autoFixer'
export async function waitFor(seconds: number): Promise<void> { /**
await new Promise((resolve) => setTimeout(resolve, seconds * 1000)) * Wait for `seconds`. If a `signal` is provided, the wait is cancellable:
* aborting rejects with the signal's reason (an `AbortError`).
*/
export async function waitFor(seconds: number, signal?: AbortSignal): Promise<void> {
if (!signal) {
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
return
}
signal.throwIfAborted()
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, seconds * 1000)
const onAbort = () => {
clearTimeout(timer)
// reason is a DOMException AbortError.
reject(signal.reason as DOMException)
}
signal.addEventListener('abort', onAbort, { once: true })
})
} }
// //
@@ -109,3 +129,15 @@ export function assert(condition: unknown, message?: string, silent?: boolean):
throw new Error(errorMessage) throw new Error(errorMessage)
} }
} }
/**
* Suppress errors from a function.
*/
export async function suppress<T>(fn: () => T | Promise<T>): Promise<Awaited<T> | undefined> {
try {
return await fn()
} catch (error) {
console.error(error)
return undefined
}
}
+2
View File
@@ -12,6 +12,8 @@ export default defineConfig({
clearScreen: false, clearScreen: false,
plugins: [ plugins: [
dts({ dts({
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts'],
bundleTypes: true, bundleTypes: true,
compilerOptions: { compilerOptions: {
composite: true, composite: true,
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
name: 'core',
include: ['src/**/*.test.ts'],
silent: 'passed-only',
},
})
+5 -1
View File
@@ -96,6 +96,10 @@ Returns: `Promise<ExecutionResult>`
Stop the current task. Stop the current task.
## Limitations
- **Normal browser windows only.** This extension relies on tab group API which does not work for pop-up window or PWA App window.
## Types ## Types
Install `@page-agent/core` for complete types: Install `@page-agent/core` for complete types:
@@ -131,7 +135,7 @@ export type Execute = (task: string, config: ExecuteConfig) => Promise<Execution
`AgentStatus` `AgentStatus`
```typescript ```typescript
type AgentStatus = 'idle' | 'running' | 'completed' | 'error' type AgentStatus = 'idle' | 'running' | 'completed' | 'error' | 'stopped'
``` ```
`AgentActivity` `AgentActivity`
+20 -20
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/ext", "name": "@page-agent/ext",
"private": true, "private": true,
"version": "1.8.2", "version": "1.11.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "wxt", "dev": "wxt",
@@ -10,38 +10,38 @@
"postinstall": "wxt prepare" "postinstall": "wxt prepare"
}, },
"devDependencies": { "devDependencies": {
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.18",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.3.2",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.2",
"@types/chrome": "^0.1.42", "@types/chrome": "^0.2.1",
"@types/react": "^19.2.14", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@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": "^1.14.0", "lucide-react": "^1.22.0",
"motion": "^12.38.0", "motion": "^12.42.2",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.6", "react": "^19.2.7",
"react-dom": "^19.2.6", "react-dom": "^19.2.7",
"rough-notation": "^0.5.1", "rough-notation": "^0.5.1",
"simple-icons": "^16.19.0", "simple-icons": "^16.24.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.2",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"wxt": "^0.20.25" "wxt": "^0.20.27"
}, },
"dependencies": { "dependencies": {
"@page-agent/core": "1.8.2", "@page-agent/core": "1.11.0",
"@page-agent/llms": "1.8.2", "@page-agent/llms": "1.11.0",
"@page-agent/page-controller": "1.8.2", "@page-agent/page-controller": "1.11.0",
"@page-agent/ui": "1.8.2", "@page-agent/ui": "1.11.0",
"ai-motion": "^0.4.8", "ai-motion": "^0.4.8",
"chalk": "^5.6.2" "chalk": "^5.6.2"
}, },
+23 -27
View File
@@ -40,43 +40,27 @@ export class MultiPageAgent extends PageAgentCore {
const experimentalIncludeAllTabs = config.experimentalIncludeAllTabs ?? false const experimentalIncludeAllTabs = config.experimentalIncludeAllTabs ?? false
/** /**
* Project agent status into chrome.storage. The content script polls
* `isAgentRunning` + `agentHeartbeat` (eventually consistent by design).
*
* When the agent is in side-panel and user closed the side-panel. * When the agent is in side-panel and user closed the side-panel.
* There is no chance for isAgentRunning to be set false. * There is no chance for isAgentRunning to be set false.
* (unload event doesn't work well in side panel.) * (unload event doesn't work well in side panel.)
* (I'm trying not to use long-lived connection because the lifecycle of a sw is hard to predict.) * (I'm trying not to use long-lived connection because the lifecycle of a sw is hard to predict.)
* This heartbeat mechanism acts as a backup. * This heartbeat mechanism acts as a backup.
*/ */
let heartBeatInterval: null | number = null let heartBeatInterval: number | null = null
super({ super({
...config, ...config,
// Disabled: AbortSignal cannot cross contexts
experimentalScriptExecutionTool: false,
pageController: pageController as any, pageController: pageController as any,
customTools: customTools, customTools: customTools,
customSystemPrompt: systemPrompt, customSystemPrompt: systemPrompt,
onBeforeTask: async (agent) => { onBeforeTask: async (agent) => {
await tabsController.init(agent.task, { includeInitialTab, experimentalIncludeAllTabs }) await tabsController.init(agent.task, { includeInitialTab, experimentalIncludeAllTabs })
heartBeatInterval = window.setInterval(() => {
chrome.storage.local.set({
agentHeartbeat: Date.now(),
})
}, 1_000)
await chrome.storage.local.set({
isAgentRunning: true,
})
},
onAfterTask: async () => {
if (heartBeatInterval) {
window.clearInterval(heartBeatInterval)
heartBeatInterval = null
}
await chrome.storage.local.set({
isAgentRunning: false,
})
}, },
onBeforeStep: async (agent) => { onBeforeStep: async (agent) => {
@@ -87,16 +71,28 @@ export class MultiPageAgent extends PageAgentCore {
onDispose: () => { onDispose: () => {
if (heartBeatInterval) { if (heartBeatInterval) {
window.clearInterval(heartBeatInterval) clearInterval(heartBeatInterval)
heartBeatInterval = null heartBeatInterval = null
} }
chrome.storage.local.set({ isAgentRunning: false }).catch(console.error)
chrome.storage.local.set({
isAgentRunning: false,
})
tabsController.dispose() tabsController.dispose()
}, },
}) })
this.addEventListener('statuschange', () => {
const running = this.status === 'running'
if (running && !heartBeatInterval) {
heartBeatInterval = window.setInterval(() => {
void chrome.storage.local.set({ agentHeartbeat: Date.now() })
}, 1_000)
} else if (!running && heartBeatInterval) {
clearInterval(heartBeatInterval)
heartBeatInterval = null
}
chrome.storage.local.set({ isAgentRunning: running }).catch(console.error)
})
} }
} }
@@ -133,9 +133,7 @@ export class RemotePageController {
return this.remoteCallDomAction('scroll_horizontally', args) return this.remoteCallDomAction('scroll_horizontally', args)
} }
async executeJavascript(...args: any[]): Promise<DomActionReturn> { // `execute_javascript` is intentionally not implemented: AbortSignal cannot cross context
return this.remoteCallDomAction('execute_javascript', args)
}
/** @note Managed by content script via storage polling. */ /** @note Managed by content script via storage polling. */
async showMask(): Promise<void> {} async showMask(): Promise<void> {}
@@ -7,6 +7,39 @@ const PREFIX = '[TabsController.background]'
const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`) const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`)
/**
* Resolve active tab.
*
* - `tabs.query({ active: true })` does not work in multi-window scenarios.
* - Extension pages (side panel, hub tab) can resolve their own windowId.
* We just find the active tab within that window.
* - Content scripts (PAGE_AGENT_EXT) can't self-report a windowId.
* Chrome populates `sender.tab` for every content-script message,
* which is the tab hosting the script.
*/
async function resolveActiveTab(
payload: { windowId?: number } | undefined,
sender: chrome.runtime.MessageSender
): Promise<chrome.tabs.Tab> {
const windowId = payload?.windowId
if (windowId != null) {
debug('get_active_tab: resolving via caller-reported windowId', windowId)
const [tab] = await chrome.tabs.query({ active: true, windowId })
if (!tab) throw new Error(`No active tab found in window ${windowId}.`)
return tab
}
if (sender.tab) {
debug('get_active_tab: resolving via sender.tab (content script)', sender.tab.id)
return sender.tab
}
throw new Error(
'Cannot resolve active tab: caller reported no windowId and is not a content script (no sender.tab).'
)
}
export function handleTabControlMessage( export function handleTabControlMessage(
message: { type: 'TAB_CONTROL'; action: TabAction; payload: any }, message: { type: 'TAB_CONTROL'; action: TabAction; payload: any },
sender: chrome.runtime.MessageSender, sender: chrome.runtime.MessageSender,
@@ -16,12 +49,11 @@ export function handleTabControlMessage(
switch (action as TabAction) { switch (action as TabAction) {
case 'get_active_tab': { case 'get_active_tab': {
debug('get_active_tab') debug('get_active_tab', payload)
chrome.tabs resolveActiveTab(payload, sender)
.query({ active: true }) .then((tab) => {
.then((tabs) => { debug('get_active_tab: success', tab)
debug('get_active_tab: success', tabs) sendResponse({ success: true, tab })
sendResponse({ success: true, tab: tabs[0] })
}) })
.catch((error) => { .catch((error) => {
sendResponse({ error: error instanceof Error ? error.message : String(error) }) sendResponse({ error: error instanceof Error ? error.message : String(error) })
@@ -46,7 +78,7 @@ export function handleTabControlMessage(
case 'open_new_tab': { case 'open_new_tab': {
debug('open_new_tab', payload) debug('open_new_tab', payload)
chrome.tabs chrome.tabs
.create({ url: payload.url, active: false }) .create({ url: payload.url, windowId: payload.windowId, active: false })
.then((newTab) => { .then((newTab) => {
debug('open_new_tab: success', newTab) debug('open_new_tab: success', newTab)
sendResponse({ success: true, tabId: newTab.id }) sendResponse({ success: true, tabId: newTab.id })
+21 -3
View File
@@ -15,6 +15,20 @@ function sendMessage(message: {
}) })
} }
/**
* Resolve the window hosting this script's own context, when knowable.
*
* Extension pages (side panel, hub tab) have `chrome.windows` access and can
* identify their own window directly via `getCurrent()`.
* Content scripts have no `chrome.windows` access; they resolve `undefined`
* here and the background script falls back to `sender.tab` instead.
*/
async function getOwnWindowId(): Promise<number | undefined> {
if (typeof chrome.windows === 'undefined') return undefined
const win = await chrome.windows.getCurrent()
return win.id
}
/** /**
* Controller for managing browser tabs. * Controller for managing browser tabs.
* - live in the agent env (extension page or content script) * - live in the agent env (extension page or content script)
@@ -42,7 +56,7 @@ export class TabsController {
throw new Error('TabsController already disposed') throw new Error('TabsController already disposed')
} }
this.currentTabId = null await this.updateCurrentTabId(null)
this.disposed = false this.disposed = false
this.port = undefined this.port = undefined
this.portRetries = 0 this.portRetries = 0
@@ -57,6 +71,7 @@ export class TabsController {
const activeTabResult = await sendMessage({ const activeTabResult = await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'get_active_tab', action: 'get_active_tab',
payload: { windowId: await getOwnWindowId() },
}) })
this.initialTabId = activeTabResult.tab?.id this.initialTabId = activeTabResult.tab?.id
@@ -124,7 +139,7 @@ export class TabsController {
const result = await sendMessage({ const result = await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'open_new_tab', action: 'open_new_tab',
payload: { url }, payload: { url, windowId: this.windowId },
}) })
if (!result.success) { if (!result.success) {
@@ -307,7 +322,10 @@ export class TabsController {
if (message.action === 'created') { if (message.action === 'created') {
const tab = message.payload.tab as chrome.tabs.Tab const tab = message.payload.tab as chrome.tabs.Tab
const shouldTrack = this.experimentalIncludeAllTabs || tab.groupId === this.tabGroupId const shouldTrack =
tab.groupId === this.tabGroupId ||
// @note Never track tabs from other windows.
(this.experimentalIncludeAllTabs && tab.windowId === this.windowId)
if (shouldTrack && tab.id != null) { if (shouldTrack && tab.id != null) {
this.addTab({ id: tab.id, isInitial: false }) this.addTab({ id: tab.id, isInitial: false })
this.switchToTab(tab.id) this.switchToTab(tab.id)
@@ -37,7 +37,7 @@ and system messages wrapped in <sys> tag.
<user_request> <user_request>
USER REQUEST: This is your ultimate objective and always remains visible. USER REQUEST: This is your ultimate objective and always remains visible.
- This has the highest priority. Make the user happy. - This has the highest priority. Make the user happy.
- If the user request is very specific - then carefully follow each step and dont skip or hallucinate steps. - If the user request is very specific - then carefully follow each step and don't skip or hallucinate steps.
- If the task is open ended you can plan yourself how to get it done. - If the task is open ended you can plan yourself how to get it done.
</user_request> </user_request>
@@ -72,7 +72,6 @@ Strictly follow these rules while using the browser and navigating the web:
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages). - You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
- All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed. - All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed.
- If a captcha appears, tell user you can not solve captcha. Finish the task and ask user to solve it. - If a captcha appears, tell user you can not solve captcha. Finish the task and ask user to solve it.
- If expected elements are missing, try scrolling, or navigating back.
- If the page is not fully loaded, use the `wait` action. - If the page is not fully loaded, use the `wait` action.
- Do not repeat one action for more than 3 times unless some conditions changed. - Do not repeat one action for more than 3 times unless some conditions changed.
- If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field. - If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field.
+1 -1
View File
@@ -80,7 +80,7 @@ export function useAgent(): UseAgentResult {
const handleStatusChange = (e: Event) => { const handleStatusChange = (e: Event) => {
const newStatus = agent.status as AgentStatus const newStatus = agent.status as AgentStatus
setStatus(newStatus) setStatus(newStatus)
if (newStatus === 'idle' || newStatus === 'completed' || newStatus === 'error') { if (newStatus !== 'running') {
setActivity(null) setActivity(null)
} }
} }
@@ -14,6 +14,7 @@ export function StatusDot({ status }: { status: AgentStatus }) {
running: 'bg-blue-500', running: 'bg-blue-500',
completed: 'bg-green-500', completed: 'bg-green-500',
error: 'bg-destructive', error: 'bg-destructive',
stopped: 'bg-muted-foreground',
}[status] }[status]
const label = { const label = {
@@ -21,6 +22,7 @@ export function StatusDot({ status }: { status: AgentStatus }) {
running: 'Running', running: 'Running',
completed: 'Done', completed: 'Done',
error: 'Error', error: 'Error',
stopped: 'Stopped',
}[status] }[status]
return ( return (
@@ -39,7 +39,7 @@ export default function App() {
if ( if (
prev === 'running' && prev === 'running' &&
(status === 'completed' || status === 'error') && (status === 'completed' || status === 'error' || status === 'stopped') &&
history.length > 0 && history.length > 0 &&
currentTask currentTask
) { ) {
+1 -1
View File
@@ -8,7 +8,7 @@ export interface SessionRecord {
id: string id: string
task: string task: string
history: HistoricalEvent[] history: HistoricalEvent[]
status: 'completed' | 'error' status: 'completed' | 'error' | 'stopped'
createdAt: number createdAt: number
} }
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/llms", "name": "@page-agent/llms",
"version": "1.8.2", "version": "1.11.0",
"type": "module", "type": "module",
"main": "./src/index.ts", "main": "./src/index.ts",
"types": "./src/index.ts", "types": "./src/index.ts",
@@ -43,6 +43,7 @@
"homepage": "https://alibaba.github.io/page-agent/", "homepage": "https://alibaba.github.io/page-agent/",
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"test": "vitest run",
"prepublishOnly": "node ../../scripts/pre-publish.js", "prepublishOnly": "node ../../scripts/pre-publish.js",
"postpublish": "node ../../scripts/post-publish.js" "postpublish": "node ../../scripts/post-publish.js"
}, },
+369
View File
@@ -0,0 +1,369 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as z from 'zod/v4'
import { OpenAIClient } from './OpenAIClient'
import { InvokeError, InvokeErrorTypes } from './errors'
import { parseLLMConfig } from './index'
import type { LLMConfig, Tool } from './types'
// ---------- Fixtures ----------
function makeClient(overrides: Partial<LLMConfig> = {}) {
const fetchMock = vi.fn<typeof fetch>()
const config = parseLLMConfig({
baseURL: 'http://test.local/v1',
model: 'test-model',
apiKey: 'sk-test',
customFetch: fetchMock,
...overrides,
})
const client = new OpenAIClient(config)
return { client, fetchMock }
}
function makeTool(): Tool<{ name: string }, string> {
return {
description: 'greet',
inputSchema: z.object({ name: z.string() }),
execute: vi.fn(async (args) => `hello ${args.name}`),
}
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
function toolCallBody(toolName: string, args: unknown, finishReason = 'tool_calls') {
return {
choices: [
{
finish_reason: finishReason,
message: {
tool_calls: [
{
id: 'call_1',
type: 'function',
function: {
name: toolName,
arguments: typeof args === 'string' ? args : JSON.stringify(args),
},
},
],
},
},
],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
}
}
function abortError(message = 'aborted'): Error {
const err = new Error(message)
err.name = 'AbortError'
return err
}
function getSentBody(fetchMock: ReturnType<typeof vi.fn>): Record<string, unknown> {
const init = fetchMock.mock.calls[0][1] as RequestInit
return JSON.parse(init.body as string)
}
const signal = new AbortController().signal
// ---------- Request construction ----------
describe('OpenAIClient.invoke — request construction', () => {
let setup: ReturnType<typeof makeClient>
const tools = { greet: makeTool() }
beforeEach(() => {
setup = makeClient()
setup.fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'world' })))
})
it('defaults tool_choice to "required" when no toolChoiceName is given', async () => {
await setup.client.invoke([], tools, signal)
expect(getSentBody(setup.fetchMock).tool_choice).toBe('required')
})
it('uses named tool_choice when toolChoiceName is given', async () => {
await setup.client.invoke([], tools, signal, { toolChoiceName: 'greet' })
expect(getSentBody(setup.fetchMock).tool_choice).toEqual({
type: 'function',
function: { name: 'greet' },
})
})
it('falls back to "required" when disableNamedToolChoice is true', async () => {
const { client, fetchMock } = makeClient({ disableNamedToolChoice: true })
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'world' })))
await client.invoke([], tools, signal, { toolChoiceName: 'greet' })
expect(getSentBody(fetchMock).tool_choice).toBe('required')
})
it('sends Authorization header when apiKey is set', async () => {
await setup.client.invoke([], tools, signal)
const init = setup.fetchMock.mock.calls[0][1]!
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer sk-test')
})
it('omits Authorization header when apiKey is empty', async () => {
const { client, fetchMock } = makeClient({ apiKey: '' })
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'world' })))
await client.invoke([], tools, signal)
const init = fetchMock.mock.calls[0][1]!
expect((init.headers as Record<string, string>).Authorization).toBeUndefined()
})
it('applies transformRequestBody (in-place form, returns undefined)', async () => {
const { client, fetchMock } = makeClient({
transformRequestBody: (body) => {
body.custom_flag = 42
return undefined
},
})
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'world' })))
await client.invoke([], tools, signal)
expect(getSentBody(fetchMock).custom_flag).toBe(42)
})
it('applies transformRequestBody (returning a new object)', async () => {
const { client, fetchMock } = makeClient({
transformRequestBody: () => ({ replaced: true }),
})
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'world' })))
await client.invoke([], tools, signal)
expect(getSentBody(fetchMock)).toEqual({ replaced: true })
})
it('wraps transformRequestBody throws as CONFIG_ERROR', async () => {
const { client } = makeClient({
transformRequestBody: () => {
throw new Error('bad transform')
},
})
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
name: 'InvokeError',
type: InvokeErrorTypes.CONFIG_ERROR,
})
})
})
// ---------- Success path ----------
describe('OpenAIClient.invoke — success', () => {
it('returns toolCall, toolResult and usage', async () => {
const { client, fetchMock } = makeClient()
const tool = makeTool()
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'Alice' })))
const result = await client.invoke([], { greet: tool }, signal)
expect(result.toolCall).toEqual({ name: 'greet', args: { name: 'Alice' } })
expect(result.toolResult).toBe('hello Alice')
expect(result.usage).toEqual({
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
cachedTokens: undefined,
reasoningTokens: undefined,
})
expect(tool.execute).toHaveBeenCalledWith({ name: 'Alice' })
})
it('accepts finish_reason="stop" with tool calls', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'Bob' }, 'stop')))
const result = await client.invoke([], { greet: makeTool() }, signal)
expect(result.toolResult).toBe('hello Bob')
})
})
// ---------- HTTP errors ----------
describe('OpenAIClient.invoke — HTTP errors', () => {
const tools = { greet: makeTool() }
it.each([
[401, InvokeErrorTypes.AUTH_ERROR],
[403, InvokeErrorTypes.AUTH_ERROR],
[429, InvokeErrorTypes.RATE_LIMIT],
[500, InvokeErrorTypes.SERVER_ERROR],
[502, InvokeErrorTypes.SERVER_ERROR],
[418, InvokeErrorTypes.UNKNOWN],
])('maps status %i to %s', async (status, type) => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(jsonResponse({ error: { message: 'nope' } }, status))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
name: 'InvokeError',
type,
})
})
it('falls back to statusText when body has no error.message', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(
new Response('not json', { status: 500, statusText: 'Internal Boom' })
)
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.SERVER_ERROR,
message: expect.stringContaining('Internal Boom'),
})
})
})
// ---------- Response anomalies ----------
describe('OpenAIClient.invoke — response anomalies', () => {
const tools = { greet: makeTool() }
it('throws CONTEXT_LENGTH on finish_reason="length"', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(
jsonResponse({ choices: [{ finish_reason: 'length', message: {} }] })
)
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.CONTEXT_LENGTH,
})
})
it('throws CONTENT_FILTER on finish_reason="content_filter"', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(
jsonResponse({ choices: [{ finish_reason: 'content_filter', message: {} }] })
)
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.CONTENT_FILTER,
})
})
it('throws INVALID_SCHEMA when there are no choices', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(jsonResponse({}))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.INVALID_SCHEMA,
})
})
it('throws NO_TOOL_CALL when message has no tool_calls', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(
jsonResponse({ choices: [{ finish_reason: 'tool_calls', message: {} }] })
)
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.NO_TOOL_CALL,
})
})
it('throws INVALID_TOOL_ARGS when arguments are not valid JSON', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', 'not-json{')))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.INVALID_TOOL_ARGS,
message: expect.stringContaining('JSON'),
})
})
it('throws INVALID_TOOL_ARGS when args fail Zod validation', async () => {
const { client, fetchMock } = makeClient()
// tool expects { name: string }, send { name: 123 }
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 123 })))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.INVALID_TOOL_ARGS,
})
})
it('throws UNKNOWN when model calls a tool that does not exist', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('mystery', { name: 'x' })))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.UNKNOWN,
message: expect.stringContaining('mystery'),
})
})
it('wraps tool.execute failures as TOOL_EXECUTION_ERROR', async () => {
const { client, fetchMock } = makeClient()
const tool: Tool<{ name: string }, string> = {
inputSchema: z.object({ name: z.string() }),
execute: vi.fn(async () => {
throw new Error('downstream blew up')
}),
}
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'x' })))
await expect(client.invoke([], { greet: tool }, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.TOOL_EXECUTION_ERROR,
message: expect.stringContaining('downstream blew up'),
})
})
})
// ---------- Abort handling (the important part) ----------
describe('OpenAIClient.invoke — abort', () => {
const tools = { greet: makeTool() }
it('throws AbortError immediately when signal is already aborted', async () => {
const { client, fetchMock } = makeClient()
const controller = new AbortController()
controller.abort()
await expect(client.invoke([], tools, controller.signal)).rejects.toMatchObject({
name: 'AbortError',
})
expect(fetchMock).not.toHaveBeenCalled()
})
it('propagates AbortError thrown by fetch (does NOT wrap as NETWORK_ERROR)', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockRejectedValue(abortError())
const err = await client.invoke([], tools, signal).catch((e) => e)
expect(err).toBeInstanceOf(Error)
expect(err).not.toBeInstanceOf(InvokeError)
expect(err.name).toBe('AbortError')
})
it('propagates AbortError thrown by response.json() (does NOT wrap as INVALID_RESPONSE)', async () => {
const { client, fetchMock } = makeClient()
// Fake Response whose .json() rejects with AbortError mid-read
const fakeResponse = {
ok: true,
status: 200,
statusText: 'OK',
json: () => Promise.reject(abortError()),
} as unknown as Response
fetchMock.mockResolvedValue(fakeResponse)
const err = await client.invoke([], tools, signal).catch((e) => e)
expect(err).not.toBeInstanceOf(InvokeError)
expect(err.name).toBe('AbortError')
})
it('propagates AbortError thrown by tool.execute (does NOT wrap as TOOL_EXECUTION_ERROR)', async () => {
const { client, fetchMock } = makeClient()
const tool: Tool<{ name: string }, string> = {
inputSchema: z.object({ name: z.string() }),
execute: vi.fn(async () => {
throw abortError()
}),
}
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'x' })))
const err = await client.invoke([], { greet: tool }, signal).catch((e) => e)
expect(err).not.toBeInstanceOf(InvokeError)
expect(err.name).toBe('AbortError')
})
// Sanity check: the wrappers we deliberately bypass for AbortError still work for normal errors
it('still wraps non-Abort fetch errors as NETWORK_ERROR', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockRejectedValue(new TypeError('ECONNREFUSED'))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
name: 'InvokeError',
type: InvokeErrorTypes.NETWORK_ERROR,
})
})
})
+45 -18
View File
@@ -4,17 +4,24 @@
import * as z from 'zod/v4' import * as z from 'zod/v4'
import { InvokeError, InvokeErrorTypes } from './errors' import { InvokeError, InvokeErrorTypes } from './errors'
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types' import type {
InvokeOptions,
InvokeResult,
LLMClient,
Message,
ResolvedLLMConfig,
Tool,
} from './types'
import { modelPatch, zodToOpenAITool } from './utils' import { modelPatch, zodToOpenAITool } from './utils'
/** /**
* Client for OpenAI compatible APIs * Client for OpenAI compatible APIs
*/ */
export class OpenAIClient implements LLMClient { export class OpenAIClient implements LLMClient {
config: Required<LLMConfig> config: ResolvedLLMConfig
private fetch: typeof globalThis.fetch private fetch: typeof globalThis.fetch
constructor(config: Required<LLMConfig>) { constructor(config: ResolvedLLMConfig) {
this.config = config this.config = config
this.fetch = config.customFetch this.fetch = config.customFetch
} }
@@ -25,6 +32,8 @@ export class OpenAIClient implements LLMClient {
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
options?: InvokeOptions options?: InvokeOptions
): Promise<InvokeResult> { ): Promise<InvokeResult> {
abortSignal?.throwIfAborted()
// 1. Convert tools to OpenAI format // 1. Convert tools to OpenAI format
const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t)) const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t))
@@ -37,14 +46,18 @@ export class OpenAIClient implements LLMClient {
const requestBody: Record<string, unknown> = { const requestBody: Record<string, unknown> = {
model: this.config.model, model: this.config.model,
temperature: this.config.temperature,
messages, messages,
tools: openaiTools, tools: openaiTools,
parallel_tool_calls: false, parallel_tool_calls: false,
tool_choice: toolChoice, tool_choice: toolChoice,
} }
// Only sent if the caller explicitly set it. Most new models throw if this is set.
if (this.config.temperature !== undefined) {
requestBody.temperature = this.config.temperature
}
modelPatch(requestBody, this.config.baseURL)
modelPatch(requestBody)
let transformedBody: Record<string, unknown> | undefined let transformedBody: Record<string, unknown> | undefined
try { try {
transformedBody = this.config.transformRequestBody(requestBody) transformedBody = this.config.transformRequestBody(requestBody)
@@ -70,17 +83,20 @@ export class OpenAIClient implements LLMClient {
signal: abortSignal, signal: abortSignal,
}) })
} catch (error: unknown) { } catch (error: unknown) {
const isAbortError = (error as any)?.name === 'AbortError' if ((error as any)?.name === 'AbortError') throw error
const errorMessage = isAbortError ? 'Network request aborted' : 'Network request failed' console.error(error)
if (!isAbortError) console.error(error) throw new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'Network request failed', error)
throw new InvokeError(InvokeErrorTypes.NETWORK_ERROR, errorMessage, error)
} }
// 3. Handle HTTP errors // 3. Handle HTTP errors
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch() let errorData: any
const errorMessage = try {
(errorData as { error?: { message?: string } }).error?.message || response.statusText errorData = await response.json()
} catch (error) {
if ((error as any)?.name === 'AbortError') throw error
}
const errorMessage = errorData?.error?.message || response.statusText
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
throw new InvokeError( throw new InvokeError(
@@ -111,11 +127,21 @@ export class OpenAIClient implements LLMClient {
} }
// 4. Parse and validate response // 4. Parse and validate response
const data = await response.json() let data: any
try {
data = await response.json()
} catch (error) {
if ((error as any)?.name === 'AbortError') throw error
throw new InvokeError(
InvokeErrorTypes.INVALID_RESPONSE,
'Response body is not valid JSON',
error
)
}
const choice = data.choices?.[0] const choice = data.choices?.[0]
if (!choice) { if (!choice) {
throw new InvokeError(InvokeErrorTypes.UNKNOWN, 'No choices in response', data) throw new InvokeError(InvokeErrorTypes.INVALID_SCHEMA, 'No choices in response', data)
} }
// Check finish_reason // Check finish_reason
@@ -140,7 +166,7 @@ export class OpenAIClient implements LLMClient {
) )
default: default:
throw new InvokeError( throw new InvokeError(
InvokeErrorTypes.UNKNOWN, InvokeErrorTypes.INVALID_SCHEMA,
`Unexpected finish_reason: ${choice.finish_reason}`, `Unexpected finish_reason: ${choice.finish_reason}`,
undefined, undefined,
data data
@@ -212,11 +238,12 @@ export class OpenAIClient implements LLMClient {
let toolResult: unknown let toolResult: unknown
try { try {
toolResult = await tool.execute(toolInput) toolResult = await tool.execute(toolInput)
} catch (e) { } catch (error: unknown) {
if ((error as any)?.name === 'AbortError') throw error
throw new InvokeError( throw new InvokeError(
InvokeErrorTypes.TOOL_EXECUTION_ERROR, InvokeErrorTypes.TOOL_EXECUTION_ERROR,
`Tool execution failed: ${(e as Error).message}`, `Tool execution failed: ${(error as Error)?.message}`,
e, error,
data data
) )
} }
-3
View File
@@ -1,3 +0,0 @@
// Internal constants
export const LLM_MAX_RETRIES = 2
export const DEFAULT_TEMPERATURE = 0.7 // higher randomness helps auto-recovery
+16 -18
View File
@@ -1,5 +1,5 @@
/** /**
* Error types and error handling for LLM invocations * Error types and error handling for LLM invocations.
*/ */
export const InvokeErrorTypes = { export const InvokeErrorTypes = {
@@ -10,6 +10,8 @@ export const InvokeErrorTypes = {
NO_TOOL_CALL: 'no_tool_call', // Model did not call tool NO_TOOL_CALL: 'no_tool_call', // Model did not call tool
INVALID_TOOL_ARGS: 'invalid_tool_args', // Tool args don't match schema INVALID_TOOL_ARGS: 'invalid_tool_args', // Tool args don't match schema
TOOL_EXECUTION_ERROR: 'tool_execution_error', // Tool execution error TOOL_EXECUTION_ERROR: 'tool_execution_error', // Tool execution error
INVALID_RESPONSE: 'invalid_response', // Response body is not valid JSON
INVALID_SCHEMA: 'invalid_schema', // Response is valid JSON but doesn't match expected shape
UNKNOWN: 'unknown', UNKNOWN: 'unknown',
@@ -22,6 +24,18 @@ export const InvokeErrorTypes = {
type InvokeErrorType = (typeof InvokeErrorTypes)[keyof typeof InvokeErrorTypes] type InvokeErrorType = (typeof InvokeErrorTypes)[keyof typeof InvokeErrorTypes]
const RETRYABLE_TYPES: readonly InvokeErrorType[] = [
InvokeErrorTypes.NETWORK_ERROR,
InvokeErrorTypes.RATE_LIMIT,
InvokeErrorTypes.SERVER_ERROR,
InvokeErrorTypes.NO_TOOL_CALL,
InvokeErrorTypes.INVALID_TOOL_ARGS,
InvokeErrorTypes.TOOL_EXECUTION_ERROR,
InvokeErrorTypes.INVALID_RESPONSE,
InvokeErrorTypes.INVALID_SCHEMA,
InvokeErrorTypes.UNKNOWN,
]
export class InvokeError extends Error { export class InvokeError extends Error {
type: InvokeErrorType type: InvokeErrorType
retryable: boolean retryable: boolean
@@ -35,24 +49,8 @@ export class InvokeError extends Error {
super(message) super(message)
this.name = 'InvokeError' this.name = 'InvokeError'
this.type = type this.type = type
this.retryable = this.isRetryable(type, rawError) this.retryable = RETRYABLE_TYPES.includes(type)
this.rawError = rawError this.rawError = rawError
this.rawResponse = rawResponse this.rawResponse = rawResponse
} }
private isRetryable(type: InvokeErrorType, rawError?: unknown): boolean {
const isAbortError = (rawError as any)?.name === 'AbortError'
if (isAbortError) return false
const retryableTypes: InvokeErrorType[] = [
InvokeErrorTypes.NETWORK_ERROR,
InvokeErrorTypes.RATE_LIMIT,
InvokeErrorTypes.SERVER_ERROR,
InvokeErrorTypes.NO_TOOL_CALL,
InvokeErrorTypes.INVALID_TOOL_ARGS,
InvokeErrorTypes.TOOL_EXECUTION_ERROR,
InvokeErrorTypes.UNKNOWN,
]
return retryableTypes.includes(type)
}
} }
+114
View File
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { InvokeError, InvokeErrorTypes, LLM } from './index'
import type { LLMClient } from './types'
function makeLLM(maxRetries = 2): LLM {
return new LLM({
baseURL: 'http://test.local/v1',
model: 'gpt-5',
maxRetries,
})
}
function abortError(): Error {
const err = new Error('aborted')
err.name = 'AbortError'
return err
}
describe('LLM.invoke retry behavior', () => {
let llm: LLM
let client: { invoke: ReturnType<typeof vi.fn> }
const signal = new AbortController().signal
beforeEach(() => {
llm = makeLLM(2)
client = { invoke: vi.fn() }
llm.client = client as unknown as LLMClient
})
it('returns immediately on first success', async () => {
client.invoke.mockResolvedValueOnce('ok')
const retryListener = vi.fn()
llm.addEventListener('retry', retryListener)
await expect(llm.invoke([], {}, signal)).resolves.toBe('ok')
expect(client.invoke).toHaveBeenCalledOnce()
expect(retryListener).not.toHaveBeenCalled()
})
it('retries up to maxRetries on retryable errors, then throws last error', async () => {
const retryable = new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'boom')
client.invoke
.mockRejectedValueOnce(retryable)
.mockRejectedValueOnce(retryable)
.mockRejectedValueOnce(retryable)
await expect(llm.invoke([], {}, signal)).rejects.toBe(retryable)
// 1 initial + 2 retries = 3 attempts total
expect(client.invoke).toHaveBeenCalledTimes(3)
})
it('succeeds on retry after transient failure', async () => {
const retryable = new InvokeError(InvokeErrorTypes.RATE_LIMIT, 'slow down')
client.invoke.mockRejectedValueOnce(retryable).mockResolvedValueOnce('ok')
await expect(llm.invoke([], {}, signal)).resolves.toBe('ok')
expect(client.invoke).toHaveBeenCalledTimes(2)
})
it('emits "retry" events with attempt count and lastError', async () => {
const err1 = new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'first')
const err2 = new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'second')
client.invoke
.mockRejectedValueOnce(err1)
.mockRejectedValueOnce(err2)
.mockResolvedValueOnce('ok')
const events: { attempt: number; maxAttempts: number; lastError: Error }[] = []
llm.addEventListener('retry', (e) => {
events.push((e as CustomEvent).detail)
})
await llm.invoke([], {}, signal)
expect(events).toEqual([
{ attempt: 1, maxAttempts: 2, lastError: err1 },
{ attempt: 2, maxAttempts: 2, lastError: err2 },
])
})
it('does not retry on AbortError, throws immediately', async () => {
const err = abortError()
client.invoke.mockRejectedValueOnce(err)
await expect(llm.invoke([], {}, signal)).rejects.toBe(err)
expect(client.invoke).toHaveBeenCalledOnce()
})
it('does not retry on non-retryable InvokeError (AUTH_ERROR)', async () => {
const err = new InvokeError(InvokeErrorTypes.AUTH_ERROR, 'bad token')
client.invoke.mockRejectedValueOnce(err)
await expect(llm.invoke([], {}, signal)).rejects.toBe(err)
expect(client.invoke).toHaveBeenCalledOnce()
})
it('does not retry on non-retryable InvokeError (CONFIG_ERROR)', async () => {
const err = new InvokeError(InvokeErrorTypes.CONFIG_ERROR, 'bad config')
client.invoke.mockRejectedValueOnce(err)
await expect(llm.invoke([], {}, signal)).rejects.toBe(err)
expect(client.invoke).toHaveBeenCalledOnce()
})
it('retries plain (non-InvokeError) errors as unknown failures', async () => {
// Plain errors are treated as retryable by withRetry (only InvokeError carries retryable flag)
const plain = new TypeError('weird')
client.invoke.mockRejectedValueOnce(plain).mockResolvedValueOnce('ok')
await expect(llm.invoke([], {}, signal)).resolves.toBe('ok')
expect(client.invoke).toHaveBeenCalledTimes(2)
})
})
+60 -65
View File
@@ -1,34 +1,23 @@
import { OpenAIClient } from './OpenAIClient' import { OpenAIClient } from './OpenAIClient'
import { DEFAULT_TEMPERATURE, LLM_MAX_RETRIES } from './constants'
import { InvokeError, InvokeErrorTypes } from './errors' import { InvokeError, InvokeErrorTypes } from './errors'
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types' import type {
InvokeOptions,
InvokeResult,
LLMClient,
LLMConfig,
Message,
ResolvedLLMConfig,
Tool,
} from './types'
export { InvokeError, InvokeErrorTypes } export { InvokeError, InvokeErrorTypes }
export type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } export type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool }
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> { /**
// Runtime validation as defensive programming (types already guarantee these) * LLM module
if (!config.baseURL || !config.model) { */
throw new Error(
'[PageAgent] LLM configuration required. Please provide: baseURL, model. ' +
'See: https://alibaba.github.io/page-agent/docs/features/models'
)
}
return {
baseURL: config.baseURL,
model: config.model,
apiKey: config.apiKey || '',
temperature: config.temperature ?? DEFAULT_TEMPERATURE,
maxRetries: config.maxRetries ?? LLM_MAX_RETRIES,
transformRequestBody: config.transformRequestBody ?? ((requestBody) => requestBody),
disableNamedToolChoice: config.disableNamedToolChoice ?? false,
customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound
}
}
export class LLM extends EventTarget { export class LLM extends EventTarget {
config: Required<LLMConfig> config: ResolvedLLMConfig
client: LLMClient client: LLMClient
constructor(config: LLMConfig) { constructor(config: LLMConfig) {
@@ -50,65 +39,71 @@ export class LLM extends EventTarget {
abortSignal: AbortSignal, abortSignal: AbortSignal,
options?: InvokeOptions options?: InvokeOptions
): Promise<InvokeResult> { ): Promise<InvokeResult> {
return await withRetry( return await withRetry(async () => this.client.invoke(messages, tools, abortSignal, options), {
async () => { maxRetries: this.config.maxRetries,
// in case user aborted before invoking onRetry: (attempt, lastError) => {
if (abortSignal.aborted) throw new Error('AbortError') this.dispatchEvent(
new CustomEvent('retry', {
const result = await this.client.invoke(messages, tools, abortSignal, options) detail: { attempt, maxAttempts: this.config.maxRetries, lastError },
})
return result )
}, },
// retry settings })
{
maxRetries: this.config.maxRetries,
onRetry: (attempt: number) => {
this.dispatchEvent(
new CustomEvent('retry', { detail: { attempt, maxAttempts: this.config.maxRetries } })
)
},
onError: (error: Error) => {
this.dispatchEvent(new CustomEvent('error', { detail: { error } }))
},
}
)
} }
} }
/**
* Retry a function until it succeeds or reaches the maximum number of retries.
*/
async function withRetry<T>( async function withRetry<T>(
fn: () => Promise<T>, fn: () => Promise<T>,
settings: { settings: {
maxRetries: number maxRetries: number
onRetry: (attempt: number) => void onRetry: (attempt: number, lastError: Error) => void
onError: (error: Error) => void
} }
): Promise<T> { ): Promise<T> {
let attempt = 0 let attempt = 0
let lastError: Error | null = null while (true) {
while (attempt <= settings.maxRetries) {
if (attempt > 0) {
settings.onRetry(attempt)
await new Promise((resolve) => setTimeout(resolve, 100))
}
try { try {
return await fn() return await fn()
} catch (error: unknown) { } catch (error: unknown) {
// do not retry if aborted by user if ((error as any)?.name === 'AbortError') throw error
if ((error as any)?.rawError?.name === 'AbortError') throw error
console.error(error)
settings.onError(error as Error)
// do not retry if error is not retryable (InvokeError)
if (error instanceof InvokeError && !error.retryable) throw error if (error instanceof InvokeError && !error.retryable) throw error
lastError = error as Error
attempt++ attempt++
if (attempt > settings.maxRetries) throw error
console.debug('[LLM] retryable failure, will retry:', error)
settings.onRetry(attempt, error as Error)
await new Promise((resolve) => setTimeout(resolve, 100)) await new Promise((resolve) => setTimeout(resolve, 100))
} }
} }
}
throw lastError!
export function parseLLMConfig(config: LLMConfig): ResolvedLLMConfig {
// Runtime validation as defensive programming (types already guarantee these)
if (!config.baseURL || !config.model) {
throw new Error(
'[PageAgent] LLM configuration required. Please provide: baseURL, model. ' +
'See: https://alibaba.github.io/page-agent/docs/features/models'
)
}
if (config.temperature !== undefined) {
console.warn(
'[PageAgent] LLMConfig.temperature is deprecated and will be removed in a future version. ' +
'Use transformRequestBody to set it only for models you have verified accept it.'
)
}
return {
baseURL: config.baseURL,
model: config.model,
apiKey: config.apiKey || '',
temperature: config.temperature,
maxRetries: config.maxRetries ?? 2,
transformRequestBody: config.transformRequestBody ?? ((requestBody) => requestBody),
disableNamedToolChoice: config.disableNamedToolChoice ?? false,
customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound
}
} }
+224
View File
@@ -0,0 +1,224 @@
/**
* Live compatibility test — hits real provider APIs.
*
* Purpose: verify request formatting and `modelPatch` (see `utils.ts`) work
* for every model below, across the providers that serve them. Not a
* correctness/quality eval — the tool call is trivial and forced via
* `toolChoiceName`, so a failure here means the request/response shape is
* wrong for that model, not that the model is "dumb".
*
* Tests `OpenAIClient` directly (not the `LLM` retry wrapper), so a failure
* always reflects the very first request/response — no retry can mask it.
*
* Each provider's tests skip (not fail) when its `TESTING_*_KEY` env var is
* absent, so this stays CI-safe. To actually run these, put keys in the
* repo-root `.env`:
*
* TESTING_OPENROUTER_KEY=...
* TESTING_DEEPSEEK_KEY=...
* TESTING_ALIYUN_KEY=...
*/
import { config as dotenvConfig } from 'dotenv'
import { dirname, resolve } from 'path'
import { fileURLToPath } from 'url'
import { describe, expect, it } from 'vitest'
import * as z from 'zod/v4'
import { OpenAIClient } from './OpenAIClient'
import { parseLLMConfig } from './index'
import type { Message, Tool } from './types'
const __dirname = dirname(fileURLToPath(import.meta.url))
dotenvConfig({ path: resolve(__dirname, '../../../.env'), quiet: true })
const TEST_TIMEOUT = 30_000
/**
* Mirrors `packages/website/src/pages/docs/features/models/page.tsx`.
* This package cannot depend on the website, so the list is duplicated here.
* Keep both lists in sync manually when models are added or renamed.
*/
const MODEL_GROUPS: Record<string, string[]> = {
Qwen: [
'qwen3.7-max',
'qwen3.7-plus',
'qwen3.6-max',
'qwen3.6-plus',
'qwen3.6-flash',
'qwen3.5-plus',
'qwen3.5-flash',
'qwen3-max',
],
OpenAI: [
'gpt-5.5',
'gpt-5.4',
'gpt-5.4-mini',
'gpt-5.4-nano',
'gpt-5.2',
'gpt-5.1',
'gpt-5',
'gpt-5-mini',
'gpt-4.1',
'gpt-4.1-mini',
],
DeepSeek: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-3.2'],
Google: [
'gemini-3.5-flash',
'gemini-3.1-pro',
'gemini-3.1-flash-lite',
'gemini-2.5-pro',
'gemini-2.5-flash',
],
Anthropic: [
'claude-sonnet-5',
'claude-opus-4-8',
'claude-opus-4-7',
'claude-opus-4-6',
'claude-opus-4-5',
'claude-sonnet-4-5',
'claude-haiku-4-5',
],
MiniMax: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.5'],
xAI: ['grok-4.3', 'grok-build-0.1'],
MoonshotAI: ['kimi-k2.7-code', 'kimi-k2.6', 'kimi-k2.5'],
'Z.AI': ['glm-5.2', 'glm-5.1', 'glm-5', 'glm-4.7'],
}
/**
* OpenRouter lists every model as `<vendor-slug>/<model-id>`, lowercase.
* See the commented-out entries in the repo-root `.env` for real examples,
* e.g. `x-ai/grok-4.1-fast`, `qwen/qwen3-coder-next`, `deepseek/deepseek-v3.2-exp`.
*/
const OPENROUTER_VENDOR_SLUG: Record<string, string> = {
Qwen: 'qwen',
OpenAI: 'openai',
DeepSeek: 'deepseek',
Google: 'google',
Anthropic: 'anthropic',
MiniMax: 'minimax',
xAI: 'x-ai',
MoonshotAI: 'moonshotai',
'Z.AI': 'z-ai',
}
/**
* Overrides for models whose OpenRouter id doesn't match the
* `<vendor-slug>/<lowercased-name>` heuristic — dated snapshots, "-preview"
* suffixes, "v"-prefixed versions, or dots instead of hyphens in the
* version number. Verified against `GET https://openrouter.ai/api/v1/models`
* on 2026-07-03; re-check when models are added to `MODEL_GROUPS`.
*/
const OPENROUTER_ID_OVERRIDES: Record<string, string> = {
'qwen3.6-max': 'qwen/qwen3.6-max-preview',
'qwen3.5-plus': 'qwen/qwen3.5-plus-20260420',
'qwen3.5-flash': 'qwen/qwen3.5-flash-02-23',
'deepseek-3.2': 'deepseek/deepseek-v3.2',
'gemini-3.1-pro': 'google/gemini-3.1-pro-preview',
'claude-opus-4-8': 'anthropic/claude-opus-4.8',
'claude-opus-4-7': 'anthropic/claude-opus-4.7',
'claude-opus-4-6': 'anthropic/claude-opus-4.6',
'claude-opus-4-5': 'anthropic/claude-opus-4.5',
'claude-sonnet-4-5': 'anthropic/claude-sonnet-4.5',
'claude-haiku-4-5': 'anthropic/claude-haiku-4.5',
}
/**
* Derive a model's OpenRouter id from its brand (a `MODEL_GROUPS` key) and
* native model name.
*/
function toOpenRouterModelId(brand: string, model: string): string {
if (model in OPENROUTER_ID_OVERRIDES) return OPENROUTER_ID_OVERRIDES[model]
const slug = OPENROUTER_VENDOR_SLUG[brand]
if (!slug) throw new Error(`No OpenRouter vendor slug mapped for brand "${brand}"`)
return `${slug}/${model.toLowerCase()}`
}
const PROVIDERS = {
openrouter: {
baseURL: 'https://openrouter.ai/api/v1',
apiKey: process.env.TESTING_OPENROUTER_KEY,
},
aliyun: {
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
apiKey: process.env.TESTING_ALIYUN_KEY,
},
deepseek: {
baseURL: 'https://api.deepseek.com',
apiKey: process.env.TESTING_DEEPSEEK_KEY,
},
} as const
const ECHO_TOOL: Tool<{ message: string }, string> = {
description: 'Echo back the given message in uppercase.',
inputSchema: z.object({ message: z.string() }),
execute: async ({ message }) => message.toUpperCase(),
}
const PROMPT: Message[] = [
{
role: 'user',
content: 'Call the "echo" tool with message set to "ping". You must call the tool.',
},
]
async function expectEchoToolCall(baseURL: string, apiKey: string, model: string) {
const client = new OpenAIClient(parseLLMConfig({ baseURL, apiKey, model }))
const result = await client.invoke(PROMPT, { echo: ECHO_TOOL }, new AbortController().signal, {
toolChoiceName: 'echo',
})
expect(result.toolResult).toBe('PING')
}
describe.concurrent('OpenRouter — all listed models', () => {
const { baseURL, apiKey } = PROVIDERS.openrouter
for (const [brand, models] of Object.entries(MODEL_GROUPS)) {
for (const model of models) {
const id = toOpenRouterModelId(brand, model)
it.skipIf(!apiKey)(
id,
async () => {
await expectEchoToolCall(baseURL, apiKey!, id)
},
TEST_TIMEOUT
)
}
}
})
// Aliyun native ids that don't match the display name in MODEL_GROUPS.
const ALIYUN_ID_OVERRIDES: Record<string, string> = {
'qwen3.6-max': 'qwen3.6-max-preview',
}
describe.concurrent('Aliyun DashScope — Qwen native', () => {
const { baseURL, apiKey } = PROVIDERS.aliyun
for (const model of MODEL_GROUPS.Qwen) {
const id = ALIYUN_ID_OVERRIDES[model] ?? model
it.skipIf(!apiKey)(
id,
async () => {
await expectEchoToolCall(baseURL, apiKey!, id)
},
TEST_TIMEOUT
)
}
})
describe.concurrent('DeepSeek — native', () => {
const { baseURL, apiKey } = PROVIDERS.deepseek
// deepseek-3.2 isn't served on DeepSeek's own API (only via OpenRouter
// resellers) — its official API only accepts deepseek-v4-pro/-flash.
const nativeModels = MODEL_GROUPS.DeepSeek.filter((model) => model !== 'deepseek-3.2')
for (const model of nativeModels) {
it.skipIf(!apiKey)(
model,
async () => {
await expectEchoToolCall(baseURL, apiKey!, model)
},
TEST_TIMEOUT
)
}
})
+7
View File
@@ -92,7 +92,12 @@ export interface LLMConfig {
model: string model: string
apiKey?: string apiKey?: string
/**
* @deprecated No longer a standard parameter; many models reject it outright.
* Use `transformRequestBody` to set it only for models you've verified.
*/
temperature?: number temperature?: number
maxRetries?: number maxRetries?: number
/** /**
@@ -118,3 +123,5 @@ export interface LLMConfig {
*/ */
customFetch?: typeof globalThis.fetch customFetch?: typeof globalThis.fetch
} }
export type ResolvedLLMConfig = Required<Omit<LLMConfig, 'temperature'>> & { temperature?: number }
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { modelPatch, normalizeModelName } from './utils'
describe('normalizeModelName', () => {
it.each([
['gpt-5.2', 'gpt-52'],
['gpt_5_2', 'gpt52'],
['GPT-52-2026-01-01', 'gpt-52-2026-01-01'],
['openai/gpt-5.2-chat', 'gpt-52-chat'],
['claude_sonnet4_5', 'claudesonnet45'],
])('%s -> %s', (input, expected) => {
expect(normalizeModelName(input)).toBe(expected)
})
})
+130 -62
View File
@@ -24,96 +24,152 @@ export function zodToOpenAITool(name: string, tool: Tool) {
} }
/** /**
* Patch model specific parameters * Patch model specific parameters. Only patches known models.
* @note in-place modification *
* @purpose
* - Reconcile the differences in the parameter schema each model accepts.
* - Disable thinking/reasoning, or lower it to the minimum where a full disable is impossible.
* - Minimize returned tokens.
* - Raise temperature for known smaller models to improve auto-recovery odds.
* @note Honor temperature if explicitly set by the user
*
* @todo Need vendor-specific patches.
* Local and 3rd-party hosted models may have different schema.
*/ */
export function modelPatch(body: Record<string, any>) { export function modelPatch(body: Record<string, any>, baseURL?: string) {
const model: string = body.model || '' const model: string = body.model || ''
if (!model) return body if (!model) return body
const provider = getProvider(baseURL)
const modelName = normalizeModelName(model) const modelName = normalizeModelName(model)
if (modelName.startsWith('qwen')) { if (modelName.startsWith('qwen')) {
debug('Applying Qwen patch: use higher temperature for auto fixing') debug('Patch Qwen: disable thinking')
body.temperature = Math.max(body.temperature || 0, 1.0)
body.enable_thinking = false body.enable_thinking = false
if (body.temperature === undefined && !/max|plus/.test(modelName)) {
debug('Patch Qwen: raise temperature to 1.0')
body.temperature = 1.0
}
} }
if (modelName.startsWith('claude')) { if (modelName.startsWith('deepseek')) {
debug('Applying Claude patch: disable thinking') debug('Patch DeepSeek: disable thinking, remove tool_choice')
body.thinking = { type: 'disabled' } body.thinking = { type: 'disabled' }
// Convert tool_choice to Claude format
if (body.tool_choice === 'required') {
// 'required' -> { type: 'any' } (must call some tool)
debug('Applying Claude patch: convert tool_choice "required" to { type: "any" }')
body.tool_choice = { type: 'any' }
} else if (body.tool_choice?.function?.name) {
// { type: 'function', function: { name: '...' } } -> { type: 'tool', name: '...' }
debug('Applying Claude patch: convert tool_choice format')
body.tool_choice = { type: 'tool', name: body.tool_choice.function.name }
}
// TODO: Claude naming pattern has changed
// needs proper handling
if (modelName.startsWith('claude-opus-4-7') || modelName.startsWith('claude-opus-47')) {
debug('Applying Claude-4.7 patch: remove temperature')
delete body.temperature
}
}
if (modelName.startsWith('grok')) {
debug('Applying Grok patch: removing tool_choice')
delete body.tool_choice delete body.tool_choice
debug('Applying Grok patch: disable reasoning and thinking')
body.thinking = { type: 'disabled', effort: 'minimal' }
body.reasoning = { enabled: false, effort: 'low' }
} }
if (modelName.startsWith('gpt')) { if (modelName.startsWith('gpt')) {
debug('Applying GPT patch: set verbosity to low') if (modelName.startsWith('gpt-5')) {
body.verbosity = 'low' body.verbosity = 'low'
if (modelName.startsWith('gpt-52')) { // gpt-5 gpt-5-mini gpt-5-nano only supports "minimal";
debug('Applying GPT-52 patch: disable reasoning') // 5.1+ supports "none".
body.reasoning_effort = 'none' body.reasoning_effort = /^gpt-5(-|$)/.test(modelName) ? 'minimal' : 'none'
} else if (modelName.startsWith('gpt-51')) { debug(`Patch GPT-5: verbosity=low, reasoning_effort=${body.reasoning_effort}`)
debug('Applying GPT-51 patch: disable reasoning') }
body.reasoning_effort = 'none'
} else if (modelName.startsWith('gpt-54')) { if (modelName.includes('chat-latest')) {
debug('Applying GPT-5.4 patch: remove reasoning_effort') debug('Omitting reasoning_effort and temperature for chat-latest')
delete body.reasoning_effort
} else if (modelName.startsWith('gpt-55')) {
debug('Applying GPT-5.4 patch: remove reasoning_effort and temperature')
delete body.reasoning_effort delete body.reasoning_effort
delete body.temperature delete body.temperature
} else if (modelName.startsWith('gpt-5-mini')) { }
debug('Applying GPT-5-mini patch: set reasoning effort to low, temperature to 1') }
body.reasoning_effort = 'low'
body.temperature = 1 if (modelName.startsWith('claude')) {
} else if (modelName.startsWith('gpt-5')) { if (/opus|sonnet|haiku/.test(modelName)) {
debug('Applying GPT-5 patch: set reasoning effort to low') debug('Patch Claude: disable thinking')
body.thinking = { type: 'disabled' }
if (provider !== 'openrouter') {
// Convert tool_choice to Claude format
if (body.tool_choice === 'required') {
// 'required' -> { type: 'any' } (must call some tool)
debug('Applying Claude patch: convert tool_choice "required" to { type: "any" }')
body.tool_choice = { type: 'any' }
} else if (body.tool_choice?.function?.name) {
// { type: 'function', function: { name: '...' } } -> { type: 'tool', name: '...' }
debug('Applying Claude patch: convert tool_choice format')
body.tool_choice = { type: 'tool', name: body.tool_choice.function.name }
}
}
} else {
debug('Patch Claude: reasoning_effort=low')
body.reasoning_effort = 'low' body.reasoning_effort = 'low'
// Fable and mythos can not disable adaptive thinking.
// Claude does not support tool_choice with extended thinking.
// These 2 concepts are blurred. Basically no tool_choice with thinking.
delete body.tool_choice
} }
} }
if (modelName.startsWith('gemini')) { if (modelName.startsWith('gemini')) {
debug('Applying Gemini patch: set reasoning effort to minimal') debug('Patch Gemini: reasoning_effort=low')
body.reasoning_effort = 'minimal' body.reasoning_effort = 'low'
if (/^gemini-25(?!.*pro)/.test(modelName)) {
debug('Patch Gemini 2.5 non-Pro: reasoning_effort=none')
body.reasoning_effort = 'none'
} else if (
modelName.startsWith('gemini-35-flash') ||
modelName.startsWith('gemini-31-flash-lite') ||
modelName.startsWith('gemini-3-flash')
) {
debug('Patch Gemini 3.x Flash/Lite: reasoning_effort=minimal')
body.reasoning_effort = 'minimal'
}
} }
if (modelName.startsWith('deepseek')) { if (modelName.startsWith('glm')) {
debug('Applying DeepSeek patch: remove tool_choice') debug('Patch GLM: disable thinking')
delete body.tool_choice body.thinking = { type: 'disabled' }
}
if (modelName.startsWith('grok')) {
if (/^grok-4-?3/.test(modelName)) {
debug('Patch Grok 4.3: reasoning_effort=none')
body.reasoning_effort = 'none'
} else if (modelName.startsWith('grok-3-mini') || modelName.startsWith('grok-code-fast')) {
debug('Patch Grok mini/code: reasoning_effort=low')
body.reasoning_effort = 'low'
}
}
if (modelName.startsWith('kimi')) {
if (!modelName.includes('code')) {
// kimi-k2.7-code cannot disable thinking
debug('Patch Kimi: disable thinking')
body.thinking = { type: 'disabled' }
}
} }
if (modelName.startsWith('minimax')) { if (modelName.startsWith('minimax')) {
debug('Applying MiniMax patch: clamp temperature to (0, 1]') debug('Patch MiniMax: remove parallel_tool_calls')
// MiniMax API rejects temperature = 0; clamp to a small positive value
body.temperature = Math.max(body.temperature || 0, 0.01)
if (body.temperature > 1) body.temperature = 1
// MiniMax does not support parallel_tool_calls
delete body.parallel_tool_calls delete body.parallel_tool_calls
if (modelName.includes('m3')) {
// Only M3 can disable thinking
debug('Patch MiniMax: disable thinking')
body.thinking = { type: 'disabled' }
}
}
// provider patches
if (provider === 'openrouter') {
// openrouter use reasoning object instead of reasoning_effort
const reasoningEffort = body.reasoning_effort
const reasoningDisabled =
body.thinking?.type === 'disabled' ||
body.enable_thinking === false ||
reasoningEffort === 'none'
if (reasoningDisabled) {
body.reasoning = { enabled: false }
} else if (reasoningEffort) {
body.reasoning = { enabled: true, effort: reasoningEffort }
}
} }
return body return body
@@ -134,7 +190,7 @@ export function modelPatch(body: Record<string, any>) {
* They should be treated as the same model. * They should be treated as the same model.
* Normalize them to `gpt-52` * Normalize them to `gpt-52`
*/ */
function normalizeModelName(modelName: string): string { export function normalizeModelName(modelName: string): string {
let normalizedName = modelName.toLowerCase() let normalizedName = modelName.toLowerCase()
// remove prefix before '/' // remove prefix before '/'
@@ -150,3 +206,15 @@ function normalizeModelName(modelName: string): string {
return normalizedName return normalizedName
} }
export function getProvider(baseURL?: string): 'openrouter' | undefined {
if (!baseURL) return undefined
try {
const url = new URL(baseURL)
const hostname = url.hostname
if (hostname === 'openrouter.ai') return 'openrouter'
return undefined
} catch (e) {
return undefined
}
}
+1 -1
View File
@@ -3,6 +3,6 @@
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo" "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
}, },
"include": ["**/*.ts"], "include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"] "exclude": ["dist", "node_modules"]
} }
+2
View File
@@ -13,6 +13,8 @@ export default defineConfig({
clearScreen: false, clearScreen: false,
plugins: [ plugins: [
dts({ dts({
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts'],
bundleTypes: true, bundleTypes: true,
compilerOptions: { compilerOptions: {
composite: true, composite: true,
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
name: 'llms',
include: ['src/**/*.test.ts'],
// Suppress console output from passing tests; failed tests still get their logs.
silent: 'passed-only',
},
})
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/mcp", "name": "@page-agent/mcp",
"private": false, "private": false,
"version": "1.8.2", "version": "1.11.0",
"type": "module", "type": "module",
"bin": { "bin": {
"page-agent-mcp": "src/index.js" "page-agent-mcp": "src/index.js"
@@ -29,7 +29,7 @@
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"ws": "^8.20.0", "ws": "^8.21.0",
"zod": "^4.4.3" "zod": "^4.4.3"
} }
} }
+3 -6
View File
@@ -3,10 +3,7 @@
<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" />
<link <link rel="icon" href="https://page-agent.github.io/assets/brand/page-agent-color-64.png" />
rel="icon"
href="https://img.alicdn.com/imgextra/i1/O1CN01mRGret1QrKiu7CFJI_!!6000000002029-2-tps-64-64.png"
/>
<title>Page Agent MCP Launcher</title> <title>Page Agent MCP Launcher</title>
<style> <style>
* { * {
@@ -145,7 +142,7 @@
<div class="card"> <div class="card">
<img <img
class="logo" class="logo"
src="https://img.alicdn.com/imgextra/i3/O1CN01JPT4Fj1FJTfmHfNxO_!!6000000000466-49-tps-512-512.webp" src="https://page-agent.github.io/assets/brand/page-agent-color-512.webp"
alt="Page Agent" alt="Page Agent"
/> />
<div class="badge">Page Agent MCP Launcher</div> <div class="badge">Page Agent MCP Launcher</div>
@@ -164,7 +161,7 @@
<a class="store-btn" href="__STORE_URL__" target="_blank"> <a class="store-btn" href="__STORE_URL__" target="_blank">
<img <img
src="https://img.alicdn.com/imgextra/i3/O1CN01JpW0Vo1sR3FpiZKFM_!!6000000005762-55-tps-192-192.svg" src="https://page-agent.github.io/assets/third-party/chrome-web-store-192.svg"
alt="" alt=""
/> />
<span data-i18n="install_btn">Install from Chrome Web Store</span> <span data-i18n="install_btn">Install from Chrome Web Store</span>
+5 -5
View File
@@ -1,7 +1,7 @@
{ {
"name": "page-agent", "name": "page-agent",
"private": false, "private": false,
"version": "1.8.2", "version": "1.11.0",
"type": "module", "type": "module",
"main": "./src/PageAgent.ts", "main": "./src/PageAgent.ts",
"types": "./src/PageAgent.ts", "types": "./src/PageAgent.ts",
@@ -54,10 +54,10 @@
"postpublish": "node ../../scripts/post-publish.js" "postpublish": "node ../../scripts/post-publish.js"
}, },
"dependencies": { "dependencies": {
"@page-agent/core": "1.8.2", "@page-agent/core": "1.11.0",
"@page-agent/llms": "1.8.2", "@page-agent/llms": "1.11.0",
"@page-agent/page-controller": "1.8.2", "@page-agent/page-controller": "1.11.0",
"@page-agent/ui": "1.8.2", "@page-agent/ui": "1.11.0",
"chalk": "^5.6.2" "chalk": "^5.6.2"
}, },
"peerDependencies": { "peerDependencies": {
+2
View File
@@ -12,6 +12,8 @@ export default defineConfig({
clearScreen: false, clearScreen: false,
plugins: [ plugins: [
dts({ dts({
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts'],
bundleTypes: true, bundleTypes: true,
compilerOptions: { compilerOptions: {
composite: true, composite: true,
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/page-controller", "name": "@page-agent/page-controller",
"version": "1.8.2", "version": "1.11.0",
"type": "module", "type": "module",
"main": "./src/PageController.ts", "main": "./src/PageController.ts",
"types": "./src/PageController.ts", "types": "./src/PageController.ts",
@@ -42,6 +42,7 @@
"homepage": "https://alibaba.github.io/page-agent/", "homepage": "https://alibaba.github.io/page-agent/",
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"test": "vitest run",
"prepublishOnly": "node ../../scripts/pre-publish.js", "prepublishOnly": "node ../../scripts/pre-publish.js",
"postpublish": "node ../../scripts/post-publish.js" "postpublish": "node ../../scripts/post-publish.js"
}, },
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { PageController } from './PageController'
describe('PageController', () => {
it('constructs and exposes the current url', async () => {
const controller = new PageController()
expect(controller).toBeInstanceOf(PageController)
expect(await controller.getCurrentUrl()).toBe(window.location.href)
})
describe('executeJavascript', () => {
it('runs a script and returns its result', async () => {
const controller = new PageController()
const result = await controller.executeJavascript('return 1 + 2')
expect(result).toMatchObject({ success: true })
expect(result.message).toContain('3')
})
it('exposes the abort signal to the script scope', async () => {
const controller = new PageController()
const controllerSignal = new AbortController()
controllerSignal.abort()
const result = await controller.executeJavascript(
'return signal.aborted',
controllerSignal.signal
)
expect(result).toMatchObject({ success: true })
expect(result.message).toContain('true')
})
it('reports a syntax error as a failed result', async () => {
const controller = new PageController()
const result = await controller.executeJavascript('return (')
expect(result.success).toBe(false)
expect(result.message).toContain('❌')
})
})
})
@@ -376,13 +376,15 @@ export class PageController extends EventTarget {
} }
/** /**
* Execute arbitrary JavaScript on the page * Execute arbitrary JavaScript on the page.
* The optional `signal` is exposed to the script scope so cooperative code
* can abort promptly when the task is stopped.
*/ */
async executeJavascript(script: string): Promise<ActionResult> { async executeJavascript(script: string, signal?: AbortSignal): Promise<ActionResult> {
try { try {
// Wrap script in async function to support await // Wrap script in async function to support await, exposing `signal`.
const asyncFunction = eval(`(async () => { ${script} })`) const asyncFunction = eval(`(async (signal) => { ${script} })`)
const result = await asyncFunction() const result = await asyncFunction(signal)
return { return {
success: true, success: true,
message: `✅ Executed JavaScript. Result: ${result}`, message: `✅ Executed JavaScript. Result: ${result}`,
+14 -12
View File
@@ -208,9 +208,7 @@ export async function inputTextElement(element: HTMLElement, text: string) {
selection?.removeAllRanges() selection?.removeAllRanges()
selection?.addRange(range) selection?.addRange(range)
// eslint-disable-next-line @typescript-eslint/no-deprecated
doc.execCommand('delete', false) doc.execCommand('delete', false)
// eslint-disable-next-line @typescript-eslint/no-deprecated
doc.execCommand('insertText', false, text) doc.execCommand('insertText', false, text)
} }
@@ -336,11 +334,13 @@ export async function scrollVertically(scroll_amount: number, element?: HTMLElem
const dy = scroll_amount const dy = scroll_amount
const bigEnough = (el: HTMLElement) => el.clientHeight >= window.innerHeight * 0.5 const bigEnough = (el: HTMLElement) => el.clientHeight >= window.innerHeight * 0.5
const canScroll = (el: HTMLElement | null) => const canScroll = (el: HTMLElement | null): boolean =>
el && Boolean(
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowY) && el &&
el.scrollHeight > el.clientHeight && /(auto|scroll|overlay)/.test(getComputedStyle(el).overflowY) &&
bigEnough(el) el.scrollHeight > el.clientHeight &&
bigEnough(el)
)
// @deprecated Heuristic container search. // @deprecated Heuristic container search.
// Unreliable in multi-panel layouts. Should guide LLMs to use indexed scroll for consistency. // Unreliable in multi-panel layouts. Should guide LLMs to use indexed scroll for consistency.
@@ -479,11 +479,13 @@ export async function scrollHorizontally(scroll_amount: number, element?: HTMLEl
const dx = 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): boolean =>
el && Boolean(
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowX) && el &&
el.scrollWidth > el.clientWidth && /(auto|scroll|overlay)/.test(getComputedStyle(el).overflowX) &&
bigEnough(el) el.scrollWidth > el.clientWidth &&
bigEnough(el)
)
// @deprecated Same heuristic container search as scrollVertically. // @deprecated Same heuristic container search as scrollVertically.
// TODO: Remove once LLMs reliably use indexed scrolling via data-scrollable. // TODO: Remove once LLMs reliably use indexed scrolling via data-scrollable.
@@ -552,8 +552,6 @@ export default (
scrollData: scrollData, scrollData: scrollData,
}) })
console.log('scrollData!!!', scrollData)
return scrollData return scrollData
} }
@@ -600,14 +598,12 @@ export default (
isAnyRectVisible = true isAnyRectVisible = true
// Viewport check for this rect // Viewport check for this rect
if ( if (!(
!( rect.bottom < -viewportExpansion ||
rect.bottom < -viewportExpansion || rect.top > window.innerHeight + viewportExpansion ||
rect.top > window.innerHeight + viewportExpansion || rect.right < -viewportExpansion ||
rect.right < -viewportExpansion || rect.left > window.innerWidth + viewportExpansion
rect.left > window.innerWidth + viewportExpansion )) {
)
) {
isAnyRectInViewport = true isAnyRectInViewport = true
break // Found a visible rect in viewport, no need to check others break // Found a visible rect in viewport, no need to check others
} }
@@ -987,12 +983,10 @@ export default (
rect.height > 0 && rect.height > 0 &&
!( !(
// Only check non-empty rects // Only check non-empty rects
( rect.bottom < -viewportExpansion ||
rect.bottom < -viewportExpansion || rect.top > window.innerHeight + viewportExpansion ||
rect.top > window.innerHeight + viewportExpansion || rect.right < -viewportExpansion ||
rect.right < -viewportExpansion || rect.left > window.innerWidth + viewportExpansion
rect.left > window.innerWidth + viewportExpansion
)
) )
) { ) {
isAnyRectInViewport = true isAnyRectInViewport = true
@@ -1105,14 +1099,12 @@ export default (
for (const rect of rects) { for (const rect of rects) {
if (rect.width === 0 || rect.height === 0) continue // Skip empty rects if (rect.width === 0 || rect.height === 0) continue // Skip empty rects
if ( if (!(
!( rect.bottom < -viewportExpansion ||
rect.bottom < -viewportExpansion || rect.top > window.innerHeight + viewportExpansion ||
rect.top > window.innerHeight + viewportExpansion || rect.right < -viewportExpansion ||
rect.right < -viewportExpansion || rect.left > window.innerWidth + viewportExpansion
rect.left > window.innerWidth + viewportExpansion )) {
)
) {
return true // Found at least one rect in the viewport return true // Found at least one rect in the viewport
} }
} }
@@ -209,7 +209,6 @@ export class SimulatorMask extends EventTarget {
dispose() { dispose() {
this.#disposed = true this.#disposed = true
console.log('dispose SimulatorMask')
this.motion?.dispose() this.motion?.dispose()
this.wrapper.remove() this.wrapper.remove()
this.dispatchEvent(new Event('dispose')) this.dispatchEvent(new Event('dispose'))
@@ -1,6 +1,25 @@
/**
* A comprehensive function to determine if the page is currently in a dark theme.
* Heuristic check. Only work for common patterns. Return false by default.
*/
export function isPageDark() {
try {
if (hasDarkModeClass()) return true
if (hasDarkModeDataAttribute()) return true
if (isColorSchemeDark()) return true
if (isBackgroundDark()) return true
if (isMainContentBackgroundDark()) return true
if (isTextColorLight()) return true
return false
} catch (error) {
console.warn('Error determining if page is dark:', error)
return false
}
}
/** /**
* Checks for common dark mode CSS classes on the html or body elements. * Checks for common dark mode CSS classes on the html or body elements.
* @returns {boolean} - True if a common dark mode class is found.
*/ */
function hasDarkModeClass() { function hasDarkModeClass() {
const DEFAULT_DARK_MODE_CLASSES = ['dark', 'dark-mode', 'theme-dark', 'night', 'night-mode'] const DEFAULT_DARK_MODE_CLASSES = ['dark', 'dark-mode', 'theme-dark', 'night', 'night-mode']
@@ -15,57 +34,47 @@ function hasDarkModeClass() {
} }
} }
// Some sites use data attributes return false
const darkThemeAttribute = htmlElement.getAttribute('data-theme') }
if (darkThemeAttribute?.toLowerCase().includes('dark')) {
return true /**
* Some UI frameworks use data attributes to indicate theme
*/
function hasDarkModeDataAttribute() {
const htmlElement = document.documentElement
const bodyElement = document.body || document.documentElement // can be null in some cases
const dataAttrs = ['data-theme', 'data-color-mode', 'data-bs-theme', 'data-mui-color-scheme']
for (const attr of dataAttrs) {
const bodyValue = bodyElement?.getAttribute(attr)
const htmlValue = htmlElement.getAttribute(attr)
if (bodyValue?.toLowerCase() === 'dark' || htmlValue?.toLowerCase() === 'dark') {
return true
}
} }
return false return false
} }
/** /**
* Parses an RGB or RGBA color string and returns an object with r, g, b properties. * Checks the CSS `color-scheme` property and `<meta name="color-scheme">` tag.
* @param {string} colorString - e.g., "rgb(34, 34, 34)" or "rgba(0, 0, 0, 0.5)" * Only "dark"/"only dark" counts as dark; "light dark" is ambiguous and ignored.
* @returns {{r: number, g: number, b: number}|null}
*/ */
function parseRgbColor(colorString: string) { function isColorSchemeDark() {
const rgbMatch = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(colorString) // Check <meta name="color-scheme" content="dark">
if (!rgbMatch) { const meta = document.querySelector<HTMLMetaElement>('meta[name="color-scheme"]')
return null // Not a valid rgb/rgba string const metaContent = meta?.content.toLowerCase()
} if (metaContent === 'dark' || metaContent === 'only dark') return true
return {
r: parseInt(rgbMatch[1]),
g: parseInt(rgbMatch[2]),
b: parseInt(rgbMatch[3]),
}
}
/** // Check the computed color-scheme CSS property on :root
* Determines if a color is "dark" based on its calculated luminance. const rootStyle = window.getComputedStyle(document.documentElement)
* @param {string} colorString - The CSS color string (e.g., "rgb(50, 50, 50)"). const colorScheme = rootStyle.getPropertyValue('color-scheme').trim().toLowerCase()
* @param {number} threshold - A value between 0 and 255. Colors with luminance below this will be considered dark. Default is 128. return colorScheme === 'dark' || colorScheme === 'only dark'
* @returns {boolean} - True if the color is considered dark.
*/
function isColorDark(colorString: string, threshold = 128) {
if (!colorString || colorString === 'transparent' || colorString.startsWith('rgba(0, 0, 0, 0)')) {
return false // Transparent is not dark
}
const rgb = parseRgbColor(colorString)
if (!rgb) {
return false // Could not parse color
}
// Calculate perceived luminance using the standard formula
const luminance = 0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b
return luminance < threshold
} }
/** /**
* Checks the background color of the body element to determine if the page is dark. * Checks the background color of the body element to determine if the page is dark.
* @returns {boolean}
*/ */
function isBackgroundDark() { function isBackgroundDark() {
// We check both <html> and <body> because some pages set the color on <html> // We check both <html> and <body> because some pages set the color on <html>
@@ -88,28 +97,85 @@ function isBackgroundDark() {
} }
/** /**
* A comprehensive function to determine if the page is currently in a dark theme. * Checks if the text color on the body is light, which implies a dark background.
* It combines class checking and background color analysis.
* @returns {boolean} - True if the page is likely dark.
*/ */
export function isPageDark() { function isTextColorLight() {
try { /** Luminance (0-255) above which body text is considered light */
// Strategy 1: Check for common dark mode classes const LIGHT_TEXT_LUMINANCE = 200
if (hasDarkModeClass()) {
return true
}
// Strategy 2: Analyze the computed background color const bodyStyle = window.getComputedStyle(document.body || document.documentElement)
if (isBackgroundDark()) { const luminance = getLuminance(bodyStyle.color)
return true
}
// @TODO add more checks here, e.g., analyzing text color, // Light text has high luminance (e.g. white text on dark bg)
// or checking the background of major layout elements like <main> or #app. return luminance !== null && luminance > LIGHT_TEXT_LUMINANCE
}
return false /**
} catch (error) { * Checks the background color of major layout elements (#app, #root, etc.).
console.warn('Error determining if page is dark:', error) * Many SPAs render into a container that may have its own dark background while
return false * <body> remains transparent.
*/
function isMainContentBackgroundDark() {
const { innerWidth: vw, innerHeight: vh } = window
const minArea = vw * vh * 0.5
const selectors = ['#app', '#root', '#__next']
for (const selector of selectors) {
const el = document.querySelector(selector)
if (!el) continue
const rect = el.getBoundingClientRect()
if (rect.width * rect.height < minArea) continue
if (isColorDark(window.getComputedStyle(el).backgroundColor)) return true
}
return false
}
// --- utils ---
/**
* Parses an RGB or RGBA color string and returns an object with r, g, b properties.
* @param {string} colorString - e.g., "rgb(34, 34, 34)" or "rgba(0, 0, 0, 0.5)"
* @returns {{r: number, g: number, b: number}|null}
*/
function parseRgbColor(colorString: string) {
const rgbMatch = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(colorString)
if (!rgbMatch) {
return null // Not a valid rgb/rgba string
}
return {
r: parseInt(rgbMatch[1]),
g: parseInt(rgbMatch[2]),
b: parseInt(rgbMatch[3]),
} }
} }
/**
* Calculates the perceived luminance (0-255) of a CSS color string.
* @param {string} colorString - e.g., "rgb(50, 50, 50)" or "rgba(0, 0, 0, 0.5)"
* @returns {number|null} - The luminance, or null if the color is transparent or unparseable.
*/
function getLuminance(colorString: string): number | null {
if (!colorString || colorString === 'transparent' || colorString.startsWith('rgba(0, 0, 0, 0)')) {
return null // Transparent has no meaningful luminance
}
const rgb = parseRgbColor(colorString)
if (!rgb) {
return null // Could not parse color
}
// Standard perceived luminance formula
return 0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b
}
/**
* Determines if a color is "dark" based on its calculated luminance.
* @param {string} colorString - The CSS color string (e.g., "rgb(50, 50, 50)").
* @param {number} threshold - A value between 0 and 255. Colors with luminance below this will be considered dark. Default is 128.
*/
function isColorDark(colorString: string, threshold = 128) {
const luminance = getLuminance(colorString)
return luminance !== null && luminance < threshold
}
+2
View File
@@ -14,6 +14,8 @@ export default defineConfig({
clearScreen: false, clearScreen: false,
plugins: [ plugins: [
dts({ dts({
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts'],
bundleTypes: true, bundleTypes: true,
compilerOptions: { compilerOptions: {
composite: true, composite: true,
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
name: 'page-controller',
environment: 'happy-dom',
include: ['src/**/*.test.ts'],
silent: 'passed-only',
},
})
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/ui", "name": "@page-agent/ui",
"version": "1.8.2", "version": "1.11.0",
"type": "module", "type": "module",
"main": "./src/index.ts", "main": "./src/index.ts",
"types": "./src/index.ts", "types": "./src/index.ts",
@@ -1,64 +0,0 @@
import styles from './motion.module.css'
export function createMotion() {
const wrapper = document.createElement('div')
wrapper.className = styles.wrapper
{
const colorWrapper = document.createElement('div')
colorWrapper.className = styles.colorWrapper
wrapper.appendChild(colorWrapper)
const layerA = document.createElement('div')
layerA.className = styles.colorLayer + ' ' + styles.layerA
colorWrapper.appendChild(layerA)
const layerB = document.createElement('div')
layerB.className = styles.colorLayer + ' ' + styles.layerB
colorWrapper.appendChild(layerB)
const layerC = document.createElement('div')
layerC.className = styles.colorLayer + ' ' + styles.layerC
colorWrapper.appendChild(layerC)
}
{
const borderWrapper = document.createElement('div')
borderWrapper.className = styles.borderWrapper
wrapper.appendChild(borderWrapper)
const layerA = document.createElement('div')
layerA.className = styles.borderLayer + ' ' + styles.layerA
borderWrapper.appendChild(layerA)
const layerB = document.createElement('div')
layerB.className = styles.borderLayer + ' ' + styles.layerB
borderWrapper.appendChild(layerB)
const layerC = document.createElement('div')
layerC.className = styles.borderLayer + ' ' + styles.layerC
borderWrapper.appendChild(layerC)
}
function show() {
wrapper.classList.remove(styles.exit)
wrapper.classList.remove(styles.entry)
// Force reflow to restart animation
void wrapper.offsetHeight
wrapper.classList.add(styles.entry)
}
function hide() {
wrapper.classList.remove(styles.entry)
wrapper.classList.remove(styles.exit)
// Force reflow to restart animation
void wrapper.offsetHeight
wrapper.classList.add(styles.exit)
}
return {
element: wrapper,
show,
hide,
}
}
@@ -1,397 +0,0 @@
.wrapper {
position: absolute;
inset: 0;
pointer-events: none;
transform-origin: center;
--color-1: rgb(57, 182, 255);
--color-2: rgb(189, 69, 251);
--color-3: rgb(255, 87, 51);
--color-4: rgb(255, 214, 0);
--blend-mode: screen;
}
.colorLayer {
position: absolute;
inset: 0;
/* 变亮混合模式 */
/* mix-blend-mode: screen; */
/* mix-blend-mode: overlay; */
/* mix-blend-mode: multiply; */
mix-blend-mode: add;
/* 边框遮罩 - 中间透明,边缘不透明 */
mask-image: url(https://img.alicdn.com/imgextra/i2/O1CN01iW1wfX1C0ICvoPbTq_!!6000000000018-2-tps-512-512.png);
mask-repeat: no-repeat;
mask-size: calc(100% + 10px) calc(100% + 10px);
}
.borderWrapper {
position: absolute;
inset: 0;
/* filter: blur(10px); */
}
.borderLayer {
position: absolute;
inset: 0;
/* 变亮混合模式 */
/* mix-blend-mode: overlay; */
mix-blend-mode: add;
mask-image:
linear-gradient(
to right,
black 0px,
black 2px,
transparent 2px,
transparent calc(100% - 2px),
black calc(100% - 2px),
black 100%
),
linear-gradient(
to top,
black 0px,
black 2px,
transparent 2px,
transparent calc(100% - 2px),
black calc(100% - 2px),
black 100%
);
mask-composite: add;
mask-repeat: no-repeat;
mask-size: 100% 100%;
/* filter: blur(100px); */
}
.blueLayer {
&.colorLayer {
mask-position: left -5px top -5px;
}
&::after {
content: '';
position: absolute;
/* inset: 0; */
width: calc(max(100vw, 100vh) * 1.5);
height: 600px;
top: calc(50% - 300px);
left: 50%;
filter: blur(100px);
background: rgb(57, 182, 255);
animation: rotate-clockwise 4s linear infinite;
animation-delay: -3s;
}
}
.purpleLayer {
&.colorLayer {
mask-position: left -3px top -7px;
}
&::after {
content: '';
position: absolute;
/* inset: 0; */
width: calc(max(100vw, 100vh) * 1.5);
height: 600px;
top: calc(50% - 300px);
left: 50%;
filter: blur(100px);
background: rgb(189, 69, 251);
animation: rotate-clockwise 4s linear infinite;
animation-delay: -2s;
}
}
.orangeLayer {
/* opacity: 0.5; */
&.colorLayer {
mask-position: left -7px top -2px;
}
&::after {
content: '';
position: absolute;
/* inset: 0; */
width: calc(max(100vw, 100vh) * 1.5);
height: 600px;
top: calc(50% - 300px);
left: 50%;
filter: blur(100px);
background: rgb(255, 87, 51);
animation: rotate-counter-clockwise 3s linear infinite;
animation-delay: -2s;
}
}
.yellowLayer {
/* opacity: 0.5; */
&.colorLayer {
mask-position: left -6px top -4px;
}
&::after {
content: '';
position: absolute;
/* inset: 0; */
width: calc(max(100vw, 100vh) * 1.5);
height: 600px;
top: calc(50% - 300px);
left: 50%;
filter: blur(100px);
background: rgb(255, 214, 0);
animation: rotate-counter-clockwise 4s linear infinite;
animation-delay: -1s;
}
}
/* 旋转动画 */
@keyframes rotate-clockwise {
0% {
transform: translateX(-50%) rotate(0deg);
}
100% {
transform: translateX(-50%) rotate(360deg);
}
}
@keyframes rotate-counter-clockwise {
0% {
transform: translateX(-50%) rotate(0deg);
}
100% {
transform: translateX(-50%) rotate(-360deg);
}
}
@keyframes wrapper-entry {
from {
transform: scale(1.1);
}
to {
transform: scale(1);
}
}
/*
rgb(57, 182, 255)
rgb(189, 69, 251)
rgb(255, 87, 51)
rgb(255, 214, 0)
*/
@keyframes mask-running {
from {
transform: translateX(0%);
}
to {
transform: translateX(100%);
}
}
@keyframes mask-running-reverse {
from {
transform: translateX(100%);
}
to {
transform: translateX(0%);
}
}
.colorWrapper {
position: absolute;
inset: 0;
.colorLayer {
position: absolute;
inset: 0;
mix-blend-mode: var(--blend-mode);
/* 边框遮罩 - 中间透明,边缘不透明 */
mask-image: url(https://img.alicdn.com/imgextra/i2/O1CN01iW1wfX1C0ICvoPbTq_!!6000000000018-2-tps-512-512.png);
mask-repeat: no-repeat;
mask-size: 100% 100%;
}
}
.borderWrapper {
position: absolute;
inset: 0;
--blend-mode: lighten;
.borderLayer {
position: absolute;
inset: 0;
mix-blend-mode: var(--blend-mode);
mask-border: url(https://img.alicdn.com/imgextra/i3/O1CN01bFjRug1yssyWEUbKL_!!6000000006635-2-tps-256-256.png)
25;
-webkit-mask-box-image: url(https://img.alicdn.com/imgextra/i3/O1CN01bFjRug1yssyWEUbKL_!!6000000006635-2-tps-256-256.png)
25;
mask-repeat: no-repeat;
mask-size: 100% 100%;
background-color: var(--color-2);
}
}
.entry .colorWrapper,
.entry .borderWrapper {
animation: wrapper-entry 0.8s ease-in-out forwards;
}
.exit .colorWrapper,
.exit .borderWrapper {
animation: wrapper-entry 0.8s ease-in-out reverse forwards;
}
.layerA {
position: absolute;
inset: 0;
&::before {
mix-blend-mode: var(--blend-mode);
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
left: -100%;
top: 0;
background-image: linear-gradient(
to right bottom,
transparent,
var(--color-1),
transparent,
var(--color-1),
transparent
);
animation: mask-running 2s linear infinite;
}
&::after {
mix-blend-mode: var(--blend-mode);
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
background-image: linear-gradient(
to right bottom,
transparent,
var(--color-1),
transparent,
var(--color-1),
transparent
);
animation: mask-running 2s linear infinite;
}
}
.layerB {
position: absolute;
inset: 0;
&::before {
mix-blend-mode: var(--blend-mode);
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
left: -100%;
top: 0;
background: linear-gradient(
to right top,
transparent,
var(--color-2),
transparent,
var(--color-2),
transparent
);
animation: mask-running-reverse 3s linear infinite;
}
&::after {
mix-blend-mode: var(--blend-mode);
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
background: linear-gradient(
to right top,
transparent,
var(--color-2),
transparent,
var(--color-2),
transparent
);
animation: mask-running-reverse 3s linear infinite;
}
}
.layerC {
position: absolute;
inset: 0;
opacity: 0.5;
&::before {
mix-blend-mode: var(--blend-mode);
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
left: -100%;
top: 0;
background: linear-gradient(
to right top,
transparent,
var(--color-3),
transparent,
var(--color-3),
transparent
);
animation: mask-running 1s linear infinite;
}
&::after {
mix-blend-mode: var(--blend-mode);
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
background: linear-gradient(
to right top,
transparent,
var(--color-3),
transparent,
var(--color-3),
transparent
);
animation: mask-running 1s linear infinite;
}
}
-5
View File
@@ -1,5 +0,0 @@
This is the CSS implementation of ai-motion.
Easy to use but Terrible performance. Causing full screen glitching in some browsers.
Use it only in a small area.
+49 -21
View File
@@ -68,7 +68,7 @@ export class Panel {
this.#i18n = new I18n(config.language ?? 'en-US') this.#i18n = new I18n(config.language ?? 'en-US')
// Set up askUser callback on agent // Set up askUser callback on agent
this.#agent.onAskUser = (question) => this.#askUser(question) this.#agent.onAskUser = (question, options) => this.#askUser(question, options?.signal)
// Create UI elements // Create UI elements
this.#wrapper = this.#createWrapper() this.#wrapper = this.#createWrapper()
@@ -100,10 +100,10 @@ export class Panel {
#handleStatusChange(): void { #handleStatusChange(): void {
const status = this.#agent.status const status = this.#agent.status
// Map agent status to UI indicator type // Map agent status to UI indicator. A `completed` run whose result reports
const indicatorType = // failure shows as error; other statuses map to their own indicator.
status === 'running' ? 'thinking' : status === 'idle' ? 'thinking' : status const failed = status === 'completed' && this.#agent.lastResult?.success === false
this.#updateStatusIndicator(indicatorType) this.#updateStatusIndicator(failed ? 'error' : status)
// Morph action button: running = stop (■), not running = close (X) // Morph action button: running = stop (■), not running = close (X)
if (status === 'running') { if (status === 'running') {
@@ -121,7 +121,7 @@ export class Panel {
} }
// Handle completion // Handle completion
if (status === 'completed' || status === 'error') { if (status === 'completed' || status === 'error' || status === 'stopped') {
if (!this.#isExpanded) { if (!this.#isExpanded) {
this.#expand() this.#expand()
} }
@@ -169,10 +169,12 @@ export class Panel {
} }
/** /**
* Ask for user input (internal, called by agent via onAskUser) * Ask for user input (internal, called by agent via onAskUser).
* Rejects when `signal` aborts (task stopped or disposed), cleaning up the
* question card and pending state so the agent loop can settle.
*/ */
#askUser(question: string): Promise<string> { #askUser(question: string, signal?: AbortSignal): Promise<string> {
return new Promise((resolve) => { return new Promise((resolve, reject) => {
// Set `waiting for user answer` state // Set `waiting for user answer` state
this.#isWaitingForUserAnswer = true this.#isWaitingForUserAnswer = true
this.#userAnswerResolver = resolve this.#userAnswerResolver = resolve
@@ -195,6 +197,27 @@ export class Panel {
this.#scrollToBottom() this.#scrollToBottom()
this.#showInputArea(this.#i18n.t('ui.panel.userAnswerPrompt')) this.#showInputArea(this.#i18n.t('ui.panel.userAnswerPrompt'))
signal?.addEventListener(
'abort',
() => {
this.#removeTempCards()
this.#isWaitingForUserAnswer = false
this.#userAnswerResolver = null
// reason is a DOMException AbortError (abort() takes no args).
reject(signal.reason as DOMException)
},
{ once: true }
)
})
}
/** Remove temporary question cards (only direct children for safety) */
#removeTempCards(): void {
Array.from(this.#historySection.children).forEach((child) => {
if (child.getAttribute('data-temp-card') === 'true') {
child.remove()
}
}) })
} }
@@ -307,12 +330,7 @@ export class Panel {
* Handle user answer * Handle user answer
*/ */
#handleUserAnswer(input: string): void { #handleUserAnswer(input: string): void {
// Remove temporary question cards (only direct children for safety) this.#removeTempCards()
Array.from(this.#historySection.children).forEach((child) => {
if (child.getAttribute('data-temp-card') === 'true') {
child.remove()
}
})
// Reset state // Reset state
this.#isWaitingForUserAnswer = false this.#isWaitingForUserAnswer = false
@@ -358,7 +376,7 @@ export class Panel {
} }
const status = this.#agent.status const status = this.#agent.status
const isTaskEnded = status === 'completed' || status === 'error' const isTaskEnded = status === 'completed' || status === 'error' || status === 'stopped'
// Only show input area after task completion if configured to do so // Only show input area after task completion if configured to do so
if (isTaskEnded) { if (isTaskEnded) {
@@ -541,13 +559,23 @@ export class Panel {
} }
#updateStatusIndicator( #updateStatusIndicator(
type: 'thinking' | 'executing' | 'executed' | 'retrying' | 'completed' | 'error' type:
| 'idle'
| 'running'
| 'thinking'
| 'executing'
| 'executed'
| 'retrying'
| 'completed'
| 'error'
| 'stopped'
): void { ): void {
// Clear all status classes // `running` animates like thinking; `idle`/`stopped` use the neutral base.
const variant = type === 'running' ? 'thinking' : type
this.#indicator.className = styles.indicator this.#indicator.className = styles.indicator
if (variant !== 'idle' && variant !== 'stopped') {
// Add corresponding status class this.#indicator.classList.add(styles[variant])
this.#indicator.classList.add(styles[type]) }
} }
#scrollToBottom(): void { #scrollToBottom(): void {
+10 -5
View File
@@ -22,14 +22,17 @@ export type AgentActivity =
* This enables decoupling and allows any agent implementation to work with Panel. * This enables decoupling and allows any agent implementation to work with Panel.
* *
* Events: * Events:
* - 'statuschange': Agent status changed (idle/running/completed/error) * - 'statuschange': Agent status changed
* - 'historychange': Historical events updated (persisted) * - 'historychange': Historical events updated (persisted)
* - 'activity': Transient activity for immediate UI feedback (thinking/executing/etc) * - 'activity': Transient activity for immediate UI feedback (thinking/executing/etc)
* - 'dispose': Agent is being disposed * - 'dispose': Agent is being disposed
*/ */
export interface PanelAgentAdapter extends EventTarget { export interface PanelAgentAdapter extends EventTarget {
/** Current agent status */ /** Current agent status */
readonly status: 'idle' | 'running' | 'completed' | 'error' readonly status: 'idle' | 'running' | 'completed' | 'error' | 'stopped'
/** Result of the most recent run, or `null` before the first run completes */
readonly lastResult: { success: boolean } | null
/** History of agent events */ /** History of agent events */
readonly history: readonly { readonly history: readonly {
@@ -60,16 +63,18 @@ export interface PanelAgentAdapter extends EventTarget {
readonly task: string readonly task: string
/** /**
* Callback for when agent needs user input. * Called when the agent needs to ask the user questions.
* If unset, the `ask_user` tool will be disabled.
* Panel will set this to handle user questions via its UI. * Panel will set this to handle user questions via its UI.
* The optional `signal` aborts when the task is stopped or disposed.
*/ */
onAskUser?: (question: string) => Promise<string> onAskUser?: (question: string, options?: { signal: AbortSignal }) => Promise<string>
/** Execute a task */ /** Execute a task */
execute(task: string): Promise<unknown> execute(task: string): Promise<unknown>
/** Stop the current task (agent remains reusable) */ /** Stop the current task (agent remains reusable) */
stop(): void stop(): Promise<void>
/** Dispose the agent (terminal, cannot be reused) */ /** Dispose the agent (terminal, cannot be reused) */
dispose(): void dispose(): void
+2
View File
@@ -14,6 +14,8 @@ export default defineConfig({
clearScreen: false, clearScreen: false,
plugins: [ plugins: [
dts({ dts({
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts'],
bundleTypes: true, bundleTypes: true,
compilerOptions: { compilerOptions: {
composite: true, composite: true,
+5 -3
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link <link
rel="icon" rel="icon"
type="image/svg+xml" type="image/webp"
href="https://img.alicdn.com/imgextra/i2/O1CN012eGDRI1X6nnMt9clU_!!6000000002875-49-tps-64-64.webp" href="https://page-agent.github.io/assets/brand/page-agent-color-64.webp"
/> />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PageAgent - The GUI Agent Living in Your Webpage</title> <title>PageAgent - The GUI Agent Living in Your Webpage</title>
@@ -19,7 +19,7 @@
/> />
<meta <meta
property="og:image" property="og:image"
content="https://img.alicdn.com/imgextra/i3/O1CN01JPT4Fj1FJTfmHfNxO_!!6000000000466-49-tps-512-512.webp" content="https://page-agent.github.io/assets/brand/page-agent-color-512.webp"
/> />
<meta property="og:url" content="https://alibaba.github.io/page-agent" /> <meta property="og:url" content="https://alibaba.github.io/page-agent" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
@@ -33,6 +33,7 @@
<meta property="og:locale:alternate" content="zh_CN" /> <meta property="og:locale:alternate" content="zh_CN" />
<!-- Google tag (gtag.js) --> <!-- Google tag (gtag.js) -->
<!-- cspell:disable -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-HCGRJTN3HM"></script> <script async src="https://www.googletagmanager.com/gtag/js?id=G-HCGRJTN3HM"></script>
<script> <script>
window.dataLayer = window.dataLayer || [] window.dataLayer = window.dataLayer || []
@@ -43,6 +44,7 @@
gtag('config', 'G-HCGRJTN3HM') gtag('config', 'G-HCGRJTN3HM')
</script> </script>
<!-- cspell:enable -->
</head> </head>
<body> <body>
<div id="root"> <div id="root">
+13 -13
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/website", "name": "@page-agent/website",
"private": true, "private": true,
"version": "1.8.2", "version": "1.11.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
@@ -10,25 +10,25 @@
}, },
"devDependencies": { "devDependencies": {
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.3.2",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.11",
"@types/react": "^19.2.14", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.14.0", "lucide-react": "^1.22.0",
"motion": "^12.38.0", "motion": "^12.42.2",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.6", "react": "^19.2.7",
"react-dom": "^19.2.6", "react-dom": "^19.2.7",
"rough-notation": "^0.5.1", "rough-notation": "^0.5.1",
"simple-icons": "^16.19.0", "simple-icons": "^16.24.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.2",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"wouter": "^3.9.0" "wouter": "^3.10.0"
} }
} }
+1 -1
View File
@@ -31,7 +31,7 @@ export default function Header() {
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
> >
<img <img
src="https://img.alicdn.com/imgextra/i2/O1CN01HB8ylu1uozANEMZw2_!!6000000006085-49-tps-128-128.webp" src="https://page-agent.github.io/assets/brand/page-agent-color-128.webp"
alt="PageAgent Logo" alt="PageAgent Logo"
className="w-10 h-10 rounded-xl group-hover:scale-110 transition-transform duration-200" className="w-10 h-10 rounded-xl group-hover:scale-110 transition-transform duration-200"
/> />
+2 -2
View File
@@ -1,8 +1,8 @@
// Demo build (auto-init with demo LLM, for quick testing) // Demo build (auto-init with demo LLM, for quick testing)
export const CDN_DEMO_URL = export const CDN_DEMO_URL =
'https://cdn.jsdelivr.net/npm/page-agent@1.8.2/dist/iife/page-agent.demo.js' 'https://cdn.jsdelivr.net/npm/page-agent@1.11.0/dist/iife/page-agent.demo.js'
export const CDN_DEMO_CN_URL = export const CDN_DEMO_CN_URL =
'https://registry.npmmirror.com/page-agent/1.8.2/files/dist/iife/page-agent.demo.js' 'https://registry.npmmirror.com/page-agent/1.11.0/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'
+3 -1
View File
@@ -9,13 +9,15 @@ export function useGitHubStars() {
useEffect(() => { useEffect(() => {
if (cached !== null) return if (cached !== null) return
fetch(STATS_URL) const controller = new AbortController()
fetch(STATS_URL, { signal: controller.signal })
.then((r) => r.json()) .then((r) => r.json())
.then((data) => { .then((data) => {
cached = data.stargazers_count ?? null cached = data.stargazers_count ?? null
setStars(cached) setStars(cached)
}) })
.catch(() => {}) .catch(() => {})
return () => controller.abort()
}, []) }, [])
return stars return stars
+2 -1
View File
@@ -22,7 +22,8 @@ interface NavSection {
export default function DocsLayout({ children }: DocsLayoutProps) { export default function DocsLayout({ children }: DocsLayoutProps) {
const { isZh } = useLanguage() const { isZh } = useLanguage()
const [location] = useLocation() const [rawLocation] = useLocation()
const location = rawLocation.replace(/\/+$/, '')
const navigationSections: NavSection[] = [ const navigationSections: NavSection[] = [
{ {
@@ -128,8 +128,8 @@ export default function CustomUIDocs() {
name: 'statuschange', name: 'statuschange',
type: 'Event', type: 'Event',
description: isZh description: isZh
? 'Agent 状态变化 (idle → running → completed/error)' ? 'Agent 状态变化 (idle → running → completed/error/stopped)'
: 'Agent status changes (idle → running → completed/error)', : 'Agent status changes (idle → running → completed/error/stopped)',
}, },
{ {
name: 'historychange', name: 'historychange',
@@ -257,7 +257,8 @@ const root = createRoot(document.getElementById('my-ui')!)
root.render(<MyAgentUI agent={agent} />) root.render(<MyAgentUI agent={agent} />)
// 4. Handle user input (optional) // 4. Handle user input (optional)
agent.onAskUser = async (question) => window.prompt(question) || '' // options.signal aborts when the task is stopped or disposed
agent.onAskUser = async (question, options) => window.prompt(question) || ''
// 5. Execute task // 5. Execute task
await agent.execute('Fill the form with test data') await agent.execute('Fill the form with test data')
@@ -143,13 +143,6 @@ const result = await agent.execute('Fill in the form with test data')`}
required: false, required: false,
description: 'LLM AK', description: 'LLM AK',
}, },
{
name: 'temperature',
type: 'number',
description: isZh
? '模型温度参数,控制输出随机性'
: 'Model temperature, controls output randomness',
},
{ {
name: 'maxRetries', name: 'maxRetries',
type: 'number', type: 'number',
@@ -281,8 +274,8 @@ const result = await agent.execute('Fill in the form with test data')`}
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-4"> <div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-4">
<p className="text-amber-800 dark:text-amber-200 text-sm"> <p className="text-amber-800 dark:text-amber-200 text-sm">
{isZh {isZh
? '这些接口高度实验性,可能在未来版本中发生变化。' ? '这些接口高度实验性,可能在未来版本中发生变化。钩子中抛出的错误会使任务失败并从 execute() 抛出;如不希望影响任务,请在钩子内部自行捕获。'
: 'These APIs are highly experimental and may change in future versions. '} : 'These APIs are highly experimental and may change in future versions. Errors thrown from hooks fail the run and propagate from execute(); catch errors inside the hook if the task should not be affected.'}
</p> </p>
</div> </div>
<APIReference <APIReference
@@ -325,7 +318,7 @@ const result = await agent.execute('Fill in the form with test data')`}
properties={[ properties={[
{ {
name: 'status', name: 'status',
type: "'idle' | 'running' | 'completed' | 'error'", type: "'idle' | 'running' | 'completed' | 'error' | 'stopped'",
description: isZh ? '当前 Agent 执行状态' : 'Current agent execution status', description: isZh ? '当前 Agent 执行状态' : 'Current agent execution status',
}, },
{ {
@@ -354,10 +347,10 @@ const result = await agent.execute('Fill in the form with test data')`}
}, },
{ {
name: 'onAskUser', name: 'onAskUser',
type: '(question: string) => Promise<string>', type: '(question: string, options?: { signal: AbortSignal }) => Promise<string>',
description: isZh description: isZh
? 'Agent 需要用户输入时的回调。未设置则禁用 ask_user 工具。' ? '当 agent 需要用户提问时调用。未设置则禁用 `ask_user` 工具。实现应在 options.signal 触发 abort 时 reject promise。'
: 'Callback when agent needs user input. If not set, ask_user tool is disabled.', : 'Called when the agent needs to ask the user questions. If unset, the `ask_user` tool will be disabled. Implementations should reject the promise when options.signal aborts.',
}, },
]} ]}
/> />
@@ -373,15 +366,15 @@ const result = await agent.execute('Fill in the form with test data')`}
name: 'execute(task)', name: 'execute(task)',
type: 'Promise<ExecutionResult>', type: 'Promise<ExecutionResult>',
description: isZh description: isZh
? '执行任务并返回结果包含 success、data 和 history 字段。' ? '执行任务并返回结果包含 success、data 和 history 字段)。若已有任务在运行则抛出错误——不支持并发执行。'
: 'Execute a task and return result. Contains success, data, and history fields.', : 'Execute a task and return result (contains success, data, and history fields). Throws if a task is already running — concurrent execution is not supported.',
}, },
{ {
name: 'stop()', name: 'stop()',
type: 'void', type: 'Promise<void>',
description: isZh description: isZh
? '停止当前任务。Agent 仍可复用。' ? '停止当前任务,并在任务完全结束后 resolve。Agent 仍可复用。'
: 'Stop the current task. Agent remains reusable.', : 'Stop the current task; resolves once the run has fully settled. Agent remains reusable.',
}, },
{ {
name: 'dispose()', name: 'dispose()',
@@ -415,8 +408,8 @@ const result = await agent.execute('Fill in the form with test data')`}
name: 'statuschange', name: 'statuschange',
type: 'Event', type: 'Event',
description: isZh description: isZh
? 'Agent 状态变化时触发 (idle → running → completed/error)' ? 'Agent 状态变化时触发 (idle → running → completed/error/stopped)'
: 'Fired when agent status changes (idle → running → completed/error)', : 'Fired when agent status changes (idle → running → completed/error/stopped)',
}, },
{ {
name: 'historychange', name: 'historychange',
@@ -38,8 +38,8 @@ import { z } from 'zod/v4'`}
</Heading> </Heading>
<p className="text-gray-600 dark:text-gray-300 mb-4"> <p className="text-gray-600 dark:text-gray-300 mb-4">
{isZh {isZh
? '使用 tool() 辅助函数定义自定义工具,每个工具包含 description、inputSchema 和 execute 三个属性。' ? '使用 tool() 辅助函数定义自定义工具,每个工具包含 description、inputSchema 和 execute 三个属性。异步工具必须 honor ctx.signal。'
: 'Use the tool() helper to define custom tools with description, inputSchema, and execute.'} : 'Use the tool() helper to define custom tools with description, inputSchema, and execute. Async tools must honor ctx.signal.'}
</p> </p>
<CodeEditor <CodeEditor
@@ -56,10 +56,11 @@ const pageAgent = new PageAgent({
productId: z.string(), productId: z.string(),
quantity: z.number().min(1).default(1), quantity: z.number().min(1).default(1),
}), }),
execute: async function (input) { execute: async function (input, { signal }) {
await fetch('/api/cart', { await fetch('/api/cart', {
method: 'POST', method: 'POST',
body: JSON.stringify(input), body: JSON.stringify(input),
signal, // honor cancellation
}) })
return \`Added \${input.quantity}x \${input.productId} to cart.\` return \`Added \${input.quantity}x \${input.productId} to cart.\`
}, },
@@ -72,9 +73,10 @@ const pageAgent = new PageAgent({
query: z.string(), query: z.string(),
limit: z.number().max(10).default(3), limit: z.number().max(10).default(3),
}), }),
execute: async function (input) { execute: async function (input, { signal }) {
const res = await fetch( const res = await fetch(
\`/api/kb?q=\${encodeURIComponent(input.query)}&limit=\${input.limit}\` \`/api/kb?q=\${encodeURIComponent(input.query)}&limit=\${input.limit}\`,
{ signal }
) )
const articles = await res.json() const articles = await res.json()
return JSON.stringify(articles) return JSON.stringify(articles)
@@ -6,10 +6,10 @@ import { Heading } from '@/components/Heading'
import { useLanguage } from '@/i18n/context' import { useLanguage } from '@/i18n/context'
const BASELINE = new Set([ const BASELINE = new Set([
'gpt-5.1',
'gpt-5.4-mini', 'gpt-5.4-mini',
'claude-haiku-4.5', 'gpt-5.4-nano',
'gemini-3.1-flash-lite', 'claude-haiku-4-5',
'gemini-3.5-flash',
'deepseek-v4-flash', 'deepseek-v4-flash',
'qwen3.5-plus', 'qwen3.5-plus',
'qwen3.5-flash', 'qwen3.5-flash',
@@ -18,14 +18,15 @@ 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.7-max',
'qwen3.7-plus',
'qwen3.6-max', 'qwen3.6-max',
'qwen3.6-plus', 'qwen3.6-plus',
'qwen3.6-flash', 'qwen3.6-flash',
'qwen3.5-plus', 'qwen3.5-plus',
'qwen3.5-flash', 'qwen3.5-flash',
'qwen3-coder-next', 'qwen3-max',
'qwen-3-max', // 'qwen3-coder-next', // low success rate
'qwen-3-plus',
], ],
OpenAI: [ OpenAI: [
'gpt-5.5', 'gpt-5.5',
@@ -40,19 +41,31 @@ const MODEL_GROUPS: Record<string, string[]> = {
'gpt-4.1-mini', 'gpt-4.1-mini',
], ],
DeepSeek: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-3.2'], DeepSeek: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-3.2'],
Google: ['gemini-3.1-flash-lite', 'gemini-3-pro', 'gemini-3-flash', 'gemini-2.5'], Google: [
Anthropic: [ 'gemini-3.5-flash',
'claude-opus-4.7', 'gemini-3.1-pro',
'claude-opus-4.6', 'gemini-3.1-flash-lite',
'claude-opus-4.5', 'gemini-2.5-pro',
'claude-sonnet-4.5', 'gemini-2.5-flash',
'claude-haiku-4.5',
'claude-sonnet-3.5',
], ],
MiniMax: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'], Anthropic: [
xAI: ['grok-4.1-fast', 'grok-4', 'grok-code-fast'], 'claude-sonnet-5',
MoonshotAI: ['kimi-k2.5'], 'claude-fable-5',
'Z.AI': ['glm-5', 'glm-4.7'], 'claude-opus-4-8',
'claude-opus-4-7',
'claude-opus-4-6',
'claude-opus-4-5',
'claude-sonnet-4-5',
'claude-haiku-4-5',
],
MiniMax: [
// 'MiniMax-M3', low success rate
'MiniMax-M2.7',
'MiniMax-M2.5',
],
xAI: ['grok-4.3', 'grok-build-0.1'],
MoonshotAI: ['kimi-k2.7-code', 'kimi-k2.6', 'kimi-k2.5'],
'Z.AI': ['glm-5.2', 'glm-5.1', 'glm-5', 'glm-4.7'],
} }
const ModelBadge = ({ model, baseline }: { model: string; baseline?: boolean }) => ( const ModelBadge = ({ model, baseline }: { model: string; baseline?: boolean }) => (
@@ -82,7 +95,7 @@ export default function Models() {
<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 ? '模型列表' : 'Model List'}
</Heading> </Heading>
<div className="bg-linear-to-br from-emerald-50 to-cyan-50 dark:from-emerald-950/30 dark:to-cyan-950/30 rounded-xl p-6 border border-emerald-200/50 dark:border-emerald-800/50"> <div className="bg-linear-to-br from-emerald-50 to-cyan-50 dark:from-emerald-950/30 dark:to-cyan-950/30 rounded-xl p-6 border border-emerald-200/50 dark:border-emerald-800/50">
<div className="grid grid-cols-[5rem_1fr] gap-x-3 gap-y-3 items-start"> <div className="grid grid-cols-[5rem_1fr] gap-x-3 gap-y-3 items-start">
@@ -115,8 +128,8 @@ export default function Models() {
</li> </li>
<li> <li>
{isZh {isZh
? 'ToolCall 能力较弱的模型可能返回错误的格式,常见错误能够自动恢复,建议设置较高的 temperature' ? 'ToolCall 能力较弱的模型可能返回错误的格式,常见错误能够自动恢复'
: 'Models with weaker ToolCall capabilities may return incorrect formats. Common errors usually auto-recover. Higher temperature recommended'} : 'Models with weaker ToolCall capabilities may return incorrect formats. Common errors usually auto-recover'}
</li> </li>
<li> <li>
{isZh {isZh
@@ -272,7 +285,7 @@ LLM_MODEL_NAME="qwen3.5-plus"`}
code={`const pageAgent = new PageAgent({ code={`const pageAgent = new PageAgent({
baseURL: 'https://your-claude-proxy.example/v1', baseURL: 'https://your-claude-proxy.example/v1',
apiKey: 'your-api-key', apiKey: 'your-api-key',
model: 'claude-sonnet-4.5', model: 'claude-sonnet-5',
transformRequestBody: (requestBody) => ({ transformRequestBody: (requestBody) => ({
...requestBody, ...requestBody,
cache_control: { type: 'ephemeral' }, cache_control: { type: 'ephemeral' },
@@ -17,11 +17,17 @@ import { useLanguage } from '../../i18n/context'
let pageAgentModule: Promise<typeof import('page-agent')> | null = null let pageAgentModule: Promise<typeof import('page-agent')> | null = null
function getInjection(useCN?: boolean) { /**
const cdn = useCN ? CDN_DEMO_CN_URL : CDN_DEMO_URL * Get the bookmarklet injection script
* @param cdnSource Which CDN mirror to use
* @param isZh Whether to use Chinese language
*/
function getInjection(cdnSource: 'china' | 'international', isZh?: boolean) {
const cdn = cdnSource === 'china' ? CDN_DEMO_CN_URL : CDN_DEMO_URL
const locale = isZh ? 'zh-CN' : 'en-US'
const injection = encodeURI( const injection = encodeURI(
`javascript:(function(){var s=document.createElement('script');s.src=\`${cdn}?t=\${Math.random()}\`;s.setAttribute('crossorigin', true);s.type="text/javascript";s.onload=()=>console.log('PageAgent script loaded!');document.body.appendChild(s);})();` `javascript:(function(){var s=document.createElement('script');s.src=\`${cdn}?lang=${locale}&t=\${Math.random()}\`;s.setAttribute('crossorigin', true);s.type="text/javascript";s.onload=()=>console.log('PageAgent script loaded!');document.body.appendChild(s);})();`
) )
return ` return `
@@ -298,7 +304,7 @@ export default function HeroSection() {
</select> </select>
<div <div
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: getInjection(cdnSource === 'china'), __html: getInjection(cdnSource, isZh),
}} }}
></div> ></div>
</div> </div>
@@ -40,7 +40,7 @@ export default function OneMoreThingSection() {
className="group inline-flex items-center gap-3 px-8 py-4 bg-linear-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-medium rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105" className="group inline-flex items-center gap-3 px-8 py-4 bg-linear-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-medium rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105"
> >
<img <img
src="https://img.alicdn.com/imgextra/i3/O1CN01JpW0Vo1sR3FpiZKFM_!!6000000005762-55-tps-192-192.svg" src="https://page-agent.github.io/assets/third-party/chrome-web-store-192.svg"
alt="Chrome Web Store" alt="Chrome Web Store"
className="w-7 h-7" className="w-7 h-7"
/> />
+3 -2
View File
@@ -36,13 +36,14 @@ if (isMainBranch()) {
run('commitlint', `npx commitlint --from ${from} --to HEAD`) run('commitlint', `npx commitlint --from ${from} --to HEAD`)
} }
// 2. Lint + Format + Typecheck in parallel // 2. Lint + Format + Typecheck + Test in parallel
console.log(chalk.bgBlue.white.bold(' ▸ lint + format + typecheck ')) console.log(chalk.bgBlue.white.bold(' ▸ lint + format + typecheck + test '))
await parallelTask( await parallelTask(
[ [
{ label: 'lint', command: 'npm run lint' }, { label: 'lint', command: 'npm run lint' },
{ label: 'format', command: 'npx prettier --check .' }, { label: 'format', command: 'npx prettier --check .' },
{ label: 'typecheck', command: 'npm run typecheck' }, { label: 'typecheck', command: 'npm run typecheck' },
{ label: 'test', command: 'npm test' },
], ],
{ timeoutMs: 120_000 } { timeoutMs: 120_000 }
) )