Compare commits

...

97 Commits

Author SHA1 Message Date
Simon 3a2f0a41ec chore(version): bump version to 1.9.0 2026-06-08 22:42:46 +08:00
Simon 64bb1f1a56 Merge pull request #541 from alibaba/docs/update-model-list
docs: update model list
2026-06-08 22:37:23 +08:00
Simon 2afcf13fbb feat: update model list 2026-06-08 22:34:33 +08:00
Simon f617728fc3 Merge pull request #540 from alibaba/feat/claude-opus-4-8
feat(llms): add Claude Opus 4.8 support
2026-06-08 22:27:53 +08:00
Simon 9376abfe2a Merge pull request #539 from alibaba/docs/abort-and-concurrency
docs(website): document ctx.signal abort contract and execute() concurrency
2026-06-08 22:26:19 +08:00
Simon 2f343bcc56 feat(llms): add Claude Opus 4.8 support
Strip temperature for claude-opus-4-8, add tests, list model on website.
2026-06-08 22:23:56 +08:00
Simon 4108f67079 docs(website): document ctx.signal abort contract and execute() concurrency 2026-06-08 22:19:25 +08:00
Simon 0ba3bbd67f Merge pull request #536 from alibaba/fix/prevent-concurrent-execute
fix(core): prevent concurrent execute() calls
2026-06-08 22:04:19 +08:00
Simon 552987eb56 fix(core): prevent concurrent execute() calls 2026-06-08 21:58:16 +08:00
Simon 99ef08ac79 Merge pull request #532 from alibaba/feat/rubost-abort
feat: robust aborting
2026-06-08 21:53:05 +08:00
Simon 4445bec08a fix: abort may affect onAfterTask; clean up while loop 2026-06-08 19:46:19 +08:00
Simon 6530f0ef40 chore: cleanup; rm get abortSignal 2026-06-08 17:40:44 +08:00
Simon f8676a5cc2 fix: throw unhonored AbortError & rm detection code for it 2026-06-08 17:27:05 +08:00
Simon 029f64e4bc Merge branch 'feat/rubost-abort' of https://github.com/alibaba/page-agent into feat/rubost-abort 2026-06-05 21:25:07 +08:00
Simon 78b6e2ad3c feat: all sync tools should respect aborting 2026-06-05 21:23:18 +08:00
Simon 112f9fff39 docs: planning 2026-06-05 20:27:30 +08:00
Simon 1c82030a21 Merge pull request #531 from alibaba/chore/vitest-llms
chore(llms): add vitest unit tests
2026-06-04 21:05:19 +08:00
Simon f378972ef8 docs: fix test workflow docs 2026-06-04 20:43:09 +08:00
Simon 68ae73d4e6 chore(llms): add vitest unit tests for llms package 2026-06-04 20:27:33 +08:00
Simon 874b302860 Merge pull request #530 from alibaba/fix/dts
fix: dts bug hotifx
2026-06-04 17:51:00 +08:00
Simon 90ff96ca15 fix: dts global declarations should not be bundled 2026-06-04 17:44:59 +08:00
Simon 3c0d55fa33 Merge pull request #527 from alibaba/fix/abort-error-contract
improve error handling
2026-06-03 23:40:48 +08:00
Simon 9891e02215 fix(llms): handle AbortError during .json() 2026-06-03 23:31:01 +08:00
Simon 823195ad94 fix(llms): add error handling for invalid JSON responses and schema validation 2026-06-03 23:14:12 +08:00
Simon fd12fb9f1b refactor(llms): split AbortError out of InvokeError 2026-06-03 23:00:33 +08:00
Simon fb8de08c39 fix(llms): fix abort handling and clean up retry logic 2026-06-03 22:44:54 +08:00
Simon 738a62e96c Merge pull request #526 from alibaba/chore/typo
chore: typo
2026-06-03 20:22:17 +08:00
Simon e7b93efda0 chore: typo
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 20:20:37 +08:00
Simon 0b6f0a6068 Merge pull request #524 from alibaba/dependabot/npm_and_yarn/development-major-a28a93d02a
chore(deps-dev): bump concurrently from 9.2.1 to 10.0.3 in the development-major group
2026-06-03 15:12:41 +08:00
Simon cc16d39e00 Merge pull request #525 from alibaba/fix/wxt-audit-overrides
fix: temporarily patch WXT audit vulnerabilities
2026-06-03 15:11:51 +08:00
Simon 7eceff26ef fix: patch WXT audit vulnerabilities 2026-06-03 15:05:34 +08:00
dependabot[bot] 61a791f759 chore(deps-dev): bump concurrently in the development-major group
Bumps the development-major group with 1 update: [concurrently](https://github.com/open-cli-tools/concurrently).


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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 06:05:01 +00:00
Simon 6ecd6e61f6 Merge pull request #506 from tolerious/fix/fix-bug-505
fix(website): normalize trailing slash in sidebar location comparison
2026-06-02 17:56:30 +08:00
Simon 1e13385f1f Merge pull request #520 from alibaba/chore/upgrade-deps
chore: upgrade deps
2026-06-02 17:16:54 +08:00
Simon dec9fe8b3d chore: update node version and lockfile 2026-06-02 17:11:18 +08:00
Simon ebd8ec5ae7 chore: upgrade deps 2026-05-28 16:06:15 +08:00
Toly 482190777c fix(website): normalize trailing slash in sidebar location comparison 2026-05-16 21:03:51 +08:00
Simon ebc785d1fe chore(version): bump version to 1.8.2 2026-05-11 19:40:30 +08:00
Simon ba932283f2 Merge pull request #501 from alibaba/feat/iife-cdn-without-auto-init
feat: add autoInit switch for demo CDN script
2026-05-11 19:39:19 +08:00
Simon f05bf68f61 docs: ?autoInit=false docs 2026-05-11 19:28:49 +08:00
Simon 35ab12a20a feat: add autoInit switch for IIFE demo 2026-05-11 19:20:52 +08:00
Simon fd7c54a695 Merge pull request #500 from alibaba/fix/demo-showpanel-type
fix(page-agent): keep demo showPanel out of config type
2026-05-11 19:06:35 +08:00
Simon eafd834daf fix(page-agent): keep demo showPanel out of config type 2026-05-11 19:05:27 +08:00
Feffery f575ae0fa4 feat: add showPanel switch to IIFE demo 2026-05-11 19:01:00 +08:00
Simon 797c9f8575 Merge pull request #499 from alibaba/chore/upgrade-dependencies
chore: upgrade dependencies
2026-05-11 17:23:36 +08:00
Simon fb3ac4d454 chore: upgrade dependencies 2026-05-11 17:19:23 +08:00
Simon c629d706ec chore: remove vite-bundle-analyzer 2026-04-28 16:46:05 +08:00
Simon 7896786311 Merge pull request #491 from alibaba/feat/vite8
Upgrade vite 8
2026-04-28 16:29:42 +08:00
Simon 820a2654ff fix: set minimal npm version to avoid peer: true 2026-04-28 16:24:50 +08:00
Simon 8d45ab8436 chore(deps): update package-lock.json 2026-04-28 16:15:16 +08:00
Simon 855020636b Merge branch 'main' into feat/vite8 2026-04-28 16:03:01 +08:00
Simon ff82603839 Merge pull request #490 from alibaba/fix/ts-value-space
fix(ts): `InvokeErrorType` separate confusing value/type space
2026-04-28 16:02:35 +08:00
Simon 90270fb76c fix(ts): InvokeErrorType separate confusing value/type space 2026-04-28 15:57:51 +08:00
Simon c9edeaea44 refactor(setup): upgrade vite8 2026-04-28 15:33:11 +08:00
Simon 757fe07b4d Merge pull request #489 from alibaba/fix/website-manual-chunks
fix(website): make manualChunks work
2026-04-28 14:44:29 +08:00
Simon f04fa706f7 fix(website): make manualChunks work 2026-04-28 14:42:34 +08:00
Simon 9daf914a89 Merge pull request #486 from alibaba/dependabot/npm_and_yarn/development-dependencies-82874c61ad
chore(deps-dev): bump the development-dependencies group with 7 updates
2026-04-28 14:21:07 +08:00
dependabot[bot] c8e5ff3010 chore(deps-dev): bump the development-dependencies group with 7 updates
Bumps the development-dependencies group with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [@commitlint/cli](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/cli) | `20.5.0` | `20.5.2` |
| [@microsoft/api-extractor](https://github.com/microsoft/rushstack/tree/HEAD/apps/api-extractor) | `7.58.6` | `7.58.7` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.2.3` | `4.2.4` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.0` | `8.59.1` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.8.0` | `1.11.0` |
| [simple-icons](https://github.com/simple-icons/simple-icons) | `16.17.0` | `16.18.0` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.2.3` | `4.2.4` |


Updates `@commitlint/cli` from 20.5.0 to 20.5.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.5.2/@commitlint/cli)

Updates `@microsoft/api-extractor` from 7.58.6 to 7.58.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.58.7/apps/api-extractor)

Updates `@tailwindcss/vite` from 4.2.3 to 4.2.4
- [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.4/packages/@tailwindcss-vite)

Updates `typescript-eslint` from 8.59.0 to 8.59.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.59.1/packages/typescript-eslint)

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

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

Updates `tailwindcss` from 4.2.3 to 4.2.4
- [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.4/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: "@commitlint/cli"
  dependency-version: 20.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@microsoft/api-extractor"
  dependency-version: 7.58.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.59.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: lucide-react
  dependency-version: 1.11.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: simple-icons
  dependency-version: 16.18.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: tailwindcss
  dependency-version: 4.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-27 22:52:43 +00:00
Simon f5591dd3ea chore(version): bump version to 1.8.1 2026-04-27 23:32:01 +08:00
Simon 62a0d988ce Merge pull request #484 from alibaba/feat/gpt-54-models
feat(models): support gpt-5.4 and update model list
2026-04-27 21:43:28 +08:00
Simon dc41969503 feat(models): support gpt-5.4 and refresh recommendations 2026-04-27 21:40:19 +08:00
Simon 9d9a4b9698 Merge pull request #483 from alibaba/docs/website-deepseek-v4-tested-models
docs(website): add DeepSeek v4 flash and pro to tested models
2026-04-27 20:49:41 +08:00
Simon b171bf210f docs(models): update DeepSeek model recommendation 2026-04-27 20:49:17 +08:00
Simon c39678dece docs(website): add DeepSeek v4 flash and pro to tested models 2026-04-27 20:43:59 +08:00
Simon 6c1f552606 chore: update skills 2026-04-27 20:36:17 +08:00
Simon dcad67d4b4 Merge pull request #482 from alibaba/fix/llms-deepseek-tool-choice
fix(llms): remove tool_choice for deepseek models
2026-04-27 20:35:37 +08:00
Simon 15f3af406c fix(llms): remove tool_choice for deepseek models 2026-04-27 20:32:53 +08:00
Simon b50abcc4ec Merge pull request #476 from fuyua9/fix/mcp-server-package-version
fix(mcp): advertise the package version
2026-04-27 20:22:09 +08:00
Simon 20f1b1f51e docs(website): add prompt caching guidance (#481) 2026-04-27 20:14:01 +08:00
Simon cd035cd30f docs(website): add prompt caching guidance 2026-04-27 20:12:25 +08:00
zzy-life a7cc935453 feat(llms): add transformRequestBody hook and improve prompt assembly (#480)
* feat(llms): add transformRequestBody hook and refine prompt handling

* docs(website): document transformRequestBody usage

* refactor(extension): keep function-valued config handling consistent in useAgent

* feat: simplify `transformRequestBody`

---------

Co-authored-by: Simon <10131203+gaomeng1900@users.noreply.github.com>
2026-04-27 19:46:46 +08:00
Simon 349609614b Merge pull request #473 from akinshaywai/chore/remove-debug-console-logs
chore: remove debug console.log statements from production code
2026-04-27 17:30:13 +08:00
Simon ad680cee99 Merge pull request #472 from akinshaywai/fix/security-hub-link-rel
fix: add rel="noopener noreferrer" to hub link in ConfigPanel
2026-04-27 17:26:50 +08:00
fuyua9 e95b2d6a28 fix(mcp): advertise package version 2026-04-25 10:38:57 +08:00
akinshaywai f55c1b984b chore: remove debug console.log statements from production code
Three debug logs left in production paths:

- useAgent.ts: logs task string on every agent execution
- HeroSection.tsx: logs getPageInstructions url/hint on every page visit
- HeroSection.tsx: logs the full execute() result to the console

None of these are error-level events and they leak internal details
to the browser console in production builds.
2026-04-24 01:49:22 +01:00
akinshaywai 5ab34a2f50 fix: add rel="noopener noreferrer" to hub link in ConfigPanel
The 'Manage Page Agent Hub' link uses target="_blank" without
rel="noopener noreferrer". This exposes the opener browsing context
to the target page via window.opener. All other external links in
ConfigPanel already include this attribute.
2026-04-24 01:48:37 +01:00
Simon de24d97a22 Merge pull request #465 from alibaba/dependabot/npm_and_yarn/development-dependencies-c41b1a6d79
chore(deps-dev): bump the development-dependencies group with 4 updates
2026-04-21 14:57:57 +08:00
dependabot[bot] 317b2a1f10 chore(deps-dev): bump the development-dependencies group with 4 updates
Bumps the development-dependencies group with 4 updates: [@microsoft/api-extractor](https://github.com/microsoft/rushstack/tree/HEAD/apps/api-extractor), [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite), [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) and [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss).


Updates `@microsoft/api-extractor` from 7.58.5 to 7.58.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.58.6/apps/api-extractor)

Updates `@tailwindcss/vite` from 4.2.2 to 4.2.3
- [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.3/packages/@tailwindcss-vite)

Updates `typescript-eslint` from 8.58.2 to 8.59.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.59.0/packages/typescript-eslint)

Updates `tailwindcss` from 4.2.2 to 4.2.3
- [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.3/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: "@microsoft/api-extractor"
  dependency-version: 7.58.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.2.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.59.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: tailwindcss
  dependency-version: 4.2.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 21:17:15 +00:00
Simon 7bc2fa4d59 Merge pull request #464 from alibaba/docs/website-model-list-qwen36
docs(website): add qwen3.6-max/flash to model list
2026-04-20 21:39:48 +08:00
Simon 9d5c5a8969 fix: rebuild package lock 2026-04-20 21:38:28 +08:00
Simon 052d302f16 chore: upgrade wxt to version 0.20.25 2026-04-20 21:33:18 +08:00
Simon bb4da7fd86 docs(website): add qwen3.6-max/flash to model list 2026-04-20 21:28:49 +08:00
Simon 359e6eadc8 Merge pull request #463 from alibaba/fix/ci
fix: upgrade wxt to fix npm ci
2026-04-18 01:20:55 +08:00
Simon e41a2609b3 fix: upgrade wxt to fix ci 2026-04-18 01:19:14 +08:00
Simon b23c909953 chore: dep vulnerability 2026-04-16 20:13:44 +08:00
Simon 79bf9e2aee Merge pull request #459 from alibaba/docs/cleanup-ai-aliases
chore: support claude code and antigravity
2026-04-16 20:07:46 +08:00
Simon d9d8d558a9 docs: clean up AI assistant alias config
Unignore CLAUDE.md and .agent, add CLAUDE.md alias file,
and simplify the developer guide alias section.
2026-04-16 20:05:08 +08:00
Simon 6afd3dcbdf Merge pull request #458 from alibaba/refactor/ci-and-prettier
refactor(setup): consolidate prettier config and streamline CI
2026-04-16 17:05:47 +08:00
Simon 5809fe3249 refactor(setup): consolidate prettier config and streamline CI
- Replace scattered .prettierignore files with a single root config
- Add scripts/ci.js to orchestrate lint, format, typecheck, commitlint, and build
- Simplify ci.yml to use ci.js and npm ci
- Apply prettier formatting to docs, locales, and HTML files
2026-04-16 17:04:23 +08:00
Simon e626e0b8ce Merge pull request #456 from akinshaywai/design/history-ui-improvements
design(ui): improve HistoryList loading/empty states and add button tooltips
2026-04-16 16:02:16 +08:00
Simon 284098ff0d chore: catch session loading errors 2026-04-16 15:57:04 +08:00
akinshaywai 90e1c297f4 style(ui): improve HistoryList loading/empty states and add button tooltips
- Replace plain 'Loading...' text in HistoryList with animated skeleton
  placeholder rows that match the shape of real history items
- Replace plain 'No history yet' text with a centred History icon +
  label for a more polished empty state
- Add aria-label and title to the Back button in HistoryList header
- Add aria-label and title to History and Settings buttons in main header
- Add aria-label and title to Send and Stop task buttons in footer
2026-04-16 15:56:57 +08:00
Simon 23adbea744 Merge pull request #455 from alibaba/chore/parallel-build-scripts
chore(scripts): add parallel build scripts
2026-04-15 17:45:52 +08:00
Simon 9af3a3f73e chore(scripts): add parallel build scripts
Replace sequential `npm run build --workspaces --if-present` with
concurrent execution via a reusable `parallelTask` utility. Full
`npm run build` now runs cleanup then all 7 build tasks in parallel.
2026-04-15 17:44:37 +08:00
Simon cc27ff9305 docs: update the dev guide 2026-04-15 17:05:09 +08:00
Olabode Felix Akinyemi 8a75c15557 Merge pull request #454 from akinshaywai/fix/a11y-config-panel-labels
fix(a11y): add accessible labels to ConfigPanel inputs and icon buttons
2026-04-15 16:31:24 +08:00
Simon e9eaf44bdd docs(ext): no more need to build libs before ext 2026-04-15 04:02:16 +08:00
74 changed files with 3516 additions and 3525 deletions
+13 -9
View File
@@ -1,7 +1,7 @@
--- ---
name: pre-impl-discussion name: pre-impl-discussion
description: "Conduct a thorough pre-implementation discussion before making significant changes. Use when the user wants to discuss, plan, or evaluate a change before implementing it — especially when they say words like 'discuss', 'evaluate', 'plan', or 'let's talk about'." description: "Conduct a thorough pre-implementation discussion before making significant changes. Use when the user wants to discuss, plan, or evaluate a change before implementing it — especially when they say words like 'discuss', 'evaluate', 'plan', or 'let's talk about'."
argument-hint: "Describe the change to evaluate" argument-hint: 'Describe the change to evaluate'
--- ---
# Pre-Implementation Discussion # Pre-Implementation Discussion
@@ -40,6 +40,7 @@ Do NOT skip this step. Do NOT rely on assumptions about what "most projects" do.
- **Search in parallel** to save time — batch independent queries - **Search in parallel** to save time — batch independent queries
Common pitfalls: Common pitfalls:
- Assuming compatibility without checking actual version constraints - Assuming compatibility without checking actual version constraints
- Confusing roadmap/aspirations with actual released state - Confusing roadmap/aspirations with actual released state
- Missing transitive constraints (a dependency of a dependency) - Missing transitive constraints (a dependency of a dependency)
@@ -53,6 +54,7 @@ Share a **brief** assessment. Tables work well for comparisons. Highlight **bloc
Surface the decisions the user needs to make. Present them as clear choices with trade-offs, not as a recommendation monologue. Surface the decisions the user needs to make. Present them as clear choices with trade-offs, not as a recommendation monologue.
For each decision point: For each decision point:
- What are the options? (2-3 max) - What are the options? (2-3 max)
- What does each option cost or give up? - What does each option cost or give up?
- What's your lean and why? (one sentence) - What's your lean and why? (one sentence)
@@ -66,6 +68,7 @@ The user will ask follow-up questions, raise concerns, or challenge assumptions.
- **Update your mental model** based on user feedback - **Update your mental model** based on user feedback
Common mistakes in this phase: Common mistakes in this phase:
- Repeating the full plan after every small clarification - Repeating the full plan after every small clarification
- Answering a narrow question with a broad redesign - Answering a narrow question with a broad redesign
- Treating user questions as confirmation to proceed - Treating user questions as confirmation to proceed
@@ -84,17 +87,18 @@ Keep it terse. Tables over paragraphs. No explanations the user already heard du
### 7. Wait for Confirmation ### 7. Wait for Confirmation
After presenting the final plan, **stop and wait**. The user will either: After presenting the final plan, **stop and wait**. The user will either:
- Confirm → then (and only then) proceed to implementation - Confirm → then (and only then) proceed to implementation
- Ask more questions → go back to step 5 - Ask more questions → go back to step 5
- Modify scope → update the plan and re-present - Modify scope → update the plan and re-present
## Anti-Patterns ## Anti-Patterns
| Don't | Do Instead | | Don't | Do Instead |
|-------|------------| | --------------------------------------------------- | ---------------------------------------- |
| Start implementation "to test" without confirmation | Present findings and wait | | Start implementation "to test" without confirmation | Present findings and wait |
| Repeat the full plan in every response | Answer the specific question asked | | Repeat the full plan in every response | Answer the specific question asked |
| Say "X should work" without checking | Say "I need to verify X" and research it | | Say "X should work" without checking | Say "I need to verify X" and research it |
| Assume project structure or constraints | Read the actual files | | Assume project structure or constraints | Read the actual files |
| Present one recommendation as the only option | Present 2-3 options with trade-offs | | Present one recommendation as the only option | Present 2-3 options with trade-offs |
| Write long paragraphs explaining trade-offs | Use tables and bullet points | | Write long paragraphs explaining trade-offs | Use tables and bullet points |
@@ -75,7 +75,7 @@ After successfully opening the PR, ALWAYS give a brief reminder in the user's la
## Validation Strategy ## Validation Strategy
- Default to enough validation to defend the PR, not the absolute minimum. - Default to enough validation to defend the PR, not the absolute minimum.
- Prefer area-specific build, lint, or test commands when they exist. - Run `npm run ci` to run all build and lint checks.
- Escalate to broader validation when the diff crosses packages, changes shared code, or affects release behavior. - Escalate to broader validation when the diff crosses packages, changes shared code, or affects release behavior.
- Never claim checks that did not actually run. - Never claim checks that did not actually run.
- You MAY note what you ran and the results below the Testing section in the PR body, but do NOT check the template checkboxes. - You MAY note what you ran and the results below the Testing section in the PR body, but do NOT check the template checkboxes.
+6 -14
View File
@@ -1,6 +1,6 @@
name: CI
permissions: permissions:
contents: read contents: read
name: CI
on: on:
push: push:
@@ -17,6 +17,8 @@ jobs:
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node.js ${{ matrix.node-version }} - name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v6 uses: actions/setup-node@v6
@@ -24,18 +26,8 @@ jobs:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'npm' cache: 'npm'
# test on default version of npm
# - 9.6~10.8 on node@20
# - 11.3~11.6 on node@24
- name: Node and NPM version
run: node --version && npm --version
- name: Install dependencies - name: Install dependencies
run: npm install run: npm ci
- name: Lint - name: CI checks
run: npx eslint . && npx prettier --check **/*.ts run: node scripts/ci.js
- name: Build
run: npm run build
+1 -3
View File
@@ -37,8 +37,6 @@ package.json.bak
.wxt .wxt
# AI # AI
.agent
.claude .claude
.cursor .cursor
.gemini .gemini
CLAUDE.md
+8
View File
@@ -0,0 +1,8 @@
# Prompt templates (formatted manually for LLM readability)
*prompt.md
# Generated
packages/extension/.wxt
# Vendored
**/components/ui
+2 -1
View File
@@ -22,7 +22,7 @@
"packages/*/node_modules": true "packages/*/node_modules": true
}, },
"markdownlint.config": { "markdownlint.config": {
// "comment": "Relaxed rules", // Relaxed rules
"default": true, "default": true,
"whitespace": false, "whitespace": false,
"line_length": false, "line_length": false,
@@ -36,6 +36,7 @@
"ol-prefix": false, "ol-prefix": false,
"no-duplicate-heading": false "no-duplicate-heading": false
}, },
"editor.defaultFormatter": "esbenp.prettier-vscode",
"js/ts.tsdk.path": "node_modules/typescript/lib", "js/ts.tsdk.path": "node_modules/typescript/lib",
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true "typescript.enablePromptUseWorkspaceTsdk": true
+18 -3
View File
@@ -21,9 +21,10 @@ Internal packages:
npm start # Start website dev server 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 typecheck # Typecheck all packages (libs + website + extension) npm run build:ext # Build and zip the extension package
npm run typecheck # Typecheck all packages
npm run test # Run unit tests across all workspaces
npm run lint # ESLint npm run lint # ESLint
npm run zip -w @page-agent/ext # Zip the extension package
``` ```
## Architecture ## Architecture
@@ -38,7 +39,7 @@ packages/
├── page-agent/ # npm: "page-agent" entry class (with UI + controller + demo builds) ├── page-agent/ # npm: "page-agent" entry class (with UI + controller + demo builds)
├── website/ # @page-agent/website (private) ├── website/ # @page-agent/website (private)
├── llms/ # @page-agent/llms ├── llms/ # @page-agent/llms
├── extension/ # Browser extension (WXT + React) ├── extension/ # Browser extension
├── page-controller/ # @page-agent/page-controller ├── page-controller/ # @page-agent/page-controller
└── ui/ # @page-agent/ui └── ui/ # @page-agent/ui
``` ```
@@ -125,6 +126,20 @@ const pageInfo = await this.pageController.getPageInfo()
2. Expose via async method in `PageController.ts` 2. Expose via async method in `PageController.ts`
3. Export from `packages/page-controller/src/index.ts` 3. Export from `packages/page-controller/src/index.ts`
## Testing
- **Framework**: Vitest (unit tests only for now; future E2E goes to `packages/e2e/` with Playwright)
- **Location**: co-located, `src/foo.test.ts` next to `src/foo.ts`
- **Coverage today**: `packages/llms` only — other packages will follow incrementally
- **Adding tests to a new package**: create `vitest.config.ts` in the package and add a `"test": "vitest run"` script. Root `npm test` and `node scripts/ci.js` pick it up through npm workspaces.
- **Template**: See @page-agent/llms
```bash
npm test # all packages with a test script
npm test -w @page-agent/llms # single package
cd packages/llms && npx vitest # watch mode in one package
```
## Code Standards ## Code Standards
- Explicit typing for exported/public APIs - Explicit typing for exported/public APIs
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+4 -2
View File
@@ -50,8 +50,10 @@ Fastest way to try PageAgent with our free Demo LLM:
| Mirrors | URL | | Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- | | ------- | ---------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.8.0/dist/iife/page-agent.demo.js | | 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.8.0/files/dist/iife/page-agent.demo.js | | China | https://registry.npmmirror.com/page-agent/1.9.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(...)`.
### NPM Installation ### NPM Installation
+23 -23
View File
@@ -16,22 +16,22 @@ appearance, race, religion, or sexual identity and orientation.
Examples of behavior that contributes to creating a positive environment Examples of behavior that contributes to creating a positive environment
include: include:
* Using welcoming and inclusive language - Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences - Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism - Gracefully accepting constructive criticism
* Focusing on what is best for the community - Focusing on what is best for the community
* Showing empathy towards other community members - Showing empathy towards other community members
Examples of unacceptable behavior by participants include: Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or - The use of sexualized language or imagery and unwelcome sexual attention or
advances advances
* Trolling, insulting/derogatory comments, and personal or political attacks - Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment - Public or private harassment
* Publishing others' private information, such as a physical or electronic - Publishing others' private information, such as a physical or electronic
address, without explicit permission address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a - Other conduct which could reasonably be considered inappropriate in a
professional setting professional setting
## Our Responsibilities ## Our Responsibilities
@@ -85,19 +85,19 @@ available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.ht
有助于创造正面环境的行为包括但不限于: 有助于创造正面环境的行为包括但不限于:
* 使用友好和包容性语言 - 使用友好和包容性语言
* 尊重不同的观点和经历 - 尊重不同的观点和经历
* 耐心地接受建设性批评 - 耐心地接受建设性批评
* 关注对社区最有利的事情 - 关注对社区最有利的事情
* 友善对待其他社区成员 - 友善对待其他社区成员
身为参与者不能接受的行为包括但不限于: 身为参与者不能接受的行为包括但不限于:
* 使用与性有关的言语或是图像,以及不受欢迎的性骚扰 - 使用与性有关的言语或是图像,以及不受欢迎的性骚扰
* 捣乱/煽动/造谣的行为或进行侮辱/贬损的评论,人身攻击及政治攻击 - 捣乱/煽动/造谣的行为或进行侮辱/贬损的评论,人身攻击及政治攻击
* 公开或私下的骚扰 - 公开或私下的骚扰
* 未经许可地发布他人的个人资料,例如住址或是电子地址 - 未经许可地发布他人的个人资料,例如住址或是电子地址
* 其他可以被合理地认定为不恰当或者违反职业操守的行为 - 其他可以被合理地认定为不恰当或者违反职业操守的行为
## 我们的责任 ## 我们的责任
+4 -3
View File
@@ -49,8 +49,10 @@
| Mirrors | URL | | Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- | | ------- | ---------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.8.0/dist/iife/page-agent.demo.js | | 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.8.0/files/dist/iife/page-agent.demo.js | | China | https://registry.npmmirror.com/page-agent/1.9.0/files/dist/iife/page-agent.demo.js |
在 URL 后添加 `?autoInit=false` 可只加载脚本,不自动创建 Demo Agent;之后可通过 `new window.PageAgent(...)` 手动初始化。
### NPM 安装 ### NPM 安装
@@ -106,4 +108,3 @@ this project possible.
--- ---
**⭐ 如果觉得 PageAgent 有用或有趣,请给项目点个星!** **⭐ 如果觉得 PageAgent 有用或有趣,请给项目点个星!**
+21 -33
View File
@@ -10,52 +10,41 @@ For contribution rules and expectations, see [../CONTRIBUTING.md](../CONTRIBUTIN
1. **Prerequisites** 1. **Prerequisites**
- `macOS` / `Linux` / `WSL` - `macOS` / `Linux` / `WSL`
- `node.js >= 24` with `npm >= 11` - `node.js ^22.13 || >=24` with `npm >= 11`
- An editor that supports `ts/eslint/prettier` - An editor that supports `ts/eslint/prettier`
- Make sure `eslint`, `prettier` and `commitlint` work well. Un-linted code won't pass the CI. - Make sure `eslint`, `prettier` and `commitlint` work well. Un-linted code won't pass the CI.
2. **Setup** 2. **Setup**
```bash ```bash
npm i npm i # Or `npm ci` if you don't want to change the lockfile
npm start # Start demo and documentation site npm start # Start website dev server
npm run build # Build libs and website npm run build # Build everything
``` ```
## 📦 Project Structure ## 📦 Project Structure
This is a **monorepo** with npm workspaces containing **4 main packages**: This is a **monorepo** with npm workspaces.
- **Page Agent** (`packages/page-agent/`) - Main entry with built-in UI Panel, published as `page-agent` on npm Published packages:
- **Page Agent** (`packages/page-agent/`) - Main entry with built-in UI Panel (npm: `page-agent`)
- **MCP** (`packages/mcp/`) - MCP server for browser control via Page Agent extension (npm: `@page-agent/mcp`)
- **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 - **LLMs** (`packages/llms/`) - LLM client with reflection-before-action mental model
- **Website** (`packages/website/`) - React documentation and landing page. Also as demo and test page for the core lib. private package `@page-agent/website` - **Page Controller** (`packages/page-controller/`) - DOM operations and visual feedback, independent of LLM
- **UI** (`packages/ui/`) - Panel and i18n, decoupled from PageAgent
> We use a simplified monorepo solution with `native npm-workspace + ts reference + vite alias`. No fancy tooling. Hoisting is required. Applications:
>
> - When developing. Use alias so that we don't have to pre-build. - **Extension** (`packages/extension/`) - Browser extension (WXT + React)
> - When bundling. Use external and disable ts `paths` alias. - **Website** (`packages/website/`) - React docs, landing page, and dev playground (private)
> - When bundling `IIFE` and `Website`. Bundle everything together.
> Source-first monorepo with `npm workspaces + ts references + vite alias`. Library `package.json` exports point to `src/*.ts` during development, and point to `dist/*.js` when published. `workspaces` in root `package.json` must be in topological order.
## 🤖 AGENTS.md Alias ## 🤖 AGENTS.md Alias
If your 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`)
```markdown
@AGENTS.md
```
- antigravity (`.agent/rules/alias.md`)
```markdown
---
trigger: always_on
---
@../../AGENTS.md
```
## 🔧 Development Workflows ## 🔧 Development Workflows
@@ -85,9 +74,8 @@ If your AI assistant does not support [AGENTS.md](https://agents.md/). Add an al
### Extension Development ### Extension Development
```bash ```bash
# make sure you ran `npm run build:libs` first and every time you changed the core libs npm run dev:ext
npm run dev -w @page-agent/ext npm run build:ext
npm run zip -w @page-agent/ext
``` ```
- Update `packages/extension/docs/extension_api.md` for API integration details - Update `packages/extension/docs/extension_api.md` for API integration details
+1659 -2923
View File
File diff suppressed because it is too large Load Diff
+33 -25
View File
@@ -1,7 +1,7 @@
{ {
"name": "root", "name": "root",
"private": true, "private": true,
"version": "1.8.0", "version": "1.9.0",
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
"packages/page-controller", "packages/page-controller",
@@ -22,51 +22,59 @@
}, },
"homepage": "https://alibaba.github.io/page-agent/", "homepage": "https://alibaba.github.io/page-agent/",
"engines": { "engines": {
"node": "^22.13.0 || >=24" "node": "^22.22.1 || >=24",
"npm": "^11.6.3"
}, },
"scripts": { "scripts": {
"start": "npm run dev --workspace=@page-agent/website", "start": "npm run dev --workspace=@page-agent/website",
"dev:ext": "npm run dev -w @page-agent/ext", "dev:ext": "npm run dev -w @page-agent/ext",
"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": "node scripts/build.js",
"build:libs": "npm run build --workspaces --if-present", "build:libs": "node scripts/build-libs.js",
"build:website": "npm run build:website --workspace=@page-agent/website", "build:website": "npm run build:website --workspace=@page-agent/website",
"build:ext": "npm run build:libs && npm run zip -w @page-agent/ext", "build:ext": "npm run zip -w @page-agent/ext",
"version": "node scripts/sync-version.js", "version": "node scripts/sync-version.js",
"postpublish": "npm run postpublish --workspaces --if-present", "postpublish": "npm run postpublish --workspaces --if-present",
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json && tsc --noEmit -p packages/extension/tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.typecheck.json && tsc --noEmit -p packages/extension/tsconfig.json",
"test": "npm test --workspaces --if-present",
"lint": "eslint .", "lint": "eslint .",
"ci": "node scripts/ci.js",
"cleanup": "rm -rf packages/*/dist && rm -rf packages/*/.output", "cleanup": "rm -rf packages/*/dist && rm -rf packages/*/.output",
"prepare": "husky || true" "prepare": "husky || true"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^20.5.0", "@commitlint/cli": "^21.0.1",
"@commitlint/config-conventional": "^20.5.0", "@commitlint/config-conventional": "^21.0.1",
"@eslint-react/eslint-plugin": "^4.2.3", "@eslint-react/eslint-plugin": "^5.8.6",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@microsoft/api-extractor": "^7.58.2", "@microsoft/api-extractor": "^7.58.7",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.3.0",
"@trivago/prettier-plugin-sort-imports": "^6.0.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^25.6.0", "@types/node": "^25.9.1",
"@vitejs/plugin-react-swc": "^4.3.0", "@vitejs/plugin-react": "^6.0.2",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"concurrently": "^9.2.1", "concurrently": "^10.0.3",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"eslint": "^10.2.0", "eslint": "^10.4.0",
"globals": "^17.5.0", "globals": "^17.6.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.4.0", "lint-staged": "^17.0.5",
"prettier": "^3.8.2", "prettier": "^3.8.3",
"typescript": "^6.0.2", "typescript": "^6.0.3",
"typescript-eslint": "^8.58.2", "typescript-eslint": "^8.60.0",
"unplugin-dts": "^1.0.0-beta.6", "unplugin-dts": "^1.0.1",
"vite": "^7.3.2", "vite": "^8.0.14",
"vite-bundle-analyzer": "^1.3.7", "vite-plugin-css-injected-by-js": "^5.0.1",
"vite-plugin-css-injected-by-js": "^4.0.1" "vitest": "^4.1.8"
}, },
"overrides": { "overrides": {
"typescript": "^6.0.2", "typescript": "^6.0.3",
"@vitejs/plugin-react": "^5.2.0" "node-notifier": {
"uuid": "11.1.1"
},
"web-ext-run": {
"tmp": "0.2.7"
}
}, },
"lint-staged": { "lint-staged": {
"*.{js,ts,cjs,cts,mjs,mts}": [ "*.{js,ts,cjs,cts,mjs,mts}": [
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/core", "name": "@page-agent/core",
"private": false, "private": false,
"version": "1.8.0", "version": "1.9.0",
"type": "module", "type": "module",
"main": "./src/PageAgentCore.ts", "main": "./src/PageAgentCore.ts",
"types": "./src/PageAgentCore.ts", "types": "./src/PageAgentCore.ts",
@@ -54,13 +54,13 @@
}, },
"dependencies": { "dependencies": {
"chalk": "^5.6.2", "chalk": "^5.6.2",
"@page-agent/llms": "1.8.0", "@page-agent/llms": "1.9.0",
"@page-agent/page-controller": "1.8.0" "@page-agent/page-controller": "1.9.0"
}, },
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.0 || ^4.0.0" "zod": "^3.25.0 || ^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"zod": "^4.3.6" "zod": "^4.4.3"
} }
} }
+65 -79
View File
@@ -73,14 +73,21 @@ export class PageAgentCore extends EventTarget {
disposed = false disposed = false
/** /**
* Callback for when agent needs user input (ask_user tool) * Called when the agent needs to ask the user questions.
* If not set, ask_user tool will be disabled * If unset, the `ask_user` tool will be disabled.
* Implementations should reject the promise when `signal` aborts.
* @example onAskUser: (q) => window.prompt(q) || '' * @example onAskUser: (q) => window.prompt(q) || ''
*/ */
onAskUser?: (question: string) => Promise<string> onAskUser?: (question: string, options?: { signal: AbortSignal }) => Promise<string>
#status: AgentStatus = 'idle' #status: AgentStatus = 'idle'
#llm: LLM #llm: LLM
/**
* Task cancellation primitive: its signal reaches the LLM fetch, tools
* (via `ctx.signal`) and async callbacks. Aborted only by `stop`/`dispose`
* (during a task) or task setup, always WITHOUT a reason so `signal.reason`
* stays a standard `AbortError`.
*/
#abortController = new AbortController() #abortController = new AbortController()
#observations: string[] = [] #observations: string[] = []
@@ -103,11 +110,14 @@ export class PageAgentCore extends EventTarget {
this.tools = new Map(tools) this.tools = new Map(tools)
this.pageController = config.pageController this.pageController = config.pageController
// Listen to LLM retry events
this.#llm.addEventListener('retry', (e) => { this.#llm.addEventListener('retry', (e) => {
const { attempt, maxAttempts } = (e as CustomEvent).detail const { attempt, maxAttempts, lastError } = (e as CustomEvent).detail
this.#emitActivity({ type: 'retrying', attempt, maxAttempts }) this.#emitActivity({ type: 'retrying', attempt, maxAttempts })
// Also push to history for panel rendering this.history.push({
type: 'error',
message: String(lastError),
rawResponse: (lastError as InvokeError).rawResponse,
})
this.history.push({ this.history.push({
type: 'retry', type: 'retry',
message: `LLM retry attempt ${attempt} of ${maxAttempts}`, message: `LLM retry attempt ${attempt} of ${maxAttempts}`,
@@ -116,19 +126,6 @@ export class PageAgentCore extends EventTarget {
}) })
this.#emitHistoryChange() this.#emitHistoryChange()
}) })
this.#llm.addEventListener('error', (e) => {
const error = (e as CustomEvent).detail.error as Error | InvokeError
if ((error as any)?.rawError?.name === 'AbortError') return
const message = String(error)
this.#emitActivity({ type: 'error', message })
// Also push to history for panel rendering
this.history.push({
type: 'error',
message,
rawResponse: (error as InvokeError).rawResponse,
})
this.#emitHistoryChange()
})
if (this.config.customTools) { if (this.config.customTools) {
for (const [name, tool] of Object.entries(this.config.customTools)) { for (const [name, tool] of Object.entries(this.config.customTools)) {
@@ -195,10 +192,20 @@ export class PageAgentCore extends EventTarget {
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 (this.disposed) throw new Error('PageAgent has been disposed. Create a new instance.')
if (this.#status === 'running') throw new Error('A task is already running.')
if (!task) throw new Error('Task is required') if (!task) throw new Error('Task is required')
this.task = task this.task = task
this.taskId = uid() this.taskId = uid()
this.history = []
this.#observations = []
this.#states = { totalWaitTime: 0, lastURL: '', browserState: null }
this.#abortController = new AbortController()
this.#setStatus('running')
this.#emitHistoryChange()
// Disable ask_user tool if onAskUser is not set // Disable ask_user tool if onAskUser is not set
if (!this.onAskUser) { if (!this.onAskUser) {
this.tools.delete('ask_user') this.tools.delete('ask_user')
@@ -209,25 +216,17 @@ export class PageAgentCore extends EventTarget {
const onBeforeTask = this.config.onBeforeTask const onBeforeTask = this.config.onBeforeTask
const onAfterTask = this.config.onAfterTask const onAfterTask = this.config.onAfterTask
await onBeforeTask?.(this) try {
await onBeforeTask?.(this)
// Show mask await this.pageController.showMask()
await this.pageController.showMask() } catch (error) {
this.#setStatus('error')
if (this.#abortController) { throw error
this.#abortController.abort()
this.#abortController = new AbortController()
} }
this.history = []
this.#setStatus('running')
this.#emitHistoryChange()
this.#observations = []
// Reset internal states
this.#states = { totalWaitTime: 0, lastURL: '', browserState: null }
let step = 0 let step = 0
let taskSuccess: boolean
let taskResult: string
while (true) { while (true) {
try { try {
@@ -289,63 +288,49 @@ export class PageAgentCore extends EventTarget {
} as AgentStepEvent) } as AgentStepEvent)
this.#emitHistoryChange() this.#emitHistoryChange()
//
await onAfterStep?.(this, this.history) await onAfterStep?.(this, this.history)
console.groupEnd() console.groupEnd()
// finish task if done
if (actionName === 'done') { if (actionName === 'done') {
const success = action.input?.success ?? false taskSuccess = action.input?.success ?? false
const text = action.input?.text || 'no text provided' taskResult = action.input?.text || 'no text provided'
console.log(chalk.green.bold('Task completed'), success, text) console.log(chalk.green.bold('Task completed'), taskSuccess, taskResult)
this.#onDone(success) break
const result: ExecutionResult = {
success,
data: text,
history: this.history,
}
await onAfterTask?.(this, result)
return result
} }
} catch (error: unknown) { } catch (error: unknown) {
console.groupEnd() // to prevent nested groups console.groupEnd()
const isAbortError = (error as any)?.rawError?.name === 'AbortError' const isAbortError = (error as any)?.name === 'AbortError'
if (!isAbortError) console.error('Task failed', error)
console.error('Task failed', error) taskResult = isAbortError ? 'Task aborted' : String(error)
const errorMessage = isAbortError ? 'Task stopped' : String(error) taskSuccess = false
this.#emitActivity({ type: 'error', message: errorMessage }) this.#emitActivity({ type: 'error', message: taskResult })
this.history.push({ type: 'error', message: errorMessage, rawResponse: error }) this.history.push({ type: 'error', message: taskResult, rawResponse: error })
this.#emitHistoryChange() this.#emitHistoryChange()
this.#onDone(false) break
const result: ExecutionResult = {
success: false,
data: errorMessage,
history: this.history,
}
await onAfterTask?.(this, result)
return result
} }
step++ step++
if (step > this.config.maxSteps) { if (step > this.config.maxSteps) {
const errorMessage = 'Step count exceeded maximum limit' taskResult = 'Step count exceeded maximum limit'
this.history.push({ type: 'error', message: errorMessage }) taskSuccess = false
this.#emitActivity({ type: 'error', message: taskResult })
this.history.push({ type: 'error', message: taskResult })
this.#emitHistoryChange() this.#emitHistoryChange()
this.#onDone(false) break
const result: ExecutionResult = {
success: false,
data: errorMessage,
history: this.history,
}
await onAfterTask?.(this, result)
return result
} }
await waitFor(this.config.stepDelay ?? 0.4) await waitFor(this.config.stepDelay ?? 0.4)
} }
this.#onDone(taskSuccess)
const result: ExecutionResult = {
success: taskSuccess,
data: taskResult,
history: this.history,
}
await onAfterTask?.(this, result)
return result
} }
/** /**
@@ -378,8 +363,8 @@ export class PageAgentCore extends EventTarget {
description: 'You MUST call this tool every step!', description: 'You MUST call this tool every step!',
inputSchema: macroToolSchema as z.ZodType<MacroToolInput>, inputSchema: macroToolSchema as z.ZodType<MacroToolInput>,
execute: async (input: MacroToolInput): Promise<MacroToolResult> => { execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
// abort const signal = this.#abortController.signal
if (this.#abortController.signal.aborted) throw new Error('AbortError') signal.throwIfAborted()
console.log(chalk.blue.bold('MacroTool input'), input) console.log(chalk.blue.bold('MacroTool input'), input)
const action = input.action const action = input.action
@@ -411,8 +396,9 @@ export class PageAgentCore extends EventTarget {
const startTime = Date.now() const startTime = Date.now()
// Execute tool, bind `this` to PageAgent const result = await tool.execute.bind(this)(toolInput, { signal })
const result = await tool.execute.bind(this)(toolInput) // Enforce abort even if the tool ignored the signal and resolved normally.
signal.throwIfAborted()
const duration = Date.now() - startTime const duration = Date.now() - startTime
console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result) console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result)
@@ -1 +0,0 @@
system_prompt.md
+13 -5
View File
@@ -7,6 +7,14 @@ import * as z from 'zod/v4'
import type { PageAgentCore } from '../PageAgentCore' import type { PageAgentCore } from '../PageAgentCore'
import { waitFor } from '../utils' import { waitFor } from '../utils'
/**
* Per-invocation context passed to every tool execution.
* Tools MUST honor `signal` to support cooperative cancellation.
*/
export interface ToolContext {
signal: AbortSignal
}
/** /**
* Internal tool definition that has access to PageAgent `this` context * Internal tool definition that has access to PageAgent `this` context
*/ */
@@ -14,7 +22,7 @@ export interface PageAgentTool<TParams = any> {
// name: string // name: string
description: string description: string
inputSchema: z.ZodType<TParams> inputSchema: z.ZodType<TParams>
execute: (this: PageAgentCore, args: TParams) => Promise<string> execute: (this: PageAgentCore, args: TParams, ctx: ToolContext) => Promise<string>
} }
export function tool<TParams>(options: PageAgentTool<TParams>): PageAgentTool<TParams> { export function tool<TParams>(options: PageAgentTool<TParams>): PageAgentTool<TParams> {
@@ -50,12 +58,12 @@ tools.set(
inputSchema: z.object({ inputSchema: z.object({
seconds: z.number().min(1).max(10).default(1), seconds: z.number().min(1).max(10).default(1),
}), }),
execute: async function (this: PageAgentCore, input) { execute: async function (this: PageAgentCore, input, { signal }) {
// try to subtract LLM calling time from the actual wait time // try to subtract LLM calling time from the actual wait time
const lastTimeUpdate = await this.pageController.getLastUpdateTime() const lastTimeUpdate = await this.pageController.getLastUpdateTime()
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000) const 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, signal)
return `✅ Waited for ${input.seconds} seconds.` return `✅ Waited for ${input.seconds} seconds.`
}, },
@@ -70,11 +78,11 @@ tools.set(
inputSchema: z.object({ inputSchema: z.object({
question: z.string(), question: z.string(),
}), }),
execute: async function (this: PageAgentCore, input) { execute: async function (this: PageAgentCore, input, { signal }) {
if (!this.onAskUser) { if (!this.onAskUser) {
throw new Error('ask_user tool requires onAskUser callback to be set') throw new Error('ask_user tool requires onAskUser callback to be set')
} }
const answer = await this.onAskUser(input.question) const answer = await this.onAskUser(input.question, { signal })
return `User answered: ${answer}` return `User answered: ${answer}`
}, },
}) })
+3 -3
View File
@@ -1,4 +1,4 @@
import { InvokeError, InvokeErrorType } from '@page-agent/llms' import { InvokeError, InvokeErrorTypes } from '@page-agent/llms'
import chalk from 'chalk' import chalk from 'chalk'
import * as z from 'zod/v4' import * as z from 'zod/v4'
@@ -137,7 +137,7 @@ function validateAction(action: any, tools: Map<string, PageAgentTool>): any {
if (!tool) { if (!tool) {
const available = Array.from(tools.keys()).join(', ') const available = Array.from(tools.keys()).join(', ')
throw new InvokeError( throw new InvokeError(
InvokeErrorType.INVALID_TOOL_ARGS, InvokeErrorTypes.INVALID_TOOL_ARGS,
`Unknown action "${toolName}". Available: ${available}` `Unknown action "${toolName}". Available: ${available}`
) )
} }
@@ -159,7 +159,7 @@ function validateAction(action: any, tools: Map<string, PageAgentTool>): any {
const result = schema.safeParse(value) const result = schema.safeParse(value)
if (!result.success) { if (!result.success) {
throw new InvokeError( throw new InvokeError(
InvokeErrorType.INVALID_TOOL_ARGS, InvokeErrorTypes.INVALID_TOOL_ARGS,
`Invalid input for action "${toolName}": ${z.prettifyError(result.error)}` `Invalid input for action "${toolName}": ${z.prettifyError(result.error)}`
) )
} }
+22 -2
View File
@@ -2,8 +2,28 @@ import chalk from 'chalk'
export * from './autoFixer' export * from './autoFixer'
export async function waitFor(seconds: number): Promise<void> { /**
await new Promise((resolve) => setTimeout(resolve, seconds * 1000)) * Wait for `seconds`. If a `signal` is provided, the wait is cancellable:
* aborting rejects with the signal's reason (an `AbortError`).
*/
export async function waitFor(seconds: number, signal?: AbortSignal): Promise<void> {
if (!signal) {
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
return
}
signal.throwIfAborted()
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, seconds * 1000)
const onAbort = () => {
clearTimeout(timer)
// reason is a DOMException AbortError.
reject(signal.reason as DOMException)
}
signal.addEventListener('abort', onAbort, { once: true })
})
} }
// //
+2
View File
@@ -12,6 +12,8 @@ export default defineConfig({
clearScreen: false, clearScreen: false,
plugins: [ plugins: [
dts({ dts({
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts'],
bundleTypes: true, bundleTypes: true,
compilerOptions: { compilerOptions: {
composite: true, composite: true,
-2
View File
@@ -1,2 +0,0 @@
.wxt
src/components/ui
+74 -89
View File
@@ -42,33 +42,28 @@ localStorage.setItem('PageAgentExtUserAuthToken', 'your-token')
## Quick Start ## Quick Start
```typescript ```typescript
import type { import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core'
AgentActivity,
AgentStatus,
ExecutionResult,
HistoricalEvent,
} from '@page-agent/core'
// Wait for extension injection (up to 1 second) // Wait for extension injection (up to 1 second)
async function waitForExtension(timeout = 1000): Promise<boolean> { async function waitForExtension(timeout = 1000): Promise<boolean> {
const start = Date.now() const start = Date.now()
while (Date.now() - start < timeout) { while (Date.now() - start < timeout) {
if (window.PAGE_AGENT_EXT) return true if (window.PAGE_AGENT_EXT) return true
await new Promise((r) => setTimeout(r, 100)) await new Promise((r) => setTimeout(r, 100))
} }
return false return false
} }
// Usage // Usage
if (await waitForExtension()) { if (await waitForExtension()) {
const result = await window.PAGE_AGENT_EXT!.execute('Click the login button', { const result = await window.PAGE_AGENT_EXT!.execute('Click the login button', {
baseURL: 'https://api.openai.com/v1', baseURL: 'https://api.openai.com/v1',
apiKey: 'your-api-key', apiKey: 'your-api-key',
model: 'gpt-5.2', model: 'gpt-5.2',
onStatusChange: (status) => console.log('Status:', status), onStatusChange: (status) => console.log('Status:', status),
onActivity: (activity) => console.log('Activity:', activity), onActivity: (activity) => console.log('Activity:', activity),
}) })
console.log('Result:', result) console.log('Result:', result)
} }
``` ```
@@ -90,10 +85,10 @@ 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 | LLM settings, options, and callbacks | | `config` | `ExecuteConfig` | Yes | LLM settings, options, and callbacks |
Returns: `Promise<ExecutionResult>` Returns: `Promise<ExecutionResult>`
@@ -106,33 +101,28 @@ Stop the current task.
Install `@page-agent/core` for complete types: Install `@page-agent/core` for complete types:
```typescript ```typescript
import type { import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core'
AgentActivity,
AgentStatus,
ExecutionResult,
HistoricalEvent,
} from '@page-agent/core'
export interface ExecuteConfig { export interface ExecuteConfig {
baseURL: string baseURL: string
model: string model: string
apiKey?: string apiKey?: string
// Global system-level instructions for the agent. // Global system-level instructions for the agent.
// Equivalent to AgentConfig.instructions.system. // Equivalent to AgentConfig.instructions.system.
systemInstruction?: string systemInstruction?: string
// Include the initial tab where page JS starts. Default: true. // Include the initial tab where page JS starts. Default: true.
includeInitialTab?: boolean includeInitialTab?: boolean
// Control all unpinned tabs in the window instead of only the tab group. // Control all unpinned tabs in the window instead of only the tab group.
// When enabled, agent sees and can switch to every non-pinned tab. // When enabled, agent sees and can switch to every non-pinned tab.
// Default: false. Experimental. // Default: false. Experimental.
experimentalIncludeAllTabs?: boolean experimentalIncludeAllTabs?: boolean
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void
} }
export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult> export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
@@ -148,31 +138,31 @@ type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
```typescript ```typescript
type AgentActivity = type AgentActivity =
| { type: 'thinking' } | { type: 'thinking' }
| { type: 'executing'; tool: string; input: unknown } | { type: 'executing'; tool: string; input: unknown }
| { type: 'executed'; tool: string; input: unknown; output: string; duration: number } | { type: 'executed'; tool: string; input: unknown; output: string; duration: number }
| { type: 'retrying'; attempt: number; maxAttempts: number } | { type: 'retrying'; attempt: number; maxAttempts: number }
| { type: 'error'; message: string } | { type: 'error'; message: string }
``` ```
`HistoricalEvent` `HistoricalEvent`
```typescript ```typescript
type HistoricalEvent = type HistoricalEvent =
| { type: 'step'; stepIndex: number; reflection: AgentReflection; action: Action } | { type: 'step'; stepIndex: number; reflection: AgentReflection; action: Action }
| { type: 'observation'; content: string } | { type: 'observation'; content: string }
| { type: 'user_takeover' } | { type: 'user_takeover' }
| { type: 'retry'; message: string; attempt: number; maxAttempts: number } | { type: 'retry'; message: string; attempt: number; maxAttempts: number }
| { type: 'error'; message: string; rawResponse?: unknown } | { type: 'error'; message: string; rawResponse?: unknown }
``` ```
`ExecutionResult` `ExecutionResult`
```typescript ```typescript
interface ExecutionResult { interface ExecutionResult {
success: boolean success: boolean
data: string data: string
history: HistoricalEvent[] history: HistoricalEvent[]
} }
``` ```
@@ -182,15 +172,15 @@ interface ExecutionResult {
```typescript ```typescript
const result = await window.PAGE_AGENT_EXT!.execute( const result = await window.PAGE_AGENT_EXT!.execute(
'Fill in the email field with test@example.com and click Submit', 'Fill in the email field with test@example.com and click Submit',
{ {
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 includeInitialTab: false, // Optional: exclude current tab
onStatusChange: (status) => console.log(status), onStatusChange: (status) => console.log(status),
onActivity: (activity) => console.log(activity), onActivity: (activity) => console.log(activity),
} }
) )
``` ```
@@ -205,35 +195,30 @@ window.PAGE_AGENT_EXT!.stop()
If you are not importing `@page-agent/core`, add: If you are not importing `@page-agent/core`, add:
```typescript ```typescript
import type { import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core'
AgentActivity,
AgentStatus,
ExecutionResult,
HistoricalEvent,
} from '@page-agent/core'
interface ExecuteConfig { interface ExecuteConfig {
baseURL: string baseURL: string
model: string model: string
apiKey?: string apiKey?: string
systemInstruction?: string systemInstruction?: string
includeInitialTab?: boolean includeInitialTab?: boolean
experimentalIncludeAllTabs?: boolean experimentalIncludeAllTabs?: boolean
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void
} }
declare global { declare global {
interface Window { interface Window {
PAGE_AGENT_EXT_VERSION?: string PAGE_AGENT_EXT_VERSION?: string
PAGE_AGENT_EXT?: { PAGE_AGENT_EXT?: {
version: string version: string
execute: Execute execute: Execute
stop: () => void stop: () => void
}
} }
}
} }
``` ```
+16 -16
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/ext", "name": "@page-agent/ext",
"private": true, "private": true,
"version": "1.8.0", "version": "1.9.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "wxt", "dev": "wxt",
@@ -16,32 +16,32 @@
"@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",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.3.0",
"@types/chrome": "^0.1.40", "@types/chrome": "^0.1.42",
"@types/react": "^19.2.14", "@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@wxt-dev/module-react": "^1.2.2", "@wxt-dev/module-react": "^1.2.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"idb": "^8.0.3", "idb": "^8.0.3",
"lucide-react": "^1.8.0", "lucide-react": "^1.16.0",
"motion": "^12.38.0", "motion": "^12.40.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.5", "react": "^19.2.6",
"react-dom": "^19.2.5", "react-dom": "^19.2.6",
"rough-notation": "^0.5.1", "rough-notation": "^0.5.1",
"simple-icons": "^16.16.0", "simple-icons": "^16.21.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"wxt": "^0.20.20" "wxt": "^0.20.26"
}, },
"dependencies": { "dependencies": {
"@page-agent/core": "1.8.0", "@page-agent/core": "1.9.0",
"@page-agent/llms": "1.8.0", "@page-agent/llms": "1.9.0",
"@page-agent/page-controller": "1.8.0", "@page-agent/page-controller": "1.9.0",
"@page-agent/ui": "1.8.0", "@page-agent/ui": "1.9.0",
"ai-motion": "^0.4.8", "ai-motion": "^0.4.8",
"chalk": "^5.6.2" "chalk": "^5.6.2"
}, },
@@ -1,11 +1,11 @@
{ {
"extName": { "extName": {
"message": "Page Agent Ext" "message": "Page Agent Ext"
}, },
"extDescription": { "extDescription": {
"message": "AI-powered browser automation assistant. Control web pages with natural language." "message": "AI-powered browser automation assistant. Control web pages with natural language."
}, },
"extActionTitle": { "extActionTitle": {
"message": "Open Page Agent" "message": "Open Page Agent"
} }
} }
@@ -1,11 +1,11 @@
{ {
"extName": { "extName": {
"message": "Page Agent Ext" "message": "Page Agent Ext"
}, },
"extDescription": { "extDescription": {
"message": "AI 驱动的浏览器自动化助手,用自然语言控制网页。" "message": "AI 驱动的浏览器自动化助手,用自然语言控制网页。"
}, },
"extActionTitle": { "extActionTitle": {
"message": "打开 Page Agent" "message": "打开 Page Agent"
} }
} }
@@ -1 +0,0 @@
system_prompt.md
-1
View File
@@ -108,7 +108,6 @@ 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('🚀 [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)
@@ -123,6 +123,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
size="icon-sm" size="icon-sm"
onClick={onClose} onClick={onClose}
className="absolute top-2 right-3 cursor-pointer" className="absolute top-2 right-3 cursor-pointer"
aria-label="Back"
> >
<CornerUpLeft className="size-3.5" /> <CornerUpLeft className="size-3.5" />
</Button> </Button>
@@ -130,12 +131,15 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
{/* User Auth Token Section */} {/* User Auth Token Section */}
<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 htmlFor="user-auth-token" 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">
Give a website the ability 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
id="user-auth-token"
readOnly readOnly
value={ value={
userAuthToken userAuthToken
@@ -152,6 +156,8 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
className="h-8 w-8 shrink-0 cursor-pointer" className="h-8 w-8 shrink-0 cursor-pointer"
onClick={() => setShowToken(!showToken)} onClick={() => setShowToken(!showToken)}
disabled={!userAuthToken} disabled={!userAuthToken}
aria-label={showToken ? 'Hide token' : 'Show token'}
aria-pressed={showToken}
> >
{showToken ? <EyeOff className="size-3" /> : <Eye className="size-3" />} {showToken ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
</Button> </Button>
@@ -161,9 +167,13 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
className="h-8 w-8 shrink-0 cursor-pointer" className="h-8 w-8 shrink-0 cursor-pointer"
onClick={handleCopyToken} onClick={handleCopyToken}
disabled={!userAuthToken} disabled={!userAuthToken}
aria-label="Copy token"
> >
{copied ? <span className=""></span> : <Copy className="size-3" />} {copied ? <span className=""></span> : <Copy className="size-3" />}
</Button> </Button>
<span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{copied ? 'Token copied' : ''}
</span>
</div> </div>
</div> </div>
@@ -171,6 +181,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
<a <a
href="/hub.html" href="/hub.html"
target="_blank" target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between p-3 rounded-md border bg-muted/50 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors" className="flex items-center justify-between p-3 rounded-md border bg-muted/50 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
> >
Manage Page Agent Hub Manage Page Agent Hub
@@ -178,8 +189,11 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
</a> </a>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Base URL</label> <label htmlFor="base-url" className="text-xs text-muted-foreground">
Base URL
</label>
<Input <Input
id="base-url"
placeholder="https://api.openai.com/v1" placeholder="https://api.openai.com/v1"
value={baseURL} value={baseURL}
onChange={(e) => setBaseURL(e.target.value)} onChange={(e) => setBaseURL(e.target.value)}
@@ -204,8 +218,11 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
)} )}
<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 htmlFor="model" className="text-xs text-muted-foreground">
Model
</label>
<Input <Input
id="model"
placeholder="gpt-5.1" placeholder="gpt-5.1"
value={model} value={model}
onChange={(e) => setModel(e.target.value)} onChange={(e) => setModel(e.target.value)}
@@ -214,9 +231,12 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">API Key</label> <label htmlFor="api-key" className="text-xs text-muted-foreground">
API Key
</label>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Input <Input
id="api-key"
type={showApiKey ? 'text' : 'password'} type={showApiKey ? 'text' : 'password'}
// placeholder="sk-..." // placeholder="sk-..."
value={apiKey} value={apiKey}
@@ -228,6 +248,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
size="icon" size="icon"
className="h-8 w-8 shrink-0 cursor-pointer" className="h-8 w-8 shrink-0 cursor-pointer"
onClick={() => setShowApiKey(!showApiKey)} onClick={() => setShowApiKey(!showApiKey)}
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
> >
{showApiKey ? <EyeOff className="size-3" /> : <Eye className="size-3" />} {showApiKey ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
</Button> </Button>
@@ -260,8 +281,11 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
{advancedOpen && ( {advancedOpen && (
<> <>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Max Steps</label> <label htmlFor="max-steps" className="text-xs text-muted-foreground">
Max Steps
</label>
<Input <Input
id="max-steps"
type="number" type="number"
placeholder="40" placeholder="40"
min={1} min={1}
@@ -1,4 +1,12 @@
import { ArrowDownToLine, ArrowLeft, CheckCircle, RotateCcw, Trash2, XCircle } from 'lucide-react' import {
ArrowDownToLine,
ArrowLeft,
CheckCircle,
History,
RotateCcw,
Trash2,
XCircle,
} from 'lucide-react'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -29,8 +37,13 @@ export function HistoryList({
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const load = useCallback(async () => { const load = useCallback(async () => {
setSessions(await listSessions()) try {
setLoading(false) setSessions(await listSessions())
} catch (err) {
console.error('[HistoryList] Failed to load sessions:', err)
} finally {
setLoading(false)
}
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -57,7 +70,14 @@ export function HistoryList({
<div className="flex flex-col h-screen bg-background"> <div className="flex flex-col h-screen bg-background">
{/* Header */} {/* Header */}
<header className="flex items-center gap-2 border-b px-3 py-2"> <header className="flex items-center gap-2 border-b px-3 py-2">
<Button variant="ghost" size="icon-sm" onClick={onBack} className="cursor-pointer"> <Button
variant="ghost"
size="icon-sm"
onClick={onBack}
className="cursor-pointer"
aria-label="Back"
title="Back"
>
<ArrowLeft className="size-3.5" /> <ArrowLeft className="size-3.5" />
</Button> </Button>
<span className="text-sm font-medium flex-1">History</span> <span className="text-sm font-medium flex-1">History</span>
@@ -80,14 +100,23 @@ export function HistoryList({
{/* List */} {/* List */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{loading && ( {loading && (
<div className="flex items-center justify-center h-32 text-xs text-muted-foreground"> <div className="flex flex-col" aria-label="Loading history" aria-busy="true">
Loading... {[...Array(4)].map((_, i) => (
<div key={i} className="flex items-start gap-2 px-3 py-2.5 border-b">
<div className="size-3.5 mt-0.5 rounded-full bg-muted animate-pulse shrink-0" />
<div className="flex-1 space-y-1.5">
<div className="h-2.5 bg-muted animate-pulse rounded w-3/4" />
<div className="h-2 bg-muted animate-pulse rounded w-1/3" />
</div>
</div>
))}
</div> </div>
)} )}
{!loading && sessions.length === 0 && ( {!loading && sessions.length === 0 && (
<div className="flex items-center justify-center h-32 text-xs text-muted-foreground"> <div className="flex flex-col items-center justify-center h-40 gap-2 text-muted-foreground">
No history yet <History className="size-8 opacity-30" />
<p className="text-xs">No history yet</p>
</div> </div>
)} )}
@@ -146,6 +146,7 @@ function CopyButton({ text, label }: { text: string; label: string }) {
function extractPrompt(rawRequest: unknown, role: 'system' | 'user'): string | null { function extractPrompt(rawRequest: unknown, role: 'system' | 'user'): string | null {
const messages = (rawRequest as { messages?: { role: string; content?: unknown }[] })?.messages const messages = (rawRequest as { messages?: { role: string; content?: unknown }[] })?.messages
if (!messages) return null if (!messages) return null
if (!Array.isArray(messages)) return null
const msg = const msg =
role === 'system' role === 'system'
? messages.find((m) => m.role === role) ? messages.find((m) => m.role === role)
@@ -147,6 +147,8 @@ export default function App() {
size="icon-sm" size="icon-sm"
onClick={() => setView({ name: 'history' })} onClick={() => setView({ name: 'history' })}
className="cursor-pointer" className="cursor-pointer"
aria-label="History"
title="History"
> >
<History className="size-3.5" /> <History className="size-3.5" />
</Button> </Button>
@@ -155,6 +157,8 @@ export default function App() {
size="icon-sm" size="icon-sm"
onClick={() => setView({ name: 'config' })} onClick={() => setView({ name: 'config' })}
className="cursor-pointer" className="cursor-pointer"
aria-label="Settings"
title="Settings"
> >
<Settings className="size-3.5" /> <Settings className="size-3.5" />
</Button> </Button>
@@ -205,6 +209,8 @@ export default function App() {
variant="destructive" variant="destructive"
onClick={handleStop} onClick={handleStop}
className="size-7" className="size-7"
aria-label="Stop task"
title="Stop task"
> >
<Square className="size-3" /> <Square className="size-3" />
</InputGroupButton> </InputGroupButton>
@@ -215,6 +221,8 @@ export default function App() {
onClick={() => handleSubmit()} onClick={() => handleSubmit()}
disabled={!inputValue.trim()} disabled={!inputValue.trim()}
className="size-7 cursor-pointer" className="size-7 cursor-pointer"
aria-label="Send"
title="Send"
> >
<Send className="size-3" /> <Send className="size-3" />
</InputGroupButton> </InputGroupButton>
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/llms", "name": "@page-agent/llms",
"version": "1.8.0", "version": "1.9.0",
"type": "module", "type": "module",
"main": "./src/index.ts", "main": "./src/index.ts",
"types": "./src/index.ts", "types": "./src/index.ts",
@@ -43,6 +43,7 @@
"homepage": "https://alibaba.github.io/page-agent/", "homepage": "https://alibaba.github.io/page-agent/",
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"test": "vitest run",
"prepublishOnly": "node ../../scripts/pre-publish.js", "prepublishOnly": "node ../../scripts/pre-publish.js",
"postpublish": "node ../../scripts/post-publish.js" "postpublish": "node ../../scripts/post-publish.js"
}, },
@@ -53,6 +54,6 @@
"zod": "^3.25.0 || ^4.0.0" "zod": "^3.25.0 || ^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"zod": "^4.3.6" "zod": "^4.4.3"
} }
} }
+369
View File
@@ -0,0 +1,369 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as z from 'zod/v4'
import { OpenAIClient } from './OpenAIClient'
import { InvokeError, InvokeErrorTypes } from './errors'
import { parseLLMConfig } from './index'
import type { LLMConfig, Tool } from './types'
// ---------- Fixtures ----------
function makeClient(overrides: Partial<LLMConfig> = {}) {
const fetchMock = vi.fn<typeof fetch>()
const config = parseLLMConfig({
baseURL: 'http://test.local/v1',
model: 'test-model',
apiKey: 'sk-test',
customFetch: fetchMock,
...overrides,
})
const client = new OpenAIClient(config)
return { client, fetchMock }
}
function makeTool(): Tool<{ name: string }, string> {
return {
description: 'greet',
inputSchema: z.object({ name: z.string() }),
execute: vi.fn(async (args) => `hello ${args.name}`),
}
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
function toolCallBody(toolName: string, args: unknown, finishReason = 'tool_calls') {
return {
choices: [
{
finish_reason: finishReason,
message: {
tool_calls: [
{
id: 'call_1',
type: 'function',
function: {
name: toolName,
arguments: typeof args === 'string' ? args : JSON.stringify(args),
},
},
],
},
},
],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
}
}
function abortError(message = 'aborted'): Error {
const err = new Error(message)
err.name = 'AbortError'
return err
}
function getSentBody(fetchMock: ReturnType<typeof vi.fn>): Record<string, unknown> {
const init = fetchMock.mock.calls[0][1] as RequestInit
return JSON.parse(init.body as string)
}
const signal = new AbortController().signal
// ---------- Request construction ----------
describe('OpenAIClient.invoke — request construction', () => {
let setup: ReturnType<typeof makeClient>
const tools = { greet: makeTool() }
beforeEach(() => {
setup = makeClient()
setup.fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'world' })))
})
it('defaults tool_choice to "required" when no toolChoiceName is given', async () => {
await setup.client.invoke([], tools, signal)
expect(getSentBody(setup.fetchMock).tool_choice).toBe('required')
})
it('uses named tool_choice when toolChoiceName is given', async () => {
await setup.client.invoke([], tools, signal, { toolChoiceName: 'greet' })
expect(getSentBody(setup.fetchMock).tool_choice).toEqual({
type: 'function',
function: { name: 'greet' },
})
})
it('falls back to "required" when disableNamedToolChoice is true', async () => {
const { client, fetchMock } = makeClient({ disableNamedToolChoice: true })
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'world' })))
await client.invoke([], tools, signal, { toolChoiceName: 'greet' })
expect(getSentBody(fetchMock).tool_choice).toBe('required')
})
it('sends Authorization header when apiKey is set', async () => {
await setup.client.invoke([], tools, signal)
const init = setup.fetchMock.mock.calls[0][1]!
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer sk-test')
})
it('omits Authorization header when apiKey is empty', async () => {
const { client, fetchMock } = makeClient({ apiKey: '' })
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'world' })))
await client.invoke([], tools, signal)
const init = fetchMock.mock.calls[0][1]!
expect((init.headers as Record<string, string>).Authorization).toBeUndefined()
})
it('applies transformRequestBody (in-place form, returns undefined)', async () => {
const { client, fetchMock } = makeClient({
transformRequestBody: (body) => {
body.custom_flag = 42
return undefined
},
})
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'world' })))
await client.invoke([], tools, signal)
expect(getSentBody(fetchMock).custom_flag).toBe(42)
})
it('applies transformRequestBody (returning a new object)', async () => {
const { client, fetchMock } = makeClient({
transformRequestBody: () => ({ replaced: true }),
})
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'world' })))
await client.invoke([], tools, signal)
expect(getSentBody(fetchMock)).toEqual({ replaced: true })
})
it('wraps transformRequestBody throws as CONFIG_ERROR', async () => {
const { client } = makeClient({
transformRequestBody: () => {
throw new Error('bad transform')
},
})
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
name: 'InvokeError',
type: InvokeErrorTypes.CONFIG_ERROR,
})
})
})
// ---------- Success path ----------
describe('OpenAIClient.invoke — success', () => {
it('returns toolCall, toolResult and usage', async () => {
const { client, fetchMock } = makeClient()
const tool = makeTool()
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'Alice' })))
const result = await client.invoke([], { greet: tool }, signal)
expect(result.toolCall).toEqual({ name: 'greet', args: { name: 'Alice' } })
expect(result.toolResult).toBe('hello Alice')
expect(result.usage).toEqual({
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
cachedTokens: undefined,
reasoningTokens: undefined,
})
expect(tool.execute).toHaveBeenCalledWith({ name: 'Alice' })
})
it('accepts finish_reason="stop" with tool calls', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'Bob' }, 'stop')))
const result = await client.invoke([], { greet: makeTool() }, signal)
expect(result.toolResult).toBe('hello Bob')
})
})
// ---------- HTTP errors ----------
describe('OpenAIClient.invoke — HTTP errors', () => {
const tools = { greet: makeTool() }
it.each([
[401, InvokeErrorTypes.AUTH_ERROR],
[403, InvokeErrorTypes.AUTH_ERROR],
[429, InvokeErrorTypes.RATE_LIMIT],
[500, InvokeErrorTypes.SERVER_ERROR],
[502, InvokeErrorTypes.SERVER_ERROR],
[418, InvokeErrorTypes.UNKNOWN],
])('maps status %i to %s', async (status, type) => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(jsonResponse({ error: { message: 'nope' } }, status))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
name: 'InvokeError',
type,
})
})
it('falls back to statusText when body has no error.message', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(
new Response('not json', { status: 500, statusText: 'Internal Boom' })
)
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.SERVER_ERROR,
message: expect.stringContaining('Internal Boom'),
})
})
})
// ---------- Response anomalies ----------
describe('OpenAIClient.invoke — response anomalies', () => {
const tools = { greet: makeTool() }
it('throws CONTEXT_LENGTH on finish_reason="length"', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(
jsonResponse({ choices: [{ finish_reason: 'length', message: {} }] })
)
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.CONTEXT_LENGTH,
})
})
it('throws CONTENT_FILTER on finish_reason="content_filter"', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(
jsonResponse({ choices: [{ finish_reason: 'content_filter', message: {} }] })
)
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.CONTENT_FILTER,
})
})
it('throws INVALID_SCHEMA when there are no choices', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(jsonResponse({}))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.INVALID_SCHEMA,
})
})
it('throws NO_TOOL_CALL when message has no tool_calls', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(
jsonResponse({ choices: [{ finish_reason: 'tool_calls', message: {} }] })
)
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.NO_TOOL_CALL,
})
})
it('throws INVALID_TOOL_ARGS when arguments are not valid JSON', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', 'not-json{')))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.INVALID_TOOL_ARGS,
message: expect.stringContaining('JSON'),
})
})
it('throws INVALID_TOOL_ARGS when args fail Zod validation', async () => {
const { client, fetchMock } = makeClient()
// tool expects { name: string }, send { name: 123 }
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 123 })))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.INVALID_TOOL_ARGS,
})
})
it('throws UNKNOWN when model calls a tool that does not exist', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('mystery', { name: 'x' })))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.UNKNOWN,
message: expect.stringContaining('mystery'),
})
})
it('wraps tool.execute failures as TOOL_EXECUTION_ERROR', async () => {
const { client, fetchMock } = makeClient()
const tool: Tool<{ name: string }, string> = {
inputSchema: z.object({ name: z.string() }),
execute: vi.fn(async () => {
throw new Error('downstream blew up')
}),
}
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'x' })))
await expect(client.invoke([], { greet: tool }, signal)).rejects.toMatchObject({
type: InvokeErrorTypes.TOOL_EXECUTION_ERROR,
message: expect.stringContaining('downstream blew up'),
})
})
})
// ---------- Abort handling (the important part) ----------
describe('OpenAIClient.invoke — abort', () => {
const tools = { greet: makeTool() }
it('throws AbortError immediately when signal is already aborted', async () => {
const { client, fetchMock } = makeClient()
const controller = new AbortController()
controller.abort()
await expect(client.invoke([], tools, controller.signal)).rejects.toMatchObject({
name: 'AbortError',
})
expect(fetchMock).not.toHaveBeenCalled()
})
it('propagates AbortError thrown by fetch (does NOT wrap as NETWORK_ERROR)', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockRejectedValue(abortError())
const err = await client.invoke([], tools, signal).catch((e) => e)
expect(err).toBeInstanceOf(Error)
expect(err).not.toBeInstanceOf(InvokeError)
expect(err.name).toBe('AbortError')
})
it('propagates AbortError thrown by response.json() (does NOT wrap as INVALID_RESPONSE)', async () => {
const { client, fetchMock } = makeClient()
// Fake Response whose .json() rejects with AbortError mid-read
const fakeResponse = {
ok: true,
status: 200,
statusText: 'OK',
json: () => Promise.reject(abortError()),
} as unknown as Response
fetchMock.mockResolvedValue(fakeResponse)
const err = await client.invoke([], tools, signal).catch((e) => e)
expect(err).not.toBeInstanceOf(InvokeError)
expect(err.name).toBe('AbortError')
})
it('propagates AbortError thrown by tool.execute (does NOT wrap as TOOL_EXECUTION_ERROR)', async () => {
const { client, fetchMock } = makeClient()
const tool: Tool<{ name: string }, string> = {
inputSchema: z.object({ name: z.string() }),
execute: vi.fn(async () => {
throw abortError()
}),
}
fetchMock.mockResolvedValue(jsonResponse(toolCallBody('greet', { name: 'x' })))
const err = await client.invoke([], { greet: tool }, signal).catch((e) => e)
expect(err).not.toBeInstanceOf(InvokeError)
expect(err.name).toBe('AbortError')
})
// Sanity check: the wrappers we deliberately bypass for AbortError still work for normal errors
it('still wraps non-Abort fetch errors as NETWORK_ERROR', async () => {
const { client, fetchMock } = makeClient()
fetchMock.mockRejectedValue(new TypeError('ECONNREFUSED'))
await expect(client.invoke([], tools, signal)).rejects.toMatchObject({
name: 'InvokeError',
type: InvokeErrorTypes.NETWORK_ERROR,
})
})
})
+55 -28
View File
@@ -3,7 +3,7 @@
*/ */
import * as z from 'zod/v4' import * as z from 'zod/v4'
import { InvokeError, InvokeErrorType } from './errors' import { InvokeError, InvokeErrorTypes } 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'
@@ -25,6 +25,8 @@ export class OpenAIClient implements LLMClient {
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
options?: InvokeOptions options?: InvokeOptions
): Promise<InvokeResult> { ): Promise<InvokeResult> {
abortSignal?.throwIfAborted()
// 1. Convert tools to OpenAI format // 1. Convert tools to OpenAI format
const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t)) const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t))
@@ -45,6 +47,17 @@ export class OpenAIClient implements LLMClient {
} }
modelPatch(requestBody) modelPatch(requestBody)
let transformedBody: Record<string, unknown> | undefined
try {
transformedBody = this.config.transformRequestBody(requestBody)
} catch (error) {
throw new InvokeError(
InvokeErrorTypes.CONFIG_ERROR,
`transformRequestBody failed: ${(error as Error).message}`,
error
)
}
const finalRequestBody = transformedBody ?? requestBody
// 2. Call API // 2. Call API
let response: Response let response: Response
@@ -55,56 +68,69 @@ export class OpenAIClient implements LLMClient {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(this.config.apiKey && { Authorization: `Bearer ${this.config.apiKey}` }), ...(this.config.apiKey && { Authorization: `Bearer ${this.config.apiKey}` }),
}, },
body: JSON.stringify(requestBody), body: JSON.stringify(finalRequestBody),
signal: abortSignal, signal: abortSignal,
}) })
} catch (error: unknown) { } catch (error: unknown) {
const isAbortError = (error as any)?.name === 'AbortError' if ((error as any)?.name === 'AbortError') throw error
const errorMessage = isAbortError ? 'Network request aborted' : 'Network request failed' console.error(error)
if (!isAbortError) console.error(error) throw new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'Network request failed', error)
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, errorMessage, error)
} }
// 3. Handle HTTP errors // 3. Handle HTTP errors
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch() let errorData: any
const errorMessage = try {
(errorData as { error?: { message?: string } }).error?.message || response.statusText errorData = await response.json()
} catch (error) {
if ((error as any)?.name === 'AbortError') throw error
}
const errorMessage = errorData?.error?.message || response.statusText
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
throw new InvokeError( throw new InvokeError(
InvokeErrorType.AUTH_ERROR, InvokeErrorTypes.AUTH_ERROR,
`Authentication failed: ${errorMessage}`, `Authentication failed: ${errorMessage}`,
errorData errorData
) )
} }
if (response.status === 429) { if (response.status === 429) {
throw new InvokeError( throw new InvokeError(
InvokeErrorType.RATE_LIMIT, InvokeErrorTypes.RATE_LIMIT,
`Rate limit exceeded: ${errorMessage}`, `Rate limit exceeded: ${errorMessage}`,
errorData errorData
) )
} }
if (response.status >= 500) { if (response.status >= 500) {
throw new InvokeError( throw new InvokeError(
InvokeErrorType.SERVER_ERROR, InvokeErrorTypes.SERVER_ERROR,
`Server error: ${errorMessage}`, `Server error: ${errorMessage}`,
errorData errorData
) )
} }
throw new InvokeError( throw new InvokeError(
InvokeErrorType.UNKNOWN, InvokeErrorTypes.UNKNOWN,
`HTTP ${response.status}: ${errorMessage}`, `HTTP ${response.status}: ${errorMessage}`,
errorData errorData
) )
} }
// 4. Parse and validate response // 4. Parse and validate response
const data = await response.json() let data: any
try {
data = await response.json()
} catch (error) {
if ((error as any)?.name === 'AbortError') throw error
throw new InvokeError(
InvokeErrorTypes.INVALID_RESPONSE,
'Response body is not valid JSON',
error
)
}
const choice = data.choices?.[0] const choice = data.choices?.[0]
if (!choice) { if (!choice) {
throw new InvokeError(InvokeErrorType.UNKNOWN, 'No choices in response', data) throw new InvokeError(InvokeErrorTypes.INVALID_SCHEMA, 'No choices in response', data)
} }
// Check finish_reason // Check finish_reason
@@ -115,21 +141,21 @@ export class OpenAIClient implements LLMClient {
break break
case 'length': case 'length':
throw new InvokeError( throw new InvokeError(
InvokeErrorType.CONTEXT_LENGTH, InvokeErrorTypes.CONTEXT_LENGTH,
'Response truncated: max tokens reached', 'Response truncated: max tokens reached',
undefined, undefined,
data data
) )
case 'content_filter': case 'content_filter':
throw new InvokeError( throw new InvokeError(
InvokeErrorType.CONTENT_FILTER, InvokeErrorTypes.CONTENT_FILTER,
'Content filtered by safety system', 'Content filtered by safety system',
undefined, undefined,
data data
) )
default: default:
throw new InvokeError( throw new InvokeError(
InvokeErrorType.UNKNOWN, InvokeErrorTypes.INVALID_SCHEMA,
`Unexpected finish_reason: ${choice.finish_reason}`, `Unexpected finish_reason: ${choice.finish_reason}`,
undefined, undefined,
data data
@@ -144,7 +170,7 @@ export class OpenAIClient implements LLMClient {
const toolCallName = normalizedChoice?.message?.tool_calls?.[0]?.function?.name const toolCallName = normalizedChoice?.message?.tool_calls?.[0]?.function?.name
if (!toolCallName) { if (!toolCallName) {
throw new InvokeError( throw new InvokeError(
InvokeErrorType.NO_TOOL_CALL, InvokeErrorTypes.NO_TOOL_CALL,
'No tool call found in response', 'No tool call found in response',
undefined, undefined,
data data
@@ -154,7 +180,7 @@ export class OpenAIClient implements LLMClient {
const tool = tools[toolCallName] const tool = tools[toolCallName]
if (!tool) { if (!tool) {
throw new InvokeError( throw new InvokeError(
InvokeErrorType.UNKNOWN, InvokeErrorTypes.UNKNOWN,
`Tool "${toolCallName}" not found in tools`, `Tool "${toolCallName}" not found in tools`,
undefined, undefined,
data data
@@ -165,7 +191,7 @@ export class OpenAIClient implements LLMClient {
const argString = normalizedChoice.message?.tool_calls?.[0]?.function?.arguments const argString = normalizedChoice.message?.tool_calls?.[0]?.function?.arguments
if (!argString) { if (!argString) {
throw new InvokeError( throw new InvokeError(
InvokeErrorType.INVALID_TOOL_ARGS, InvokeErrorTypes.INVALID_TOOL_ARGS,
'No tool call arguments found', 'No tool call arguments found',
undefined, undefined,
data data
@@ -177,7 +203,7 @@ export class OpenAIClient implements LLMClient {
parsedArgs = JSON.parse(argString) parsedArgs = JSON.parse(argString)
} catch (error) { } catch (error) {
throw new InvokeError( throw new InvokeError(
InvokeErrorType.INVALID_TOOL_ARGS, InvokeErrorTypes.INVALID_TOOL_ARGS,
'Failed to parse tool arguments as JSON', 'Failed to parse tool arguments as JSON',
error, error,
data data
@@ -189,7 +215,7 @@ export class OpenAIClient implements LLMClient {
if (!validation.success) { if (!validation.success) {
console.error(z.prettifyError(validation.error)) console.error(z.prettifyError(validation.error))
throw new InvokeError( throw new InvokeError(
InvokeErrorType.INVALID_TOOL_ARGS, InvokeErrorTypes.INVALID_TOOL_ARGS,
'Tool arguments validation failed', 'Tool arguments validation failed',
validation.error, validation.error,
data data
@@ -201,11 +227,12 @@ export class OpenAIClient implements LLMClient {
let toolResult: unknown let toolResult: unknown
try { try {
toolResult = await tool.execute(toolInput) toolResult = await tool.execute(toolInput)
} catch (e) { } catch (error: unknown) {
if ((error as any)?.name === 'AbortError') throw error
throw new InvokeError( throw new InvokeError(
InvokeErrorType.TOOL_EXECUTION_ERROR, InvokeErrorTypes.TOOL_EXECUTION_ERROR,
`Tool execution failed: ${(e as Error).message}`, `Tool execution failed: ${(error as Error)?.message}`,
e, error,
data data
) )
} }
@@ -225,7 +252,7 @@ export class OpenAIClient implements LLMClient {
reasoningTokens: data.usage?.completion_tokens_details?.reasoning_tokens, reasoningTokens: data.usage?.completion_tokens_details?.reasoning_tokens,
}, },
rawResponse: data, rawResponse: data,
rawRequest: requestBody, rawRequest: finalRequestBody,
} }
} }
} }
+19 -20
View File
@@ -1,8 +1,8 @@
/** /**
* Error types and error handling for LLM invocations * Error types and error handling for LLM invocations.
*/ */
export const InvokeErrorType = { export const InvokeErrorTypes = {
// Retryable // Retryable
NETWORK_ERROR: 'network_error', // Network error, retry NETWORK_ERROR: 'network_error', // Network error, retry
RATE_LIMIT: 'rate_limit', // Rate limit, retry RATE_LIMIT: 'rate_limit', // Rate limit, retry
@@ -10,16 +10,31 @@ export const InvokeErrorType = {
NO_TOOL_CALL: 'no_tool_call', // Model did not call tool NO_TOOL_CALL: 'no_tool_call', // Model did not call tool
INVALID_TOOL_ARGS: 'invalid_tool_args', // Tool args don't match schema INVALID_TOOL_ARGS: 'invalid_tool_args', // Tool args don't match schema
TOOL_EXECUTION_ERROR: 'tool_execution_error', // Tool execution error TOOL_EXECUTION_ERROR: 'tool_execution_error', // Tool execution error
INVALID_RESPONSE: 'invalid_response', // Response body is not valid JSON
INVALID_SCHEMA: 'invalid_schema', // Response is valid JSON but doesn't match expected shape
UNKNOWN: 'unknown', UNKNOWN: 'unknown',
// Non-retryable // Non-retryable
CONFIG_ERROR: 'config_error', // Invalid local configuration or hook
AUTH_ERROR: 'auth_error', // Authentication failed AUTH_ERROR: 'auth_error', // Authentication failed
CONTEXT_LENGTH: 'context_length', // Prompt too long CONTEXT_LENGTH: 'context_length', // Prompt too long
CONTENT_FILTER: 'content_filter', // Content filtered CONTENT_FILTER: 'content_filter', // Content filtered
} as const } as const
export type InvokeErrorType = (typeof InvokeErrorType)[keyof typeof InvokeErrorType] type InvokeErrorType = (typeof InvokeErrorTypes)[keyof typeof InvokeErrorTypes]
const RETRYABLE_TYPES: readonly InvokeErrorType[] = [
InvokeErrorTypes.NETWORK_ERROR,
InvokeErrorTypes.RATE_LIMIT,
InvokeErrorTypes.SERVER_ERROR,
InvokeErrorTypes.NO_TOOL_CALL,
InvokeErrorTypes.INVALID_TOOL_ARGS,
InvokeErrorTypes.TOOL_EXECUTION_ERROR,
InvokeErrorTypes.INVALID_RESPONSE,
InvokeErrorTypes.INVALID_SCHEMA,
InvokeErrorTypes.UNKNOWN,
]
export class InvokeError extends Error { export class InvokeError extends Error {
type: InvokeErrorType type: InvokeErrorType
@@ -34,24 +49,8 @@ export class InvokeError extends Error {
super(message) super(message)
this.name = 'InvokeError' this.name = 'InvokeError'
this.type = type this.type = type
this.retryable = this.isRetryable(type, rawError) this.retryable = RETRYABLE_TYPES.includes(type)
this.rawError = rawError this.rawError = rawError
this.rawResponse = rawResponse this.rawResponse = rawResponse
} }
private isRetryable(type: InvokeErrorType, rawError?: unknown): boolean {
const isAbortError = (rawError as any)?.name === 'AbortError'
if (isAbortError) return false
const retryableTypes: InvokeErrorType[] = [
InvokeErrorType.NETWORK_ERROR,
InvokeErrorType.RATE_LIMIT,
InvokeErrorType.SERVER_ERROR,
InvokeErrorType.NO_TOOL_CALL,
InvokeErrorType.INVALID_TOOL_ARGS,
InvokeErrorType.TOOL_EXECUTION_ERROR,
InvokeErrorType.UNKNOWN,
]
return retryableTypes.includes(type)
}
} }
+114
View File
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { InvokeError, InvokeErrorTypes, LLM } from './index'
import type { LLMClient } from './types'
function makeLLM(maxRetries = 2): LLM {
return new LLM({
baseURL: 'http://test.local/v1',
model: 'gpt-5',
maxRetries,
})
}
function abortError(): Error {
const err = new Error('aborted')
err.name = 'AbortError'
return err
}
describe('LLM.invoke retry behavior', () => {
let llm: LLM
let client: { invoke: ReturnType<typeof vi.fn> }
const signal = new AbortController().signal
beforeEach(() => {
llm = makeLLM(2)
client = { invoke: vi.fn() }
llm.client = client as unknown as LLMClient
})
it('returns immediately on first success', async () => {
client.invoke.mockResolvedValueOnce('ok')
const retryListener = vi.fn()
llm.addEventListener('retry', retryListener)
await expect(llm.invoke([], {}, signal)).resolves.toBe('ok')
expect(client.invoke).toHaveBeenCalledOnce()
expect(retryListener).not.toHaveBeenCalled()
})
it('retries up to maxRetries on retryable errors, then throws last error', async () => {
const retryable = new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'boom')
client.invoke
.mockRejectedValueOnce(retryable)
.mockRejectedValueOnce(retryable)
.mockRejectedValueOnce(retryable)
await expect(llm.invoke([], {}, signal)).rejects.toBe(retryable)
// 1 initial + 2 retries = 3 attempts total
expect(client.invoke).toHaveBeenCalledTimes(3)
})
it('succeeds on retry after transient failure', async () => {
const retryable = new InvokeError(InvokeErrorTypes.RATE_LIMIT, 'slow down')
client.invoke.mockRejectedValueOnce(retryable).mockResolvedValueOnce('ok')
await expect(llm.invoke([], {}, signal)).resolves.toBe('ok')
expect(client.invoke).toHaveBeenCalledTimes(2)
})
it('emits "retry" events with attempt count and lastError', async () => {
const err1 = new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'first')
const err2 = new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'second')
client.invoke
.mockRejectedValueOnce(err1)
.mockRejectedValueOnce(err2)
.mockResolvedValueOnce('ok')
const events: { attempt: number; maxAttempts: number; lastError: Error }[] = []
llm.addEventListener('retry', (e) => {
events.push((e as CustomEvent).detail)
})
await llm.invoke([], {}, signal)
expect(events).toEqual([
{ attempt: 1, maxAttempts: 2, lastError: err1 },
{ attempt: 2, maxAttempts: 2, lastError: err2 },
])
})
it('does not retry on AbortError, throws immediately', async () => {
const err = abortError()
client.invoke.mockRejectedValueOnce(err)
await expect(llm.invoke([], {}, signal)).rejects.toBe(err)
expect(client.invoke).toHaveBeenCalledOnce()
})
it('does not retry on non-retryable InvokeError (AUTH_ERROR)', async () => {
const err = new InvokeError(InvokeErrorTypes.AUTH_ERROR, 'bad token')
client.invoke.mockRejectedValueOnce(err)
await expect(llm.invoke([], {}, signal)).rejects.toBe(err)
expect(client.invoke).toHaveBeenCalledOnce()
})
it('does not retry on non-retryable InvokeError (CONFIG_ERROR)', async () => {
const err = new InvokeError(InvokeErrorTypes.CONFIG_ERROR, 'bad config')
client.invoke.mockRejectedValueOnce(err)
await expect(llm.invoke([], {}, signal)).rejects.toBe(err)
expect(client.invoke).toHaveBeenCalledOnce()
})
it('retries plain (non-InvokeError) errors as unknown failures', async () => {
// Plain errors are treated as retryable by withRetry (only InvokeError carries retryable flag)
const plain = new TypeError('weird')
client.invoke.mockRejectedValueOnce(plain).mockResolvedValueOnce('ok')
await expect(llm.invoke([], {}, signal)).resolves.toBe('ok')
expect(client.invoke).toHaveBeenCalledTimes(2)
})
})
+22 -43
View File
@@ -1,9 +1,9 @@
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, InvokeErrorType } from './errors' import { InvokeError, InvokeErrorTypes } from './errors'
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types' import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
export { InvokeError, InvokeErrorType } export { InvokeError, InvokeErrorTypes }
export type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } export type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool }
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> { export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
@@ -21,6 +21,7 @@ export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
apiKey: config.apiKey || '', apiKey: config.apiKey || '',
temperature: config.temperature ?? DEFAULT_TEMPERATURE, temperature: config.temperature ?? DEFAULT_TEMPERATURE,
maxRetries: config.maxRetries ?? LLM_MAX_RETRIES, maxRetries: config.maxRetries ?? LLM_MAX_RETRIES,
transformRequestBody: config.transformRequestBody ?? ((requestBody) => requestBody),
disableNamedToolChoice: config.disableNamedToolChoice ?? false, disableNamedToolChoice: config.disableNamedToolChoice ?? false,
customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound
} }
@@ -49,65 +50,43 @@ export class LLM extends EventTarget {
abortSignal: AbortSignal, abortSignal: AbortSignal,
options?: InvokeOptions options?: InvokeOptions
): Promise<InvokeResult> { ): Promise<InvokeResult> {
return await withRetry( return await withRetry(async () => this.client.invoke(messages, tools, abortSignal, options), {
async () => { maxRetries: this.config.maxRetries,
// in case user aborted before invoking onRetry: (attempt, lastError) => {
if (abortSignal.aborted) throw new Error('AbortError') this.dispatchEvent(
new CustomEvent('retry', {
const result = await this.client.invoke(messages, tools, abortSignal, options) detail: { attempt, maxAttempts: this.config.maxRetries, lastError },
})
return result )
}, },
// retry settings })
{
maxRetries: this.config.maxRetries,
onRetry: (attempt: number) => {
this.dispatchEvent(
new CustomEvent('retry', { detail: { attempt, maxAttempts: this.config.maxRetries } })
)
},
onError: (error: Error) => {
this.dispatchEvent(new CustomEvent('error', { detail: { error } }))
},
}
)
} }
} }
/**
* Retry a function until it succeeds or reaches the maximum number of retries.
*/
async function withRetry<T>( async function withRetry<T>(
fn: () => Promise<T>, fn: () => Promise<T>,
settings: { settings: {
maxRetries: number maxRetries: number
onRetry: (attempt: number) => void onRetry: (attempt: number, lastError: Error) => void
onError: (error: Error) => void
} }
): Promise<T> { ): Promise<T> {
let attempt = 0 let attempt = 0
let lastError: Error | null = null while (true) {
while (attempt <= settings.maxRetries) {
if (attempt > 0) {
settings.onRetry(attempt)
await new Promise((resolve) => setTimeout(resolve, 100))
}
try { try {
return await fn() return await fn()
} catch (error: unknown) { } catch (error: unknown) {
// do not retry if aborted by user if ((error as any)?.name === 'AbortError') throw error
if ((error as any)?.rawError?.name === 'AbortError') throw error
console.error(error)
settings.onError(error as Error)
// do not retry if error is not retryable (InvokeError)
if (error instanceof InvokeError && !error.retryable) throw error if (error instanceof InvokeError && !error.retryable) throw error
lastError = error as Error
attempt++ attempt++
if (attempt > settings.maxRetries) throw error
console.debug('[LLM] retryable failure, will retry:', error)
settings.onRetry(attempt, error as Error)
await new Promise((resolve) => setTimeout(resolve, 100)) await new Promise((resolve) => setTimeout(resolve, 100))
} }
} }
throw lastError!
} }
+10
View File
@@ -95,6 +95,16 @@ export interface LLMConfig {
temperature?: number temperature?: number
maxRetries?: number maxRetries?: number
/**
* Transform the final request body before sending it to the provider.
* Use this to implement provider-specific request tweaks such as caching hints or custom flags.
*
* Return a new object, or mutate the input object and return undefined.
*/
transformRequestBody?: (
requestBody: Record<string, unknown>
) => Record<string, unknown> | undefined
/** /**
* remove the tool_choice field from the request. * remove the tool_choice field from the request.
* @note fix "Invalid tool_choice type: 'object'" for some LLMs. * @note fix "Invalid tool_choice type: 'object'" for some LLMs.
+162
View File
@@ -0,0 +1,162 @@
import { describe, expect, it } from 'vitest'
import { modelPatch } from './utils'
/**
* Baseline request body used as starting point for each provider test.
* Mirrors what OpenAIClient builds before calling modelPatch.
*/
function baseBody(model: string) {
return {
model,
temperature: 0.7,
messages: [],
tools: [],
parallel_tool_calls: false,
tool_choice: 'required' as unknown,
}
}
describe('modelPatch', () => {
it('returns body unchanged when model is missing', () => {
const body = { temperature: 0.7 }
expect(modelPatch(body)).toBe(body)
expect(body).toEqual({ temperature: 0.7 })
})
it('qwen: bumps temperature and disables thinking', () => {
const body = baseBody('qwen-max')
modelPatch(body)
expect(body.temperature).toBe(1.0)
expect(body).toMatchObject({ enable_thinking: false })
})
it('qwen: keeps higher caller-provided temperature', () => {
const body = baseBody('qwen-max')
body.temperature = 1.5
modelPatch(body)
expect(body.temperature).toBe(1.5)
})
it('claude: disables thinking and converts tool_choice "required" -> { type: "any" }', () => {
const body = baseBody('claude-3-5-sonnet')
modelPatch(body)
expect(body).toMatchObject({
thinking: { type: 'disabled' },
tool_choice: { type: 'any' },
})
})
it('claude: converts named tool_choice to { type: "tool", name }', () => {
const body = baseBody('claude-3-5-sonnet')
body.tool_choice = { type: 'function', function: { name: 'doStuff' } }
modelPatch(body)
expect(body.tool_choice).toEqual({ type: 'tool', name: 'doStuff' })
})
it('claude-opus-4-7: drops temperature', () => {
const body = baseBody('claude-opus-4-7')
modelPatch(body)
expect(body).not.toHaveProperty('temperature')
})
it('claude-opus-47 (alt id form): drops temperature', () => {
// Provider sometimes ships ids with the dot stripped; modelPatch normalizes.
const body = baseBody('claude-opus-47-20251029')
modelPatch(body)
expect(body).not.toHaveProperty('temperature')
})
it('claude-opus-4-8: drops temperature', () => {
const body = baseBody('claude-opus-4-8')
modelPatch(body)
expect(body).not.toHaveProperty('temperature')
})
it('claude-opus-48 (alt id form): drops temperature', () => {
const body = baseBody('claude-opus-48-20251210')
modelPatch(body)
expect(body).not.toHaveProperty('temperature')
})
it('grok: removes tool_choice and disables reasoning/thinking', () => {
const body = baseBody('grok-4')
modelPatch(body)
expect(body).not.toHaveProperty('tool_choice')
expect(body).toMatchObject({
thinking: { type: 'disabled', effort: 'minimal' },
reasoning: { enabled: false, effort: 'low' },
})
})
it('gpt-5: sets verbosity=low and reasoning_effort=low', () => {
const body = baseBody('gpt-5')
modelPatch(body)
expect(body).toMatchObject({ verbosity: 'low', reasoning_effort: 'low' })
})
it('gpt-5-mini: low effort, temperature=1', () => {
const body = baseBody('gpt-5-mini')
modelPatch(body)
expect(body).toMatchObject({
verbosity: 'low',
reasoning_effort: 'low',
temperature: 1,
})
})
it('gpt-5.1 (gpt-51): disables reasoning', () => {
const body = baseBody('gpt-5.1')
modelPatch(body)
expect(body).toMatchObject({ verbosity: 'low', reasoning_effort: 'none' })
})
it('gpt-5.4 (gpt-54): drops reasoning_effort', () => {
const body = baseBody('gpt-5.4')
modelPatch(body)
expect(body).toMatchObject({ verbosity: 'low' })
expect(body).not.toHaveProperty('reasoning_effort')
})
it('gpt-5.5 (gpt-55): drops reasoning_effort and temperature', () => {
const body = baseBody('gpt-5.5')
modelPatch(body)
expect(body).toMatchObject({ verbosity: 'low' })
expect(body).not.toHaveProperty('reasoning_effort')
expect(body).not.toHaveProperty('temperature')
})
it('gemini: sets reasoning_effort=minimal', () => {
const body = baseBody('gemini-2.5-pro')
modelPatch(body)
expect(body).toMatchObject({ reasoning_effort: 'minimal' })
})
it('deepseek: removes tool_choice', () => {
const body = baseBody('deepseek-chat')
modelPatch(body)
expect(body).not.toHaveProperty('tool_choice')
})
it('minimax: clamps temperature into (0, 1] and removes parallel_tool_calls', () => {
const body = baseBody('minimax-m2')
body.temperature = 0
modelPatch(body)
expect(body.temperature).toBeGreaterThan(0)
expect(body.temperature).toBeLessThanOrEqual(1)
expect(body).not.toHaveProperty('parallel_tool_calls')
})
it('minimax: caps temperature at 1', () => {
const body = baseBody('minimax-m2')
body.temperature = 2
modelPatch(body)
expect(body.temperature).toBe(1)
})
it('normalizes provider-prefixed model id (openai/gpt-5)', () => {
const body = baseBody('openai/gpt-5')
modelPatch(body)
expect(body).toMatchObject({ verbosity: 'low', reasoning_effort: 'low' })
})
})
+22 -3
View File
@@ -53,6 +53,18 @@ export function modelPatch(body: Record<string, any>) {
debug('Applying Claude patch: convert tool_choice format') debug('Applying Claude patch: convert tool_choice format')
body.tool_choice = { type: 'tool', name: body.tool_choice.function.name } body.tool_choice = { type: 'tool', name: body.tool_choice.function.name }
} }
// TODO: Claude naming pattern has changed
// needs proper handling
if (
modelName.startsWith('claude-opus-4-7') ||
modelName.startsWith('claude-opus-47') ||
modelName.startsWith('claude-opus-4-8') ||
modelName.startsWith('claude-opus-48')
) {
debug('Applying Claude-4.7/4.8 patch: remove temperature')
delete body.temperature
}
} }
if (modelName.startsWith('grok')) { if (modelName.startsWith('grok')) {
@@ -74,10 +86,12 @@ export function modelPatch(body: Record<string, any>) {
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')) { } else if (modelName.startsWith('gpt-54')) {
debug( debug('Applying GPT-5.4 patch: remove reasoning_effort')
'Applying GPT-5.4 patch: skip reasoning_effort because chat/completions rejects it with function tools'
)
delete body.reasoning_effort delete body.reasoning_effort
} else if (modelName.startsWith('gpt-55')) {
debug('Applying GPT-5.4 patch: remove reasoning_effort and temperature')
delete body.reasoning_effort
delete body.temperature
} 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'
@@ -93,6 +107,11 @@ export function modelPatch(body: Record<string, any>) {
body.reasoning_effort = 'minimal' body.reasoning_effort = 'minimal'
} }
if (modelName.startsWith('deepseek')) {
debug('Applying DeepSeek patch: remove tool_choice')
delete body.tool_choice
}
if (modelName.startsWith('minimax')) { if (modelName.startsWith('minimax')) {
debug('Applying MiniMax patch: clamp temperature to (0, 1]') debug('Applying MiniMax patch: clamp temperature to (0, 1]')
// MiniMax API rejects temperature = 0; clamp to a small positive value // MiniMax API rejects temperature = 0; clamp to a small positive value
+2
View File
@@ -13,6 +13,8 @@ export default defineConfig({
clearScreen: false, clearScreen: false,
plugins: [ plugins: [
dts({ dts({
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts'],
bundleTypes: true, bundleTypes: true,
compilerOptions: { compilerOptions: {
composite: true, composite: true,
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
name: 'llms',
include: ['src/**/*.test.ts'],
// Suppress console output from passing tests; failed tests still get their logs.
silent: 'passed-only',
},
})
+4 -5
View File
@@ -36,11 +36,11 @@ Same format — add the config to the MCP settings of your client.
## MCP Tools ## MCP Tools
| Tool | Input | Description | | Tool | Input | Description |
| -------------- | ------------------ | ---------------------------------------------------- | | -------------- | ------------------ | ----------------------------------------------------- |
| `execute_task` | `{ task: string }` | Execute a browser task in natural language. Blocking. | | `execute_task` | `{ task: string }` | Execute a browser task in natural language. Blocking. |
| `get_status` | — | Returns `{ connected, busy }` | | `get_status` | — | Returns `{ connected, busy }` |
| `stop_task` | — | Stop the currently running task. | | `stop_task` | — | Stop the currently running task. |
## Environment Variables ## Environment Variables
@@ -88,7 +88,6 @@ src/
## Dev ## Dev
```bash ```bash
npm run build:libs
npm run dev:ext npm run dev:ext
npx @modelcontextprotocol/inspector node packages/mcp/src/index.js npx @modelcontextprotocol/inspector node packages/mcp/src/index.js
``` ```
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/mcp", "name": "@page-agent/mcp",
"private": false, "private": false,
"version": "1.8.0", "version": "1.9.0",
"type": "module", "type": "module",
"bin": { "bin": {
"page-agent-mcp": "src/index.js" "page-agent-mcp": "src/index.js"
@@ -29,7 +29,7 @@
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"ws": "^8.20.0", "ws": "^8.21.0",
"zod": "^4.3.6" "zod": "^4.4.3"
} }
} }
+3 -1
View File
@@ -2,6 +2,7 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { exec } from 'node:child_process' import { exec } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { platform } from 'node:os' import { platform } from 'node:os'
import * as z from 'zod/v4' import * as z from 'zod/v4'
@@ -9,6 +10,7 @@ import { HubBridge } from './hub-bridge.js'
const env = process.env const env = process.env
const port = parseInt(env.PORT || '38401') const port = parseInt(env.PORT || '38401')
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
/** @type {Record<string, string>} */ /** @type {Record<string, string>} */
const llmConfig = {} const llmConfig = {}
@@ -30,7 +32,7 @@ exec(`${cmd} "${url}"`, (err) => {
// --- MCP server (stdio) --- // --- MCP server (stdio) ---
const mcpServer = new McpServer({ name: 'page-agent', version: '1.5.8' }) const mcpServer = new McpServer({ name: 'page-agent', version })
mcpServer.registerTool( mcpServer.registerTool(
'execute_task', 'execute_task',
+22 -14
View File
@@ -3,7 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="https://img.alicdn.com/imgextra/i1/O1CN01mRGret1QrKiu7CFJI_!!6000000002029-2-tps-64-64.png" /> <link
rel="icon"
href="https://img.alicdn.com/imgextra/i1/O1CN01mRGret1QrKiu7CFJI_!!6000000002029-2-tps-64-64.png"
/>
<title>Page Agent MCP Launcher</title> <title>Page Agent MCP Launcher</title>
<style> <style>
* { * {
@@ -172,16 +175,26 @@
If the extension is outdated, please update it to the latest version. If the extension is outdated, please update it to the latest version.
</li> </li>
<li data-i18n="tip_other_browser"> <li data-i18n="tip_other_browser">
If the extension is not installed in this browser, open this page from the If the extension is not installed in this browser, open this page from the browser that
browser that has it installed. has it installed.
</li> </li>
<li data-i18n="tip_refresh">Refresh this page after installing or updating.</li> <li data-i18n="tip_refresh">Refresh this page after installing or updating.</li>
</ul> </ul>
</div> </div>
<div class="links"> <div class="links">
<a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank" data-i18n="link_docs">Docs</a> <a
<a href="https://github.com/alibaba/page-agent/issues" target="_blank" data-i18n="link_issues">Report an Issue</a> href="https://alibaba.github.io/page-agent/docs/introduction/overview"
target="_blank"
data-i18n="link_docs"
>Docs</a
>
<a
href="https://github.com/alibaba/page-agent/issues"
target="_blank"
data-i18n="link_issues"
>Report an Issue</a
>
</div> </div>
</div> </div>
@@ -197,8 +210,7 @@
install_sub: 'Page Agent 需要安装最新版浏览器插件才能运行。', install_sub: 'Page Agent 需要安装最新版浏览器插件才能运行。',
install_btn: '从 Chrome 应用商店安装', install_btn: '从 Chrome 应用商店安装',
tip_outdated: '如果插件版本过旧,请更新到最新版本。', tip_outdated: '如果插件版本过旧,请更新到最新版本。',
tip_other_browser: tip_other_browser: '如果该浏览器中未安装插件,请从装有插件的浏览器打开此页面。',
'如果该浏览器中未安装插件,请从装有插件的浏览器打开此页面。',
tip_refresh: '安装或更新后,请刷新此页面。', tip_refresh: '安装或更新后,请刷新此页面。',
link_docs: '文档', link_docs: '文档',
link_issues: '问题反馈', link_issues: '问题反馈',
@@ -220,13 +232,9 @@
if (!globalThis.chrome?.runtime?.sendMessage) { if (!globalThis.chrome?.runtime?.sendMessage) {
showInstall() showInstall()
} else { } else {
chrome.runtime.sendMessage( chrome.runtime.sendMessage(EXT_ID, { type: 'OPEN_HUB', wsPort }, (response) => {
EXT_ID, if (chrome.runtime.lastError || !response?.ok) showInstall()
{ type: 'OPEN_HUB', wsPort }, })
(response) => {
if (chrome.runtime.lastError || !response?.ok) showInstall()
}
)
} }
} catch { } catch {
showInstall() showInstall()
+6 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "page-agent", "name": "page-agent",
"private": false, "private": false,
"version": "1.8.0", "version": "1.9.0",
"type": "module", "type": "module",
"main": "./src/PageAgent.ts", "main": "./src/PageAgent.ts",
"types": "./src/PageAgent.ts", "types": "./src/PageAgent.ts",
@@ -54,16 +54,16 @@
"postpublish": "node ../../scripts/post-publish.js" "postpublish": "node ../../scripts/post-publish.js"
}, },
"dependencies": { "dependencies": {
"@page-agent/core": "1.8.0", "@page-agent/core": "1.9.0",
"@page-agent/llms": "1.8.0", "@page-agent/llms": "1.9.0",
"@page-agent/page-controller": "1.8.0", "@page-agent/page-controller": "1.9.0",
"@page-agent/ui": "1.8.0", "@page-agent/ui": "1.9.0",
"chalk": "^5.6.2" "chalk": "^5.6.2"
}, },
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.0 || ^4.0.0" "zod": "^3.25.0 || ^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"zod": "^4.3.6" "zod": "^4.4.3"
} }
} }
+32 -25
View File
@@ -3,8 +3,12 @@
*/ */
import { PageAgent, type PageAgentConfig } from './PageAgent' import { PageAgent, type PageAgentConfig } from './PageAgent'
const currentScript = document.currentScript as HTMLScriptElement | null
const currentScriptURL = currentScript?.src ? new URL(currentScript.src) : null
const autoInit = currentScriptURL?.searchParams.get('autoInit') !== 'false'
// Clean up existing instances to prevent multiple injections from bookmarklet // Clean up existing instances to prevent multiple injections from bookmarklet
if (window.pageAgent) { if (autoInit && window.pageAgent) {
window.pageAgent.dispose() window.pageAgent.dispose()
} }
@@ -17,32 +21,35 @@ const DEMO_MODEL = 'qwen3.5-plus'
const DEMO_BASE_URL = 'https://page-ag-testing-ohftxirgbn.cn-shanghai.fcapp.run' const DEMO_BASE_URL = 'https://page-ag-testing-ohftxirgbn.cn-shanghai.fcapp.run'
const DEMO_API_KEY = 'NA' const DEMO_API_KEY = 'NA'
const currentScript = document.currentScript as HTMLScriptElement | null
// in case document.x is not ready yet // in case document.x is not ready yet
setTimeout(() => { if (autoInit) {
let config: PageAgentConfig setTimeout(() => {
let config: PageAgentConfig
let showPanel = true
if (currentScript) { if (currentScriptURL) {
console.log('🚀 page-agent.js detected current script:', currentScript.src) const url = currentScriptURL
const url = new URL(currentScript.src) const model = url.searchParams.get('model') || DEMO_MODEL
const model = url.searchParams.get('model') || DEMO_MODEL const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL
const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY
const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY const language = (url.searchParams.get('lang') as 'zh-CN' | 'en-US') || 'zh-CN'
const language = (url.searchParams.get('lang') as 'zh-CN' | 'en-US') || 'zh-CN' showPanel = ((url.searchParams.get('showPanel') as 'true' | 'false') || 'true') === 'true'
config = { model, baseURL, apiKey, language } config = { model, baseURL, apiKey, language }
} else { } else {
console.log('🚀 page-agent.js no current script detected, using default demo config') console.log('🚀 page-agent.js no current script detected, using default demo config')
config = { config = {
model: import.meta.env.LLM_MODEL_NAME ? import.meta.env.LLM_MODEL_NAME : DEMO_MODEL, model: import.meta.env.LLM_MODEL_NAME ? import.meta.env.LLM_MODEL_NAME : DEMO_MODEL,
baseURL: import.meta.env.LLM_BASE_URL ? import.meta.env.LLM_BASE_URL : DEMO_BASE_URL, baseURL: import.meta.env.LLM_BASE_URL ? import.meta.env.LLM_BASE_URL : DEMO_BASE_URL,
apiKey: import.meta.env.LLM_API_KEY ? import.meta.env.LLM_API_KEY : DEMO_API_KEY, apiKey: import.meta.env.LLM_API_KEY ? import.meta.env.LLM_API_KEY : DEMO_API_KEY,
}
} }
}
// Create agent // Create agent
window.pageAgent = new PageAgent(config) window.pageAgent = new PageAgent(config)
window.pageAgent.panel.show() if (showPanel) {
window.pageAgent.panel.show()
}
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config) console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
}) })
}
+2
View File
@@ -12,6 +12,8 @@ export default defineConfig({
clearScreen: false, clearScreen: false,
plugins: [ plugins: [
dts({ dts({
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts'],
bundleTypes: true, bundleTypes: true,
compilerOptions: { compilerOptions: {
composite: true, composite: true,
-1
View File
@@ -3,7 +3,6 @@ import { config as dotenvConfig } from 'dotenv'
import { dirname, resolve } from 'path' import { dirname, resolve } from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
// import { analyzer } from 'vite-bundle-analyzer'
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js' import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/page-controller", "name": "@page-agent/page-controller",
"version": "1.8.0", "version": "1.9.0",
"type": "module", "type": "module",
"main": "./src/PageController.ts", "main": "./src/PageController.ts",
"types": "./src/PageController.ts", "types": "./src/PageController.ts",
+2
View File
@@ -14,6 +14,8 @@ export default defineConfig({
clearScreen: false, clearScreen: false,
plugins: [ plugins: [
dts({ dts({
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts'],
bundleTypes: true, bundleTypes: true,
compilerOptions: { compilerOptions: {
composite: true, composite: true,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/ui", "name": "@page-agent/ui",
"version": "1.8.0", "version": "1.9.0",
"type": "module", "type": "module",
"main": "./src/index.ts", "main": "./src/index.ts",
"types": "./src/index.ts", "types": "./src/index.ts",
+28 -10
View File
@@ -68,7 +68,7 @@ export class Panel {
this.#i18n = new I18n(config.language ?? 'en-US') this.#i18n = new I18n(config.language ?? 'en-US')
// Set up askUser callback on agent // Set up askUser callback on agent
this.#agent.onAskUser = (question) => this.#askUser(question) this.#agent.onAskUser = (question, options) => this.#askUser(question, options?.signal)
// Create UI elements // Create UI elements
this.#wrapper = this.#createWrapper() this.#wrapper = this.#createWrapper()
@@ -169,10 +169,12 @@ export class Panel {
} }
/** /**
* Ask for user input (internal, called by agent via onAskUser) * Ask for user input (internal, called by agent via onAskUser).
* Rejects when `signal` aborts (task stopped or disposed), cleaning up the
* question card and pending state so the agent loop can settle.
*/ */
#askUser(question: string): Promise<string> { #askUser(question: string, signal?: AbortSignal): Promise<string> {
return new Promise((resolve) => { return new Promise((resolve, reject) => {
// Set `waiting for user answer` state // Set `waiting for user answer` state
this.#isWaitingForUserAnswer = true this.#isWaitingForUserAnswer = true
this.#userAnswerResolver = resolve this.#userAnswerResolver = resolve
@@ -195,6 +197,27 @@ export class Panel {
this.#scrollToBottom() this.#scrollToBottom()
this.#showInputArea(this.#i18n.t('ui.panel.userAnswerPrompt')) this.#showInputArea(this.#i18n.t('ui.panel.userAnswerPrompt'))
signal?.addEventListener(
'abort',
() => {
this.#removeTempCards()
this.#isWaitingForUserAnswer = false
this.#userAnswerResolver = null
// reason is a DOMException AbortError (abort() takes no args).
reject(signal.reason as DOMException)
},
{ once: true }
)
})
}
/** Remove temporary question cards (only direct children for safety) */
#removeTempCards(): void {
Array.from(this.#historySection.children).forEach((child) => {
if (child.getAttribute('data-temp-card') === 'true') {
child.remove()
}
}) })
} }
@@ -307,12 +330,7 @@ export class Panel {
* Handle user answer * Handle user answer
*/ */
#handleUserAnswer(input: string): void { #handleUserAnswer(input: string): void {
// Remove temporary question cards (only direct children for safety) this.#removeTempCards()
Array.from(this.#historySection.children).forEach((child) => {
if (child.getAttribute('data-temp-card') === 'true') {
child.remove()
}
})
// Reset state // Reset state
this.#isWaitingForUserAnswer = false this.#isWaitingForUserAnswer = false
+4 -2
View File
@@ -60,10 +60,12 @@ export interface PanelAgentAdapter extends EventTarget {
readonly task: string readonly task: string
/** /**
* Callback for when agent needs user input. * Called when the agent needs to ask the user questions.
* If unset, the `ask_user` tool will be disabled.
* Panel will set this to handle user questions via its UI. * Panel will set this to handle user questions via its UI.
* The optional `signal` aborts when the task is stopped or disposed.
*/ */
onAskUser?: (question: string) => Promise<string> onAskUser?: (question: string, options?: { signal: AbortSignal }) => Promise<string>
/** Execute a task */ /** Execute a task */
execute(task: string): Promise<unknown> execute(task: string): Promise<unknown>
+2
View File
@@ -14,6 +14,8 @@ export default defineConfig({
clearScreen: false, clearScreen: false,
plugins: [ plugins: [
dts({ dts({
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts'],
bundleTypes: true, bundleTypes: true,
compilerOptions: { compilerOptions: {
composite: true, composite: true,
+28 -4
View File
@@ -49,10 +49,34 @@
<div id="sk"> <div id="sk">
<p class="sk-text" id="sk-text">Loading...</p> <p class="sk-text" id="sk-text">Loading...</p>
<style> <style>
#sk{display:flex;align-items:center;justify-content:center;min-height:100vh;background:linear-gradient(135deg,#eff6ff,#f5f3ff)} #sk {
@media(prefers-color-scheme:dark){#sk{background:linear-gradient(135deg,#111827,#1f2937)}} display: flex;
.sk-text{font:400 14px/1 system-ui,sans-serif;color:#94a3b8;animation:skf 2s ease-in-out infinite} align-items: center;
@keyframes skf{0%,100%{opacity:.6}50%{opacity:.3}} 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: 0.6;
}
50% {
opacity: 0.3;
}
}
</style> </style>
</div> </div>
</div> </div>
+10 -10
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/website", "name": "@page-agent/website",
"private": true, "private": true,
"version": "1.8.0", "version": "1.9.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
@@ -14,21 +14,21 @@
"@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": "^19.2.15",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.8.0", "lucide-react": "^1.16.0",
"motion": "^12.38.0", "motion": "^12.40.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.5", "react": "^19.2.6",
"react-dom": "^19.2.5", "react-dom": "^19.2.6",
"rough-notation": "^0.5.1", "rough-notation": "^0.5.1",
"simple-icons": "^16.16.0", "simple-icons": "^16.21.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"wouter": "^3.9.0" "wouter": "^3.10.0"
} }
} }
+4 -3
View File
@@ -2,7 +2,7 @@ import { ComponentPropsWithoutRef, useEffect, useRef } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
type Level = 2 | 3 type Level = 2 | 3 | 4
interface HeadingProps extends Omit<ComponentPropsWithoutRef<'h2'>, 'children'> { interface HeadingProps extends Omit<ComponentPropsWithoutRef<'h2'>, 'children'> {
id: string id: string
@@ -11,8 +11,9 @@ interface HeadingProps extends Omit<ComponentPropsWithoutRef<'h2'>, 'children'>
} }
const levelStyles = { const levelStyles = {
2: { tag: 'h2', className: 'text-2xl font-semibold mb-4' }, 2: { tag: 'h2', className: 'text-3xl font-semibold mb-4' },
3: { tag: 'h3', className: 'text-xl font-semibold mb-3' }, 3: { tag: 'h3', className: 'text-[1.375rem] font-semibold mb-3' },
4: { tag: 'h4', className: 'text-[1.0625rem] font-medium mb-2' },
} as const } as const
export function Heading({ id, level = 2, className, children, ...props }: HeadingProps) { export function Heading({ id, level = 2, className, children, ...props }: HeadingProps) {
+2 -2
View File
@@ -1,8 +1,8 @@
// Demo build (auto-init with demo LLM, for quick testing) // Demo build (auto-init with demo LLM, for quick testing)
export const CDN_DEMO_URL = export const CDN_DEMO_URL =
'https://cdn.jsdelivr.net/npm/page-agent@1.8.0/dist/iife/page-agent.demo.js' 'https://cdn.jsdelivr.net/npm/page-agent@1.9.0/dist/iife/page-agent.demo.js'
export const CDN_DEMO_CN_URL = export const CDN_DEMO_CN_URL =
'https://registry.npmmirror.com/page-agent/1.8.0/files/dist/iife/page-agent.demo.js' 'https://registry.npmmirror.com/page-agent/1.9.0/files/dist/iife/page-agent.demo.js'
// Demo LLM for website testing (homepage quick trial uses flash) // Demo LLM for website testing (homepage quick trial uses flash)
export const DEMO_MODEL = 'qwen3.5-flash' export const DEMO_MODEL = 'qwen3.5-flash'
+28 -1
View File
@@ -95,7 +95,7 @@ body {
line-height: 1.6; line-height: 1.6;
} }
/* 标题使用中等字重(相对细体更重,但比默认 bold 更轻) */ /* 文档标题层级需要比正文标题更清晰,避免 h2/h3/h4 视觉差异过小。 */
.prose h1, .prose h1,
.prose h2, .prose h2,
.prose h3, .prose h3,
@@ -103,6 +103,33 @@ body {
.prose h5, .prose h5,
.prose h6 { .prose h6 {
font-weight: 480; font-weight: 480;
line-height: 1.25;
letter-spacing: -0.015em;
}
.prose h2[id] {
margin-bottom: 1rem;
padding-bottom: 0.45rem;
border-bottom: 1px solid rgba(23, 23, 23, 0.12);
font-size: 1.875rem;
font-weight: 650;
}
.prose h3[id] {
margin-bottom: 0.75rem;
font-size: 1.375rem;
font-weight: 600;
}
.prose h4[id] {
margin-bottom: 0.5rem;
font-size: 1.0625rem;
font-weight: 560;
letter-spacing: -0.005em;
}
.dark .prose h2[id] {
border-bottom-color: rgba(255, 255, 255, 0.14);
} }
/* strong/b 也用中等字重 */ /* strong/b 也用中等字重 */
+2 -1
View File
@@ -22,7 +22,8 @@ interface NavSection {
export default function DocsLayout({ children }: DocsLayoutProps) { export default function DocsLayout({ children }: DocsLayoutProps) {
const { isZh } = useLanguage() const { isZh } = useLanguage()
const [location] = useLocation() const [rawLocation] = useLocation()
const location = rawLocation.replace(/\/+$/, '')
const navigationSections: NavSection[] = [ const navigationSections: NavSection[] = [
{ {
@@ -257,7 +257,8 @@ const root = createRoot(document.getElementById('my-ui')!)
root.render(<MyAgentUI agent={agent} />) root.render(<MyAgentUI agent={agent} />)
// 4. Handle user input (optional) // 4. Handle user input (optional)
agent.onAskUser = async (question) => window.prompt(question) || '' // options.signal aborts when the task is stopped or disposed
agent.onAskUser = async (question, options) => window.prompt(question) || ''
// 5. Execute task // 5. Execute task
await agent.execute('Fill the form with test data') await agent.execute('Fill the form with test data')
@@ -156,6 +156,34 @@ const result = await agent.execute('Fill in the form with test data')`}
defaultValue: '3', defaultValue: '3',
description: isZh ? 'API 调用失败时的最大重试次数' : 'Maximum retries on API failure', description: isZh ? 'API 调用失败时的最大重试次数' : 'Maximum retries on API failure',
}, },
{
name: 'transformRequestBody',
type: '(requestBody) => Record<string, unknown> | undefined',
description: isZh ? (
<>
request body{' '}
<Link
href="/features/models#prompt-caching"
className="text-blue-600 dark:text-blue-400 hover:underline"
>
</Link>
</>
) : (
<>
Transform the final request body before sending it. Useful for provider-specific
cache hints or private request parameters. See{' '}
<Link
href="/features/models#prompt-caching"
className="text-blue-600 dark:text-blue-400 hover:underline"
>
prompt caching examples
</Link>
.
</>
),
},
{ {
name: 'disableNamedToolChoice', name: 'disableNamedToolChoice',
type: 'boolean', type: 'boolean',
@@ -326,10 +354,10 @@ const result = await agent.execute('Fill in the form with test data')`}
}, },
{ {
name: 'onAskUser', name: 'onAskUser',
type: '(question: string) => Promise<string>', type: '(question: string, options?: { signal: AbortSignal }) => Promise<string>',
description: isZh description: isZh
? 'Agent 需要用户输入时的回调。未设置则禁用 ask_user 工具。' ? '当 agent 需要用户提问时调用。未设置则禁用 `ask_user` 工具。实现应在 options.signal 触发 abort 时 reject promise。'
: 'Callback when agent needs user input. If not set, ask_user tool is disabled.', : 'Called when the agent needs to ask the user questions. If unset, the `ask_user` tool will be disabled. Implementations should reject the promise when options.signal aborts.',
}, },
]} ]}
/> />
@@ -345,8 +373,8 @@ const result = await agent.execute('Fill in the form with test data')`}
name: 'execute(task)', name: 'execute(task)',
type: 'Promise<ExecutionResult>', type: 'Promise<ExecutionResult>',
description: isZh description: isZh
? '执行任务并返回结果包含 success、data 和 history 字段。' ? '执行任务并返回结果包含 success、data 和 history 字段)。若已有任务在运行则抛出错误——不支持并发执行。'
: 'Execute a task and return result. Contains success, data, and history fields.', : 'Execute a task and return result (contains success, data, and history fields). Throws if a task is already running — concurrent execution is not supported.',
}, },
{ {
name: 'stop()', name: 'stop()',
@@ -38,8 +38,8 @@ import { z } from 'zod/v4'`}
</Heading> </Heading>
<p className="text-gray-600 dark:text-gray-300 mb-4"> <p className="text-gray-600 dark:text-gray-300 mb-4">
{isZh {isZh
? '使用 tool() 辅助函数定义自定义工具,每个工具包含 description、inputSchema 和 execute 三个属性。' ? '使用 tool() 辅助函数定义自定义工具,每个工具包含 description、inputSchema 和 execute 三个属性。异步工具必须 honor ctx.signal。'
: 'Use the tool() helper to define custom tools with description, inputSchema, and execute.'} : 'Use the tool() helper to define custom tools with description, inputSchema, and execute. Async tools must honor ctx.signal.'}
</p> </p>
<CodeEditor <CodeEditor
@@ -56,10 +56,11 @@ const pageAgent = new PageAgent({
productId: z.string(), productId: z.string(),
quantity: z.number().min(1).default(1), quantity: z.number().min(1).default(1),
}), }),
execute: async function (input) { execute: async function (input, { signal }) {
await fetch('/api/cart', { await fetch('/api/cart', {
method: 'POST', method: 'POST',
body: JSON.stringify(input), body: JSON.stringify(input),
signal, // honor cancellation
}) })
return \`Added \${input.quantity}x \${input.productId} to cart.\` return \`Added \${input.quantity}x \${input.productId} to cart.\`
}, },
@@ -72,9 +73,10 @@ const pageAgent = new PageAgent({
query: z.string(), query: z.string(),
limit: z.number().max(10).default(3), limit: z.number().max(10).default(3),
}), }),
execute: async function (input) { execute: async function (input, { signal }) {
const res = await fetch( const res = await fetch(
\`/api/kb?q=\${encodeURIComponent(input.query)}&limit=\${input.limit}\` \`/api/kb?q=\${encodeURIComponent(input.query)}&limit=\${input.limit}\`,
{ signal }
) )
const articles = await res.json() const articles = await res.json()
return JSON.stringify(articles) return JSON.stringify(articles)
@@ -1,4 +1,5 @@
import { Fragment } from 'react' import { Fragment } from 'react'
import { Link } from 'wouter'
import CodeEditor from '@/components/CodeEditor' import CodeEditor from '@/components/CodeEditor'
import { Heading } from '@/components/Heading' import { Heading } from '@/components/Heading'
@@ -6,9 +7,10 @@ import { useLanguage } from '@/i18n/context'
const BASELINE = new Set([ const BASELINE = new Set([
'gpt-5.1', 'gpt-5.1',
'gpt-5.4-mini',
'claude-haiku-4.5', 'claude-haiku-4.5',
'gemini-3-flash', 'gemini-3.5-flash',
'deepseek-3.2', 'deepseek-v4-flash',
'qwen3.5-plus', 'qwen3.5-plus',
'qwen3.5-flash', 'qwen3.5-flash',
]) ])
@@ -16,17 +18,40 @@ const BASELINE = new Set([
// Models grouped by brand, newest first // Models grouped by brand, newest first
const MODEL_GROUPS: Record<string, string[]> = { const MODEL_GROUPS: Record<string, string[]> = {
Qwen: [ Qwen: [
'qwen3.7-max',
'qwen3.7-plus',
'qwen3.6-max',
'qwen3.6-plus', 'qwen3.6-plus',
'qwen3.6-flash',
'qwen3.5-plus', 'qwen3.5-plus',
'qwen3.5-flash', 'qwen3.5-flash',
'qwen3-coder-next', 'qwen3-coder-next',
'qwen-3-max', 'qwen-3-max',
'qwen-3-plus', 'qwen-3-plus',
], ],
OpenAI: ['gpt-5.4', 'gpt-5.2', 'gpt-5.1', 'gpt-5', 'gpt-5-mini', 'gpt-4.1', 'gpt-4.1-mini'], OpenAI: [
DeepSeek: ['deepseek-3.2'], 'gpt-5.5',
Google: ['gemini-3-pro', 'gemini-3-flash', 'gemini-2.5'], 'gpt-5.4',
'gpt-5.4-mini',
'gpt-5.4-nano',
'gpt-5.2',
'gpt-5.1',
'gpt-5',
'gpt-5-mini',
'gpt-4.1',
'gpt-4.1-mini',
],
DeepSeek: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-3.2'],
Google: [
'gemini-3.5-flash',
'gemini-3.1-flash-lite',
'gemini-3-pro',
'gemini-3-flash',
'gemini-2.5',
],
Anthropic: [ Anthropic: [
'claude-opus-4.8',
'claude-opus-4.7',
'claude-opus-4.6', 'claude-opus-4.6',
'claude-opus-4.5', 'claude-opus-4.5',
'claude-sonnet-4.5', 'claude-sonnet-4.5',
@@ -124,46 +149,6 @@ const pageAgent = new PageAgent({
/> />
</section> </section>
<section className="mb-10">
<Heading id="production-authentication" className="text-2xl font-semibold mb-4">
{isZh ? '🔐 生产环境鉴权' : '🔐 Production Authentication'}
</Heading>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
{isZh
? '如果你只是将它用作个人助手,可以直接连接你的 LLM 服务。'
: 'If you only use it as a personal assistant, you can connect to your LLM service directly.'}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
{isZh ? (
<>
Web LLM 使{' '}
<code>customFetch</code> Cookie
</>
) : (
<>
If you plan to integrate it into your web app, it's better to have a backend proxy for
the LLM and use <code>customFetch</code> to authenticate the request with cookies or
other methods:
</>
)}
</p>
<CodeEditor
code={`const agent = new PageAgent({
baseURL: '/api/llm-proxy',
model: 'gpt-5.1',
customFetch: (url, init) =>
fetch(url, { ...init, credentials: 'include' }),
});`}
/>
<div className="mt-4 bg-yellow-50 dark:bg-yellow-950/20 border-l-4 border-yellow-500 p-4 rounded-r-lg">
<p className="text-sm font-semibold text-yellow-900 dark:text-yellow-200">
{isZh
? ' LLM API Key '
: ' NEVER commit real LLM API keys to your frontend code'}
</p>
</div>
</section>
<section className="mb-10"> <section className="mb-10">
<Heading id="free-testing-api">{isZh ? '免费测试接口' : 'Free Testing API'}</Heading> <Heading id="free-testing-api">{isZh ? '免费测试接口' : 'Free Testing API'}</Heading>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4"> <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
@@ -213,7 +198,139 @@ LLM_MODEL_NAME="qwen3.5-plus"`}
</section> </section>
<section className="mb-10"> <section className="mb-10">
<Heading id="local-runtimes">{isZh ? '' : 'Local Runtimes'}</Heading> <Heading id="production-authentication" className="text-2xl font-semibold mb-4">
{isZh ? '🔐 生产环境鉴权' : '🔐 Production Authentication'}
</Heading>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
{isZh
? '如果你只是将它用作个人助手,可以直接连接你的 LLM 服务。'
: 'If you only use it as a personal assistant, you can connect to your LLM service directly.'}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
{isZh ? (
<>
Web LLM 使{' '}
<code>customFetch</code> Cookie
</>
) : (
<>
If you plan to integrate it into your web app, it's better to have a backend proxy for
the LLM and use <code>customFetch</code> to authenticate the request with cookies or
other methods:
</>
)}
</p>
<CodeEditor
code={`const agent = new PageAgent({
baseURL: '/api/llm-proxy',
model: 'gpt-5.1',
customFetch: (url, init) =>
fetch(url, { ...init, credentials: 'include' }),
});`}
/>
<div className="mt-4 bg-yellow-50 dark:bg-yellow-950/20 border-l-4 border-yellow-500 p-4 rounded-r-lg">
<p className="text-sm font-semibold text-yellow-900 dark:text-yellow-200">
{isZh
? ' LLM API Key '
: ' NEVER commit real LLM API keys to your frontend code'}
</p>
</div>
</section>
<section className="mb-10">
<Heading id="prompt-caching">{isZh ? '' : 'Prompt Caching'}</Heading>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
{isZh ? (
<>
一些 LLM 能从主动缓存中受益很多。由于各个供应商的主动缓存接口不同,推荐使用{' '}
<Link
href="/advanced/page-agent-core#configuration"
className="text-blue-600 dark:text-blue-400 hover:underline"
>
transformRequestBody
</Link>{' '}
为你的模型供应商配置缓存提示。
</>
) : (
<>
Some LLMs benefit significantly from prompt caching. Because each provider exposes
caching differently, use{' '}
<Link
href="/advanced/page-agent-core#configuration"
className="text-blue-600 dark:text-blue-400 hover:underline"
>
transformRequestBody
</Link>{' '}
to add provider-specific cache hints.
</>
)}
</p>
<div className="space-y-6">
<section>
<Heading id="prompt-caching-claude" level={3}>
Claude
</Heading>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
{isZh
? 'Claude Automatic prompt caching使 Claude cache_control'
: 'Claude supports global Automatic prompt caching. When using a Claude-compatible proxy, add cache_control at the top level of the request body.'}
</p>
<CodeEditor
language="typescript"
code={`const pageAgent = new PageAgent({
baseURL: 'https://your-claude-proxy.example/v1',
apiKey: 'your-api-key',
model: 'claude-sonnet-4.5',
transformRequestBody: (requestBody) => ({
...requestBody,
cache_control: { type: 'ephemeral' },
}),
});`}
/>
</section>
<section>
<Heading id="prompt-caching-qwen" level={3}>
{isZh ? '阿里云百炼 Qwen' : 'Alibaba Cloud Bailian Qwen'}
</Heading>
<CodeEditor
language="typescript"
code={`const pageAgent = new PageAgent({
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
apiKey: 'your-api-key',
model: 'qwen3.5-plus',
transformRequestBody: (requestBody) => {
const [systemMessage, ...restMessages] = requestBody.messages
if (systemMessage.role !== 'system' || typeof systemMessage.content !== 'string') {
return requestBody
}
return {
...requestBody,
messages: [
{
...systemMessage,
content: [
{
type: 'text',
text: systemMessage.content,
cache_control: { type: 'ephemeral' },
},
],
},
...restMessages,
],
}
},
});`}
/>
</section>
</div>
</section>
<section className="mb-10">
<Heading id="local-runtimes">{isZh ? '本地 LLMs' : 'Local LLMs'}</Heading>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6"> <p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
{isZh {isZh
? '通过 Ollama、LM Studio 等本地 OpenAI-compatible 运行时接入 PageAgent,实现离线或局域网部署。' ? '通过 Ollama、LM Studio 等本地 OpenAI-compatible 运行时接入 PageAgent,实现离线或局域网部署。'
@@ -58,6 +58,11 @@ export default function QuickStart() {
code={`<script src="DEMO_CDN_URL" crossorigin="true"></script>`} code={`<script src="DEMO_CDN_URL" crossorigin="true"></script>`}
language="html" language="html"
/> />
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
{isZh
? '在 URL 后添加 ?autoInit=false 可只加载脚本,不自动创建 Demo Agent;之后可通过 new window.PageAgent(...) 手动初始化。'
: 'Add ?autoInit=false to load the script without creating the demo agent automatically. You can then instantiate it with new window.PageAgent(...).'}
</p>
<table className="w-full border-collapse text-sm"> <table className="w-full border-collapse text-sm">
<thead> <thead>
<tr className="border-b border-gray-200 dark:border-gray-700"> <tr className="border-b border-gray-200 dark:border-gray-700">
@@ -77,9 +77,7 @@ export default function HeroSection() {
instructions: { instructions: {
system: 'You are a helpful assistant on PageAgent website.', system: 'You are a helpful assistant on PageAgent website.',
getPageInstructions: (url: string) => { getPageInstructions: (url: string) => {
const hint = url.includes('page-agent') ? 'This is PageAgent demo page.' : undefined return url.includes('page-agent') ? 'This is PageAgent demo page.' : undefined
console.log('[instructions] getPageInstructions:', url, '->', hint)
return hint
}, },
}, },
@@ -98,8 +96,7 @@ export default function HeroSection() {
}) })
} }
const result = await win.pageAgent.execute(task) await win.pageAgent.execute(task)
console.log(result)
} }
return ( return (
+5 -3
View File
@@ -1,5 +1,5 @@
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react-swc' import react from '@vitejs/plugin-react'
import { config as dotenvConfig } from 'dotenv' import { config as dotenvConfig } from 'dotenv'
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import process from 'node:process' import process from 'node:process'
@@ -82,8 +82,10 @@ export default defineConfig(({ mode }) => ({
handler(message) handler(message)
}, },
output: { output: {
manualChunks: { manualChunks(id) {
vendor: ['react', 'react-dom', 'wouter'], if (/[\\/]node_modules[\\/](react|react-dom|wouter)([\\/]|$)/.test(id)) {
return 'vendor'
}
}, },
}, },
}, },
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env node
/**
* Equivalent to: npm run build --workspaces --if-present
*
* Reads the workspace list from root package.json, filters to those with a
* "build" script, and runs them all concurrently via parallelTask.
*/
import { readFileSync } from 'fs'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
import { parallelTask } from './parallel-task.js'
const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..')
const rootPkg = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf-8'))
const tasks = rootPkg.workspaces
.map((ws) => {
const dir = join(rootDir, ws)
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8'))
return pkg.scripts?.build ? { label: pkg.name, command: 'npm run build', cwd: dir } : null
})
.filter(Boolean)
await parallelTask(tasks, { timeoutMs: 120_000 })
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
/**
* Full build pipeline. Equivalent to:
* npm run cleanup && npm run build --workspaces --if-present
* && npm run build:website -w @page-agent/website
* && npm run zip -w @page-agent/ext
*
* 1. cleanup
* 2. build everything in parallel (libs + website + extension)
*/
import chalk from 'chalk'
import { execSync } from 'child_process'
import { readFileSync } from 'fs'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
import { parallelTask } from './parallel-task.js'
const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..')
const rootPkg = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf-8'))
// Step 1: cleanup
console.log(chalk.bgBlue.white.bold(' ▸ cleanup '))
execSync('npm run cleanup', { cwd: rootDir, stdio: 'inherit' })
// Step 2: build all in parallel
console.log(chalk.bgBlue.white.bold(' ▸ build '))
const tasks = rootPkg.workspaces
.map((ws) => {
const dir = join(rootDir, ws)
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8'))
return pkg.scripts?.build ? { label: pkg.name, command: 'npm run build', cwd: dir } : null
})
.filter(Boolean)
tasks.push(
{
label: '@page-agent/website',
command: 'npm run build:website',
cwd: join(rootDir, 'packages/website'),
},
{ label: '@page-agent/ext', command: 'npm run zip', cwd: join(rootDir, 'packages/extension') }
)
await parallelTask(tasks, { timeoutMs: 120_000 })
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env node
/**
* CI check script. Run locally before commit or in GitHub Actions.
*
* Usage:
* node scripts/ci.js # run all checks
* node scripts/ci.js --no-build # skip build step
*/
import chalk from 'chalk'
import { execSync } from 'child_process'
import { parallelTask } from './parallel-task.js'
const args = new Set(process.argv.slice(2))
const skipBuild = args.has('--no-build')
function run(label, command) {
console.log(chalk.bgBlue.white.bold(`${label} `))
execSync(command, { stdio: 'inherit' })
}
function isMainBranch() {
if (process.env.GITHUB_REF) return process.env.GITHUB_REF === 'refs/heads/main'
try {
return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim() === 'main'
} catch {
return true
}
}
// 1. Commitlint — skip on main
if (isMainBranch()) {
console.log(chalk.dim(' ▸ commitlint (skipped on main)'))
} else {
const from = execSync('git merge-base origin/main HEAD', { encoding: 'utf-8' }).trim()
run('commitlint', `npx commitlint --from ${from} --to HEAD`)
}
// 2. Lint + Format + Typecheck + Test in parallel
console.log(chalk.bgBlue.white.bold(' ▸ lint + format + typecheck + test '))
await parallelTask(
[
{ label: 'lint', command: 'npm run lint' },
{ label: 'format', command: 'npx prettier --check .' },
{ label: 'typecheck', command: 'npm run typecheck' },
{ label: 'test', command: 'npm test' },
],
{ timeoutMs: 120_000 }
)
// 3. Build
if (skipBuild) {
console.log(chalk.dim(' ▸ build (skipped)'))
} else {
run('build', 'npm run build')
}
+115
View File
@@ -0,0 +1,115 @@
import chalk from 'chalk'
import { spawn } from 'child_process'
/**
* Run multiple shell commands in parallel with progress reporting.
*
* @param {{ label: string, command: string, cwd?: string }[]} tasks
* @param {{ timeoutMs?: number }} options - Default timeout 30s per task
* @returns {Promise<void>} Rejects (process.exit) if any task fails
*/
export async function parallelTask(tasks, options = {}) {
const { timeoutMs = 30_000 } = options
const total = tasks.length
const bgColors = [
chalk.bgCyan,
chalk.bgMagenta,
chalk.bgBlue,
chalk.bgYellow,
chalk.bgGreenBright,
]
const fgOnBg = [chalk.black, chalk.white, chalk.white, chalk.black, chalk.black]
let done = 0
let failed = 0
const spinner = ['◐', '◓', '◑', '◒']
let tick = 0
const printProgress = () => {
const running = total - done - failed
const s = spinner[tick++ % spinner.length]
const status = failed
? `${running} running, ${done} done, ${chalk.bgRed.white.bold(` ${failed} failed `)}`
: `${running} running, ${done} done`
process.stderr.write(`\r${chalk.bgCyan.black.bold(` ${s} ${done}/${total} `)} ${status} `)
}
const timer = setInterval(printProgress, 1000)
printProgress()
/** @type {{ label: string, output: string, exitCode: number | null, timedOut: boolean }[]} */
const results = await Promise.all(
tasks.map(
(task) =>
new Promise((resolve) => {
const chunks = /** @type {Buffer[]} */ ([])
const child = spawn('sh', ['-c', task.command], {
cwd: task.cwd,
env: { ...process.env, FORCE_COLOR: '1', NO_COLOR: '' },
stdio: ['ignore', 'pipe', 'pipe'],
})
child.stdout.on('data', (d) => chunks.push(d))
child.stderr.on('data', (d) => chunks.push(d))
const timeout = setTimeout(() => {
child.kill('SIGTERM')
}, timeoutMs)
child.on('close', (code, signal) => {
clearTimeout(timeout)
const timedOut = signal === 'SIGTERM'
const exitCode = timedOut ? 1 : code
if (exitCode === 0) done++
else failed++
resolve({
label: task.label,
output: Buffer.concat(chunks).toString(),
exitCode,
timedOut,
})
})
})
)
)
clearInterval(timer)
process.stderr.write('\r\x1b[K')
const failedTasks = /** @type {typeof results} */ ([])
for (let i = 0; i < results.length; i++) {
const r = results[i]
if (r.exitCode !== 0) {
failedTasks.push(r)
continue
}
const bg = bgColors[i % bgColors.length]
const fg = fgOnBg[i % fgOnBg.length]
const banner = bg(fg.bold(`${r.label} `))
console.log(`\n${banner}`)
if (r.output.trim()) process.stdout.write(r.output)
}
if (failedTasks.length) {
for (const r of failedTasks) {
const banner = chalk.bgRed(
chalk.white.bold(`${r.label} ${r.timedOut ? '· timed out' : '· failed'} `)
)
console.log(`\n${banner}`)
if (r.output.trim()) process.stdout.write(r.output)
}
const summary = failedTasks.map((t) => t.label).join(', ')
console.error(
`\n${chalk.bgRed.white.bold(`${failedTasks.length}/${total} failed: ${summary} `)}`
)
process.exit(1)
}
console.log(`\n${chalk.bgGreen.black.bold(` ✔ All ${total} tasks completed `)}`)
}