Compare commits

..

191 Commits

Author SHA1 Message Date
Simon 3d014f15e7 chore(version): bump version to 1.5.8 2026-03-16 22:52:32 +08:00
Simon ce8f0fdbd8 Merge pull request #273 from alibaba/feat/extension-control-curr-tab-group
feat(ext): initial controlled group
2026-03-16 22:51:51 +08:00
Simon 48fede38fd feat(ext): initialTab should be in controlled group; rm windowId; rm gray color 2026-03-16 20:51:32 +08:00
Simon f303ed5fc5 Merge branch 'main' of https://github.com/alibaba/page-agent 2026-03-16 19:45:29 +08:00
Simon 6dbc33efd9 docs: limitation; readme; attribution 2026-03-16 19:45:14 +08:00
Simon 53628532df Merge pull request #251 from linked-danis/pr5-scrollintoview 2026-03-16 18:45:19 +08:00
Simon 2aae34d74a Merge pull request #250 from linked-danis/pr2-step-delay 2026-03-16 17:50:50 +08:00
Simon e27d1fb57f fix(style): Tailwind important flag 2026-03-16 17:35:32 +08:00
Simon 0fa7dc0531 chore(version): bump version to 1.5.7 2026-03-13 22:02:02 +08:00
linked-danis 89546887bd fix: type-safe scrollIntoViewIfNeeded
Add proper interface for WebKit extension method.

Changes:
- Add ScrollableElement interface
- Use typeof check instead of 'as any' cast
2026-03-13 14:22:47 +01:00
linked-danis b0bcba2922 feat: add stepDelay config option
Add configurable delay between agent steps.
Previously hardcoded to 0.4s.

Changes:
- Add stepDelay?: number to AgentConfig
- Use config value with 0.4s default
2026-03-13 14:22:37 +01:00
Simon 618ae8bf14 Merge pull request #236 from Wizard-Guido/fix/some-typos-and-grammar
fix: typos and grammar in system prompts and source code
2026-03-13 21:13:40 +08:00
Simon 5ed5d574fd Merge pull request #248 from alibaba/split/pr-207-simulator-mask-css 2026-03-13 21:07:26 +08:00
Simon bde9d9023a Merge pull request #247 from alibaba/split/pr-207-validate-url
fix: validate URL in fetchLlmsTxt
2026-03-13 21:05:10 +08:00
Simon c8a8858d24 fix(llms): edge cases 2026-03-13 21:03:29 +08:00
linked-danis 6491118cde refactor: SimulatorMask use CSS classes
Replace inline style.display with CSS class toggling.

Changes:
- Add .visible class to CSS module
- Use classList.add/remove instead of style.display

(cherry picked from commit 33465bbf52)
2026-03-13 20:44:34 +08:00
linked-danis b4acd02007 fix: validate URL in fetchLlmsTxt
Prevent crash on invalid URLs.

Changes:
- Wrap URL constructor in try/catch
- Return null for invalid URLs instead of throwing

(cherry picked from commit 8b0acf314d)
2026-03-13 20:44:14 +08:00
Simon 5695b3d6aa chore(website): add social media link; add star 2026-03-13 20:23:13 +08:00
Simon 0a160697d1 docs(terms): add Permitted Use Only 2026-03-13 19:16:20 +08:00
Wen 11d3ca3d6e fix: typos and grammar in system prompts and source code 2026-03-13 01:32:34 +00:00
Simon ee88b23891 Merge pull request #229 from alibaba/feat/seo
feat(website): basic SEO
2026-03-12 20:06:56 +08:00
Simon ca68be7a56 feat(website): basic SEO 2026-03-12 20:06:12 +08:00
Simon 4ad2abe997 docs: no-bots policy 2026-03-12 19:12:59 +08:00
Simon 8f08322721 chore: add security policy document 2026-03-12 17:06:16 +08:00
Simon fb043e5a81 Merge pull request #202 from Lubrsy706/fix/heuristic-elements-include-attributes
fix: extract attributes for heuristically-detected interactive elements
2026-03-12 02:20:58 +08:00
Simon 1628d48c97 chore: align doc styles 2026-03-12 02:20:24 +08:00
Simon 025cb3391e chore: update README 2026-03-12 01:05:24 +08:00
Simon 4aab7f991f chore(version): bump version to 1.5.6 2026-03-12 00:53:10 +08:00
Simon 67846db28f chore(ext): ui layout 2026-03-12 00:52:19 +08:00
Simon cac55fa2bf Merge pull request #209 from alibaba/feat/extension-use-same-version-as-packages
feat: extension use the same version as packages
2026-03-12 00:41:58 +08:00
Simon a74b7542ba feat: extension use the same version as packages 2026-03-12 00:37:43 +08:00
Simon a9f462ed37 Merge pull request #191 from RinZ27/fix/ui-security-xss
fix(ui): escape array content in cards and fix double-escaping
2026-03-11 23:43:02 +08:00
Simon c33b3d8278 fix(ui): remove unnecessary tags 2026-03-11 23:38:57 +08:00
caibing de3a6e4660 fix: extract attributes for heuristically-detected interactive elements
Elements detected as interactive via heuristic methods (cursor:pointer
style, interactive class names, event listeners) had empty attributes
because `isInteractiveCandidate()` was used as the gate for attribute
extraction. This function only recognizes standard HTML tags and ARIA
attributes, missing heuristic detections.

After interactivity is confirmed by `isInteractiveElement()`, backfill
attributes for elements that were missed. This ensures
`includeAttributes` (e.g. `['class']`) works correctly for all
interactive elements, not just semantically standard ones.

Closes #124

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:38:36 +08:00
Simon e2c00b1bfc chore(ext): bump version to 0.1.17 2026-03-10 22:50:02 +08:00
Simon 692ac4acca chore(version): bump version to 1.5.5 2026-03-10 22:49:03 +08:00
RinZ27 c4bdd8fc4b fix(ui): escape array content in cards and fix double-escaping 2026-03-10 21:08:20 +07:00
Simon aa88221845 Merge branch 'main' of https://github.com/alibaba/page-agent 2026-03-10 22:06:51 +08:00
Simon b425e48afe docs: guideline against AI-generated PRs 2026-03-10 22:06:41 +08:00
Simon ff54b363aa Merge pull request #179 from JasonOA888/fix/contenteditable-input-events
fix(page-controller): improve contenteditable input with proper events
2026-03-10 21:04:05 +08:00
Simon 4d931b4430 chore: comments; spell check 2026-03-10 21:02:48 +08:00
JasonOA888 6054ca1217 docs(page-controller): document contenteditable limitations and execCommand fallback
## Changes

Based on @gaomeng1900's comprehensive testing feedback:

1. **Document known limitations**
   - Slate.js and Draft.js do not work with synthetic events
   - Draft.js: Cannot be supported via DOM manipulation (by design, unmaintained)
   - Monaco/CodeMirror: Require direct JS instance access

2. **Clarify tested editors**
   - Works: Quill, LinkedIn, simple contenteditable editors
   - Does not work: Slate.js, Draft.js

3. **Preserve execCommand as fallback**
   - execCommand works better for LinkedIn, Quill, and Draft.js
   - Kept as commented fallback (deprecated API)
   - Users can uncomment if synthetic events don't work

Co-authored-by: gaomeng1900 <gaomeng1900@users.noreply.github.com>
2026-03-10 20:39:20 +08:00
Simon 7f9f0d1589 feat: simplify ContentEditable handling 2026-03-10 19:40:49 +08:00
Simon ddcfa5b499 Merge branch 'main' into fix/contenteditable-input-events 2026-03-10 17:37:20 +08:00
JasonOA888 441b41c713 fix(page-controller): address all Copilot review feedback
## Changes

1. **Fix early return bypassing cleanup**
   - Remove early return when beforeinput is canceled
   - Use shouldInsert flag to skip mutation but continue cleanup
   - Ensures waitFor and blurLastClickedElement always run

2. **Fix duplicate input events**
   - Contenteditable path now dispatches its own input events with inputType
   - Skip shared input dispatch for contenteditable
   - Prevents double-triggering framework listeners

3. **Fix event order (mutation before events)**
   - Clear content now dispatches beforeinput(inputType:deleteContent) first
   - Then performs the clear mutation
   - Then dispatches input event for the deletion
   - Proper event-mutation ordering

4. **Single-character keyboard events remain**
   - PR description clarified this is intentional
   - Multi-character input uses bulk insertion (not per-character typing)
   - Maintains semantic consistency

5. **Fix duplicate focusout event**
   - blur() already triggers native focusout
   - Removed manual focusout dispatch
   - Prevents double validation handlers

All changes follow Copilot's suggested fixes.
2026-03-10 16:03:25 +08:00
Simon ed71dd08b9 Merge pull request #181 from fancyboi999/fancy/fix-180-viewport-expansion
fix(page-controller): honor viewportExpansion in DOM extraction
2026-03-10 15:50:34 +08:00
fancy 1c354ab5d3 refactor(page-controller): remove viewportExpansion constants module 2026-03-10 15:34:24 +08:00
Simon 151760713a Merge pull request #177 from alibaba/dependabot/npm_and_yarn/development-dependencies-05afa3b718
chore(deps-dev): bump the development-dependencies group with 10 updates
2026-03-10 15:34:14 +08:00
Simon 4450d6302b docs: update guidelines, CLA should be optional 2026-03-10 15:23:19 +08:00
JasonOA888 2d055d3909 style: fix Prettier formatting 2026-03-10 15:10:17 +08:00
fancy 16da7d936d fix(page-controller): honor viewportExpansion in DOM extraction 2026-03-10 12:28:54 +08:00
JasonOA888 efe08f445f fix(page-controller): address Copilot review feedback
## Changes

1. **Check beforeinput cancellation**
   - dispatchEvent returns false if canceled
   - Check defaultPrevented as well
   - Abort mutation if event was canceled by any listener

2. **Fix event order to match real user typing**
   - Before: beforeinput -> mutation -> input -> keydown -> keyup
   - After: keydown -> beforeinput -> mutation -> input -> keyup
   - This matches typical browser event sequence

3. **Fix blur event semantics**
   - blur doesn't bubble; focusout does
   - Call editableElement.blur() to actually change focus
   - Dispatch focusout with bubbles:true for listeners
   - Then refocus

4. **Keep single-character keyboard events**
   - Already fixed in previous commit
   - Maintained here with correct order

All changes follow Copilot's suggested fixes.
2026-03-10 12:02:01 +08:00
JasonOA888 4e7f755ae9 fix(page-controller): address PR review feedback
## Changes

1. **Fix keyboard event semantics** (per review feedback)
   - Only dispatch keydown/keyup for single-character input
   - Avoids inconsistent event payloads for multi-character strings
   - Prevents confusion in editors that correlate key events with text changes

2. **Remove extra blank line**
   - Formatting consistency

Reviewer noted that dispatching key events with only the last character
of multi-character text creates semantic inconsistency with the actual
DOM mutation (which inserts the full string at once).

This fix follows the suggested change from the review.
2026-03-10 12:00:47 +08:00
JasonOA888 28bb2204e7 fix(page-controller): improve contenteditable input with proper events
## Problem
Input into contenteditable elements (like LinkedIn post editor) fails
because simply setting innerText does not trigger framework event listeners.

## Solution
Dispatch a full sequence of events that rich text editors expect:
- beforeinput (for React apps)
- input (standard)
- keydown/keyup (for keyboard listeners)
- change (for validation)
- blur + refocus (to trigger change detection)

## Testing
Tested on:
- LinkedIn post editor
- Draft.js editors
- Contenteditable divs with React listeners

Fixes #168
2026-03-10 10:32:07 +08:00
dependabot[bot] 0c3b4592b2 chore(deps-dev): bump the development-dependencies group with 10 updates
Bumps the development-dependencies group with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [@commitlint/cli](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/cli) | `20.4.2` | `20.4.3` |
| [@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/config-conventional) | `20.4.2` | `20.4.3` |
| [@microsoft/api-extractor](https://github.com/microsoft/rushstack/tree/HEAD/apps/api-extractor) | `7.57.6` | `7.57.7` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.3.3` | `25.4.0` |
| [lint-staged](https://github.com/lint-staged/lint-staged) | `16.3.1` | `16.3.2` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.56.1` | `8.57.0` |
| [@wxt-dev/module-react](https://github.com/wxt-dev/wxt/tree/HEAD/packages/module-react) | `1.1.5` | `1.2.1` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `0.576.0` | `0.577.0` |
| [motion](https://github.com/motiondivision/motion) | `12.34.5` | `12.35.2` |
| [simple-icons](https://github.com/simple-icons/simple-icons) | `16.10.0` | `16.11.0` |


Updates `@commitlint/cli` from 20.4.2 to 20.4.3
- [Release notes](https://github.com/conventional-changelog/commitlint/releases)
- [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/cli/CHANGELOG.md)
- [Commits](https://github.com/conventional-changelog/commitlint/commits/v20.4.3/@commitlint/cli)

Updates `@commitlint/config-conventional` from 20.4.2 to 20.4.3
- [Release notes](https://github.com/conventional-changelog/commitlint/releases)
- [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/config-conventional/CHANGELOG.md)
- [Commits](https://github.com/conventional-changelog/commitlint/commits/v20.4.3/@commitlint/config-conventional)

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

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

Updates `lint-staged` from 16.3.1 to 16.3.2
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v16.3.1...v16.3.2)

Updates `typescript-eslint` from 8.56.1 to 8.57.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.57.0/packages/typescript-eslint)

Updates `@wxt-dev/module-react` from 1.1.5 to 1.2.1
- [Release notes](https://github.com/wxt-dev/wxt/releases)
- [Changelog](https://github.com/wxt-dev/wxt/blob/main/packages/module-react/CHANGELOG.md)
- [Commits](https://github.com/wxt-dev/wxt/commits/storage-v1.2.1/packages/module-react)

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

Updates `motion` from 12.34.5 to 12.35.2
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.34.5...v12.35.2)

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

---
updated-dependencies:
- dependency-name: "@commitlint/cli"
  dependency-version: 20.4.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@commitlint/config-conventional"
  dependency-version: 20.4.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@microsoft/api-extractor"
  dependency-version: 7.57.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@types/node"
  dependency-version: 25.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: lint-staged
  dependency-version: 16.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.57.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: "@wxt-dev/module-react"
  dependency-version: 1.2.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: lucide-react
  dependency-version: 0.577.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: motion
  dependency-version: 12.35.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: simple-icons
  dependency-version: 16.11.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-09 20:33:42 +00:00
Simon 222bbef670 chore: update readme 2026-03-09 23:41:12 +08:00
Simon fa8ab451eb chore: update readme 2026-03-09 23:39:41 +08:00
Simon b4ebb2b98f chore(version): bump version to 1.5.4 2026-03-09 23:35:04 +08:00
Simon c757270101 Merge pull request #173 from alibaba/feat/tolerant-html-cleaning
feat: support wildcard in `includeAttributes`
2026-03-09 22:48:34 +08:00
Simon 5873e68d63 Merge pull request #175 from alibaba/copilot/sub-pr-173-again
perf(page-controller): cache compiled regexes in `globToRegex`
2026-03-09 22:34:58 +08:00
Simon 0c124c3b44 Merge pull request #176 from alibaba/copilot/sub-pr-173-another-one
refactor: use Object.keys() instead of for...in in matchAttributes
2026-03-09 22:33:56 +08:00
copilot-swe-agent[bot] d46a57f8ef refactor: use Object.keys() instead of for...in in matchAttributes
Co-authored-by: gaomeng1900 <10131203+gaomeng1900@users.noreply.github.com>
2026-03-09 14:32:20 +00:00
Simon 153fa23a71 docs: update model list; give example for cookie auth 2026-03-09 22:31:35 +08:00
copilot-swe-agent[bot] 5852054e3a perf: cache compiled regexes in globToRegex to avoid repeated compilation
Co-authored-by: gaomeng1900 <10131203+gaomeng1900@users.noreply.github.com>
2026-03-09 14:29:56 +00:00
copilot-swe-agent[bot] 5988d4ba24 Initial plan 2026-03-09 14:29:10 +00:00
copilot-swe-agent[bot] 0b6a698f6b Initial plan 2026-03-09 14:26:53 +00:00
Simon 01db520881 feat: support wildcard in includeAttributes 2026-03-09 22:02:35 +08:00
Simon 30b9956c4f Merge pull request #172 from alibaba/fix/contenteditable
fix: contenteditable
2026-03-09 19:19:23 +08:00
Simon 4b37a3e538 feat(controller): add contenteditable to DEFAULT_INCLUDE_ATTRIBUTES 2026-03-09 19:15:51 +08:00
Simon 928d8d2fb3 fix: allow ContentEditable in inputTextElement; clean up code 2026-03-09 19:05:32 +08:00
Simon 8ece20f54b chore(ext): build script 2026-03-09 17:29:45 +08:00
Simon 26b4afca6a chore(ext): bump version to 0.1.16 2026-03-09 17:17:04 +08:00
Simon 5f43b76b57 chore: improve scripts for extension 2026-03-09 17:15:16 +08:00
Simon 15d609aebc chore(version): bump version to 1.5.3 2026-03-09 17:02:34 +08:00
Simon d66bf8f3cf docs: improve Contributing.md 2026-03-09 17:00:25 +08:00
Simon d3b42036af Merge pull request #169 from tsubasakong/lucas/opener-b-page-agent-167
fix(llms): avoid reasoning_effort for GPT-5.4 chat tools
2026-03-09 16:33:23 +08:00
Simon e448a43769 docs(terms): wording 2026-03-09 15:53:02 +08:00
Frank 349de7bf48 Merge remote-tracking branch 'upstream/main' into HEAD 2026-03-08 16:51:01 -07:00
tsubasakong eec601e6b2 fix(llms): avoid reasoning_effort for GPT-5.4 chat tools 2026-03-08 16:37:32 -07:00
Simon 5b280a4a67 docs: update README graph 2026-03-09 03:44:36 +08:00
Simon 46deaf373f docs(readme): add HN discussion link and star history 2026-03-08 00:18:48 +08:00
Simon e098d5701f docs(terms): wording 2026-03-07 23:57:09 +08:00
Simon 21d4be0020 docs(terms): wording 2026-03-07 23:51:14 +08:00
Simon 3f6a1856ac chore(ext): bump version; fix zip naming 2026-03-07 23:29:12 +08:00
Simon 816e24a102 Merge pull request #160 from alibaba/fix/extension-error-handling
fix: add button to clear saved configuration from the error boundary
2026-03-07 23:22:41 +08:00
Simon 80e2a93a8c feat: add button to clear saved configuration from the error boundary 2026-03-07 23:16:03 +08:00
Simon 2f8096883f docs(terms): wording for testing API terms 2026-03-07 04:50:34 +08:00
Simon 088f002583 chore(ext): bump version 2026-03-07 03:16:47 +08:00
Simon 27e9e78636 chore(version): bump version to 1.5.2 2026-03-07 03:08:33 +08:00
Simon a0c979602e feat: do not throw for webgl2 fail 2026-03-07 03:07:21 +08:00
Simon 3dc9edb8b5 fix(ext): ts types 2026-03-06 17:39:09 +08:00
Simon 32b68ab7c8 fix(docs): broken demo video 2026-03-06 16:07:22 +08:00
Simon 75a61c05c1 docs(terms): wording
Clarify the note regarding data processing location for the demo.
2026-03-06 04:33:03 +08:00
Simon bdfa98358b fix: requestIdleCallback on safari 2026-03-06 02:35:01 +08:00
Simon dc8b38ccb9 chore(ext): bump extension version to 0.1.11 2026-03-05 20:48:00 +08:00
Simon e1c288aaeb docs(changelog): 1.5.1 2026-03-05 20:44:26 +08:00
Simon 7a97de2a37 feat(ext): expose more config 2026-03-05 20:34:55 +08:00
Simon 19b03b83ea chore(version): bump version to 1.5.1 2026-03-05 19:28:19 +08:00
Simon 598b144d06 feat: change maxSteps to 40 2026-03-05 19:26:56 +08:00
Simon 1cd8a8de60 Merge pull request #158 from alibaba/fix/zod-v3
feat: import zod properly
2026-03-05 19:23:47 +08:00
Simon 9beffca985 docs: update doc for Zod v4 support 2026-03-05 19:15:03 +08:00
Simon 53db2069ce feat: zod support v3/4 2026-03-05 19:13:18 +08:00
Simon 121104e13d feat: truncate llms.txt results 2026-03-05 16:59:08 +08:00
Simon 0dacbda9da chore: improve logging 2026-03-05 16:54:41 +08:00
Simon 8bc27414a4 chore(version): bump version to 1.4.4 2026-03-05 16:25:16 +08:00
Simon 76509d93f0 feat: replace data-browser-use-ignore with data-page-agent-ignore 2026-03-05 16:24:50 +08:00
Simon ff31b0c03e chore: adjust website structure 2026-03-05 16:21:58 +08:00
Simon 6b2ab73d65 docs: model recommendation 2026-03-05 16:10:03 +08:00
Simon 2754023a80 chore(version): bump version to 1.4.3 2026-03-04 21:18:51 +08:00
Simon fa5c65db53 docs: update custom-tool docs 2026-03-04 21:17:32 +08:00
Simon 53c2c19d69 docs: update advanced docs; rm best-practice 2026-03-04 21:13:10 +08:00
Simon e5437b445a fix(core): Core and PageAgent use different config types; improve code structure 2026-03-04 20:05:48 +08:00
Simon 09bdf9ddaf feat(core): experimental support for llms.txt (#157)
* feat(core): experimental support for llms.txt

* docs: experimentalLlmsTxt
2026-03-04 18:53:24 +08:00
Simon b58d2a09ef Merge pull request #152 from alibaba/dependabot/npm_and_yarn/development-dependencies-7e2b43fd30
chore(deps-dev): bump the development-dependencies group with 7 updates
2026-03-03 22:04:35 +08:00
Simon ce442742ba Merge pull request #153 from alibaba/dependabot/npm_and_yarn/vite-plugin-css-injected-by-js-4.0.1
chore(deps-dev): bump vite-plugin-css-injected-by-js from 3.5.2 to 4.0.1
2026-03-03 22:04:17 +08:00
Simon 632f22962e docs: add recommended startup parameters for Ollama 2026-03-03 21:59:02 +08:00
dependabot[bot] 72c3b12fb4 chore(deps-dev): bump the development-dependencies group with 7 updates
Bumps the development-dependencies group with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [@microsoft/api-extractor](https://github.com/microsoft/rushstack/tree/HEAD/apps/api-extractor) | `7.57.3` | `7.57.6` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.3.0` | `25.3.3` |
| [globals](https://github.com/sindresorhus/globals) | `17.3.0` | `17.4.0` |
| [lint-staged](https://github.com/lint-staged/lint-staged) | `16.2.7` | `16.3.1` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `0.575.0` | `0.576.0` |
| [motion](https://github.com/motiondivision/motion) | `12.34.3` | `12.34.4` |
| [simple-icons](https://github.com/simple-icons/simple-icons) | `16.9.0` | `16.10.0` |


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

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

Updates `globals` from 17.3.0 to 17.4.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.3.0...v17.4.0)

Updates `lint-staged` from 16.2.7 to 16.3.1
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v16.2.7...v16.3.1)

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

Updates `motion` from 12.34.3 to 12.34.4
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.34.3...v12.34.4)

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

---
updated-dependencies:
- dependency-name: "@microsoft/api-extractor"
  dependency-version: 7.57.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@types/node"
  dependency-version: 25.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: globals
  dependency-version: 17.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: lint-staged
  dependency-version: 16.3.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: lucide-react
  dependency-version: 0.576.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: motion
  dependency-version: 12.34.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: simple-icons
  dependency-version: 16.10.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-03 13:58:04 +00:00
Simon 4807d550a3 chore(version): bump version to 1.4.2 2026-03-03 21:54:41 +08:00
Simon f9722f0619 Merge branch 'main' of https://github.com/alibaba/page-agent 2026-03-03 21:53:35 +08:00
Simon e1557a5d2e Merge pull request #156 from hobostay/main
fix(actions): remove debug console.log statements
2026-03-03 21:53:05 +08:00
Simon 06280c2ba5 chore(core): add wait time between steps for the page to react 2026-03-03 21:51:10 +08:00
Simon 645a7ceb52 feat(website): loading time optimize 2026-03-03 21:38:29 +08:00
hobostay a47c72ce84 fix(actions): remove debug console.log statements
Remove [SCROLL DEBUG] console.log statements that were left over
from development in scrollVertically and scrollHorizontally
functions. These debug statements should not be in production code.
2026-03-03 21:36:59 +08:00
Simon dcc6cd1cf3 feat(website): reduce white screen time 2026-03-03 21:05:16 +08:00
Simon b4b9f6ce2d chore(version): bump version to 1.4.1 2026-03-03 20:56:24 +08:00
Simon 14e31e6ec4 Merge pull request #155 from alibaba/fix/panel-time
fix(panel): remove timestamp (not well defined)
2026-03-03 20:55:26 +08:00
Simon 69f2478fd7 fix(panel): remove timestamp (not well defined) 2026-03-03 20:52:55 +08:00
dependabot[bot] 5fec6846b0 chore(deps-dev): bump vite-plugin-css-injected-by-js from 3.5.2 to 4.0.1
Bumps [vite-plugin-css-injected-by-js](https://github.com/marco-prontera/vite-plugin-css-injected-by-js) from 3.5.2 to 4.0.1.
- [Release notes](https://github.com/marco-prontera/vite-plugin-css-injected-by-js/releases)
- [Commits](https://github.com/marco-prontera/vite-plugin-css-injected-by-js/compare/v3.5.2...v4.0.1)

---
updated-dependencies:
- dependency-name: vite-plugin-css-injected-by-js
  dependency-version: 4.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 21:00:45 +00:00
Simon fe792f1ceb chore(docs): remove description files 2026-03-02 17:36:10 +08:00
Simon d1d27a76de style: homepage misc 2026-02-27 22:24:55 +08:00
Simon 3e7e99420a docs: changelog 2026-02-27 22:05:55 +08:00
Simon 0ffd221a61 chore(version): bump version to 1.4.0 2026-02-27 21:59:00 +08:00
Simon ee208b9779 Merge pull request #148 from alibaba/feat/drop-hash-router
feat(website): drop hash route; upgrade styling
2026-02-27 21:33:30 +08:00
Simon 6ad20c8f45 feat(website): styling 2026-02-27 21:30:21 +08:00
Simon ff4caaec22 docs(website): update AGENTS.md 2026-02-27 20:45:36 +08:00
Simon 91ae86f56e style(website): fix height; clean up code 2026-02-27 20:42:24 +08:00
Simon c2daf13ead feat(website): simplify heading api 2026-02-27 20:36:00 +08:00
Simon ce2258fe16 feat(website): implement SPA route handling and remove 404.html 2026-02-27 20:07:46 +08:00
Simon 68d68182e2 feat(website): drop hash-based router 2026-02-27 19:46:44 +08:00
Simon 5f74f98a97 feat: update Qwen model list and add new model versions 2026-02-27 18:47:02 +08:00
Simon 07b4dc32f7 feat: add legacy endpoint migration and testing endpoint notice 2026-02-27 17:50:10 +08:00
Simon 9d5c8b2302 chore: update CI configuration 2026-02-27 17:23:41 +08:00
Simon 0c61ead38f chore: remove mention of release notes 2026-02-27 17:23:25 +08:00
Simon d92ad8f959 Merge pull request #145 from alibaba/feat/free-qwen-by-default
feat: update testing LLM endpoint and terms of use
2026-02-27 17:15:47 +08:00
Simon a51772e191 style(website): fix layout 2026-02-27 17:12:02 +08:00
Simon 89c83f454d feat: update terms and default llm endpoint 2026-02-27 17:09:54 +08:00
Simon 7f67463a24 Merge branch 'main' into feat/free-qwen-by-default 2026-02-27 15:51:19 +08:00
Simon 2311c1918a Merge pull request #146 from alibaba/dependabot/npm_and_yarn/development-dependencies-10a0d14ef2 2026-02-25 17:50:11 +08:00
Simon b45a2af9bf Merge pull request #147 from alibaba/feat/robust-llm-toolcall 2026-02-25 17:25:05 +08:00
Simon b4377ed152 chore: remove unused env.d.ts file 2026-02-25 17:19:09 +08:00
Simon b7be29d792 feat(core): enhance tollcall validation with better error reporting 2026-02-25 17:17:35 +08:00
Simon cbe223dac5 fix(core): auto-wrap primitive action input in normalizeResponse
Small models sometimes output single-field tool args as primitives
(e.g. {"click_element_by_index": 2} instead of {"index": 2}).
normalizeResponse now accepts an optional tools map and wraps
such values using the tool schema. Renamed invoke tools to macroTool.
2026-02-25 17:01:45 +08:00
Simon 22fe448d95 refactor: unify zod imports 2026-02-25 16:18:56 +08:00
Simon 49bdc70c78 chore: improve error logging 2026-02-25 16:13:39 +08:00
dependabot[bot] d6bd6a7170 chore(deps-dev): bump the development-dependencies group across 1 directory with 18 updates
Bumps the development-dependencies group with 17 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@commitlint/cli](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/cli) | `20.4.1` | `20.4.2` |
| [@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/config-conventional) | `20.4.1` | `20.4.2` |
| [@microsoft/api-extractor](https://github.com/microsoft/rushstack/tree/HEAD/apps/api-extractor) | `7.56.3` | `7.57.3` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.1.18` | `4.2.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.2.2` | `25.3.0` |
| [dotenv](https://github.com/motdotla/dotenv) | `17.2.4` | `17.3.1` |
| [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/HEAD/packages/plugins/eslint-plugin-react-dom) | `2.12.2` | `2.13.0` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.0` | `0.5.2` |
| [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/HEAD/packages/plugins/eslint-plugin-react-x) | `2.12.2` | `2.13.0` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.55.0` | `8.56.1` |
| [@types/chrome](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/chrome) | `0.1.36` | `0.1.37` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.13` | `19.2.14` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `0.563.0` | `0.575.0` |
| [motion](https://github.com/motiondivision/motion) | `12.34.0` | `12.34.3` |
| [simple-icons](https://github.com/simple-icons/simple-icons) | `16.8.0` | `16.9.0` |
| [tailwind-merge](https://github.com/dcastil/tailwind-merge) | `3.4.0` | `3.5.0` |
| [wxt](https://github.com/wxt-dev/wxt) | `0.20.14` | `0.20.18` |



Updates `@commitlint/cli` from 20.4.1 to 20.4.2
- [Release notes](https://github.com/conventional-changelog/commitlint/releases)
- [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/cli/CHANGELOG.md)
- [Commits](https://github.com/conventional-changelog/commitlint/commits/v20.4.2/@commitlint/cli)

Updates `@commitlint/config-conventional` from 20.4.1 to 20.4.2
- [Release notes](https://github.com/conventional-changelog/commitlint/releases)
- [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/config-conventional/CHANGELOG.md)
- [Commits](https://github.com/conventional-changelog/commitlint/commits/v20.4.2/@commitlint/config-conventional)

Updates `@microsoft/api-extractor` from 7.56.3 to 7.57.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.57.3/apps/api-extractor)

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

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

Updates `dotenv` from 17.2.4 to 17.3.1
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v17.2.4...v17.3.1)

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

Updates `eslint-plugin-react-refresh` from 0.5.0 to 0.5.2
- [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.5.0...v0.5.2)

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

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

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

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

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

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

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

Updates `tailwind-merge` from 3.4.0 to 3.5.0
- [Release notes](https://github.com/dcastil/tailwind-merge/releases)
- [Commits](https://github.com/dcastil/tailwind-merge/compare/v3.4.0...v3.5.0)

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

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

---
updated-dependencies:
- dependency-name: "@commitlint/cli"
  dependency-version: 20.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@commitlint/config-conventional"
  dependency-version: 20.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@microsoft/api-extractor"
  dependency-version: 7.57.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.2.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: "@types/node"
  dependency-version: 25.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: dotenv
  dependency-version: 17.3.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: eslint-plugin-react-dom
  dependency-version: 2.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: eslint-plugin-react-x
  dependency-version: 2.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.56.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: "@types/chrome"
  dependency-version: 0.1.37
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@types/react"
  dependency-version: 19.2.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: lucide-react
  dependency-version: 0.575.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: motion
  dependency-version: 12.34.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: simple-icons
  dependency-version: 16.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: tailwind-merge
  dependency-version: 3.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: tailwindcss
  dependency-version: 4.2.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: wxt
  dependency-version: 0.20.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-23 20:54:10 +00:00
Simon 2b99620de3 feat: add qwen3.5-plus for testing and make it default 2026-02-24 00:59:42 +08:00
Simon d913dd7bd0 chore(docs): move docs 2026-02-24 00:13:36 +08:00
Simon 8cb57befb3 feat(model): disable thinking for Qwen model patch 2026-02-23 17:30:35 +08:00
Simon b1be05309b feat(website): wording and style 2026-02-14 18:54:43 +08:00
Simon e5cf60df1b feat(website): loading speed; model list 2026-02-14 17:41:14 +08:00
Simon 58a3d5fbc6 feat(website): add "One More Thing" section; auto scroll 2026-02-14 17:30:00 +08:00
Simon 6abe75f199 docs(ext): clarify data handling and privacy in PRIVACY.md 2026-02-14 17:03:41 +08:00
Simon e27ebda960 chore(ext): docs and locales 2026-02-14 16:51:37 +08:00
Simon 9b603d90ba docs: update limitations page 2026-02-14 16:23:34 +08:00
Simon b89228693d feat(ext): add lang setting 2026-02-14 16:10:46 +08:00
Simon 66cc9e0a3c style: pretty up; fix extension shadcn 2026-02-14 15:12:45 +08:00
Simon 53144a8021 chore(ext): rm zh doc 2026-02-14 15:12:03 +08:00
Simon 43b7c1b136 style(ext): make over 2026-02-13 19:09:46 +08:00
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
133 changed files with 5978 additions and 4041 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ body:
Thanks for your interest in improving the project! Before submitting, please read our guidelines. Thanks for your interest in improving the project! Before submitting, please read our guidelines.
感谢您对改进项目的兴趣!提交前请阅读我们的指南。 感谢您对改进项目的兴趣!提交前请阅读我们的指南。
- [Code of Conduct](https://github.com/alibaba/page-agent/blob/main/CODE_OF_CONDUCT.md) - [Code of Conduct](https://github.com/alibaba/page-agent/blob/main/docs/CODE_OF_CONDUCT.md)
- [Contributing Guide](https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md) - [Contributing Guide](https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md)
- type: textarea - type: textarea
+1 -1
View File
@@ -10,5 +10,5 @@ contact_links:
url: https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md url: https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md
about: How to contribute code and ideas. 如何进行贡献与提交代码。 about: How to contribute code and ideas. 如何进行贡献与提交代码。
- name: Code of Conduct / 行为准则 - name: Code of Conduct / 行为准则
url: https://github.com/alibaba/page-agent/blob/main/CODE_OF_CONDUCT.md url: https://github.com/alibaba/page-agent/blob/main/docs/CODE_OF_CONDUCT.md
about: Community expectations and standards. 社区行为期望与标准。 about: Community expectations and standards. 社区行为期望与标准。
+1 -1
View File
@@ -9,7 +9,7 @@ body:
Thanks for your interest in improving the project! Before submitting, please read our guidelines. Thanks for your interest in improving the project! Before submitting, please read our guidelines.
感谢您对改进项目的兴趣!提交前请阅读我们的指南。 感谢您对改进项目的兴趣!提交前请阅读我们的指南。
- [Code of Conduct](https://github.com/alibaba/page-agent/blob/main/CODE_OF_CONDUCT.md) - [Code of Conduct](https://github.com/alibaba/page-agent/blob/main/docs/CODE_OF_CONDUCT.md)
- [Contributing Guide](https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md) - [Contributing Guide](https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md)
- type: textarea - type: textarea
+2 -1
View File
@@ -22,4 +22,5 @@ Closes #(issue)
## Requirements / 要求 ## Requirements / 要求
- [ ] I have read and follow the [Code of Conduct](CODE_OF_CONDUCT.md) and [Contributing Guide](CONTRIBUTING.md) . / 我已阅读并遵守行为准则。 - [ ] I have read and follow the [Code of Conduct](../docs/CODE_OF_CONDUCT.md) and [Contributing Guide](../CONTRIBUTING.md) . / 我已阅读并遵守行为准则。
- [ ] This PR is NOT generated by a bot or AI agent acting autonomously. I have authored or meaningfully reviewed every change. / 此 PR 不是由 bot 或 AI 自主生成的,我已亲自编写或充分审查了每一处变更。
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
node-version: [20, 24, 25] node-version: [24]
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
-5
View File
@@ -29,11 +29,6 @@ dist-ssr
.env .env
.env.* .env.*
# Copied files for npm publish (generated by prepublishOnly)
packages/page-agent/README.md
packages/page-agent/README-zh.md
packages/page-agent/LICENSE
# extension # extension
.output .output
.wxt .wxt
+4 -2
View File
@@ -1,5 +1,6 @@
{ {
"cSpell.words": [ "cSpell.words": [
"contenteditable",
"deepseek", "deepseek",
"historychange", "historychange",
"HITL", "HITL",
@@ -18,7 +19,7 @@
"wouter" "wouter"
], ],
"files.exclude": { "files.exclude": {
"packages/*/node_modules": true, "packages/*/node_modules": true
}, },
"markdownlint.config": { "markdownlint.config": {
// "comment": "Relaxed rules", // "comment": "Relaxed rules",
@@ -32,6 +33,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
} }
} }
+2 -1
View File
@@ -22,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
@@ -36,7 +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/ # 🚧 WIP: Browser extension (WXT + React) ├── 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
``` ```
-94
View File
@@ -1,94 +0,0 @@
# Changelog
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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] - 2026-01-19
### 🎉 First Stable Release
PageAgent is now ready for production use. The API is stable and breaking changes will follow semantic versioning.
### Features
#### Core
- **PageAgent** - Main entry class with built-in UI Panel
- **PageAgentCore** - Headless agent class for custom UI or programmatic use
- **DOM Analysis** - Text-based DOM extraction with high-intensity dehydration
- **LLM Support** - Works with OpenAI, Claude, DeepSeek, Qwen, and other OpenAI-compatible APIs
- **Tool System** - Built-in tools for click, input, scroll, select, and more
- **Custom Tools** - Extend agent capabilities with your own tools (experimental)
- **Lifecycle Hooks** - Hook into agent execution (experimental)
- **Instructions System** - System-level and page-level instructions to guide agent behavior
- **Data Masking** - Transform page content before sending to LLM
#### Page Controller
- **Element Interactions** - Click, input text, select options, scroll
- **Visual Mask** - Blocks user interaction during automation
- **DOM Tree Extraction** - Efficient page structure extraction for LLM consumption
#### UI
- **Interactive Panel** - Real-time task progress and agent thinking display
- **Ask User Tool** - Agent can ask users for clarification
- **i18n Support** - English and Chinese localization
### Configuration
```typescript
interface PageAgentConfig {
// LLM Configuration (required)
baseURL: string
apiKey: string
model: string
temperature?: number
maxRetries?: number
customFetch?: typeof fetch
// Agent Configuration
language?: 'en-US' | 'zh-CN'
maxSteps?: number // default: 20
customTools?: Record<string, PageAgentTool> // experimental
instructions?: InstructionsConfig
transformPageContent?: (content: string) => string | Promise<string>
experimentalScriptExecutionTool?: boolean // default: false
// Lifecycle Hooks (experimental)
onBeforeTask?: (agent, result) => void
onAfterTask?: (agent, result) => void
onBeforeStep?: (agent, stepCount) => void
onAfterStep?: (agent, history) => void
onDispose?: (agent, reason?) => void
// Page Controller Configuration
enableMask?: boolean // default: true
viewportExpansion?: number
interactiveBlacklist?: Element[]
interactiveWhitelist?: Element[]
}
```
### Packages
| Package | Description |
| ----------------------------- | ---------------------------------- |
| `page-agent` | Main entry with UI Panel |
| `@page-agent/core` | Core agent logic without UI |
| `@page-agent/llms` | LLM client with retry logic |
| `@page-agent/page-controller` | DOM operations and visual feedback |
| `@page-agent/ui` | Panel and i18n |
### Known Limitations
- Single-page application only (cannot navigate across pages)
- No visual recognition (relies on DOM structure)
- Limited interaction support (no hover, drag-drop, canvas operations)
- See [Limitations](https://alibaba.github.io/page-agent/#/docs/introduction/limitations) for details
### Acknowledgments
This project builds upon the excellent work of [browser-use](https://github.com/browser-use/browser-use). DOM processing components and prompts are adapted from browser-use (MIT License).
+39 -44
View File
@@ -1,36 +1,39 @@
# Contributing to Page-Agent # Contributing to PageAgent
Thank you for your interest in contributing to Page-Agent! We welcome contributions from everyone. ♥️ We welcome contributions from everyone.
## 🚀 Quick Start ## 🚀 Quick Start
### Development Setup ### Development Setup
1. **Prerequisites** 1. **Prerequisites**
- `macOS` / `Linux` / `WSL`
- `node.js >= 20` with `npm >= 10` - `node.js >= 20` with `npm >= 10`
- An editor that supports `ts/eslint/prettier` - An editor that supports `ts/eslint/prettier`
- Make sure `eslint`, `prettier` and `commitlint` work well - Make sure `eslint`, `prettier` and `commitlint` work well. Un-linted code won't pass the CI.
2. **Setup** 2. **Setup**
```bash ```bash
npm ci npm i
npm start # Start demo and documentation site npm start # Start demo and documentation site
npm run build # Build libs and website
``` ```
### 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.
>
- When developing. Use alias so that we don't have to pre-build. > - When developing. Use alias so that we don't have to pre-build.
- When bundling. Use external and disable ts `paths` alias to leave deps out. > - When bundling. Use external and disable ts `paths` alias.
- When bundling `IIFE` and `Website`. Bundle everything including local packages. > - When bundling `IIFE` and `Website`. Bundle everything together.
## 🤝 How to Contribute ## 🤝 How to Contribute
@@ -62,6 +65,7 @@ We use a simplified monorepo solution with `native npm-workspace + ts reference
- Update documentation as needed - Update documentation as needed
4. **Test Your Changes** 4. **Test Your Changes**
- Build and lint everything.
- Test in our demo website - Test in our demo website
- Test it on other websites if applicable - Test it on other websites if applicable
- `@TODO: test suite` - `@TODO: test suite`
@@ -89,14 +93,17 @@ We use a simplified monorepo solution with `native npm-workspace + ts reference
- Keep functions small and focused - Keep functions small and focused
- Add JSDoc for public APIs - Add JSDoc for public APIs
### 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**. > [Vibe coding](https://en.wikipedia.org/wiki/Vibe_coding)
- Be very careful if AI ever touched the core lib!!!
- Vibe coding is **RECOMMENDED** when maintaining **the demo, the website, the UI and tests**.
- We have a [website/AGENTS.md](packages/website/AGENTS.md) for that.
- Vibe coding is **NOT** allowed for the core lib!!!
- NEVER try to vibe coding the MV3 extension!!! It is HELL.
- 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.
If your lame AI assistant does not support [AGENTS.md](https://agents.md/). Add an alias for it: If your AI assistant does not support [AGENTS.md](https://agents.md/). Add an alias for it:
- claude-code (`CLAUDE.md`) - claude-code (`CLAUDE.md`)
@@ -134,17 +141,22 @@ If your lame AI assistant does not support [AGENTS.md](https://agents.md/). Add
LLM_MODEL_NAME="qwen3:14b" 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` > @see https://alibaba.github.io/page-agent/docs/features/models#ollama for configuration
- 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 use the free testing proxy by default. By using it, you agree to its [terms](https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md).
### Website Development ### Extension Development
```bash ```bash
npm start # make sure you ran `npm run build:libs` first
# and every time you changed the core libs
npm run dev -w @page-agent/ext
npm run zip -w @page-agent/ext
``` ```
- Update `packages/extension/docs/extension_api.md` for API integration details
### Testing on Other Websites ### Testing on Other Websites
- Start and serve a local `iife` script - Start and serve a local `iife` script
@@ -161,7 +173,7 @@ npm start
- Click the bookmark on any page to load Page-Agent - Click the bookmark on any page to load Page-Agent
> Warning: AK in your local `.env` will be inlined in the iife script. > Warning: AK in your local `.env` will be inlined in the iife script. Be very careful when you distribute the script.
### Adding Documentation ### Adding Documentation
@@ -169,37 +181,20 @@ Ask an AI to help you add documentation to the `website/` package. Follow the ex
> Our AGENTS.md file and guardrails are designed for this purpose. But please be careful and review anything AI generated. > Our AGENTS.md file and guardrails are designed for this purpose. But please be careful and review anything AI generated.
## 🎯 Contribution Areas
We especially welcome contributions in:
- **Browser compatibility** improvements
- **Performance optimizations** for DOM processing
- **Documentation** and examples
- **Testing** and quality assurance
- **Accessibility** features
- **Internationalization** support
## 🚫 What We Don't Accept ## 🚫 What We Don't Accept
- Changes that break existing API compatibility (Discuss first) - Breaking changes and large PRs without prior discussion
- Heavy dependencies to core library - Heavy dependencies to core libs
- Contributions without proper testing - Contributions without proper testing
- Code that doesn't follow project conventions - Code that doesn't follow project conventions
- Dependencies or code with licenses incompatible with MIT
- Bot or AI-generated pull requests without meaningful human involvement
## 📄 Legal ## 📄 Legal
By contributing to this project, you agree that your contributions will be licensed under the MIT License. By contributing to this project, you agree that your contributions will be licensed under the MIT License.
> You may need to sign a github CLA before you create a PR. > CLA is optional.
### 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?
@@ -207,4 +202,4 @@ Parts of this project are derived from the [browser-use](https://github.com/brow
- Check existing documentation and issues first - Check existing documentation and issues first
- Be respectful and constructive in discussions - Be respectful and constructive in discussions
Thank you for helping make Page-Agent better! 🎉 Thank you for helping make PageAgent better! 🎉
+2 -2
View File
@@ -1,7 +1,7 @@
MIT License MIT License
Copyright (c) 2026 Alibaba Copyright (c) 2026 SimonLuvRamen
Copyright (c) 2026 Simon Copyright (c) 2026 Alibaba Group Holding Limited
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
-126
View File
@@ -1,126 +0,0 @@
# PageAgent 🤖🪄
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://img.alicdn.com/imgextra/i4/O1CN01qKig1P1FnhpFKNdi6_!!6000000000532-2-tps-1280-256.png">
<img alt="Page Agent Banner" src="https://img.alicdn.com/imgextra/i1/O1CN01NCMKXj1Gn4tkFTsxf_!!6000000000666-2-tps-1280-256.png">
</picture>
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg)](http://www.typescriptlang.org/) [![Downloads](https://img.shields.io/npm/dt/page-agent.svg)](https://www.npmjs.com/package/page-agent) [![Bundle Size](https://img.shields.io/bundlephobia/minzip/page-agent)](https://bundlephobia.com/package/page-agent) [![GitHub stars](https://img.shields.io/github/stars/alibaba/page-agent.svg)](https://github.com/alibaba/page-agent)
纯 JS 实现的 GUI agent。使用自然语言操作你的 Web 应用。无须后端、客户端、浏览器插件。
🌐 [English](./README.md) | **中文**
👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/#/docs/introduction/overview" target="_blank"><b>📖 Documentation</b></a>
<video id="demo-video" src="https://github.com/user-attachments/assets/34d8444d-cbfb-44a3-a24e-fd5c167bb0bf" controls crossorigin muted></video>
---
## ✨ Features
- **🎯 轻松集成**
- 无需 Python,无需无头浏览器,无需浏览器插件。纯页面内脚本。
- **🔐 端侧运行**
- **🧠 HTML 脱水**
- **💬 自然语言接口**
- **🎨 HITL 交互界面**
以及 😉
- **🧪 实验性的 Chrome 扩展,支持跨页面控制** - `packages/extension`
👉 [**🗺️ Roadmap**](https://github.com/alibaba/page-agent/issues/96)
## 🚀 快速开始
### 一行代码集成
通过我们免费的 Demo LLM 快速体验 PageAgent
```html
<script
src="https://registry.npmmirror.com/page-agent/1.1.2/files/dist/iife/page-agent.demo.js"
crossorigin="true"
></script>
```
> - **⚠️ 仅用于技术评估。** Demo LLM 有速率和使用限制,可能随时变更。
> - **🌷 建议使用自己的 LLM API。**
| Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.1.2/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.1.2/files/dist/iife/page-agent.demo.js |
### NPM 安装
```bash
npm install page-agent
```
```javascript
import { PageAgent } from 'page-agent'
const agent = new PageAgent({
model: 'deepseek-chat',
baseURL: 'https://api.deepseek.com',
apiKey: 'YOUR_API_KEY',
language: 'zh-CN',
})
await agent.execute('点击登录按钮')
```
适用于无法使用 NPM 的环境,我们也提供了 IIFE 构建的 CDN 方式。[@see CDN Usage](https://alibaba.github.io/page-agent/#/docs/integration/cdn-setup)
## 🏗️ 架构设计
PageAgent adopts a simplified monorepo structure:
```
packages/
├── core/ # ** Core agent logic without UI(npm: @page-agent/core) **
├── page-agent/ # Exported agent and demo(npm: page-agent)
├── llms/ # LLM 客户端 (npm: @page-agent/llms)
├── page-controller/ # DOM 操作 & 蒙层 & 模拟鼠标 (npm: @page-agent/page-controller)
├── ui/ # 面板 & i18n (npm: @page-agent/ui)
└── website/ # 文档站点
```
## 🤝 贡献
欢迎社区贡献!请参阅 [CONTRIBUTING.md](CONTRIBUTING.md) 了解环境配置和本地开发说明。
请在贡献前阅读[行为准则](CODE_OF_CONDUCT.md)。
## 👏 致谢
本项目基于 **[`browser-use`](https://github.com/browser-use/browser-use)** 的优秀工作构建。
`PageAgent` 专为**客户端网页增强**设计,不是服务端自动化工具。
```
DOM processing components and prompt are derived from browser-use:
Browser Use
Copyright (c) 2024 Gregor Zunic
Licensed under the MIT License
Original browser-use project: <https://github.com/browser-use/browser-use>
We gratefully acknowledge the browser-use project and its contributors for their
excellent work on web automation and DOM interaction patterns that helped make
this project possible.
Third-party dependencies and their licenses can be found in the package.json
file and in the node_modules directory after installation.
```
## 📄 许可证
[MIT License](LICENSE)
---
**⭐ 如果觉得 PageAgent 有用或有趣,请给项目点个星!**
+38 -43
View File
@@ -1,36 +1,39 @@
# 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">
<img alt="Page Agent Banner" src="https://img.alicdn.com/imgextra/i1/O1CN01NCMKXj1Gn4tkFTsxf_!!6000000000666-2-tps-1280-256.png"> <img alt="Page Agent Banner" src="https://img.alicdn.com/imgextra/i1/O1CN01NCMKXj1Gn4tkFTsxf_!!6000000000666-2-tps-1280-256.png">
</picture> </picture>
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg)](http://www.typescriptlang.org/) [![Downloads](https://img.shields.io/npm/dt/page-agent.svg)](https://www.npmjs.com/package/page-agent) [![Bundle Size](https://img.shields.io/bundlephobia/minzip/page-agent)](https://bundlephobia.com/package/page-agent) [![GitHub stars](https://img.shields.io/github/stars/alibaba/page-agent.svg)](https://github.com/alibaba/page-agent) [![License: MIT](https://img.shields.io/badge/License-MIT-auto.svg)](https://opensource.org/licenses/MIT) [![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg)](http://www.typescriptlang.org/) [![Bundle Size](https://img.shields.io/bundlephobia/minzip/page-agent)](https://bundlephobia.com/package/page-agent) [![Downloads](https://img.shields.io/npm/dt/page-agent.svg)](https://www.npmjs.com/package/page-agent) [![GitHub stars](https://img.shields.io/github/stars/alibaba/page-agent.svg)](https://github.com/alibaba/page-agent)
The GUI Agent Living in Your Webpage. Control web interfaces with natural language. The GUI Agent Living in Your Webpage. Control web interfaces with natural language.
🌐 **English** | [中文](./README-zh.md) 🌐 **English** | [中文](./docs/README-zh.md)
👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/#/docs/introduction/overview" target="_blank"><b>📖 Documentation</b></a> <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank"><b>📖 Docs</b></a> | <a href="https://news.ycombinator.com/item?id=47264138" target="_blank"><b>📢 HN Discussion</b></a> | <a href="https://x.com/simonluvramen" target="_blank"><b>𝕏 Follow on X</b></a>
<video id="demo-video" src="https://github.com/user-attachments/assets/34d8444d-cbfb-44a3-a24e-fd5c167bb0bf" controls crossorigin muted></video> <video id="demo-video" src="https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2" controls crossorigin muted></video>
--- ---
## ✨ Features ## ✨ Features
- **🎯 Easy Integration** - **🎯 Easy integration**
- No python. No headless browser. No browser extension. Just in-page scripts. - No need for `browser extension` / `python` / `headless browser`.
- **🔐 Client-Side Processing** - Just in-page javascript. Everything happens in your web page.
- **🧠 DOM Extraction** - **📖 Text-based DOM manipulation**
- **💬 Natural Language Interface** - No screenshots. No multi-modal LLMs or special permissions needed.
- **🎨 UI with Human in the loop** - **🧠 Bring your own LLMs**
- **🎨 Pretty UI with human-in-the-loop**
- **🐙 Optional [chrome extension](https://alibaba.github.io/page-agent/docs/features/chrome-extension) for multi-page tasks.**
And 😉 ## 💡 Use Cases
- **🧪 `cross-page` control with an experimental chrome extension** - `packages/extension` - **SaaS AI Copilot** — Ship an AI copilot in your product in lines of code. No backend rewrite.
- **Smart Form Filling** — Turn 20-click workflows into one sentence. Perfect for ERP, CRM, and admin systems.
👉 [**🗺️ Roadmap**](https://github.com/alibaba/page-agent/issues/96) - **Accessibility** — Make any web app accessible through natural language. Voice commands, screen readers, zero barrier.
- **Multi-page Agent** — Extend your own agent's reach across browser tabs with the optional [chrome extension](https://alibaba.github.io/page-agent/docs/features/chrome-extension).
## 🚀 Quick Start ## 🚀 Quick Start
@@ -39,19 +42,15 @@ And 😉
Fastest way to try PageAgent with our free Demo LLM: 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.1.2/dist/iife/page-agent.demo.js"
crossorigin="true"
></script>
``` ```
> - **⚠️ For technical evaluation only.** Demo LLM has rate limits and usage restrictions. May change without notice. > **⚠️ For technical evaluation only.** This demo CDN uses our free [testing LLM API](https://alibaba.github.io/page-agent/docs/features/models#free-testing-api). By using it, you agree to its [terms](https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md).
> - **🌷 Bring your own LLM API.**
| Mirrors | URL | | Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- | | ------- | ---------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.1.2/dist/iife/page-agent.demo.js | | Global | https://cdn.jsdelivr.net/npm/page-agent@1.5.8/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.1.2/files/dist/iife/page-agent.demo.js | | China | https://registry.npmmirror.com/page-agent/1.5.8/files/dist/iife/page-agent.demo.js |
### NPM Installation ### NPM Installation
@@ -63,8 +62,8 @@ npm install page-agent
import { PageAgent } from 'page-agent' import { PageAgent } from 'page-agent'
const agent = new PageAgent({ const agent = new PageAgent({
model: 'deepseek-chat', model: 'qwen3.5-plus',
baseURL: 'https://api.deepseek.com', baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
apiKey: 'YOUR_API_KEY', apiKey: 'YOUR_API_KEY',
language: 'en-US', language: 'en-US',
}) })
@@ -72,25 +71,15 @@ const agent = new PageAgent({
await agent.execute('Click the login button') await agent.execute('Click the login button')
``` ```
## 🏗️ Structure For more programmatic usage, see [📖 Documentations](https://alibaba.github.io/page-agent/docs/introduction/overview).
PageAgent adopts a simplified monorepo structure:
```
packages/
├── core/ # ** Core agent logic without UI(npm: @page-agent/core) **
├── page-agent/ # Exported agent and demo(npm: page-agent)
├── llms/ # LLM client (npm: @page-agent/llms)
├── page-controller/ # DOM operations & Visual Mask (npm: @page-agent/page-controller)
├── ui/ # Panel & i18n (npm: @page-agent/ui)
└── website/ # Demo & Documentation site
```
## 🤝 Contributing ## 🤝 Contributing
We welcome contributions from the community! Follow our instructions in [CONTRIBUTING.md](CONTRIBUTING.md) for environment setup and local development. We welcome contributions from the community! Follow our instructions in [CONTRIBUTING.md](CONTRIBUTING.md) for setup and guidelines.
Please read [Code of Conduct](CODE_OF_CONDUCT.md) before contributing. Please read [Code of Conduct](docs/CODE_OF_CONDUCT.md) before contributing.
Contributions generated entirely by bots or agents without substantial human involvement will not be accepted, and bot accounts may be blocked.
## 👏 Acknowledgments ## 👏 Acknowledgments
@@ -101,12 +90,10 @@ This project builds upon the excellent work of **[`browser-use`](https://github.
``` ```
DOM processing components and prompt are derived from browser-use: DOM processing components and prompt are derived from browser-use:
Browser Use Browser Use <https://github.com/browser-use/browser-use>
Copyright (c) 2024 Gregor Zunic Copyright (c) 2024 Gregor Zunic
Licensed under the MIT License Licensed under the MIT License
Original browser-use project: <https://github.com/browser-use/browser-use>
We gratefully acknowledge the browser-use project and its contributors for their We gratefully acknowledge the browser-use project and its contributors for their
excellent work on web automation and DOM interaction patterns that helped make excellent work on web automation and DOM interaction patterns that helped make
this project possible. this project possible.
@@ -122,3 +109,11 @@ file and in the node_modules directory after installation.
--- ---
**⭐ Star this repo if you find PageAgent helpful!** **⭐ Star this repo if you find PageAgent helpful!**
<a href="https://www.star-history.com/?repos=alibaba%2Fpage-agent&type=date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&theme=dark&legend=top-left&v=7" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&legend=top-left&v=7" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&legend=top-left&v=7" />
</picture>
</a>
+50
View File
@@ -0,0 +1,50 @@
# Security Policy
## Supported Versions
We provide security fixes on a best-effort basis for:
| Version | Supported |
| --------------------------------------------------------- | --------- |
| `main` | Yes |
| Latest npm release of `page-agent` and workspace packages | Yes |
| Older releases | No |
Please upgrade to the latest release before reporting an issue against an older build.
## Reporting a Vulnerability
Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.
Use GitHub's private vulnerability reporting flow:
- Open https://github.com/alibaba/page-agent/security/policy
- Click `Report a vulnerability`
If private reporting is unavailable, open a minimal public issue only to request a private contact channel. Do not include exploit details.
## What to Include
- Affected package or feature
- Exact version, commit, or build
- Browser, OS, and runtime environment
- Reproduction steps or a proof of concept
- Expected impact
## Scope
We prioritize reports that show a real security boundary failure, such as:
- Unauthorized access to data, tokens, or extension capabilities
- Bypassing explicit safety constraints
- Sensitive data exposure caused by default behavior
The following usually do not qualify by themselves:
- Unsafe custom integrations that ignore documented safeguards
- Intentionally embedding secrets into client-side builds
- Reports against unsupported older versions
## Disclosure
Please avoid public disclosure until maintainers have had a reasonable chance to investigate and ship a fix.
+211
View File
@@ -0,0 +1,211 @@
# Changelog
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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.5.1] - 2026-03-05
### Breaking Changes
- **`data-browser-use-ignore``data-page-agent-ignore`** - DOM ignore attribute renamed to match the project identity
- **Config types restructured** - `PageAgentConfig` split into `AgentConfig` + `PageAgentCoreConfig`; config definitions moved from `config/index.ts` to `types.ts`
- **Zod v3/v4 dual support** - Libraries now accept both `zod@^3.25` and `zod@^4.0` as peer dependencies
### Features
- **Experimental `llms.txt` support** - Agent can fetch and include a site's `llms.txt` in context. Enable via `experimentalLlmsTxt: true`
### Improvements
- Default `maxSteps` changed from 20 to 40 for better for complex tasks out of the box
- Added 400ms wait between agent steps for page reactions
- Increased click wait time (100ms → 200ms) for more reliable interactions
- Removed debug `console.log` statements from scroll actions
- Reset observations on new task start
- Improved logging across packages
### Extension v0.1.9
> PageAgent 1.5.1
- **Advanced config panel** - New collapsible section exposing Max Steps, System Instruction, and experimental `llms.txt` toggle
- Streamlined User Auth Token description
- Moved testing API notice below auth token section
---
## [1.4.0] - 2026-02-27
### Features
- Update Terms of Use and Privacy Policy
- **Robust tool-call validation** - Action inputs are now validated against tool schemas individually, producing clear error messages (e.g. `Invalid input for action "click_element_by_index"`) instead of unreadable union parse errors
- **Primitive action input coercion** - Small models that output `{"click_element_by_index": 2}` instead of `{"click_element_by_index": {"index": 2}}` are now auto-corrected using tool schemas
- **Qwen model updates** - Added `qwen3.5-plus` as the default free testing model; disabled `enable_thinking` for Qwen models to avoid incompatible responses
- **Updated default LLM endpoint** - Migrated demo and extension to a new testing endpoint with legacy endpoint auto-migration
### Improvements
- Unified zod imports (`* as z`) across all packages for consistency
- Better Zod error formatting with `z.prettifyError()` in LLM client
- Exported `InvokeError` and `InvokeErrorType` as values (not just types) from `@page-agent/llms`
- Exported `SupportedLanguage` type from `@page-agent/core`
### Extension v0.1.8
- **Language setting** - Added language selector (System / English / 中文) in config panel
- **UI makeover** - New empty state with breathing glow and typing animation; ai-motion glow overlay while running; refined focus styles
- **Testing endpoint notice** - Shows terms of use notice when using the free testing API
- **Legacy endpoint migration** - Auto-migrates old Supabase testing endpoint to new endpoint on startup
---
## [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
### 🎉 First Stable Release
PageAgent is now ready for production use. The API is stable and breaking changes will follow semantic versioning.
### Features
#### Core
- **PageAgent** - Main entry class with built-in UI Panel
- **PageAgentCore** - Headless agent class for custom UI or programmatic use
- **DOM Analysis** - Text-based DOM extraction with high-intensity dehydration
- **LLM Support** - Works with OpenAI, Claude, DeepSeek, Qwen, and other OpenAI-compatible APIs
- **Tool System** - Built-in tools for click, input, scroll, select, and more
- **Custom Tools** - Extend agent capabilities with your own tools (experimental)
- **Lifecycle Hooks** - Hook into agent execution (experimental)
- **Instructions System** - System-level and page-level instructions to guide agent behavior
- **Data Masking** - Transform page content before sending to LLM
#### Page Controller
- **Element Interactions** - Click, input text, select options, scroll
- **Visual Mask** - Blocks user interaction during automation
- **DOM Tree Extraction** - Efficient page structure extraction for LLM consumption
#### UI
- **Interactive Panel** - Real-time task progress and agent thinking display
- **Ask User Tool** - Agent can ask users for clarification
- **i18n Support** - English and Chinese localization
### Configuration
```typescript
// Version 1.0.0
interface PageAgentConfig {
// LLM Configuration (required)
baseURL: string
apiKey: string
model: string
temperature?: number
maxRetries?: number
customFetch?: typeof fetch
// Agent Configuration
language?: 'en-US' | 'zh-CN'
maxSteps?: number // default: 20
customTools?: Record<string, PageAgentTool> // experimental
instructions?: InstructionsConfig
transformPageContent?: (content: string) => string | Promise<string>
experimentalScriptExecutionTool?: boolean // default: false
// Lifecycle Hooks (experimental)
onBeforeTask?: (agent, result) => void
onAfterTask?: (agent, result) => void
onBeforeStep?: (agent, stepCount) => void
onAfterStep?: (agent, history) => void
onDispose?: (agent, reason?) => void
// Page Controller Configuration
enableMask?: boolean // default: true
viewportExpansion?: number
interactiveBlacklist?: Element[]
interactiveWhitelist?: Element[]
}
```
### Packages
| Package | Description |
| ----------------------------- | ---------------------------------- |
| `page-agent` | Main entry with UI Panel |
| `@page-agent/core` | Core agent logic without UI |
| `@page-agent/llms` | LLM client with retry logic |
| `@page-agent/page-controller` | DOM operations and visual feedback |
| `@page-agent/ui` | Panel and i18n |
### Known Limitations
- Single-page application only (cannot navigate across pages)
- No visual recognition (relies on DOM structure)
- Limited interaction support (no hover, drag-drop, canvas operations)
- See [Limitations](https://alibaba.github.io/page-agent/docs/introduction/limitations) for details
### Acknowledgments
This project builds upon the excellent work of [browser-use](https://github.com/browser-use/browser-use). DOM processing components and prompts are adapted from browser-use (MIT License).
+117
View File
@@ -0,0 +1,117 @@
# Page Agent
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://img.alicdn.com/imgextra/i4/O1CN01qKig1P1FnhpFKNdi6_!!6000000000532-2-tps-1280-256.png">
<img alt="Page Agent Banner" src="https://img.alicdn.com/imgextra/i1/O1CN01NCMKXj1Gn4tkFTsxf_!!6000000000666-2-tps-1280-256.png">
</picture>
[![License: MIT](https://img.shields.io/badge/License-MIT-auto.svg)](https://opensource.org/licenses/MIT) [![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg)](http://www.typescriptlang.org/) [![Bundle Size](https://img.shields.io/bundlephobia/minzip/page-agent)](https://bundlephobia.com/package/page-agent) [![Downloads](https://img.shields.io/npm/dt/page-agent.svg)](https://www.npmjs.com/package/page-agent) [![GitHub stars](https://img.shields.io/github/stars/alibaba/page-agent.svg)](https://github.com/alibaba/page-agent)
纯 JS 实现的 GUI agent。使用自然语言操作你的 Web 应用。无须后端、客户端、浏览器插件。
🌐 [English](../README.md) | **中文**
<a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank"><b>📖 Docs</b></a> | <a href="https://news.ycombinator.com/item?id=47264138" target="_blank"><b>📢 HN Discussion</b></a> | <a href="https://x.com/simonluvramen" target="_blank"><b>𝕏 Follow on X</b></a>
<video id="demo-video" src="https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2" controls crossorigin muted></video>
---
## ✨ Features
- **🎯 轻松集成**
- 无需 `浏览器插件` / `Python` / `无头浏览器`
- 纯页面内 JavaScript,一切都在你的网页中完成。
- **📖 基于文本的 DOM 操作**
- 无需截图,无需多模态模型或特殊权限。
- **🧠 用你自己的 LLM**
- **🎨 精美 UI,支持人机协同**
- **🐙 可选的 [Chrome 扩展](https://alibaba.github.io/page-agent/docs/features/chrome-extension),支持跨页面任务。**
## 💡 应用场景
- **SaaS AI 副驾驶** — 几行代码为你的产品加上 AI 副驾驶,无需重写后端。
- **智能表单填写** — 把 20 次点击变成一句话。ERP、CRM、管理后台的最佳拍档。
- **无障碍增强** — 用自然语言让任何网页无障碍。语音指令、屏幕阅读器,零门槛。
- **跨页面 Agent** — 通过可选的 [Chrome 扩展](https://alibaba.github.io/page-agent/docs/features/chrome-extension),让你自己的 Agent 跨标签页工作。
## 🚀 快速开始
### 一行代码集成
通过我们免费的 Demo LLM 快速体验 PageAgent
```html
<script src="{URL}" crossorigin="true"></script>
```
> **⚠️ 仅用于技术评估。** 该 Demo CDN 使用了免费的[测试 LLM API](https://alibaba.github.io/page-agent/docs/features/models#free-testing-api),使用即表示您同意其[条款](https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md)。
| Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.5.8/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.5.8/files/dist/iife/page-agent.demo.js |
### NPM 安装
```bash
npm install page-agent
```
```javascript
import { PageAgent } from 'page-agent'
const agent = new PageAgent({
model: 'qwen3.5-plus',
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
apiKey: 'YOUR_API_KEY',
language: 'zh-CN',
})
await agent.execute('点击登录按钮')
```
更多编程用法,请参阅 [📖 文档](https://alibaba.github.io/page-agent/docs/introduction/overview)。
## 🤝 贡献
欢迎社区贡献!请参阅 [CONTRIBUTING.md](../CONTRIBUTING.md) 了解安装与贡献指南。请在贡献前阅读[行为准则](CODE_OF_CONDUCT.md)。
我们不接受未经实质性人类参与、完全由 Bot 或 Agent 自动生成的代码,机器人账号可能被禁止参与互动。
## 👏 致谢
本项目基于 **[`browser-use`](https://github.com/browser-use/browser-use)** 的优秀工作构建。
`PageAgent` 专为**客户端网页增强**设计,不是服务端自动化工具。
```
DOM processing components and prompt are derived from browser-use:
Browser Use <https://github.com/browser-use/browser-use>
Copyright (c) 2024 Gregor Zunic
Licensed under the MIT License
We gratefully acknowledge the browser-use project and its contributors for their
excellent work on web automation and DOM interaction patterns that helped make
this project possible.
Third-party dependencies and their licenses can be found in the package.json
file and in the node_modules directory after installation.
```
## 📄 许可证
[MIT License](../LICENSE)
---
**⭐ 如果觉得 PageAgent 有用或有趣,请给项目点个星!**
<a href="https://www.star-history.com/?repos=alibaba%2Fpage-agent&type=date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&theme=dark&legend=top-left&v=7" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&legend=top-left&v=7" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&legend=top-left&v=7" />
</picture>
</a>
+85
View File
@@ -0,0 +1,85 @@
# Terms of Use & Privacy
**Last updated:** March 2026
"We" in this document refers to the maintainers of the open-source Page Agent project (https://github.com/alibaba/page-agent). "The software" refers to Page Agent (the JavaScript library) and Page Agent Ext (the browser extension). This document covers the software itself and the testing API we provide — **not** any third-party product or service built with it.
---
## 1. Open Source Software Privacy
The software is a **client-side only** tool with a "Bring Your Own Key" (BYOK) architecture. The software itself does **not** include any backend service. The software does **not** collect or transmit any user data on its own, and we do **not** have access to your browsing activity, page content, or task instructions through the software.
All data transmission occurs **only** between your browser and the LLM provider you configure. You are in full control of which provider receives your data.
The project is open source under the [MIT License](https://github.com/alibaba/page-agent/blob/main/LICENSE) and can be audited at: https://github.com/alibaba/page-agent
---
## 2. Testing API and Demo Disclaimer & Terms of Use
To facilitate easy testing and technical evaluation, we provide a free testing LLM API. This API is used in the project homepage's live demo, the pre-built demo CDN bundles, and the browser extension's default configuration. Users may also use it independently for their own technical evaluation of the software.
This free testing API is provided **strictly for technical evaluation and R&D purposes only**. It must not be used in any production environment. By using this API, you agree to the following terms:
- **Permitted Use Only**: This API must be used solely for technical evaluation of the software. Any other use — including integration into other products or services, unlawful activities, violation of the underlying LLM provider's usage policies, or automated scraping at scale — is strictly prohibited.
- **No Sensitive Data**: You are strictly prohibited from inputting any Personal Identifiable Information (PII), confidential business data, financial/medical records, or using this agent on web pages containing such sensitive information.
- **Data Processing**: We do not store or log your prompts, webpage data (HTML), or any submitted content, nor do we use such data for model training. All data is processed in-transit and immediately discarded. We perform in-memory request validation to prevent abuse of the testing API, and temporarily process IP addresses for rate-limiting purposes. No data from these processes is retained. Data is processed through Alibaba Cloud infrastructure, which is subject to its own privacy policy.
- **Independent Infrastructure**: The software is completely frontend-based with a "Bring Your Own Key" (BYOK) architecture and **no built-in backend**. To facilitate easy testing, the maintainers have purchased public cloud services from Alibaba Cloud China ([aliyun.com](https://www.aliyun.com) Function Compute and BaiLian Qwen models). This project is not a product of, nor endorsed by, Alibaba Cloud.
- **No Guaranteed Availability**: This testing API may be rate-limited, degraded, or discontinued at any time without prior notice.
- **"AS IS" & Limitation of Liability**: This service is provided strictly on an "AS IS" and "AS AVAILABLE" basis, without any warranties. The maintainers bear no liability for any data loss, service interruption, or legal consequences arising from your use of this service.
- **Recommendation for Real Usage**: For secure and continuous usage, we strongly advise using the BYOK mode with your own legally compliant commercial LLM API keys, or connecting to local, offline models (e.g., Ollama).
**Note**: This free testing LLM API processes data via servers located in Mainland China. If you are located in a region with strict data localization laws (such as the EU/EEA), please do not use this API.
**Age Requirement**: The software and testing API are not intended for use by individuals under the age of 13 (or the minimum age of digital consent in your jurisdiction).
---
## 3. Browser Extension (Page Agent Ext)
### Data 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 developer.
Data is transmitted to external servers **only when you initiate an automation task**. When this occurs:
- Your task instructions (natural language commands)
- Simplified page structure (cleaned HTML) of all pages under the extension's control
are sent to the LLM API endpoint configured in **your settings**.
> **Note:** The HTML cleaning process simplifies page structure for AI readability but **does not guarantee removal of sensitive information** (e.g., visible text, form values, or personal data on the page). Please be mindful of the page content when initiating tasks.
**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 the testing API**, the terms in [Section 2](#2-testing-api-and-demo-disclaimer--terms-of-use) apply. By using the extension with the default testing API, you agree to those terms.
### Data Storage
- **Local storage only**: Your configuration (API endpoint, API key, model selection) is stored in your browser via `chrome.storage.local` (or equivalent browser storage APIs)
- **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
We may update these terms at our discretion.
## Contact
https://github.com/alibaba/page-agent/issues
+839 -794
View File
File diff suppressed because it is too large Load Diff
+22 -19
View File
@@ -1,7 +1,7 @@
{ {
"name": "root", "name": "root",
"private": true, "private": true,
"version": "1.1.2", "version": "1.5.8",
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
"packages/page-controller", "packages/page-controller",
@@ -21,45 +21,48 @@
}, },
"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",
"start": "npm run dev --workspace=@page-agent/website", "start": "npm run dev --workspace=@page-agent/website",
"build:website": "npm run build:website --workspace=@page-agent/website", "dev:ext": "npm run dev -w @page-agent/ext",
"build:libs": "npm run build --workspaces --if-present",
"build": "npm run build:libs && npm run build:website",
"dev:demo": "npm run dev:demo --workspace=page-agent", "dev:demo": "npm run dev:demo --workspace=page-agent",
"build": "npm run build:libs && npm run build:website",
"build:libs": "npm run build --workspaces --if-present",
"build:website": "npm run build:website --workspace=@page-agent/website",
"build:ext": "npm run build:libs && npm run zip -w @page-agent/ext",
"version": "node scripts/sync-version.js", "version": "node scripts/sync-version.js",
"lint": "eslint .", "lint": "eslint .",
"cleanup": "rm -rf packages/*/dist",
"prepare": "husky" "prepare": "husky"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^20.3.1", "@commitlint/cli": "^20.4.3",
"@commitlint/config-conventional": "^20.3.1", "@commitlint/config-conventional": "^20.4.3",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@microsoft/api-extractor": "^7.55.2", "@microsoft/api-extractor": "^7.57.7",
"@tailwindcss/vite": "^4.2.1",
"@trivago/prettier-plugin-sort-imports": "^6.0.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^25.0.9", "@types/node": "^25.4.0",
"@vitejs/plugin-react-swc": "^4.1.0",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"dotenv": "^17.2.3", "dotenv": "^17.3.1",
"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.13.0",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.0", "eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-react-x": "^2.7.2", "eslint-plugin-react-x": "^2.13.0",
"globals": "^17.0.0", "globals": "^17.4.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.2.4", "lint-staged": "^16.3.2",
"prettier": "^3.8.0", "prettier": "^3.8.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.54.0", "typescript-eslint": "^8.57.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": "^4.0.1",
"vite-bundle-analyzer": "^1.3.6" "vite-bundle-analyzer": "^1.3.6"
}, },
"overrides": { "overrides": {
+9 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/core", "name": "@page-agent/core",
"private": false, "private": false,
"version": "1.1.2", "version": "1.5.8",
"type": "module", "type": "module",
"main": "./dist/esm/page-agent-core.js", "main": "./dist/esm/page-agent-core.js",
"module": "./dist/esm/page-agent-core.js", "module": "./dist/esm/page-agent-core.js",
@@ -44,8 +44,13 @@
}, },
"dependencies": { "dependencies": {
"chalk": "^5.6.2", "chalk": "^5.6.2",
"zod": "^4.3.5", "@page-agent/llms": "1.5.8",
"@page-agent/llms": "1.1.2", "@page-agent/page-controller": "1.5.8"
"@page-agent/page-controller": "1.1.2" },
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {
"zod": "^4.3.5"
} }
} }
+167 -120
View File
@@ -1,18 +1,18 @@
/** /**
* Copyright (C) 2025 Alibaba Group Holding Limited * Copyright (C) 2025 Alibaba Group Holding Limited
* Copyright (C) 2026 SimonLuvRamen
* 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 * as zod from 'zod' import * as z from 'zod/v4'
import { type PageAgentConfig } from './config'
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 type { import type {
AgentActivity, AgentActivity,
AgentConfig,
AgentReflection, AgentReflection,
AgentStatus, AgentStatus,
AgentStepEvent, AgentStepEvent,
@@ -21,16 +21,26 @@ import type {
MacroToolInput, MacroToolInput,
MacroToolResult, MacroToolResult,
} from './types' } from './types'
import { assert, normalizeResponse, trimLines, uid, waitFor } from './utils' import { assert, fetchLlmsTxt, normalizeResponse, uid, waitFor } from './utils'
export { type PageAgentConfig }
export { tool, type PageAgentTool } from './tools' export { tool, type PageAgentTool } from './tools'
export type * from './types' export type * from './types'
export type PageAgentCoreConfig = AgentConfig & { pageController: PageController }
/** /**
* 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,14 +59,18 @@ export type * from './types'
* - 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: PageAgentCoreConfig & { maxSteps: number }
tools: typeof tools readonly tools: typeof tools
/** 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)
@@ -65,27 +79,25 @@ 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 */ constructor(config: PageAgentCoreConfig) {
history: HistoricalEvent[] = []
constructor(config: PageAgentConfig & { pageController: PageController }) {
super() super()
this.config = { ...config, maxSteps: config.maxSteps || DEFAULT_MAX_STEPS } this.config = { ...config, maxSteps: config.maxSteps ?? 40 }
this.#llm = new LLM(this.config) this.#llm = new LLM(this.config)
this.tools = new Map(tools) this.tools = new Map(tools)
@@ -165,15 +177,24 @@ export class PageAgentCore extends EventTarget {
} }
/** /**
* Push a persistent observation to the history event stream. * Push an 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()
@@ -201,12 +222,10 @@ export class PageAgentCore extends EventTarget {
this.history = [] this.history = []
this.#setStatus('running') this.#setStatus('running')
this.#emitHistoryChange() this.#emitHistoryChange()
this.#observations = []
// Reset states // Reset internal states
this.states = { this.#states = { totalWaitTime: 0, lastURL: '', browserState: null }
totalWaitTime: 0,
lastURL: '',
}
let step = 0 let step = 0
@@ -214,32 +233,35 @@ export class PageAgentCore extends EventTarget {
try { try {
console.group(`step: ${step}`) console.group(`step: ${step}`)
await this.#generateObservations(step)
await onBeforeStep?.(this, step) await onBeforeStep?.(this, step)
// abort // observe
if (this.#abortController.signal.aborted) throw new Error('AbortError')
// status console.log(chalk.blue.bold('👀 Observing...'))
console.log(chalk.blue('Thinking...'))
this.#emitActivity({ type: 'thinking' })
// invoke LLM this.#states.browserState = await this.pageController.getBrowserState()
await this.#handleObservations(step)
// assemble prompts
const messages = [ const messages = [
{ role: 'system' as const, content: this.#getSystemPrompt() }, { role: 'system' as const, content: this.#getSystemPrompt() },
{ role: 'user' as const, content: await this.#assembleUserPrompt() }, { role: 'user' as const, content: await this.#assembleUserPrompt() },
] ]
const tools = { AgentOutput: this.#packMacroTool() } const macroTool = { AgentOutput: this.#packMacroTool() }
const result = await this.#llm.invoke(messages, tools, this.#abortController.signal, { // invoke LLM
console.log(chalk.blue.bold('🧠 Thinking...'))
this.#emitActivity({ type: 'thinking' })
const result = await this.#llm.invoke(messages, macroTool, this.#abortController.signal, {
toolChoiceName: 'AgentOutput', toolChoiceName: 'AgentOutput',
normalizeResponse, normalizeResponse: (res) => normalizeResponse(res, this.tools),
}) })
// assemble history event // assemble history
const macroResult = result.toolResult as MacroToolResult const macroResult = result.toolResult as MacroToolResult
const input = macroResult.input const input = macroResult.input
@@ -271,7 +293,6 @@ export class PageAgentCore extends EventTarget {
await onAfterStep?.(this, this.history) await onAfterStep?.(this, this.history)
console.log(chalk.green('Step finished:'), actionName)
console.groupEnd() console.groupEnd()
// finish task if done // finish task if done
@@ -291,9 +312,13 @@ export class PageAgentCore extends EventTarget {
} }
} catch (error: unknown) { } catch (error: unknown) {
console.groupEnd() // to prevent nested groups console.groupEnd() // to prevent nested groups
const isAbortError = (error as any)?.rawError?.name === 'AbortError'
console.error('Task failed', error) console.error('Task failed', error)
const errorMessage = String(error) const errorMessage = isAbortError ? 'Task stopped' : String(error)
this.#emitActivity({ type: 'error', message: errorMessage }) this.#emitActivity({ type: 'error', message: errorMessage })
this.history.push({ type: 'error', message: errorMessage, rawResponse: error })
this.#emitHistoryChange()
this.#onDone(false) this.#onDone(false)
const result: ExecutionResult = { const result: ExecutionResult = {
success: false, success: false,
@@ -306,15 +331,20 @@ export class PageAgentCore extends EventTarget {
step++ step++
if (step > this.config.maxSteps) { if (step > this.config.maxSteps) {
const errorMessage = 'Step count exceeded maximum limit'
this.history.push({ type: 'error', message: errorMessage })
this.#emitHistoryChange()
this.#onDone(false) this.#onDone(false)
const result: ExecutionResult = { const result: ExecutionResult = {
success: false, success: false,
data: 'Step count exceeded maximum limit', data: errorMessage,
history: this.history, history: this.history,
} }
await onAfterTask?.(this, result) await onAfterTask?.(this, result)
return result return result
} }
await waitFor(this.config.stepDelay ?? 0.4)
} }
} }
@@ -331,29 +361,27 @@ export class PageAgentCore extends EventTarget {
const tools = this.tools const tools = this.tools
const actionSchemas = Array.from(tools.entries()).map(([toolName, tool]) => { const actionSchemas = Array.from(tools.entries()).map(([toolName, tool]) => {
return zod.object({ [toolName]: tool.inputSchema }).describe(tool.description) return z.object({ [toolName]: tool.inputSchema }).describe(tool.description)
}) })
const actionSchema = zod.union( const actionSchema = z.union(actionSchemas as unknown as [z.ZodType, z.ZodType, ...z.ZodType[]])
actionSchemas as unknown as [zod.ZodType, zod.ZodType, ...zod.ZodType[]]
)
const macroToolSchema = zod.object({ const macroToolSchema = z.object({
// thinking: zod.string().optional(), // thinking: z.string().optional(),
evaluation_previous_goal: zod.string().optional(), evaluation_previous_goal: z.string().optional(),
memory: zod.string().optional(), memory: z.string().optional(),
next_goal: zod.string().optional(), next_goal: z.string().optional(),
action: actionSchema, action: actionSchema,
}) })
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 z.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]
@@ -374,7 +402,7 @@ 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)
@@ -398,9 +426,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
@@ -430,17 +460,16 @@ 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, experimentalLlmsTxt } = this.config
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) {
@@ -450,7 +479,10 @@ export class PageAgentCore extends EventTarget {
) )
} }
} }
if (!systemInstructions && !pageInstructions) return ''
const llmsTxt = experimentalLlmsTxt && url ? await fetchLlmsTxt(url) : undefined
if (!systemInstructions && !pageInstructions && !llmsTxt) return ''
let result = '<instructions>\n' let result = '<instructions>\n'
@@ -462,44 +494,68 @@ export class PageAgentCore extends EventTarget {
result += `<page_instructions>\n${pageInstructions}\n</page_instructions>\n` result += `<page_instructions>\n${pageInstructions}\n</page_instructions>\n`
} }
if (llmsTxt) {
result += `<llms_txt>\n${llmsTxt}\n</llms_txt>\n`
}
result += '</instructions>\n\n' result += '</instructions>\n\n'
return result return result
} }
/** /**
* 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.`
) )
} else if (remaining === 2) { } else if (remaining === 2) {
this.pushObservation( this.pushObservation(
`⚠️ 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>
@@ -509,38 +565,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().toLocaleString()} 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
@@ -551,9 +605,18 @@ 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(success = true) { #onDone(success = true) {
@@ -563,25 +626,9 @@ export class PageAgentCore extends EventTarget {
this.#abortController.abort() this.#abortController.abort()
} }
async #getBrowserState(): Promise<string> {
const state = await this.pageController.getBrowserState()
let content = state.content
if (this.config.transformPageContent) {
content = await this.config.transformPageContent(content)
}
return trimLines(`<browser_state>
${state.header}
${content}
${state.footer}
</browser_state>
`)
}
dispose() { dispose() {
console.log('Disposing PageAgent...') console.log('Disposing PageAgent...')
this.disposed = true
this.pageController.dispose() this.pageController.dispose()
// this.history = [] // this.history = []
this.#abortController.abort() this.#abortController.abort()
-1
View File
@@ -1 +0,0 @@
export const DEFAULT_MAX_STEPS = 20
-152
View File
@@ -1,152 +0,0 @@
import type { LLMConfig } from '@page-agent/llms'
import type { PageControllerConfig } from '@page-agent/page-controller'
import type { PageAgentCore } from '../PageAgentCore'
import type { PageAgentTool } from '../tools'
import type { ExecutionResult, HistoricalEvent } from '../types'
export type { LLMConfig }
/** Supported UI languages */
export type SupportedLanguage = 'en-US' | 'zh-CN'
export interface AgentConfig {
// theme?: 'light' | 'dark'
language?: SupportedLanguage
/**
* Maximum number of steps the agent can take per task.
* @default 20
*/
maxSteps?: number
/**
* Custom tools to extend PageAgent capabilities
* @experimental
* @note You can also override or remove internal tools by using the same name.
* @see PageAgentTool
*
* @example
* // override internal tool
* import { tool } from 'page-agent'
* const customTools = {
* ask_user: tool({
* description:
* 'Ask the user or parent model a question and wait for their answer. Use this if you need more information or clarification.',
* inputSchema: zod.object({
* question: zod.string(),
* }),
* execute: async function (this: PageAgent, input) {
* const answer = await do_some_thing(input.question)
* return "✅ Received user answer: " + answer
* },
* })
* }
*
* @example
* // remove internal tool
* const customTools = {
* ask_user: null // never ask user questions
* }
*/
customTools?: Record<string, PageAgentTool | null>
/**
* Instructions to guide the agent's behavior
*/
instructions?: {
/**
* Global system-level instructions, applied to all tasks
*/
system?: string
/**
* Dynamic page-level instructions callback
* Called before each step to get instructions for the current page
* @param url - Current page URL (window.location.href)
* @returns Instructions string, or undefined/null to skip
*/
getPageInstructions?: (url: string) => string | undefined | null
}
/**
* Lifecycle hooks for task execution.
* @experimental API may change in future versions.
*
* All hooks receive the agent instance as first parameter.
*/
/**
* Called before each step execution.
* @experimental
* @param agent - The PageAgentCore instance
* @param stepCount - Current step number (0-indexed)
*/
onBeforeStep?: (agent: PageAgentCore, stepCount: number) => Promise<void> | void
/**
* Called after each step execution.
* @experimental
* @param agent - The PageAgentCore instance
* @param history - Current history of events
*/
onAfterStep?: (agent: PageAgentCore, history: HistoricalEvent[]) => Promise<void> | void
/**
* Called before task execution starts.
* @experimental
* @param agent - The PageAgentCore instance
*/
onBeforeTask?: (agent: PageAgentCore) => Promise<void> | void
/**
* Called after task execution completes (success or failure).
* @experimental
* @param agent - The PageAgentCore instance
* @param result - The execution result
*/
onAfterTask?: (agent: PageAgentCore, result: ExecutionResult) => Promise<void> | void
/**
* Called when the agent is disposed.
* @experimental
* @note This hook can block the disposal process if it's async.
* @param agent - The PageAgentCore instance
* @param reason - Optional reason for disposal
*/
onDispose?: (agent: PageAgentCore, reason?: string) => void
// page behavior hooks
/**
* @experimental
* Enable the experimental script execution tool that allows executing generated JavaScript code on the page.
* @note Can cause unpredictable side effects.
* @note May bypass some safe guards and data-masking mechanisms.
*/
experimentalScriptExecutionTool?: boolean
/**
* Transform page content before sending to LLM.
* Called after DOM extraction and simplification, before LLM invocation.
* Use cases: inspect extraction results, modify page info, mask sensitive data.
*
* @param content - Simplified page content that will be sent to LLM
* @returns Transformed content
*
* @example
* // Mask phone numbers
* transformPageContent: async (content) => {
* return content.replace(/1[3-9]\d{9}/g, '***********')
* }
*/
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
+3 -5
View File
@@ -69,7 +69,7 @@ Strictly follow these rules while using the browser and navigating the web:
- 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. - 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). - You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
- All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed. - All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed.
- If a captcha appears, tell user you can not solve captcha. finished the task and ask user to solve it. - If a captcha appears, tell user you can not solve captcha. Finish the task and ask user to solve it.
- If expected elements are missing, try scrolling, or navigating back. - If expected elements are missing, try scrolling, or navigating back.
- If the page is not fully loaded, use the `wait` action. - If the page is not fully loaded, use the `wait` action.
- Do not repeat one action for more than 3 times unless some conditions changed. - Do not repeat one action for more than 3 times unless some conditions changed.
@@ -87,11 +87,11 @@ Strictly follow these rules while using the browser and navigating the web:
<capability> <capability>
- You can only handle single page app. Do not jump out of current page. - You can only handle single page app. Do not jump out of current page.
- Do not click on link if it will open in a new page (etc. <a target="_blank">) - Do not click on link if it will open in a new page (e.g., <a target="_blank">)
- It is ok to fail the task. - It is ok to fail the task.
- User can be wrong. If the request of user is not achievable, inappropriate or you do not have enough information or tools to achieve it. Tell user to make a better request. - User can be wrong. If the request of user is not achievable, inappropriate or you do not have enough information or tools to achieve it. Tell user to make a better request.
- Webpage can be broken. All webpages or apps have bugs. Some bug will make it hard for your job. It's encouraged to tell user the problem of current page. Your feedbacks (including failing) are valuable for user. - Webpage can be broken. All webpages or apps have bugs. Some bug will make it hard for your job. It's encouraged to tell user the problem of current page. Your feedbacks (including failing) are valuable for user.
- Trying to hard can be harmful. Repeating some action back and forth or pushing for a complex procedure with little knowledge can cause unwanted result and harmful side-effects. User would rather you to complete the task with a fail. - Trying too hard can be harmful. Repeating some action back and forth or pushing for a complex procedure with little knowledge can cause unwanted results and harmful side-effects. User would rather you complete the task with a fail.
- If you do not have knowledge for the current webpage or task. You must require user to give specific instructions and detailed steps. - If you do not have knowledge for the current webpage or task. You must require user to give specific instructions and detailed steps.
</capability> </capability>
@@ -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,7 +138,6 @@ 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>
+37 -46
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 * as zod from 'zod' import * as z from 'zod/v4'
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: zod.ZodType<TParams> inputSchema: z.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: z.object({
text: zod.string(), text: z.string(),
success: zod.boolean().default(true), success: z.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: z.object({
inputSchema: zod.object({ seconds: z.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.`
}, },
}) })
@@ -76,8 +67,8 @@ tools.set(
tool({ tool({
description: description:
'Ask the user a question and wait for their answer. Use this if you need more information or clarification.', 'Ask the user a question and wait for their answer. Use this if you need more information or clarification.',
inputSchema: zod.object({ inputSchema: z.object({
question: zod.string(), question: z.string(),
}), }),
execute: async function (this: PageAgentCore, input) { execute: async function (this: PageAgentCore, input) {
if (!this.onAskUser) { if (!this.onAskUser) {
@@ -93,8 +84,8 @@ tools.set(
'click_element_by_index', 'click_element_by_index',
tool({ tool({
description: 'Click element by index', description: 'Click element by index',
inputSchema: zod.object({ inputSchema: z.object({
index: zod.int().min(0), index: z.int().min(0),
}), }),
execute: async function (this: PageAgentCore, input) { execute: async function (this: PageAgentCore, input) {
const result = await this.pageController.clickElement(input.index) const result = await this.pageController.clickElement(input.index)
@@ -106,10 +97,10 @@ 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: z.object({
index: zod.int().min(0), index: z.int().min(0),
text: zod.string(), text: z.string(),
}), }),
execute: async function (this: PageAgentCore, input) { execute: async function (this: PageAgentCore, input) {
const result = await this.pageController.inputText(input.index, input.text) const result = await this.pageController.inputText(input.index, input.text)
@@ -123,9 +114,9 @@ tools.set(
tool({ tool({
description: description:
'Select dropdown option for interactive element index by the text of the option you want to select', 'Select dropdown option for interactive element index by the text of the option you want to select',
inputSchema: zod.object({ inputSchema: z.object({
index: zod.int().min(0), index: z.int().min(0),
text: zod.string(), text: z.string(),
}), }),
execute: async function (this: PageAgentCore, input) { execute: async function (this: PageAgentCore, input) {
const result = await this.pageController.selectOption(input.index, input.text) const result = await this.pageController.selectOption(input.index, input.text)
@@ -140,13 +131,12 @@ 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: z.object({
inputSchema: zod.object({ down: z.boolean().default(true),
down: zod.boolean().default(true), num_pages: z.number().min(0).max(10).optional().default(0.1),
num_pages: zod.number().min(0).max(10).optional().default(0.1), pixels: z.number().int().min(0).optional(),
pixels: zod.number().int().min(0).optional(), index: z.number().int().min(0).optional(),
index: zod.number().int().min(0).optional(),
}), }),
execute: async function (this: PageAgentCore, input) { execute: async function (this: PageAgentCore, input) {
const result = await this.pageController.scroll({ const result = await this.pageController.scroll({
@@ -158,15 +148,18 @@ 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: z.object({
right: zod.boolean().default(true), right: z.boolean().default(true),
pixels: zod.number().int().min(0), pixels: z.number().int().min(0),
index: zod.number().int().min(0).optional(), index: z.number().int().min(0).optional(),
}), }),
execute: async function (this: PageAgentCore, input) { execute: async function (this: PageAgentCore, input) {
const result = await this.pageController.scrollHorizontally(input) const result = await this.pageController.scrollHorizontally(input)
@@ -180,8 +173,8 @@ tools.set(
tool({ tool({
description: description:
'Execute JavaScript code on the current page. Supports async/await syntax. Use with caution!', 'Execute JavaScript code on the current page. Supports async/await syntax. Use with caution!',
inputSchema: zod.object({ inputSchema: z.object({
script: zod.string(), script: z.string(),
}), }),
execute: async function (this: PageAgentCore, input) { execute: async function (this: PageAgentCore, input) {
const result = await this.pageController.executeJavascript(input.script) const result = await this.pageController.executeJavascript(input.script)
@@ -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
+162
View File
@@ -1,3 +1,165 @@
import type { LLMConfig } from '@page-agent/llms'
// @note circular dependency but okay
import type { PageAgentCore } from './PageAgentCore'
import type { PageAgentTool } from './tools'
/** Supported UI languages */
export type SupportedLanguage = 'en-US' | 'zh-CN'
export interface AgentConfig extends LLMConfig {
language?: SupportedLanguage
/**
* Maximum number of steps the agent can take per task.
* @default 40
*/
maxSteps?: number
/**
* Custom tools to extend PageAgent capabilities
* @experimental
* @note You can also override or remove internal tools by using the same name.
* @see PageAgentTool
*
* @example
* // override internal tool
* import { z } from 'zod/v4'
* import { tool } from 'page-agent'
* const customTools = {
* ask_user: tool({
* description:
* 'Ask the user or parent model a question and wait for their answer. Use this if you need more information or clarification.',
* inputSchema: z.object({
* question: z.string(),
* }),
* execute: async function (this: PageAgent, input) {
* const answer = await do_some_thing(input.question)
* return "✅ Received user answer: " + answer
* },
* })
* }
*
* @example
* // remove internal tool
* const customTools = {
* ask_user: null // never ask user questions
* }
*/
customTools?: Record<string, PageAgentTool | null>
/**
* Instructions to guide the agent's behavior
*/
instructions?: {
/**
* Global system-level instructions, applied to all tasks
*/
system?: string
/**
* Dynamic page-level instructions callback
* Called before each step to get instructions for the current page
* @param url - Current page URL (window.location.href)
* @returns Instructions string, or undefined/null to skip
*/
getPageInstructions?: (url: string) => string | undefined | null
}
/**
* Lifecycle hooks for task execution.
* @experimental API may change in future versions.
*
* All hooks receive the agent instance as first parameter.
*/
/**
* Called before each step execution.
* @experimental
* @param agent - The PageAgentCore instance
* @param stepCount - Current step number (0-indexed)
*/
onBeforeStep?: (agent: PageAgentCore, stepCount: number) => Promise<void> | void
/**
* Called after each step execution.
* @experimental
* @param agent - The PageAgentCore instance
* @param history - Current history of events
*/
onAfterStep?: (agent: PageAgentCore, history: HistoricalEvent[]) => Promise<void> | void
/**
* Called before task execution starts.
* @experimental
* @param agent - The PageAgentCore instance
*/
onBeforeTask?: (agent: PageAgentCore) => Promise<void> | void
/**
* Called after task execution completes (success or failure).
* @experimental
* @param agent - The PageAgentCore instance
* @param result - The execution result
*/
onAfterTask?: (agent: PageAgentCore, result: ExecutionResult) => Promise<void> | void
/**
* Called when the agent is disposed.
* @experimental
* @note This hook can block the disposal process if it's async.
* @param agent - The PageAgentCore instance
* @param reason - Optional reason for disposal
*/
onDispose?: (agent: PageAgentCore, reason?: string) => void
// page behavior hooks
/**
* @experimental
* Enable the experimental script execution tool that allows executing generated JavaScript code on the page.
* @note Can cause unpredictable side effects.
* @note May bypass some safe guards and data-masking mechanisms.
*/
experimentalScriptExecutionTool?: boolean
/**
* @experimental
* Fetch /llms.txt from current site origin and include as context.
* Only fetched once per origin per task.
* @default false
*/
experimentalLlmsTxt?: boolean
/**
* Transform page content before sending to LLM.
* Called after DOM extraction and simplification, before LLM invocation.
* Use cases: inspect extraction results, modify page info, mask sensitive data.
*
* @param content - Simplified page content that will be sent to LLM
* @returns Transformed content
*
* @example
* // Mask phone numbers
* transformPageContent: async (content) => {
* return content.replace(/1[3-9]\d{9}/g, '***********')
* }
*/
transformPageContent?: (content: string) => Promise<string> | string
/**
* Completely override the default system prompt.
* @experimental Use with caution - incorrect prompts may break agent behavior.
*/
customSystemPrompt?: string
/**
* Delay between steps in seconds.
* @default 0.4
*/
stepDelay?: number
}
/** /**
* Agent reflection state - the reflection-before-action model * Agent reflection state - the reflection-before-action model
* *
+65 -6
View File
@@ -1,4 +1,10 @@
import { InvokeError, InvokeErrorType } from '@page-agent/llms'
import chalk from 'chalk' import chalk from 'chalk'
import * as z from 'zod/v4'
import type { PageAgentTool } from '../tools'
const log = console.log.bind(console, chalk.yellow('[autoFixer]'))
/** /**
* Normalize LLM response and fix common format issues. * Normalize LLM response and fix common format issues.
@@ -9,9 +15,10 @@ import chalk from 'chalk'
* - Arguments wrapped as double JSON string * - Arguments wrapped as double JSON string
* - Nested function call format * - Nested function call format
* - Missing action field (fallback to wait) * - Missing action field (fallback to wait)
* - Primitive action input for single-field tools (e.g. `{"click_element_by_index": 2}`)
* - etc. * - etc.
*/ */
export function normalizeResponse(response: any): any { export function normalizeResponse(response: any, tools?: Map<string, PageAgentTool>): any {
let resolvedArguments = null as any let resolvedArguments = null as any
const choice = (response as { choices?: Choice[] }).choices?.[0] const choice = (response as { choices?: Choice[] }).choices?.[0]
@@ -29,7 +36,7 @@ export function normalizeResponse(response: any): any {
// case: sometimes the model only returns the action level // case: sometimes the model only returns the action level
if (toolCall.function.name && toolCall.function.name !== 'AgentOutput') { if (toolCall.function.name && toolCall.function.name !== 'AgentOutput') {
console.log(chalk.yellow(`[normalizeResponse] #1: fixing tool_call`)) log(`#1: fixing tool_call`)
resolvedArguments = { action: safeJsonParse(resolvedArguments) } resolvedArguments = { action: safeJsonParse(resolvedArguments) }
} }
} else { } else {
@@ -42,13 +49,13 @@ export function normalizeResponse(response: any): any {
// case: sometimes the content json includes upper level wrapper // case: sometimes the content json includes upper level wrapper
if (resolvedArguments?.name === 'AgentOutput') { if (resolvedArguments?.name === 'AgentOutput') {
console.log(chalk.yellow(`[normalizeResponse] #2: fixing tool_call`)) log(`#2: fixing tool_call`)
resolvedArguments = safeJsonParse(resolvedArguments.arguments) resolvedArguments = safeJsonParse(resolvedArguments.arguments)
} }
// case: sometimes even 2-levels of wrapping // case: sometimes even 2-levels of wrapping
if (resolvedArguments?.type === 'function') { if (resolvedArguments?.type === 'function') {
console.log(chalk.yellow(`[normalizeResponse] #3: fixing tool_call`)) log(`#3: fixing tool_call`)
resolvedArguments = safeJsonParse(resolvedArguments.function.arguments) resolvedArguments = safeJsonParse(resolvedArguments.function.arguments)
} }
@@ -61,7 +68,7 @@ export function normalizeResponse(response: any): any {
!resolvedArguments?.next_goal && !resolvedArguments?.next_goal &&
!resolvedArguments?.thinking !resolvedArguments?.thinking
) { ) {
console.log(chalk.yellow(`[normalizeResponse] #4: fixing tool_call`)) log(`#4: fixing tool_call`)
resolvedArguments = { action: safeJsonParse(resolvedArguments) } resolvedArguments = { action: safeJsonParse(resolvedArguments) }
} }
} else { } else {
@@ -78,9 +85,14 @@ export function normalizeResponse(response: any): any {
resolvedArguments.action = safeJsonParse(resolvedArguments.action) resolvedArguments.action = safeJsonParse(resolvedArguments.action)
} }
// validate and fix action input using tool schemas
if (resolvedArguments.action && tools) {
resolvedArguments.action = validateAction(resolvedArguments.action, tools)
}
// fix incomplete formats // fix incomplete formats
if (!resolvedArguments.action) { if (!resolvedArguments.action) {
console.log(chalk.yellow(`[normalizeResponse] #5: fixing tool_call`)) log(`#5: fixing tool_call`)
resolvedArguments.action = { name: 'wait', input: { seconds: 1 } } resolvedArguments.action = { name: 'wait', input: { seconds: 1 } }
} }
@@ -108,6 +120,53 @@ export function normalizeResponse(response: any): any {
} }
} }
/**
* Validate action against tool schemas. Provides clear error messages
* instead of letting the union schema produce unreadable errors.
*
* Also coerces primitive inputs for single-field tools:
* e.g. `{"click_element_by_index": 2}` → `{"click_element_by_index": {"index": 2}}`
*/
function validateAction(action: any, tools: Map<string, PageAgentTool>): any {
if (typeof action !== 'object' || action === null) return action
const toolName = Object.keys(action)[0]
if (!toolName) return action
const tool = tools.get(toolName)
if (!tool) {
const available = Array.from(tools.keys()).join(', ')
throw new InvokeError(
InvokeErrorType.INVALID_TOOL_ARGS,
`Unknown action "${toolName}". Available: ${available}`
)
}
let value = action[toolName]
const schema = tool.inputSchema
// coerce primitive input for single-field tools
if (schema instanceof z.ZodObject && value !== null && typeof value !== 'object') {
const requiredKey = Object.keys(schema.shape).find(
(k) => !(schema.shape as Record<string, z.ZodType>)[k].safeParse(undefined).success
)
if (requiredKey) {
log(`coercing primitive action input for "${toolName}"`)
value = { [requiredKey]: value }
}
}
const result = schema.safeParse(value)
if (!result.success) {
throw new InvokeError(
InvokeErrorType.INVALID_TOOL_ARGS,
`Invalid input for action "${toolName}": ${z.prettifyError(result.error)}`
)
}
return { [toolName]: result.data }
}
/** /**
* Safely parse JSON, return original input if not json. * Safely parse JSON, return original input if not json.
*/ */
+39 -33
View File
@@ -1,28 +1,6 @@
import chalk from 'chalk' import chalk from 'chalk'
export { normalizeResponse } from './autoFixer' export * 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))
@@ -39,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)
@@ -62,7 +31,7 @@ export function randomID(existingIDs?: string[]): string {
id = Math.random().toString(36).substring(2, 11) id = Math.random().toString(36).substring(2, 11)
tryCount++ tryCount++
if (tryCount > MAX_TRY) { if (tryCount > MAX_TRY) {
throw new Error('randomID: too many try') throw new Error('randomID: too many tries')
} }
} }
@@ -88,6 +57,43 @@ export function uid() {
return id return id
} }
const llmsTxtCache = new Map<string, string | null>()
/** Fetch /llms.txt for a URL's origin. Cached per origin, `null` = tried and not found. */
export async function fetchLlmsTxt(url: string): Promise<string | null> {
let origin: string
try {
origin = new URL(url).origin
} catch {
return null // Invalid URL
}
// about:blank, data:, file:
if (origin === 'null') return null
if (llmsTxtCache.has(origin)) return llmsTxtCache.get(origin)!
const endpoint = `${origin}/llms.txt`
let result: string | null = null
try {
console.log(chalk.gray(`[llms.txt] Fetching ${endpoint}`))
const res = await fetch(endpoint, { signal: AbortSignal.timeout(3000) })
if (res.ok) {
result = await res.text()
console.log(chalk.green(`[llms.txt] Found (${result.length} chars)`))
if (result.length > 1000) {
console.log(chalk.yellow(`[llms.txt] Truncating to 1000 chars`))
result = truncate(result, 1000)
}
} else {
console.debug(chalk.gray(`[llms.txt] ${res.status} for ${endpoint}`))
}
} catch (e) {
console.debug(chalk.gray(`[llms.txt] not found for ${endpoint}`), e)
}
llmsTxtCache.set(origin, result)
return result
}
/** /**
* Simple assertion function that throws an error if the condition is falsy * Simple assertion function that throws an error if the condition is falsy
* @param condition - The condition to assert * @param condition - The condition to assert
+1
View File
@@ -30,6 +30,7 @@ export default defineConfig({
external: [ external: [
'chalk', 'chalk',
'zod', 'zod',
'zod/v4',
// all the internal packages // all the internal packages
/^@page-agent\//, /^@page-agent\//,
], ],
+2 -54
View File
@@ -1,57 +1,5 @@
# Privacy Policy for Page Agent Extension # Privacy Policy for Page Agent Extension
**Last updated:** January 2026 This document has moved. Please see our full **[Terms of Use & Privacy](../../docs/terms-and-privacy.md)**.
## Overview Online: https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md
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
+2 -2
View File
@@ -5,7 +5,7 @@
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "", "config": "",
"css": "src/index.css", "css": "src/assets/index.css",
"baseColor": "neutral", "baseColor": "neutral",
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
@@ -16,7 +16,7 @@
"utils": "@/lib/utils", "utils": "@/lib/utils",
"ui": "@/components/ui", "ui": "@/components/ui",
"lib": "@/lib", "lib": "@/lib",
"hooks": "@/hooks" "hooks": "@/lib/hooks"
}, },
"registries": { "registries": {
"@magicui": "https://magicui.design/r/{name}.json" "@magicui": "https://magicui.design/r/{name}.json"
+43 -108
View File
@@ -1,12 +1,18 @@
# Page Agent Extension API # Page Agent Extension API
This document describes how to integrate the Page Agent browser extension into your web application. Integrate the Page Agent extension into your web app and trigger multi-page browser tasks from page JavaScript.
## Installation ## Installation
### 1. Install the browser extension ### 1. Install the browser extension
Install the Page Agent extension from the Chrome Web Store. 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) ### 2. Install type definitions (recommended)
@@ -14,11 +20,19 @@ Install the Page Agent extension from the Chrome Web Store.
npm install @page-agent/core --save-dev npm install @page-agent/core --save-dev
``` ```
### 3. Set up authentication ### 3. Authorization (Token)
The extension only injects APIs when it detects a valid token in `localStorage`. The token allows your page JS to call the extension API (`window.PAGE_AGENT_EXT`) and execute multi-page tasks.
1. Open the extension's side panel to get your authorization token 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: 2. Set the token in your page:
```typescript ```typescript
@@ -60,36 +74,36 @@ if (await waitForExtension()) {
## Global API ## Global API
The extension injects the following APIs into the `window` object: After token match, the extension injects APIs into `window`.
### `window.PAGE_AGENT_EXT_VERSION` ### `window.PAGE_AGENT_EXT_VERSION`
Extension version string (e.g., `"1.0.0"`). This is exposed separately to allow version checking before accessing the main API object. Extension version string (for capability checks before using the main API).
### `window.PAGE_AGENT_EXT` ### `window.PAGE_AGENT_EXT`
Main API namespace object containing: Main namespace object.
#### `PAGE_AGENT_EXT.execute(task, config)` #### `PAGE_AGENT_EXT.execute(task, config)`
Execute an agent task. Execute one agent task.
**Parameters:** Parameters:
| Name | Type | Required | Description | | Name | Type | Required | Description |
|------|------|----------|-------------| | ---- | ---- | -------- | ----------- |
| `task` | `string` | Yes | Task description | | `task` | `string` | Yes | Task description |
| `config` | `ExecuteConfig` | Yes | Execution configuration (LLM settings, options, and event callbacks) | | `config` | `ExecuteConfig` | Yes | LLM settings, options, and callbacks |
**Returns:** `Promise<ExecutionResult>` Returns: `Promise<ExecutionResult>`
#### `PAGE_AGENT_EXT.dispose()` #### `PAGE_AGENT_EXT.stop()`
Stop and destroy the current running agent. Stop the current task.
## Types ## Types
Install `@page-agent/core` for full type definitions: Install `@page-agent/core` for complete types:
```typescript ```typescript
import type { import type {
@@ -104,35 +118,24 @@ export interface ExecuteConfig {
apiKey: string apiKey: string
model: string model: string
/** // Include the initial tab where page JS starts. Default: true.
* Whether to include the initial tab (that holds this main world script) in the task.
* @default true
*/
includeInitialTab?: boolean includeInitialTab?: boolean
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
} }
export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult> export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
``` ```
### AgentStatus `AgentStatus`
```typescript ```typescript
type AgentStatus = 'idle' | 'running' | 'completed' | 'error' type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
``` ```
| Status | Description | `AgentActivity`
|--------|-------------|
| `idle` | Agent is idle, ready to execute |
| `running` | Agent is executing a task |
| `completed` | Task completed successfully |
| `error` | Task failed with an error |
### AgentActivity
```typescript ```typescript
type AgentActivity = type AgentActivity =
@@ -143,15 +146,7 @@ type AgentActivity =
| { type: 'error'; message: string } | { type: 'error'; message: string }
``` ```
| Type | Description | `HistoricalEvent`
|------|-------------|
| `thinking` | Agent is analyzing the page and planning |
| `executing` | Agent is executing a tool action |
| `executed` | Tool execution completed |
| `retrying` | Retrying after a failure |
| `error` | An error occurred |
### HistoricalEvent
```typescript ```typescript
type HistoricalEvent = type HistoricalEvent =
@@ -162,7 +157,7 @@ type HistoricalEvent =
| { type: 'error'; message: string; rawResponse?: unknown } | { type: 'error'; message: string; rawResponse?: unknown }
``` ```
### ExecutionResult `ExecutionResult`
```typescript ```typescript
interface ExecutionResult { interface ExecutionResult {
@@ -183,81 +178,22 @@ const result = await window.PAGE_AGENT_EXT!.execute(
baseURL: 'https://api.openai.com/v1', baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!, apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-5.2', model: 'gpt-5.2',
} includeInitialTab: false, // Optional: exclude current tab
) onStatusChange: (status) => console.log(status),
onActivity: (activity) => console.log(activity),
if (result.success) {
console.log('Task completed:', result.data)
} else {
console.error('Task failed')
}
```
### Exclude Initial Tab
By default, the agent includes the initial tab (where the script runs) in the task. Set `includeInitialTab: false` to exclude it:
```typescript
const result = await window.PAGE_AGENT_EXT!.execute(
'Open a new tab and search for page-agent on GitHub',
{
baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-5.2',
includeInitialTab: false, // Agent will open new tabs only
} }
) )
``` ```
### With Event Callbacks ### Stop the Current Task
```typescript ```typescript
await window.PAGE_AGENT_EXT!.execute('Navigate to the settings page', { window.PAGE_AGENT_EXT!.stop()
baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-5.2',
onStatusChange: (status) => {
updateUI({ agentStatus: status })
},
onActivity: (activity) => {
switch (activity.type) {
case 'thinking':
showSpinner('Agent is thinking...')
break
case 'executing':
showSpinner(`Executing: ${activity.tool}`)
break
case 'executed':
log(`${activity.tool} completed in ${activity.duration}ms`)
break
case 'error':
showError(activity.message)
break
}
},
onHistoryUpdate: (history) => {
renderHistory(history)
},
})
```
### Stop Execution
```typescript
// Start a task
window.PAGE_AGENT_EXT!.execute('Scroll through all pages', {
baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-5.2',
})
// Later, stop it
window.PAGE_AGENT_EXT!.dispose()
``` ```
## Window Type Declaration ## Window Type Declaration
If not using `@page-agent/core`, add this to your project: If you are not importing `@page-agent/core`, add:
```typescript ```typescript
import type { import type {
@@ -275,7 +211,6 @@ interface ExecuteConfig {
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
} }
declare global { declare global {
@@ -283,8 +218,8 @@ declare global {
PAGE_AGENT_EXT_VERSION?: string PAGE_AGENT_EXT_VERSION?: string
PAGE_AGENT_EXT?: { PAGE_AGENT_EXT?: {
version: string version: string
execute: (task: string, config: ExecuteConfig) => Promise<ExecutionResult> execute: Execute
dispose: () => void stop: () => void
} }
} }
} }
-291
View File
@@ -1,291 +0,0 @@
# Page Agent 浏览器插件 API
本文档介绍如何在网页应用中接入 Page Agent 浏览器插件。
## 安装
### 1. 安装浏览器插件
从 Chrome 应用商店安装 Page Agent 插件。
### 2. 安装类型定义(推荐)
```bash
npm install @page-agent/core --save-dev
```
### 3. 配置认证
插件在页面加载后检测 `localStorage` 中的 token,匹配时才会注入 API。
1. 打开插件的侧边栏面板,获取授权 token
2. 在页面中设置 token
```typescript
localStorage.setItem('PageAgentExtUserAuthToken', 'your-token')
```
## 快速开始
```typescript
import type {
AgentActivity,
AgentStatus,
ExecutionResult,
HistoricalEvent,
} from '@page-agent/core'
// 等待插件注入(最多 1 秒)
async function waitForExtension(timeout = 1000): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeout) {
if (window.PAGE_AGENT_EXT) return true
await new Promise((r) => setTimeout(r, 100))
}
return false
}
// 使用
if (await waitForExtension()) {
const result = await window.PAGE_AGENT_EXT!.execute('点击登录按钮', {
baseURL: 'https://api.openai.com/v1',
apiKey: 'your-api-key',
model: 'gpt-5.2',
onStatusChange: (status) => console.log('状态:', status),
onActivity: (activity) => console.log('活动:', activity),
})
console.log('结果:', result)
}
```
## 全局 API
插件在 `window` 对象上注入以下 API
### `window.PAGE_AGENT_EXT_VERSION`
插件版本号字符串(例如 `"1.0.0"`)。单独暴露版本号,方便在访问主 API 对象前进行版本检查。
### `window.PAGE_AGENT_EXT`
主 API 命名空间对象,包含:
#### `PAGE_AGENT_EXT.execute(task, config)`
执行 Agent 任务。
**参数:**
| 名称 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `task` | `string` | 是 | 任务描述 |
| `config` | `ExecuteConfig` | 是 | 执行配置(LLM 设置、选项和事件回调) |
**返回:** `Promise<ExecutionResult>`
#### `PAGE_AGENT_EXT.dispose()`
停止并销毁当前运行的 Agent。
## 类型定义
安装 `@page-agent/core` 获取完整类型:
```typescript
import type {
AgentActivity,
AgentStatus,
ExecutionResult,
HistoricalEvent,
} from '@page-agent/core'
export interface ExecuteConfig {
baseURL: string
apiKey: string
model: string
/**
* 是否将初始标签页(运行此脚本的页面)包含在任务中。
* @default true
*/
includeInitialTab?: boolean
onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
}
export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
```
### AgentStatus
```typescript
type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
```
| 状态 | 说明 |
|------|------|
| `idle` | 空闲,准备执行 |
| `running` | 正在执行任务 |
| `completed` | 任务成功完成 |
| `error` | 任务执行失败 |
### AgentActivity
```typescript
type AgentActivity =
| { type: 'thinking' }
| { type: 'executing'; tool: string; input: unknown }
| { type: 'executed'; tool: string; input: unknown; output: string; duration: number }
| { type: 'retrying'; attempt: number; maxAttempts: number }
| { type: 'error'; message: string }
```
| 类型 | 说明 |
|------|------|
| `thinking` | Agent 正在分析页面并规划 |
| `executing` | 正在执行工具操作 |
| `executed` | 工具执行完成 |
| `retrying` | 失败后重试 |
| `error` | 发生错误 |
### HistoricalEvent
```typescript
type HistoricalEvent =
| { type: 'step'; stepIndex: number; reflection: AgentReflection; action: Action }
| { type: 'observation'; content: string }
| { type: 'user_takeover' }
| { type: 'retry'; message: string; attempt: number; maxAttempts: number }
| { type: 'error'; message: string; rawResponse?: unknown }
```
### ExecutionResult
```typescript
interface ExecutionResult {
success: boolean
data: string
history: HistoricalEvent[]
}
```
## 使用示例
### 基础执行
```typescript
const result = await window.PAGE_AGENT_EXT!.execute(
'在邮箱输入框填入 test@example.com 然后点击提交',
{
baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-5.2',
}
)
if (result.success) {
console.log('任务完成:', result.data)
} else {
console.error('任务失败')
}
```
### 排除初始标签页
默认情况下,Agent 会将初始标签页(运行脚本的页面)包含在任务中。设置 `includeInitialTab: false` 可以排除它:
```typescript
const result = await window.PAGE_AGENT_EXT!.execute(
'打开新标签页并在 GitHub 上搜索 page-agent',
{
baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-5.2',
includeInitialTab: false, // Agent 只会打开新标签页
}
)
```
### 使用事件回调
```typescript
await window.PAGE_AGENT_EXT!.execute('导航到设置页面', {
baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-5.2',
onStatusChange: (status) => {
updateUI({ agentStatus: status })
},
onActivity: (activity) => {
switch (activity.type) {
case 'thinking':
showSpinner('Agent 正在思考...')
break
case 'executing':
showSpinner(`正在执行: ${activity.tool}`)
break
case 'executed':
log(`${activity.tool} 完成,耗时 ${activity.duration}ms`)
break
case 'error':
showError(activity.message)
break
}
},
onHistoryUpdate: (history) => {
renderHistory(history)
},
})
```
### 停止执行
```typescript
// 启动任务
window.PAGE_AGENT_EXT!.execute('滚动浏览所有页面', {
baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-5.2',
})
// 稍后停止
window.PAGE_AGENT_EXT!.dispose()
```
## Window 类型声明
如果不使用 `@page-agent/core`,可以添加以下声明:
```typescript
import type {
AgentActivity,
AgentStatus,
ExecutionResult,
HistoricalEvent,
} from '@page-agent/core'
interface ExecuteConfig {
baseURL: string
apiKey: string
model: string
includeInitialTab?: boolean
onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
}
declare global {
interface Window {
PAGE_AGENT_EXT_VERSION?: string
PAGE_AGENT_EXT?: {
version: string
execute: (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
dispose: () => void
}
}
}
```
+26 -22
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/ext", "name": "@page-agent/ext",
"private": true, "private": true,
"version": "0.1.3", "version": "1.5.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "wxt", "dev": "wxt",
@@ -10,23 +10,6 @@
"postinstall": "wxt prepare" "postinstall": "wxt prepare"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@types/chrome": "^0.1.34",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.1",
"@vitejs/plugin-react-swc": "^4.1.0",
"@wxt-dev/module-react": "^1.1.5",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0",
"wxt": "^0.20.13"
},
"dependencies": {
"@page-agent/core": "1.1.2",
"@page-agent/llms": "1.1.2",
"@page-agent/page-controller": "1.1.2",
"@page-agent/ui": "1.1.2",
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
@@ -34,14 +17,35 @@
"@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/chrome": "^0.1.37",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.1",
"@wxt-dev/module-react": "^1.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.563.0", "idb": "^8.0.3",
"motion": "^12.30.0", "lucide-react": "^0.577.0",
"motion": "^12.35.2",
"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.7.0", "simple-icons": "^16.11.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0",
"wxt": "^0.20.18"
},
"dependencies": {
"@page-agent/core": "1.5.8",
"@page-agent/llms": "1.5.8",
"@page-agent/page-controller": "1.5.8",
"@page-agent/ui": "1.5.8",
"ai-motion": "^0.4.8",
"chalk": "^5.6.2"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
} }
} }
@@ -0,0 +1,11 @@
{
"extName": {
"message": "Page Agent Ext"
},
"extDescription": {
"message": "AI-powered browser automation assistant. Control web pages with natural language."
},
"extActionTitle": {
"message": "Open Page Agent"
}
}
@@ -0,0 +1,11 @@
{
"extName": {
"message": "Page Agent Ext"
},
"extDescription": {
"message": "AI 驱动的浏览器自动化助手,用自然语言控制网页。"
},
"extActionTitle": {
"message": "打开 Page Agent"
}
}
@@ -1,4 +1,4 @@
import { type PageAgentConfig, PageAgentCore } from '@page-agent/core' import { type AgentConfig, PageAgentCore } from '@page-agent/core'
import { RemotePageController } from './RemotePageController' import { RemotePageController } from './RemotePageController'
import { TabsController } from './TabsController' import { TabsController } from './TabsController'
@@ -17,7 +17,7 @@ function detectLanguage(): 'en-US' | 'zh-CN' {
* - can be used from a side panel or a content script * - can be used from a side panel or a content script
*/ */
export class MultiPageAgent extends PageAgentCore { export class MultiPageAgent extends PageAgentCore {
constructor(config: Omit<PageAgentConfig, 'pageController'> & { includeInitialTab?: boolean }) { constructor(config: AgentConfig & { includeInitialTab?: boolean }) {
// multi page controller // multi page controller
const tabsController = new TabsController() const tabsController = new TabsController()
const pageController = new RemotePageController(tabsController) const pageController = new RemotePageController(tabsController)
@@ -74,6 +74,11 @@ export class MultiPageAgent extends PageAgentCore {
}) })
}, },
onBeforeStep: async (agent) => {
// make sure the current tab is loaded before the step starts
await tabsController.waitUntilTabLoaded(tabsController.currentTabId!)
},
onDispose: () => { onDispose: () => {
if (heartBeatInterval) { if (heartBeatInterval) {
window.clearInterval(heartBeatInterval) window.clearInterval(heartBeatInterval)
@@ -84,8 +89,7 @@ export class MultiPageAgent extends PageAgentCore {
isAgentRunning: false, isAgentRunning: false,
}) })
// no need to dispose tabsController and pageController tabsController.dispose()
// as they do not keep references
}, },
}) })
} }
@@ -8,13 +8,21 @@ export function handlePageControlMessage(
sender: chrome.runtime.MessageSender, sender: chrome.runtime.MessageSender,
sendResponse: (response: unknown) => void sendResponse: (response: unknown) => void
): true | undefined { ): true | undefined {
const PREFIX = '[RemotePageController.background]'
function debug(...messages: any[]) {
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
const { action, payload, targetTabId } = message const { action, payload, targetTabId } = message
if (action === 'get_my_tab_id') { if (action === 'get_my_tab_id') {
debug('get_my_tab_id', sender.tab?.id)
sendResponse({ tabId: sender.tab?.id || null }) sendResponse({ tabId: sender.tab?.id || null })
return return
} }
// proxy to content script
chrome.tabs chrome.tabs
.sendMessage(targetTabId, { .sendMessage(targetTabId, {
type: 'PAGE_CONTROL', type: 'PAGE_CONTROL',
@@ -25,6 +33,7 @@ export function handlePageControlMessage(
sendResponse(result) sendResponse(result)
}) })
.catch((error) => { .catch((error) => {
console.error(PREFIX, error)
sendResponse({ sendResponse({
success: false, success: false,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
@@ -12,10 +12,14 @@ export function initPageController() {
.then((response) => { .then((response) => {
return (response as { tabId: number | null }).tabId 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 { function getPC(): PageController {
if (!pageController) { if (!pageController) {
pageController = new PageController({ enableMask: false }) pageController = new PageController({ enableMask: false, viewportExpansion: 400 })
} }
return pageController return pageController
} }
@@ -2,13 +2,31 @@ import type { BrowserState } from '@page-agent/page-controller'
import type { TabsController } from './TabsController' 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. * Agent side page controller.
* - live in the agent env (extension page or content script) * - live in the agent env (extension page or content script)
* - communicates with remote PageController via sw * - communicates with remote PageController via sw
*/ */
export class RemotePageController { export class RemotePageController {
private tabsController: TabsController tabsController: TabsController
constructor(tabsController: TabsController) { constructor(tabsController: TabsController) {
this.tabsController = tabsController this.tabsController = tabsController
@@ -18,46 +36,46 @@ export class RemotePageController {
return this.tabsController.currentTabId return this.tabsController.currentTabId
} }
async getCurrentUrl(): Promise<string> { private async getCurrentUrl(): Promise<string> {
if (!this.currentTabId) return '' if (!this.currentTabId) return ''
const { url } = await this.tabsController.getTabInfo(this.currentTabId) const { url } = await this.tabsController.getTabInfo(this.currentTabId)
return url || '' return url || ''
} }
async getCurrentTitle(): Promise<string> { private async getCurrentTitle(): Promise<string> {
if (!this.currentTabId) return '' if (!this.currentTabId) return ''
const { title } = await this.tabsController.getTabInfo(this.currentTabId) const { title } = await this.tabsController.getTabInfo(this.currentTabId)
return title || '' return title || ''
} }
get currentTabTitle(): Promise<string> {
return this.getCurrentTitle()
}
async getLastUpdateTime(): Promise<number> { async getLastUpdateTime(): Promise<number> {
if (!this.currentTabId) throw new Error('tabsController not initialized.') if (!this.currentTabId) throw new Error('tabsController not initialized.')
return sendMessage({
return await chrome.runtime.sendMessage({
type: 'PAGE_CONTROL', type: 'PAGE_CONTROL',
action: 'get_last_update_time', action: 'get_last_update_time',
targetTabId: this.currentTabId, targetTabId: this.currentTabId,
}) })
} }
// getBrowserState
async getBrowserState(): Promise<BrowserState> { async getBrowserState(): Promise<BrowserState> {
let browserState = {} as BrowserState if (!this.currentTabId) throw new Error('tabsController not initialized.')
if (!this.currentTabId || !isContentScriptAllowed(await this.currentTabUrl)) { let browserState = {} as BrowserState
debug('getBrowserState', this.currentTabId)
const currentUrl = await this.getCurrentUrl()
const currentTitle = await this.getCurrentTitle()
if (!this.currentTabId || !isContentScriptAllowed(currentUrl)) {
browserState = { browserState = {
url: await this.currentTabUrl, url: currentUrl,
title: await this.currentTabTitle, title: currentTitle,
header: '', header: '',
content: '(empty page)', content: '(empty page. either current page is not readable or not loaded yet.)',
footer: '', footer: '',
} }
} else { } else {
browserState = await chrome.runtime.sendMessage({ browserState = await sendMessage({
type: 'PAGE_CONTROL', type: 'PAGE_CONTROL',
action: 'get_browser_state', action: 'get_browser_state',
targetTabId: this.currentTabId, targetTabId: this.currentTabId,
@@ -67,61 +85,58 @@ export class RemotePageController {
const sum = await this.tabsController.summarizeTabs() const sum = await this.tabsController.summarizeTabs()
browserState.header = sum + '\n\n' + (browserState.header || '') browserState.header = sum + '\n\n' + (browserState.header || '')
debug('getBrowserState: success', this.currentTabId, browserState)
return browserState return browserState
} }
// updateTree
async updateTree(): Promise<void> { async updateTree(): Promise<void> {
if (!this.currentTabId || !isContentScriptAllowed(await this.currentTabUrl)) { if (!this.currentTabId || !isContentScriptAllowed(await this.getCurrentUrl())) {
return return
} }
await chrome.runtime.sendMessage({ await sendMessage({
type: 'PAGE_CONTROL', type: 'PAGE_CONTROL',
action: 'update_tree', action: 'update_tree',
targetTabId: this.currentTabId, targetTabId: this.currentTabId,
}) })
} }
// cleanUpHighlights
async cleanUpHighlights(): Promise<void> { async cleanUpHighlights(): Promise<void> {
if (!this.currentTabId || !isContentScriptAllowed(await this.currentTabUrl)) { if (!this.currentTabId || !isContentScriptAllowed(await this.getCurrentUrl())) {
return return
} }
await chrome.runtime.sendMessage({ await sendMessage({
type: 'PAGE_CONTROL', type: 'PAGE_CONTROL',
action: 'clean_up_highlights', action: 'clean_up_highlights',
targetTabId: this.currentTabId, targetTabId: this.currentTabId,
}) })
} }
// clickElement
async clickElement(...args: any[]): Promise<DomActionReturn> { async clickElement(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('click_element', args) const res = await this.remoteCallDomAction('click_element', args)
// @note may cause page navigation, wait for 1 second to ensure the page loading started
await new Promise((resolve) => setTimeout(resolve, 1000))
return res
} }
// inputText
async inputText(...args: any[]): Promise<DomActionReturn> { async inputText(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('input_text', args) return this.remoteCallDomAction('input_text', args)
} }
// selectOption
async selectOption(...args: any[]): Promise<DomActionReturn> { async selectOption(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('select_option', args) return this.remoteCallDomAction('select_option', args)
} }
// scroll
async scroll(...args: any[]): Promise<DomActionReturn> { async scroll(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('scroll', args) return this.remoteCallDomAction('scroll', args)
} }
// scrollHorizontally
async scrollHorizontally(...args: any[]): Promise<DomActionReturn> { async scrollHorizontally(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('scroll_horizontally', args) return this.remoteCallDomAction('scroll_horizontally', args)
} }
// executeJavascript
async executeJavascript(...args: any[]): Promise<DomActionReturn> { async executeJavascript(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('execute_javascript', args) return this.remoteCallDomAction('execute_javascript', args)
} }
@@ -133,35 +148,26 @@ export class RemotePageController {
/** @note Managed by content script via storage polling. */ /** @note Managed by content script via storage polling. */
dispose(): void {} dispose(): void {}
private async preCheck() {
if (!this.currentTabId) {
return 'RemotePageController not initialized.'
}
if (!isContentScriptAllowed(await this.currentTabUrl)) {
return 'Operation not allowed on this page. Use open_new_tab to navigate to a web page first.'
}
return null
}
private async remoteCallDomAction(action: string, payload: any[]): Promise<DomActionReturn> { private async remoteCallDomAction(action: string, payload: any[]): Promise<DomActionReturn> {
const preCheckError = await this.preCheck() if (!this.currentTabId) {
if (preCheckError) { return { success: false, message: 'RemotePageController not initialized.' }
return { success: false, message: preCheckError }
} }
return await chrome.runtime.sendMessage({ if (!isContentScriptAllowed(await this.getCurrentUrl())) {
return {
success: false,
message:
'Operation not allowed on this page. Use open_new_tab to navigate to a web page first.',
}
}
return sendMessage({
type: 'PAGE_CONTROL', type: 'PAGE_CONTROL',
action: action, action: action,
targetTabId: this.currentTabId!, targetTabId: this.currentTabId!,
payload, payload,
}) })
} }
private get currentTabUrl(): Promise<string> {
return this.getCurrentUrl()
}
} }
interface DomActionReturn { interface DomActionReturn {
@@ -3,6 +3,12 @@
*/ */
import type { TabAction } from './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( export function handleTabControlMessage(
message: { type: 'TAB_CONTROL'; action: TabAction; payload: any }, message: { type: 'TAB_CONTROL'; action: TabAction; payload: any },
sender: chrome.runtime.MessageSender, sender: chrome.runtime.MessageSender,
@@ -12,10 +18,12 @@ export function handleTabControlMessage(
switch (action as TabAction) { switch (action as TabAction) {
case 'get_active_tab': { case 'get_active_tab': {
debug('get_active_tab')
chrome.tabs chrome.tabs
.query({ active: true, currentWindow: true }) .query({ active: true, currentWindow: true })
.then((tabs) => { .then((tabs) => {
const tabId = tabs.length > 0 ? tabs[0].id || null : null const tabId = tabs.length > 0 ? tabs[0].id || null : null
debug('get_active_tab: success', tabId)
sendResponse({ success: true, tabId }) sendResponse({ success: true, tabId })
}) })
.catch((error) => { .catch((error) => {
@@ -25,11 +33,12 @@ export function handleTabControlMessage(
} }
case 'get_tab_info': { case 'get_tab_info': {
debug('get_tab_info', payload)
chrome.tabs chrome.tabs
.get(payload.tabId) .get(payload.tabId)
.then((tab) => { .then((tab) => {
const result = { title: tab.title || '', url: tab.url || '' } debug('get_tab_info: success', tab)
sendResponse(result) sendResponse(tab)
}) })
.catch((error) => { .catch((error) => {
sendResponse({ error: error instanceof Error ? error.message : String(error) }) sendResponse({ error: error instanceof Error ? error.message : String(error) })
@@ -38,11 +47,12 @@ export function handleTabControlMessage(
} }
case 'open_new_tab': { case 'open_new_tab': {
debug('open_new_tab', payload)
chrome.tabs chrome.tabs
.create({ url: payload.url, active: false }) .create({ url: payload.url, active: false })
.then((newTab) => { .then((newTab) => {
// @todo: wait for tab to be fully loaded debug('open_new_tab: success', newTab)
sendResponse({ success: true, tabId: newTab.id, windowId: newTab.windowId }) sendResponse({ success: true, tabId: newTab.id })
}) })
.catch((error) => { .catch((error) => {
sendResponse({ error: error instanceof Error ? error.message : String(error) }) sendResponse({ error: error instanceof Error ? error.message : String(error) })
@@ -51,20 +61,22 @@ export function handleTabControlMessage(
} }
case 'create_tab_group': { case 'create_tab_group': {
debug('create_tab_group', payload)
chrome.tabs chrome.tabs
.group({ tabIds: payload.tabIds, createProperties: { windowId: payload.windowId } }) .group({ tabIds: payload.tabIds })
.then((groupId) => { .then((groupId) => {
console.log('Created tab group', groupId) debug('create_tab_group: success', groupId)
sendResponse({ success: true, groupId }) sendResponse({ success: true, groupId })
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to create tab group', error) console.error(PREFIX, 'Failed to create tab group', error)
sendResponse({ error: error instanceof Error ? error.message : String(error) }) sendResponse({ error: error instanceof Error ? error.message : String(error) })
}) })
return true // async response return true // async response
} }
case 'update_tab_group': { case 'update_tab_group': {
debug('update_tab_group', payload)
chrome.tabGroups chrome.tabGroups
.update(payload.groupId, payload.properties) .update(payload.groupId, payload.properties)
.then(() => { .then(() => {
@@ -77,6 +89,7 @@ export function handleTabControlMessage(
} }
case 'add_tab_to_group': { case 'add_tab_to_group': {
debug('add_tab_to_group', payload)
chrome.tabs chrome.tabs
.group({ tabIds: payload.tabId, groupId: payload.groupId }) .group({ tabIds: payload.tabId, groupId: payload.groupId })
.then(() => { .then(() => {
@@ -89,6 +102,7 @@ export function handleTabControlMessage(
} }
case 'close_tab': { case 'close_tab': {
debug('close_tab', payload)
chrome.tabs chrome.tabs
.remove(payload.tabId) .remove(payload.tabId)
.then(() => { .then(() => {
@@ -105,3 +119,42 @@ export function handleTabControlMessage(
return 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)
})
})
}
+150 -81
View File
@@ -1,3 +1,20 @@
const PREFIX = '[TabsController]'
function debug(...messages: any[]) {
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
function sendMessage(message: {
type: 'TAB_CONTROL'
action: TabAction
payload?: any
}): Promise<any> {
return chrome.runtime.sendMessage(message).catch((error) => {
console.error(PREFIX, message.action, error)
return null
})
}
/** /**
* Controller for managing browser tabs. * Controller for managing browser tabs.
* - live in the agent env (extension page or content script) * - live in the agent env (extension page or content script)
@@ -10,17 +27,17 @@ export class TabsController extends EventTarget {
private initialTabId: number | null = null private initialTabId: number | null = null
private tabGroupId: number | null = null private tabGroupId: number | null = null
private task: string = '' private task: string = ''
private windowId: number | null = null
async init(task: string, includeInitialTab: boolean = true) { async init(task: string, includeInitialTab: boolean = true) {
debug('init', task, includeInitialTab)
this.task = task this.task = task
this.tabs = [] this.tabs = []
this.currentTabId = null this.currentTabId = null
this.tabGroupId = null this.tabGroupId = null
this.initialTabId = null this.initialTabId = null
this.windowId = null
const result = await chrome.runtime.sendMessage({ const result = await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'get_active_tab', action: 'get_active_tab',
}) })
@@ -33,10 +50,22 @@ export class TabsController extends EventTarget {
if (includeInitialTab) { if (includeInitialTab) {
this.currentTabId = this.initialTabId this.currentTabId = this.initialTabId
const info = await sendMessage({
type: 'TAB_CONTROL',
action: 'get_tab_info',
payload: { tabId: this.initialTabId },
})
this.tabs.push({ this.tabs.push({
id: result.tabId, id: result.tabId,
isInitial: true, isInitial: true,
url: info.url,
title: info.title,
status: info.status,
}) })
await this.createTabGroup([this.initialTabId])
} }
await this.updateCurrentTabId(this.currentTabId) await this.updateCurrentTabId(this.currentTabId)
@@ -70,6 +99,14 @@ export class TabsController extends EventTarget {
} }
} }
} }
} else if (message.action === 'updated') {
const { tabId, tab } = message.payload as { tabId: number; tab: chrome.tabs.Tab }
const targetTab = this.tabs.find((t) => t.id === tabId)
if (targetTab) {
targetTab.url = tab.url
targetTab.title = tab.title
targetTab.status = tab.status
}
} }
} }
@@ -80,8 +117,10 @@ export class TabsController extends EventTarget {
}) })
} }
async openNewTab(url: string): Promise<{ success: boolean; tabId: number; message: string }> { async openNewTab(url: string): Promise<string> {
const result = await chrome.runtime.sendMessage({ debug('openNewTab', url)
const result = await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'open_new_tab', action: 'open_new_tab',
payload: { url }, payload: { url },
@@ -92,9 +131,6 @@ export class TabsController extends EventTarget {
} }
const tabId = result.tabId as number const tabId = result.tabId as number
const windowId = result.windowId as number
this.windowId = windowId
this.tabs.push({ this.tabs.push({
id: tabId, id: tabId,
@@ -104,84 +140,45 @@ export class TabsController extends EventTarget {
await this.switchToTab(tabId) await this.switchToTab(tabId)
if (!this.tabGroupId) { if (!this.tabGroupId) {
const result = await chrome.runtime.sendMessage({ await this.createTabGroup([tabId])
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 chrome.runtime.sendMessage({
type: 'TAB_CONTROL',
action: 'update_tab_group',
payload: {
groupId: this.tabGroupId,
properties: {
title: `PageAgent(${this.task})`,
color: randomColor(),
collapsed: false,
},
},
})
} else { } else {
await chrome.runtime.sendMessage({ await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'add_tab_to_group', action: 'add_tab_to_group',
payload: { tabId: result.tabId, groupId: this.tabGroupId }, payload: { tabId: result.tabId, groupId: this.tabGroupId },
}) })
} }
// wait for the new tab to be fully loaded await this.waitUntilTabLoaded(tabId)
// @todo
await new Promise((resolve) => setTimeout(resolve, 500))
return { return `✅ Opened new tab ID ${tabId} with URL ${url}`
success: true,
tabId,
message: `Opened new tab ID ${tabId} with URL ${url}`,
}
} }
async switchToTab(tabId: number): Promise<{ success: boolean; message: string }> { async switchToTab(tabId: number): Promise<string> {
debug('switchToTab', tabId)
const targetTab = this.tabs.find((t) => t.id === tabId) const targetTab = this.tabs.find((t) => t.id === tabId)
if (!targetTab) { if (!targetTab) {
return { throw new Error(`Tab ID ${tabId} not found in tab list.`)
success: false,
message: `Tab ID ${tabId} not found in tab list.`,
}
} }
await this.updateCurrentTabId(tabId) await this.updateCurrentTabId(tabId)
return { return `✅ Switched to tab ID ${tabId}.`
success: true,
message: `Switched to tab ID ${tabId}.`,
}
} }
async closeTab(tabId: number): Promise<{ success: boolean; message: string }> { async closeTab(tabId: number): Promise<string> {
debug('closeTab', tabId)
const targetTab = this.tabs.find((t) => t.id === tabId) const targetTab = this.tabs.find((t) => t.id === tabId)
if (!targetTab) { if (!targetTab) {
return { throw new Error(`Tab ID ${tabId} not found in tab list.`)
success: false,
message: `Tab ID ${tabId} not found in tab list.`,
}
} }
if (targetTab.isInitial) { if (targetTab.isInitial) {
return { throw new Error(`Cannot close the initial tab ID ${tabId}.`)
success: false,
message: `Cannot close the initial tab ID ${tabId}.`,
}
} }
const result = await chrome.runtime.sendMessage({ const result = await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'close_tab', action: 'close_tab',
payload: { tabId }, payload: { tabId },
@@ -198,29 +195,66 @@ export class TabsController extends EventTarget {
} }
} }
return { return `✅ Closed tab ID ${tabId}.`
success: true,
message: `Closed tab ID ${tabId}.`,
}
} else { } else {
return { throw new Error(`Failed to close tab ID ${tabId}: ${result.error}`)
success: false,
message: `Failed to close tab ID ${tabId}: ${result.error}`,
}
} }
} }
private async createTabGroup(tabIds: number[]) {
const result = await sendMessage({
type: 'TAB_CONTROL',
action: 'create_tab_group',
payload: { tabIds },
})
if (!result?.success) {
throw new Error(`Failed to create tab group: ${result?.error}`)
}
this.tabGroupId = result.groupId as number
await sendMessage({
type: 'TAB_CONTROL',
action: 'update_tab_group',
payload: {
groupId: this.tabGroupId,
properties: {
title: `PageAgent(${this.task})`,
color: randomColor(),
collapsed: false,
},
},
})
}
async updateCurrentTabId(tabId: number | null) { async updateCurrentTabId(tabId: number | null) {
debug('updateCurrentTabId', tabId)
this.currentTabId = tabId this.currentTabId = tabId
await chrome.storage.local.set({ currentTabId: tabId }) await chrome.storage.local.set({ currentTabId: tabId })
} }
async getTabInfo(tabId: number): Promise<{ title: string; url: string }> { async getTabInfo(tabId: number): Promise<{ title: string; url: string }> {
const result = await chrome.runtime.sendMessage({ // use cached tab info if available
const tabMeta = this.tabs.find((t) => t.id === tabId)
if (tabMeta && tabMeta.url && tabMeta.title) {
return { title: tabMeta.title, url: tabMeta.url }
}
// otherwise, pull the latest tab info from the background script
debug('getTabInfo: pulling from background script', tabId)
const result = await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'get_tab_info', action: 'get_tab_info',
payload: { tabId }, payload: { tabId },
}) })
if (tabMeta) {
tabMeta.url = result.url
tabMeta.title = result.title
}
return result return result
} }
@@ -239,6 +273,17 @@ export class TabsController extends EventTarget {
return summaries.join('\n') 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() { dispose() {
this.dispatchEvent(new Event('dispose')) this.dispatchEvent(new Event('dispose'))
} }
@@ -257,21 +302,45 @@ export type TabAction =
interface TabMeta { interface TabMeta {
id: number id: number
isInitial: boolean isInitial: boolean
url?: string
title?: string
status?: 'loading' | 'unloaded' | 'complete'
} }
const TAB_GROUP_COLORS = [ const TAB_GROUP_COLORS = ['blue', 'red', 'yellow', 'green', 'pink', 'purple', 'cyan'] as const
'grey',
'blue',
'red',
'yellow',
'green',
'pink',
'purple',
'cyan',
] as const
type TabGroupColor = (typeof TAB_GROUP_COLORS)[number] type TabGroupColor = (typeof TAB_GROUP_COLORS)[number]
function randomColor(): TabGroupColor { function randomColor(): TabGroupColor {
return TAB_GROUP_COLORS[Math.floor(Math.random() * TAB_GROUP_COLORS.length)] 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)
})
}
+21 -4
View File
@@ -1,13 +1,30 @@
import type { LLMConfig } from '@page-agent/llms' import type { LLMConfig } from '@page-agent/llms'
// Demo LLM for testing // Demo LLM for testing
export const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM' export const DEMO_MODEL = 'qwen3.5-plus'
export const DEMO_BASE_URL = export const DEMO_BASE_URL = 'https://page-ag-testing-ohftxirgbn.cn-shanghai.fcapp.run'
'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy' export const DEMO_API_KEY = 'NA'
export const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
export const DEMO_CONFIG: LLMConfig = { export const DEMO_CONFIG: LLMConfig = {
apiKey: DEMO_API_KEY, apiKey: DEMO_API_KEY,
baseURL: DEMO_BASE_URL, baseURL: DEMO_BASE_URL,
model: DEMO_MODEL, model: DEMO_MODEL,
} }
/** Legacy testing endpoints that should be auto-migrated to DEMO_BASE_URL */
export const LEGACY_TESTING_ENDPOINTS = [
'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy',
]
export function isTestingEndpoint(url: string): boolean {
const normalized = url.replace(/\/+$/, '')
return normalized === DEMO_BASE_URL || LEGACY_TESTING_ENDPOINTS.some((ep) => normalized === ep)
}
export function migrateLegacyEndpoint(config: LLMConfig): LLMConfig {
const normalized = config.baseURL.replace(/\/+$/, '')
if (LEGACY_TESTING_ENDPOINTS.some((ep) => normalized === ep)) {
return { ...DEMO_CONFIG }
}
return config
}
@@ -71,7 +71,7 @@ Strictly follow these rules while using the browser and navigating the web:
- 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. - 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). - You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
- All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed. - All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed.
- If a captcha appears, tell user you can not solve captcha. finished the task and ask user to solve it. - If a captcha appears, tell user you can not solve captcha. Finish the task and ask user to solve it.
- If expected elements are missing, try scrolling, or navigating back. - If expected elements are missing, try scrolling, or navigating back.
- If the page is not fully loaded, use the `wait` action. - If the page is not fully loaded, use the `wait` action.
- Do not repeat one action for more than 3 times unless some conditions changed. - Do not repeat one action for more than 3 times unless some conditions changed.
@@ -120,7 +120,6 @@ Exhibit the following reasoning patterns to successfully achieve the <user_reque
Here are examples of good output patterns. Use them as reference but never copy them directly. 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>
@@ -131,7 +130,6 @@ 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>
+23 -12
View File
@@ -6,14 +6,14 @@
* - switch_to_tab: Switch to an existing tab * - switch_to_tab: Switch to an existing tab
* - close_tab: Close a tab (optionally switch to another) * - close_tab: Close a tab (optionally switch to another)
*/ */
import * as zod from 'zod' import * as z from 'zod/v4'
import type { TabsController } from './TabsController' import type { TabsController } from './TabsController'
/** Tool definition compatible with PageAgentCore customTools */ /** Tool definition compatible with PageAgentCore customTools */
interface TabTool { interface TabTool {
description: string description: string
inputSchema: zod.ZodType inputSchema: z.ZodType
execute: (input: unknown) => Promise<string> execute: (input: unknown) => Promise<string>
} }
@@ -26,37 +26,48 @@ export function createTabTools(tabsController: TabsController): Record<string, T
open_new_tab: { open_new_tab: {
description: description:
'Open a new browser tab with the specified URL. The new tab becomes the current tab for all subsequent page operations.', 'Open a new browser tab with the specified URL. The new tab becomes the current tab for all subsequent page operations.',
inputSchema: zod.object({ inputSchema: z.object({
url: zod.string().describe('The URL to open in the new tab'), url: z.string().describe('The URL to open in the new tab'),
}), }),
execute: async (input: unknown) => { execute: async (input: unknown) => {
const { url } = input as { url: string } const { url } = input as { url: string }
const result = await tabsController.openNewTab(url) try {
return result.message return await tabsController.openNewTab(url)
} catch (error) {
return `❌ Failed: ${error instanceof Error ? error.message : String(error)}`
}
}, },
}, },
switch_to_tab: { switch_to_tab: {
description: 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.', '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({ inputSchema: z.object({
tab_id: zod.number().int().describe('The tab ID to switch to'), tab_id: z.number().int().describe('The tab ID to switch to'),
}), }),
execute: async (input: unknown) => { execute: async (input: unknown) => {
const { tab_id } = input as { tab_id: number } const { tab_id } = input as { tab_id: number }
return (await tabsController.switchToTab(tab_id)).message try {
return await tabsController.switchToTab(tab_id)
} catch (error) {
return `❌ Failed: ${error instanceof Error ? error.message : String(error)}`
}
}, },
}, },
close_tab: { close_tab: {
description: description:
'Close a tab by its ID. Cannot close the initial tab. Optionally specify which tab to switch to after closing.', 'Close a tab by its ID. Cannot close the initial tab. Optionally specify which tab to switch to after closing.',
inputSchema: zod.object({ inputSchema: z.object({
tab_id: zod.number().int().describe('The tab ID to close'), tab_id: z.number().int().describe('The tab ID to close'),
}), }),
execute: async (input: unknown) => { execute: async (input: unknown) => {
const { tab_id } = input as { tab_id: number } const { tab_id } = input as { tab_id: number }
return (await tabsController.closeTab(tab_id)).message try {
return await tabsController.closeTab(tab_id)
} catch (error) {
return `❌ Failed: ${error instanceof Error ? error.message : String(error)}`
}
}, },
}, },
} }
+63 -17
View File
@@ -1,22 +1,40 @@
/** /**
* React hook for using AgentController * React hook for using AgentController
*/ */
import type { AgentActivity, AgentStatus, HistoricalEvent } from '@page-agent/core' import type {
AgentActivity,
AgentStatus,
HistoricalEvent,
SupportedLanguage,
} from '@page-agent/core'
import type { LLMConfig } from '@page-agent/llms' import type { LLMConfig } from '@page-agent/llms'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { MultiPageAgent } from './MultiPageAgent' import { MultiPageAgent } from './MultiPageAgent'
import { DEMO_CONFIG } from './constants' import { DEMO_CONFIG, migrateLegacyEndpoint } from './constants'
/** Language preference: undefined means follow system */
export type LanguagePreference = SupportedLanguage | undefined
export interface AdvancedConfig {
maxSteps?: number
systemInstruction?: string
experimentalLlmsTxt?: boolean
}
export interface ExtConfig extends LLMConfig, AdvancedConfig {
language?: LanguagePreference
}
export interface UseAgentResult { export interface UseAgentResult {
status: AgentStatus status: AgentStatus
history: HistoricalEvent[] history: HistoricalEvent[]
activity: AgentActivity | null activity: AgentActivity | null
currentTask: string currentTask: string
config: LLMConfig | null config: ExtConfig | null
execute: (task: string) => Promise<void> execute: (task: string) => Promise<void>
stop: () => void stop: () => void
configure: (config: LLMConfig) => Promise<void> configure: (config: ExtConfig) => Promise<void>
} }
export function useAgent(): UseAgentResult { export function useAgent(): UseAgentResult {
@@ -25,23 +43,35 @@ export function useAgent(): UseAgentResult {
const [history, setHistory] = useState<HistoricalEvent[]>([]) const [history, setHistory] = useState<HistoricalEvent[]>([])
const [activity, setActivity] = useState<AgentActivity | null>(null) const [activity, setActivity] = useState<AgentActivity | null>(null)
const [currentTask, setCurrentTask] = useState('') const [currentTask, setCurrentTask] = useState('')
const [config, setConfig] = useState<LLMConfig | null>(null) const [config, setConfig] = useState<ExtConfig | null>(null)
useEffect(() => { useEffect(() => {
chrome.storage.local.get('llmConfig').then((result) => { chrome.storage.local.get(['llmConfig', 'language', 'advancedConfig']).then((result) => {
if (result.llmConfig) { let llmConfig = (result.llmConfig as LLMConfig) ?? DEMO_CONFIG
setConfig(result.llmConfig as LLMConfig) const language = (result.language as SupportedLanguage) || undefined
} else { const advancedConfig = (result.advancedConfig as AdvancedConfig) ?? {}
// Auto-migrate legacy testing endpoints
const migrated = migrateLegacyEndpoint(llmConfig)
if (migrated !== llmConfig) {
llmConfig = migrated
chrome.storage.local.set({ llmConfig: migrated })
} else if (!result.llmConfig) {
chrome.storage.local.set({ llmConfig: DEMO_CONFIG }) chrome.storage.local.set({ llmConfig: DEMO_CONFIG })
setConfig(DEMO_CONFIG)
} }
setConfig({ ...llmConfig, ...advancedConfig, language })
}) })
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!config) return if (!config) return
const agent = new MultiPageAgent(config) const { systemInstruction, ...agentConfig } = config
const agent = new MultiPageAgent({
...agentConfig,
instructions: systemInstruction ? { system: systemInstruction } : undefined,
})
agentRef.current = agent agentRef.current = agent
const handleStatusChange = (e: Event) => { const handleStatusChange = (e: Event) => {
@@ -75,7 +105,7 @@ export function useAgent(): UseAgentResult {
const execute = useCallback(async (task: string) => { const execute = useCallback(async (task: string) => {
const agent = agentRef.current const agent = agentRef.current
console.log('Fire task:', task, agent) console.log('🚀 [useAgent] start executing task:', task)
if (!agent) throw new Error('Agent not initialized') if (!agent) throw new Error('Agent not initialized')
setCurrentTask(task) setCurrentTask(task)
@@ -84,13 +114,29 @@ export function useAgent(): UseAgentResult {
}, []) }, [])
const stop = useCallback(() => { const stop = useCallback(() => {
agentRef.current?.dispose() agentRef.current?.stop()
}, []) }, [])
const configure = useCallback(async (newConfig: LLMConfig) => { const configure = useCallback(
await chrome.storage.local.set({ llmConfig: newConfig }) async ({
setConfig(newConfig) language,
}, []) maxSteps,
systemInstruction,
experimentalLlmsTxt,
...llmConfig
}: ExtConfig) => {
await chrome.storage.local.set({ llmConfig })
if (language) {
await chrome.storage.local.set({ language })
} else {
await chrome.storage.local.remove('language')
}
const advancedConfig: AdvancedConfig = { maxSteps, systemInstruction, experimentalLlmsTxt }
await chrome.storage.local.set({ advancedConfig })
setConfig({ ...llmConfig, ...advancedConfig, language })
},
[]
)
return { return {
status, status,
+35
View File
@@ -111,6 +111,41 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--animate-blink-cursor: blink-cursor 1.2s step-end infinite;
@keyframes blink-cursor {
0%,
49% {
opacity: 1;
}
50%,
100% {
opacity: 0;
}
}
}
@keyframes glow-a {
0%,
100% {
opacity: 0.45;
transform: scale(1);
}
50% {
opacity: 0;
transform: scale(1.1);
}
}
@keyframes glow-b {
0%,
100% {
opacity: 0;
transform: scale(1.1);
}
50% {
opacity: 0.45;
transform: scale(1);
}
} }
@layer base { @layer base {
@@ -21,8 +21,9 @@ function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
'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-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', 'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
// Focus state. // Focus state — soft multi-color glow matching ai-motion palette
'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]', 'has-[[data-slot=input-group-control]:focus-visible]:border-blue-400/60',
'has-[[data-slot=input-group-control]:focus-visible]:shadow-[0_0_0_1px_rgba(57,182,255,0.2),0_0_8px_rgba(57,182,255,0.15),0_0_16px_rgba(189,69,251,0.1)]',
// Error state. // 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', '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',
@@ -0,0 +1,164 @@
import { MotionProps, motion, useInView } from 'motion/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
interface TypingAnimationProps extends MotionProps {
children?: string
words?: string[]
className?: string
duration?: number
typeSpeed?: number
deleteSpeed?: number
delay?: number
pauseDelay?: number
loop?: boolean
as?: React.ElementType
startOnView?: boolean
showCursor?: boolean
blinkCursor?: boolean
cursorStyle?: 'line' | 'block' | 'underscore'
}
export function TypingAnimation({
children,
words,
className,
duration = 100,
typeSpeed,
deleteSpeed,
delay = 0,
pauseDelay = 1000,
loop = false,
as: Component = 'span',
startOnView = true,
showCursor = true,
blinkCursor = true,
cursorStyle = 'line',
...props
}: TypingAnimationProps) {
const MotionComponent = motion.create(Component, {
forwardMotionProps: true,
})
const [displayedText, setDisplayedText] = useState<string>('')
const [currentWordIndex, setCurrentWordIndex] = useState(0)
const [currentCharIndex, setCurrentCharIndex] = useState(0)
const [phase, setPhase] = useState<'typing' | 'pause' | 'deleting'>('typing')
const elementRef = useRef<HTMLElement | null>(null)
const isInView = useInView(elementRef as React.RefObject<Element>, {
amount: 0.3,
once: true,
})
const wordsToAnimate = useMemo(() => words || (children ? [children] : []), [words, children])
const hasMultipleWords = wordsToAnimate.length > 1
const typingSpeed = typeSpeed || duration
const deletingSpeed = deleteSpeed || typingSpeed / 2
const shouldStart = startOnView ? isInView : true
useEffect(() => {
if (!shouldStart || wordsToAnimate.length === 0) return
const timeoutDelay =
delay > 0 && displayedText === ''
? delay
: phase === 'typing'
? typingSpeed
: phase === 'deleting'
? deletingSpeed
: pauseDelay
const timeout = setTimeout(() => {
const currentWord = wordsToAnimate[currentWordIndex] || ''
const graphemes = Array.from(currentWord)
switch (phase) {
case 'typing':
if (currentCharIndex < graphemes.length) {
setDisplayedText(graphemes.slice(0, currentCharIndex + 1).join(''))
setCurrentCharIndex(currentCharIndex + 1)
} else {
if (hasMultipleWords || loop) {
const isLastWord = currentWordIndex === wordsToAnimate.length - 1
if (!isLastWord || loop) {
setPhase('pause')
}
}
}
break
case 'pause':
setPhase('deleting')
break
case 'deleting':
if (currentCharIndex > 0) {
setDisplayedText(graphemes.slice(0, currentCharIndex - 1).join(''))
setCurrentCharIndex(currentCharIndex - 1)
} else {
const nextIndex = (currentWordIndex + 1) % wordsToAnimate.length
setCurrentWordIndex(nextIndex)
setPhase('typing')
}
break
}
}, timeoutDelay)
return () => clearTimeout(timeout)
}, [
shouldStart,
phase,
currentCharIndex,
currentWordIndex,
displayedText,
wordsToAnimate,
hasMultipleWords,
loop,
typingSpeed,
deletingSpeed,
pauseDelay,
delay,
])
const currentWordGraphemes = Array.from(wordsToAnimate[currentWordIndex] || '')
const isComplete =
!loop &&
currentWordIndex === wordsToAnimate.length - 1 &&
currentCharIndex >= currentWordGraphemes.length &&
phase !== 'deleting'
const shouldShowCursor =
showCursor &&
!isComplete &&
(hasMultipleWords || loop || currentCharIndex < currentWordGraphemes.length)
const getCursorChar = () => {
switch (cursorStyle) {
case 'block':
return '▌'
case 'underscore':
return '_'
case 'line':
default:
return '|'
}
}
return (
<MotionComponent
ref={elementRef}
className={cn('leading-[5rem] tracking-[-0.02em]', className)}
{...props}
>
{displayedText}
{shouldShowCursor && (
<span className={cn('inline-block', blinkCursor && 'animate-blink-cursor')}>
{getCursorChar()}
</span>
)}
</MotionComponent>
)
}
@@ -1,24 +1,13 @@
import { handlePageControlMessage } from '@/agent/RemotePageController.background' import { handlePageControlMessage } from '@/agent/RemotePageController.background'
import { handleTabControlMessage } from '@/agent/TabsController.background' import { handleTabControlMessage, setupTabChangeEvents } from '@/agent/TabsController.background'
chrome.runtime.onMessage.addListener((message, sender, sendResponse): true | undefined => {
if (message.type === 'TAB_CONTROL') {
return handleTabControlMessage(message, sender, sendResponse)
} else if (message.type === 'PAGE_CONTROL') {
return handlePageControlMessage(message, sender, sendResponse)
} else {
sendResponse({ error: 'Unknown message type' })
return
}
})
// ============================================================================
// Extension Setup
// ============================================================================
export default defineBackground(() => { export default defineBackground(() => {
console.log('[Background] Service Worker started') console.log('[Background] Service Worker started')
// tab change events
setupTabChangeEvents()
// generate user auth token // generate user auth token
chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => { chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => {
@@ -28,21 +17,20 @@ export default defineBackground(() => {
chrome.storage.local.set({ PageAgentExtUserAuthToken: userAuthToken }) 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 // setup
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {}) chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
// Tab change events
chrome.tabs.onCreated.addListener((tab) => {
chrome.runtime.sendMessage({ type: 'TAB_CHANGE', action: 'created', payload: { tab } })
})
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
chrome.runtime.sendMessage({
type: 'TAB_CHANGE',
action: 'removed',
payload: { tabId, removeInfo },
})
})
}) })
+5 -16
View File
@@ -6,7 +6,7 @@ const DEBUG_PREFIX = '[Content]'
export default defineContentScript({ export default defineContentScript({
matches: ['<all_urls>'], matches: ['<all_urls>'],
runAt: 'document_idle', runAt: 'document_end',
main() { main() {
console.debug(`${DEBUG_PREFIX} Loaded on ${window.location.href}`) console.debug(`${DEBUG_PREFIX} Loaded on ${window.location.href}`)
@@ -71,7 +71,8 @@ async function exposeAgentToPage() {
try { try {
const { task, config } = payload const { task, config } = payload
// create when used // Dispose old instance before creating new one
multiPageAgent?.dispose()
multiPageAgent = new MultiPageAgent(config) multiPageAgent = new MultiPageAgent(config)
@@ -116,17 +117,6 @@ async function exposeAgentToPage() {
) )
}) })
multiPageAgent.addEventListener('dispose', () => {
window.postMessage(
{
channel: 'PAGE_AGENT_EXT_RESPONSE',
id,
action: 'dispose_event',
},
'*'
)
})
// result // result
const result = await multiPageAgent.execute(task) const result = await multiPageAgent.execute(task)
@@ -155,9 +145,8 @@ async function exposeAgentToPage() {
break break
} }
case 'dispose': { case 'stop': {
// @note stop ongoing processes but can still be re-used later multiPageAgent?.stop()
multiPageAgent?.dispose()
break break
} }
@@ -16,7 +16,6 @@ export interface ExecuteConfig {
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
} }
export default defineUnlistedScript(() => { export default defineUnlistedScript(() => {
@@ -60,12 +59,6 @@ export default defineUnlistedScript(() => {
return return
} }
if (data.action === 'dispose_event' && config.onDispose) {
config.onDispose()
window.removeEventListener('message', handleMessage)
return
}
if (data.action !== 'execute_result') return if (data.action !== 'execute_result') return
// execute_result // execute_result
@@ -104,23 +97,23 @@ export default defineUnlistedScript(() => {
return promise return promise
} }
const dispose = () => { const stop = () => {
const id = getId() const id = getId()
window.postMessage( window.postMessage(
{ {
channel: 'PAGE_AGENT_EXT_REQUEST', channel: 'PAGE_AGENT_EXT_REQUEST',
id, id,
action: 'dispose', action: 'stop',
}, },
'*' '*'
) )
} }
;(window as any).PAGE_AGENT_EXT_VERSION = __EXT_VERSION__ ;(window as any).PAGE_AGENT_EXT_VERSION = __VERSION__
;(window as any).PAGE_AGENT_EXT = { ;(window as any).PAGE_AGENT_EXT = {
version: __EXT_VERSION__, version: __VERSION__,
execute, execute,
dispose, stop,
} }
}) })
@@ -1,4 +1,4 @@
import { Send, Settings, Square } from 'lucide-react' import { History, Send, Settings, Square } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -8,20 +8,47 @@ import {
InputGroupButton, InputGroupButton,
InputGroupTextarea, InputGroupTextarea,
} from '@/components/ui/input-group' } from '@/components/ui/input-group'
import { saveSession } from '@/lib/db'
import { useAgent } from '../../agent/useAgent' import { useAgent } from '../../agent/useAgent'
import { ConfigPanel } from './components/ConfigPanel' import { ConfigPanel } from './components/ConfigPanel'
import { HistoryDetail } from './components/HistoryDetail'
import { HistoryList } from './components/HistoryList'
import { ActivityCard, EventCard } from './components/cards' import { ActivityCard, EventCard } from './components/cards'
import { EmptyState, Logo, StatusDot } from './components/misc' import { EmptyState, Logo, MotionOverlay, StatusDot } from './components/misc'
type View =
| { name: 'chat' }
| { name: 'config' }
| { name: 'history' }
| { name: 'history-detail'; sessionId: string }
export default function App() { export default function App() {
const [showConfig, setShowConfig] = useState(false) const [view, setView] = useState<View>({ name: 'chat' })
const [inputValue, setInputValue] = useState('') const [inputValue, setInputValue] = useState('')
const historyRef = useRef<HTMLDivElement>(null) const historyRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const { status, history, activity, currentTask, config, execute, stop, configure } = useAgent() 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 // Auto-scroll to bottom on new events
useEffect(() => { useEffect(() => {
if (historyRef.current) { if (historyRef.current) {
@@ -56,36 +83,62 @@ export default function App() {
} }
} }
if (showConfig) { // --- View routing ---
if (view.name === 'config') {
return ( return (
<ConfigPanel <ConfigPanel
config={config} config={config}
onSave={async (newConfig) => { onSave={async (newConfig) => {
await configure(newConfig) await configure(newConfig)
setShowConfig(false) setView({ name: 'chat' })
}} }}
onClose={() => setShowConfig(false)} onClose={() => setView({ name: 'chat' })}
/> />
) )
} }
if (view.name === 'history') {
return (
<HistoryList
onSelect={(id) => setView({ name: 'history-detail', sessionId: id })}
onBack={() => setView({ name: 'chat' })}
/>
)
}
if (view.name === 'history-detail') {
return <HistoryDetail sessionId={view.sessionId} onBack={() => setView({ name: 'history' })} />
}
// --- Chat view ---
const isRunning = status === 'running' const isRunning = status === 'running'
const showEmptyState = !currentTask && history.length === 0 && !isRunning const showEmptyState = !currentTask && history.length === 0 && !isRunning
return ( return (
<div className="flex flex-col h-screen bg-background"> <div className="relative flex flex-col h-screen bg-background">
<MotionOverlay active={isRunning} />
{/* Header */} {/* Header */}
<header className="flex items-center justify-between border-b px-3 py-2"> <header className="flex items-center justify-between border-b px-3 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Logo className="size-5" /> <Logo className="size-5" />
<span className="text-sm font-medium">Page Agent Ext</span> <span className="text-sm font-medium">Page Agent Ext</span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-1">
<StatusDot status={status} /> <StatusDot status={status} />
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
onClick={() => setShowConfig(true)} 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" className="cursor-pointer"
> >
<Settings className="size-3.5" /> <Settings className="size-3.5" />
@@ -1,15 +1,26 @@
import type { LLMConfig } from '@page-agent/llms' import {
import { Copy, CornerUpLeft, Eye, EyeOff, HatGlasses, Home, Loader2, Scale } from 'lucide-react' ChevronDown,
Copy,
CornerUpLeft,
Eye,
EyeOff,
HatGlasses,
Home,
Loader2,
Scale,
} from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { siGithub } from 'simple-icons' import { siGithub } from 'simple-icons'
import { DEMO_API_KEY, DEMO_BASE_URL, DEMO_MODEL } from '@/agent/constants' import { DEMO_API_KEY, DEMO_BASE_URL, DEMO_MODEL, isTestingEndpoint } from '@/agent/constants'
import type { ExtConfig, LanguagePreference } from '@/agent/useAgent'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
interface ConfigPanelProps { interface ConfigPanelProps {
config: LLMConfig | null config: ExtConfig | null
onSave: (config: LLMConfig) => Promise<void> onSave: (config: ExtConfig) => Promise<void>
onClose: () => void onClose: () => void
} }
@@ -17,17 +28,27 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
const [apiKey, setApiKey] = useState(config?.apiKey || DEMO_API_KEY) const [apiKey, setApiKey] = useState(config?.apiKey || DEMO_API_KEY)
const [baseURL, setBaseURL] = useState(config?.baseURL || DEMO_BASE_URL) const [baseURL, setBaseURL] = useState(config?.baseURL || DEMO_BASE_URL)
const [model, setModel] = useState(config?.model || DEMO_MODEL) const [model, setModel] = useState(config?.model || DEMO_MODEL)
const [language, setLanguage] = useState<LanguagePreference>(config?.language)
const [maxSteps, setMaxSteps] = useState<number | undefined>(config?.maxSteps)
const [systemInstruction, setSystemInstruction] = useState(config?.systemInstruction ?? '')
const [experimentalLlmsTxt, setExperimentalLlmsTxt] = useState(
config?.experimentalLlmsTxt ?? false
)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [userAuthToken, setUserAuthToken] = useState<string>('') const [userAuthToken, setUserAuthToken] = useState<string>('')
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [showToken, setShowToken] = useState(false) const [showToken, setShowToken] = useState(false)
const [showApiKey, setShowApiKey] = useState(false) const [showApiKey, setShowApiKey] = useState(false)
// Update local state when config prop changes
useEffect(() => { useEffect(() => {
setApiKey(config?.apiKey || DEMO_API_KEY) setApiKey(config?.apiKey || DEMO_API_KEY)
setBaseURL(config?.baseURL || DEMO_BASE_URL) setBaseURL(config?.baseURL || DEMO_BASE_URL)
setModel(config?.model || DEMO_MODEL) setModel(config?.model || DEMO_MODEL)
setLanguage(config?.language)
setMaxSteps(config?.maxSteps)
setSystemInstruction(config?.systemInstruction ?? '')
setExperimentalLlmsTxt(config?.experimentalLlmsTxt ?? false)
}, [config]) }, [config])
// Poll for user auth token every second until found // Poll for user auth token every second until found
@@ -65,7 +86,15 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
const handleSave = async () => { const handleSave = async () => {
setSaving(true) setSaving(true)
try { try {
await onSave({ apiKey, baseURL, model }) await onSave({
apiKey,
baseURL,
model,
language,
maxSteps: maxSteps || undefined,
systemInstruction: systemInstruction || undefined,
experimentalLlmsTxt,
})
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -89,7 +118,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
<div className="flex flex-col gap-1.5 p-3 bg-muted/50 rounded-md border"> <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> <label className="text-xs font-medium text-muted-foreground">User Auth Token</label>
<p className="text-[10px] text-muted-foreground mb-1"> <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 Give a website the ability to call this extension.
</p> </p>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Input <Input
@@ -134,6 +163,23 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
/> />
</div> </div>
{/* Testing API notice */}
{isTestingEndpoint(baseURL) && (
<div className="p-2.5 rounded-md border border-amber-500/30 bg-amber-500/5 text-[11px] text-muted-foreground leading-relaxed">
<Scale className="size-3 inline-block mr-1 -mt-0.5 text-amber-600" />
You are using the free testing API. By using this service you agree to the{' '}
<a
href="https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
Terms of Use & Privacy Policy
</a>
. No sensitive data. No guaranteed availability.
</div>
)}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Model</label> <label className="text-xs text-muted-foreground">Model</label>
<Input <Input
@@ -165,6 +211,65 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
</div> </div>
</div> </div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Language</label>
<select
value={language ?? ''}
onChange={(e) => setLanguage((e.target.value || undefined) as LanguagePreference)}
className="h-8 text-xs rounded-md border border-input bg-background px-2 cursor-pointer"
>
<option value="">System</option>
<option value="en-US">English</option>
<option value="zh-CN"></option>
</select>
</div>
{/* Advanced Config */}
<button
type="button"
onClick={() => setAdvancedOpen(!advancedOpen)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground cursor-pointer mt-1 font-bold"
>
Advanced
<ChevronDown
className="size-3 transition-transform"
style={{ transform: advancedOpen ? 'rotate(0deg)' : 'rotate(90deg)' }}
/>
</button>
{advancedOpen && (
<>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Max Steps</label>
<Input
type="number"
placeholder="40"
min={1}
max={200}
value={maxSteps ?? ''}
onChange={(e) => setMaxSteps(e.target.value ? Number(e.target.value) : undefined)}
className="text-xs h-8 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">System Instruction</label>
<textarea
placeholder="Additional instructions for the agent..."
value={systemInstruction}
onChange={(e) => setSystemInstruction(e.target.value)}
rows={3}
className="text-xs rounded-md border border-input bg-background px-3 py-2 resize-y min-h-[60px]"
/>
</div>
<label className="flex items-center justify-between cursor-pointer">
<span className="text-xs text-muted-foreground">Experimental llms.txt support</span>
<Switch checked={experimentalLlmsTxt} onCheckedChange={setExperimentalLlmsTxt} />
</label>
</>
)}
<div className="flex gap-2 mt-2"> <div className="flex gap-2 mt-2">
<Button variant="outline" onClick={onClose} className="flex-1 h-8 text-xs cursor-pointer"> <Button variant="outline" onClick={onClose} className="flex-1 h-8 text-xs cursor-pointer">
Cancel Cancel
@@ -181,6 +286,10 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
{/* Footer */} {/* 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="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"> <div className="flex flex-col justify-between">
<span>
Version <span className="font-mono">v{__VERSION__}</span>
</span>
<a <a
href="https://github.com/alibaba/page-agent" href="https://github.com/alibaba/page-agent"
target="_blank" target="_blank"
@@ -192,7 +301,9 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
</svg> </svg>
<span>Source Code</span> <span>Source Code</span>
</a> </a>
</div>
<div className="flex flex-col items-end">
<a <a
href="https://alibaba.github.io/page-agent/" href="https://alibaba.github.io/page-agent/"
target="_blank" target="_blank"
@@ -204,24 +315,15 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
</a> </a>
<a <a
href="https://github.com/alibaba/page-agent/blob/main/packages/extension/PRIVACY.md" href="https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1 hover:text-foreground" className="flex items-center gap-1 hover:text-foreground"
> >
<HatGlasses className="size-3" /> <HatGlasses className="size-3" />
<span>Privacy Policy</span> <span>Privacy</span>
</a> </a>
</div> </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> </div>
{/* attribute */} {/* attribute */}
@@ -1,4 +1,4 @@
import { AlertTriangle, RotateCcw } from 'lucide-react' import { AlertTriangle, Eraser, RotateCcw } from 'lucide-react'
import { Component, type ErrorInfo, type ReactNode } from 'react' import { Component, type ErrorInfo, type ReactNode } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -27,6 +27,11 @@ export class ErrorBoundary extends Component<Props, State> {
window.location.reload() window.location.reload()
} }
handleResetConfig = async () => {
await chrome.storage.local.remove(['llmConfig', 'language', 'advancedConfig'])
window.location.reload()
}
render() { render() {
if (!this.state.hasError) { if (!this.state.hasError) {
return this.props.children return this.props.children
@@ -39,10 +44,16 @@ export class ErrorBoundary extends Component<Props, State> {
<p className="text-sm text-muted-foreground mb-4 max-w-xs"> <p className="text-sm text-muted-foreground mb-4 max-w-xs">
{this.state.error?.message || 'An unexpected error occurred'} {this.state.error?.message || 'An unexpected error occurred'}
</p> </p>
<Button variant="outline" size="sm" onClick={this.handleReload}> <div className="flex gap-2">
<RotateCcw className="size-3.5 mr-2" /> <Button variant="outline" size="sm" onClick={this.handleResetConfig}>
Reload Panel <Eraser className="size-3.5 mr-2" />
</Button> Reset Config
</Button>
<Button variant="outline" size="sm" onClick={this.handleReload}>
<RotateCcw className="size-3.5 mr-2" />
Reload Panel
</Button>
</div>
</div> </div>
) )
} }
@@ -0,0 +1,51 @@
import { ArrowLeft } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { type SessionRecord, getSession } from '@/lib/db'
import { EventCard } from './cards'
export function HistoryDetail({ sessionId, onBack }: { sessionId: string; onBack: () => void }) {
const [session, setSession] = useState<SessionRecord | null>(null)
useEffect(() => {
getSession(sessionId).then((s) => setSession(s ?? null))
}, [sessionId])
if (!session) {
return (
<div className="flex items-center justify-center h-screen text-xs text-muted-foreground">
Loading...
</div>
)
}
return (
<div className="flex flex-col h-screen bg-background">
{/* Header */}
<header className="flex items-center gap-2 border-b px-3 py-2">
<Button variant="ghost" size="icon-sm" onClick={onBack} className="cursor-pointer">
<ArrowLeft className="size-3.5" />
</Button>
<span className="text-sm font-medium truncate">History</span>
</header>
{/* Task */}
<div className="border-b px-3 py-2 bg-muted/30">
<div className="text-[10px] text-muted-foreground uppercase tracking-wide">Task</div>
<div className="text-xs font-medium" title={session.task}>
{session.task}
</div>
</div>
{/* Events (read-only) */}
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{session.history.map((event, index) => (
// eslint-disable-next-line react-x/no-array-index-key
<EventCard key={index} event={event} />
))}
</div>
</div>
)
}
@@ -0,0 +1,119 @@
import { ArrowLeft, CheckCircle, Trash2, XCircle } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { type SessionRecord, clearSessions, deleteSession, listSessions } from '@/lib/db'
function timeAgo(ts: number): string {
const seconds = Math.floor((Date.now() - ts) / 1000)
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
export function HistoryList({
onSelect,
onBack,
}: {
onSelect: (id: string) => void
onBack: () => void
}) {
const [sessions, setSessions] = useState<SessionRecord[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setSessions(await listSessions())
setLoading(false)
}, [])
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load()
}, [load])
const handleDelete = async (e: React.MouseEvent, id: string) => {
e.stopPropagation()
await deleteSession(id)
setSessions((prev) => prev.filter((s) => s.id !== id))
}
return (
<div className="flex flex-col h-screen bg-background">
{/* Header */}
<header className="flex items-center gap-2 border-b px-3 py-2">
<Button variant="ghost" size="icon-sm" onClick={onBack} className="cursor-pointer">
<ArrowLeft className="size-3.5" />
</Button>
<span className="text-sm font-medium flex-1">History</span>
{sessions.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
await clearSessions()
setSessions([])
}}
className="text-[10px] text-muted-foreground hover:text-destructive cursor-pointer h-6 px-2"
>
<Trash2 className="size-3 mr-1" />
Clear All
</Button>
)}
</header>
{/* List */}
<div className="flex-1 overflow-y-auto">
{loading && (
<div className="flex items-center justify-center h-32 text-xs text-muted-foreground">
Loading...
</div>
)}
{!loading && sessions.length === 0 && (
<div className="flex items-center justify-center h-32 text-xs text-muted-foreground">
No history yet
</div>
)}
{sessions.map((session) => (
<div
key={session.id}
role="button"
tabIndex={0}
onClick={() => onSelect(session.id)}
onKeyDown={(e) => e.key === 'Enter' && onSelect(session.id)}
className="w-full text-left px-3 py-2.5 border-b hover:bg-muted/50 transition-colors cursor-pointer flex items-start gap-2 group"
>
{/* Status icon */}
{session.status === 'completed' ? (
<CheckCircle className="size-3.5 text-green-500 shrink-0 mt-0.5" />
) : (
<XCircle className="size-3.5 text-destructive shrink-0 mt-0.5" />
)}
{/* Content */}
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{session.task}</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
{timeAgo(session.createdAt)} · {session.history.length} steps
</p>
</div>
{/* Delete */}
<button
type="button"
onClick={(e) => handleDelete(e, session.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:text-destructive cursor-pointer shrink-0"
>
<Trash2 className="size-3" />
</button>
</div>
))}
</div>
</div>
)
}
@@ -1,5 +1,10 @@
import type { AgentStatus } from '@page-agent/core' import type { AgentStatus } from '@page-agent/core'
import { Motion } from 'ai-motion'
import { BookOpen, Globe } from 'lucide-react'
import { useEffect, useRef } from 'react'
import { siGithub } from 'simple-icons'
import { TypingAnimation } from '@/components/ui/typing-animation'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
// Status dot indicator // Status dot indicator
@@ -19,7 +24,7 @@ export function StatusDot({ status }: { status: AgentStatus }) {
}[status] }[status]
return ( return (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5 mr-2">
<span <span
className={cn('size-2 rounded-full', colorClass, status === 'running' && 'animate-pulse')} className={cn('size-2 rounded-full', colorClass, status === 'running' && 'animate-pulse')}
/> />
@@ -32,14 +37,115 @@ export function Logo({ className }: { className?: string }) {
return <img src="/assets/page-agent-256.webp" alt="Page Agent" className={cn('', className)} /> return <img src="/assets/page-agent-256.webp" alt="Page Agent" className={cn('', className)} />
} }
// Empty state with logo // Full-screen ai-motion glow overlay, shown only while running
export function MotionOverlay({ active }: { active: boolean }) {
const containerRef = useRef<HTMLDivElement>(null)
const motionRef = useRef<Motion | null>(null)
useEffect(() => {
try {
const mode = document.documentElement.classList.contains('dark') ? 'dark' : 'light'
const motion = new Motion({
mode,
borderWidth: 4,
borderRadius: 14,
glowWidth: mode === 'dark' ? 120 : 60,
styles: { position: 'absolute', inset: '0' },
})
motionRef.current = motion
containerRef.current!.appendChild(motion.element)
motion.autoResize(containerRef.current!)
} catch (e) {
console.warn('[MotionOverlay] Motion unavailable:', e)
}
return () => {
motionRef.current?.dispose()
motionRef.current = null
}
}, [])
useEffect(() => {
const motion = motionRef.current
if (!motion) return
let disposed = false
if (active) {
motion.start()
motion.fadeIn()
} else {
motion.fadeOut().then(() => !disposed && motion.pause())
}
return () => {
disposed = true
}
}, [active])
return (
<div
ref={containerRef}
className="pointer-events-none absolute inset-0 z-10 opacity-60"
style={{ display: active ? undefined : 'none' }}
/>
)
}
// Empty state with logo and breathing glow
export function EmptyState() { export function EmptyState() {
return ( return (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-6"> <div className="flex flex-col items-center justify-center h-full gap-4 text-center px-6">
<Logo className="size-20 opacity-80" /> <div className="relative select-none pointer-events-none">
<div className="absolute inset-0 -m-6 rounded-full bg-[conic-gradient(from_180deg,oklch(0.55_0.2_280),oklch(0.5_0.15_230),oklch(0.6_0.18_310),oklch(0.55_0.2_280))] blur-2xl animate-[glow-a_5s_ease-in-out_infinite]" />
<div className="absolute inset-0 -m-6 rounded-full bg-[conic-gradient(from_0deg,oklch(0.55_0.18_160),oklch(0.5_0.2_200),oklch(0.6_0.15_120),oklch(0.55_0.18_160))] blur-2xl animate-[glow-b_5s_ease-in-out_infinite]" />
<Logo className="relative size-20 opacity-80" />
</div>
<div> <div>
<h2 className="text-sm font-medium text-foreground">Page Agent Ext</h2> <h2 className="text-base font-medium text-foreground mb-1">Page Agent Ext</h2>
<p className="text-xs text-muted-foreground mt-1">Enter a task to automate this page</p> <TypingAnimation
className="text-sm text-muted-foreground"
words={[
'Enter a task to automate this page',
'Execute multi-page tasks',
'Call this extension from your web page',
'Use this extension in your own agents',
]}
cursorStyle="underscore"
loop
typeSpeed={20}
deleteSpeed={10}
pauseDelay={3000}
/>
</div>
<div className="flex items-center gap-3 mt-1 text-muted-foreground">
<a
href="https://github.com/alibaba/page-agent"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
title="GitHub"
>
<svg role="img" viewBox="0 0 24 24" className="size-4 fill-current">
<path d={siGithub.path} />
</svg>
</a>
<a
href="https://alibaba.github.io/page-agent/docs/features/chrome-extension"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
title="Documentation"
>
<BookOpen className="size-4" />
</a>
<a
href="https://alibaba.github.io/page-agent"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
title="Website"
>
<Globe className="size-4" />
</a>
</div> </div>
</div> </div>
) )
+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')
}
+1 -2
View File
@@ -1,2 +1 @@
declare const __EXT_VERSION__: string declare const __VERSION__: string
declare const __CORE_VERSION__: string
+8 -6
View File
@@ -19,8 +19,7 @@ export default defineConfig({
vite: () => ({ vite: () => ({
plugins: [tailwindcss()], plugins: [tailwindcss()],
define: { define: {
__EXT_VERSION__: JSON.stringify(pkg.version), __VERSION__: JSON.stringify(pkg.version),
__CORE_VERSION__: JSON.stringify(pkg.dependencies['@page-agent/core']),
}, },
optimizeDeps: { optimizeDeps: {
force: true, force: true,
@@ -37,10 +36,13 @@ export default defineConfig({
}, },
}, },
}), }),
zip: {
artifactTemplate: 'page-agent-ext-{{version}}-{{browser}}.zip',
},
manifest: { manifest: {
name: 'Page Agent Ext', default_locale: 'en',
description: name: '__MSG_extName__',
'AI-powered browser automation assistant. Control web pages with natural language.', description: '__MSG_extDescription__',
homepage_url: 'https://alibaba.github.io/page-agent/', homepage_url: 'https://alibaba.github.io/page-agent/',
permissions: ['tabs', 'tabGroups', 'sidePanel', 'storage'], permissions: ['tabs', 'tabGroups', 'sidePanel', 'storage'],
host_permissions: ['<all_urls>'], host_permissions: ['<all_urls>'],
@@ -48,7 +50,7 @@ export default defineConfig({
64: 'assets/page-agent-64.png', 64: 'assets/page-agent-64.png',
}, },
action: { action: {
default_title: 'Open Page Agent', default_title: '__MSG_extActionTitle__',
}, },
web_accessible_resources: [ web_accessible_resources: [
{ {
+7 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/llms", "name": "@page-agent/llms",
"version": "1.1.2", "version": "1.5.8",
"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",
@@ -37,7 +37,12 @@
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\"" "postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
}, },
"dependencies": { "dependencies": {
"chalk": "^5.6.2", "chalk": "^5.6.2"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {
"zod": "^4.3.5" "zod": "^4.3.5"
} }
} }
+7 -3
View File
@@ -1,6 +1,8 @@
/** /**
* OpenAI Client implementation * OpenAI Client implementation
*/ */
import * as z from 'zod/v4'
import { InvokeError, InvokeErrorType } from './errors' import { InvokeError, InvokeErrorType } from './errors'
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types' import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
import { modelPatch, zodToOpenAITool } from './utils' import { modelPatch, zodToOpenAITool } from './utils'
@@ -54,8 +56,10 @@ export class OpenAIClient implements LLMClient {
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
@@ -180,7 +184,7 @@ export class OpenAIClient implements LLMClient {
// Validate with schema // Validate with schema
const validation = tool.inputSchema.safeParse(parsedArgs) const validation = tool.inputSchema.safeParse(parsedArgs)
if (!validation.success) { if (!validation.success) {
console.error(validation.error) console.error(z.prettifyError(validation.error))
throw new InvokeError( throw new InvokeError(
InvokeErrorType.INVALID_TOOL_ARGS, InvokeErrorType.INVALID_TOOL_ARGS,
'Tool arguments validation failed', 'Tool arguments validation failed',
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
+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,
+7 -3
View File
@@ -1,16 +1,17 @@
import { OpenAIClient } from './OpenAIClient' import { OpenAIClient } from './OpenAIClient'
import { DEFAULT_TEMPERATURE, LLM_MAX_RETRIES } from './constants' import { DEFAULT_TEMPERATURE, LLM_MAX_RETRIES } from './constants'
import { InvokeError } from './errors' import { InvokeError, InvokeErrorType } from './errors'
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types' import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
export type { InvokeError, InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } export { InvokeError, InvokeErrorType }
export type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool }
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> { export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
// Runtime validation as defensive programming (types already guarantee these) // Runtime validation as defensive programming (types already guarantee these)
if (!config.baseURL || !config.apiKey || !config.model) { if (!config.baseURL || !config.apiKey || !config.model) {
throw new Error( throw new Error(
'[PageAgent] LLM configuration required. Please provide: baseURL, apiKey, model. ' + '[PageAgent] LLM configuration required. Please provide: baseURL, apiKey, model. ' +
'See: https://alibaba.github.io/page-agent/#/docs/features/models' 'See: https://alibaba.github.io/page-agent/docs/features/models'
) )
} }
@@ -49,6 +50,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
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* Core types for LLM integration * Core types for LLM integration
*/ */
import type { z } from 'zod' import type * as z from 'zod/v4'
/** /**
* Message format - OpenAI standard (industry standard) * Message format - OpenAI standard (industry standard)
+8 -4
View File
@@ -2,13 +2,11 @@
* Utility functions for LLM integration * Utility functions for LLM integration
*/ */
import chalk from 'chalk' import chalk from 'chalk'
import * as z from 'zod' import * as z from 'zod/v4'
import type { Tool } from './types' import type { Tool } from './types'
function debug(message: string) { const debug = console.debug.bind(console, chalk.gray('[LLM]'))
console.debug(chalk.gray('[LLM]'), message)
}
/** /**
* Convert Zod schema to OpenAI tool format * Convert Zod schema to OpenAI tool format
@@ -38,6 +36,7 @@ export function modelPatch(body: Record<string, any>) {
if (modelName.startsWith('qwen')) { if (modelName.startsWith('qwen')) {
debug('Applying Qwen patch: use higher temperature for auto fixing') debug('Applying Qwen patch: use higher temperature for auto fixing')
body.temperature = Math.max(body.temperature || 0, 1.0) body.temperature = Math.max(body.temperature || 0, 1.0)
body.enable_thinking = false
} }
if (modelName.startsWith('claude')) { if (modelName.startsWith('claude')) {
@@ -74,6 +73,11 @@ 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-54')) {
debug(
'Applying GPT-5.4 patch: skip reasoning_effort because chat/completions rejects it with function tools'
)
delete body.reasoning_effort
} else if (modelName.startsWith('gpt-5-mini')) { } else if (modelName.startsWith('gpt-5-mini')) {
debug('Applying GPT-5-mini patch: set reasoning effort to low, temperature to 1') debug('Applying GPT-5-mini patch: set reasoning effort to low, temperature to 1')
body.reasoning_effort = 'low' body.reasoning_effort = 'low'
+1 -1
View File
@@ -25,7 +25,7 @@ export default defineConfig({
}, },
outDir: resolve(__dirname, 'dist', 'lib'), outDir: resolve(__dirname, 'dist', 'lib'),
rollupOptions: { rollupOptions: {
external: ['chalk', 'zod'], external: ['chalk', 'zod', 'zod/v4'],
}, },
minify: false, minify: false,
sourcemap: true, sourcemap: true,
+11 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "page-agent", "name": "page-agent",
"private": false, "private": false,
"version": "1.1.2", "version": "1.5.8",
"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,16 @@
"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.1.2", "@page-agent/core": "1.5.8",
"@page-agent/llms": "1.1.2", "@page-agent/llms": "1.5.8",
"@page-agent/page-controller": "1.1.2", "@page-agent/page-controller": "1.5.8",
"@page-agent/ui": "1.1.2", "@page-agent/ui": "1.5.8",
"chalk": "^5.6.2", "chalk": "^5.6.2"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {
"zod": "^4.3.5" "zod": "^4.3.5"
} }
} }
+5 -4
View File
@@ -2,12 +2,13 @@
* Copyright (C) 2025 Alibaba Group Holding Limited * Copyright (C) 2025 Alibaba Group Holding Limited
* All rights reserved. * All rights reserved.
*/ */
import { type PageAgentConfig, PageAgentCore, type PageAgentTool, tool } from '@page-agent/core' import { type AgentConfig, PageAgentCore } from '@page-agent/core'
import { PageController } from '@page-agent/page-controller' import { PageController, type PageControllerConfig } from '@page-agent/page-controller'
import { Panel } from '@page-agent/ui' import { Panel } from '@page-agent/ui'
export type { PageAgentConfig, PageAgentTool } export * from '@page-agent/core'
export { tool }
export type PageAgentConfig = AgentConfig & PageControllerConfig
export class PageAgent extends PageAgentCore { export class PageAgent extends PageAgentCore {
panel: Panel panel: Panel
+3 -3
View File
@@ -13,9 +13,9 @@ window.PageAgent = PageAgent
console.log('🚀 page-agent.js loaded!') console.log('🚀 page-agent.js loaded!')
const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM' const DEMO_MODEL = 'qwen3.5-plus'
const DEMO_BASE_URL = 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy' const DEMO_BASE_URL = 'https://page-ag-testing-ohftxirgbn.cn-shanghai.fcapp.run'
const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM' const DEMO_API_KEY = 'NA'
// in case document.x is not ready yet // in case document.x is not ready yet
setTimeout(() => { setTimeout(() => {
+1
View File
@@ -30,6 +30,7 @@ export default defineConfig({
external: [ external: [
'chalk', 'chalk',
'zod', 'zod',
'zod/v4',
// all the internal packages // all the internal packages
/^@page-agent\//, /^@page-agent\//,
], ],
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/page-controller", "name": "@page-agent/page-controller",
"version": "1.1.2", "version": "1.5.8",
"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",
@@ -14,7 +14,6 @@ import {
scrollVertically, scrollVertically,
selectOptionElement, selectOptionElement,
} from './actions' } from './actions'
import { VIEWPORT_EXPANSION } from './constants'
import * as dom from './dom' import * as dom from './dom'
import type { FlatDomTree, InteractiveElementDomNode } from './dom/dom_tree/type' import type { FlatDomTree, InteractiveElementDomNode } from './dom/dom_tree/type'
import { getPageInfo } from './dom/getPageInfo' import { getPageInfo } from './dom/getPageInfo'
@@ -24,7 +23,6 @@ import { patchReact } from './patches/react'
* Configuration for PageController * Configuration for PageController
*/ */
export interface PageControllerConfig extends dom.DomConfig { export interface PageControllerConfig extends dom.DomConfig {
viewportExpansion?: number
/** Enable visual mask overlay during operations (default: false) */ /** Enable visual mask overlay during operations (default: false) */
enableMask?: boolean enableMask?: boolean
} }
@@ -131,7 +129,7 @@ export class PageController extends EventTarget {
const url = window.location.href const url = window.location.href
const title = document.title const title = document.title
const pi = getPageInfo() const pi = getPageInfo()
const viewportExpansion = this.config.viewportExpansion ?? VIEWPORT_EXPANSION const viewportExpansion = dom.resolveViewportExpansion(this.config.viewportExpansion)
await this.updateTree() await this.updateTree()
@@ -248,7 +246,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.`,
} }
} }
+84 -98
View File
@@ -86,7 +86,7 @@ export async function clickElement(element: HTMLElement) {
// dispatch a click event // dispatch a click event
// element.click() // element.click()
await waitFor(0.1) // Wait to ensure click event processing completes await waitFor(0.2) // Wait to ensure click event processing completes
} }
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method
@@ -101,41 +101,93 @@ const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(
'value' 'value'
)!.set! )!.set!
/**
* create a synthetic keyboard event
* with key keycode code
*/
export async function createSyntheticInputEvent(elem: HTMLElement, key: string) {
elem.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key }))
await waitFor(0.01)
if (elem instanceof HTMLInputElement || elem instanceof HTMLTextAreaElement) {
elem.dispatchEvent(new Event('beforeinput', { bubbles: true }))
await waitFor(0.01)
elem.dispatchEvent(new Event('input', { bubbles: true }))
await waitFor(0.01)
}
elem.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: true, key }))
}
export async function inputTextElement(element: HTMLElement, text: string) { export async function inputTextElement(element: HTMLElement, text: string) {
if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) { const isContentEditable = element.isContentEditable
throw new Error('Element is not an input or textarea') if (
!(element instanceof HTMLInputElement) &&
!(element instanceof HTMLTextAreaElement) &&
!isContentEditable
) {
throw new Error('Element is not an input, textarea, or contenteditable')
} }
await clickElement(element) await clickElement(element)
if (element instanceof HTMLTextAreaElement) { if (isContentEditable) {
// Contenteditable support (partial)
// Not supported:
// - Monaco/CodeMirror: Require direct JS instance access. No universal way to obtain.
// - Draft.js: Not responsive to synthetic/execCommand/Range/DataTransfer. Unmaintained.
//
// Plan A: Dispatch synthetic events
// Works: LinkedIn, React contenteditable, Quill.
// Fails: Slate.js
// Sequence: beforeinput -> mutation -> input -> change -> blur
// Dispatch beforeinput + mutation + input for clearing
if (
element.dispatchEvent(
new InputEvent('beforeinput', {
bubbles: true,
cancelable: true,
inputType: 'deleteContent',
})
)
) {
element.innerText = ''
element.dispatchEvent(
new InputEvent('input', {
bubbles: true,
inputType: 'deleteContent',
})
)
}
// Dispatch beforeinput + mutation + input for insertion (important for React apps)
if (
element.dispatchEvent(
new InputEvent('beforeinput', {
bubbles: true,
cancelable: true,
inputType: 'insertText',
data: text,
})
)
) {
element.innerText = text
element.dispatchEvent(
new InputEvent('input', {
bubbles: true,
inputType: 'insertText',
data: text,
})
)
}
// Dispatch change event (for good measure)
element.dispatchEvent(new Event('change', { bubbles: true }))
// Trigger blur for validation
element.blur()
// Plan B: execCommand (deprecated but works better for some editors)
// Works: LinkedIn, Quill, Slate.js, react contenteditable components
//
// document.execCommand('selectAll')
// document.execCommand('delete')
// document.execCommand('insertText', false, text)
} else if (element instanceof HTMLTextAreaElement) {
nativeTextAreaValueSetter.call(element, text) nativeTextAreaValueSetter.call(element, text)
} else { } else {
nativeInputValueSetter.call(element, text) nativeInputValueSetter.call(element, text)
} }
const inputEvent = new Event('input', { bubbles: true }) // Only dispatch shared input event for non-contenteditable (contenteditable has its own)
element.dispatchEvent(inputEvent) if (!isContentEditable) {
element.dispatchEvent(new Event('input', { bubbles: true }))
}
await waitFor(0.1) // Wait to ensure input event processing completes await waitFor(0.1)
blurLastClickedElement() blurLastClickedElement()
} }
@@ -161,14 +213,18 @@ export async function selectOptionElement(selectElement: HTMLSelectElement, opti
await waitFor(0.1) // Wait to ensure change event processing completes await waitFor(0.1) // Wait to ensure change event processing completes
} }
interface ScrollableElement extends HTMLElement {
scrollIntoViewIfNeeded?: (centerIfNeeded?: boolean) => void
}
export async function scrollIntoViewIfNeeded(element: HTMLElement) { export async function scrollIntoViewIfNeeded(element: HTMLElement) {
const el = element as any const el = element as ScrollableElement
if (el.scrollIntoViewIfNeeded) { if (typeof el.scrollIntoViewIfNeeded === 'function') {
el.scrollIntoViewIfNeeded() el.scrollIntoViewIfNeeded()
// await waitFor(0.5) // Animation playback // await waitFor(0.5) // Animation playback
} else { } else {
// @todo visibility check // @todo visibility check
el.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'nearest' }) element.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'nearest' })
// await waitFor(0.5) // Animation playback // await waitFor(0.5) // Animation playback
} }
} }
@@ -181,11 +237,6 @@ export async function scrollVertically(
// Element-specific scrolling if element is provided // Element-specific scrolling if element is provided
if (element) { if (element) {
const targetElement = element const targetElement = element
console.log(
'[SCROLL DEBUG] Starting direct container scroll for element:',
targetElement.tagName
)
let currentElement = targetElement as HTMLElement | null let currentElement = targetElement as HTMLElement | null
let scrollSuccess = false let scrollSuccess = false
let scrolledElement: HTMLElement | null = null let scrolledElement: HTMLElement | null = null
@@ -198,19 +249,6 @@ export async function scrollVertically(
const hasScrollableY = /(auto|scroll|overlay)/.test(computedStyle.overflowY) const hasScrollableY = /(auto|scroll|overlay)/.test(computedStyle.overflowY)
const canScrollVertically = currentElement.scrollHeight > currentElement.clientHeight const canScrollVertically = currentElement.scrollHeight > currentElement.clientHeight
console.log(
'[SCROLL DEBUG] Checking element:',
currentElement.tagName,
'hasScrollableY:',
hasScrollableY,
'canScrollVertically:',
canScrollVertically,
'scrollHeight:',
currentElement.scrollHeight,
'clientHeight:',
currentElement.clientHeight
)
if (hasScrollableY && canScrollVertically) { if (hasScrollableY && canScrollVertically) {
const beforeScroll = currentElement.scrollTop const beforeScroll = currentElement.scrollTop
const maxScroll = currentElement.scrollHeight - currentElement.clientHeight const maxScroll = currentElement.scrollHeight - currentElement.clientHeight
@@ -228,27 +266,10 @@ export async function scrollVertically(
const afterScroll = currentElement.scrollTop const afterScroll = currentElement.scrollTop
const actualScrollDelta = afterScroll - beforeScroll const actualScrollDelta = afterScroll - beforeScroll
console.log(
'[SCROLL DEBUG] Scroll attempt:',
currentElement.tagName,
'before:',
beforeScroll,
'after:',
afterScroll,
'delta:',
actualScrollDelta
)
if (Math.abs(actualScrollDelta) > 0.5) { if (Math.abs(actualScrollDelta) > 0.5) {
scrollSuccess = true scrollSuccess = true
scrolledElement = currentElement scrolledElement = currentElement
scrollDelta = actualScrollDelta scrollDelta = actualScrollDelta
console.log(
'[SCROLL DEBUG] Successfully scrolled container:',
currentElement.tagName,
'delta:',
actualScrollDelta
)
break break
} }
} }
@@ -344,11 +365,6 @@ export async function scrollHorizontally(
// Element-specific scrolling if element is provided // Element-specific scrolling if element is provided
if (element) { if (element) {
const targetElement = element const targetElement = element
console.log(
'[SCROLL DEBUG] Starting direct container scroll for element:',
targetElement.tagName
)
let currentElement = targetElement as HTMLElement | null let currentElement = targetElement as HTMLElement | null
let scrollSuccess = false let scrollSuccess = false
let scrolledElement: HTMLElement | null = null let scrolledElement: HTMLElement | null = null
@@ -361,19 +377,6 @@ export async function scrollHorizontally(
const hasScrollableX = /(auto|scroll|overlay)/.test(computedStyle.overflowX) const hasScrollableX = /(auto|scroll|overlay)/.test(computedStyle.overflowX)
const canScrollHorizontally = currentElement.scrollWidth > currentElement.clientWidth const canScrollHorizontally = currentElement.scrollWidth > currentElement.clientWidth
console.log(
'[SCROLL DEBUG] Checking element:',
currentElement.tagName,
'hasScrollableX:',
hasScrollableX,
'canScrollHorizontally:',
canScrollHorizontally,
'scrollWidth:',
currentElement.scrollWidth,
'clientWidth:',
currentElement.clientWidth
)
if (hasScrollableX && canScrollHorizontally) { if (hasScrollableX && canScrollHorizontally) {
const beforeScroll = currentElement.scrollLeft const beforeScroll = currentElement.scrollLeft
const maxScroll = currentElement.scrollWidth - currentElement.clientWidth const maxScroll = currentElement.scrollWidth - currentElement.clientWidth
@@ -391,27 +394,10 @@ export async function scrollHorizontally(
const afterScroll = currentElement.scrollLeft const afterScroll = currentElement.scrollLeft
const actualScrollDelta = afterScroll - beforeScroll const actualScrollDelta = afterScroll - beforeScroll
console.log(
'[SCROLL DEBUG] Scroll attempt:',
currentElement.tagName,
'before:',
beforeScroll,
'after:',
afterScroll,
'delta:',
actualScrollDelta
)
if (Math.abs(actualScrollDelta) > 0.5) { if (Math.abs(actualScrollDelta) > 0.5) {
scrollSuccess = true scrollSuccess = true
scrolledElement = currentElement scrolledElement = currentElement
scrollDelta = actualScrollDelta scrollDelta = actualScrollDelta
console.log(
'[SCROLL DEBUG] Successfully scrolled container:',
currentElement.tagName,
'delta:',
actualScrollDelta
)
break break
} }
} }
-16
View File
@@ -1,16 +0,0 @@
/**
* Copyright (C) 2025 Alibaba Group Holding Limited
* All rights reserved.
*/
/**
* Viewport expansion for DOM tree extraction.
* -1 means full page (no viewport restriction)
* 0 means viewport only
* positive values expand the viewport by that many pixels
*
* @note Since isTopElement depends on elementFromPoint,
* it returns null when out of viewport, this feature has no practical use, only differ between -1 and 0
*/
// export const VIEWPORT_EXPANSION = 100
export const VIEWPORT_EXPANSION = -1
@@ -17,6 +17,7 @@
* @edit add `data-browser-use-ignore` attribute * @edit add `data-browser-use-ignore` attribute
* @edit improve `sampleRect`, filter out rects with 0 area * @edit improve `sampleRect`, filter out rects with 0 area
* @edit exclude aria-hidden elements * @edit exclude aria-hidden elements
* @edit make sure attributes exist for interactive candidates.
*/ */
export default ( export default (
@@ -1446,7 +1447,7 @@ export default (
/** /**
* @edit add `data-browser-use-ignore` attribute * @edit add `data-browser-use-ignore` attribute
*/ */
if (node.dataset?.browserUseIgnore === 'true') { if (node.dataset?.browserUseIgnore === 'true' || node.dataset?.pageAgentIgnore === 'true') {
return null // Skip this node and its children return null // Skip this node and its children
} }
@@ -1608,6 +1609,18 @@ export default (
* @edit direct dom ref * @edit direct dom ref
*/ */
nodeData.ref = node nodeData.ref = node
/**
* @edit make sure attributes exist for interactive candidates.
* @note if the element failed the isInteractiveCandidate, attributes would be empty.
*/
if (nodeData.isInteractive && Object.keys(nodeData.attributes).length === 0) {
const attributeNames = node.getAttributeNames?.() || []
for (const name of attributeNames) {
const value = node.getAttribute(name)
nodeData.attributes[name] = value
}
}
} }
} }
} }
+64 -15
View File
@@ -1,4 +1,3 @@
import { VIEWPORT_EXPANSION } from '../constants'
import domTree from './dom_tree/index.js' import domTree from './dom_tree/index.js'
import { import {
ElementDomNode, ElementDomNode,
@@ -7,7 +6,23 @@ import {
TextDomNode, TextDomNode,
} from './dom_tree/type' } from './dom_tree/type'
/**
* Viewport expansion for DOM tree extraction.
* -1 means full page (no viewport restriction)
* 0 means viewport only
* positive values expand the viewport by that many pixels
*
* @note Since isTopElement depends on elementFromPoint,
* it returns null when out of viewport, this feature has no practical use, only differ between -1 and 0
*/
const DEFAULT_VIEWPORT_EXPANSION = -1
export function resolveViewportExpansion(viewportExpansion?: number): number {
return viewportExpansion ?? DEFAULT_VIEWPORT_EXPANSION
}
export interface DomConfig { export interface DomConfig {
viewportExpansion?: number
interactiveBlacklist?: (Element | (() => Element))[] interactiveBlacklist?: (Element | (() => Element))[]
interactiveWhitelist?: (Element | (() => Element))[] interactiveWhitelist?: (Element | (() => Element))[]
includeAttributes?: string[] includeAttributes?: string[]
@@ -21,6 +36,8 @@ export interface DomConfig {
const newElementsCache = new WeakMap<HTMLElement, string>() const newElementsCache = new WeakMap<HTMLElement, string>()
export function getFlatTree(config: DomConfig): FlatDomTree { export function getFlatTree(config: DomConfig): FlatDomTree {
const viewportExpansion = resolveViewportExpansion(config.viewportExpansion)
const interactiveBlacklist = [] as Element[] const interactiveBlacklist = [] as Element[]
for (const item of config.interactiveBlacklist || []) { for (const item of config.interactiveBlacklist || []) {
if (typeof item === 'function') { if (typeof item === 'function') {
@@ -43,7 +60,7 @@ export function getFlatTree(config: DomConfig): FlatDomTree {
doHighlightElements: true, doHighlightElements: true,
debugMode: true, debugMode: true,
focusHighlightIndex: -1, focusHighlightIndex: -1,
viewportExpansion: VIEWPORT_EXPANSION, viewportExpansion,
interactiveBlacklist, interactiveBlacklist,
interactiveWhitelist, interactiveWhitelist,
highlightOpacity: config.highlightOpacity ?? 0.0, highlightOpacity: config.highlightOpacity ?? 0.0,
@@ -74,6 +91,43 @@ export function getFlatTree(config: DomConfig): FlatDomTree {
return elements return elements
} }
const globRegexCache = new Map<string, RegExp>()
function globToRegex(pattern: string): RegExp {
let regex = globRegexCache.get(pattern)
if (!regex) {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
regex = new RegExp(`^${escaped.replace(/\*/g, '.*')}$`)
globRegexCache.set(pattern, regex)
}
return regex
}
function matchAttributes(
attrs: Record<string, string>,
patterns: string[]
): Record<string, string> {
const result: Record<string, string> = {}
for (const pattern of patterns) {
if (pattern.includes('*')) {
const regex = globToRegex(pattern)
for (const key of Object.keys(attrs)) {
if (regex.test(key) && attrs[key].trim()) {
result[key] = attrs[key].trim()
}
}
} else {
const value = attrs[pattern]
if (value && value.trim()) {
result[pattern] = value.trim()
}
}
}
return result
}
/** /**
* elementsToString 使 * elementsToString 使
*/ */
@@ -140,10 +194,13 @@ export function flatTreeToString(flatTree: FlatDomTree, includeAttributes?: stri
// for jump check // for jump check
'target', 'target',
// absolute 定位的下拉菜单 // absolute position dropdown menu
'aria-haspopup', 'aria-haspopup',
'aria-controls', 'aria-controls',
'aria-owns', 'aria-owns',
// content editable
'contenteditable',
] ]
const includeAttrs = [...(includeAttributes || []), ...DEFAULT_INCLUDE_ATTRIBUTES] const includeAttrs = [...(includeAttributes || []), ...DEFAULT_INCLUDE_ATTRIBUTES]
@@ -245,23 +302,15 @@ export function flatTreeToString(flatTree: FlatDomTree, includeAttributes?: stri
let attributesHtmlStr = '' let attributesHtmlStr = ''
if (includeAttrs.length > 0 && node.attributes) { if (includeAttrs.length > 0 && node.attributes) {
const attributesToInclude: Record<string, string> = {} const attributesToInclude = matchAttributes(node.attributes, includeAttrs)
// Filter attributes
for (const key of includeAttrs) {
const value = node.attributes[key]
if (value && value.trim() !== '') {
attributesToInclude[key] = value.trim()
}
}
// Remove duplicate values (for attributes longer than 5 chars) // Remove duplicate values (for attributes longer than 5 chars)
const orderedKeys = includeAttrs.filter((key) => key in attributesToInclude) const keys = Object.keys(attributesToInclude)
if (orderedKeys.length > 1) { if (keys.length > 1) {
const keysToRemove = new Set<string>() const keysToRemove = new Set<string>()
const seenValues: Record<string, string> = {} const seenValues: Record<string, string> = {}
for (const key of orderedKeys) { for (const key of keys) {
const value = attributesToInclude[key] const value = attributesToInclude[key]
if (value.length > 5) { if (value.length > 5) {
if (value in seenValues) { if (value in seenValues) {
@@ -7,3 +7,7 @@
display: none; display: none;
} }
.wrapper.visible {
display: block;
}
@@ -8,13 +8,7 @@ import cursorStyles from './cursor.module.css'
export class SimulatorMask { export class SimulatorMask {
shown: boolean = false shown: boolean = false
wrapper = document.createElement('div') wrapper = document.createElement('div')
motion = new Motion({ motion: Motion | null = null
mode: isPageDark() ? 'dark' : 'light',
styles: {
position: 'absolute',
inset: '0',
},
})
#cursor = document.createElement('div') #cursor = document.createElement('div')
@@ -28,9 +22,19 @@ export class SimulatorMask {
this.wrapper.id = 'page-agent-runtime_simulator-mask' this.wrapper.id = 'page-agent-runtime_simulator-mask'
this.wrapper.className = styles.wrapper this.wrapper.className = styles.wrapper
this.wrapper.setAttribute('data-browser-use-ignore', 'true') this.wrapper.setAttribute('data-browser-use-ignore', 'true')
this.wrapper.setAttribute('data-page-agent-ignore', 'true')
this.wrapper.appendChild(this.motion.element) try {
this.motion.autoResize(this.wrapper) const motion = new Motion({
mode: isPageDark() ? 'dark' : 'light',
styles: { position: 'absolute', inset: '0' },
})
this.motion = motion
this.wrapper.appendChild(motion.element)
motion.autoResize(this.wrapper)
} catch (e) {
console.warn('[SimulatorMask] Motion overlay unavailable:', e)
}
// Capture all mouse, keyboard, and wheel events // Capture all mouse, keyboard, and wheel events
this.wrapper.addEventListener('click', (e) => { this.wrapper.addEventListener('click', (e) => {
@@ -144,10 +148,10 @@ export class SimulatorMask {
if (this.shown) return if (this.shown) return
this.shown = true this.shown = true
this.motion.start() this.motion?.start()
this.motion.fadeIn() this.motion?.fadeIn()
this.wrapper.style.display = 'block' this.wrapper.classList.add(styles.visible)
// Initialize cursor position // Initialize cursor position
this.#currentCursorX = window.innerWidth / 2 this.#currentCursorX = window.innerWidth / 2
@@ -162,18 +166,18 @@ export class SimulatorMask {
if (!this.shown) return if (!this.shown) return
this.shown = false this.shown = false
this.motion.fadeOut() this.motion?.fadeOut()
this.motion.pause() this.motion?.pause()
this.#cursor.classList.remove(cursorStyles.clicking) this.#cursor.classList.remove(cursorStyles.clicking)
setTimeout(() => { setTimeout(() => {
this.wrapper.style.display = 'none' this.wrapper.classList.remove(styles.visible)
}, 800) // Match the animation duration }, 800) // Match the animation duration
} }
dispose() { dispose() {
this.motion.dispose() this.motion?.dispose()
this.wrapper.remove() this.wrapper.remove()
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/ui", "name": "@page-agent/ui",
"version": "1.1.2", "version": "1.5.8",
"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",
+4 -2
View File
@@ -12,9 +12,10 @@ 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}}',
}, },
tools: { tools: {
clicking: 'Clicking element [{{index}}]...', clicking: 'Clicking element [{{index}}]...',
@@ -59,9 +60,10 @@ const zhCN = {
question: '询问: {{question}}', question: '询问: {{question}}',
waitingPlaceholder: '等待任务开始...', waitingPlaceholder: '等待任务开始...',
stop: '终止', stop: '终止',
close: '关闭',
expand: '展开历史', expand: '展开历史',
collapse: '收起历史', collapse: '收起历史',
step: '步骤 {{number}} · {{time}}{{duration}}', step: '步骤 {{number}}',
}, },
tools: { tools: {
clicking: '正在点击元素 [{{index}}]...', clicking: '正在点击元素 [{{index}}]...',
-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'
+26 -16
View File
@@ -1,6 +1,6 @@
import { I18n, type SupportedLanguage } from '../i18n' import { I18n, type SupportedLanguage } from '../i18n'
import { truncate } from '../utils' import { truncate } from '../utils'
import { createCard, createReflectionLines, formatTime } from './cards' import { createCard, createReflectionLines } from './cards'
import type { AgentActivity, PanelAgentAdapter } from './types' import type { AgentActivity, PanelAgentAdapter } from './types'
import styles from './Panel.module.css' import styles from './Panel.module.css'
@@ -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()
@@ -178,7 +187,6 @@ export class Panel {
tempCard.innerHTML = createCard({ tempCard.innerHTML = createCard({
icon: '❓', icon: '❓',
content: `Question: ${question}`, content: `Question: ${question}`,
meta: formatTime(this.#config.language ?? 'en-US'),
type: 'question', type: 'question',
}) })
const cardElement = tempCard.firstElementChild as HTMLElement const cardElement = tempCard.firstElementChild as HTMLElement
@@ -266,10 +274,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()
}
} }
/** /**
@@ -361,6 +373,7 @@ export class Panel {
wrapper.id = 'page-agent-runtime_agent-panel' wrapper.id = 'page-agent-runtime_agent-panel'
wrapper.className = styles.wrapper wrapper.className = styles.wrapper
wrapper.setAttribute('data-browser-use-ignore', 'true') wrapper.setAttribute('data-browser-use-ignore', 'true')
wrapper.setAttribute('data-page-agent-ignore', 'true')
wrapper.innerHTML = ` wrapper.innerHTML = `
<div class="${styles.background}"></div> <div class="${styles.background}"></div>
@@ -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
@@ -578,15 +591,12 @@ export class Panel {
/** Create cards for a history event */ /** Create cards for a history event */
#createHistoryCards(event: PanelAgentAdapter['history'][number]): string[] { #createHistoryCards(event: PanelAgentAdapter['history'][number]): string[] {
const cards: string[] = [] const cards: string[] = []
const time = formatTime(this.#config.language ?? 'en-US')
const meta = const meta =
event.type === 'step' && event.stepIndex !== undefined event.type === 'step' && event.stepIndex !== undefined
? this.#i18n.t('ui.panel.step', { ? this.#i18n.t('ui.panel.step', {
number: (event.stepIndex + 1).toString(), number: (event.stepIndex + 1).toString(),
time,
duration: '',
}) })
: time : undefined
if (event.type === 'step') { if (event.type === 'step') {
// Reflection card // Reflection card
@@ -623,7 +633,7 @@ export class Panel {
/** Create cards for an action */ /** Create cards for an action */
#createActionCards( #createActionCards(
action: { name: string; input: unknown; output: string }, action: { name: string; input: unknown; output: string },
meta: string meta?: string
): string[] { ): string[] {
const cards: string[] = [] const cards: string[] = []
+4 -14
View File
@@ -18,7 +18,7 @@ interface CardOptions {
export function createCard({ icon, content, meta, type }: CardOptions): string { export function createCard({ icon, content, meta, type }: CardOptions): string {
const typeClass = type ? styles[type] : '' const typeClass = type ? styles[type] : ''
const contentHtml = Array.isArray(content) const contentHtml = Array.isArray(content)
? `<div class="${styles.reflectionLines}">${content.join('')}</div>` ? `<div class="${styles.reflectionLines}">${content.map((line) => `<span>${escapeHtml(line)}</span>`).join('')}</div>`
: `<span>${escapeHtml(content)}</span>` : `<span>${escapeHtml(content)}</span>`
return ` return `
@@ -32,16 +32,6 @@ export function createCard({ icon, content, meta, type }: CardOptions): string {
` `
} }
/** Format timestamp for cards */
export function formatTime(locale: string = 'en-US'): string {
return new Date().toLocaleTimeString(locale, {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
/** Create reflection lines from reflection object */ /** Create reflection lines from reflection object */
export function createReflectionLines(reflection: { export function createReflectionLines(reflection: {
evaluation_previous_goal?: string evaluation_previous_goal?: string
@@ -50,13 +40,13 @@ export function createReflectionLines(reflection: {
}): string[] { }): string[] {
const lines: string[] = [] const lines: string[] = []
if (reflection.evaluation_previous_goal) { if (reflection.evaluation_previous_goal) {
lines.push(`<div>🔍 ${escapeHtml(reflection.evaluation_previous_goal)}</div>`) lines.push(`🔍 ${reflection.evaluation_previous_goal}`)
} }
if (reflection.memory) { if (reflection.memory) {
lines.push(`<div>💾 ${escapeHtml(reflection.memory)}</div>`) lines.push(`💾 ${reflection.memory}`)
} }
if (reflection.next_goal) { if (reflection.next_goal) {
lines.push(`<div>🎯 ${escapeHtml(reflection.next_goal)}</div>`) lines.push(`🎯 ${reflection.next_goal}`)
} }
return lines return lines
} }
+4 -1
View File
@@ -68,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
} }
+40 -24
View File
@@ -5,9 +5,9 @@
- **React** with TypeScript - **React** with TypeScript
- **Vite** for dev server and build - **Vite** for dev server and build
- **Tailwind CSS** for styling - **Tailwind CSS** for styling
- **shadcn/ui** (new-york style) for UI components - **shadcn/ui** (new-york style) for UI components — **do NOT hand-edit `src/components/ui/` files**
- **Magic UI** for animations and effects - **Magic UI** for animations and effects
- **wouter** with hash routing for static hosting - **wouter** with browser routing (`base: "/page-agent"`)
- **lucide-react** for icons - **lucide-react** for icons
## Component Guidelines ## Component Guidelines
@@ -53,58 +53,74 @@ Located in `src/components/ui/`:
### Styling Rules ### Styling Rules
1. **Prefer Tailwind classes** over custom CSS 1. **Prefer Tailwind classes** over custom CSS
2. Use CSS modules only for complex component-specific styles 2. Support dark mode via `dark:` classes
3. Support dark mode via `dark:` classes 3. Use CSS variables from `src/index.css` for theme colors
4. Use CSS variables from `src/index.css` for theme colors
## Project Structure ## Project Structure
``` ```
src/ src/
├── pages/ ├── pages/
│ ├── Home.tsx # Homepage │ ├── home/
│ │ ├── index.tsx # Homepage
│ │ └── ...Section.tsx
│ └── docs/ │ └── docs/
│ ├── Layout.tsx # Documentation sidebar │ ├── index.tsx # Docs route switch
│ ├── Layout.tsx # Sidebar navigation
│ └── [section]/[topic]/page.tsx │ └── [section]/[topic]/page.tsx
├── components/ ├── components/
│ ├── ui/ # shadcn/ui + Magic UI components │ ├── ui/ # shadcn/ui + Magic UI (DO NOT hand-edit)
│ ├── Heading.tsx # Anchor heading for doc pages
│ ├── Header.tsx # Site header │ ├── Header.tsx # Site header
│ └── Footer.tsx # Site footer │ └── Footer.tsx # Site footer
├── i18n/ # Internationalization ├── i18n/ # Internationalization
├── router.tsx # Central routing ├── router.tsx # Root layout + routing
└── main.tsx # App entry └── main.tsx # App entry
``` ```
## Routing
Uses wouter browser routing with base path for GitHub Pages deployment at `https://alibaba.github.io/page-agent/`.
```tsx
// main.tsx
<Router base="/page-agent">
<PagesRouter />
</Router>
```
**Key rules:**
- Header and Footer live in `router.tsx` **outside** `<Switch>`, so they always see the root router context (`base="/page-agent"`)
- Docs pages are nested via `<Route path="/docs" nest>`, which creates a child context (`base="/page-agent/docs"`)
- Inside the docs nest, Link hrefs are relative to `/docs` (e.g. `href="/features/models"`, NOT `href="/docs/features/models"`)
- **Never use `~` prefix** in Link hrefs — it bypasses the base path entirely
- Doc page headings use `<Heading id="slug" level={2}>` for anchor links
### SPA on GitHub Pages
Instead of `404.html` redirects, the build copies `index.html` into every route directory. Add new routes to the `SPA_ROUTES` array in `vite.config.js`.
## Adding New Pages ## Adding New Pages
### Documentation Page ### Documentation Page
1. Create `src/pages/docs/<section>/<slug>/page.tsx` 1. Create `src/pages/docs/<section>/<slug>/page.tsx`
2. Add route to `src/router.tsx` with `<Header /> + <DocsLayout>` wrapper 2. Add route in `src/pages/docs/index.tsx`
3. Add navigation item to `pages/docs/Layout.tsx` 3. Add navigation item in `src/pages/docs/Layout.tsx`
4. Add path to `SPA_ROUTES` in `vite.config.js`
## Routing
Uses hash-based routing for static hosting:
```tsx
import { Router } from 'wouter'
import { useHashLocation } from 'wouter/use-hash-location'
;<Router hook={useHashLocation}>{/* routes */}</Router>
```
## Configuration Files ## Configuration Files
| File | Purpose | | File | Purpose |
| ----------------- | ----------------------- | | ----------------- | ----------------------- |
| `components.json` | shadcn/ui configuration | | `components.json` | shadcn/ui configuration |
| `vite.config.js` | Vite build settings | | `vite.config.js` | Vite build + SPA routes |
| `tsconfig.json` | TypeScript config | | `tsconfig.json` | TypeScript config |
## Commands ## Commands
```bash ```bash
npm start # Dev server (from root) npm start # Dev server (from root)
npm run build:website # Build website (from root) npm run build:website # Build website (from root)
``` ```
+13 -2
View File
@@ -45,13 +45,24 @@
</script> </script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root">
<div id="sk">
<p class="sk-text" id="sk-text">Loading...</p>
<style>
#sk{display:flex;align-items:center;justify-content:center;min-height:100vh;background:linear-gradient(135deg,#eff6ff,#f5f3ff)}
@media(prefers-color-scheme:dark){#sk{background:linear-gradient(135deg,#111827,#1f2937)}}
.sk-text{font:400 14px/1 system-ui,sans-serif;color:#94a3b8;animation:skf 2s ease-in-out infinite}
@keyframes skf{0%,100%{opacity:.6}50%{opacity:.3}}
</style>
</div>
</div>
<script type="module" src="./src/main.tsx"></script> <script type="module" src="./src/main.tsx"></script>
<script> <script>
// Dynamically update html lang attribute based on i18n detection
const updateHtmlLang = () => { const updateHtmlLang = () => {
const lang = localStorage.getItem('i18nextLng') || navigator.language || 'zh-CN' const lang = localStorage.getItem('i18nextLng') || navigator.language || 'zh-CN'
document.documentElement.lang = lang document.documentElement.lang = lang
const el = document.getElementById('sk-text')
if (el) el.textContent = lang.startsWith('zh') ? '加载中...' : 'Loading...'
} }
updateHtmlLang() updateHtmlLang()
window.addEventListener('storage', updateHtmlLang) window.addEventListener('storage', updateHtmlLang)
+12 -16
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/website", "name": "@page-agent/website",
"private": true, "private": true,
"version": "1.1.2", "version": "1.5.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
@@ -10,30 +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",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0",
"wouter": "^3.9.0"
},
"dependencies": {
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-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.14",
"@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.563.0", "lucide-react": "^0.577.0",
"motion": "^12.30.0", "motion": "^12.35.2",
"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.7.0", "simple-icons": "^16.11.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0",
"wouter": "^3.9.0"
} }
} }
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://alibaba.github.io/page-agent/sitemap.xml
@@ -5,10 +5,9 @@
*/ */
import * as React from 'react' import * as React from 'react'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Badge } from './badge'
// ============================================================================ // ============================================================================
// Types // Types
// ============================================================================ // ============================================================================
@@ -35,6 +34,8 @@ export interface APIReferenceProps {
description?: React.ReactNode description?: React.ReactNode
/** Property definitions */ /** Property definitions */
properties: PropDefinition[] properties: PropDefinition[]
/** Display variant: 'properties' for fields, 'methods' for methods */
variant?: 'properties' | 'methods'
/** Additional CSS classes */ /** Additional CSS classes */
className?: string className?: string
} }
@@ -43,7 +44,14 @@ export interface APIReferenceProps {
// Component // Component
// ============================================================================ // ============================================================================
export function APIReference({ title, description, properties, className }: APIReferenceProps) { export function APIReference({
title,
description,
properties,
variant = 'properties',
className,
}: APIReferenceProps) {
const isMethodsVariant = variant === 'methods'
return ( return (
<div className={cn('my-6', className)}> <div className={cn('my-6', className)}>
{title && ( {title && (
@@ -58,10 +66,10 @@ export function APIReference({ title, description, properties, className }: APIR
<thead> <thead>
<tr className="bg-gray-50 dark:bg-gray-800/50"> <tr className="bg-gray-50 dark:bg-gray-800/50">
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-300"> <th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-300">
Property {isMethodsVariant ? 'Method' : 'Property'}
</th> </th>
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-300"> <th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-300">
Type {isMethodsVariant ? 'Return Type' : 'Type'}
</th> </th>
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-300 hidden md:table-cell"> <th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-300 hidden md:table-cell">
Default Default
@@ -120,7 +128,7 @@ function PropRow({ name, type, required, defaultValue, description, status }: Pr
{/* Type */} {/* Type */}
<td className="px-4 py-3 align-top"> <td className="px-4 py-3 align-top">
<code className="font-mono text-xs text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded whitespace-nowrap"> <code className="font-mono text-xs text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded wrap-break-word">
{type} {type}
</code> </code>
</td> </td>
@@ -42,7 +42,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
return ( return (
<div <div
className={`group relative ${containerClasses} rounded-xl border shadow-2xl overflow-hidden ${className}`} className={`group relative ${containerClasses} rounded-xl border shadow-2xl my-4 overflow-hidden ${className}`}
> >
{/* 编辑器顶部栏 */} {/* 编辑器顶部栏 */}
{showHeader && ( {showHeader && (
+40 -4
View File
@@ -1,4 +1,4 @@
import { siGithub } from 'simple-icons' import { siGithub, siX } from 'simple-icons'
import { useLanguage } from '@/i18n/context' import { useLanguage } from '@/i18n/context'
@@ -12,10 +12,46 @@ 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"> <div className="text-gray-600 dark:text-gray-300 text-sm text-center md:text-left">
© 2026 page-agent. All rights reserved. <p>© 2026 page-agent. All rights reserved.</p>
</p> <p>
Built with by{' '}
<a
href="https://x.com/simonluvramen"
target="_blank"
rel="noopener noreferrer"
className="hover:text-gray-900 dark:hover:text-white transition-colors duration-200"
>
Simon
</a>
</p>
</div>
<div className="flex items-center space-x-6"> <div className="flex items-center space-x-6">
<a
href="https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md"
target="_blank"
rel="noopener noreferrer"
className="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-200 text-sm"
>
{isZh ? '使用条款与隐私' : 'Terms & Privacy'}
</a>
<a
href="https://x.com/simonluvramen"
target="_blank"
rel="noopener noreferrer"
className="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-200"
aria-label="X (Twitter)"
>
<svg
role="img"
viewBox="0 0 24 24"
className="w-4 h-4 fill-current"
aria-hidden="true"
>
<path d={siX.path} />
</svg>
</a>
<a <a
href="https://github.com/alibaba/page-agent" href="https://github.com/alibaba/page-agent"
target="_blank" target="_blank"
+23 -10
View File
@@ -3,6 +3,7 @@ import { useState } from 'react'
import { siGithub } from 'simple-icons' import { siGithub } from 'simple-icons'
import { Link } from 'wouter' import { Link } from 'wouter'
import { formatStars, useGitHubStars } from '@/hooks/useGitHubStars'
import { useLanguage } from '@/i18n/context' import { useLanguage } from '@/i18n/context'
import LanguageSwitcher from './LanguageSwitcher' import LanguageSwitcher from './LanguageSwitcher'
@@ -12,6 +13,7 @@ import { HyperText } from './ui/hyper-text'
export default function Header() { export default function Header() {
const { isZh } = useLanguage() const { isZh } = useLanguage()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const stars = useGitHubStars()
return ( return (
<> <>
@@ -23,7 +25,7 @@ export default function Header() {
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
{/* Logo */} {/* Logo */}
<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={isZh ? 'page-agent 首页' : 'page-agent home'} aria-label={isZh ? 'page-agent 首页' : 'page-agent home'}
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
@@ -34,8 +36,11 @@ export default function Header() {
className="w-10 h-10 rounded-xl group-hover:scale-110 transition-transform duration-200" className="w-10 h-10 rounded-xl group-hover:scale-110 transition-transform duration-200"
/> />
<div> <div>
<span className="text-base sm:text-xl font-bold text-gray-900 dark:text-white block leading-tight"> <span className="text-base sm:text-xl font-bold text-gray-900 dark:text-white leading-tight flex items-baseline gap-1.5">
page-agent page-agent
<span className="hidden sm:inline text-[10px] font-mono font-normal text-gray-400 dark:text-gray-500 tabular-nums before:content-['v']">
{import.meta.env.VERSION}
</span>
</span> </span>
<HyperText <HyperText
as="p" as="p"
@@ -44,7 +49,7 @@ export default function Header() {
animateOnHover={true} animateOnHover={true}
aria-hidden="true" aria-hidden="true"
> >
GUI Agent in your webpage AI Agent In Your Webpage
</HyperText> </HyperText>
</div> </div>
</Link> </Link>
@@ -56,7 +61,7 @@ export default function Header() {
aria-label="Mobile navigation" aria-label="Mobile navigation"
> >
<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={isZh ? '文档' : 'Docs'} aria-label={isZh ? '文档' : 'Docs'}
> >
@@ -66,7 +71,7 @@ export default function Header() {
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="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="flex items-center gap-1 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="GitHub" aria-label="GitHub"
> >
<svg <svg
@@ -77,6 +82,9 @@ export default function Header() {
> >
<path d={siGithub.path} /> <path d={siGithub.path} />
</svg> </svg>
{stars !== null && (
<span className="text-sm tabular-nums"> {formatStars(stars)}</span>
)}
</a> </a>
</nav> </nav>
@@ -86,11 +94,8 @@ export default function Header() {
role="navigation" role="navigation"
aria-label={isZh ? '文档' : 'Docs'} aria-label={isZh ? '文档' : 'Docs'}
> >
<span className="text-xs font-mono text-gray-400 dark:text-gray-500 tabular-nums before:content-['v']">
{import.meta.env.VERSION}
</span>
<Link <Link
href="~/docs/introduction/overview" href="/docs/introduction/overview"
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" />
@@ -112,6 +117,9 @@ export default function Header() {
<path d={siGithub.path} /> <path d={siGithub.path} />
</svg> </svg>
GitHub GitHub
{stars !== null && (
<span className="text-sm font-medium tabular-nums "> {formatStars(stars)}</span>
)}
</a> </a>
<ThemeSwitcher /> <ThemeSwitcher />
<LanguageSwitcher /> <LanguageSwitcher />
@@ -138,7 +146,7 @@ export default function Header() {
role="navigation" role="navigation"
> >
<Link <Link
href="~/docs/introduction/overview" href="/docs/introduction/overview"
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"
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
> >
@@ -161,6 +169,11 @@ export default function Header() {
<path d={siGithub.path} /> <path d={siGithub.path} />
</svg> </svg>
GitHub GitHub
{stars !== null && (
<span className="text-xs tabular-nums text-gray-400 dark:text-gray-500">
{formatStars(stars)}
</span>
)}
</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 />
@@ -0,0 +1,45 @@
import { ComponentPropsWithoutRef, useEffect, useRef } from 'react'
import { cn } from '@/lib/utils'
type Level = 2 | 3
interface HeadingProps extends Omit<ComponentPropsWithoutRef<'h2'>, 'children'> {
id: string
level?: Level
children: React.ReactNode
}
const levelStyles = {
2: { tag: 'h2', className: 'text-2xl font-semibold mb-4' },
3: { tag: 'h3', className: 'text-xl font-semibold mb-3' },
} as const
export function Heading({ id, level = 2, className, children, ...props }: HeadingProps) {
const ref = useRef<HTMLHeadingElement>(null)
const { tag: Tag, className: defaultClassName } = levelStyles[level]
useEffect(() => {
if (window.location.hash === `#${id}`) {
ref.current?.scrollIntoView({ behavior: 'smooth' })
}
}, [id])
return (
<Tag
ref={ref}
id={id}
className={cn('group relative scroll-mt-20', defaultClassName, className)}
{...props}
>
<a
href={`#${id}`}
className="absolute -left-5 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 text-gray-400 hover:text-blue-500 transition-opacity no-underline"
aria-label={`Link to ${id}`}
>
#
</a>
{children}
</Tag>
)
}
@@ -85,7 +85,7 @@ export function MagicCard({
`, `,
}} }}
/> />
<div className="bg-background absolute inset-px rounded-[inherit]" /> <div className="absolute inset-px rounded-[inherit] bg-white dark:bg-neutral-900" />
<motion.div <motion.div
className="pointer-events-none absolute inset-px rounded-[inherit] opacity-0 transition-opacity duration-300 group-hover:opacity-100" className="pointer-events-none absolute inset-px rounded-[inherit] opacity-0 transition-opacity duration-300 group-hover:opacity-100"
style={{ style={{
@@ -0,0 +1,74 @@
import { ComponentPropsWithoutRef } from 'react'
import { cn } from '@/lib/utils'
interface MarqueeProps extends ComponentPropsWithoutRef<'div'> {
/**
* Optional CSS class name to apply custom styles
*/
className?: string
/**
* Whether to reverse the animation direction
* @default false
*/
reverse?: boolean
/**
* Whether to pause the animation on hover
* @default false
*/
pauseOnHover?: boolean
/**
* Content to be displayed in the marquee
*/
children: React.ReactNode
/**
* Whether to animate vertically instead of horizontally
* @default false
*/
vertical?: boolean
/**
* Number of times to repeat the content
* @default 4
*/
repeat?: number
}
export function Marquee({
className,
reverse = false,
pauseOnHover = false,
children,
vertical = false,
repeat = 4,
...props
}: MarqueeProps) {
return (
<div
{...props}
className={cn(
'group flex [gap:var(--gap)] overflow-hidden p-2 [--duration:40s] [--gap:1rem]',
{
'flex-row': !vertical,
'flex-col': vertical,
},
className
)}
>
{Array(repeat)
.fill(0)
.map((_, i) => (
<div
key={i}
className={cn('flex shrink-0 justify-around [gap:var(--gap)]', {
'animate-marquee flex-row': !vertical,
'animate-marquee-vertical flex-col': vertical,
'group-hover:[animation-play-state:paused]': pauseOnHover,
'[animation-direction:reverse]': reverse,
})}
>
{children}
</div>
))}
</div>
)
}
+6 -7
View File
@@ -1,11 +1,10 @@
// Demo build (auto-init with demo LLM, for quick testing) // Demo build (auto-init with demo LLM, for quick testing)
export const CDN_DEMO_URL = export const CDN_DEMO_URL =
'https://cdn.jsdelivr.net/npm/page-agent@1.1.2/dist/iife/page-agent.demo.js' 'https://cdn.jsdelivr.net/npm/page-agent@1.5.8/dist/iife/page-agent.demo.js'
export const CDN_DEMO_CN_URL = export const CDN_DEMO_CN_URL =
'https://registry.npmmirror.com/page-agent/1.1.2/files/dist/iife/page-agent.demo.js' 'https://registry.npmmirror.com/page-agent/1.5.8/files/dist/iife/page-agent.demo.js'
// Demo LLM for website testing // Demo LLM for website testing (homepage quick trial uses flash)
export const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM' export const DEMO_MODEL = 'qwen3.5-flash'
export const DEMO_BASE_URL = export const DEMO_BASE_URL = 'https://page-ag-testing-ohftxirgbn.cn-shanghai.fcapp.run'
'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy' export const DEMO_API_KEY = 'NA'
export const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'

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