Compare commits

...

45 Commits

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-08 19:34:16 +00:00
Simon 4ee773bef9 chore(changelog): update for version 1.9.0 release 2026-06-08 22:58:56 +08:00
64JohnLee 7b623c5b22 chore(page-controller): remove accidental debug console.log calls
Two debug log statements were left in production code:

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 16:47:04 +08:00
linked-danis e525e4c6dd fix: report actual wait duration 2026-05-18 15:01:06 +02:00
linked-danis 903c2a736c fix: return booleans from scroll predicates 2026-05-18 15:00:52 +02:00
Simon c6f09375f8 fix(controller): isMainContentDark too aggressive 2026-04-20 20:31:50 -04:00
tageniu cc33297cc4 fix(controller): check html and body data attrs independently
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-20 20:31:45 -04:00
Shijie Sun 8556745645 feat(page-controller): enhance dark mode detection
Resolve the @TODO in checkDarkMode.ts by adding 3 new detection
strategies and expanding data-attribute coverage:

- Check data-color-mode, data-bs-theme, data-color-scheme attributes
- Read CSS color-scheme property and <meta name="color-scheme"> tag
- Inspect background of SPA containers (#app, #root, #__next, etc.)
- Detect light text color as a dark-theme signal
2026-04-20 20:14:07 -04:00
45 changed files with 1393 additions and 751 deletions
+2 -1
View File
@@ -39,4 +39,5 @@ package.json.bak
# AI
.claude
.cursor
.gemini
.gemini
.codex
+1
View File
@@ -1,5 +1,6 @@
{
"cSpell.words": [
"agentic",
"contenteditable",
"deepseek",
"historychange",
+5 -3
View File
@@ -13,7 +13,9 @@ The GUI Agent Living in Your Webpage. Control web interfaces with natural langua
<a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank"><b>📖 Docs</b></a> | <a href="https://news.ycombinator.com/item?id=47264138" target="_blank"><b>📢 HN Discussion</b></a> | <a href="https://x.com/simonluvramen" target="_blank"><b>𝕏 Follow on X</b></a>
<video id="demo-video" src="https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2" controls crossorigin muted></video>
<!-- demo video -->
https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2
---
@@ -50,8 +52,8 @@ Fastest way to try PageAgent with our free Demo LLM:
| Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.9.0/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.9.0/files/dist/iife/page-agent.demo.js |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.10.0/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.10.0/files/dist/iife/page-agent.demo.js |
Add `?autoInit=false` to load the script without creating the demo agent automatically. You can then instantiate it with `new window.PageAgent(...)`.
+93
View File
@@ -5,6 +5,99 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.10.0] - 2026-06-15
### Breaking Changes
- **Agent run lifecycle rework** - `stop()` is now async and resolves only after the run fully settles. Run status is decoupled from task outcome: a new `stopped` state was added, and LLM self-reported failures now end as `completed`. Lifecycle hooks re-throw instead of folding errors into the result, agent errors are recorded in history, and `agent.lastResult` was added.
### Features
- **Abortable JavaScript execution** - `execute_javascript` now honors the `AbortSignal`.
- **Leaner agent prompts** - Simplified the waiting-response flow and removed navigation-back instructions to reduce LLM cognitive load.
- **MultiPageAgent safety** - Disabled `ScriptExecutionTool` for `MultiPageAgent`.
### Improvements
- **Dark mode detection** - Refined detection heuristics and made `isMainContentDark` less aggressive by checking `html` and `body` data attributes independently.
- **Extension lifecycle robustness** - Drove heartbeat and running state from status changes, cleared stale activity on any non-running status, handled the stopped lifecycle state, and cleared `currentTabId` on `TabsController.init`.
### Bug Fixes
- **Accurate wait reporting** - Wait steps now report the actual wait duration.
- **Scroll predicates** - Scroll predicates now return booleans.
- **Docs** - Fixed the broken demo video on GitHub.
## [1.9.0] - 2026-06-08
### Features
- **Robust abort handling** - Rewrote the aborting system; sync tools, loop execution, and LLM clients now correctly respect task abort signals (`ctx.signal`). Also decoupled `AbortError` from `InvokeError` in `@page-agent/llms`.
- **Claude Opus 4.8 support** - Added support for Claude Opus 4.8 model.
### Improvements
- **Concurrency guard** - Prevented concurrent `execute()` calls on a single PageAgent/Core instance to avoid race conditions.
- **Model recommendations refresh** - Updated default and tested model list recommendations.
- **Test coverage** - Added comprehensive Vitest unit tests for the `@page-agent/llms` package.
- **Improved documentation** - Added website documentation for the `ctx.signal` abort contract and `execute()` concurrency rules.
### Bug Fixes
- **DTS bundle fix** - Fixed a packaging bug where global type declarations were incorrectly bundled into `.d.ts` outputs.
- **Website sidebar fix** - Normalized trailing slashes in the website's sidebar location comparison.
## [1.8.2] - 2026-05-11
### Features
- **IIFE demo control** - Added `showPanel` and `autoInit` switches to the IIFE CDN script to control whether the UI panel automatically displays or initializes on load.
### Improvements
- **Build toolchain modernization** - Upgraded build infrastructure to Vite 8.
### Bug Fixes
- **TypeScript `InvokeErrorType` fix** - Separated the value and type space for `InvokeErrorType` to resolve TypeScript compilation issues.
- **Website chunking fix** - Restored working code-splitting with `manualChunks` for the documentation website.
## [1.8.1] - 2026-04-27
### Features
- **GPT-5.4 & Qwen 3.6 support** - Added support for `gpt-5.4` and `qwen3.6-max/flash` in the recommended LLM list.
- **Custom LLM request hook** - Added a `transformRequestBody` hook to allow custom modification of payloads before sending requests to LLM providers.
### Improvements
- **Accessibility (a11y) enhancements** - Added descriptive accessible labels to `ConfigPanel` input fields and icon buttons in compliance with WCAG 4.1.2.
- **UI polish** - Improved `HistoryList` loading and empty states, and added helpful tooltips for actions.
- **Prompt caching guidance** - Added website documentation for prompt caching optimization.
- **Build speedups** - Added parallel build scripts to accelerate local development compilation.
### Bug Fixes
- **DeepSeek tool choice fix** - Disabled explicit `tool_choice` for DeepSeek models to avoid API compatibility errors.
- **MCP version advertising** - MCP server now correctly advertises its package version.
## [1.8.0] - 2026-04-15
### Breaking Changes
- **TypeScript 6 & ESLint 10 upgrade** - Major toolchain modernization. Upgraded the entire monorepo to TypeScript 6 and ESLint 10 with source-first monorepo resolution (library exports resolve to source files directly during local development).
### Improvements
- **MCP security hardening** - Bound the MCP HTTP + WebSocket server to `localhost` only.
- **Extension UI refinement** - Made the history panel height responsive to the viewport and improved the result card readability by increasing font size.
### Bug Fixes
- **SimulatorMask memory leak** - Fixed a memory leak by ensuring the `requestAnimationFrame` loop is cancelled when disposing the SimulatorMask.
- **Autofixer format fix** - Corrected the fallback action format for `autoFixer` when waiting.
- **IIFE scope protection** - Fixed a name collision in IIFE builds by preventing global helper function re-declarations.
## [1.7.1] - 2026-04-04
### Features
+5 -3
View File
@@ -13,7 +13,9 @@
<a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank"><b>📖 Docs</b></a> | <a href="https://news.ycombinator.com/item?id=47264138" target="_blank"><b>📢 HN Discussion</b></a> | <a href="https://x.com/simonluvramen" target="_blank"><b>𝕏 Follow on X</b></a>
<video id="demo-video" src="https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2" controls crossorigin muted></video>
<!-- demo video -->
https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2
---
@@ -49,8 +51,8 @@
| Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.9.0/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.9.0/files/dist/iife/page-agent.demo.js |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.10.0/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.10.0/files/dist/iife/page-agent.demo.js |
在 URL 后添加 `?autoInit=false` 可只加载脚本,不自动创建 Demo Agent;之后可通过 `new window.PageAgent(...)` 手动初始化。
+438 -450
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "root",
"private": true,
"version": "1.9.0",
"version": "1.10.0",
"type": "module",
"workspaces": [
"packages/page-controller",
@@ -45,12 +45,12 @@
"devDependencies": {
"@commitlint/cli": "^21.0.1",
"@commitlint/config-conventional": "^21.0.1",
"@eslint-react/eslint-plugin": "^5.8.6",
"@eslint-react/eslint-plugin": "^5.8.16",
"@eslint/js": "^10.0.1",
"@microsoft/api-extractor": "^7.58.7",
"@microsoft/api-extractor": "^7.58.8",
"@tailwindcss/vite": "^4.3.0",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^25.9.1",
"@types/node": "^25.9.2",
"@vitejs/plugin-react": "^6.0.2",
"chalk": "^5.6.2",
"concurrently": "^10.0.3",
@@ -61,7 +61,7 @@
"lint-staged": "^17.0.5",
"prettier": "^3.8.3",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.0",
"typescript-eslint": "^8.61.0",
"unplugin-dts": "^1.0.1",
"vite": "^8.0.14",
"vite-plugin-css-injected-by-js": "^5.0.1",
+4 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@page-agent/core",
"private": false,
"version": "1.9.0",
"version": "1.10.0",
"type": "module",
"main": "./src/PageAgentCore.ts",
"types": "./src/PageAgentCore.ts",
@@ -48,14 +48,15 @@
"homepage": "https://alibaba.github.io/page-agent/",
"scripts": {
"build": "vite build",
"test": "vitest run",
"dev:iife": "concurrently \"vite build --config vite.iife.config.js --watch\" \"npx serve dist/iife -p 5174\"",
"prepublishOnly": "node ../../scripts/pre-publish.js",
"postpublish": "node ../../scripts/post-publish.js"
},
"dependencies": {
"chalk": "^5.6.2",
"@page-agent/llms": "1.9.0",
"@page-agent/page-controller": "1.9.0"
"@page-agent/llms": "1.10.0",
"@page-agent/page-controller": "1.10.0"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
+365
View File
@@ -0,0 +1,365 @@
import type { BrowserState, PageController } from '@page-agent/page-controller'
import { describe, expect, it, vi } from 'vitest'
import * as z from 'zod/v4'
import { PageAgentCore, tool } from './PageAgentCore'
import type { ExecutionResult } from './types'
type TestFetch = (...args: Parameters<typeof globalThis.fetch>) => Promise<Response>
function agentResponse(args: unknown): Response {
return new Response(
JSON.stringify({
choices: [
{
finish_reason: 'tool_calls',
message: {
tool_calls: [
{
function: {
name: 'AgentOutput',
arguments: JSON.stringify(args),
},
},
],
},
},
],
usage: {},
})
)
}
function createPageController(): PageController {
const browserState: BrowserState = {
url: 'https://example.test/',
title: 'Test page',
header: '',
content: '',
footer: '',
}
return {
showMask: vi.fn(async () => {}),
hideMask: vi.fn(),
cleanUpHighlights: vi.fn(),
getLastUpdateTime: vi.fn(() => Date.now()),
getBrowserState: vi.fn(async () => browserState),
dispose: vi.fn(),
} as unknown as PageController
}
function createAgent(
customFetch: TestFetch,
options: Partial<ConstructorParameters<typeof PageAgentCore>[0]> = {}
): PageAgentCore {
return new PageAgentCore({
baseURL: 'https://llm.test',
model: 'test-model',
maxRetries: 0,
stepDelay: 0,
customFetch,
customSystemPrompt: 'test',
pageController: createPageController(),
...options,
})
}
function createFetchMock() {
return vi.fn<TestFetch>()
}
function onceActivity(
agent: PageAgentCore,
predicate: (detail: unknown) => boolean
): Promise<void> {
return new Promise((resolve) => {
const onActivity = (event: Event) => {
if (!predicate((event as CustomEvent).detail)) return
agent.removeEventListener('activity', onActivity)
resolve()
}
agent.addEventListener('activity', onActivity)
})
}
function isExecutingTool(detail: unknown, toolName: string): boolean {
return (
typeof detail === 'object' &&
detail !== null &&
'type' in detail &&
'tool' in detail &&
detail.type === 'executing' &&
detail.tool === toolName
)
}
function doneResponse(text: string, success = true): Response {
return agentResponse({ action: { done: { text, success } } })
}
function waitResponse(seconds = 10): Response {
return agentResponse({ action: { wait: { seconds } } })
}
/**
* Start a task that blocks on `wait`, returning once the tool is executing.
* The running promise is wrapped so awaiting this helper does not await the task.
*/
async function startBlockedTask(
agent: PageAgentCore,
task = 'first'
): Promise<{ result: Promise<ExecutionResult> }> {
const waitStarted = onceActivity(agent, (detail) => isExecutingTool(detail, 'wait'))
const result = agent.execute(task)
await waitStarted
return { result }
}
describe.concurrent('PageAgentCore lifecycle', () => {
describe('normal execution', () => {
it('runs a task to natural completion', async () => {
const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('all done'))
const agent = createAgent(fetchMock)
const result = await agent.execute('do something')
expect(result).toMatchObject({ success: true, data: 'all done' })
expect(agent.status).toBe('completed')
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('completes (not errors) when the LLM reports task failure', async () => {
const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('gave up', false))
const agent = createAgent(fetchMock)
const result = await agent.execute('do something')
expect(result).toMatchObject({ success: false, data: 'gave up' })
expect(agent.status).toBe('completed')
})
it('throws when a task is already running', async () => {
const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse())
const agent = createAgent(fetchMock)
const { result } = await startBlockedTask(agent)
await expect(agent.execute('second')).rejects.toThrow('A task is already running.')
await agent.stop()
await result
})
})
describe('stop', () => {
it('aborts the running task and keeps the agent reusable', async () => {
const fetchMock = createFetchMock()
.mockResolvedValueOnce(waitResponse())
.mockResolvedValueOnce(doneResponse('second task'))
const agent = createAgent(fetchMock)
const { result: firstTask } = await startBlockedTask(agent)
await agent.stop()
expect(agent.status).toBe('stopped')
await expect(firstTask).resolves.toMatchObject({ success: false, data: 'Task aborted' })
const secondTask = await agent.execute('second')
expect(secondTask).toMatchObject({ success: true, data: 'second task' })
expect(agent.status).toBe('completed')
})
it('resolves only after the run has fully settled', async () => {
const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse())
const agent = createAgent(fetchMock)
const { result } = await startBlockedTask(agent)
await agent.stop()
expect(agent.status).toBe('stopped')
await expect(result).resolves.toMatchObject({ success: false })
})
it('is a no-op when no task is running', async () => {
const agent = createAgent(createFetchMock())
await expect(agent.stop()).resolves.toBeUndefined()
await expect(agent.stop()).resolves.toBeUndefined()
expect(agent.status).toBe('idle')
})
})
describe('dispose', () => {
it('aborts the running task and blocks further execution', async () => {
const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse())
const agent = createAgent(fetchMock)
const { result: task } = await startBlockedTask(agent)
agent.dispose()
await expect(task).resolves.toMatchObject({ success: false, data: 'Task aborted' })
expect(agent.disposed).toBe(true)
await expect(agent.execute('again')).rejects.toThrow('has been disposed')
})
it('is idempotent', () => {
const agent = createAgent(createFetchMock())
expect(() => {
agent.dispose()
agent.dispose()
}).not.toThrow()
expect(agent.disposed).toBe(true)
})
})
describe('error handling', () => {
it('fails the task when the network request rejects', async () => {
const fetchMock = createFetchMock().mockRejectedValue(new Error('network down'))
const agent = createAgent(fetchMock)
const result = await agent.execute('do something')
expect(result.success).toBe(false)
expect(agent.status).toBe('error')
})
it('fails the task when a tool throws', async () => {
const fetchMock = createFetchMock().mockResolvedValue(agentResponse({ action: { boom: {} } }))
const agent = createAgent(fetchMock, {
customTools: {
boom: tool({
description: 'Always throws.',
inputSchema: z.object({}),
execute: async () => {
throw new Error('tool exploded')
},
}),
},
})
const result = await agent.execute('trigger tool error')
expect(result.success).toBe(false)
expect(agent.status).toBe('error')
})
it('re-throws and sets error status when onBeforeTask throws', async () => {
const agent = createAgent(createFetchMock(), {
onBeforeTask: async () => {
throw new Error('setup failed')
},
})
await expect(agent.execute('do something')).rejects.toThrow('setup failed')
expect(agent.status).toBe('error')
expect(agent.history.some((e) => e.type === 'error')).toBe(false)
})
it('re-throws and sets error status when onAfterTask throws', async () => {
const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('all done'))
const agent = createAgent(fetchMock, {
onAfterTask: async () => {
throw new Error('teardown failed')
},
})
await expect(agent.execute('do something')).rejects.toThrow('teardown failed')
expect(agent.status).toBe('error')
})
it('stays reusable after onBeforeTask throws', async () => {
const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('second'))
let failOnce = true
const agent = createAgent(fetchMock, {
onBeforeTask: async () => {
if (failOnce) {
failOnce = false
throw new Error('setup failed')
}
},
})
await expect(agent.execute('first')).rejects.toThrow('setup failed')
const result = await agent.execute('second')
expect(result).toMatchObject({ success: true, data: 'second' })
})
it('re-throws and sets error status when onBeforeStep throws', async () => {
const agent = createAgent(createFetchMock(), {
onBeforeStep: async () => {
throw new Error('before step failed')
},
})
await expect(agent.execute('do something')).rejects.toThrow('before step failed')
expect(agent.status).toBe('error')
expect(agent.history.some((e) => e.type === 'error')).toBe(false)
})
it('re-throws and sets error status when onAfterStep throws', async () => {
const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('all done'))
const agent = createAgent(fetchMock, {
onAfterStep: async () => {
throw new Error('after step failed')
},
})
await expect(agent.execute('do something')).rejects.toThrow('after step failed')
expect(agent.status).toBe('error')
expect(agent.history.some((e) => e.type === 'error')).toBe(false)
})
})
describe('cancellation edge cases', () => {
it('rejects a new task while a stop is still settling', async () => {
const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse())
const agent = createAgent(fetchMock)
const { result: firstTask } = await startBlockedTask(agent)
const stopped = agent.stop()
await expect(agent.execute('too early')).rejects.toThrow('A task is already running.')
await stopped
await expect(firstTask).resolves.toMatchObject({ success: false, data: 'Task aborted' })
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('discards a custom tool result that resolves after stop', async () => {
let resolveTool!: () => void
let notifyToolStarted!: () => void
const toolFinished = new Promise<void>((resolve) => {
resolveTool = resolve
})
const toolStarted = new Promise<void>((resolve) => {
notifyToolStarted = resolve
})
const fetchMock = createFetchMock().mockResolvedValue(
agentResponse({ action: { slow_tool: {} } })
)
const agent = createAgent(fetchMock, {
customTools: {
slow_tool: tool({
description: 'A tool that deliberately ignores cancellation.',
inputSchema: z.object({}),
execute: async () => {
notifyToolStarted()
await toolFinished
return 'ignored stop'
},
}),
},
})
const task = agent.execute('run slow tool')
await toolStarted
const stopped = agent.stop()
resolveTool()
await stopped
await expect(task).resolves.toMatchObject({ success: false, data: 'Task aborted' })
expect(agent.status).toBe('stopped')
})
})
})
+144 -110
View File
@@ -21,7 +21,7 @@ import type {
MacroToolInput,
MacroToolResult,
} from './types'
import { assert, fetchLlmsTxt, normalizeResponse, uid, waitFor } from './utils'
import { assert, fetchLlmsTxt, normalizeResponse, suppress, uid, waitFor } from './utils'
export { tool, type PageAgentTool } from './tools'
export type * from './types'
@@ -42,7 +42,7 @@ export type PageAgentCoreConfig = AgentConfig & { pageController: PageController
* - loop
*
* ## Event System
* - `statuschange` - Agent status transitions (idle → running → completed/error)
* - `statuschange` - Agent status transitions (idle → running → completed/error/stopped)
* - `historychange` - History events updated (persistent, part of agent memory)
* - `activity` - Real-time activity feedback (transient, for UI only)
* - `dispose` - Agent cleanup triggered
@@ -91,6 +91,10 @@ export class PageAgentCore extends EventTarget {
#abortController = new AbortController()
#observations: string[] = []
/** Resolves when the current run has fully settled. Awaited by `stop()`. */
#running: Promise<void> = Promise.resolve()
#lastResult: ExecutionResult | null = null
/** internal states during a single task execution */
#states = {
/** Accumulated wait time in seconds */
@@ -147,13 +151,19 @@ export class PageAgentCore extends EventTarget {
return this.#status
}
/** Result of the most recent run, or `null` before the first run completes. */
get lastResult(): ExecutionResult | null {
return this.#lastResult
}
/** Emit statuschange event */
#emitStatusChange(): void {
this.dispatchEvent(new Event('statuschange'))
}
/** Emit historychange event */
#emitHistoryChange(): void {
#emitHistoryChange(pushHistoricalEvent?: HistoricalEvent): void {
if (pushHistoricalEvent) this.history.push(pushHistoricalEvent)
this.dispatchEvent(new Event('historychange'))
}
@@ -183,14 +193,22 @@ export class PageAgentCore extends EventTarget {
this.#observations.push(content)
}
/** Stop the current task. Agent remains reusable. */
stop() {
this.pageController.cleanUpHighlights()
this.pageController.hideMask()
/**
* Stop the current task and wait until the run has fully settled (including lifecycle hooks).
* @note never await .stop() in a lifecycle hook.
*/
async stop(): Promise<void> {
if (this.#status !== 'running') return
this.#abortController.abort()
await this.#running
}
/**
* external errors (pre-checks/config/hooks) will threw;
* agent errors will be caught and added to history, and return a failed result
*/
async execute(task: string): Promise<ExecutionResult> {
// pre-checks
if (this.disposed) throw new Error('PageAgent has been disposed. Create a new instance.')
if (this.#status === 'running') throw new Error('A task is already running.')
if (!task) throw new Error('Task is required')
@@ -202,135 +220,158 @@ export class PageAgentCore extends EventTarget {
this.#observations = []
this.#states = { totalWaitTime: 0, lastURL: '', browserState: null }
this.#abortController = new AbortController()
const signal = this.#abortController.signal
let resolveRunning!: () => void
this.#running = new Promise<void>((r) => (resolveRunning = r))
this.#setStatus('running')
this.#emitHistoryChange()
// Disable ask_user tool if onAskUser is not set
if (!this.onAskUser) {
this.tools.delete('ask_user')
}
if (!this.onAskUser) this.tools.delete('ask_user')
const onBeforeStep = this.config.onBeforeStep
const onAfterStep = this.config.onAfterStep
const onBeforeTask = this.config.onBeforeTask
const onAfterTask = this.config.onAfterTask
try {
await onBeforeTask?.(this)
await this.pageController.showMask()
} catch (error) {
this.#setStatus('error')
throw error
}
const stepDelay = this.config.stepDelay ?? 0.4
const maxSteps = this.config.maxSteps
let step = 0
let taskSuccess: boolean
let taskResult: string
let taskResult: ExecutionResult
let finalStatus: AgentStatus = 'error'
while (true) {
try {
console.group(`step: ${step}`)
await suppress(() => this.pageController.showMask())
// graceful exit
try {
await onBeforeTask?.(this)
while (true) {
await onBeforeStep?.(this, step)
// observe
// handle internal agent errors
try {
console.group(`step: ${step}`)
console.log(chalk.blue.bold('👀 Observing...'))
// @note It's convenient to treat stepDelay as part of the next step.
// Maybe move it to a dedicated try block for better semantics?
if (step > 0) await waitFor(stepDelay, signal)
this.#states.browserState = await this.pageController.getBrowserState()
await this.#handleObservations(step)
signal.throwIfAborted()
// assemble prompts
// observe
const messages = [
{ role: 'system' as const, content: this.#getSystemPrompt() },
{ role: 'user' as const, content: await this.#assembleUserPrompt() },
]
console.log(chalk.blue.bold('👀 Observing...'))
const macroTool = { AgentOutput: this.#packMacroTool() }
this.#states.browserState = await this.pageController.getBrowserState()
await this.#handleObservations(step)
// invoke LLM
// assemble prompts
console.log(chalk.blue.bold('🧠 Thinking...'))
this.#emitActivity({ type: 'thinking' })
const messages = [
{ role: 'system' as const, content: this.#getSystemPrompt() },
{ role: 'user' as const, content: await this.#assembleUserPrompt() },
]
const result = await this.#llm.invoke(messages, macroTool, this.#abortController.signal, {
toolChoiceName: 'AgentOutput',
normalizeResponse: (res) => normalizeResponse(res, this.tools),
})
const macroTool = { AgentOutput: this.#packMacroTool() }
// assemble history
// invoke LLM
const macroResult = result.toolResult as MacroToolResult
const input = macroResult.input
const output = macroResult.output
const reflection: Partial<AgentReflection> = {
evaluation_previous_goal: input.evaluation_previous_goal,
memory: input.memory,
next_goal: input.next_goal,
}
const actionName = Object.keys(input.action)[0]
const action: AgentStepEvent['action'] = {
name: actionName,
input: input.action[actionName],
output: output,
console.log(chalk.blue.bold('🧠 Thinking...'))
this.#emitActivity({ type: 'thinking' })
const result = await this.#llm.invoke(messages, macroTool, signal, {
toolChoiceName: 'AgentOutput',
normalizeResponse: (res) => normalizeResponse(res, this.tools),
})
// assemble history
const macroResult = result.toolResult as MacroToolResult
const input = macroResult.input
const output = macroResult.output
const reflection: Partial<AgentReflection> = {
evaluation_previous_goal: input.evaluation_previous_goal,
memory: input.memory,
next_goal: input.next_goal,
}
const actionName = Object.keys(input.action)[0]
const action: AgentStepEvent['action'] = {
name: actionName,
input: input.action[actionName],
output: output,
}
this.#emitHistoryChange({
type: 'step',
stepIndex: step,
reflection,
action,
usage: result.usage,
rawResponse: result.rawResponse,
rawRequest: result.rawRequest,
})
if (actionName === 'done') {
const success = action.input?.success ?? false
const data = action.input?.text || 'no text provided'
console.log(chalk.green.bold('Task completed'), success, data)
taskResult = { success, data, history: this.history }
this.#lastResult = taskResult
finalStatus = 'completed'
break
}
} catch (error: unknown) {
// catch block must not throw error. otherwise the error may be overridden if finally block also throws error.
const isAbortError = (error as any)?.name === 'AbortError'
if (!isAbortError) console.error('Task failed', error)
const message = isAbortError ? 'Task aborted' : String(error)
this.#emitActivity({ type: 'error', message: message })
this.#emitHistoryChange({ type: 'error', message: message, rawResponse: error })
taskResult = { success: false, data: message, history: this.history }
this.#lastResult = taskResult
finalStatus = isAbortError ? 'stopped' : 'error'
break
} finally {
// finally block runs before the break above.
console.groupEnd()
// @note hook may throw error.
// which will override the `break` above and be handled as an external error.
// as expected.
await onAfterStep?.(this, this.history)
}
this.history.push({
type: 'step',
stepIndex: step,
reflection,
action,
usage: result.usage,
rawResponse: result.rawResponse,
rawRequest: result.rawRequest,
} as AgentStepEvent)
this.#emitHistoryChange()
await onAfterStep?.(this, this.history)
console.groupEnd()
if (actionName === 'done') {
taskSuccess = action.input?.success ?? false
taskResult = action.input?.text || 'no text provided'
console.log(chalk.green.bold('Task completed'), taskSuccess, taskResult)
step++
if (step > maxSteps) {
const message = 'Step count exceeded maximum limit'
console.error(message)
this.#emitActivity({ type: 'error', message: message })
this.#emitHistoryChange({ type: 'error', message: message })
taskResult = { success: false, data: message, history: this.history }
this.#lastResult = taskResult
finalStatus = 'error'
break
}
} catch (error: unknown) {
console.groupEnd()
const isAbortError = (error as any)?.name === 'AbortError'
if (!isAbortError) console.error('Task failed', error)
taskResult = isAbortError ? 'Task aborted' : String(error)
taskSuccess = false
this.#emitActivity({ type: 'error', message: taskResult })
this.history.push({ type: 'error', message: taskResult, rawResponse: error })
this.#emitHistoryChange()
break
}
} // while
step++
if (step > this.config.maxSteps) {
taskResult = 'Step count exceeded maximum limit'
taskSuccess = false
this.#emitActivity({ type: 'error', message: taskResult })
this.history.push({ type: 'error', message: taskResult })
this.#emitHistoryChange()
break
}
await onAfterTask?.(this, taskResult)
await waitFor(this.config.stepDelay ?? 0.4)
return taskResult
} catch (error) {
this.#emitActivity({ type: 'error', message: String(error) })
finalStatus = 'error'
throw error
} finally {
await suppress(() => this.pageController.cleanUpHighlights())
await suppress(() => this.pageController.hideMask())
this.#abortController.abort()
resolveRunning()
this.#setStatus(finalStatus)
}
this.#onDone(taskSuccess)
const result: ExecutionResult = {
success: taskSuccess,
data: taskResult,
history: this.history,
}
await onAfterTask?.(this, result)
return result
}
/**
@@ -605,13 +646,6 @@ export class PageAgentCore extends EventTarget {
return prompt
}
#onDone(success = true) {
this.pageController.cleanUpHighlights()
this.pageController.hideMask() // No await - fire and forget
this.#setStatus(success ? 'completed' : 'error')
this.#abortController.abort()
}
dispose() {
console.log('Disposing PageAgent...')
this.disposed = true
@@ -70,7 +70,6 @@ Strictly follow these rules while using the browser and navigating the web:
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
- All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed.
- If a captcha appears, tell user you can not solve captcha. Finish the task and ask user to solve it.
- If expected elements are missing, try scrolling, or navigating back.
- If the page is not fully loaded, use the `wait` action.
- Do not repeat one action for more than 3 times unless some conditions changed.
- If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field.
+10 -6
View File
@@ -61,11 +61,13 @@ tools.set(
execute: async function (this: PageAgentCore, input, { signal }) {
// try to subtract LLM calling time from the actual wait time
const lastTimeUpdate = await this.pageController.getLastUpdateTime()
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000)
const secondsSinceLastUpdate = (Date.now() - lastTimeUpdate) / 1000
const actualWaitTime = Math.max(0, input.seconds - secondsSinceLastUpdate)
console.log(`actualWaitTime: ${actualWaitTime} seconds`)
await waitFor(actualWaitTime, signal)
return `✅ Waited for ${input.seconds} seconds.`
const waitedSeconds = (secondsSinceLastUpdate + actualWaitTime).toFixed(2)
return `✅ Waited for ${waitedSeconds} seconds.`
},
})
)
@@ -181,12 +183,15 @@ tools.set(
'execute_javascript',
tool({
description:
'Execute JavaScript code on the current page. Supports async/await syntax. Use with caution!',
'Execute JavaScript code on the current page. Supports async/await syntax. Use with caution! ' +
'An `AbortSignal` named `signal` is available in scope: long-running async code MUST honor it ' +
'(e.g. `await fetch(url, { signal })`, or `signal.throwIfAborted()` in loops)',
inputSchema: z.object({
script: z.string(),
}),
execute: async function (this: PageAgentCore, input) {
const result = await this.pageController.executeJavascript(input.script)
execute: async function (this: PageAgentCore, input, { signal }) {
const result = await this.pageController.executeJavascript(input.script, signal)
signal.throwIfAborted()
return result.message
},
})
@@ -194,5 +199,4 @@ tools.set(
// @todo send_keys
// @todo upload_file
// @todo go_back
// @todo extract_structured_data
+2 -2
View File
@@ -262,9 +262,9 @@ export type HistoricalEvent =
| AgentErrorEvent
/**
* Agent execution status
* Agent lifecycle status.
*/
export type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
export type AgentStatus = 'idle' | 'running' | 'completed' | 'error' | 'stopped'
/**
* Agent activity - transient state for immediate UI feedback.
+12
View File
@@ -129,3 +129,15 @@ export function assert(condition: unknown, message?: string, silent?: boolean):
throw new Error(errorMessage)
}
}
/**
* Suppress errors from a function.
*/
export async function suppress<T>(fn: () => T | Promise<T>): Promise<Awaited<T> | undefined> {
try {
return await fn()
} catch (error) {
console.error(error)
return undefined
}
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
name: 'core',
include: ['src/**/*.test.ts'],
silent: 'passed-only',
},
})
+1 -1
View File
@@ -131,7 +131,7 @@ export type Execute = (task: string, config: ExecuteConfig) => Promise<Execution
`AgentStatus`
```typescript
type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
type AgentStatus = 'idle' | 'running' | 'completed' | 'error' | 'stopped'
```
`AgentActivity`
+12 -12
View File
@@ -1,7 +1,7 @@
{
"name": "@page-agent/ext",
"private": true,
"version": "1.9.0",
"version": "1.10.0",
"type": "module",
"scripts": {
"dev": "wxt",
@@ -10,15 +10,15 @@
"postinstall": "wxt prepare"
},
"devDependencies": {
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-hover-card": "^1.1.16",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-label": "^2.1.9",
"@radix-ui/react-separator": "^1.1.9",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-switch": "^1.3.0",
"@tailwindcss/vite": "^4.3.0",
"@types/chrome": "^0.1.42",
"@types/react": "^19.2.15",
"@types/chrome": "^0.1.43",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@wxt-dev/module-react": "^1.2.2",
"class-variance-authority": "^0.7.1",
@@ -30,7 +30,7 @@
"react": "^19.2.6",
"react-dom": "^19.2.6",
"rough-notation": "^0.5.1",
"simple-icons": "^16.21.0",
"simple-icons": "^16.23.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
@@ -38,10 +38,10 @@
"wxt": "^0.20.26"
},
"dependencies": {
"@page-agent/core": "1.9.0",
"@page-agent/llms": "1.9.0",
"@page-agent/page-controller": "1.9.0",
"@page-agent/ui": "1.9.0",
"@page-agent/core": "1.10.0",
"@page-agent/llms": "1.10.0",
"@page-agent/page-controller": "1.10.0",
"@page-agent/ui": "1.10.0",
"ai-motion": "^0.4.8",
"chalk": "^5.6.2"
},
+23 -27
View File
@@ -40,43 +40,27 @@ export class MultiPageAgent extends PageAgentCore {
const experimentalIncludeAllTabs = config.experimentalIncludeAllTabs ?? false
/**
* Project agent status into chrome.storage. The content script polls
* `isAgentRunning` + `agentHeartbeat` (eventually consistent by design).
*
* When the agent is in side-panel and user closed the side-panel.
* There is no chance for isAgentRunning to be set false.
* (unload event doesn't work well in side panel.)
* (I'm trying not to use long-lived connection because the lifecycle of a sw is hard to predict.)
* This heartbeat mechanism acts as a backup.
*/
let heartBeatInterval: null | number = null
let heartBeatInterval: number | null = null
super({
...config,
// Disabled: AbortSignal cannot cross contexts
experimentalScriptExecutionTool: false,
pageController: pageController as any,
customTools: customTools,
customSystemPrompt: systemPrompt,
onBeforeTask: async (agent) => {
await tabsController.init(agent.task, { includeInitialTab, experimentalIncludeAllTabs })
heartBeatInterval = window.setInterval(() => {
chrome.storage.local.set({
agentHeartbeat: Date.now(),
})
}, 1_000)
await chrome.storage.local.set({
isAgentRunning: true,
})
},
onAfterTask: async () => {
if (heartBeatInterval) {
window.clearInterval(heartBeatInterval)
heartBeatInterval = null
}
await chrome.storage.local.set({
isAgentRunning: false,
})
},
onBeforeStep: async (agent) => {
@@ -87,16 +71,28 @@ export class MultiPageAgent extends PageAgentCore {
onDispose: () => {
if (heartBeatInterval) {
window.clearInterval(heartBeatInterval)
clearInterval(heartBeatInterval)
heartBeatInterval = null
}
chrome.storage.local.set({
isAgentRunning: false,
})
chrome.storage.local.set({ isAgentRunning: false }).catch(console.error)
tabsController.dispose()
},
})
this.addEventListener('statuschange', () => {
const running = this.status === 'running'
if (running && !heartBeatInterval) {
heartBeatInterval = window.setInterval(() => {
void chrome.storage.local.set({ agentHeartbeat: Date.now() })
}, 1_000)
} else if (!running && heartBeatInterval) {
clearInterval(heartBeatInterval)
heartBeatInterval = null
}
chrome.storage.local.set({ isAgentRunning: running }).catch(console.error)
})
}
}
@@ -133,9 +133,7 @@ export class RemotePageController {
return this.remoteCallDomAction('scroll_horizontally', args)
}
async executeJavascript(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('execute_javascript', args)
}
// `execute_javascript` is intentionally not implemented: AbortSignal cannot cross context
/** @note Managed by content script via storage polling. */
async showMask(): Promise<void> {}
@@ -42,7 +42,7 @@ export class TabsController {
throw new Error('TabsController already disposed')
}
this.currentTabId = null
await this.updateCurrentTabId(null)
this.disposed = false
this.port = undefined
this.portRetries = 0
@@ -72,7 +72,6 @@ Strictly follow these rules while using the browser and navigating the web:
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
- All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed.
- If a captcha appears, tell user you can not solve captcha. Finish the task and ask user to solve it.
- If expected elements are missing, try scrolling, or navigating back.
- If the page is not fully loaded, use the `wait` action.
- Do not repeat one action for more than 3 times unless some conditions changed.
- If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field.
+1 -1
View File
@@ -80,7 +80,7 @@ export function useAgent(): UseAgentResult {
const handleStatusChange = (e: Event) => {
const newStatus = agent.status as AgentStatus
setStatus(newStatus)
if (newStatus === 'idle' || newStatus === 'completed' || newStatus === 'error') {
if (newStatus !== 'running') {
setActivity(null)
}
}
@@ -14,6 +14,7 @@ export function StatusDot({ status }: { status: AgentStatus }) {
running: 'bg-blue-500',
completed: 'bg-green-500',
error: 'bg-destructive',
stopped: 'bg-muted-foreground',
}[status]
const label = {
@@ -21,6 +22,7 @@ export function StatusDot({ status }: { status: AgentStatus }) {
running: 'Running',
completed: 'Done',
error: 'Error',
stopped: 'Stopped',
}[status]
return (
@@ -39,7 +39,7 @@ export default function App() {
if (
prev === 'running' &&
(status === 'completed' || status === 'error') &&
(status === 'completed' || status === 'error' || status === 'stopped') &&
history.length > 0 &&
currentTask
) {
+1 -1
View File
@@ -8,7 +8,7 @@ export interface SessionRecord {
id: string
task: string
history: HistoricalEvent[]
status: 'completed' | 'error'
status: 'completed' | 'error' | 'stopped'
createdAt: number
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@page-agent/llms",
"version": "1.9.0",
"version": "1.10.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
+1 -1
View File
@@ -3,6 +3,6 @@
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
},
"include": ["**/*.ts"],
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@page-agent/mcp",
"private": false,
"version": "1.9.0",
"version": "1.10.0",
"type": "module",
"bin": {
"page-agent-mcp": "src/index.js"
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "page-agent",
"private": false,
"version": "1.9.0",
"version": "1.10.0",
"type": "module",
"main": "./src/PageAgent.ts",
"types": "./src/PageAgent.ts",
@@ -54,10 +54,10 @@
"postpublish": "node ../../scripts/post-publish.js"
},
"dependencies": {
"@page-agent/core": "1.9.0",
"@page-agent/llms": "1.9.0",
"@page-agent/page-controller": "1.9.0",
"@page-agent/ui": "1.9.0",
"@page-agent/core": "1.10.0",
"@page-agent/llms": "1.10.0",
"@page-agent/page-controller": "1.10.0",
"@page-agent/ui": "1.10.0",
"chalk": "^5.6.2"
},
"peerDependencies": {
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@page-agent/page-controller",
"version": "1.9.0",
"version": "1.10.0",
"type": "module",
"main": "./src/PageController.ts",
"types": "./src/PageController.ts",
@@ -42,10 +42,14 @@
"homepage": "https://alibaba.github.io/page-agent/",
"scripts": {
"build": "vite build",
"test": "vitest run",
"prepublishOnly": "node ../../scripts/pre-publish.js",
"postpublish": "node ../../scripts/post-publish.js"
},
"dependencies": {
"ai-motion": "^0.4.8"
},
"devDependencies": {
"happy-dom": "^20.10.2"
}
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { PageController } from './PageController'
describe('PageController', () => {
it('constructs and exposes the current url', async () => {
const controller = new PageController()
expect(controller).toBeInstanceOf(PageController)
expect(await controller.getCurrentUrl()).toBe(window.location.href)
})
describe('executeJavascript', () => {
it('runs a script and returns its result', async () => {
const controller = new PageController()
const result = await controller.executeJavascript('return 1 + 2')
expect(result).toMatchObject({ success: true })
expect(result.message).toContain('3')
})
it('exposes the abort signal to the script scope', async () => {
const controller = new PageController()
const controllerSignal = new AbortController()
controllerSignal.abort()
const result = await controller.executeJavascript(
'return signal.aborted',
controllerSignal.signal
)
expect(result).toMatchObject({ success: true })
expect(result.message).toContain('true')
})
it('reports a syntax error as a failed result', async () => {
const controller = new PageController()
const result = await controller.executeJavascript('return (')
expect(result.success).toBe(false)
expect(result.message).toContain('❌')
})
})
})
@@ -376,13 +376,15 @@ export class PageController extends EventTarget {
}
/**
* Execute arbitrary JavaScript on the page
* Execute arbitrary JavaScript on the page.
* The optional `signal` is exposed to the script scope so cooperative code
* can abort promptly when the task is stopped.
*/
async executeJavascript(script: string): Promise<ActionResult> {
async executeJavascript(script: string, signal?: AbortSignal): Promise<ActionResult> {
try {
// Wrap script in async function to support await
const asyncFunction = eval(`(async () => { ${script} })`)
const result = await asyncFunction()
// Wrap script in async function to support await, exposing `signal`.
const asyncFunction = eval(`(async (signal) => { ${script} })`)
const result = await asyncFunction(signal)
return {
success: true,
message: `✅ Executed JavaScript. Result: ${result}`,
+14 -10
View File
@@ -336,11 +336,13 @@ export async function scrollVertically(scroll_amount: number, element?: HTMLElem
const dy = scroll_amount
const bigEnough = (el: HTMLElement) => el.clientHeight >= window.innerHeight * 0.5
const canScroll = (el: HTMLElement | null) =>
el &&
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowY) &&
el.scrollHeight > el.clientHeight &&
bigEnough(el)
const canScroll = (el: HTMLElement | null): boolean =>
Boolean(
el &&
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowY) &&
el.scrollHeight > el.clientHeight &&
bigEnough(el)
)
// @deprecated Heuristic container search.
// Unreliable in multi-panel layouts. Should guide LLMs to use indexed scroll for consistency.
@@ -479,11 +481,13 @@ export async function scrollHorizontally(scroll_amount: number, element?: HTMLEl
const dx = scroll_amount
const bigEnough = (el: HTMLElement) => el.clientWidth >= window.innerWidth * 0.5
const canScroll = (el: HTMLElement | null) =>
el &&
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowX) &&
el.scrollWidth > el.clientWidth &&
bigEnough(el)
const canScroll = (el: HTMLElement | null): boolean =>
Boolean(
el &&
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowX) &&
el.scrollWidth > el.clientWidth &&
bigEnough(el)
)
// @deprecated Same heuristic container search as scrollVertically.
// TODO: Remove once LLMs reliably use indexed scrolling via data-scrollable.
@@ -552,8 +552,6 @@ export default (
scrollData: scrollData,
})
console.log('scrollData!!!', scrollData)
return scrollData
}
@@ -209,7 +209,6 @@ export class SimulatorMask extends EventTarget {
dispose() {
this.#disposed = true
console.log('dispose SimulatorMask')
this.motion?.dispose()
this.wrapper.remove()
this.dispatchEvent(new Event('dispose'))
@@ -1,6 +1,25 @@
/**
* A comprehensive function to determine if the page is currently in a dark theme.
* Heuristic check. Only work for common patterns. Return false by default.
*/
export function isPageDark() {
try {
if (hasDarkModeClass()) return true
if (hasDarkModeDataAttribute()) return true
if (isColorSchemeDark()) return true
if (isBackgroundDark()) return true
if (isMainContentBackgroundDark()) return true
if (isTextColorLight()) return true
return false
} catch (error) {
console.warn('Error determining if page is dark:', error)
return false
}
}
/**
* Checks for common dark mode CSS classes on the html or body elements.
* @returns {boolean} - True if a common dark mode class is found.
*/
function hasDarkModeClass() {
const DEFAULT_DARK_MODE_CLASSES = ['dark', 'dark-mode', 'theme-dark', 'night', 'night-mode']
@@ -15,57 +34,47 @@ function hasDarkModeClass() {
}
}
// Some sites use data attributes
const darkThemeAttribute = htmlElement.getAttribute('data-theme')
if (darkThemeAttribute?.toLowerCase().includes('dark')) {
return true
return false
}
/**
* Some UI frameworks use data attributes to indicate theme
*/
function hasDarkModeDataAttribute() {
const htmlElement = document.documentElement
const bodyElement = document.body || document.documentElement // can be null in some cases
const dataAttrs = ['data-theme', 'data-color-mode', 'data-bs-theme', 'data-mui-color-scheme']
for (const attr of dataAttrs) {
const bodyValue = bodyElement?.getAttribute(attr)
const htmlValue = htmlElement.getAttribute(attr)
if (bodyValue?.toLowerCase() === 'dark' || htmlValue?.toLowerCase() === 'dark') {
return true
}
}
return false
}
/**
* Parses an RGB or RGBA color string and returns an object with r, g, b properties.
* @param {string} colorString - e.g., "rgb(34, 34, 34)" or "rgba(0, 0, 0, 0.5)"
* @returns {{r: number, g: number, b: number}|null}
* Checks the CSS `color-scheme` property and `<meta name="color-scheme">` tag.
* Only "dark"/"only dark" counts as dark; "light dark" is ambiguous and ignored.
*/
function parseRgbColor(colorString: string) {
const rgbMatch = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(colorString)
if (!rgbMatch) {
return null // Not a valid rgb/rgba string
}
return {
r: parseInt(rgbMatch[1]),
g: parseInt(rgbMatch[2]),
b: parseInt(rgbMatch[3]),
}
}
function isColorSchemeDark() {
// Check <meta name="color-scheme" content="dark">
const meta = document.querySelector<HTMLMetaElement>('meta[name="color-scheme"]')
const metaContent = meta?.content.toLowerCase()
if (metaContent === 'dark' || metaContent === 'only dark') return true
/**
* Determines if a color is "dark" based on its calculated luminance.
* @param {string} colorString - The CSS color string (e.g., "rgb(50, 50, 50)").
* @param {number} threshold - A value between 0 and 255. Colors with luminance below this will be considered dark. Default is 128.
* @returns {boolean} - True if the color is considered dark.
*/
function isColorDark(colorString: string, threshold = 128) {
if (!colorString || colorString === 'transparent' || colorString.startsWith('rgba(0, 0, 0, 0)')) {
return false // Transparent is not dark
}
const rgb = parseRgbColor(colorString)
if (!rgb) {
return false // Could not parse color
}
// Calculate perceived luminance using the standard formula
const luminance = 0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b
return luminance < threshold
// Check the computed color-scheme CSS property on :root
const rootStyle = window.getComputedStyle(document.documentElement)
const colorScheme = rootStyle.getPropertyValue('color-scheme').trim().toLowerCase()
return colorScheme === 'dark' || colorScheme === 'only dark'
}
/**
* Checks the background color of the body element to determine if the page is dark.
* @returns {boolean}
*/
function isBackgroundDark() {
// We check both <html> and <body> because some pages set the color on <html>
@@ -88,28 +97,85 @@ function isBackgroundDark() {
}
/**
* A comprehensive function to determine if the page is currently in a dark theme.
* It combines class checking and background color analysis.
* @returns {boolean} - True if the page is likely dark.
* Checks if the text color on the body is light, which implies a dark background.
*/
export function isPageDark() {
try {
// Strategy 1: Check for common dark mode classes
if (hasDarkModeClass()) {
return true
}
function isTextColorLight() {
/** Luminance (0-255) above which body text is considered light */
const LIGHT_TEXT_LUMINANCE = 200
// Strategy 2: Analyze the computed background color
if (isBackgroundDark()) {
return true
}
const bodyStyle = window.getComputedStyle(document.body || document.documentElement)
const luminance = getLuminance(bodyStyle.color)
// @TODO add more checks here, e.g., analyzing text color,
// or checking the background of major layout elements like <main> or #app.
// Light text has high luminance (e.g. white text on dark bg)
return luminance !== null && luminance > LIGHT_TEXT_LUMINANCE
}
return false
} catch (error) {
console.warn('Error determining if page is dark:', error)
return false
/**
* Checks the background color of major layout elements (#app, #root, etc.).
* Many SPAs render into a container that may have its own dark background while
* <body> remains transparent.
*/
function isMainContentBackgroundDark() {
const { innerWidth: vw, innerHeight: vh } = window
const minArea = vw * vh * 0.5
const selectors = ['#app', '#root', '#__next']
for (const selector of selectors) {
const el = document.querySelector(selector)
if (!el) continue
const rect = el.getBoundingClientRect()
if (rect.width * rect.height < minArea) continue
if (isColorDark(window.getComputedStyle(el).backgroundColor)) return true
}
return false
}
// --- utils ---
/**
* Parses an RGB or RGBA color string and returns an object with r, g, b properties.
* @param {string} colorString - e.g., "rgb(34, 34, 34)" or "rgba(0, 0, 0, 0.5)"
* @returns {{r: number, g: number, b: number}|null}
*/
function parseRgbColor(colorString: string) {
const rgbMatch = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(colorString)
if (!rgbMatch) {
return null // Not a valid rgb/rgba string
}
return {
r: parseInt(rgbMatch[1]),
g: parseInt(rgbMatch[2]),
b: parseInt(rgbMatch[3]),
}
}
/**
* Calculates the perceived luminance (0-255) of a CSS color string.
* @param {string} colorString - e.g., "rgb(50, 50, 50)" or "rgba(0, 0, 0, 0.5)"
* @returns {number|null} - The luminance, or null if the color is transparent or unparseable.
*/
function getLuminance(colorString: string): number | null {
if (!colorString || colorString === 'transparent' || colorString.startsWith('rgba(0, 0, 0, 0)')) {
return null // Transparent has no meaningful luminance
}
const rgb = parseRgbColor(colorString)
if (!rgb) {
return null // Could not parse color
}
// Standard perceived luminance formula
return 0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b
}
/**
* Determines if a color is "dark" based on its calculated luminance.
* @param {string} colorString - The CSS color string (e.g., "rgb(50, 50, 50)").
* @param {number} threshold - A value between 0 and 255. Colors with luminance below this will be considered dark. Default is 128.
*/
function isColorDark(colorString: string, threshold = 128) {
const luminance = getLuminance(colorString)
return luminance !== null && luminance < threshold
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
name: 'page-controller',
environment: 'happy-dom',
include: ['src/**/*.test.ts'],
silent: 'passed-only',
},
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@page-agent/ui",
"version": "1.9.0",
"version": "1.10.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
+21 -11
View File
@@ -100,10 +100,10 @@ export class Panel {
#handleStatusChange(): void {
const status = this.#agent.status
// Map agent status to UI indicator type
const indicatorType =
status === 'running' ? 'thinking' : status === 'idle' ? 'thinking' : status
this.#updateStatusIndicator(indicatorType)
// Map agent status to UI indicator. A `completed` run whose result reports
// failure shows as error; other statuses map to their own indicator.
const failed = status === 'completed' && this.#agent.lastResult?.success === false
this.#updateStatusIndicator(failed ? 'error' : status)
// Morph action button: running = stop (■), not running = close (X)
if (status === 'running') {
@@ -121,7 +121,7 @@ export class Panel {
}
// Handle completion
if (status === 'completed' || status === 'error') {
if (status === 'completed' || status === 'error' || status === 'stopped') {
if (!this.#isExpanded) {
this.#expand()
}
@@ -376,7 +376,7 @@ export class Panel {
}
const status = this.#agent.status
const isTaskEnded = status === 'completed' || status === 'error'
const isTaskEnded = status === 'completed' || status === 'error' || status === 'stopped'
// Only show input area after task completion if configured to do so
if (isTaskEnded) {
@@ -559,13 +559,23 @@ export class Panel {
}
#updateStatusIndicator(
type: 'thinking' | 'executing' | 'executed' | 'retrying' | 'completed' | 'error'
type:
| 'idle'
| 'running'
| 'thinking'
| 'executing'
| 'executed'
| 'retrying'
| 'completed'
| 'error'
| 'stopped'
): void {
// Clear all status classes
// `running` animates like thinking; `idle`/`stopped` use the neutral base.
const variant = type === 'running' ? 'thinking' : type
this.#indicator.className = styles.indicator
// Add corresponding status class
this.#indicator.classList.add(styles[type])
if (variant !== 'idle' && variant !== 'stopped') {
this.#indicator.classList.add(styles[variant])
}
}
#scrollToBottom(): void {
+6 -3
View File
@@ -22,14 +22,17 @@ export type AgentActivity =
* This enables decoupling and allows any agent implementation to work with Panel.
*
* Events:
* - 'statuschange': Agent status changed (idle/running/completed/error)
* - 'statuschange': Agent status changed
* - 'historychange': Historical events updated (persisted)
* - 'activity': Transient activity for immediate UI feedback (thinking/executing/etc)
* - 'dispose': Agent is being disposed
*/
export interface PanelAgentAdapter extends EventTarget {
/** Current agent status */
readonly status: 'idle' | 'running' | 'completed' | 'error'
readonly status: 'idle' | 'running' | 'completed' | 'error' | 'stopped'
/** Result of the most recent run, or `null` before the first run completes */
readonly lastResult: { success: boolean } | null
/** History of agent events */
readonly history: readonly {
@@ -71,7 +74,7 @@ export interface PanelAgentAdapter extends EventTarget {
execute(task: string): Promise<unknown>
/** Stop the current task (agent remains reusable) */
stop(): void
stop(): Promise<void>
/** Dispose the agent (terminal, cannot be reused) */
dispose(): void
+6 -6
View File
@@ -1,7 +1,7 @@
{
"name": "@page-agent/website",
"private": true,
"version": "1.9.0",
"version": "1.10.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
@@ -10,11 +10,11 @@
},
"devDependencies": {
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-separator": "^1.1.9",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/react": "^19.2.15",
"@radix-ui/react-switch": "^1.3.0",
"@radix-ui/react-tooltip": "^1.2.9",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -24,7 +24,7 @@
"react": "^19.2.6",
"react-dom": "^19.2.6",
"rough-notation": "^0.5.1",
"simple-icons": "^16.21.0",
"simple-icons": "^16.23.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
+2 -2
View File
@@ -1,8 +1,8 @@
// Demo build (auto-init with demo LLM, for quick testing)
export const CDN_DEMO_URL =
'https://cdn.jsdelivr.net/npm/page-agent@1.9.0/dist/iife/page-agent.demo.js'
'https://cdn.jsdelivr.net/npm/page-agent@1.10.0/dist/iife/page-agent.demo.js'
export const CDN_DEMO_CN_URL =
'https://registry.npmmirror.com/page-agent/1.9.0/files/dist/iife/page-agent.demo.js'
'https://registry.npmmirror.com/page-agent/1.10.0/files/dist/iife/page-agent.demo.js'
// Demo LLM for website testing (homepage quick trial uses flash)
export const DEMO_MODEL = 'qwen3.5-flash'
@@ -128,8 +128,8 @@ export default function CustomUIDocs() {
name: 'statuschange',
type: 'Event',
description: isZh
? 'Agent 状态变化 (idle → running → completed/error)'
: 'Agent status changes (idle → running → completed/error)',
? 'Agent 状态变化 (idle → running → completed/error/stopped)'
: 'Agent status changes (idle → running → completed/error/stopped)',
},
{
name: 'historychange',
@@ -281,8 +281,8 @@ const result = await agent.execute('Fill in the form with test data')`}
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-4">
<p className="text-amber-800 dark:text-amber-200 text-sm">
{isZh
? '这些接口高度实验性,可能在未来版本中发生变化。'
: 'These APIs are highly experimental and may change in future versions. '}
? '这些接口高度实验性,可能在未来版本中发生变化。钩子中抛出的错误会使任务失败并从 execute() 抛出;如不希望影响任务,请在钩子内部自行捕获。'
: 'These APIs are highly experimental and may change in future versions. Errors thrown from hooks fail the run and propagate from execute(); catch errors inside the hook if the task should not be affected.'}
</p>
</div>
<APIReference
@@ -325,7 +325,7 @@ const result = await agent.execute('Fill in the form with test data')`}
properties={[
{
name: 'status',
type: "'idle' | 'running' | 'completed' | 'error'",
type: "'idle' | 'running' | 'completed' | 'error' | 'stopped'",
description: isZh ? '当前 Agent 执行状态' : 'Current agent execution status',
},
{
@@ -378,10 +378,10 @@ const result = await agent.execute('Fill in the form with test data')`}
},
{
name: 'stop()',
type: 'void',
type: 'Promise<void>',
description: isZh
? '停止当前任务。Agent 仍可复用。'
: 'Stop the current task. Agent remains reusable.',
? '停止当前任务,并在任务完全结束后 resolve。Agent 仍可复用。'
: 'Stop the current task; resolves once the run has fully settled. Agent remains reusable.',
},
{
name: 'dispose()',
@@ -415,8 +415,8 @@ const result = await agent.execute('Fill in the form with test data')`}
name: 'statuschange',
type: 'Event',
description: isZh
? 'Agent 状态变化时触发 (idle → running → completed/error)'
: 'Fired when agent status changes (idle → running → completed/error)',
? 'Agent 状态变化时触发 (idle → running → completed/error/stopped)'
: 'Fired when agent status changes (idle → running → completed/error/stopped)',
},
{
name: 'historychange',