Compare commits

...

167 Commits

Author SHA1 Message Date
Simon 3e7851849f chore(version): bump version to 1.3.0 2026-02-13 18:50:32 +08:00
Simon dffcb53db9 feat!: Refine lifecycle hooks; fix abortSignal
- add `stop` method. agent can be reused after stopped
- agent can not be reused after disposed
- extension DO NOT exposes `dispose` anymore. only `stop`.
- update panel for new `stop` method
- fix MultiPageAgent dispose event
- better handling abortSignal
2026-02-13 17:57:12 +08:00
Simon 4dc332a32c docs: update changelog 2026-02-12 17:23:35 +08:00
Simon f19b3cc2cc docs: update extension related docs 2026-02-12 17:19:14 +08:00
Simon 11d66f42c4 chore(ext): version 0.1.5 2026-02-11 20:12:57 +08:00
Simon 20e947a1c1 chore(version): bump version to 1.2.0 2026-02-11 20:09:23 +08:00
Simon 7c87c90258 fix(ext): fix multi-thread logic; extensive logging and error handling 2026-02-11 19:51:19 +08:00
Simon fcb9ec4e57 chore: clean up 2026-02-11 19:27:14 +08:00
Simon 1fcf931685 feat(core): add a observe phase in a step; improve abortSignal 2026-02-10 22:20:42 +08:00
Simon c71ffcec1c refactor(ext): tidy up background code 2026-02-10 21:59:58 +08:00
Simon 430be466fc feat(ext): add chat history 2026-02-10 18:43:54 +08:00
Simon 3f284cf2e6 chore(controller): remove link hint 2026-02-10 17:19:02 +08:00
Simon 6f0655478a fix(core): remove trimLines to fix indentation lost 2026-02-10 16:30:03 +08:00
Simon 7d5449967b feat(core): improve observation logic; mark states as private 2026-02-10 16:28:25 +08:00
Simon fd47888d1b chore(core): tidy up PageAgentCore members 2026-02-10 15:45:04 +08:00
Simon 10f470ce7d chore: improve package.json 2026-02-10 15:28:50 +08:00
Simon cb29db1cc4 Merge pull request #138 from alibaba/dependabot/npm_and_yarn/production-dependencies-58bd30f460
chore(deps): bump the production-dependencies group with 2 updates
2026-02-10 14:21:11 +08:00
Simon 8c08f639dd Merge pull request #139 from alibaba/dependabot/npm_and_yarn/development-dependencies-dee6027aab
chore(deps-dev): bump the development-dependencies group with 8 updates
2026-02-10 14:18:18 +08:00
dependabot[bot] 488f080e68 chore(deps-dev): bump the development-dependencies group with 8 updates
Bumps the development-dependencies group with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [@microsoft/api-extractor](https://github.com/microsoft/rushstack/tree/HEAD/apps/api-extractor) | `7.56.0` | `7.56.3` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.2.0` | `25.2.2` |
| [dotenv](https://github.com/motdotla/dotenv) | `17.2.3` | `17.2.4` |
| [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/HEAD/packages/plugins/eslint-plugin-react-dom) | `2.9.3` | `2.12.2` |
| [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/HEAD/packages/plugins/eslint-plugin-react-x) | `2.9.3` | `2.12.2` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.54.0` | `8.55.0` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.10` | `19.2.13` |
| [wxt](https://github.com/wxt-dev/wxt) | `0.20.13` | `0.20.14` |


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

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

Updates `dotenv` from 17.2.3 to 17.2.4
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/commits)

Updates `eslint-plugin-react-dom` from 2.9.3 to 2.12.2
- [Release notes](https://github.com/Rel1cx/eslint-react/releases)
- [Changelog](https://github.com/Rel1cx/eslint-react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Rel1cx/eslint-react/commits/v2.12.2/packages/plugins/eslint-plugin-react-dom)

Updates `eslint-plugin-react-x` from 2.9.3 to 2.12.2
- [Release notes](https://github.com/Rel1cx/eslint-react/releases)
- [Changelog](https://github.com/Rel1cx/eslint-react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Rel1cx/eslint-react/commits/v2.12.2/packages/plugins/eslint-plugin-react-x)

Updates `typescript-eslint` from 8.54.0 to 8.55.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.55.0/packages/typescript-eslint)

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

Updates `wxt` from 0.20.13 to 0.20.14
- [Release notes](https://github.com/wxt-dev/wxt/releases)
- [Commits](https://github.com/wxt-dev/wxt/compare/wxt-v0.20.13...wxt-v0.20.14)

---
updated-dependencies:
- dependency-name: "@microsoft/api-extractor"
  dependency-version: 7.56.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@types/node"
  dependency-version: 25.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: dotenv
  dependency-version: 17.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: eslint-plugin-react-dom
  dependency-version: 2.12.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: eslint-plugin-react-x
  dependency-version: 2.12.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.55.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: "@types/react"
  dependency-version: 19.2.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: wxt
  dependency-version: 0.20.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-09 22:25:35 +00:00
dependabot[bot] 6e07af422b chore(deps): bump the production-dependencies group with 2 updates
Bumps the production-dependencies group with 2 updates: [motion](https://github.com/motiondivision/motion) and [simple-icons](https://github.com/simple-icons/simple-icons).


Updates `motion` from 12.30.0 to 12.34.0
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.30.0...v12.34.0)

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

---
updated-dependencies:
- dependency-name: motion
  dependency-version: 12.34.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: simple-icons
  dependency-version: 16.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-09 22:23:28 +00:00
Simon e9d721f6b9 chore(ext): bump extension version to 0.1.4 2026-02-09 21:12:11 +08:00
Simon 840162df8c feat: change observation timing 2026-02-09 21:05:29 +08:00
Simon 1517bc4868 feat: prune prompts 2026-02-09 21:04:39 +08:00
Simon 67f6a56a87 chore(version): bump version to 1.1.2 2026-02-09 17:50:34 +08:00
Simon 6f0ff1fd33 refactor: zod tree-shaking; better error handling in agent steps 2026-02-09 17:49:10 +08:00
Simon 6fdb8d03f2 docs: trouble shooting 2026-02-09 16:32:58 +08:00
Simon d00a8dcc21 feat: improve prompt; update model list 2026-02-09 15:50:20 +08:00
Simon 851e9ef158 docs: model list 2026-02-06 18:49:06 +08:00
Simon 8ea8ce9acd fix(llms): gpt-5-mini temperature 2026-02-06 17:19:06 +08:00
Simon 9bd4a47d35 feat(ext): add includeInitialTab option; change main world API 2026-02-04 19:22:06 +08:00
Simon 71ca554108 feat(ext): use PAGE_AGENT_EXT namespace; add viber instructions 2026-02-03 19:09:37 +08:00
Simon 648a0c1bda feat(ext): expose ext meta data to main world 2026-02-03 16:39:24 +08:00
Simon 1a0422f99d Merge pull request #135 from alibaba/dependabot/npm_and_yarn/development-dependencies-ad640cb911
chore(deps-dev): bump eslint-plugin-react-refresh from 0.4.26 to 0.5.0 in the development-dependencies group across 1 directory
2026-02-03 14:20:04 +08:00
dependabot[bot] 2f72c677a0 chore(deps-dev): bump eslint-plugin-react-refresh
Bumps the development-dependencies group with 1 update in the / directory: [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh).


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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 06:00:00 +00:00
Simon 18019d390a Merge pull request #132 from alibaba/dependabot/npm_and_yarn/production-dependencies-8604a495b2
chore(deps): bump the production-dependencies group with 2 updates
2026-02-03 13:48:12 +08:00
Simon bb3605b3df chore: clean up dep 2026-02-03 13:39:47 +08:00
Simon 2b3bb8ab9e chore(ext): rm React.FormEvent 2026-02-03 13:18:03 +08:00
dependabot[bot] 7a09bc559d chore(deps): bump the production-dependencies group with 2 updates
Bumps the production-dependencies group with 2 updates: [motion](https://github.com/motiondivision/motion) and [simple-icons](https://github.com/simple-icons/simple-icons).


Updates `motion` from 12.29.2 to 12.30.0
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.29.2...v12.30.0)

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

---
updated-dependencies:
- dependency-name: motion
  dependency-version: 12.30.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: simple-icons
  dependency-version: 16.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 01:15:58 +00:00
Simon a834d37f28 fix(ext): inject versions 2026-02-02 21:11:23 +08:00
Simon 0c3be817c9 style(ext): attribution; dark mode 2026-02-02 21:07:31 +08:00
Simon b8999fdb7c style(ext): improve config panel 2026-02-02 19:51:48 +08:00
Simon 9ba95e96b6 feat(ext): detect user lang 2026-02-02 19:29:30 +08:00
Simon 4459026297 feat: set default lang to english; fix ext lang setting 2026-02-02 19:17:15 +08:00
Simon 98d3016395 style(ext): remove raw section from ResultCard 2026-02-02 19:16:07 +08:00
Simon 15ea488719 fix(ext): save config to storage 2026-02-02 18:48:16 +08:00
Simon 5b2a11c0b2 chore(version): bump version to 1.1.0 2026-02-02 17:28:40 +08:00
Simon c5c6340eb6 chore: ext version 2026-02-02 17:27:58 +08:00
Simon 74aea50875 feat: rename include_attributes to includeAttributes 2026-02-02 17:24:08 +08:00
Simon f86bb03745 Merge branch 'main' of https://github.com/alibaba/page-agent 2026-02-02 16:53:45 +08:00
Simon f6b4447ef7 docs: tips about Ollama 2026-02-02 16:51:51 +08:00
Simon d8ec4cf1ab feat: better action key in prompt example 2026-02-02 13:39:04 +08:00
Simon da440f63c3 chore(version): bump version to 1.0.1 2026-01-30 16:59:11 +08:00
Simon 622fbff790 docs: simplify layout; rm useless CDN-setup 2026-01-30 16:54:31 +08:00
Simon 68c944c3a4 docs: remove i18n dep 2026-01-30 16:47:23 +08:00
Simon 6cd3e3726c docs: typography 2026-01-30 16:34:27 +08:00
Simon d11a2593b3 docs: pretty chrome extension entry 2026-01-30 15:19:54 +08:00
Simon 5e936c35d7 docs: simplify i18n 2026-01-30 15:13:31 +08:00
Simon 97cdb1e1cd docs: Add Custom UI doc 2026-01-30 14:58:33 +08:00
Simon 7470c1987b chore(ext): update extension version 2026-01-29 22:34:15 +08:00
Simon b767f10a85 feat(ext): extending execute api 2026-01-29 22:26:31 +08:00
Simon 8a03391c95 fix(core): change emitActivity to #emitActivity 2026-01-29 21:15:24 +08:00
Simon 5cfaa292d3 feat(ext): add agent heart beat check 2026-01-29 19:21:57 +08:00
Simon 4e87940127 fix(ext): update message handlers
> If multiple listeners are registered for onMessage,
> only the first listener to respond, reject, or throw
> an error will affect the sender; all other listeners
> will run, but their results will be ignored.
2026-01-29 18:37:42 +08:00
Simon 5d77990187 feat(docs): add Chrome extension documentation 2026-01-28 23:27:11 +08:00
Simon b0e07d5180 feat(ext): monitor to tab change 2026-01-28 23:03:03 +08:00
Simon cf4eeb9481 fix(docs): update extension reference in README 2026-01-28 20:48:22 +08:00
Simon d67ca95925 chore(ext): build extension 0.1.0-beta.1 2026-01-28 20:35:42 +08:00
Simon f67ec5a2c2 feat(ext): require llmConfig when calling from main world 2026-01-28 20:28:21 +08:00
Simon 0f3ae38cb3 fix(ConfigPanel): correct auth token storage key 2026-01-28 20:19:16 +08:00
Simon d6323f1488 feat(ext): improve token validation and error handling 2026-01-28 19:59:55 +08:00
Simon ef2d115742 feat(ext): expose extension to main-world and auth with token 2026-01-28 19:47:15 +08:00
Simon ff75aebc20 feat(core): remove unused .disposed 2026-01-28 19:17:23 +08:00
Simon 04d51c566d feat: delay mask import 2026-01-28 17:16:39 +08:00
Simon 142231a16c fix(PageController): use local svg for cursor mask to fix CSP errors 2026-01-28 17:04:54 +08:00
Simon d712ea7041 chore(scripts): skip version update for extension package 2026-01-28 15:43:54 +08:00
Simon 9d37a452e4 feat(ext): improve tabs sum 2026-01-28 15:38:42 +08:00
Simon 94ae80ff6a chore: move title line from Agent to PageController 2026-01-28 15:38:05 +08:00
Simon d7b17facb6 feat(ext): UI for debugging 2026-01-28 15:26:11 +08:00
Simon 501411b254 feat: improve date format and error message for llm 2026-01-28 15:24:08 +08:00
Simon f6394a04b1 feat(core): add rawRequest in history for debug 2026-01-28 14:30:04 +08:00
Simon c80de93d3b fix(ext): sw calling robust 2026-01-28 14:13:28 +08:00
Simon 0cad8a1159 feat(ext): use full task for tab group name 2026-01-28 14:12:30 +08:00
Simon 77a771f0b6 chore(ext): react misc 2026-01-28 14:11:44 +08:00
Simon dd593f77e9 chore(ext): refine MultiPageAgent api and clean up 2026-01-28 13:36:46 +08:00
Simon 55ffbb8f08 refactor(ext): improve folder structure 2026-01-28 13:15:34 +08:00
Simon e425a3ed9f fix(ext): wait for new tab to load 2026-01-27 21:49:06 +08:00
Simon 87c3fe36d2 feat(ext): new prompt 2026-01-27 21:45:51 +08:00
Simon d64e144ab4 chore(ext): clean up package.json; rebuild lock 2026-01-27 18:47:32 +08:00
Simon 5c3e3d1066 Merge pull request #127 from alibaba/dependabot/npm_and_yarn/production-dependencies-e7b19cf5cb
chore(deps): bump the production-dependencies group across 1 directory with 2 updates
2026-01-27 18:39:21 +08:00
Simon a243f158d7 Merge pull request #128 from alibaba/dependabot/npm_and_yarn/development-dependencies-7b2f699698
chore(deps-dev): bump the development-dependencies group across 1 directory with 3 updates
2026-01-27 18:39:02 +08:00
Simon 96a51d3674 fix(ext): prevent submit on composing state 2026-01-27 17:47:56 +08:00
dependabot[bot] 06f2f8be6c chore(deps-dev): bump the development-dependencies group across 1 directory with 3 updates
Bumps the development-dependencies group with 3 updates in the / directory: [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint), [react](https://github.com/facebook/react/tree/HEAD/packages/react) and [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom).


Updates `typescript-eslint` from 8.53.1 to 8.54.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.54.0/packages/typescript-eslint)

Updates `react` from 19.2.3 to 19.2.4
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.4/packages/react)

Updates `react-dom` from 19.2.3 to 19.2.4
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.4/packages/react-dom)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.54.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: react
  dependency-version: 19.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: react-dom
  dependency-version: 19.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-27 09:44:01 +00:00
dependabot[bot] bb68d218cc chore(deps): bump the production-dependencies group across 1 directory with 2 updates
Bumps the production-dependencies group with 2 updates in the / directory: [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) and [motion](https://github.com/motiondivision/motion).


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

Updates `motion` from 12.29.0 to 12.29.2
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.29.0...v12.29.2)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 0.563.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: motion
  dependency-version: 12.29.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-27 09:42:47 +00:00
Simon 6ff2a032d6 Merge pull request #122 from alibaba/feat/ext
[WIP] feat: the browser extension
2026-01-27 17:41:10 +08:00
Simon fdc3cf4e6d feat(ext): handcraft the whole ext from scratch
AI coding doesn't work for MV3 extensions.
Threading was an unfixable mess.
Removed everything and rebuilt by hand.
2026-01-27 17:21:32 +08:00
Simon 8efa8e18c1 refactor(ext): rewrite ext. totally-broken -> still-broken; THIS IS NOT WORKING 2026-01-26 21:03:51 +08:00
Simon cdecf3cc3d refactor(ext): mv files 2026-01-26 19:33:57 +08:00
Simon 89d6835a99 chore(ext): highlight cleanup with mask 2026-01-26 19:25:00 +08:00
Simon 3bf616dde1 fix: remove ensureResponseListener; remove isPageAgentMessage 2026-01-26 19:17:52 +08:00
Simon f18b756390 chore: add isPageAgentMessage to message 2026-01-26 18:55:54 +08:00
Simon 969dd31db5 chore: update AGENTS.md for error handling 2026-01-26 16:27:20 +08:00
Simon c4bcad04bd chore(ext): debug logging 2026-01-26 16:16:04 +08:00
Simon 4f170b674e fix: rebuild lock 2026-01-26 15:32:51 +08:00
Simon 5826ff47cd Merge branch 'main' into feat/ext 2026-01-26 15:26:08 +08:00
Simon 0bac51b67b chore: typo 2026-01-26 15:25:56 +08:00
Simon 8a7b9b39ce Merge branch 'main' into feat/ext 2026-01-26 15:22:12 +08:00
Simon 3cfc830d14 feat: ignore alias to AGENTS.md 2026-01-26 15:21:48 +08:00
Simon 6141c0f669 feat(ext): update mask controller 2026-01-24 19:37:46 +08:00
Simon fa5ab9d567 feat: multi tabs control 2026-01-24 19:29:27 +08:00
Simon 2aa9c3b978 Merge branch 'main' into feat/ext 2026-01-23 00:56:24 +08:00
Simon f4eec86e10 chore: clean up 2026-01-23 00:55:58 +08:00
Simon 60b401d86c Merge branch 'main' into feat/ext 2026-01-22 20:22:01 +08:00
Simon dad837e173 fix: don't retry or show AbortError; remove reason from dispose; don't clean history in dispose 2026-01-22 20:19:51 +08:00
Simon efe1f33144 feat: add custom system prompt configuration 2026-01-22 17:21:10 +08:00
Simon 7a560341aa chore(ext): add privacy policy document 2026-01-22 16:08:21 +08:00
Simon 2024a417c6 chore: sync version 2026-01-22 15:45:17 +08:00
Simon 9738545b2c Merge branch 'main' into feat/ext 2026-01-22 15:43:38 +08:00
Simon 299a5b2075 chore(version): bump version to 1.0.0 2026-01-22 15:38:21 +08:00
Simon 1f3e51eeb4 chore: sync version 2026-01-22 15:31:52 +08:00
Simon e96dac28fb Merge branch 'main' into feat/ext 2026-01-22 15:31:07 +08:00
Simon 785a2d194e chore(version): bump version to 1.0.0-beta.5 2026-01-22 15:17:19 +08:00
Simon 391e70dca4 feat: update historical event types 2026-01-22 15:16:15 +08:00
Simon 8c43fe1409 feat: update historical event types 2026-01-22 15:15:18 +08:00
Simon d861f7b7f2 Merge branch 'main' into feat/ext 2026-01-22 15:02:53 +08:00
Simon 3ffcd6482d refactor: rename AgentStep to AgentStepEventErrorEvent to AgentErrorEvent 2026-01-22 14:59:35 +08:00
Simon 53d3aaa67f refactor: update event types 2026-01-22 14:57:21 +08:00
Simon 5c4777fe7a chore: remove unnecessary permissions from extension config 2026-01-22 14:46:14 +08:00
Simon 4c7de54365 Merge branch 'main' into feat/ext 2026-01-22 14:15:15 +08:00
Simon 0cd04db40f fix: rebuild lock 2026-01-22 14:12:37 +08:00
Simon 2d1b2bbb85 Merge branch 'main' into feat/ext 2026-01-21 20:15:49 +08:00
Simon 23f1818503 docs: update README for clarity and integration details 2026-01-21 20:15:33 +08:00
Simon c1ae196bdc feat: update extension description and homepage URL 2026-01-21 19:54:11 +08:00
Simon c06742b8ed chore(ext): sync version to 1.0.0-beta.4 2026-01-21 19:21:09 +08:00
Simon 0df512add7 Merge branch 'main' into feat/ext 2026-01-21 19:20:37 +08:00
Simon 5fdb49fecb fix(sync-version): update version check to use pkgJson.version 2026-01-21 19:20:23 +08:00
Simon af133470e1 feat: add error handling for content scripts 2026-01-21 19:17:54 +08:00
Simon 4153db866f Merge branch 'main' into feat/ext 2026-01-21 19:06:12 +08:00
Simon fe1893a382 fix: rebuild lock 2026-01-21 19:04:26 +08:00
Simon b2260e9484 Merge branch 'main' into feat/ext 2026-01-21 19:01:46 +08:00
Simon 8c1c1ed9fe fix: correct prettier extension recommendation 2026-01-21 19:01:27 +08:00
Simon fb2ffa1de3 Merge branch 'main' into feat/ext 2026-01-21 18:55:50 +08:00
Simon 6cbb4e99c4 chore(version): bump version to 1.0.0-beta.4 2026-01-21 18:55:07 +08:00
Simon 34ce56b446 feat(PageController): make sure page is indexed before calling actions on elements 2026-01-21 18:54:17 +08:00
Simon 3fea74faa9 feat(ext): handling page reload/redirect/close 2026-01-21 18:46:50 +08:00
Simon 570c79623b feat(ext): update API Key input to use password type for enhanced security 2026-01-21 17:37:27 +08:00
Simon f0cb69ec7f Merge branch 'main' into feat/ext 2026-01-21 17:26:56 +08:00
Simon 23ec4602c2 feat: export types from './types' in PageAgentCore 2026-01-21 17:26:31 +08:00
Simon 7dcfad42f5 feat(ext): refactor event card; the result also counts as a step 2026-01-21 17:25:44 +08:00
Simon 189e9f3d39 feat(ext): display step index in event card 2026-01-21 17:14:51 +08:00
Simon 908ffae77c Merge branch 'main' into feat/ext 2026-01-21 17:00:45 +08:00
Simon 9531fcff5a feat(ext): enhance error handling and improve UI structure 2026-01-21 16:50:55 +08:00
Simon 52454305fb chore(vscode): update settings 2026-01-21 14:32:08 +08:00
Simon a18ebfd6c1 chore(ext): wxt configuration 2026-01-21 14:04:26 +08:00
Simon 5402cdecc2 style: update text sizes and spacing in components 2026-01-21 02:22:12 +08:00
Simon 969c00b2d1 feat(ext): auto fold reflections 2026-01-21 01:54:44 +08:00
Simon 266eec1474 Merge branch 'main' into feat/ext 2026-01-21 01:30:42 +08:00
Simon 5210a6edff Merge branch 'main' into feat/ext 2026-01-21 01:26:56 +08:00
Simon 4200c78327 feat(ext): style; code split 2026-01-20 21:13:44 +08:00
Simon f500445965 feat: show rawResponse in cards 2026-01-20 20:38:02 +08:00
Simon a47dfef659 Merge branch 'main' into feat/ext 2026-01-20 20:17:00 +08:00
Simon 32a9973e49 Merge branch 'main' into feat/ext 2026-01-20 19:57:05 +08:00
Simon ba7e79251f docs(ext): update PageController lifecycle and tab binding 2026-01-20 19:17:39 +08:00
Simon b4d17aa907 fix: no mask for new task after stop prev task 2026-01-20 19:08:52 +08:00
Simon 1a0c533cb4 fix: lost control of tab when window inactive 2026-01-20 19:03:15 +08:00
Simon 37332cbb9d feat(ext): icon; set chrome profile 2026-01-20 18:56:39 +08:00
Simon be85d63ef8 chore: update instructions 2026-01-20 17:41:45 +08:00
Simon db3b24a5ea feat(ext): draft extension structure (single-page mode) 2026-01-20 17:36:33 +08:00
135 changed files with 11367 additions and 3073 deletions
+8 -1
View File
@@ -36,4 +36,11 @@ packages/page-agent/LICENSE
# extension # extension
.output .output
.wxt .wxt
# AI
.agent
.claude
.cursor
.gemini
CLAUDE.md
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"recommendations": ["dbaeumer.vscode-eslint", "prettier.prettier-vscode"] "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
} }
+8 -2
View File
@@ -1,20 +1,25 @@
{ {
"editor.fontLigatures": true,
"cSpell.words": [ "cSpell.words": [
"deepseek", "deepseek",
"historychange", "historychange",
"HITL", "HITL",
"innerhtml", "innerhtml",
"languagedetector",
"llms", "llms",
"magicui",
"npmmirror", "npmmirror",
"onwarn", "onwarn",
"opensource", "opensource",
"qwen", "qwen",
"retryable", "retryable",
"shadcn", "shadcn",
"sidepanel",
"statuschange", "statuschange",
"wouter" "wouter"
], ],
"files.exclude": {
"packages/*/node_modules": true
},
"markdownlint.config": { "markdownlint.config": {
// "comment": "Relaxed rules", // "comment": "Relaxed rules",
"default": true, "default": true,
@@ -27,6 +32,7 @@
"first-line-h1": false, "first-line-h1": false,
"block-spacing": false, "block-spacing": false,
"blanks-around-lists": false, "blanks-around-lists": false,
"ol-prefix": false "ol-prefix": false,
"no-duplicate-heading": false
} }
} }
+5
View File
@@ -5,6 +5,7 @@
This is a **monorepo** with npm workspaces: This is a **monorepo** with npm workspaces:
- **Page Agent** (`packages/page-agent/`) - Main entry with built-in UI Panel, published as `page-agent` on npm - **Page Agent** (`packages/page-agent/`) - Main entry with built-in UI Panel, published as `page-agent` on npm
- **Extension** (`packages/extension/`) - Browser extension (WXT + React) 🚧 WIP
- **Website** (`packages/website/`) - React docs and landing page. **When working on website, follow `packages/website/AGENTS.md`** - **Website** (`packages/website/`) - React docs and landing page. **When working on website, follow `packages/website/AGENTS.md`**
Internal packages: Internal packages:
@@ -21,6 +22,7 @@ npm start # Start website dev server
npm run build # Build all packages npm run build # Build all packages
npm run build:libs # Build all libraries npm run build:libs # Build all libraries
npm run lint # ESLint with TypeScript strict rules npm run lint # ESLint with TypeScript strict rules
npm run zip -w @page-agent/ext # Zip the extension package
``` ```
## Architecture ## Architecture
@@ -35,6 +37,7 @@ packages/
├── page-agent/ # npm: "page-agent" entry class (with UI + controller + demo builds) ├── page-agent/ # npm: "page-agent" entry class (with UI + controller + demo builds)
├── website/ # @page-agent/website (private) ├── website/ # @page-agent/website (private)
├── llms/ # @page-agent/llms ├── llms/ # @page-agent/llms
├── extension/ # Browser extension (WXT + React)
├── page-controller/ # @page-agent/page-controller ├── page-controller/ # @page-agent/page-controller
└── ui/ # @page-agent/ui └── ui/ # @page-agent/ui
``` ```
@@ -127,3 +130,5 @@ const pageInfo = await this.pageController.getPageInfo()
- ESLint relaxes some unsafe rules for rapid iteration - ESLint relaxes some unsafe rules for rapid iteration
- Every change you make should not only implement the desired functionality but also improve the quality of the codebase - Every change you make should not only implement the desired functionality but also improve the quality of the codebase
- All code and comments must be in English. - All code and comments must be in English.
- Do not try to hide errors or risks. They are valuable feedbacks for developers and users. Make them visible and actionable.
- Traceability and predictability is more important than success rate.
+59
View File
@@ -5,6 +5,65 @@ 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.3.0] - 2026-02-13
### Breaking Changes
- **Lifecycle: `stop()` vs `dispose()`** - New `stop()` method to cancel the current task while keeping the agent reusable. `dispose()` is now terminal — a disposed agent cannot be reused. This affects both `PageAgentCore` and `PanelAgentAdapter`.
### Features
- **Panel action button** - The panel button now morphs between Stop (■) and Close (X) based on agent status
- **Error history** - Errors and max-step failures are now recorded in `history` as `AgentErrorEvent`, making post-task analysis more complete
### Bug Fixes
- **AbortError handling** - `AbortError` is no longer retried by the LLM client, and shows a clean "Task stopped" message instead of a raw error stack
---
## [1.2.0] - 2026-02-11
### Features
- **Observe Phase** - Agent now observes the page before each action, improving decision accuracy on dynamic pages
- **Better Abort Handling** - Improved `abortSignal` support for cleaner task cancellation
### Improvements
- Pruned system prompts for lower token usage and faster responses
- Improved error handling during agent steps with better error messages
- Zod tree-shaking for smaller bundle size
### Bug Fixes
- Fixed indentation lost in DOM extraction caused by `trimLines`
- Fixed `gpt-5-mini` temperature configuration
---
## [1.1.0] - 2026-02-02
### Features
- **Custom System Prompt** - New `systemPrompt` config option to customize or extend the default system prompt
- **Chrome Extension** - Extension with multi-tab control, main-world API with token auth, and tab lifecycle management
### Improvements
- Renamed `include_attributes` to `includeAttributes` in PageController config (camelCase consistency)
- Lazy-loaded mask module for faster initialization
- Better date formatting and error messages from LLM client
- Added `rawRequest` to step history for easier debugging
### Bug Fixes
- Fixed CSP errors by using local SVGs for cursor mask instead of inline styles
- Fixed `AbortError` being incorrectly retried and shown to users
- Fixed mask not working correctly when starting a new task after stopping a previous one
---
## [1.0.0] - 2026-01-19 ## [1.0.0] - 2026-01-19
### 🎉 First Stable Release ### 🎉 First Stable Release
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
+41 -10
View File
@@ -20,10 +20,11 @@ Thank you for your interest in contributing to Page-Agent! We welcome contributi
### Project Structure ### Project Structure
This is a **monorepo** with npm workspaces containing **3 main packages**: This is a **monorepo** with npm workspaces containing **4 main packages**:
- **Page Agent** (`packages/page-agent/`) - Main entry with built-in UI Panel, published as `page-agent` on npm - **Page Agent** (`packages/page-agent/`) - Main entry with built-in UI Panel, published as `page-agent` on npm
- **Core** (`packages/core/`) - Core agent logic without UI (npm: `@page-agent/core`) - **Core** (`packages/core/`) - Core agent logic without UI (npm: `@page-agent/core`)
- **Extension** (`packages/extension/`) - Chrome extension for multi-page tasks and browser-level automation
- **Website** (`packages/website/`) - React documentation and landing page. Also as demo and test page for the core lib. private package `@page-agent/website` - **Website** (`packages/website/`) - React documentation and landing page. Also as demo and test page for the core lib. private package `@page-agent/website`
We use a simplified monorepo solution with `native npm-workspace + ts reference + vite alias`. No fancy tooling. Hoisting is required. We use a simplified monorepo solution with `native npm-workspace + ts reference + vite alias`. No fancy tooling. Hoisting is required.
@@ -92,10 +93,28 @@ We use a simplified monorepo solution with `native npm-workspace + ts reference
### Vibe coding with AI ### Vibe coding with AI
- It's **recommended** to heavily rely on AI (aka "vibe coding") when maintaining **demo pages and tests**. - It's **recommended** to heavily rely on AI (aka "vibe coding") when maintaining **demo pages and tests**.
- BUT **NOT the core lib!!!** Be very careful if AI ever touched the core lib!!! - Be very careful if AI ever touched the core lib!!!
- Review anything AI wrote before make a commit. You are the author of anything you commit. NOT AI. - Review anything AI wrote before make a commit. You are the author of anything you commit. NOT AI.
- Update the [AI instructions](AGENTS.md) when structure changed. - Update the [AI instructions](AGENTS.md) when structure changed.
If your lame AI assistant does not support [AGENTS.md](https://agents.md/). Add an alias for it:
- claude-code (`CLAUDE.md`)
```markdown
@AGENTS.md
```
- antigravity (`.agent/rules/alias.md`)
```markdown
---
trigger: always_on
---
@../../AGENTS.md
```
## 🔧 Development Workflows ## 🔧 Development Workflows
### Test With Your Own LLM API ### Test With Your Own LLM API
@@ -108,6 +127,16 @@ We use a simplified monorepo solution with `native npm-workspace + ts reference
LLM_BASE_URL=https://api.your-llm-provider.com/v1 LLM_BASE_URL=https://api.your-llm-provider.com/v1
``` ```
- **Ollama example** (tested on 0.15 + qwen3:14b, RTX3090 24GB):
```env
LLM_BASE_URL="http://localhost:11434/v1"
LLM_API_KEY="NA"
LLM_MODEL_NAME="qwen3:14b"
```
> ⚠️ Add `*` to `OLLAMA_ORIGINS` (403). Models < 10B unlikely strong enough. Requires tool_call support. Set context length > 15k (default 4k will NOT work): `$env:OLLAMA_CONTEXT_LENGTH=64000; ollama serve`
- Restart the dev server to load new env vars - Restart the dev server to load new env vars
- If not provided, the demo will the free testing proxy by default - If not provided, the demo will the free testing proxy by default
@@ -117,6 +146,16 @@ We use a simplified monorepo solution with `native npm-workspace + ts reference
npm start npm start
``` ```
### Extension Development
```bash
npm run dev -w @page-agent/ext
npm run zip -w @page-agent/ext
```
- Load extension in Chrome via `chrome://extensions` -> **Load unpacked**
- Use `packages/extension/docs/extension_api.md` (EN) or `packages/extension/docs/extension_api_zh.md` (ZH) for API integration details
### Testing on Other Websites ### Testing on Other Websites
- Start and serve a local `iife` script - Start and serve a local `iife` script
@@ -165,14 +204,6 @@ By contributing to this project, you agree that your contributions will be licen
> You may need to sign a github CLA before you create a PR. > You may need to sign a github CLA before you create a PR.
### Browser-Use Attribution
Parts of this project are derived from the [browser-use](https://github.com/browser-use/browser-use) project (MIT License). When contributing to DOM-related functionality:
- Maintain existing attribution comments
- Follow similar patterns for consistency
- Credit browser-use for derived concepts
## 💬 Questions? ## 💬 Questions?
- Open a GitHub issue for technical questions - Open a GitHub issue for technical questions
+28 -35
View File
@@ -1,4 +1,4 @@
# PageAgent 🤖🪄 # 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://img.alicdn.com/imgextra/i4/O1CN01qKig1P1FnhpFKNdi6_!!6000000000532-2-tps-1280-256.png">
@@ -19,35 +19,33 @@
## ✨ Features ## ✨ Features
- **🎯 轻松集成** - **🎯 轻松集成**
- **🔐 端侧运行** - 无需 `浏览器插件` / `Python` / `无头浏览器`
- **🧠 HTML 脱水** - 纯页面内 JavaScript,一切都在你的网页中完成。
- **💬 自然语言接口** - The best tool for your agent to control web pages.
- **🎨 HITL 交互界面** - **📖 基于文本的 DOM 操作**
- 无需截图,无需 OCR 或多模态模型。
## 🗺️ Roadmap - 无需特殊权限。
- **🧠 用你自己的 LLM**
👉 [**Roadmap**](https://github.com/alibaba/page-agent/issues/96) - **🎨 精美 UI,支持人机协同**
- **🐙 可选的 [Chrome 扩展](https://alibaba.github.io/page-agent/#/docs/features/chrome-extension),支持跨页面任务。**
## 🚀 快速开始 ## 🚀 快速开始
### 快速体验 (With Testing LLM) ### 一行代码集成
最快的体验方式 通过我们免费的 Demo LLM 快速体验 PageAgent
```html ```html
<script <script src="{URL}" crossorigin="true"></script>
src="https://registry.npmmirror.com/page-agent/1.0.0-beta.3/files/dist/iife/page-agent.demo.js"
crossorigin="true"
></script>
``` ```
> ⚠️ **仅用于技术评估。** Demo 模型有速率限制和使用限制,生产环境请使用 NPM 方式。 | Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.3.0/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.3.0/files/dist/iife/page-agent.demo.js |
| 镜像 | URL | > **⚠️ 仅用于技术评估。** Demo LLM 有速率和使用限制,速度较慢,可能随时变更。
| ------ | ----------------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.0.0-beta.3/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.0.0-beta.3/files/dist/iife/page-agent.demo.js |
### NPM 安装 ### NPM 安装
@@ -68,7 +66,7 @@ const agent = new PageAgent({
await agent.execute('点击登录按钮') await agent.execute('点击登录按钮')
``` ```
适用于无法使用 NPM 的环境,我们也提供了 IIFE 构建的 CDN 方式。[@see CDN Usage](https://alibaba.github.io/page-agent/#/docs/integration/cdn-setup) 更多编程用法,请参阅 [📖 文档](https://alibaba.github.io/page-agent/#/docs/introduction/overview)
## 🏗️ 架构设计 ## 🏗️ 架构设计
@@ -76,25 +74,20 @@ PageAgent adopts a simplified monorepo structure:
``` ```
packages/ packages/
├── core/ # ** Core agent logic without UI(npm: @page-agent/core) ** ├── core/ # ** Core agent logic (npm: @page-agent/core) **
├── page-agent/ # Exported agent and demo(npm: page-agent)
├── llms/ # LLM 客户端 (npm: @page-agent/llms) ├── llms/ # LLM 客户端 (npm: @page-agent/llms)
├── page-controller/ # DOM 操作 & 蒙层 & 模拟鼠标 (npm: @page-agent/page-controller) ├── page-controller/ # DOM 操作 (npm: @page-agent/page-controller)
├── ui/ # 面板 & i18n (npm: @page-agent/ui) ├── ui/ # 面板 UI (npm: @page-agent/ui)
── website/ # 文档站点 ── page-agent/ # 入口类 & iife 包 (npm: page-agent)
├── extension/ # Chrome 扩展,支持跨页面任务
└── website/ # 网站 & 文档站点
``` ```
## 🤝 贡献 ## 🤝 贡献
欢迎社区贡献!以下是参与方式: 欢迎社区贡献!请参阅 [CONTRIBUTING.md](CONTRIBUTING.md) 了解环境配置和本地开发说明。
1. Fork 并 clone。`git clone https://github.com/alibaba/page-agent.git && cd page-agent` 请在贡献前阅读[行为准则](CODE_OF_CONDUCT.md)。
2. 安装依赖: `npm install`
3. 启动开发: `npm start`
更多详情请参阅 [CONTRIBUTING.md](CONTRIBUTING.md)。
请在贡献前阅读我们的[行为准则](CODE_OF_CONDUCT.md)。
## 👏 致谢 ## 👏 致谢
+29 -34
View File
@@ -1,4 +1,4 @@
# PageAgent 🤖🪄 # 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://img.alicdn.com/imgextra/i4/O1CN01qKig1P1FnhpFKNdi6_!!6000000000532-2-tps-1280-256.png">
@@ -19,35 +19,33 @@ The GUI Agent Living in Your Webpage. Control web interfaces with natural langua
## ✨ Features ## ✨ Features
- **🎯 Easy Integration** - Transform your webpage into an agent with a single script tag. - **🎯 Easy integration**
- **🔐 Client-Side Processing** - No need for `browser extension` / `python` / `headless browser`.
- **🧠 DOM Extraction** - Just in-page javascript. Everything happens in your web page.
- **💬 Natural Language Interface** - The best tool for your agent to control web pages.
- **🎨 UI with Human in the loop** - **📖 Text-based DOM manipulation**
- No screenshots. No OCR or multi-modal LLMs needed.
## 🗺️ Roadmap - No special permissions required.
- **🧠 Bring your own LLMs**
👉 [**Roadmap**](https://github.com/alibaba/page-agent/issues/96) - **🎨 Pretty UI with human-in-the-loop**
- **🐙 Optional [chrome extension](https://alibaba.github.io/page-agent/#/docs/features/chrome-extension) for multi-page tasks.**
## 🚀 Quick Start ## 🚀 Quick Start
### Quick Try (With Testing LLM) ### One-line integration
Fastest way to try PageAgent: Fastest way to try PageAgent with our free Demo LLM:
```html ```html
<script <script src="{URL}" crossorigin="true"></script>
src="https://cdn.jsdelivr.net/npm/page-agent@1.0.0-beta.3/dist/iife/page-agent.demo.js"
crossorigin="true"
></script>
``` ```
> ⚠️ **For technical evaluation only.** Demo model has rate limits and usage restrictions. Use NPM for production. | Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.3.0/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.3.0/files/dist/iife/page-agent.demo.js |
| Mirrors | URL | > **⚠️ For technical evaluation only.** Demo LLM has rate limits and usage restrictions. Slow. May change without notice.
| ------- | ----------------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.0.0-beta.3/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.0.0-beta.3/files/dist/iife/page-agent.demo.js |
### NPM Installation ### NPM Installation
@@ -68,31 +66,28 @@ const agent = new PageAgent({
await agent.execute('Click the login button') await agent.execute('Click the login button')
``` ```
For more programmatic usage, see [📖 Documentations](https://alibaba.github.io/page-agent/#/docs/introduction/overview).
## 🏗️ Structure ## 🏗️ Structure
PageAgent adopts a simplified monorepo structure: PageAgent adopts a simplified monorepo structure:
``` ```
packages/ packages/
├── core/ # ** Core agent logic without UI(npm: @page-agent/core) ** ├── core/ # ** Core agent logic (npm: @page-agent/core) **
├── page-agent/ # Exported agent and demo(npm: page-agent)
├── llms/ # LLM client (npm: @page-agent/llms) ├── llms/ # LLM client (npm: @page-agent/llms)
├── page-controller/ # DOM operations & Visual Mask (npm: @page-agent/page-controller) ├── page-controller/ # DOM operations (npm: @page-agent/page-controller)
├── ui/ # Panel & i18n (npm: @page-agent/ui) ├── ui/ # Panel UI (npm: @page-agent/ui)
── website/ # Demo & Documentation site ── page-agent/ # Entry class and iife builds(npm: page-agent)
├── extension/ # Chrome extension for multi-page tasks
└── website/ # Website & Documentation site
``` ```
## 🤝 Contributing ## 🤝 Contributing
We welcome contributions from the community! Here's how to get started: We welcome contributions from the community! Follow our instructions in [CONTRIBUTING.md](CONTRIBUTING.md) for environment setup and local development.
1. Fork and clone. `git clone https://github.com/alibaba/page-agent.git && cd page-agent` Please read [Code of Conduct](CODE_OF_CONDUCT.md) before contributing.
2. Install dependencies: `npm install`
3. Start development: `npm start`
More details in [CONTRIBUTING.md](CONTRIBUTING.md).
Please read our [Code of Conduct](CODE_OF_CONDUCT.md) before contributing.
## 👏 Acknowledgments ## 👏 Acknowledgments
+9 -1
View File
@@ -8,7 +8,13 @@ import globals from 'globals'
import tseslint from 'typescript-eslint' import tseslint from 'typescript-eslint'
export default defineConfig([ export default defineConfig([
globalIgnores(['**/dist', '**/node_modules', 'packages/website/src/components/ui']), globalIgnores([
'**/dist',
'**/node_modules',
'packages/*/src/components/ui',
'**/.wxt',
'**/.output',
]),
{ {
plugins: { plugins: {
'react-hooks': reactHooks, 'react-hooks': reactHooks,
@@ -69,6 +75,8 @@ export default defineConfig([
'react-dom/no-missing-button-type': 'off', 'react-dom/no-missing-button-type': 'off',
'react-x/no-nested-component-definitions': 'off', 'react-x/no-nested-component-definitions': 'off',
'@typescript-eslint/prefer-optional-chain': 'off', '@typescript-eslint/prefer-optional-chain': 'off',
'@typescript-eslint/use-unknown-in-catch-callback-variable': 'off',
'@typescript-eslint/no-unnecessary-type-parameters': 'off',
// 'require-await': 'off', // 'require-await': 'off',
'@typescript-eslint/require-await': 'off', '@typescript-eslint/require-await': 'off',
+4349 -1394
View File
File diff suppressed because it is too large Load Diff
+15 -11
View File
@@ -1,7 +1,7 @@
{ {
"name": "root", "name": "root",
"private": true, "private": true,
"version": "1.0.0-beta.3", "version": "1.3.0",
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
"packages/page-controller", "packages/page-controller",
@@ -9,6 +9,7 @@
"packages/llms", "packages/llms",
"packages/core", "packages/core",
"packages/page-agent", "packages/page-agent",
"packages/extension",
"packages/website" "packages/website"
], ],
"description": "AI-powered UI agent for web applications", "description": "AI-powered UI agent for web applications",
@@ -20,8 +21,7 @@
}, },
"homepage": "https://alibaba.github.io/page-agent/", "homepage": "https://alibaba.github.io/page-agent/",
"engines": { "engines": {
"node": ">=20.0.0", "node": "^20.19.0 || ^22.13.0 || >=24"
"npm": ">=10.0.0"
}, },
"scripts": { "scripts": {
"cleanup": "rm -rf packages/*/dist", "cleanup": "rm -rf packages/*/dist",
@@ -38,26 +38,30 @@
"@commitlint/cli": "^20.3.1", "@commitlint/cli": "^20.3.1",
"@commitlint/config-conventional": "^20.3.1", "@commitlint/config-conventional": "^20.3.1",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@microsoft/api-extractor": "^7.55.2", "@microsoft/api-extractor": "^7.56.3",
"@tailwindcss/vite": "^4.1.18",
"@trivago/prettier-plugin-sort-imports": "^6.0.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^25.0.9", "@types/node": "^25.2.2",
"@vitejs/plugin-react-swc": "^4.1.0",
"chalk": "^5.6.2",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.4",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-dom": "^2.7.2", "eslint-plugin-react-dom": "^2.12.2",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26", "eslint-plugin-react-refresh": "^0.5.0",
"eslint-plugin-react-x": "^2.7.2", "eslint-plugin-react-x": "^2.12.2",
"globals": "^17.0.0", "globals": "^17.0.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.2.4", "lint-staged": "^16.2.4",
"prettier": "^3.8.0", "prettier": "^3.8.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.53.1", "typescript-eslint": "^8.55.0",
"unplugin-dts": "^1.0.0-beta.6", "unplugin-dts": "^1.0.0-beta.6",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-plugin-css-injected-by-js": "^3.5.2" "vite-plugin-css-injected-by-js": "^3.5.2",
"vite-bundle-analyzer": "^1.3.6"
}, },
"overrides": { "overrides": {
"typescript": "^5.9.3" "typescript": "^5.9.3"
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/core", "name": "@page-agent/core",
"private": false, "private": false,
"version": "1.0.0-beta.3", "version": "1.3.0",
"type": "module", "type": "module",
"main": "./dist/esm/page-agent-core.js", "main": "./dist/esm/page-agent-core.js",
"module": "./dist/esm/page-agent-core.js", "module": "./dist/esm/page-agent-core.js",
@@ -45,7 +45,7 @@
"dependencies": { "dependencies": {
"chalk": "^5.6.2", "chalk": "^5.6.2",
"zod": "^4.3.5", "zod": "^4.3.5",
"@page-agent/llms": "1.0.0-beta.3", "@page-agent/llms": "1.3.0",
"@page-agent/page-controller": "1.0.0-beta.3" "@page-agent/page-controller": "1.3.0"
} }
} }
+199 -161
View File
@@ -3,34 +3,43 @@
* All rights reserved. * All rights reserved.
*/ */
import { InvokeError, LLM, type Tool } from '@page-agent/llms' import { InvokeError, LLM, type Tool } from '@page-agent/llms'
import type { PageController } from '@page-agent/page-controller' import type { BrowserState, PageController } from '@page-agent/page-controller'
import chalk from 'chalk' import chalk from 'chalk'
import zod from 'zod' import * as zod from 'zod'
import { type PageAgentConfig } from './config' import { type PageAgentConfig } from './config'
import { DEFAULT_MAX_STEPS } from './config/constants' import { DEFAULT_MAX_STEPS } from './config/constants'
import SYSTEM_PROMPT from './prompts/system_prompt.md?raw' import SYSTEM_PROMPT from './prompts/system_prompt.md?raw'
import { tools } from './tools' import { tools } from './tools'
import { import type {
AgentActivity, AgentActivity,
AgentReflection, AgentReflection,
AgentStatus, AgentStatus,
AgentStep, AgentStepEvent,
ExecutionResult, ExecutionResult,
HistoricalEvent, HistoricalEvent,
MacroToolInput, MacroToolInput,
MacroToolResult, MacroToolResult,
} from './types' } from './types'
import { normalizeResponse, trimLines, uid, waitFor } from './utils' import { assert, normalizeResponse, uid, waitFor } from './utils'
import { assert } from './utils/assert'
export { type PageAgentConfig } export { type PageAgentConfig }
export { tool, type PageAgentTool } from './tools' export { tool, type PageAgentTool } from './tools'
export type * from './types'
/** /**
* AI agent for browser DOM automation. * AI agent for browser automation.
* *
* @remarks * @remarks
* ## Re-act Agent Loop
* - step
* - observe (gather information about current environment and context)
* - think (LLM calling)
* - reflection (evaluate history, generate memory, short-term planning)
* - action (give the action to approach the next goal)
* - act (execute the action)
* - loop
*
* ## Event System * ## Event System
* - `statuschange` - Agent status transitions (idle → running → completed/error) * - `statuschange` - Agent status transitions (idle → running → completed/error)
* - `historychange` - History events updated (persistent, part of agent memory) * - `historychange` - History events updated (persistent, part of agent memory)
@@ -49,15 +58,18 @@ export { tool, type PageAgentTool } from './tools'
* - Types: thinking, executing, executed, retrying, error * - Types: thinking, executing, executed, retrying, error
*/ */
export class PageAgentCore extends EventTarget { export class PageAgentCore extends EventTarget {
config: PageAgentConfig & { maxSteps: number } readonly id = uid()
id = uid() readonly config: PageAgentConfig & { maxSteps: number }
tools: typeof tools readonly tools: typeof tools
disposed = false /** PageController for DOM operations */
readonly pageController: PageController
task = '' task = ''
taskId = '' taskId = ''
/** History events */
/** Agent execution status */ history: HistoricalEvent[] = []
#status: AgentStatus = 'idle' /** Whether this agent has been disposed */
disposed = false
/** /**
* Callback for when agent needs user input (ask_user tool) * Callback for when agent needs user input (ask_user tool)
@@ -66,23 +78,21 @@ export class PageAgentCore extends EventTarget {
*/ */
onAskUser?: (question: string) => Promise<string> onAskUser?: (question: string) => Promise<string>
#status: AgentStatus = 'idle'
#llm: LLM #llm: LLM
#abortController = new AbortController() #abortController = new AbortController()
#observations: string[] = []
/** PageController for DOM operations */ /** internal states during a single task execution */
pageController: PageController #states = {
/** Accumulated wait time in seconds */
/** Runtime states for tracking across steps */
states = {
/** Accumulated wait time in seconds, used by wait tool */
totalWaitTime: 0, totalWaitTime: 0,
/** Last known URL for detecting navigation */ /** For detecting navigation */
lastURL: '', lastURL: '',
/** Browser state */
browserState: null as BrowserState | null,
} }
/** History events */
history: HistoricalEvent[] = []
constructor(config: PageAgentConfig & { pageController: PageController }) { constructor(config: PageAgentConfig & { pageController: PageController }) {
super() super()
@@ -95,11 +105,10 @@ export class PageAgentCore extends EventTarget {
// Listen to LLM retry events // 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 } = (e as CustomEvent).detail
this.emitActivity({ type: 'retrying', attempt, maxAttempts }) this.#emitActivity({ type: 'retrying', attempt, maxAttempts })
// Also push to history for panel rendering // Also push to history for panel rendering
this.history.push({ this.history.push({
type: 'error', type: 'retry',
errorType: 'retry',
message: `LLM retry attempt ${attempt} of ${maxAttempts}`, message: `LLM retry attempt ${attempt} of ${maxAttempts}`,
attempt, attempt,
maxAttempts, maxAttempts,
@@ -108,12 +117,12 @@ export class PageAgentCore extends EventTarget {
}) })
this.#llm.addEventListener('error', (e) => { this.#llm.addEventListener('error', (e) => {
const error = (e as CustomEvent).detail.error as Error | InvokeError const error = (e as CustomEvent).detail.error as Error | InvokeError
if ((error as any)?.rawError?.name === 'AbortError') return
const message = String(error) const message = String(error)
this.emitActivity({ type: 'error', message }) this.#emitActivity({ type: 'error', message })
// Also push to history for panel rendering // Also push to history for panel rendering
this.history.push({ this.history.push({
type: 'error', type: 'error',
errorType: 'error',
message, message,
rawResponse: (error as InvokeError).rawResponse, rawResponse: (error as InvokeError).rawResponse,
}) })
@@ -154,7 +163,7 @@ export class PageAgentCore extends EventTarget {
* Emit activity event - for transient UI feedback * Emit activity event - for transient UI feedback
* @param activity - Current agent activity * @param activity - Current agent activity
*/ */
emitActivity(activity: AgentActivity): void { #emitActivity(activity: AgentActivity): void {
this.dispatchEvent(new CustomEvent('activity', { detail: activity })) this.dispatchEvent(new CustomEvent('activity', { detail: activity }))
} }
@@ -167,15 +176,24 @@ export class PageAgentCore extends EventTarget {
} }
/** /**
* Push a persistent observation to the history event stream. * Push a observation message to the history event stream.
* This will be visible in <agent_history> and remain in memory across steps. * This will be visible in <agent_history> and remain persistent in memory across steps.
* @experimental @internal
* @note history change will be emitted before next step starts
*/ */
pushObservation(content: string): void { pushObservation(content: string): void {
this.history.push({ type: 'observation', content }) this.#observations.push(content)
this.#emitHistoryChange() }
/** Stop the current task. Agent remains reusable. */
stop() {
this.pageController.cleanUpHighlights()
this.pageController.hideMask()
this.#abortController.abort()
} }
async execute(task: string): Promise<ExecutionResult> { async execute(task: string): Promise<ExecutionResult> {
if (this.disposed) throw new Error('PageAgent has been disposed. Create a new instance.')
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()
@@ -204,47 +222,44 @@ export class PageAgentCore extends EventTarget {
this.#setStatus('running') this.#setStatus('running')
this.#emitHistoryChange() this.#emitHistoryChange()
// Reset states // Reset internal states
this.states = { this.#states = { totalWaitTime: 0, lastURL: '', browserState: null }
totalWaitTime: 0,
lastURL: '',
}
try { let step = 0
let step = 0
while (true) { while (true) {
await this.#generateObservations(step) try {
console.group(`step: ${step}`)
await onBeforeStep?.(this, step) await onBeforeStep?.(this, step)
console.group(`step: ${step}`) // observe
// abort console.log(chalk.blue.bold('👀 Observing...'))
if (this.#abortController.signal.aborted) throw new Error('AbortError')
// Thinking this.#states.browserState = await this.pageController.getBrowserState()
console.log(chalk.blue('Thinking...')) await this.#handleObservations(step)
this.emitActivity({ type: 'thinking' })
const result = await this.#llm.invoke( // assemble prompts
[
{ const messages = [
role: 'system', { role: 'system' as const, content: this.#getSystemPrompt() },
content: this.#getSystemPrompt(), { role: 'user' as const, content: await this.#assembleUserPrompt() },
}, ]
{
role: 'user', const tools = { AgentOutput: this.#packMacroTool() }
content: await this.#assembleUserPrompt(),
}, // invoke LLM
],
{ AgentOutput: this.#packMacroTool() }, console.log(chalk.blue.bold('🧠 Thinking...'))
this.#abortController.signal, this.#emitActivity({ type: 'thinking' })
{
toolChoiceName: 'AgentOutput', const result = await this.#llm.invoke(messages, tools, this.#abortController.signal, {
normalizeResponse, toolChoiceName: 'AgentOutput',
} normalizeResponse,
) })
// assemble history
const macroResult = result.toolResult as MacroToolResult const macroResult = result.toolResult as MacroToolResult
const input = macroResult.input const input = macroResult.input
@@ -255,7 +270,7 @@ export class PageAgentCore extends EventTarget {
next_goal: input.next_goal, next_goal: input.next_goal,
} }
const actionName = Object.keys(input.action)[0] const actionName = Object.keys(input.action)[0]
const action: AgentStep['action'] = { const action: AgentStepEvent['action'] = {
name: actionName, name: actionName,
input: input.action[actionName], input: input.action[actionName],
output: output, output: output,
@@ -268,30 +283,23 @@ export class PageAgentCore extends EventTarget {
action, action,
usage: result.usage, usage: result.usage,
rawResponse: result.rawResponse, rawResponse: result.rawResponse,
} as AgentStep) rawRequest: result.rawRequest,
} as AgentStepEvent)
this.#emitHistoryChange() this.#emitHistoryChange()
console.log(chalk.green('Step finished:'), actionName) //
console.groupEnd()
await onAfterStep?.(this, this.history) await onAfterStep?.(this, this.history)
step++ console.groupEnd()
if (step > this.config.maxSteps) {
this.#onDone('Step count exceeded maximum limit', false) // finish task if done
const result: ExecutionResult = {
success: false,
data: 'Step count exceeded maximum limit',
history: this.history,
}
await onAfterTask?.(this, result)
return result
}
if (actionName === 'done') { if (actionName === 'done') {
const success = action.input?.success ?? false const success = action.input?.success ?? false
const text = action.input?.text || 'no text provided' const text = action.input?.text || 'no text provided'
console.log(chalk.green.bold('Task completed'), success, text) console.log(chalk.green.bold('Task completed'), success, text)
this.#onDone(text, success) this.#onDone(success)
const result: ExecutionResult = { const result: ExecutionResult = {
success, success,
data: text, data: text,
@@ -300,19 +308,39 @@ export class PageAgentCore extends EventTarget {
await onAfterTask?.(this, result) await onAfterTask?.(this, result)
return result return result
} }
} catch (error: unknown) {
console.groupEnd() // to prevent nested groups
const isAbortError = (error as any)?.rawError?.name === 'AbortError'
console.error('Task failed', error)
const errorMessage = isAbortError ? 'Task stopped' : String(error)
this.#emitActivity({ type: 'error', message: errorMessage })
this.history.push({ type: 'error', message: errorMessage, rawResponse: error })
this.#emitHistoryChange()
this.#onDone(false)
const result: ExecutionResult = {
success: false,
data: errorMessage,
history: this.history,
}
await onAfterTask?.(this, result)
return result
} }
} catch (error: unknown) {
console.error('Task failed', error) step++
const errorMessage = String(error) if (step > this.config.maxSteps) {
this.emitActivity({ type: 'error', message: errorMessage }) const errorMessage = 'Step count exceeded maximum limit'
this.#onDone(errorMessage, false) this.history.push({ type: 'error', message: errorMessage })
const result: ExecutionResult = { this.#emitHistoryChange()
success: false, this.#onDone(false)
data: errorMessage, const result: ExecutionResult = {
history: this.history, success: false,
data: errorMessage,
history: this.history,
}
await onAfterTask?.(this, result)
return result
} }
await onAfterTask?.(this, result)
return result
} }
} }
@@ -345,13 +373,13 @@ export class PageAgentCore extends EventTarget {
}) })
return { return {
description: 'You MUST call this tool every step. Outputs your reflections and next action.', description: 'You MUST call this tool every step!',
inputSchema: macroToolSchema as zod.ZodType<MacroToolInput>, inputSchema: macroToolSchema as zod.ZodType<MacroToolInput>,
execute: async (input: MacroToolInput): Promise<MacroToolResult> => { execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
// abort // abort
if (this.#abortController.signal.aborted) throw new Error('AbortError') if (this.#abortController.signal.aborted) throw new Error('AbortError')
console.log(chalk.blue.bold('MacroTool execute'), input) console.log(chalk.blue.bold('MacroTool input'), input)
const action = input.action const action = input.action
const toolName = Object.keys(action)[0] const toolName = Object.keys(action)[0]
@@ -372,12 +400,12 @@ export class PageAgentCore extends EventTarget {
// Find the corresponding tool // Find the corresponding tool
const tool = tools.get(toolName) const tool = tools.get(toolName)
assert(tool, `Tool ${toolName} not found. (@note should have been caught before this!!!)`) assert(tool, `Tool ${toolName} not found`)
console.log(chalk.blue.bold(`Executing tool: ${toolName}`), toolInput) console.log(chalk.blue.bold(`Executing tool: ${toolName}`), toolInput)
// Emit executing activity // Emit executing activity
this.emitActivity({ type: 'executing', tool: toolName, input: toolInput }) this.#emitActivity({ type: 'executing', tool: toolName, input: toolInput })
const startTime = Date.now() const startTime = Date.now()
@@ -388,7 +416,7 @@ export class PageAgentCore extends EventTarget {
console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result) console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result)
// Emit executed activity // Emit executed activity
this.emitActivity({ this.#emitActivity({
type: 'executed', type: 'executed',
tool: toolName, tool: toolName,
input: toolInput, input: toolInput,
@@ -396,9 +424,11 @@ export class PageAgentCore extends EventTarget {
duration, duration,
}) })
// Reset wait time for non-wait tools // counting wait time
if (toolName !== 'wait') { if (toolName === 'wait') {
this.states.totalWaitTime = 0 this.#states.totalWaitTime += toolInput?.seconds || 0
} else {
this.#states.totalWaitTime = 0
} }
// Return structured result // Return structured result
@@ -414,10 +444,12 @@ export class PageAgentCore extends EventTarget {
* Get system prompt, dynamically replace language settings based on configured language * Get system prompt, dynamically replace language settings based on configured language
*/ */
#getSystemPrompt(): string { #getSystemPrompt(): string {
let systemPrompt = SYSTEM_PROMPT if (this.config.customSystemPrompt) {
return this.config.customSystemPrompt
}
const targetLanguage = this.config.language === 'zh-CN' ? '中文' : 'English' const targetLanguage = this.config.language === 'zh-CN' ? '中文' : 'English'
systemPrompt = systemPrompt.replace( const systemPrompt = SYSTEM_PROMPT.replace(
/Default working language: \*\*.*?\*\*/, /Default working language: \*\*.*?\*\*/,
`Default working language: **${targetLanguage}**` `Default working language: **${targetLanguage}**`
) )
@@ -426,17 +458,17 @@ export class PageAgentCore extends EventTarget {
} }
/** /**
* Get instructions from config and format as XML block * Get instructions from config
*/ */
async #getInstructions(): Promise<string> { async #getInstructions(): Promise<string> {
const { instructions } = this.config const { instructions } = this.config
if (!instructions) return '' if (!instructions) return ''
const systemInstructions = instructions.system?.trim() const systemInstructions = instructions.system?.trim()
const url = await this.pageController.getCurrentUrl()
let pageInstructions: string | undefined let pageInstructions: string | undefined
if (instructions.getPageInstructions) { const url = this.#states.browserState?.url || ''
if (instructions.getPageInstructions && url) {
try { try {
pageInstructions = instructions.getPageInstructions(url)?.trim() pageInstructions = instructions.getPageInstructions(url)?.trim()
} catch (error) { } catch (error) {
@@ -464,23 +496,28 @@ export class PageAgentCore extends EventTarget {
} }
/** /**
* Generate observations before each step * Generate system observations before each step
* - URL change detection
* - Too many steps warning
* @todo loop detection * @todo loop detection
* @todo console error * @todo console error
*/ */
async #generateObservations(stepCount: number): Promise<void> { async #handleObservations(step: number): Promise<void> {
// Accumulated wait time warning
if (this.#states.totalWaitTime >= 3) {
this.pushObservation(
`You have waited ${this.#states.totalWaitTime} seconds accumulatively. DO NOT wait any longer unless you have a good reason.`
)
}
// Detect URL change // Detect URL change
const currentURL = await this.pageController.getCurrentUrl() const currentURL = this.#states.browserState?.url || ''
if (currentURL !== this.states.lastURL) { if (currentURL !== this.#states.lastURL) {
this.pushObservation(`Page navigated to → ${currentURL}`) this.pushObservation(`Page navigated to → ${currentURL}`)
this.states.lastURL = currentURL this.#states.lastURL = currentURL
await waitFor(0.5) // wait for page to stabilize await waitFor(0.5) // wait for page to stabilize
} }
// Warn about remaining steps // Remaining steps warning
const remaining = this.config.maxSteps - stepCount const remaining = this.config.maxSteps - step
if (remaining === 5) { if (remaining === 5) {
this.pushObservation( this.pushObservation(
`⚠️ Only ${remaining} steps remaining. Consider wrapping up or calling done with partial results.` `⚠️ Only ${remaining} steps remaining. Consider wrapping up or calling done with partial results.`
@@ -490,12 +527,25 @@ export class PageAgentCore extends EventTarget {
`⚠️ Critical: Only ${remaining} steps left! You must finish the task or call done immediately.` `⚠️ Critical: Only ${remaining} steps left! You must finish the task or call done immediately.`
) )
} }
// Push observations to history and emit
if (this.#observations.length > 0) {
for (const content of this.#observations) {
this.history.push({ type: 'observation', content })
console.log(chalk.cyan('Observation:'), content)
}
this.#observations = []
this.#emitHistoryChange()
}
} }
async #assembleUserPrompt(): Promise<string> { async #assembleUserPrompt(): Promise<string> {
const browserState = this.#states.browserState!
let prompt = '' let prompt = ''
// <instructions> (optional) // <instructions> (optional)
prompt += await this.#getInstructions() prompt += await this.#getInstructions()
// <agent_state> // <agent_state>
@@ -505,38 +555,36 @@ export class PageAgentCore extends EventTarget {
const stepCount = this.history.filter((e) => e.type === 'step').length const stepCount = this.history.filter((e) => e.type === 'step').length
prompt += `<agent_state> prompt += '<agent_state>\n'
<user_request> prompt += '<user_request>\n'
${this.task} prompt += `${this.task}\n`
</user_request> prompt += '</user_request>\n'
<step_info> prompt += '<step_info>\n'
Step ${stepCount + 1} of ${this.config.maxSteps} max possible steps prompt += `Step ${stepCount + 1} of ${this.config.maxSteps} max possible steps\n`
Current date and time: ${new Date().toISOString()} prompt += `Current time: ${new Date().toLocaleString()}\n`
</step_info> prompt += '</step_info>\n'
</agent_state> prompt += '</agent_state>\n\n'
`
// <agent_history> // <agent_history>
// - <step_N> for steps // - <step_N> for steps
// - <sys> for observations and system messages // - <sys> for observations and system messages
prompt += '\n<agent_history>\n' prompt += '<agent_history>\n'
let stepIndex = 0 let stepIndex = 0
for (const event of this.history) { for (const event of this.history) {
if (event.type === 'step') { if (event.type === 'step') {
stepIndex++ stepIndex++
prompt += `<step_${stepIndex}> prompt += `<step_${stepIndex}>\n`
Evaluation of Previous Step: ${event.reflection.evaluation_previous_goal} prompt += `Evaluation of Previous Step: ${event.reflection.evaluation_previous_goal}\n`
Memory: ${event.reflection.memory} prompt += `Memory: ${event.reflection.memory}\n`
Next Goal: ${event.reflection.next_goal} prompt += `Next Goal: ${event.reflection.next_goal}\n`
Action Results: ${event.action.output} prompt += `Action Results: ${event.action.output}\n`
</step_${stepIndex}> prompt += `</step_${stepIndex}>\n`
`
} else if (event.type === 'observation') { } else if (event.type === 'observation') {
prompt += `<sys>${event.content}</sys>\n` prompt += `<sys>${event.content}</sys>\n`
} else if (event.type === 'user_takeover') { } else if (event.type === 'user_takeover') {
prompt += `<sys>User took over control and made changes to the page.</sys>\n` prompt += `<sys>User took over control and made changes to the page</sys>\n`
} else if (event.type === 'error') { } else if (event.type === 'error') {
// Error events are mainly for panel rendering, not included in LLM context // Error events are mainly for panel rendering, not included in LLM context
// to avoid polluting the agent's reasoning with transient errors // to avoid polluting the agent's reasoning with transient errors
@@ -547,47 +595,37 @@ export class PageAgentCore extends EventTarget {
// <browser_state> // <browser_state>
prompt += await this.#getBrowserState() let pageContent = browserState.content
if (this.config.transformPageContent) {
pageContent = await this.config.transformPageContent(pageContent)
}
return trimLines(prompt) prompt += '<browser_state>\n'
prompt += browserState.header + '\n'
prompt += pageContent + '\n'
prompt += browserState.footer + '\n\n'
prompt += '</browser_state>\n\n'
return prompt
} }
#onDone(text: string, success = true) { #onDone(success = true) {
this.pageController.cleanUpHighlights() this.pageController.cleanUpHighlights()
this.pageController.hideMask() // No await - fire and forget this.pageController.hideMask() // No await - fire and forget
this.#setStatus(success ? 'completed' : 'error') this.#setStatus(success ? 'completed' : 'error')
this.#abortController.abort() this.#abortController.abort()
} }
async #getBrowserState(): Promise<string> { dispose() {
const state = await this.pageController.getBrowserState()
let content = state.content
if (this.config.transformPageContent) {
content = await this.config.transformPageContent(content)
}
return trimLines(`<browser_state>
Current Page: [${state.title}](${state.url})
${state.header}
${content}
${state.footer}
</browser_state>
`)
}
dispose(reason?: string) {
console.log('Disposing PageAgent...') console.log('Disposing PageAgent...')
this.disposed = true this.disposed = true
this.pageController.dispose() this.pageController.dispose()
this.history = [] // this.history = []
this.#abortController.abort(reason ?? 'PageAgent disposed') this.#abortController.abort()
// Emit dispose event for UI cleanup // Emit dispose event for UI cleanup
this.dispatchEvent(new Event('dispose')) this.dispatchEvent(new Event('dispose'))
this.config.onDispose?.(this, reason) this.config.onDispose?.(this)
} }
} }
+6
View File
@@ -141,6 +141,12 @@ export interface AgentConfig {
* } * }
*/ */
transformPageContent?: (content: string) => Promise<string> | string transformPageContent?: (content: string) => Promise<string> | string
/**
* Completely override the default system prompt.
* @experimental Use with caution - incorrect prompts may break agent behavior.
*/
customSystemPrompt?: string
} }
export type PageAgentConfig = LLMConfig & AgentConfig & PageControllerConfig export type PageAgentConfig = LLMConfig & AgentConfig & PageControllerConfig
+5 -7
View File
@@ -10,7 +10,7 @@ You excel at following tasks:
</intro> </intro>
<language_settings> <language_settings>
- Default working language: **中文** - Default working language: **English**
- Use the language that user is using. Return in user's language. - Use the language that user is using. Return in user's language.
</language_settings> </language_settings>
@@ -128,7 +128,6 @@ Exhibit the following reasoning patterns to successfully achieve the <user_reque
Here are examples of good output patterns. Use them as reference but never copy them directly. Here are examples of good output patterns. Use them as reference but never copy them directly.
<evaluation_examples> <evaluation_examples>
- Positive Examples:
"evaluation_previous_goal": "Successfully navigated to the product page and found the target information. Verdict: Success" "evaluation_previous_goal": "Successfully navigated to the product page and found the target information. Verdict: Success"
"evaluation_previous_goal": "Clicked the login button and user authentication form appeared. Verdict: Success" "evaluation_previous_goal": "Clicked the login button and user authentication form appeared. Verdict: Success"
</evaluation_examples> </evaluation_examples>
@@ -139,17 +138,16 @@ Here are examples of good output patterns. Use them as reference but never copy
<next_goal_examples> <next_goal_examples>
"next_goal": "Click on the 'Add to Cart' button to proceed with the purchase flow." "next_goal": "Click on the 'Add to Cart' button to proceed with the purchase flow."
"next_goal": "Extract details from the first item on the page."
</next_goal_examples> </next_goal_examples>
</examples> </examples>
<output> <output>
You must ALWAYS respond with a valid JSON in this exact format:
{ {
"evaluation_previous_goal": "Concise one-sentence analysis of your last action. Clearly state success, failure, or uncertain.", "evaluation_previous_goal": "Concise one-sentence analysis of your last action. Clearly state success, failure, or uncertain.",
"memory": "1-3 concise sentences of specific memory of this step and overall progress. You should put here everything that will help you track progress in future steps. Like counting pages visited, items found, etc.", "memory": "1-3 concise sentences of specific memory of this step and overall progress. You should put here everything that will help you track progress in future steps. Like counting pages visited, items found, etc.",
"next_goal": "State the next immediate goal and action to achieve it, in one clear sentence." "next_goal": "State the next immediate goal and action to achieve it, in one clear sentence.",
"action":{"one_action_name": {// action-specific parameter}} "action":{
"Action name": {// Action parameters}
}
} }
</output> </output>
+11 -20
View File
@@ -2,7 +2,7 @@
* Internal tools for PageAgent. * Internal tools for PageAgent.
* @note Adapted from browser-use * @note Adapted from browser-use
*/ */
import zod, { type z } from 'zod' import * as zod from 'zod'
import type { PageAgentCore } from '../PageAgentCore' import type { PageAgentCore } from '../PageAgentCore'
import { waitFor } from '../utils' import { waitFor } from '../utils'
@@ -13,7 +13,7 @@ import { waitFor } from '../utils'
export interface PageAgentTool<TParams = any> { export interface PageAgentTool<TParams = any> {
// name: string // name: string
description: string description: string
inputSchema: z.ZodType<TParams> inputSchema: zod.ZodType<TParams>
execute: (this: PageAgentCore, args: TParams) => Promise<string> execute: (this: PageAgentCore, args: TParams) => Promise<string>
} }
@@ -31,14 +31,13 @@ tools.set(
'done', 'done',
tool({ tool({
description: description:
'Complete task - provide a summary of results for the user. Set success=True if task completed successfully, false otherwise. Text should be your response to the user summarizing results.', 'Complete task. Text is your final response to the user — keep it concise unless the user explicitly asks for detail.',
inputSchema: zod.object({ inputSchema: zod.object({
text: zod.string(), text: zod.string(),
success: zod.boolean().default(true), success: zod.boolean().default(true),
}), }),
execute: async function (this: PageAgentCore, input) { execute: async function (this: PageAgentCore, input) {
// @note main loop will handle this one // @note main loop will handle this one
// this.onDone(input.text, input.success)
return Promise.resolve('Task completed') return Promise.resolve('Task completed')
}, },
}) })
@@ -47,25 +46,17 @@ tools.set(
tools.set( tools.set(
'wait', 'wait',
tool({ tool({
description: description: 'Wait for x seconds. Can be used to wait until the page or data is fully loaded.',
'Wait for x seconds. default 1s (max 10 seconds, min 1 second). This can be used to wait until the page or data is fully loaded.',
inputSchema: zod.object({ inputSchema: zod.object({
seconds: zod.number().min(1).max(10).default(1), seconds: zod.number().min(1).max(10).default(1),
}), }),
execute: async function (this: PageAgentCore, input) { execute: async function (this: PageAgentCore, input) {
// try to subtract LLM calling time from the actual wait time
const lastTimeUpdate = await this.pageController.getLastUpdateTime() const lastTimeUpdate = await this.pageController.getLastUpdateTime()
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000) const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000)
console.log(`actualWaitTime: ${actualWaitTime} seconds`) console.log(`actualWaitTime: ${actualWaitTime} seconds`)
await waitFor(actualWaitTime) await waitFor(actualWaitTime)
this.states.totalWaitTime += input.seconds
if (this.states.totalWaitTime >= 3) {
this.pushObservation(
`You have waited ${this.states.totalWaitTime} seconds accumulatively. Do NOT wait any longer unless you have a good reason.`
)
}
return `✅ Waited for ${input.seconds} seconds.` return `✅ Waited for ${input.seconds} seconds.`
}, },
}) })
@@ -106,7 +97,7 @@ tools.set(
tools.set( tools.set(
'input_text', 'input_text',
tool({ tool({
description: 'Click and input text into a input interactive element', description: 'Click and type text into an interactive input element',
inputSchema: zod.object({ inputSchema: zod.object({
index: zod.int().min(0), index: zod.int().min(0),
text: zod.string(), text: zod.string(),
@@ -140,8 +131,7 @@ tools.set(
tools.set( tools.set(
'scroll', 'scroll',
tool({ tool({
description: description: 'Scroll the page vertically. Use index for scroll elements (dropdowns/custom UI).',
'Scroll the page by specified number of pages (set down=True to scroll down, down=False to scroll up, num_pages=number of pages to scroll like 0.5 for half page, 1.0 for one page, etc.). Optional index parameter to scroll within a specific element or its scroll container (works well for dropdowns and custom UI components). Optional pixels parameter to scroll by a specific number of pixels instead of pages.',
inputSchema: zod.object({ inputSchema: zod.object({
down: zod.boolean().default(true), down: zod.boolean().default(true),
num_pages: zod.number().min(0).max(10).optional().default(0.1), num_pages: zod.number().min(0).max(10).optional().default(0.1),
@@ -158,11 +148,14 @@ tools.set(
}) })
) )
/**
* @todo Tables need a dedicated parser to extract structured data. This tool is useless.
*/
tools.set( tools.set(
'scroll_horizontally', 'scroll_horizontally',
tool({ tool({
description: description:
'Scroll the page or element horizontally (set right=True to scroll right, right=False to scroll left, pixels=number of pixels to scroll). Optional index parameter to scroll within a specific element or its scroll container (works well for wide tables).', 'Scroll the page horizontally, or within a specific element by index. Useful for wide tables.',
inputSchema: zod.object({ inputSchema: zod.object({
right: zod.boolean().default(true), right: zod.boolean().default(true),
pixels: zod.number().int().min(0), pixels: zod.number().int().min(0),
@@ -190,8 +183,6 @@ tools.set(
}) })
) )
// @todo get_dropdown_options
// @todo select_dropdown_option
// @todo send_keys // @todo send_keys
// @todo upload_file // @todo upload_file
// @todo go_back // @todo go_back
+22 -8
View File
@@ -33,7 +33,7 @@ export interface MacroToolResult {
/** /**
* A single agent step with reflection and action * A single agent step with reflection and action
*/ */
export interface AgentStep { export interface AgentStepEvent {
type: 'step' type: 'step'
stepIndex: number stepIndex: number
reflection: Partial<AgentReflection> reflection: Partial<AgentReflection>
@@ -51,6 +51,8 @@ export interface AgentStep {
} }
/** Raw LLM response for debugging */ /** Raw LLM response for debugging */
rawResponse?: unknown rawResponse?: unknown
/** Raw LLM request for debugging */
rawRequest?: unknown
} }
/** /**
@@ -69,21 +71,33 @@ export interface UserTakeoverEvent {
} }
/** /**
* Error event (retry or error from LLM) * Retry event - LLM call is being retried
*/ */
export interface ErrorEvent { export interface RetryEvent {
type: 'error' type: 'retry'
errorType: 'retry' | 'error' message: string
attempt: number
maxAttempts: number
}
/**
* Error event - fatal error from LLM or execution
*/
export interface AgentErrorEvent {
type: 'error'
message: string message: string
attempt?: number
maxAttempts?: number
rawResponse?: unknown rawResponse?: unknown
} }
/** /**
* Union type for all history events * Union type for all history events
*/ */
export type HistoricalEvent = AgentStep | ObservationEvent | UserTakeoverEvent | ErrorEvent export type HistoricalEvent =
| AgentStepEvent
| ObservationEvent
| UserTakeoverEvent
| RetryEvent
| AgentErrorEvent
/** /**
* Agent execution status * Agent execution status
-17
View File
@@ -1,17 +0,0 @@
import chalk from 'chalk'
/**
* Simple assertion function that throws an error if the condition is falsy
* @param condition - The condition to assert
* @param message - Optional error message
* @throws Error if condition is falsy
*/
export function assert(condition: unknown, message?: string, silent?: boolean): asserts condition {
if (!condition) {
const errorMessage = message ?? 'Assertion failed'
if (!silent) console.error(chalk.red(`❌ assert: ${errorMessage}`))
throw new Error(errorMessage)
}
}
+18 -31
View File
@@ -1,27 +1,7 @@
import chalk from 'chalk'
export { normalizeResponse } from './autoFixer' export { normalizeResponse } from './autoFixer'
/**
* Wait until condition becomes true
* @returns Returns when condition becomes true, throws otherwise
* @param timeout Timeout in milliseconds, default 0 means no timeout, throws error on timeout
*/
export async function waitUntil(check: () => boolean, timeout = 60 * 60_1000): Promise<boolean> {
if (check()) return true
return new Promise((resolve, reject) => {
const start = Date.now()
const interval = setInterval(() => {
if (check()) {
clearInterval(interval)
resolve(true)
} else if (Date.now() - start > timeout) {
clearInterval(interval)
reject(new Error('Timeout waiting for condition to become true'))
}
}, 100)
})
}
export async function waitFor(seconds: number): Promise<void> { export async function waitFor(seconds: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, seconds * 1000)) await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
} }
@@ -37,15 +17,6 @@ export function truncate(text: string, maxLength: number): string {
// //
export function trimLines(text: string): string {
return text
.split('\n')
.map((line) => line.trim())
.join('\n')
}
//
export function randomID(existingIDs?: string[]): string { export function randomID(existingIDs?: string[]): string {
let id = Math.random().toString(36).substring(2, 11) let id = Math.random().toString(36).substring(2, 11)
@@ -85,3 +56,19 @@ export function uid() {
ids.push(id) ids.push(id)
return id return id
} }
/**
* Simple assertion function that throws an error if the condition is falsy
* @param condition - The condition to assert
* @param message - Optional error message
* @throws Error if condition is falsy
*/
export function assert(condition: unknown, message?: string, silent?: boolean): asserts condition {
if (!condition) {
const errorMessage = message ?? 'Assertion failed'
if (!silent) console.error(chalk.red(`❌ assert: ${errorMessage}`))
throw new Error(errorMessage)
}
}
+2
View File
@@ -0,0 +1,2 @@
.wxt
src/components/ui
+57
View File
@@ -0,0 +1,57 @@
# Privacy Policy for Page Agent Extension
**Last updated:** January 2026
## Overview
Page Agent Extension is a browser automation tool that uses AI to help you interact with web pages. This policy explains what data is processed and under what circumstances.
## Data Processing
### Local Processing
The extension performs DOM analysis and automation actions **locally in your browser**. Your browsing history, passwords, and form data are not accessed or collected by the extension itself.
### Data Transmission
Data is transmitted to external servers **only when you initiate an automation task**. When this occurs:
- Your task instructions (natural language commands)
- Sanitized page structure (simplified DOM, excluding sensitive form values)
are sent to the LLM API endpoint configured in **your settings**.
**If you configure a third-party LLM provider** (e.g., OpenAI, Anthropic, or others), data is sent directly to that provider. Their privacy policies apply.
**If you use our testing endpoint**, your requests are proxied to [DeepSeek](https://deepseek.com) for AI processing. Regarding this test endpoint:
- This endpoint is provided for evaluation purposes only and is not recommended for production or daily use
- We do **not** store your task content, page content, or visited URLs
- Minimal logging (timestamps, request metadata, IP addresses) may occur for abuse prevention and service stability
- DeepSeek's [Privacy Policy](https://cdn.deepseek.com/policies/en-US/deepseek-privacy-policy.html) applies to their processing of your requests
## Data Storage
- **Local storage only**: Your configuration (API endpoint, API key, model selection) is stored in your browser via `chrome.storage.local`
- **No cloud sync**: Configuration is not synced to any external server
- **No analytics**: The extension does not include any analytics or tracking code
## Your Control
- The extension is open source and can be audited by anyone
- You choose which LLM provider to use
- You may configure your own API endpoint at any time
- You can clear all stored data by removing the extension
## Changes to This Policy
We may update this policy as the extension evolves. Significant changes will be noted in the extension's release notes.
## Contact
For questions about this privacy policy:
https://github.com/alibaba/page-agent/issues
---
Source code: https://github.com/alibaba/page-agent
+24
View File
@@ -0,0 +1,24 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@magicui": "https://magicui.design/r/{name}.json"
}
}
+226
View File
@@ -0,0 +1,226 @@
# Page Agent Extension API
Integrate the Page Agent extension into your web app and trigger multi-page browser tasks from page JavaScript.
## Installation
### 1. Install the browser extension
Primary channel:
- Chrome Web Store: https://chromewebstore.google.com/detail/page-agent-ext/akldabonmimlicnjlflnapfeklbfemhj
Latest updates are often published earlier on:
- GitHub Releases: https://github.com/alibaba/page-agent/releases
### 2. Install type definitions (recommended)
```bash
npm install @page-agent/core --save-dev
```
### 3. Authorization (Token)
The token allows your page JS to call the extension API (`window.PAGE_AGENT_EXT`) and execute multi-page tasks.
Why token-based access is required:
- The extension has broad browser permissions (page access, navigation, multi-tab control).
- If abused, it can harm user privacy and security.
- Users must explicitly provide the token only to applications they trust.
Setup:
1. Open the extension side panel and copy your auth token.
2. Set the token in your page:
```typescript
localStorage.setItem('PageAgentExtUserAuthToken', 'your-token')
```
## Quick Start
```typescript
import type {
AgentActivity,
AgentStatus,
ExecutionResult,
HistoricalEvent,
} from '@page-agent/core'
// Wait for extension injection (up to 1 second)
async function waitForExtension(timeout = 1000): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeout) {
if (window.PAGE_AGENT_EXT) return true
await new Promise((r) => setTimeout(r, 100))
}
return false
}
// Usage
if (await waitForExtension()) {
const result = await window.PAGE_AGENT_EXT!.execute('Click the login button', {
baseURL: 'https://api.openai.com/v1',
apiKey: 'your-api-key',
model: 'gpt-5.2',
onStatusChange: (status) => console.log('Status:', status),
onActivity: (activity) => console.log('Activity:', activity),
})
console.log('Result:', result)
}
```
## Global API
After token match, the extension injects APIs into `window`.
### `window.PAGE_AGENT_EXT_VERSION`
Extension version string (for capability checks before using the main API).
### `window.PAGE_AGENT_EXT`
Main namespace object.
#### `PAGE_AGENT_EXT.execute(task, config)`
Execute one agent task.
Parameters:
| Name | Type | Required | Description |
| ---- | ---- | -------- | ----------- |
| `task` | `string` | Yes | Task description |
| `config` | `ExecuteConfig` | Yes | LLM settings, options, and callbacks |
Returns: `Promise<ExecutionResult>`
#### `PAGE_AGENT_EXT.stop()`
Stop the current task.
## Types
Install `@page-agent/core` for complete types:
```typescript
import type {
AgentActivity,
AgentStatus,
ExecutionResult,
HistoricalEvent,
} from '@page-agent/core'
export interface ExecuteConfig {
baseURL: string
apiKey: string
model: string
// Include the initial tab where page JS starts. Default: true.
includeInitialTab?: boolean
onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
}
export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
```
`AgentStatus`
```typescript
type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
```
`AgentActivity`
```typescript
type AgentActivity =
| { type: 'thinking' }
| { type: 'executing'; tool: string; input: unknown }
| { type: 'executed'; tool: string; input: unknown; output: string; duration: number }
| { type: 'retrying'; attempt: number; maxAttempts: number }
| { type: 'error'; message: string }
```
`HistoricalEvent`
```typescript
type HistoricalEvent =
| { type: 'step'; stepIndex: number; reflection: AgentReflection; action: Action }
| { type: 'observation'; content: string }
| { type: 'user_takeover' }
| { type: 'retry'; message: string; attempt: number; maxAttempts: number }
| { type: 'error'; message: string; rawResponse?: unknown }
```
`ExecutionResult`
```typescript
interface ExecutionResult {
success: boolean
data: string
history: HistoricalEvent[]
}
```
## Usage Examples
### Basic Execution
```typescript
const result = await window.PAGE_AGENT_EXT!.execute(
'Fill in the email field with test@example.com and click Submit',
{
baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-5.2',
includeInitialTab: false, // Optional: exclude current tab
onStatusChange: (status) => console.log(status),
onActivity: (activity) => console.log(activity),
}
)
```
### Stop the Current Task
```typescript
window.PAGE_AGENT_EXT!.stop()
```
## Window Type Declaration
If you are not importing `@page-agent/core`, add:
```typescript
import type {
AgentActivity,
AgentStatus,
ExecutionResult,
HistoricalEvent,
} from '@page-agent/core'
interface ExecuteConfig {
baseURL: string
apiKey: string
model: string
includeInitialTab?: boolean
onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
}
declare global {
interface Window {
PAGE_AGENT_EXT_VERSION?: string
PAGE_AGENT_EXT?: {
version: string
execute: Execute
stop: () => void
}
}
}
```
+226
View File
@@ -0,0 +1,226 @@
# Page Agent 浏览器插件 API
在你的网页应用中接入 Page Agent 插件,并通过页面 JavaScript 发起多页面浏览器任务。
## 安装
### 1. 安装浏览器插件
首选渠道:
- Chrome 应用商店:https://chromewebstore.google.com/detail/page-agent-ext/akldabonmimlicnjlflnapfeklbfemhj
通常更快提供最新构建的渠道:
- GitHub Releaseshttps://github.com/alibaba/page-agent/releases
### 2. 安装类型定义(推荐)
```bash
npm install @page-agent/core --save-dev
```
### 3. 授权(Token
token 用于让页面 JS 调用扩展 API(`window.PAGE_AGENT_EXT`)并执行多页面任务。
为什么必须使用 token
- 插件具备较广的浏览器权限(页面访问、导航、多标签控制)。
- 若被滥用,可能危害用户隐私与安全。
- 用户必须主动将 token 提供给其信任的应用。
配置步骤:
1. 在扩展侧边栏中复制 auth token。
2. 在页面中设置 token
```typescript
localStorage.setItem('PageAgentExtUserAuthToken', 'your-token')
```
## 快速开始
```typescript
import type {
AgentActivity,
AgentStatus,
ExecutionResult,
HistoricalEvent,
} from '@page-agent/core'
// 等待插件注入(最多 1 秒)
async function waitForExtension(timeout = 1000): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeout) {
if (window.PAGE_AGENT_EXT) return true
await new Promise((r) => setTimeout(r, 100))
}
return false
}
// 使用
if (await waitForExtension()) {
const result = await window.PAGE_AGENT_EXT!.execute('点击登录按钮', {
baseURL: 'https://api.openai.com/v1',
apiKey: 'your-api-key',
model: 'gpt-5.2',
onStatusChange: (status) => console.log('状态:', status),
onActivity: (activity) => console.log('活动:', activity),
})
console.log('结果:', result)
}
```
## 全局 API
token 匹配后,插件会在 `window` 上注入 API。
### `window.PAGE_AGENT_EXT_VERSION`
插件版本号字符串,可用于在访问主 API 前做能力检查。
### `window.PAGE_AGENT_EXT`
主命名空间对象。
#### `PAGE_AGENT_EXT.execute(task, config)`
执行 Agent 任务。
参数:
| 名称 | 类型 | 必填 | 说明 |
| ---- | ---- | ---- | ---- |
| `task` | `string` | 是 | 任务描述 |
| `config` | `ExecuteConfig` | 是 | LLM 设置、执行选项和回调 |
返回:`Promise<ExecutionResult>`
#### `PAGE_AGENT_EXT.stop()`
停止当前任务。
## 类型定义
安装 `@page-agent/core` 获取完整类型:
```typescript
import type {
AgentActivity,
AgentStatus,
ExecutionResult,
HistoricalEvent,
} from '@page-agent/core'
export interface ExecuteConfig {
baseURL: string
apiKey: string
model: string
// 是否包含启动脚本所在标签页。默认 true。
includeInitialTab?: boolean
onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
}
export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
```
`AgentStatus`
```typescript
type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
```
`AgentActivity`
```typescript
type AgentActivity =
| { type: 'thinking' }
| { type: 'executing'; tool: string; input: unknown }
| { type: 'executed'; tool: string; input: unknown; output: string; duration: number }
| { type: 'retrying'; attempt: number; maxAttempts: number }
| { type: 'error'; message: string }
```
`HistoricalEvent`
```typescript
type HistoricalEvent =
| { type: 'step'; stepIndex: number; reflection: AgentReflection; action: Action }
| { type: 'observation'; content: string }
| { type: 'user_takeover' }
| { type: 'retry'; message: string; attempt: number; maxAttempts: number }
| { type: 'error'; message: string; rawResponse?: unknown }
```
`ExecutionResult`
```typescript
interface ExecutionResult {
success: boolean
data: string
history: HistoricalEvent[]
}
```
## 使用示例
### 基础执行
```typescript
const result = await window.PAGE_AGENT_EXT!.execute(
'在邮箱输入框填入 test@example.com 然后点击提交',
{
baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-5.2',
includeInitialTab: false, // 可选:排除当前标签页
onStatusChange: (status) => console.log(status),
onActivity: (activity) => console.log(activity),
}
)
```
### 停止当前任务
```typescript
window.PAGE_AGENT_EXT!.stop()
```
## Window 类型声明
如果你不直接引入 `@page-agent/core`,可添加以下声明:
```typescript
import type {
AgentActivity,
AgentStatus,
ExecutionResult,
HistoricalEvent,
} from '@page-agent/core'
interface ExecuteConfig {
baseURL: string
apiKey: string
model: string
includeInitialTab?: boolean
onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
}
declare global {
interface Window {
PAGE_AGENT_EXT_VERSION?: string
PAGE_AGENT_EXT?: {
version: string
execute: Execute
stop: () => void
}
}
}
```
+48
View File
@@ -0,0 +1,48 @@
{
"name": "@page-agent/ext",
"private": true,
"version": "0.1.5",
"type": "module",
"scripts": {
"dev": "wxt",
"build:ext": "wxt build",
"zip": "wxt zip",
"postinstall": "wxt prepare"
},
"devDependencies": {
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/chrome": "^0.1.34",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.1",
"@wxt-dev/module-react": "^1.1.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"idb": "^8.0.3",
"lucide-react": "^0.563.0",
"motion": "^12.34.0",
"next-themes": "^0.4.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"rough-notation": "^0.5.1",
"simple-icons": "^16.8.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0",
"wxt": "^0.20.14"
},
"dependencies": {
"@page-agent/core": "1.3.0",
"@page-agent/llms": "1.3.0",
"@page-agent/page-controller": "1.3.0",
"@page-agent/ui": "1.3.0",
"chalk": "^5.6.2",
"zod": "^4.3.5"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

@@ -0,0 +1 @@
system_prompt.md
@@ -0,0 +1,96 @@
import { type PageAgentConfig, PageAgentCore } from '@page-agent/core'
import { RemotePageController } from './RemotePageController'
import { TabsController } from './TabsController'
import SYSTEM_PROMPT from './system_prompt.md?raw'
import { createTabTools } from './tabTools'
/** Detect user language from browser settings */
function detectLanguage(): 'en-US' | 'zh-CN' {
const lang = navigator.language || navigator.languages?.[0] || 'en-US'
return lang.startsWith('zh') ? 'zh-CN' : 'en-US'
}
/**
* MultiPageAgent
* - use with extension
* - can be used from a side panel or a content script
*/
export class MultiPageAgent extends PageAgentCore {
constructor(config: Omit<PageAgentConfig, 'pageController'> & { includeInitialTab?: boolean }) {
// multi page controller
const tabsController = new TabsController()
const pageController = new RemotePageController(tabsController)
const customTools = createTabTools(tabsController)
// system prompt - auto-detect language if not specified
const language = config.language ?? detectLanguage()
const targetLanguage = language === 'zh-CN' ? '中文' : 'English'
const systemPrompt = SYSTEM_PROMPT.replace(
/Default working language: \*\*.*?\*\*/,
`Default working language: **${targetLanguage}**`
)
// include initial tab for controlling
const includeInitialTab = config.includeInitialTab ?? true
/**
* When the agent is in side-panel and user closed the side-panel.
* There is no chance for isAgentRunning to be set false.
* (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.)
* This heartbeat mechanism acts as a backup.
*/
let heartBeatInterval: null | number = null
super({
...config,
pageController: pageController as any,
customTools: customTools,
customSystemPrompt: systemPrompt,
onBeforeTask: async (agent) => {
await tabsController.init(agent.task, includeInitialTab)
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) => {
// make sure the current tab is loaded before the step starts
await tabsController.waitUntilTabLoaded(tabsController.currentTabId!)
},
onDispose: () => {
if (heartBeatInterval) {
window.clearInterval(heartBeatInterval)
heartBeatInterval = null
}
chrome.storage.local.set({
isAgentRunning: false,
})
tabsController.dispose()
},
})
}
}
@@ -0,0 +1,44 @@
/**
* background logics for RemotePageController
* - redirect messages from RemotePageController(Agent, extension pages) to ContentScript
*/
export function handlePageControlMessage(
message: { type: 'PAGE_CONTROL'; action: string; payload: any; targetTabId: number },
sender: chrome.runtime.MessageSender,
sendResponse: (response: unknown) => void
): true | undefined {
const PREFIX = '[RemotePageController.background]'
function debug(...messages: any[]) {
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
const { action, payload, targetTabId } = message
if (action === 'get_my_tab_id') {
debug('get_my_tab_id', sender.tab?.id)
sendResponse({ tabId: sender.tab?.id || null })
return
}
// proxy to content script
chrome.tabs
.sendMessage(targetTabId, {
type: 'PAGE_CONTROL',
action,
payload,
})
.then((result) => {
sendResponse(result)
})
.catch((error) => {
console.error(PREFIX, error)
sendResponse({
success: false,
error: error instanceof Error ? error.message : String(error),
})
})
return true // async response
}
@@ -0,0 +1,132 @@
/**
* content script for RemotePageController
*/
import { PageController } from '@page-agent/page-controller'
export function initPageController() {
let pageController: PageController | null = null
let intervalID: number | null = null
const myTabIdPromise = chrome.runtime
.sendMessage({ type: 'PAGE_CONTROL', action: 'get_my_tab_id' })
.then((response) => {
return (response as { tabId: number | null }).tabId
})
.catch((error) => {
console.error('[RemotePageController.ContentScript]: Failed to get my tab id', error)
return null
})
function getPC(): PageController {
if (!pageController) {
pageController = new PageController({ enableMask: false, viewportExpansion: 400 })
}
return pageController
}
intervalID = window.setInterval(async () => {
const agentHeartbeat = (await chrome.storage.local.get('agentHeartbeat')).agentHeartbeat
const now = Date.now()
const agentInTouch = typeof agentHeartbeat === 'number' && now - agentHeartbeat < 2_000
const isAgentRunning = (await chrome.storage.local.get('isAgentRunning')).isAgentRunning
const currentTabId = (await chrome.storage.local.get('currentTabId')).currentTabId
const shouldShowMask = isAgentRunning && agentInTouch && currentTabId === (await myTabIdPromise)
if (shouldShowMask) {
const pc = getPC()
pc.initMask()
await pc.showMask()
} else {
// await getPC().hideMask()
if (pageController) {
pageController.hideMask()
pageController.cleanUpHighlights()
}
}
if (!isAgentRunning && agentInTouch) {
if (pageController) {
pageController.dispose()
pageController = null
}
}
}, 500)
chrome.runtime.onMessage.addListener((message, sender, sendResponse): true | undefined => {
if (message.type !== 'PAGE_CONTROL') {
// sendResponse({
// success: false,
// error: `[RemotePageController.ContentScript]: Invalid message type: ${message.type}`,
// })
return
}
const { action, payload } = message
const methodName = getMethodName(action)
const pc = getPC() as any
switch (action) {
case 'get_last_update_time':
case 'get_browser_state':
case 'update_tree':
case 'clean_up_highlights':
case 'click_element':
case 'input_text':
case 'select_option':
case 'scroll':
case 'scroll_horizontally':
case 'execute_javascript':
pc[methodName](...(payload || []))
.then((result: any) => sendResponse(result))
.catch((error: any) =>
sendResponse({
success: false,
error: error instanceof Error ? error.message : String(error),
})
)
break
default:
sendResponse({
success: false,
error: `Unknown PAGE_CONTROL action: ${action}`,
})
}
return true
})
}
function getMethodName(action: string): string {
switch (action) {
case 'get_last_update_time':
return 'getLastUpdateTime' as const
case 'get_browser_state':
return 'getBrowserState' as const
case 'update_tree':
return 'updateTree' as const
case 'clean_up_highlights':
return 'cleanUpHighlights' as const
// DOM actions
case 'click_element':
return 'clickElement' as const
case 'input_text':
return 'inputText' as const
case 'select_option':
return 'selectOption' as const
case 'scroll':
return 'scroll' as const
case 'scroll_horizontally':
return 'scrollHorizontally' as const
case 'execute_javascript':
return 'executeJavascript' as const
default:
return action
}
}
@@ -0,0 +1,198 @@
import type { BrowserState } from '@page-agent/page-controller'
import type { TabsController } from './TabsController'
const PREFIX = '[RemotePageController]'
function debug(...messages: any[]) {
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
function sendMessage(message: {
type: 'PAGE_CONTROL'
action: string
targetTabId: number
payload?: any
}): Promise<any> {
return chrome.runtime.sendMessage(message).catch((error) => {
console.error(PREFIX, message.action, error)
return null
})
}
/**
* Agent side page controller.
* - live in the agent env (extension page or content script)
* - communicates with remote PageController via sw
*/
export class RemotePageController {
tabsController: TabsController
constructor(tabsController: TabsController) {
this.tabsController = tabsController
}
get currentTabId(): number | null {
return this.tabsController.currentTabId
}
private async getCurrentUrl(): Promise<string> {
if (!this.currentTabId) return ''
const { url } = await this.tabsController.getTabInfo(this.currentTabId)
return url || ''
}
private async getCurrentTitle(): Promise<string> {
if (!this.currentTabId) return ''
const { title } = await this.tabsController.getTabInfo(this.currentTabId)
return title || ''
}
async getLastUpdateTime(): Promise<number> {
if (!this.currentTabId) throw new Error('tabsController not initialized.')
return sendMessage({
type: 'PAGE_CONTROL',
action: 'get_last_update_time',
targetTabId: this.currentTabId,
})
}
async getBrowserState(): Promise<BrowserState> {
if (!this.currentTabId) throw new Error('tabsController not initialized.')
let browserState = {} as BrowserState
debug('getBrowserState', this.currentTabId)
const currentUrl = await this.getCurrentUrl()
const currentTitle = await this.getCurrentTitle()
if (!this.currentTabId || !isContentScriptAllowed(currentUrl)) {
browserState = {
url: currentUrl,
title: currentTitle,
header: '',
content: '(empty page. either current page is not readable or not loaded yet.)',
footer: '',
}
} else {
browserState = await sendMessage({
type: 'PAGE_CONTROL',
action: 'get_browser_state',
targetTabId: this.currentTabId,
})
}
const sum = await this.tabsController.summarizeTabs()
browserState.header = sum + '\n\n' + (browserState.header || '')
debug('getBrowserState: success', this.currentTabId, browserState)
return browserState
}
async updateTree(): Promise<void> {
if (!this.currentTabId || !isContentScriptAllowed(await this.getCurrentUrl())) {
return
}
await sendMessage({
type: 'PAGE_CONTROL',
action: 'update_tree',
targetTabId: this.currentTabId,
})
}
async cleanUpHighlights(): Promise<void> {
if (!this.currentTabId || !isContentScriptAllowed(await this.getCurrentUrl())) {
return
}
await sendMessage({
type: 'PAGE_CONTROL',
action: 'clean_up_highlights',
targetTabId: this.currentTabId,
})
}
async clickElement(...args: any[]): Promise<DomActionReturn> {
const res = await this.remoteCallDomAction('click_element', args)
// @note may cause page navigation, wait for 1 second to ensure the page loading started
await new Promise((resolve) => setTimeout(resolve, 1000))
return res
}
async inputText(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('input_text', args)
}
async selectOption(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('select_option', args)
}
async scroll(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('scroll', args)
}
async scrollHorizontally(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('scroll_horizontally', args)
}
async executeJavascript(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('execute_javascript', args)
}
/** @note Managed by content script via storage polling. */
async showMask(): Promise<void> {}
/** @note Managed by content script via storage polling. */
async hideMask(): Promise<void> {}
/** @note Managed by content script via storage polling. */
dispose(): void {}
private async remoteCallDomAction(action: string, payload: any[]): Promise<DomActionReturn> {
if (!this.currentTabId) {
return { success: false, message: 'RemotePageController not initialized.' }
}
if (!isContentScriptAllowed(await this.getCurrentUrl())) {
return {
success: false,
message:
'Operation not allowed on this page. Use open_new_tab to navigate to a web page first.',
}
}
return sendMessage({
type: 'PAGE_CONTROL',
action: action,
targetTabId: this.currentTabId!,
payload,
})
}
}
interface DomActionReturn {
success: boolean
message: string
}
/**
* Check if a URL can run content scripts.
*/
function isContentScriptAllowed(url: string | undefined): boolean {
if (!url) return false
const restrictedPatterns = [
/^chrome:\/\//,
/^chrome-extension:\/\//,
/^about:/,
/^edge:\/\//,
/^brave:\/\//,
/^opera:\/\//,
/^vivaldi:\/\//,
/^file:\/\//,
/^view-source:/,
/^devtools:\/\//,
]
return !restrictedPatterns.some((pattern) => pattern.test(url))
}
@@ -0,0 +1,160 @@
/**
* background logics for TabsController
*/
import type { TabAction } from './TabsController'
const PREFIX = '[TabsController.background]'
function debug(...messages: any[]) {
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
export function handleTabControlMessage(
message: { type: 'TAB_CONTROL'; action: TabAction; payload: any },
sender: chrome.runtime.MessageSender,
sendResponse: (response: unknown) => void
): true | undefined {
const { action, payload } = message
switch (action as TabAction) {
case 'get_active_tab': {
debug('get_active_tab')
chrome.tabs
.query({ active: true, currentWindow: true })
.then((tabs) => {
const tabId = tabs.length > 0 ? tabs[0].id || null : null
debug('get_active_tab: success', tabId)
sendResponse({ success: true, tabId })
})
.catch((error) => {
sendResponse({ error: error instanceof Error ? error.message : String(error) })
})
return true // async response
}
case 'get_tab_info': {
debug('get_tab_info', payload)
chrome.tabs
.get(payload.tabId)
.then((tab) => {
debug('get_tab_info: success', tab)
sendResponse(tab)
})
.catch((error) => {
sendResponse({ error: error instanceof Error ? error.message : String(error) })
})
return true // async response
}
case 'open_new_tab': {
debug('open_new_tab', payload)
chrome.tabs
.create({ url: payload.url, active: false })
.then((newTab) => {
debug('open_new_tab: success', newTab)
sendResponse({ success: true, tabId: newTab.id, windowId: newTab.windowId })
})
.catch((error) => {
sendResponse({ error: error instanceof Error ? error.message : String(error) })
})
return true // async response
}
case 'create_tab_group': {
debug('create_tab_group', payload)
chrome.tabs
.group({ tabIds: payload.tabIds, createProperties: { windowId: payload.windowId } })
.then((groupId) => {
debug('create_tab_group: success', groupId)
sendResponse({ success: true, groupId })
})
.catch((error) => {
console.error(PREFIX, 'Failed to create tab group', error)
sendResponse({ error: error instanceof Error ? error.message : String(error) })
})
return true // async response
}
case 'update_tab_group': {
debug('update_tab_group', payload)
chrome.tabGroups
.update(payload.groupId, payload.properties)
.then(() => {
sendResponse({ success: true })
})
.catch((error) => {
sendResponse({ error: error instanceof Error ? error.message : String(error) })
})
return true // async response
}
case 'add_tab_to_group': {
debug('add_tab_to_group', payload)
chrome.tabs
.group({ tabIds: payload.tabId, groupId: payload.groupId })
.then(() => {
sendResponse({ success: true })
})
.catch((error) => {
sendResponse({ error: error instanceof Error ? error.message : String(error) })
})
return true // async response
}
case 'close_tab': {
debug('close_tab', payload)
chrome.tabs
.remove(payload.tabId)
.then(() => {
sendResponse({ success: true })
})
.catch((error) => {
sendResponse({ error: error instanceof Error ? error.message : String(error) })
})
return true // async response
}
default:
sendResponse({ error: `Unknown action: ${action}` })
return
}
}
export function setupTabChangeEvents() {
console.log('[TabsController.background] setupTabChangeEvents')
chrome.tabs.onCreated.addListener((tab) => {
debug('onCreated', tab)
chrome.runtime
.sendMessage({ type: 'TAB_CHANGE', action: 'created', payload: { tab } })
.catch((error) => {
debug('onCreated error:', error)
})
})
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
debug('onRemoved', tabId, removeInfo)
chrome.runtime
.sendMessage({
type: 'TAB_CHANGE',
action: 'removed',
payload: { tabId, removeInfo },
})
.catch((error) => {
debug('onRemoved error:', error)
})
})
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
debug('onUpdated', tabId, changeInfo)
chrome.runtime
.sendMessage({
type: 'TAB_CHANGE',
action: 'updated',
payload: { tabId, changeInfo, tab },
})
.catch((error) => {
debug('onUpdated error:', error)
})
})
}
@@ -0,0 +1,357 @@
const PREFIX = '[TabsController]'
function debug(...messages: any[]) {
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
function sendMessage(message: {
type: 'TAB_CONTROL'
action: TabAction
payload?: any
}): Promise<any> {
return chrome.runtime.sendMessage(message).catch((error) => {
console.error(PREFIX, message.action, error)
return null
})
}
/**
* Controller for managing browser tabs.
* - live in the agent env (extension page or content script)
* - no chrome apis. call sw for tab operations
*/
export class TabsController extends EventTarget {
currentTabId: number | null = null
private tabs: TabMeta[] = []
private initialTabId: number | null = null
private tabGroupId: number | null = null
private task: string = ''
private windowId: number | null = null
async init(task: string, includeInitialTab: boolean = true) {
debug('init', task, includeInitialTab)
this.task = task
this.tabs = []
this.currentTabId = null
this.tabGroupId = null
this.initialTabId = null
this.windowId = null
const result = await sendMessage({
type: 'TAB_CONTROL',
action: 'get_active_tab',
})
this.initialTabId = result.tabId
if (!this.initialTabId) {
throw new Error('Failed to get initial tab ID')
}
if (includeInitialTab) {
this.currentTabId = this.initialTabId
// update tab status immediately
const info = await sendMessage({
type: 'TAB_CONTROL',
action: 'get_tab_info',
payload: { tabId: this.initialTabId },
})
this.tabs.push({
id: result.tabId,
isInitial: true,
url: info.url,
title: info.title,
status: info.status,
})
}
await this.updateCurrentTabId(this.currentTabId)
const tabChangeHandler = (message: any): void => {
if (message.type !== 'TAB_CHANGE') {
// throw new Error(`[TabsController]: Invalid message type: ${message.type}`)
return
}
if (message.action === 'created') {
const tab = message.payload.tab as chrome.tabs.Tab
if (tab.groupId === this.tabGroupId && tab.id != null) {
// Tab created in our controlled group
if (!this.tabs.find((t) => t.id === tab.id)) {
this.tabs.push({ id: tab.id, isInitial: false })
}
this.switchToTab(tab.id)
}
} else if (message.action === 'removed') {
const { tabId } = message.payload as { tabId: number }
const targetTab = this.tabs.find((t) => t.id === tabId)
if (targetTab) {
this.tabs = this.tabs.filter((t) => t.id !== tabId)
if (this.currentTabId === tabId) {
const newCurrentTab = this.tabs[this.tabs.length - 1] || null
if (newCurrentTab) {
this.switchToTab(newCurrentTab.id)
} else {
this.updateCurrentTabId(null)
}
}
}
} else if (message.action === 'updated') {
const { tabId, tab } = message.payload as { tabId: number; tab: chrome.tabs.Tab }
const targetTab = this.tabs.find((t) => t.id === tabId)
if (targetTab) {
targetTab.url = tab.url
targetTab.title = tab.title
targetTab.status = tab.status
}
}
}
chrome.runtime.onMessage.addListener(tabChangeHandler)
this.addEventListener('dispose', () => {
chrome.runtime.onMessage.removeListener(tabChangeHandler)
})
}
async openNewTab(url: string): Promise<string> {
debug('openNewTab', url)
const result = await sendMessage({
type: 'TAB_CONTROL',
action: 'open_new_tab',
payload: { url },
})
if (!result.success) {
throw new Error(`Failed to open new tab: ${result.error}`)
}
const tabId = result.tabId as number
const windowId = result.windowId as number
this.windowId = windowId
this.tabs.push({
id: tabId,
isInitial: false,
})
await this.switchToTab(tabId)
if (!this.tabGroupId) {
const result = await sendMessage({
type: 'TAB_CONTROL',
action: 'create_tab_group',
payload: { tabIds: [tabId], windowId: this.windowId },
})
if (!result.success) {
throw new Error(`Failed to create tab group: ${result.error}`)
}
const groupId = result.groupId as number
this.tabGroupId = groupId
await sendMessage({
type: 'TAB_CONTROL',
action: 'update_tab_group',
payload: {
groupId: this.tabGroupId,
properties: {
title: `PageAgent(${this.task})`,
color: randomColor(),
collapsed: false,
},
},
})
} else {
await sendMessage({
type: 'TAB_CONTROL',
action: 'add_tab_to_group',
payload: { tabId: result.tabId, groupId: this.tabGroupId },
})
}
await this.waitUntilTabLoaded(tabId)
return `✅ Opened new tab ID ${tabId} with URL ${url}`
}
async switchToTab(tabId: number): Promise<string> {
debug('switchToTab', tabId)
const targetTab = this.tabs.find((t) => t.id === tabId)
if (!targetTab) {
throw new Error(`Tab ID ${tabId} not found in tab list.`)
}
await this.updateCurrentTabId(tabId)
return `✅ Switched to tab ID ${tabId}.`
}
async closeTab(tabId: number): Promise<string> {
debug('closeTab', tabId)
const targetTab = this.tabs.find((t) => t.id === tabId)
if (!targetTab) {
throw new Error(`Tab ID ${tabId} not found in tab list.`)
}
if (targetTab.isInitial) {
throw new Error(`Cannot close the initial tab ID ${tabId}.`)
}
const result = await sendMessage({
type: 'TAB_CONTROL',
action: 'close_tab',
payload: { tabId },
})
if (result.success) {
this.tabs = this.tabs.filter((t) => t.id !== tabId)
if (this.currentTabId === tabId) {
const newCurrentTab = this.tabs[this.tabs.length - 1] || null
if (newCurrentTab) {
await this.switchToTab(newCurrentTab.id)
} else {
await this.updateCurrentTabId(null)
}
}
return `✅ Closed tab ID ${tabId}.`
} else {
throw new Error(`Failed to close tab ID ${tabId}: ${result.error}`)
}
}
async updateCurrentTabId(tabId: number | null) {
debug('updateCurrentTabId', tabId)
this.currentTabId = tabId
await chrome.storage.local.set({ currentTabId: tabId })
}
async getTabInfo(tabId: number): Promise<{ title: string; url: string }> {
// use cached tab info if available
const tabMeta = this.tabs.find((t) => t.id === tabId)
if (tabMeta && tabMeta.url && tabMeta.title) {
return { title: tabMeta.title, url: tabMeta.url }
}
// otherwise, pull the latest tab info from the background script
debug('getTabInfo: pulling from background script', tabId)
const result = await sendMessage({
type: 'TAB_CONTROL',
action: 'get_tab_info',
payload: { tabId },
})
if (tabMeta) {
tabMeta.url = result.url
tabMeta.title = result.title
}
return result
}
async summarizeTabs(): Promise<string> {
const summaries = [`| Tab ID | URL | Title | Current |`, `|-----|-----|-----|-----|`]
for (const tab of this.tabs) {
const { title, url } = await this.getTabInfo(tab.id)
summaries.push(
`| ${tab.id} | ${url} | ${title} | ${this.currentTabId === tab.id ? '✅' : ''} |`
)
}
if (!this.tabs.length) {
summaries.push('\nNo tabs available. Open a tab if needed.')
}
return summaries.join('\n')
}
async waitUntilTabLoaded(tabId: number): Promise<void> {
const tab = this.tabs.find((t) => t.id === tabId)
if (!tab) throw new Error(`Tab ID ${tabId} not found in tab list.`)
if (tab.status === 'unloaded') throw new Error(`Tab ID ${tabId} is unloaded.`)
if (tab.status === 'complete') return
debug('waitUntilTabLoaded', tabId)
await waitUntil(() => tab.status === 'complete', 4_000)
}
dispose() {
this.dispatchEvent(new Event('dispose'))
}
}
export type TabAction =
| 'get_active_tab'
| 'get_tab_info'
| 'open_new_tab'
| 'create_tab_group'
| 'update_tab_group'
| 'add_tab_to_group'
| 'close_tab'
| 'get_tab_title'
interface TabMeta {
id: number
isInitial: boolean
url?: string
title?: string
status?: 'loading' | 'unloaded' | 'complete'
}
const TAB_GROUP_COLORS = [
'grey',
'blue',
'red',
'yellow',
'green',
'pink',
'purple',
'cyan',
] as const
type TabGroupColor = (typeof TAB_GROUP_COLORS)[number]
function randomColor(): TabGroupColor {
return TAB_GROUP_COLORS[Math.floor(Math.random() * TAB_GROUP_COLORS.length)]
}
/**
* Wait until condition becomes true
* @returns Returns when condition becomes true, throws otherwise
* @param timeoutMS Timeout in milliseconds, default 1 minutes, throws error on timeout
* @param error Error object to reject on timeout. If not provided, will resolve with false
*/
export async function waitUntil(
check: () => boolean | Promise<boolean>,
timeoutMS = 60_000,
error?: string
): Promise<boolean> {
if (await check()) return true
return new Promise((resolve, reject) => {
const start = Date.now()
const poll = async () => {
if (await check()) return resolve(true)
if (Date.now() - start > timeoutMS) {
if (error) {
return reject(new Error(error))
} else {
return resolve(false)
}
}
setTimeout(poll, 100)
}
setTimeout(poll, 100)
})
}
+13
View File
@@ -0,0 +1,13 @@
import type { LLMConfig } from '@page-agent/llms'
// Demo LLM for testing
export const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
export const DEMO_BASE_URL =
'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
export const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
export const DEMO_CONFIG: LLMConfig = {
apiKey: DEMO_API_KEY,
baseURL: DEMO_BASE_URL,
model: DEMO_MODEL,
}
@@ -0,0 +1,145 @@
You are an AI agent designed to operate in an iterative loop to automate browser tasks. Your ultimate goal is accomplishing the task provided in <user_request>.
<intro>
You excel at following tasks:
1. Navigating complex websites and extracting precise information
2. Automating form submissions and interactive web actions
3. Gathering and saving information
4. Operate effectively in an agent loop
5. Efficiently performing diverse web tasks
</intro>
<language_settings>
- Default working language: **English**
- Use the language that user is using. Return in user's language.
</language_settings>
<input>
At every step, your input will consist of:
1. <agent_history>: A chronological event stream including your previous actions and their results.
2. <agent_state>: Current <user_request> and <step_info>.
3. <browser_state>: Tabs, Current Tab, Current URL, interactive elements indexed for actions, and visible page content.
</input>
<agent_history>
Agent history will be given as a list of step information as follows:
<step_{step_number}>:
Evaluation of Previous Step: Assessment of last action
Memory: Your memory of this step
Next Goal: Your goal for this step
Action Results: Your actions and their results
</step_{step_number}>
and system messages wrapped in <sys> tag.
</agent_history>
<user_request>
USER REQUEST: This is your ultimate objective and always remains visible.
- 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 task is open ended you can plan yourself how to get it done.
</user_request>
<browser_state>
1. Browser State will be given as:
Open Tabs: Open tabs with their ids.
Current Tab: The tab you are currently viewing.
Current URL: URL of the page you are currently viewing.
Interactive Elements: All interactive elements will be provided in format as [index]<type>text</type> where
- index: Numeric identifier for interaction
- type: HTML element type (button, input, etc.)
- text: Element description
Examples:
[33]<div>User form</div>
\t*[35]<button aria-label='Submit form'>Submit</button>
Note that:
- Only elements with numeric indexes in [] are interactive
- (stacked) indentation (with \t) is important and means that the element is a (html) child of the element above (with a lower index)
- Elements tagged with `*[` are the new clickable elements that appeared on the website since the last step - if url has not changed.
- Pure text elements without [] are not interactive.
</browser_state>
<browser_rules>
Strictly follow these rules while using the browser and navigating the web:
- Only interact with elements that have a numeric [index] assigned.
- Only use indexes that are explicitly provided.
- If the page changes after, for example, an input text action, analyze if you need to interact with new elements, e.g. selecting the right option from the list.
- By default, only elements in the visible viewport are listed. Use scrolling actions if you suspect relevant content is offscreen which you need to interact with. Scroll ONLY if there are more pixels below or above the page.
- 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.
- If a captcha appears, tell user you can not solve captcha. finished 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.
- 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 the <user_request> includes specific page information such as product type, rating, price, location, etc., try to apply filters to be more efficient.
- The <user_request> is the ultimate goal. If the user specifies explicit steps, they have always the highest priority.
- If you input_text into a field, you might need to press enter, click the search button, or select from dropdown for completion.
- Don't login into a page if you don't have to. Don't login if you don't have the credentials.
- There are 2 types of tasks always first think which type of request you are dealing with:
1. Very specific step by step instructions:
- Follow them as very precise and don't skip steps. Try to complete everything as requested.
2. Open ended tasks. Plan yourself, be creative in achieving them.
- If you get stuck e.g. with logins or captcha in open-ended tasks you can re-evaluate the task and try alternative ways, e.g. sometimes accidentally login pops up, even though there some part of the page is accessible or you get some information via web search.
</browser_rules>
<task_completion_rules>
You must call the `done` action in one of three cases:
- When you have fully completed the USER REQUEST.
- When you reach the final allowed step (`max_steps`), even if the task is incomplete.
- When you feel stuck or unable to solve user request. Or user request is not clear or contains inappropriate content.
- When it is ABSOLUTELY IMPOSSIBLE to continue.
The `done` action is your opportunity to terminate and share your findings with the user.
- Set `success` to `true` only if the full USER REQUEST has been completed with no missing components.
- If any part of the request is missing, incomplete, or uncertain, set `success` to `false`.
- You can use the `text` field of the `done` action to communicate your findings and to provide a coherent reply to the user and fulfill the USER REQUEST.
- You are ONLY ALLOWED to call `done` as a single action. Don't call it together with other actions.
- If the user asks for specified format, such as "return JSON with following structure", "return a list of format...", MAKE sure to use the right format in your answer.
- If the user asks for a structured output, your `done` action's schema may be modified. Take this schema into account when solving the task!
</task_completion_rules>
<reasoning_rules>
Exhibit the following reasoning patterns to successfully achieve the <user_request>:
- Reason about <agent_history> to track progress and context toward <user_request>.
- Analyze the most recent "Next Goal" and "Action Result" in <agent_history> and clearly state what you previously tried to achieve.
- Analyze all relevant items in <agent_history> and <browser_state> to understand your state.
- Explicitly judge success/failure/uncertainty of the last action. Never assume an action succeeded just because it appears to be executed in your last step in <agent_history>. If the expected change is missing, mark the last action as failed (or uncertain) and plan a recovery.
- Analyze whether you are stuck, e.g. when you repeat the same actions multiple times without any progress. Then consider alternative approaches e.g. scrolling for more context or ask user for help.
- Ask user for help if you have any difficulty. Keep user in the loop.
- If you see information relevant to <user_request>, plan saving the information to memory.
- Always reason about the <user_request>. Make sure to carefully analyze the specific steps and information required. E.g. specific filters, specific form fields, specific information to search. Make sure to always compare the current trajectory with the user request and think carefully if thats how the user requested it.
</reasoning_rules>
<examples>
Here are examples of good output patterns. Use them as reference but never copy them directly.
<evaluation_examples>
"evaluation_previous_goal": "Successfully navigated to the product page and found the target information. Verdict: Success"
"evaluation_previous_goal": "Clicked the login button and user authentication form appeared. Verdict: Success"
</evaluation_examples>
<memory_examples>
"memory": "Found many pending reports that need to be analyzed in the main page. Successfully processed the first 2 reports on quarterly sales data and moving on to inventory analysis and customer feedback reports."
</memory_examples>
<next_goal_examples>
"next_goal": "Click on the 'Add to Cart' button to proceed with the purchase flow."
</next_goal_examples>
</examples>
<output>
{
"evaluation_previous_goal": "Concise one-sentence analysis of your last action. Clearly state success, failure, or uncertain.",
"memory": "1-3 concise sentences of specific memory of this step and overall progress. You should put here everything that will help you track progress in future steps. Like counting pages visited, items found, etc.",
"next_goal": "State the next immediate goal and action to achieve it, in one clear sentence.",
"action":{
"Action name": {// Action parameters}
}
}
</output>
+74
View File
@@ -0,0 +1,74 @@
/**
* Tab control tools for browser extension
*
* These tools allow the agent to manage multiple browser tabs:
* - open_new_tab: Open a new tab and set it as current
* - switch_to_tab: Switch to an existing tab
* - close_tab: Close a tab (optionally switch to another)
*/
import * as zod from 'zod'
import type { TabsController } from './TabsController'
/** Tool definition compatible with PageAgentCore customTools */
interface TabTool {
description: string
inputSchema: zod.ZodType
execute: (input: unknown) => Promise<string>
}
/**
* Create tab control tools bound to a TabsManager instance.
* These tools are injected into PageAgentCore via customTools config.
*/
export function createTabTools(tabsController: TabsController): Record<string, TabTool> {
return {
open_new_tab: {
description:
'Open a new browser tab with the specified URL. The new tab becomes the current tab for all subsequent page operations.',
inputSchema: zod.object({
url: zod.string().describe('The URL to open in the new tab'),
}),
execute: async (input: unknown) => {
const { url } = input as { url: string }
try {
return await tabsController.openNewTab(url)
} catch (error) {
return `❌ Failed: ${error instanceof Error ? error.message : String(error)}`
}
},
},
switch_to_tab: {
description:
'Switch to an existing tab by its ID. After switching, all page operations will target the new current tab. You can only switch to tabs in the tab list shown in browser state.',
inputSchema: zod.object({
tab_id: zod.number().int().describe('The tab ID to switch to'),
}),
execute: async (input: unknown) => {
const { tab_id } = input as { tab_id: number }
try {
return await tabsController.switchToTab(tab_id)
} catch (error) {
return `❌ Failed: ${error instanceof Error ? error.message : String(error)}`
}
},
},
close_tab: {
description:
'Close a tab by its ID. Cannot close the initial tab. Optionally specify which tab to switch to after closing.',
inputSchema: zod.object({
tab_id: zod.number().int().describe('The tab ID to close'),
}),
execute: async (input: unknown) => {
const { tab_id } = input as { tab_id: number }
try {
return await tabsController.closeTab(tab_id)
} catch (error) {
return `❌ Failed: ${error instanceof Error ? error.message : String(error)}`
}
},
},
}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* React hook for using AgentController
*/
import type { AgentActivity, AgentStatus, HistoricalEvent } from '@page-agent/core'
import type { LLMConfig } from '@page-agent/llms'
import { useCallback, useEffect, useRef, useState } from 'react'
import { MultiPageAgent } from './MultiPageAgent'
import { DEMO_CONFIG } from './constants'
export interface UseAgentResult {
status: AgentStatus
history: HistoricalEvent[]
activity: AgentActivity | null
currentTask: string
config: LLMConfig | null
execute: (task: string) => Promise<void>
stop: () => void
configure: (config: LLMConfig) => Promise<void>
}
export function useAgent(): UseAgentResult {
const agentRef = useRef<MultiPageAgent | null>(null)
const [status, setStatus] = useState<AgentStatus>('idle')
const [history, setHistory] = useState<HistoricalEvent[]>([])
const [activity, setActivity] = useState<AgentActivity | null>(null)
const [currentTask, setCurrentTask] = useState('')
const [config, setConfig] = useState<LLMConfig | null>(null)
useEffect(() => {
chrome.storage.local.get('llmConfig').then((result) => {
if (result.llmConfig) {
setConfig(result.llmConfig as LLMConfig)
} else {
chrome.storage.local.set({ llmConfig: DEMO_CONFIG })
setConfig(DEMO_CONFIG)
}
})
}, [])
useEffect(() => {
if (!config) return
const agent = new MultiPageAgent(config)
agentRef.current = agent
const handleStatusChange = (e: Event) => {
const newStatus = agent.status as AgentStatus
setStatus(newStatus)
if (newStatus === 'idle' || newStatus === 'completed' || newStatus === 'error') {
setActivity(null)
}
}
const handleHistoryChange = (e: Event) => {
setHistory([...agent.history])
}
const handleActivity = (e: Event) => {
const newActivity = (e as CustomEvent).detail as AgentActivity
setActivity(newActivity)
}
agent.addEventListener('statuschange', handleStatusChange)
agent.addEventListener('historychange', handleHistoryChange)
agent.addEventListener('activity', handleActivity)
return () => {
agent.removeEventListener('statuschange', handleStatusChange)
agent.removeEventListener('historychange', handleHistoryChange)
agent.removeEventListener('activity', handleActivity)
agent.dispose()
}
}, [config])
const execute = useCallback(async (task: string) => {
const agent = agentRef.current
console.log('🚀 [useAgent] start executing task:', task)
if (!agent) throw new Error('Agent not initialized')
setCurrentTask(task)
setHistory([])
await agent.execute(task)
}, [])
const stop = useCallback(() => {
agentRef.current?.stop()
}, [])
const configure = useCallback(async (newConfig: LLMConfig) => {
await chrome.storage.local.set({ llmConfig: newConfig })
setConfig(newConfig)
}, [])
return {
status,
history,
activity,
currentTask,
config,
execute,
stop,
configure,
}
}
+123
View File
@@ -0,0 +1,123 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.19 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

@@ -0,0 +1,60 @@
import { Slot } from '@radix-ui/react-slot'
import { type VariantProps, cva } from 'class-variance-authority'
import * as React from 'react'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
function Button({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'button'
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,75 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
)
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
@@ -0,0 +1,232 @@
import { type VariantProps, cva } from 'class-variance-authority'
import { useMemo } from 'react'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
return (
<fieldset
data-slot="field-set"
className={cn(
'flex flex-col gap-6',
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = 'legend',
...props
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
'mb-3 font-medium',
'data-[variant=legend]:text-base',
'data-[variant=label]:text-sm',
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-group"
className={cn(
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
className
)}
{...props}
/>
)
}
const fieldVariants = cva('group/field flex w-full gap-3 data-[invalid=true]:text-destructive', {
variants: {
orientation: {
vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'],
horizontal: [
'flex-row items-center',
'[&>[data-slot=field-label]]:flex-auto',
'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
],
responsive: [
'flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto',
'@md/field-group:[&>[data-slot=field-label]]:flex-auto',
'@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
],
},
},
defaultVariants: {
orientation: 'vertical',
},
})
function Field({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-content"
className={cn('group/field-content flex flex-1 flex-col gap-1.5 leading-snug', className)}
{...props}
/>
)
}
function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-label"
className={cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="field-description"
className={cn(
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<'div'> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<'div'> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [...new Map(errors.map((error) => [error?.message, error])).values()]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map((error, index) => error?.message && <li key={index}>{error.message}</li>)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn('text-destructive text-sm font-normal', className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
@@ -0,0 +1,36 @@
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
import * as React from 'react'
import { cn } from '@/lib/utils'
function HoverCard({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
}
function HoverCardContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }
@@ -0,0 +1,156 @@
import { type VariantProps, cva } from 'class-variance-authority'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
'h-9 min-w-0 has-[>textarea]:h-auto',
// Variants based on alignment.
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
// Focus state.
'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',
// Error state.
'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
'inline-start': 'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
'inline-end': 'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]',
'block-start':
'order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5',
'block-end':
'order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5',
},
},
defaultVariants: {
align: 'inline-start',
},
}
)
function InputGroupAddon({
className,
align = 'inline-start',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) {
return
}
e.currentTarget.parentElement?.querySelector('input')?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva('text-sm shadow-none flex gap-2 items-center', {
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: 'h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5',
'icon-xs': 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
},
},
defaultVariants: {
size: 'xs',
},
})
function InputGroupButton({
className,
type = 'button',
variant = 'ghost',
size = 'xs',
...props
}: Omit<React.ComponentProps<typeof Button>, 'size'> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>) {
return (
<Input
data-slot="input-group-control"
className={cn(
'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
className
)}
{...props}
/>
)
}
function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
@@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
)}
{...props}
/>
)
}
export { Input }
@@ -0,0 +1,172 @@
import { Slot } from '@radix-ui/react-slot'
import { type VariantProps, cva } from 'class-variance-authority'
import * as React from 'react'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
role="list"
data-slot="item-group"
className={cn('group/item-group flex flex-col', className)}
{...props}
/>
)
}
function ItemSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn('my-0', className)}
{...props}
/>
)
}
const itemVariants = cva(
'group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
{
variants: {
variant: {
default: 'bg-transparent',
outline: 'border-border',
muted: 'bg-muted/50',
},
size: {
default: 'p-4 gap-4 ',
sm: 'py-3 px-4 gap-2.5',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
function Item({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div'
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
)
}
const itemMediaVariants = cva(
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5',
{
variants: {
variant: {
default: 'bg-transparent',
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image: 'size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover',
},
},
defaultVariants: {
variant: 'default',
},
}
)
function ItemMedia({
className,
variant = 'default',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-content"
className={cn('flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none', className)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-title"
className={cn('flex w-fit items-center gap-2 text-sm leading-snug font-medium', className)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="item-description"
className={cn(
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot="item-actions" className={cn('flex items-center gap-2', className)} {...props} />
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-header"
className={cn('flex basis-full items-center justify-between gap-2', className)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-footer"
className={cn('flex basis-full items-center justify-between gap-2', className)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}
@@ -0,0 +1,19 @@
import * as LabelPrimitive from '@radix-ui/react-label'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className
)}
{...props}
/>
)
}
export { Label }
@@ -0,0 +1,26 @@
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className
)}
{...props}
/>
)
}
export { Separator }
@@ -0,0 +1,38 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from 'lucide-react'
import { useTheme } from 'next-themes'
import { Toaster as Sonner, type ToasterProps } from 'sonner'
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme()
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
@@ -0,0 +1,16 @@
import { Loader2Icon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Spinner({ className, ...props }: React.ComponentProps<'svg'>) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn('size-4 animate-spin', className)}
{...props}
/>
)
}
export { Spinner }
@@ -0,0 +1,26 @@
import * as SwitchPrimitive from '@radix-ui/react-switch'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
@@ -0,0 +1,18 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
{...props}
/>
)
}
export { Textarea }
@@ -0,0 +1,36 @@
import { handlePageControlMessage } from '@/agent/RemotePageController.background'
import { handleTabControlMessage, setupTabChangeEvents } from '@/agent/TabsController.background'
export default defineBackground(() => {
console.log('[Background] Service Worker started')
// tab change events
setupTabChangeEvents()
// generate user auth token
chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => {
if (result.PageAgentExtUserAuthToken) return
const userAuthToken = crypto.randomUUID()
chrome.storage.local.set({ PageAgentExtUserAuthToken: userAuthToken })
})
// message proxy
chrome.runtime.onMessage.addListener((message, sender, sendResponse): true | undefined => {
if (message.type === 'TAB_CONTROL') {
return handleTabControlMessage(message, sender, sendResponse)
} else if (message.type === 'PAGE_CONTROL') {
return handlePageControlMessage(message, sender, sendResponse)
} else {
sendResponse({ error: 'Unknown message type' })
return
}
})
// setup
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
})
@@ -0,0 +1,158 @@
import { initPageController } from '@/agent/RemotePageController.content'
// import { DEMO_CONFIG } from '@/agent/constants'
const DEBUG_PREFIX = '[Content]'
export default defineContentScript({
matches: ['<all_urls>'],
runAt: 'document_end',
main() {
console.debug(`${DEBUG_PREFIX} Loaded on ${window.location.href}`)
initPageController()
// if auth token matches, expose agent to page
chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => {
// extension side token.
// @note this is isolated world. it is safe to assume user script cannot access it
const extToken = result.PageAgentExtUserAuthToken
if (!extToken) return
// page side token
const pageToken = localStorage.getItem('PageAgentExtUserAuthToken')
if (!pageToken) return
if (pageToken !== extToken) return
console.log('[PageAgentExt]: Auth tokens match. Exposing agent to page.')
// add isolated world script
exposeAgentToPage().then(
// add main-world script
() => injectScript('/main-world.js')
)
})
},
})
async function exposeAgentToPage() {
const { MultiPageAgent } = await import('@/agent/MultiPageAgent')
console.log('[PageAgentExt]: MultiPageAgent loaded')
/**
* singleton MultiPageAgent to handle requests from the page
*/
let multiPageAgent: InstanceType<typeof MultiPageAgent> | null = null
window.addEventListener('message', async (e) => {
const data = e.data
if (typeof data !== 'object' || data === null) return
if (data.channel !== 'PAGE_AGENT_EXT_REQUEST') return
const { action, payload, id } = data
switch (action) {
case 'execute': {
// singleton check
if (multiPageAgent && multiPageAgent.status === 'running') {
window.postMessage(
{
channel: 'PAGE_AGENT_EXT_RESPONSE',
id,
action: 'execute_result',
error: 'Agent is already running a task. Please wait until it finishes.',
},
'*'
)
return
}
try {
const { task, config } = payload
// Dispose old instance before creating new one
multiPageAgent?.dispose()
multiPageAgent = new MultiPageAgent(config)
// events
multiPageAgent.addEventListener('statuschange', (event) => {
if (!multiPageAgent) return
window.postMessage(
{
channel: 'PAGE_AGENT_EXT_RESPONSE',
id,
action: 'status_change_event',
payload: multiPageAgent.status,
},
'*'
)
})
multiPageAgent.addEventListener('activity', (event) => {
if (!multiPageAgent) return
window.postMessage(
{
channel: 'PAGE_AGENT_EXT_RESPONSE',
id,
action: 'activity_event',
payload: (event as CustomEvent).detail,
},
'*'
)
})
multiPageAgent.addEventListener('historychange', (event) => {
if (!multiPageAgent) return
window.postMessage(
{
channel: 'PAGE_AGENT_EXT_RESPONSE',
id,
action: 'history_change_event',
payload: multiPageAgent.history,
},
'*'
)
})
// result
const result = await multiPageAgent.execute(task)
window.postMessage(
{
channel: 'PAGE_AGENT_EXT_RESPONSE',
id,
action: 'execute_result',
payload: result,
},
'*'
)
} catch (error) {
window.postMessage(
{
channel: 'PAGE_AGENT_EXT_RESPONSE',
id,
action: 'execute_result',
error: (error as Error).message,
},
'*'
)
}
break
}
case 'stop': {
multiPageAgent?.stop()
break
}
default:
console.warn(`${DEBUG_PREFIX} Unknown action from page:`, action)
break
}
})
}
@@ -0,0 +1,119 @@
import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core'
export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
export interface ExecuteConfig {
baseURL: string
apiKey: string
model: string
/**
* Whether to include the initial tab (that holds this main world script) in the task.
* @default true
*/
includeInitialTab?: boolean
onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
}
export default defineUnlistedScript(() => {
let _lastId = 0
function getId() {
_lastId += 1
return _lastId
}
const execute: Execute = async (task, config) => {
if (typeof task !== 'string') throw new Error('Task must be a string')
if (task.trim().length === 0) throw new Error('Task cannot be empty')
if (!config) throw new Error('Config is required')
if (!config.baseURL) throw new Error('Config must have a baseURL')
if (!config.apiKey) throw new Error('Config must have an apiKey')
if (!config.model) throw new Error('Config must have a model')
const id = getId()
const promise = new Promise<ExecutionResult>((resolve, reject) => {
function handleMessage(e: MessageEvent) {
const data = e.data
if (typeof data !== 'object' || data === null) return
if (data.channel !== 'PAGE_AGENT_EXT_RESPONSE') return
if (data.id !== id) return
// events
if (data.action === 'status_change_event' && config.onStatusChange) {
config.onStatusChange(data.payload)
return
}
if (data.action === 'activity_event' && config.onActivity) {
config.onActivity(data.payload)
return
}
if (data.action === 'history_change_event' && config.onHistoryUpdate) {
config.onHistoryUpdate(data.payload)
return
}
if (data.action !== 'execute_result') return
// execute_result
window.removeEventListener('message', handleMessage)
if (data.error) {
reject(new Error(data.error))
} else {
resolve(data.payload)
}
}
// @note will be removed on dispose or result
window.addEventListener('message', handleMessage)
})
window.postMessage(
{
channel: 'PAGE_AGENT_EXT_REQUEST',
id,
action: 'execute',
payload: {
task,
config: {
baseURL: config.baseURL,
apiKey: config.apiKey,
model: config.model,
includeInitialTab: config.includeInitialTab,
},
},
},
'*'
)
return promise
}
const stop = () => {
const id = getId()
window.postMessage(
{
channel: 'PAGE_AGENT_EXT_REQUEST',
id,
action: 'stop',
},
'*'
)
}
;(window as any).PAGE_AGENT_EXT_VERSION = __EXT_VERSION__
;(window as any).PAGE_AGENT_EXT = {
version: __EXT_VERSION__,
execute,
stop,
}
})
@@ -0,0 +1,212 @@
import { History, Send, Settings, Square } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from '@/components/ui/input-group'
import { saveSession } from '@/lib/db'
import { useAgent } from '../../agent/useAgent'
import { ConfigPanel } from './components/ConfigPanel'
import { HistoryDetail } from './components/HistoryDetail'
import { HistoryList } from './components/HistoryList'
import { ActivityCard, EventCard } from './components/cards'
import { EmptyState, Logo, StatusDot } from './components/misc'
type View =
| { name: 'chat' }
| { name: 'config' }
| { name: 'history' }
| { name: 'history-detail'; sessionId: string }
export default function App() {
const [view, setView] = useState<View>({ name: 'chat' })
const [inputValue, setInputValue] = useState('')
const historyRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const { status, history, activity, currentTask, config, execute, stop, configure } = useAgent()
// Persist session when task finishes
const prevStatusRef = useRef(status)
useEffect(() => {
const prev = prevStatusRef.current
prevStatusRef.current = status
if (
prev === 'running' &&
(status === 'completed' || status === 'error') &&
history.length > 0 &&
currentTask
) {
saveSession({ task: currentTask, history, status }).catch((err) =>
console.error('[SidePanel] Failed to save session:', err)
)
}
}, [status, history, currentTask])
// Auto-scroll to bottom on new events
useEffect(() => {
if (historyRef.current) {
historyRef.current.scrollTop = historyRef.current.scrollHeight
}
}, [history, activity])
const handleSubmit = useCallback(
(e?: React.SyntheticEvent) => {
e?.preventDefault()
if (!inputValue.trim() || status === 'running') return
const taskToExecute = inputValue.trim()
setInputValue('')
execute(taskToExecute).catch((error) => {
console.error('[SidePanel] Failed to execute task:', error)
})
},
[inputValue, status, execute]
)
const handleStop = useCallback(() => {
console.log('[SidePanel] Stopping task...')
stop()
}, [stop])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault()
handleSubmit()
}
}
// --- View routing ---
if (view.name === 'config') {
return (
<ConfigPanel
config={config}
onSave={async (newConfig) => {
await configure(newConfig)
setView({ name: 'chat' })
}}
onClose={() => setView({ name: 'chat' })}
/>
)
}
if (view.name === 'history') {
return (
<HistoryList
onSelect={(id) => setView({ name: 'history-detail', sessionId: id })}
onBack={() => setView({ name: 'chat' })}
/>
)
}
if (view.name === 'history-detail') {
return <HistoryDetail sessionId={view.sessionId} onBack={() => setView({ name: 'history' })} />
}
// --- Chat view ---
const isRunning = status === 'running'
const showEmptyState = !currentTask && history.length === 0 && !isRunning
return (
<div className="flex flex-col h-screen bg-background">
{/* Header */}
<header className="flex items-center justify-between border-b px-3 py-2">
<div className="flex items-center gap-2">
<Logo className="size-5" />
<span className="text-sm font-medium">Page Agent Ext</span>
</div>
<div className="flex items-center gap-3">
<StatusDot status={status} />
<Button
variant="ghost"
size="icon-sm"
onClick={() => setView({ name: 'history' })}
className="cursor-pointer"
>
<History className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setView({ name: 'config' })}
className="cursor-pointer"
>
<Settings className="size-3.5" />
</Button>
</div>
</header>
{/* Content */}
<main className="flex-1 overflow-hidden flex flex-col">
{/* Current task */}
{currentTask && (
<div className="border-b px-3 py-2 bg-muted/30">
<div className="text-[10px] text-muted-foreground uppercase tracking-wide">Task</div>
<div className="text-xs font-medium truncate" title={currentTask}>
{currentTask}
</div>
</div>
)}
{/* History */}
<div ref={historyRef} className="flex-1 overflow-y-auto p-3 space-y-2">
{showEmptyState && <EmptyState />}
{history.map((event, index) => (
// eslint-disable-next-line react-x/no-array-index-key
<EventCard key={index} event={event} />
))}
{/* Activity indicator at bottom */}
{activity && <ActivityCard activity={activity} />}
</div>
</main>
{/* Input */}
<footer className="border-t p-3">
<InputGroup className="relative rounded-lg">
<InputGroupTextarea
ref={textareaRef}
placeholder="Describe your task... (Enter to send)"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isRunning}
className="text-xs pr-12 min-h-10"
/>
<InputGroupAddon align="inline-end" className="absolute bottom-0 right-0">
{isRunning ? (
<InputGroupButton
size="icon-sm"
variant="destructive"
onClick={handleStop}
className="size-7"
>
<Square className="size-3" />
</InputGroupButton>
) : (
<InputGroupButton
size="icon-sm"
variant="default"
onClick={() => handleSubmit()}
disabled={!inputValue.trim()}
className="size-7 cursor-pointer"
>
<Send className="size-3" />
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
</footer>
</div>
)
}
@@ -0,0 +1,243 @@
import type { LLMConfig } from '@page-agent/llms'
import { Copy, CornerUpLeft, Eye, EyeOff, HatGlasses, Home, Loader2, Scale } from 'lucide-react'
import { useEffect, useState } from 'react'
import { siGithub } from 'simple-icons'
import { DEMO_API_KEY, DEMO_BASE_URL, DEMO_MODEL } from '@/agent/constants'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
interface ConfigPanelProps {
config: LLMConfig | null
onSave: (config: LLMConfig) => Promise<void>
onClose: () => void
}
export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
const [apiKey, setApiKey] = useState(config?.apiKey || DEMO_API_KEY)
const [baseURL, setBaseURL] = useState(config?.baseURL || DEMO_BASE_URL)
const [model, setModel] = useState(config?.model || DEMO_MODEL)
const [saving, setSaving] = useState(false)
const [userAuthToken, setUserAuthToken] = useState<string>('')
const [copied, setCopied] = useState(false)
const [showToken, setShowToken] = useState(false)
const [showApiKey, setShowApiKey] = useState(false)
// Update local state when config prop changes
useEffect(() => {
setApiKey(config?.apiKey || DEMO_API_KEY)
setBaseURL(config?.baseURL || DEMO_BASE_URL)
setModel(config?.model || DEMO_MODEL)
}, [config])
// Poll for user auth token every second until found
useEffect(() => {
let interval: NodeJS.Timeout | null = null
const fetchToken = async () => {
const result = await chrome.storage.local.get('PageAgentExtUserAuthToken')
const token = result.PageAgentExtUserAuthToken
if (typeof token === 'string' && token) {
setUserAuthToken(token)
if (interval) {
clearInterval(interval)
interval = null
}
}
}
fetchToken()
interval = setInterval(fetchToken, 1000)
return () => {
if (interval) clearInterval(interval)
}
}, [])
const handleCopyToken = async () => {
if (userAuthToken) {
await navigator.clipboard.writeText(userAuthToken)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
const handleSave = async () => {
setSaving(true)
try {
await onSave({ apiKey, baseURL, model })
} finally {
setSaving(false)
}
}
return (
<div className="flex flex-col gap-4 p-4 relative">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold">Settings</h2>
<Button
variant="ghost"
size="icon-sm"
onClick={onClose}
className="absolute top-2 right-3 cursor-pointer"
>
<CornerUpLeft className="size-3.5" />
</Button>
</div>
{/* User Auth Token Section */}
<div className="flex flex-col gap-1.5 p-3 bg-muted/50 rounded-md border">
<label className="text-xs font-medium text-muted-foreground">User Auth Token</label>
<p className="text-[10px] text-muted-foreground mb-1">
Add this token to a website's localStorage to give it authorization to call this extension
</p>
<div className="flex gap-2 items-center">
<Input
readOnly
value={
userAuthToken
? showToken
? userAuthToken
: `${userAuthToken.slice(0, 4)}${''.repeat(userAuthToken.length - 8)}${userAuthToken.slice(-4)}`
: 'Loading...'
}
className="text-xs h-8 font-mono bg-background"
/>
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 cursor-pointer"
onClick={() => setShowToken(!showToken)}
disabled={!userAuthToken}
>
{showToken ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 cursor-pointer"
onClick={handleCopyToken}
disabled={!userAuthToken}
>
{copied ? <span className="">✓</span> : <Copy className="size-3" />}
</Button>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Base URL</label>
<Input
placeholder="https://api.openai.com/v1"
value={baseURL}
onChange={(e) => setBaseURL(e.target.value)}
className="text-xs h-8"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Model</label>
<Input
placeholder="gpt-5.2"
value={model}
onChange={(e) => setModel(e.target.value)}
className="text-xs h-8"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">API Key</label>
<div className="flex gap-2 items-center">
<Input
type={showApiKey ? 'text' : 'password'}
placeholder="sk-..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="text-xs h-8"
/>
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 cursor-pointer"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
</Button>
</div>
</div>
<div className="flex gap-2 mt-2">
<Button variant="outline" onClick={onClose} className="flex-1 h-8 text-xs cursor-pointer">
Cancel
</Button>
<Button
onClick={handleSave}
disabled={saving}
className="flex-1 h-8 text-xs cursor-pointer"
>
{saving ? <Loader2 className="size-3 animate-spin" /> : 'Save'}
</Button>
</div>
{/* Footer */}
<div className="mt-4 mb-4 pt-4 border-t border-border/50 flex gap-2 justify-between text-[10px] text-muted-foreground">
<div className="flex flex-col justify-between">
<a
href="https://github.com/alibaba/page-agent"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 hover:text-foreground"
>
<svg role="img" viewBox="0 0 24 24" className="size-3 fill-current">
<path d={siGithub.path} />
</svg>
<span>Source Code</span>
</a>
<a
href="https://alibaba.github.io/page-agent/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 hover:text-foreground"
>
<Home className="size-3" />
<span>Home Page</span>
</a>
<a
href="https://github.com/alibaba/page-agent/blob/main/packages/extension/PRIVACY.md"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 hover:text-foreground"
>
<HatGlasses className="size-3" />
<span>Privacy Policy</span>
</a>
</div>
<div className="flex flex-col items-end">
<span>
Extension <span className="font-mono">v{__EXT_VERSION__}</span>
</span>
<span>
PageAgent <span className="font-mono">v{__CORE_VERSION__}</span>
</span>
</div>
</div>
{/* attribute */}
<div className="text-[10px] text-muted-foreground bg-background fixed bottom-0 w-full flex justify-around">
<span className="leading-loose">
Built with ♥️ by{' '}
<a
href="https://github.com/gaomeng1900"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
@Simon
</a>
</span>
</div>
</div>
)
}
@@ -0,0 +1,49 @@
import { AlertTriangle, RotateCcw } from 'lucide-react'
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
interface Props {
children: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null }
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('[ErrorBoundary]', error, errorInfo.componentStack)
}
handleReload = () => {
window.location.reload()
}
render() {
if (!this.state.hasError) {
return this.props.children
}
return (
<div className="flex flex-col items-center justify-center h-screen bg-background p-6 text-center">
<AlertTriangle className="size-12 text-destructive mb-4" />
<h2 className="text-lg font-semibold mb-2">Something went wrong</h2>
<p className="text-sm text-muted-foreground mb-4 max-w-xs">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<Button variant="outline" size="sm" onClick={this.handleReload}>
<RotateCcw className="size-3.5 mr-2" />
Reload Panel
</Button>
</div>
)
}
}
@@ -0,0 +1,51 @@
import { ArrowLeft } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { type SessionRecord, getSession } from '@/lib/db'
import { EventCard } from './cards'
export function HistoryDetail({ sessionId, onBack }: { sessionId: string; onBack: () => void }) {
const [session, setSession] = useState<SessionRecord | null>(null)
useEffect(() => {
getSession(sessionId).then((s) => setSession(s ?? null))
}, [sessionId])
if (!session) {
return (
<div className="flex items-center justify-center h-screen text-xs text-muted-foreground">
Loading...
</div>
)
}
return (
<div className="flex flex-col h-screen bg-background">
{/* Header */}
<header className="flex items-center gap-2 border-b px-3 py-2">
<Button variant="ghost" size="icon-sm" onClick={onBack} className="cursor-pointer">
<ArrowLeft className="size-3.5" />
</Button>
<span className="text-sm font-medium truncate">History</span>
</header>
{/* Task */}
<div className="border-b px-3 py-2 bg-muted/30">
<div className="text-[10px] text-muted-foreground uppercase tracking-wide">Task</div>
<div className="text-xs font-medium" title={session.task}>
{session.task}
</div>
</div>
{/* Events (read-only) */}
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{session.history.map((event, index) => (
// eslint-disable-next-line react-x/no-array-index-key
<EventCard key={index} event={event} />
))}
</div>
</div>
)
}
@@ -0,0 +1,119 @@
import { ArrowLeft, CheckCircle, Trash2, XCircle } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { type SessionRecord, clearSessions, deleteSession, listSessions } from '@/lib/db'
function timeAgo(ts: number): string {
const seconds = Math.floor((Date.now() - ts) / 1000)
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
export function HistoryList({
onSelect,
onBack,
}: {
onSelect: (id: string) => void
onBack: () => void
}) {
const [sessions, setSessions] = useState<SessionRecord[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setSessions(await listSessions())
setLoading(false)
}, [])
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load()
}, [load])
const handleDelete = async (e: React.MouseEvent, id: string) => {
e.stopPropagation()
await deleteSession(id)
setSessions((prev) => prev.filter((s) => s.id !== id))
}
return (
<div className="flex flex-col h-screen bg-background">
{/* Header */}
<header className="flex items-center gap-2 border-b px-3 py-2">
<Button variant="ghost" size="icon-sm" onClick={onBack} className="cursor-pointer">
<ArrowLeft className="size-3.5" />
</Button>
<span className="text-sm font-medium flex-1">History</span>
{sessions.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
await clearSessions()
setSessions([])
}}
className="text-[10px] text-muted-foreground hover:text-destructive cursor-pointer h-6 px-2"
>
<Trash2 className="size-3 mr-1" />
Clear All
</Button>
)}
</header>
{/* List */}
<div className="flex-1 overflow-y-auto">
{loading && (
<div className="flex items-center justify-center h-32 text-xs text-muted-foreground">
Loading...
</div>
)}
{!loading && sessions.length === 0 && (
<div className="flex items-center justify-center h-32 text-xs text-muted-foreground">
No history yet
</div>
)}
{sessions.map((session) => (
<div
key={session.id}
role="button"
tabIndex={0}
onClick={() => onSelect(session.id)}
onKeyDown={(e) => e.key === 'Enter' && onSelect(session.id)}
className="w-full text-left px-3 py-2.5 border-b hover:bg-muted/50 transition-colors cursor-pointer flex items-start gap-2 group"
>
{/* Status icon */}
{session.status === 'completed' ? (
<CheckCircle className="size-3.5 text-green-500 shrink-0 mt-0.5" />
) : (
<XCircle className="size-3.5 text-destructive shrink-0 mt-0.5" />
)}
{/* Content */}
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{session.task}</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
{timeAgo(session.createdAt)} · {session.history.length} steps
</p>
</div>
{/* Delete */}
<button
type="button"
onClick={(e) => handleDelete(e, session.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:text-destructive cursor-pointer shrink-0"
>
<Trash2 className="size-3" />
</button>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,386 @@
import type {
AgentActivity,
AgentErrorEvent,
AgentStepEvent,
HistoricalEvent,
ObservationEvent,
RetryEvent,
} from '@page-agent/core'
import {
CheckCircle,
Eye,
Globe,
Keyboard,
Mouse,
MoveVertical,
RefreshCw,
Sparkles,
XCircle,
Zap,
} from 'lucide-react'
import { Fragment, useState } from 'react'
import { cn } from '@/lib/utils'
// Result card for done action
function ResultCard({
success,
text,
children,
}: {
success: boolean
text: string
children?: React.ReactNode
}) {
return (
<div
className={cn(
'rounded-lg border p-3',
success ? 'border-green-500/30 bg-green-500/10' : 'border-destructive/30 bg-destructive/10'
)}
>
<div className="flex items-center gap-2 mb-2">
{success ? (
<CheckCircle className="size-3.5 text-green-500" />
) : (
<XCircle className="size-3.5 text-destructive" />
)}
<span
className={cn(
'text-xs font-medium',
success ? 'text-green-600 dark:text-green-400' : 'text-destructive'
)}
>
Result: {success ? 'Success' : 'Failed'}
</span>
</div>
<p className="text-xs text-[11px] text-muted-foreground pl-5 whitespace-pre-wrap">{text}</p>
{children}
</div>
)
}
// Single reflection item with truncation
function ReflectionItem({ icon, value }: { icon: string; value: string }) {
const [expanded, setExpanded] = useState(false)
return (
<Fragment>
<span className="text-xs flex justify-center">{icon}</span>
<span
className={cn(
'text-[11px] text-muted-foreground cursor-pointer hover:text-muted-foreground/70',
!expanded && 'line-clamp-1'
)}
onClick={() => setExpanded(!expanded)}
>
{value}
</span>
</Fragment>
)
}
// Reflection section in step card
function ReflectionSection({
reflection,
}: {
reflection: {
evaluation_previous_goal?: string
memory?: string
next_goal?: string
}
}) {
const items = [
{ icon: '☑️', label: 'eval', value: reflection.evaluation_previous_goal },
{ icon: '🧠', label: 'memory', value: reflection.memory },
{ icon: '🎯', label: 'goal', value: reflection.next_goal },
].filter((item) => item.value)
if (items.length === 0) return null
return (
<div className="mb-2">
{/* <div className="text-[11px] font-semibold text-foreground uppercase tracking-wide mb-2">
Reflection
</div> */}
<div className="grid grid-cols-[14px_1fr] gap-x-2 gap-y-2">
{items.map((item) => (
<ReflectionItem key={item.label} icon={item.icon} value={item.value!} />
))}
</div>
</div>
)
}
// Get icon for action type
function ActionIcon({ name, className }: { name: string; className?: string }) {
const icons: Record<string, React.ReactNode> = {
click_element_by_index: <Mouse className={className} />,
input: <Keyboard className={className} />,
scroll: <MoveVertical className={className} />,
go_to_url: <Globe className={className} />,
}
return icons[name] || <Zap className={className} />
}
// Copy button with "Copied!" feedback
function CopyButton({ text, label }: { text: string; label: string }) {
const [copied, setCopied] = useState(false)
return (
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}}
className="text-[9px] text-muted-foreground hover:text-foreground transition-colors border px-1 rounded shrink-0 cursor-pointer backdrop-blur-xs"
>
{copied ? 'Copied!' : label}
</button>
)
}
// Extract message content by role from raw request
function extractPrompt(rawRequest: unknown, role: 'system' | 'user'): string | null {
const messages = (rawRequest as { messages?: { role: string; content?: unknown }[] })?.messages
if (!messages) return null
const msg =
role === 'system'
? messages.find((m) => m.role === role)
: messages.findLast((m) => m.role === role)
if (!msg?.content) return null
return typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content, null, 2)
}
// Raw request/response section (collapsible tabs, for debugging)
function RawSection({ rawRequest, rawResponse }: { rawRequest?: unknown; rawResponse?: unknown }) {
const [activeTab, setActiveTab] = useState<'request' | 'response' | null>(null)
if (!rawRequest && !rawResponse) return null
const handleTabClick = (tab: 'request' | 'response') => {
setActiveTab(activeTab === tab ? null : tab)
}
const content =
activeTab === 'request' ? rawRequest : activeTab === 'response' ? rawResponse : null
const systemPrompt = activeTab === 'request' ? extractPrompt(rawRequest, 'system') : null
const userPrompt = activeTab === 'request' ? extractPrompt(rawRequest, 'user') : null
return (
<div className="mt-2 border-t border-dashed pt-2">
<div className="flex items-center gap-3 -my-1">
{rawRequest != null && (
<button
type="button"
onClick={() => handleTabClick('request')}
className={cn(
'text-[10px] mt-0.5 transition-colors border-b cursor-pointer',
activeTab === 'request'
? 'text-foreground border-foreground'
: 'text-muted-foreground border-transparent hover:text-foreground'
)}
>
Raw Request
</button>
)}
{rawResponse != null && (
<button
type="button"
onClick={() => handleTabClick('response')}
className={cn(
'text-[10px] mt-0.5 transition-colors border-b cursor-pointer',
activeTab === 'response'
? 'text-foreground border-foreground'
: 'text-muted-foreground border-transparent hover:text-foreground'
)}
>
Raw Response
</button>
)}
</div>
{content != null && (
<div className="relative mt-1.5">
<div className="absolute top-1 right-1 flex gap-1">
{systemPrompt && <CopyButton text={systemPrompt} label="Copy System" />}
{userPrompt && <CopyButton text={userPrompt} label="Copy User" />}
<CopyButton text={JSON.stringify(content, null, 4)} label="Copy" />
</div>
<pre className="p-2 pt-5 text-[10px] text-foreground/70 bg-muted rounded overflow-x-auto max-h-60 overflow-y-auto">
{JSON.stringify(content, null, 4)}
</pre>
</div>
)}
</div>
)
}
function StepCard({ event }: { event: AgentStepEvent }) {
return (
<div className="rounded-lg border-l-2 border-l-blue-500/50 border bg-muted/40 p-2.5">
<div className="text-[11px] font-semibold text-foreground tracking-wide mb-2">
Step #{event.stepIndex! + 1}
</div>
{/* Reflection */}
{event.reflection && <ReflectionSection reflection={event.reflection} />}
{/* Action */}
{event.action && (
<div>
<div className="text-[11px] font-semibold text-foreground tracking-wide mb-1">
Actions
</div>
<div className="flex items-start gap-2">
<ActionIcon
name={event.action.name}
className="size-3.5 text-blue-500 shrink-0 mt-0.5"
/>
<div className="flex-1 min-w-0">
<p className="text-xs text-foreground/80 mb-0.5 wrap-anywhere break-all line-clamp-1 hover:line-clamp-none">
<span className="font-medium text-foreground/70">{event.action.name}</span>
{event.action.name !== 'done' && (
<span className="text-muted-foreground/70 ml-1.5">
{JSON.stringify(event.action.input)}
</span>
)}
</p>
<p className="text-[11px] text-muted-foreground/70 grid grid-cols-[auto_1fr] gap-1.5">
<span className=""></span>
<span className="wrap-anywhere break-all line-clamp-1 hover:line-clamp-3">
{event.action.output}
</span>
</p>
</div>
</div>
</div>
)}
{/* Raw Response */}
<RawSection rawRequest={event.rawRequest} rawResponse={event.rawResponse} />
</div>
)
}
function ObservationCard({ event }: { event: ObservationEvent }) {
return (
<div className="rounded-lg border-l-2 border-l-green-500/50 border bg-muted/40 p-2.5">
{/* <div className="text-[11px] font-semibold text-foreground uppercase tracking-wide mb-2">
Observation
</div> */}
<div className="flex items-start gap-2">
<Eye className="size-3.5 text-green-500 shrink-0 mt-0.5" />
<span className="text-[11px] text-muted-foreground">{event.content}</span>
</div>
</div>
)
}
function RetryCard({ event }: { event: RetryEvent }) {
return (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-2.5">
<div className="flex items-start gap-1.5">
<RefreshCw className="size-3 text-amber-500 shrink-0 mt-0.5" />
<span className="text-xs text-amber-600 dark:text-amber-400">
{event.message} ({event.attempt}/{event.maxAttempts})
</span>
</div>
</div>
)
}
function ErrorCard({ event }: { event: AgentErrorEvent }) {
return (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-2.5">
<div className="flex items-start gap-1.5">
<XCircle className="size-3 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">{event.message}</span>
</div>
<RawSection rawResponse={event.rawResponse} />
</div>
)
}
// History event card component
export function EventCard({ event }: { event: HistoricalEvent }) {
// Done action - show as result card
if (event.type === 'step' && event.action?.name === 'done') {
const input = event.action.input as { text?: string; success?: boolean }
return (
<>
<StepCard event={event as AgentStepEvent} />
<ResultCard
success={input?.success ?? true}
text={input?.text || event.action.output || ''}
/>
</>
)
}
if (event.type === 'step') {
return <StepCard event={event as AgentStepEvent} />
}
if (event.type === 'observation') {
return <ObservationCard event={event as ObservationEvent} />
}
if (event.type === 'retry') {
return <RetryCard event={event as RetryEvent} />
}
if (event.type === 'error') {
return <ErrorCard event={event as AgentErrorEvent} />
}
return null
}
// Activity card with animation
export function ActivityCard({ activity }: { activity: AgentActivity }) {
const getActivityInfo = () => {
switch (activity.type) {
case 'thinking':
return { text: 'Thinking...', color: 'text-blue-500' }
case 'executing':
return { text: `Executing ${activity.tool}...`, color: 'text-amber-500' }
case 'executed':
return { text: `Done: ${activity.tool}`, color: 'text-green-500' }
case 'retrying':
return {
text: `Retrying (${activity.attempt}/${activity.maxAttempts})...`,
color: 'text-amber-500',
}
case 'error':
return { text: activity.message, color: 'text-destructive' }
}
}
const info = getActivityInfo()
return (
<div className="flex items-center gap-2 rounded-lg border bg-muted/40 p-2.5 animate-pulse">
<div className="relative">
<Sparkles className={cn('size-3.5', info.color)} />
<span
className={cn(
'absolute -top-0.5 -right-0.5 size-1.5 rounded-full animate-ping',
activity.type === 'thinking'
? 'bg-blue-500'
: activity.type === 'executing'
? 'bg-amber-500'
: activity.type === 'retrying'
? 'bg-amber-500'
: activity.type === 'error'
? 'bg-destructive'
: 'bg-green-500'
)}
/>
</div>
<span className={cn('text-xs', info.color)}>{info.text}</span>
</div>
)
}
@@ -0,0 +1,46 @@
import type { AgentStatus } from '@page-agent/core'
import { cn } from '@/lib/utils'
// Status dot indicator
export function StatusDot({ status }: { status: AgentStatus }) {
const colorClass = {
idle: 'bg-muted-foreground',
running: 'bg-blue-500',
completed: 'bg-green-500',
error: 'bg-destructive',
}[status]
const label = {
idle: 'Ready',
running: 'Running',
completed: 'Done',
error: 'Error',
}[status]
return (
<div className="flex items-center gap-1.5">
<span
className={cn('size-2 rounded-full', colorClass, status === 'running' && 'animate-pulse')}
/>
<span className="text-xs text-muted-foreground">{label}</span>
</div>
)
}
export function Logo({ className }: { className?: string }) {
return <img src="/assets/page-agent-256.webp" alt="Page Agent" className={cn('', className)} />
}
// Empty state with logo
export function EmptyState() {
return (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-6">
<Logo className="size-20 opacity-80" />
<div>
<h2 className="text-sm font-medium text-foreground">Page Agent Ext</h2>
<p className="text-xs text-muted-foreground mt-1">Enter a task to automate this page</p>
</div>
</div>
)
}
@@ -0,0 +1,13 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/assets/page-agent-64.png" />
<title>Page Agent</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
@@ -0,0 +1,25 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import { ErrorBoundary } from './components/ErrorBoundary'
import '@/assets/index.css'
// Sync dark mode with system preference
const syncDarkMode = () => {
document.documentElement.classList.toggle(
'dark',
matchMedia('(prefers-color-scheme: dark)').matches
)
}
syncDarkMode()
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncDarkMode)
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>
)
+70
View File
@@ -0,0 +1,70 @@
import type { HistoricalEvent } from '@page-agent/core'
import { type DBSchema, type IDBPDatabase, openDB } from 'idb'
const DB_NAME = 'page-agent-ext'
const DB_VERSION = 1
export interface SessionRecord {
id: string
task: string
history: HistoricalEvent[]
status: 'completed' | 'error'
createdAt: number
}
interface PageAgentDB extends DBSchema {
sessions: {
key: string
value: SessionRecord
indexes: { 'by-created': number }
}
}
let dbPromise: Promise<IDBPDatabase<PageAgentDB>> | null = null
function getDB() {
if (!dbPromise) {
dbPromise = openDB<PageAgentDB>(DB_NAME, DB_VERSION, {
upgrade(db) {
const store = db.createObjectStore('sessions', { keyPath: 'id' })
store.createIndex('by-created', 'createdAt')
},
})
}
return dbPromise
}
export async function saveSession(
session: Omit<SessionRecord, 'id' | 'createdAt'>
): Promise<SessionRecord> {
const db = await getDB()
const record: SessionRecord = {
...session,
id: crypto.randomUUID(),
createdAt: Date.now(),
}
await db.put('sessions', record)
return record
}
/** List sessions, newest first */
export async function listSessions(): Promise<SessionRecord[]> {
const db = await getDB()
const all = await db.getAllFromIndex('sessions', 'by-created')
return all.reverse()
}
export async function getSession(id: string): Promise<SessionRecord | undefined> {
const db = await getDB()
return db.get('sessions', id)
}
export async function deleteSession(id: string): Promise<void> {
const db = await getDB()
await db.delete('sessions', id)
}
export async function clearSessions(): Promise<void> {
const db = await getDB()
await db.clear('sessions')
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+25
View File
@@ -0,0 +1,25 @@
// Asset type declarations
declare module '*.webp' {
const src: string
export default src
}
declare module '*.png' {
const src: string
export default src
}
declare module '*.jpg' {
const src: string
export default src
}
declare module '*.jpeg' {
const src: string
export default src
}
declare module '*.svg' {
const src: string
export default src
}
+2
View File
@@ -0,0 +1,2 @@
declare const __EXT_VERSION__: string
declare const __CORE_VERSION__: string
+4
View File
@@ -0,0 +1,4 @@
declare module '*.md?raw' {
const content: string
export default content
}
+28
View File
@@ -0,0 +1,28 @@
{
"extends": "./.wxt/tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
"useDefineForClassFields": true,
"noEmit": false,
"allowImportingTsExtensions": false,
"strictNullChecks": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
// Self root
"@/*": ["src/*"],
"@page-agent/llms": ["../llms/src/index.ts"],
"@page-agent/page-controller": ["../page-controller/src/PageController.ts"],
"@page-agent/core": ["../core/src/PageAgentCore.ts"],
"@page-agent/ui": ["../ui/src/index.ts"]
}
},
"references": [
//
{ "path": "../llms" },
{ "path": "../page-controller" },
{ "path": "../core" },
{ "path": "../ui" }
]
}
+63
View File
@@ -0,0 +1,63 @@
import tailwindcss from '@tailwindcss/vite'
import { mkdirSync, readFileSync } from 'node:fs'
import { defineConfig } from 'wxt'
const chromeProfile = '.wxt/chrome-data'
mkdirSync(chromeProfile, { recursive: true })
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))
// See https://wxt.dev/api/config.html
export default defineConfig({
srcDir: 'src',
modules: ['@wxt-dev/module-react'],
webExt: {
chromiumProfile: chromeProfile,
keepProfileChanges: true,
chromiumArgs: ['--hide-crash-restore-bubble'],
},
vite: () => ({
plugins: [tailwindcss()],
define: {
__EXT_VERSION__: JSON.stringify(pkg.version),
__CORE_VERSION__: JSON.stringify(pkg.dependencies['@page-agent/core']),
},
optimizeDeps: {
force: true,
},
build: {
minify: false,
chunkSizeWarningLimit: 2000,
cssCodeSplit: true,
rollupOptions: {
onwarn: function (message, handler) {
if (message.code === 'EVAL') return
handler(message)
},
},
},
}),
manifest: {
name: 'Page Agent Ext',
description:
'AI-powered browser automation assistant. Control web pages with natural language.',
homepage_url: 'https://alibaba.github.io/page-agent/',
permissions: ['tabs', 'tabGroups', 'sidePanel', 'storage'],
host_permissions: ['<all_urls>'],
icons: {
64: 'assets/page-agent-64.png',
},
action: {
default_title: 'Open Page Agent',
},
web_accessible_resources: [
{
resources: ['main-world.js'],
matches: ['*://*/*'],
},
],
side_panel: {
default_path: 'sidepanel/index.html',
},
},
})
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/llms", "name": "@page-agent/llms",
"version": "1.0.0-beta.3", "version": "1.3.0",
"type": "module", "type": "module",
"main": "./dist/lib/page-agent-llms.js", "main": "./dist/lib/page-agent-llms.js",
"module": "./dist/lib/page-agent-llms.js", "module": "./dist/lib/page-agent-llms.js",
+8 -3
View File
@@ -39,6 +39,8 @@ export class OpenAIClient implements LLMClient {
: 'required', : 'required',
} }
modelPatch(requestBody)
// 2. Call API // 2. Call API
let response: Response let response: Response
try { try {
@@ -48,12 +50,14 @@ export class OpenAIClient implements LLMClient {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${this.config.apiKey}`, Authorization: `Bearer ${this.config.apiKey}`,
}, },
body: JSON.stringify(modelPatch(requestBody)), body: JSON.stringify(requestBody),
signal: abortSignal, signal: abortSignal,
}) })
} catch (error: unknown) { } catch (error: unknown) {
console.error(error) const isAbortError = (error as any)?.name === 'AbortError'
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, 'Network request failed', error) const errorMessage = isAbortError ? 'Network request aborted' : 'Network request failed'
if (!isAbortError) console.error(error)
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, errorMessage, error)
} }
// 3. Handle HTTP errors // 3. Handle HTTP errors
@@ -216,6 +220,7 @@ export class OpenAIClient implements LLMClient {
reasoningTokens: data.usage?.completion_tokens_details?.reasoning_tokens, reasoningTokens: data.usage?.completion_tokens_details?.reasoning_tokens,
}, },
rawResponse: data, rawResponse: data,
rawRequest: requestBody,
} }
} }
} }
+5 -2
View File
@@ -34,12 +34,15 @@ 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) this.retryable = this.isRetryable(type, rawError)
this.rawError = rawError this.rawError = rawError
this.rawResponse = rawResponse this.rawResponse = rawResponse
} }
private isRetryable(type: InvokeErrorType): boolean { private isRetryable(type: InvokeErrorType, rawError?: unknown): boolean {
const isAbortError = (rawError as any)?.name === 'AbortError'
if (isAbortError) return false
const retryableTypes: InvokeErrorType[] = [ const retryableTypes: InvokeErrorType[] = [
InvokeErrorType.NETWORK_ERROR, InvokeErrorType.NETWORK_ERROR,
InvokeErrorType.RATE_LIMIT, InvokeErrorType.RATE_LIMIT,
+6 -3
View File
@@ -49,6 +49,9 @@ export class LLM extends EventTarget {
): Promise<InvokeResult> { ): Promise<InvokeResult> {
return await withRetry( return await withRetry(
async () => { async () => {
// in case user aborted before invoking
if (abortSignal.aborted) throw new Error('AbortError')
const result = await this.client.invoke(messages, tools, abortSignal, options) const result = await this.client.invoke(messages, tools, abortSignal, options)
return result return result
@@ -88,12 +91,12 @@ async function withRetry<T>(
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)?.rawError?.name === 'AbortError') throw error
console.error(error) console.error(error)
settings.onError(error as Error) settings.onError(error as Error)
// do not retry if aborted by user
if ((error as { name?: string })?.name === 'AbortError') throw error
// do not retry if error is not retryable (InvokeError) // 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
+1
View File
@@ -81,6 +81,7 @@ export interface InvokeResult<TResult = unknown> {
reasoningTokens?: number // OpenAI o1 series reasoning tokens reasoningTokens?: number // OpenAI o1 series reasoning tokens
} }
rawResponse?: unknown // Raw response for debugging rawResponse?: unknown // Raw response for debugging
rawRequest?: unknown // Raw request for debugging
} }
/** /**
+6 -1
View File
@@ -2,7 +2,7 @@
* Utility functions for LLM integration * Utility functions for LLM integration
*/ */
import chalk from 'chalk' import chalk from 'chalk'
import { z } from 'zod' import * as z from 'zod'
import type { Tool } from './types' import type { Tool } from './types'
@@ -27,6 +27,7 @@ export function zodToOpenAITool(name: string, tool: Tool) {
/** /**
* Patch model specific parameters * Patch model specific parameters
* @note in-place modification
*/ */
export function modelPatch(body: Record<string, any>) { export function modelPatch(body: Record<string, any>) {
const model: string = body.model || '' const model: string = body.model || ''
@@ -73,6 +74,10 @@ export function modelPatch(body: Record<string, any>) {
} else if (modelName.startsWith('gpt-51')) { } else if (modelName.startsWith('gpt-51')) {
debug('Applying GPT-51 patch: disable reasoning') debug('Applying GPT-51 patch: disable reasoning')
body.reasoning_effort = 'none' body.reasoning_effort = 'none'
} else if (modelName.startsWith('gpt-5-mini')) {
debug('Applying GPT-5-mini patch: set reasoning effort to low, temperature to 1')
body.reasoning_effort = 'low'
body.temperature = 1
} else if (modelName.startsWith('gpt-5')) { } else if (modelName.startsWith('gpt-5')) {
debug('Applying GPT-5 patch: set reasoning effort to low') debug('Applying GPT-5 patch: set reasoning effort to low')
body.reasoning_effort = 'low' body.reasoning_effort = 'low'
+6 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "page-agent", "name": "page-agent",
"private": false, "private": false,
"version": "1.0.0-beta.3", "version": "1.3.0",
"type": "module", "type": "module",
"main": "./dist/esm/page-agent.js", "main": "./dist/esm/page-agent.js",
"module": "./dist/esm/page-agent.js", "module": "./dist/esm/page-agent.js",
@@ -44,11 +44,11 @@
"postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\"" "postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
}, },
"dependencies": { "dependencies": {
"@page-agent/core": "1.3.0",
"@page-agent/llms": "1.3.0",
"@page-agent/page-controller": "1.3.0",
"@page-agent/ui": "1.3.0",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"zod": "^4.3.5", "zod": "^4.3.5"
"@page-agent/llms": "1.0.0-beta.3",
"@page-agent/page-controller": "1.0.0-beta.3",
"@page-agent/core": "1.0.0-beta.3",
"@page-agent/ui": "1.0.0-beta.3"
} }
} }
+6 -2
View File
@@ -3,6 +3,7 @@ import { config as dotenvConfig } from 'dotenv'
import { dirname, resolve } from 'path' import { dirname, resolve } from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
// import { analyzer } from 'vite-bundle-analyzer'
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js' import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -14,8 +15,11 @@ dotenvConfig({ path: resolve(__dirname, '../../.env') })
// - alias all local packages so that they can be build in // - alias all local packages so that they can be build in
// - no external // - no external
// - no d.ts. dts does not work with monorepo aliasing // - no d.ts. dts does not work with monorepo aliasing
export default defineConfig(({ mode }) => ({ export default defineConfig(() => ({
plugins: [cssInjectedByJsPlugin({ relativeCSSInjection: true })], plugins: [
cssInjectedByJsPlugin({ relativeCSSInjection: true }),
// analyzer()
],
publicDir: false, publicDir: false,
esbuild: { esbuild: {
keepNames: true, keepNames: true,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/page-controller", "name": "@page-agent/page-controller",
"version": "1.0.0-beta.3", "version": "1.3.0",
"type": "module", "type": "module",
"main": "./dist/lib/page-controller.js", "main": "./dist/lib/page-controller.js",
"module": "./dist/lib/page-controller.js", "module": "./dist/lib/page-controller.js",
+38 -11
View File
@@ -80,6 +80,9 @@ export class PageController extends EventTarget {
/** last time the tree was updated */ /** last time the tree was updated */
private lastTimeUpdate = 0 private lastTimeUpdate = 0
/** Whether the tree has been indexed at least once */
private isIndexed = false
/** Visual mask overlay for blocking user interaction during automation */ /** Visual mask overlay for blocking user interaction during automation */
private mask: InstanceType<typeof import('./mask/SimulatorMask').SimulatorMask> | null = null private mask: InstanceType<typeof import('./mask/SimulatorMask').SimulatorMask> | null = null
private maskReady: Promise<void> | null = null private maskReady: Promise<void> | null = null
@@ -91,17 +94,18 @@ export class PageController extends EventTarget {
patchReact(this) patchReact(this)
if (config.enableMask) { if (config.enableMask) this.initMask()
this.maskReady = this.initMask()
}
} }
/** /**
* Initialize mask asynchronously (dynamic import to avoid CSS loading in Node) * Initialize mask asynchronously (dynamic import to avoid CSS loading in Node)
*/ */
private async initMask(): Promise<void> { initMask() {
const { SimulatorMask } = await import('./mask/SimulatorMask') if (this.maskReady !== null) return
this.mask = new SimulatorMask() this.maskReady = (async () => {
const { SimulatorMask } = await import('./mask/SimulatorMask')
this.mask = new SimulatorMask()
})()
} }
// ======= State Queries ======= // ======= State Queries =======
@@ -134,6 +138,8 @@ export class PageController extends EventTarget {
const content = this.simplifiedHTML const content = this.simplifiedHTML
// Build header: page info + scroll position hint // Build header: page info + scroll position hint
const titleLine = `Current Page: [${title}](${url})`
const pageInfoLine = `Page info: ${pi.viewport_width}x${pi.viewport_height}px viewport, ${pi.page_width}x${pi.page_height}px total page size, ${pi.pages_above.toFixed(1)} pages above, ${pi.pages_below.toFixed(1)} pages below, ${pi.total_pages.toFixed(1)} total pages, at ${(pi.current_page_position * 100).toFixed(0)}% of page` const pageInfoLine = `Page info: ${pi.viewport_width}x${pi.viewport_height}px viewport, ${pi.page_width}x${pi.page_height}px total page size, ${pi.pages_above.toFixed(1)} pages above, ${pi.pages_below.toFixed(1)} pages below, ${pi.total_pages.toFixed(1)} total pages, at ${(pi.current_page_position * 100).toFixed(0)}% of page`
const elementsLabel = const elementsLabel =
@@ -147,7 +153,7 @@ export class PageController extends EventTarget {
? `... ${pi.pixels_above} pixels above (${pi.pages_above.toFixed(1)} pages) - scroll to see more ...` ? `... ${pi.pixels_above} pixels above (${pi.pages_above.toFixed(1)} pages) - scroll to see more ...`
: '[Start of page]' : '[Start of page]'
const header = `${pageInfoLine}\n\n${elementsLabel}\n\n${scrollHintAbove}` const header = `${titleLine}\n${pageInfoLine}\n\n${elementsLabel}\n\n${scrollHintAbove}`
// Build footer: scroll position hint // Build footer: scroll position hint
const hasContentBelow = pi.pixels_below > 4 const hasContentBelow = pi.pixels_below > 4
@@ -188,7 +194,7 @@ export class PageController extends EventTarget {
interactiveBlacklist: blacklist, interactiveBlacklist: blacklist,
}) })
this.simplifiedHTML = dom.flatTreeToString(this.flatTree, this.config.include_attributes) this.simplifiedHTML = dom.flatTreeToString(this.flatTree, this.config.includeAttributes)
this.selectorMap.clear() this.selectorMap.clear()
this.selectorMap = dom.getSelectorMap(this.flatTree) this.selectorMap = dom.getSelectorMap(this.flatTree)
@@ -196,6 +202,9 @@ export class PageController extends EventTarget {
this.elementTextMap.clear() this.elementTextMap.clear()
this.elementTextMap = dom.getElementTextMap(this.simplifiedHTML) this.elementTextMap = dom.getElementTextMap(this.simplifiedHTML)
// Mark as indexed - now element actions are allowed
this.isIndexed = true
// Restore mask blocking // Restore mask blocking
if (this.mask) { if (this.mask) {
this.mask.wrapper.style.pointerEvents = 'auto' this.mask.wrapper.style.pointerEvents = 'auto'
@@ -215,11 +224,22 @@ export class PageController extends EventTarget {
// ======= Element Actions ======= // ======= Element Actions =======
/**
* Ensure the tree has been indexed before any index-based operation.
* Throws if updateTree() hasn't been called yet.
*/
private assertIndexed(): void {
if (!this.isIndexed) {
throw new Error('DOM tree not indexed yet. Can not perform actions on elements.')
}
}
/** /**
* Click element by index * Click element by index
*/ */
async clickElement(index: number): Promise<ActionResult> { async clickElement(index: number): Promise<ActionResult> {
try { try {
this.assertIndexed()
const element = getElementByIndex(this.selectorMap, index) const element = getElementByIndex(this.selectorMap, index)
const elemText = this.elementTextMap.get(index) const elemText = this.elementTextMap.get(index)
await clickElement(element) await clickElement(element)
@@ -228,7 +248,7 @@ export class PageController extends EventTarget {
if (element instanceof HTMLAnchorElement && element.target === '_blank') { if (element instanceof HTMLAnchorElement && element.target === '_blank') {
return { return {
success: true, success: true,
message: `✅ Clicked element (${elemText ?? index}). ⚠️ Link opens in a new tab. You are not capable of reading new tabs.`, message: `✅ Clicked element (${elemText ?? index}). ⚠️ Link opened in a new tab.`,
} }
} }
@@ -249,6 +269,7 @@ export class PageController extends EventTarget {
*/ */
async inputText(index: number, text: string): Promise<ActionResult> { async inputText(index: number, text: string): Promise<ActionResult> {
try { try {
this.assertIndexed()
const element = getElementByIndex(this.selectorMap, index) const element = getElementByIndex(this.selectorMap, index)
const elemText = this.elementTextMap.get(index) const elemText = this.elementTextMap.get(index)
await inputTextElement(element, text) await inputTextElement(element, text)
@@ -270,6 +291,7 @@ export class PageController extends EventTarget {
*/ */
async selectOption(index: number, optionText: string): Promise<ActionResult> { async selectOption(index: number, optionText: string): Promise<ActionResult> {
try { try {
this.assertIndexed()
const element = getElementByIndex(this.selectorMap, index) const element = getElementByIndex(this.selectorMap, index)
const elemText = this.elementTextMap.get(index) const elemText = this.elementTextMap.get(index)
await selectOptionElement(element as HTMLSelectElement, optionText) await selectOptionElement(element as HTMLSelectElement, optionText)
@@ -298,6 +320,8 @@ export class PageController extends EventTarget {
try { try {
const { down, numPages, pixels, index } = options const { down, numPages, pixels, index } = options
this.assertIndexed()
const scrollAmount = pixels ?? numPages * (down ? 1 : -1) * window.innerHeight const scrollAmount = pixels ?? numPages * (down ? 1 : -1) * window.innerHeight
const element = index !== undefined ? getElementByIndex(this.selectorMap, index) : null const element = index !== undefined ? getElementByIndex(this.selectorMap, index) : null
@@ -327,6 +351,8 @@ export class PageController extends EventTarget {
try { try {
const { right, pixels, index } = options const { right, pixels, index } = options
this.assertIndexed()
const scrollAmount = pixels * (right ? 1 : -1) const scrollAmount = pixels * (right ? 1 : -1)
const element = index !== undefined ? getElementByIndex(this.selectorMap, index) : null const element = index !== undefined ? getElementByIndex(this.selectorMap, index) : null
@@ -369,7 +395,7 @@ export class PageController extends EventTarget {
/** /**
* Show the visual mask overlay. * Show the visual mask overlay.
* Only works if enableMask was set to true in config. * Only works after mask is setup.
*/ */
async showMask(): Promise<void> { async showMask(): Promise<void> {
await this.maskReady await this.maskReady
@@ -378,7 +404,7 @@ export class PageController extends EventTarget {
/** /**
* Hide the visual mask overlay. * Hide the visual mask overlay.
* Only works if enableMask was set to true in config. * Only works after mask is setup.
*/ */
async hideMask(): Promise<void> { async hideMask(): Promise<void> {
await this.maskReady await this.maskReady
@@ -394,6 +420,7 @@ export class PageController extends EventTarget {
this.selectorMap.clear() this.selectorMap.clear()
this.elementTextMap.clear() this.elementTextMap.clear()
this.simplifiedHTML = '<EMPTY>' this.simplifiedHTML = '<EMPTY>'
this.isIndexed = false
this.mask?.dispose() this.mask?.dispose()
this.mask = null this.mask = null
} }
+3 -3
View File
@@ -10,7 +10,7 @@ import {
export interface DomConfig { export interface DomConfig {
interactiveBlacklist?: (Element | (() => Element))[] interactiveBlacklist?: (Element | (() => Element))[]
interactiveWhitelist?: (Element | (() => Element))[] interactiveWhitelist?: (Element | (() => Element))[]
include_attributes?: string[] includeAttributes?: string[]
highlightOpacity?: number highlightOpacity?: number
highlightLabelOpacity?: number highlightLabelOpacity?: number
} }
@@ -117,7 +117,7 @@ interface TreeNode {
* *
* @todo * @todo
*/ */
export function flatTreeToString(flatTree: FlatDomTree, include_attributes?: string[]): string { export function flatTreeToString(flatTree: FlatDomTree, includeAttributes?: string[]): string {
const DEFAULT_INCLUDE_ATTRIBUTES = [ const DEFAULT_INCLUDE_ATTRIBUTES = [
'title', 'title',
'type', 'type',
@@ -146,7 +146,7 @@ export function flatTreeToString(flatTree: FlatDomTree, include_attributes?: str
'aria-owns', 'aria-owns',
] ]
const includeAttrs = [...(include_attributes || []), ...DEFAULT_INCLUDE_ATTRIBUTES] const includeAttrs = [...(includeAttributes || []), ...DEFAULT_INCLUDE_ATTRIBUTES]
// Helper function to cap text length // Helper function to cap text length
const capTextLength = (text: string, maxLength: number): string => { const capTextLength = (text: string, maxLength: number): string => {
@@ -2,8 +2,7 @@
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 2147483641; /* 确保在所有元素之上,除了 panel */ z-index: 2147483641; /* 确保在所有元素之上,除了 panel */
/* pointer-events: none; */ cursor: wait;
cursor: not-allowed;
overflow: hidden; overflow: hidden;
display: none; display: none;
@@ -6,6 +6,7 @@ import styles from './SimulatorMask.module.css'
import cursorStyles from './cursor.module.css' import cursorStyles from './cursor.module.css'
export class SimulatorMask { export class SimulatorMask {
shown: boolean = false
wrapper = document.createElement('div') wrapper = document.createElement('div')
motion = new Motion({ motion = new Motion({
mode: isPageDark() ? 'dark' : 'light', mode: isPageDark() ? 'dark' : 'light',
@@ -140,6 +141,9 @@ export class SimulatorMask {
} }
show() { show() {
if (this.shown) return
this.shown = true
this.motion.start() this.motion.start()
this.motion.fadeIn() this.motion.fadeIn()
@@ -155,6 +159,9 @@ export class SimulatorMask {
} }
hide() { hide() {
if (!this.shown) return
this.shown = false
this.motion.fadeOut() this.motion.fadeOut()
this.motion.pause() this.motion.pause()
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<g><path d="M 15 42 L 15 36.99 Q 15 31.99 23.7 31.99 L 28.05 31.99 Q 32.41 31.99 32.41 21.99 L 32.41 17 Q 32.41 12 41.09 16.95 L 76.31 37.05 Q 85 42 76.31 46.95 L 41.09 67.05 Q 32.41 72 32.41 62.01 L 32.41 57.01 Q 32.41 52.01 23.7 52.01 L 19.35 52.01 Q 15 52.01 15 47.01 Z" fill="none" stroke="#000000" stroke-width="6" stroke-miterlimit="10" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g>
</svg>

After

Width:  |  Height:  |  Size: 495 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
</defs>
<g xmlns="http://www.w3.org/2000/svg" style="filter: drop-shadow(light-dark(rgba(0, 0, 0, 0.4), rgba(237, 237, 237, 0.4)) 3px 4px 4px);"><path d="M 15 42 L 15 36.99 Q 15 31.99 23.7 31.99 L 28.05 31.99 Q 32.41 31.99 32.41 21.99 L 32.41 17 Q 32.41 12 41.09 16.95 L 76.31 37.05 Q 85 42 76.31 46.95 L 41.09 67.05 Q 32.41 72 32.41 62.01 L 32.41 57.01 Q 32.41 52.01 23.7 52.01 L 19.35 52.01 Q 15 52.01 15 47.01 Z" fill="#ffffff" stroke="none" style="fill: #ffffff;"/></g>
</svg>

After

Width:  |  Height:  |  Size: 557 B

@@ -5,80 +5,59 @@
height: var(--cursor-size, 75px); height: var(--cursor-size, 75px);
pointer-events: none; pointer-events: none;
z-index: 10000; z-index: 10000;
transform: translate(-30%, -30%);
animation: cursor-enter 300ms ease-out forwards;
} }
.cursorBorder { .cursorBorder {
position: absolute; position: absolute;
inset: 0; width: 100%;
height: 100%;
background: linear-gradient(45deg, rgb(57, 182, 255), rgb(189, 69, 251)); background: linear-gradient(45deg, rgb(57, 182, 255), rgb(189, 69, 251));
mask-image: url(https://img.alicdn.com/imgextra/i1/O1CN01YHLVYR1LvqWIyo5kH_!!6000000001362-2-tps-202-202.png); mask-image: url(./cursor-border.svg);
mask-size: 100% 100%; mask-size: 100% 100%;
mask-repeat: no-repeat; mask-repeat: no-repeat;
animation: cursor-breathe 2s ease-in-out infinite;
transform-origin: center;
transform: rotate(-135deg) scale(1.2);
margin-left: -10px;
margin-top: -18px;
} }
.cursorFilling { .cursorFilling {
position: absolute; position: absolute;
inset: 0; width: 100%;
background: url(https://img.alicdn.com/imgextra/i3/O1CN01JZOqOS1Tu1sIKbPLW_!!6000000002441-2-tps-202-202.png); height: 100%;
background: url(./cursor-fill.svg);
background-size: 100% 100%; background-size: 100% 100%;
background-repeat: no-repeat; background-repeat: no-repeat;
transform-origin: center;
transform: rotate(-135deg) scale(1.2);
margin-left: -10px;
margin-top: -18px;
} }
.cursorRipple { .cursorRipple {
position: absolute; position: absolute;
inset: 0; width: 100%;
height: 100%;
pointer-events: none; pointer-events: none;
margin-left: -50%;
margin-top: -50%;
&::after {
content: '';
opacity: 0;
position: absolute;
inset: 0;
border: 4px solid rgba(57, 182, 255, 1);
border-radius: 50%;
}
} }
.cursor.clicking .cursorRipple::after { .cursor.clicking .cursorRipple::after {
content: '';
position: absolute;
width: 100%;
height: 100%;
left: -30%;
top: -30%;
border: 4px solid rgba(57, 182, 255, 1);
border-radius: 50%;
animation: cursor-ripple 300ms ease-out forwards; animation: cursor-ripple 300ms ease-out forwards;
} }
/* 光标动画关键帧 */
@keyframes cursor-breathe {
0%,
100% {
transform: scale(1);
opacity: 0.9;
}
50% {
transform: scale(1.05);
opacity: 1;
}
}
@keyframes cursor-rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes cursor-enter {
0% {
transform: translate(-30%, -30%) scale(0.5);
opacity: 0;
}
100% {
transform: translate(-30%, -30%) scale(1);
opacity: 1;
}
}
@keyframes cursor-ripple { @keyframes cursor-ripple {
0% { 0% {
transform: scale(0); transform: scale(0);
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/ui", "name": "@page-agent/ui",
"version": "1.0.0-beta.3", "version": "1.3.0",
"type": "module", "type": "module",
"main": "./dist/lib/page-agent-ui.js", "main": "./dist/lib/page-agent-ui.js",
"module": "./dist/lib/page-agent-ui.js", "module": "./dist/lib/page-agent-ui.js",
+2
View File
@@ -12,6 +12,7 @@ const enUS = {
question: 'Question: {{question}}', question: 'Question: {{question}}',
waitingPlaceholder: 'Waiting for task to start...', waitingPlaceholder: 'Waiting for task to start...',
stop: 'Stop', stop: 'Stop',
close: 'Close',
expand: 'Expand history', expand: 'Expand history',
collapse: 'Collapse history', collapse: 'Collapse history',
step: 'Step {{number}} · {{time}}{{duration}}', step: 'Step {{number}} · {{time}}{{duration}}',
@@ -59,6 +60,7 @@ const zhCN = {
question: '询问: {{question}}', question: '询问: {{question}}',
waitingPlaceholder: '等待任务开始...', waitingPlaceholder: '等待任务开始...',
stop: '终止', stop: '终止',
close: '关闭',
expand: '展开历史', expand: '展开历史',
collapse: '收起历史', collapse: '收起历史',
step: '步骤 {{number}} · {{time}}{{duration}}', step: '步骤 {{number}} · {{time}}{{duration}}',
-1
View File
@@ -1,3 +1,2 @@
export { Panel, type PanelConfig } from './panel/Panel' export { Panel, type PanelConfig } from './panel/Panel'
export type { AgentActivity, PanelAgentAdapter } from './panel/types'
export { I18n, type SupportedLanguage, type TranslationKey } from './i18n' export { I18n, type SupportedLanguage, type TranslationKey } from './i18n'
+29 -9
View File
@@ -33,7 +33,7 @@ export class Panel {
#statusText: HTMLElement #statusText: HTMLElement
#historySection: HTMLElement #historySection: HTMLElement
#expandButton: HTMLElement #expandButton: HTMLElement
#stopButton: HTMLElement #actionButton: HTMLElement
#inputSection: HTMLElement #inputSection: HTMLElement
#taskInput: HTMLInputElement #taskInput: HTMLInputElement
@@ -76,7 +76,7 @@ export class Panel {
this.#statusText = this.#wrapper.querySelector(`.${styles.statusText}`)! this.#statusText = this.#wrapper.querySelector(`.${styles.statusText}`)!
this.#historySection = this.#wrapper.querySelector(`.${styles.historySection}`)! this.#historySection = this.#wrapper.querySelector(`.${styles.historySection}`)!
this.#expandButton = this.#wrapper.querySelector(`.${styles.expandButton}`)! this.#expandButton = this.#wrapper.querySelector(`.${styles.expandButton}`)!
this.#stopButton = this.#wrapper.querySelector(`.${styles.stopButton}`)! this.#actionButton = this.#wrapper.querySelector(`.${styles.stopButton}`)!
this.#inputSection = this.#wrapper.querySelector(`.${styles.inputSectionWrapper}`)! this.#inputSection = this.#wrapper.querySelector(`.${styles.inputSectionWrapper}`)!
this.#taskInput = this.#wrapper.querySelector(`.${styles.taskInput}`)! this.#taskInput = this.#wrapper.querySelector(`.${styles.taskInput}`)!
@@ -105,6 +105,15 @@ export class Panel {
status === 'running' ? 'thinking' : status === 'idle' ? 'thinking' : status status === 'running' ? 'thinking' : status === 'idle' ? 'thinking' : status
this.#updateStatusIndicator(indicatorType) this.#updateStatusIndicator(indicatorType)
// Morph action button: running = stop (■), not running = close (X)
if (status === 'running') {
this.#actionButton.textContent = '■'
this.#actionButton.title = this.#i18n.t('ui.panel.stop')
} else {
this.#actionButton.textContent = 'X'
this.#actionButton.title = this.#i18n.t('ui.panel.close')
}
// Show/hide based on status // Show/hide based on status
if (status === 'running') { if (status === 'running') {
this.show() this.show()
@@ -266,10 +275,14 @@ export class Panel {
} }
/** /**
* Stop Agent * Action button handler: stop when running, close (dispose) when idle
*/ */
#stopAgent(): void { #handleActionButton(): void {
this.#agent.dispose() if (this.#agent.status === 'running') {
this.#agent.stop()
} else {
this.#agent.dispose()
}
} }
/** /**
@@ -383,7 +396,7 @@ export class Panel {
<button class="${styles.controlButton} ${styles.expandButton}" title="${this.#i18n.t('ui.panel.expand')}"> <button class="${styles.controlButton} ${styles.expandButton}" title="${this.#i18n.t('ui.panel.expand')}">
</button> </button>
<button class="${styles.controlButton} ${styles.stopButton}" title="${this.#i18n.t('ui.panel.stop')}"> <button class="${styles.controlButton} ${styles.stopButton}" title="${this.#i18n.t('ui.panel.close')}">
X X
</button> </button>
</div> </div>
@@ -420,10 +433,10 @@ export class Panel {
this.#toggle() this.#toggle()
}) })
// Stop button // Action button (stop / close)
this.#stopButton.addEventListener('click', (e) => { this.#actionButton.addEventListener('click', (e) => {
e.stopPropagation() e.stopPropagation()
this.#stopAgent() this.#handleActionButton()
}) })
// Submit on Enter key in input field // Submit on Enter key in input field
@@ -608,6 +621,13 @@ export class Panel {
) )
} else if (event.type === 'user_takeover') { } else if (event.type === 'user_takeover') {
cards.push(createCard({ icon: '👤', content: 'User takeover', meta, type: 'input' })) cards.push(createCard({ icon: '👤', content: 'User takeover', meta, type: 'input' }))
} else if (event.type === 'retry') {
const retryInfo = `${event.message || 'Retrying'} (${event.attempt}/${event.maxAttempts})`
cards.push(createCard({ icon: '🔄', content: retryInfo, meta, type: 'observation' }))
} else if (event.type === 'error') {
cards.push(
createCard({ icon: '❌', content: event.message || 'Error', meta, type: 'observation' })
)
} }
return cards return cards
+10 -2
View File
@@ -33,7 +33,7 @@ export interface PanelAgentAdapter extends EventTarget {
/** History of agent events */ /** History of agent events */
readonly history: readonly { readonly history: readonly {
type: 'step' | 'observation' | 'user_takeover' | 'error' type: 'step' | 'observation' | 'user_takeover' | 'retry' | 'error'
stepIndex?: number stepIndex?: number
/** For 'step' type */ /** For 'step' type */
reflection?: { reflection?: {
@@ -49,6 +49,11 @@ export interface PanelAgentAdapter extends EventTarget {
} }
/** For 'observation' type */ /** For 'observation' type */
content?: string content?: string
/** For 'retry' type */
attempt?: number
maxAttempts?: number
/** For 'retry' and 'error' types */
message?: string
}[] }[]
/** Current task being executed */ /** Current task being executed */
@@ -63,6 +68,9 @@ export interface PanelAgentAdapter extends EventTarget {
/** Execute a task */ /** Execute a task */
execute(task: string): Promise<unknown> execute(task: string): Promise<unknown>
/** Dispose the agent */ /** Stop the current task (agent remains reusable) */
stop(): void
/** Dispose the agent (terminal, cannot be reused) */
dispose(): void dispose(): void
} }
+12 -19
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/website", "name": "@page-agent/website",
"private": true, "private": true,
"version": "1.0.0-beta.3", "version": "1.3.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
@@ -10,33 +10,26 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.1",
"@vitejs/plugin-react-swc": "^4.1.0",
"i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-i18next": "^16.5.3",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0",
"wouter": "^3.9.0"
},
"dependencies": {
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.563.0",
"motion": "^12.27.1", "motion": "^12.34.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"rough-notation": "^0.5.1", "rough-notation": "^0.5.1",
"simple-icons": "^16.6.0", "simple-icons": "^16.8.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0",
"wouter": "^3.9.0"
} }
} }
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next' import { useLanguage } from '@/i18n/context'
export default function BetaNotice() { export default function BetaNotice() {
const { t } = useTranslation('common') const { isZh } = useLanguage()
return ( return (
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4 mb-8"> <div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4 mb-8">
@@ -11,9 +11,13 @@ export default function BetaNotice() {
</div> </div>
<div className="ml-3"> <div className="ml-3">
<h3 className="text-sm font-medium text-orange-800 dark:text-orange-200 mb-1"> <h3 className="text-sm font-medium text-orange-800 dark:text-orange-200 mb-1">
{t('beta_notice.title')} {isZh ? 'Beta 阶段' : 'Beta Stage'}
</h3> </h3>
<p className="text-sm text-orange-700 dark:text-orange-300">{t('beta_notice.content')}</p> <p className="text-sm text-orange-700 dark:text-orange-300">
{isZh
? '当前功能未完成,接口可能随时变更。正式版本发布前请勿用于生产环境。'
: 'Current features are incomplete and the API may change at any time. Please do not use in production environments before the official release.'}
</p>
</div> </div>
</div> </div>
</div> </div>
+7 -4
View File
@@ -1,8 +1,9 @@
import { useTranslation } from 'react-i18next'
import { siGithub } from 'simple-icons' import { siGithub } from 'simple-icons'
import { useLanguage } from '@/i18n/context'
export default function Footer() { export default function Footer() {
const { t } = useTranslation('common') const { isZh } = useLanguage()
return ( return (
<footer <footer
@@ -11,14 +12,16 @@ export default function Footer() {
> >
<div className="max-w-7xl mx-auto px-6 py-6"> <div className="max-w-7xl mx-auto px-6 py-6">
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0"> <div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
<p className="text-gray-600 dark:text-gray-300 text-sm">{t('footer.copyright')}</p> <p className="text-gray-600 dark:text-gray-300 text-sm">
© 2026 page-agent. All rights reserved.
</p>
<div className="flex items-center space-x-6"> <div className="flex items-center space-x-6">
<a <a
href="https://github.com/alibaba/page-agent" href="https://github.com/alibaba/page-agent"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-200" className="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-200"
aria-label={t('footer.github_label')} aria-label={isZh ? '访问 GitHub 仓库' : 'Visit GitHub repository'}
> >
<svg <svg
role="img" role="img"
+15 -14
View File
@@ -1,15 +1,16 @@
import { BookOpen, Menu, X } from 'lucide-react' import { BookOpen, Menu, X } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { siGithub } from 'simple-icons' import { siGithub } from 'simple-icons'
import { Link } from 'wouter' import { Link } from 'wouter'
import { useLanguage } from '@/i18n/context'
import LanguageSwitcher from './LanguageSwitcher' import LanguageSwitcher from './LanguageSwitcher'
import ThemeSwitcher from './ThemeSwitcher' import ThemeSwitcher from './ThemeSwitcher'
import { HyperText } from './ui/hyper-text' import { HyperText } from './ui/hyper-text'
export default function Header() { export default function Header() {
const { t } = useTranslation('common') const { isZh } = useLanguage()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
return ( return (
@@ -24,7 +25,7 @@ export default function Header() {
<Link <Link
href="~/" href="~/"
className="flex items-center gap-2 sm:gap-3 group shrink-0" className="flex items-center gap-2 sm:gap-3 group shrink-0"
aria-label={t('header.logo_alt')} aria-label={isZh ? 'page-agent 首页' : 'page-agent home'}
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
> >
<img <img
@@ -43,7 +44,7 @@ export default function Header() {
animateOnHover={true} animateOnHover={true}
aria-hidden="true" aria-hidden="true"
> >
{t('header.slogan')} GUI Agent in your webpage
</HyperText> </HyperText>
</div> </div>
</Link> </Link>
@@ -57,7 +58,7 @@ export default function Header() {
<Link <Link
href="~/docs/introduction/overview" href="~/docs/introduction/overview"
className="p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 shrink-0" className="p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 shrink-0"
aria-label={t('header.nav_docs')} aria-label={isZh ? '文档' : 'Docs'}
> >
<BookOpen className="w-5 h-5" /> <BookOpen className="w-5 h-5" />
</Link> </Link>
@@ -66,7 +67,7 @@ export default function Header() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 shrink-0" className="p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 shrink-0"
aria-label={t('header.nav_source')} aria-label="GitHub"
> >
<svg <svg
role="img" role="img"
@@ -83,7 +84,7 @@ export default function Header() {
<nav <nav
className="hidden md:flex items-center space-x-6" className="hidden md:flex items-center space-x-6"
role="navigation" role="navigation"
aria-label={t('header.nav_docs')} aria-label={isZh ? '文档' : 'Docs'}
> >
<span className="text-xs font-mono text-gray-400 dark:text-gray-500 tabular-nums before:content-['v']"> <span className="text-xs font-mono text-gray-400 dark:text-gray-500 tabular-nums before:content-['v']">
{import.meta.env.VERSION} {import.meta.env.VERSION}
@@ -93,14 +94,14 @@ export default function Header() {
className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200" className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
> >
<BookOpen className="w-4 h-4" /> <BookOpen className="w-4 h-4" />
{t('header.nav_docs')} {isZh ? '文档' : 'Docs'}
</Link> </Link>
<a <a
href="https://github.com/alibaba/page-agent" href="https://github.com/alibaba/page-agent"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200" className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
aria-label={t('header.nav_source')} aria-label="GitHub"
> >
<svg <svg
role="img" role="img"
@@ -110,7 +111,7 @@ export default function Header() {
> >
<path d={siGithub.path} /> <path d={siGithub.path} />
</svg> </svg>
{t('header.nav_source')} GitHub
</a> </a>
<ThemeSwitcher /> <ThemeSwitcher />
<LanguageSwitcher /> <LanguageSwitcher />
@@ -120,7 +121,7 @@ export default function Header() {
<button <button
type="button" type="button"
className="md:hidden p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-200 shrink-0" className="md:hidden p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-200 shrink-0"
aria-label={t('header.mobile_menu')} aria-label={isZh ? '打开导航栏' : 'Open navigation'}
aria-expanded={mobileMenuOpen} aria-expanded={mobileMenuOpen}
aria-controls="mobile-menu" aria-controls="mobile-menu"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)} onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
@@ -142,14 +143,14 @@ export default function Header() {
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
> >
<BookOpen className="w-5 h-5" /> <BookOpen className="w-5 h-5" />
{t('header.nav_docs')} {isZh ? '文档' : 'Docs'}
</Link> </Link>
<a <a
href="https://github.com/alibaba/page-agent" href="https://github.com/alibaba/page-agent"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200" className="flex items-center gap-2 px-3 py-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
aria-label={t('header.nav_source')} aria-label="GitHub"
> >
<svg <svg
role="img" role="img"
@@ -159,7 +160,7 @@ export default function Header() {
> >
<path d={siGithub.path} /> <path d={siGithub.path} />
</svg> </svg>
{t('header.nav_source')} GitHub
</a> </a>
<div className="flex items-center gap-3 px-3 py-2"> <div className="flex items-center gap-3 px-3 py-2">
<ThemeSwitcher /> <ThemeSwitcher />
@@ -3,6 +3,8 @@
word-break: break-word; word-break: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
font-family: monospace; font-family: monospace;
font-size: 13px;
line-height: 1;
color: #171717; color: #171717;
} }
@@ -1,22 +1,21 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLanguage } from '@/i18n/context'
export default function LanguageSwitcher() { export default function LanguageSwitcher() {
const { i18n, t } = useTranslation('common') const { language, isZh, setLanguage } = useLanguage()
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null) const dropdownRef = useRef<HTMLDivElement>(null)
const currentLang = i18n.language
const languages = [ const languages = [
{ code: 'zh-CN', label: '中文' }, { code: 'zh-CN' as const, label: '中文' },
{ code: 'en-US', label: 'English' }, { code: 'en-US' as const, label: 'English' },
] ]
const currentLanguage = languages.find((lang) => lang.code === currentLang) || languages[0] const currentLanguage = languages.find((lang) => lang.code === language) || languages[0]
const handleLanguageChange = (langCode: string) => { const handleLanguageChange = (langCode: 'zh-CN' | 'en-US') => {
i18n.changeLanguage(langCode) setLanguage(langCode)
setIsOpen(false) setIsOpen(false)
} }
@@ -42,7 +41,7 @@ export default function LanguageSwitcher() {
<button <button
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors text-sm font-medium border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300" className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors text-sm font-medium border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300"
aria-label={t('language.switch_label')} aria-label={isZh ? '切换语言' : 'Switch language'}
aria-expanded={isOpen} aria-expanded={isOpen}
aria-haspopup="true" aria-haspopup="true"
> >
@@ -83,14 +82,14 @@ export default function LanguageSwitcher() {
key={lang.code} key={lang.code}
onClick={() => handleLanguageChange(lang.code)} onClick={() => handleLanguageChange(lang.code)}
className={`w-full flex items-center gap-2 px-4 py-2 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${ className={`w-full flex items-center gap-2 px-4 py-2 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
currentLang === lang.code language === lang.code
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300' ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
: 'text-gray-700 dark:text-gray-300' : 'text-gray-700 dark:text-gray-300'
}`} }`}
role="menuitem" role="menuitem"
> >
<span>{lang.label}</span> <span>{lang.label}</span>
{currentLang === lang.code && ( {language === lang.code && (
<svg <svg
className="w-4 h-4 ml-auto" className="w-4 h-4 ml-auto"
fill="none" fill="none"

Some files were not shown because too many files have changed in this diff Show More