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