Compare commits
141 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| ebc785d1fe | |||
| ba932283f2 | |||
| f05bf68f61 | |||
| 35ab12a20a | |||
| fd7c54a695 | |||
| eafd834daf | |||
| f575ae0fa4 | |||
| 797c9f8575 | |||
| fb3ac4d454 | |||
| c629d706ec | |||
| 7896786311 | |||
| 820a2654ff | |||
| 8d45ab8436 | |||
| 855020636b | |||
| ff82603839 | |||
| 90270fb76c | |||
| c9edeaea44 | |||
| 757fe07b4d | |||
| f04fa706f7 | |||
| 9daf914a89 | |||
| c8e5ff3010 | |||
| f5591dd3ea | |||
| 62a0d988ce | |||
| dc41969503 | |||
| 9d9a4b9698 | |||
| b171bf210f | |||
| c39678dece | |||
| 6c1f552606 | |||
| dcad67d4b4 | |||
| 15f3af406c | |||
| b50abcc4ec | |||
| 20f1b1f51e | |||
| cd035cd30f | |||
| a7cc935453 | |||
| 349609614b | |||
| ad680cee99 | |||
| e95b2d6a28 | |||
| f55c1b984b | |||
| 5ab34a2f50 | |||
| de24d97a22 | |||
| c6f09375f8 | |||
| cc33297cc4 | |||
| 8556745645 | |||
| 317b2a1f10 | |||
| 7bc2fa4d59 | |||
| 9d5c5a8969 | |||
| 052d302f16 | |||
| bb4da7fd86 | |||
| 359e6eadc8 | |||
| e41a2609b3 | |||
| b23c909953 | |||
| 79bf9e2aee | |||
| d9d8d558a9 | |||
| 6afd3dcbdf | |||
| 5809fe3249 | |||
| e626e0b8ce | |||
| 284098ff0d | |||
| 90e1c297f4 | |||
| 23adbea744 | |||
| 9af3a3f73e | |||
| cc27ff9305 | |||
| 8a75c15557 | |||
| e9eaf44bdd |
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: pre-impl-discussion
|
||||
description: "Conduct a thorough pre-implementation discussion before making significant changes. Use when the user wants to discuss, plan, or evaluate a change before implementing it — especially when they say words like 'discuss', 'evaluate', 'plan', or 'let's talk about'."
|
||||
argument-hint: "Describe the change to evaluate"
|
||||
argument-hint: 'Describe the change to evaluate'
|
||||
---
|
||||
|
||||
# Pre-Implementation Discussion
|
||||
@@ -40,6 +40,7 @@ Do NOT skip this step. Do NOT rely on assumptions about what "most projects" do.
|
||||
- **Search in parallel** to save time — batch independent queries
|
||||
|
||||
Common pitfalls:
|
||||
|
||||
- Assuming compatibility without checking actual version constraints
|
||||
- Confusing roadmap/aspirations with actual released state
|
||||
- Missing transitive constraints (a dependency of a dependency)
|
||||
@@ -53,6 +54,7 @@ Share a **brief** assessment. Tables work well for comparisons. Highlight **bloc
|
||||
Surface the decisions the user needs to make. Present them as clear choices with trade-offs, not as a recommendation monologue.
|
||||
|
||||
For each decision point:
|
||||
|
||||
- What are the options? (2-3 max)
|
||||
- What does each option cost or give up?
|
||||
- What's your lean and why? (one sentence)
|
||||
@@ -66,6 +68,7 @@ The user will ask follow-up questions, raise concerns, or challenge assumptions.
|
||||
- **Update your mental model** based on user feedback
|
||||
|
||||
Common mistakes in this phase:
|
||||
|
||||
- Repeating the full plan after every small clarification
|
||||
- Answering a narrow question with a broad redesign
|
||||
- Treating user questions as confirmation to proceed
|
||||
@@ -84,17 +87,18 @@ Keep it terse. Tables over paragraphs. No explanations the user already heard du
|
||||
### 7. Wait for Confirmation
|
||||
|
||||
After presenting the final plan, **stop and wait**. The user will either:
|
||||
|
||||
- Confirm → then (and only then) proceed to implementation
|
||||
- Ask more questions → go back to step 5
|
||||
- Modify scope → update the plan and re-present
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Don't | Do Instead |
|
||||
|-------|------------|
|
||||
| Start implementation "to test" without confirmation | Present findings and wait |
|
||||
| Repeat the full plan in every response | Answer the specific question asked |
|
||||
| Say "X should work" without checking | Say "I need to verify X" and research it |
|
||||
| Assume project structure or constraints | Read the actual files |
|
||||
| Present one recommendation as the only option | Present 2-3 options with trade-offs |
|
||||
| Write long paragraphs explaining trade-offs | Use tables and bullet points |
|
||||
| Don't | Do Instead |
|
||||
| --------------------------------------------------- | ---------------------------------------- |
|
||||
| Start implementation "to test" without confirmation | Present findings and wait |
|
||||
| Repeat the full plan in every response | Answer the specific question asked |
|
||||
| Say "X should work" without checking | Say "I need to verify X" and research it |
|
||||
| Assume project structure or constraints | Read the actual files |
|
||||
| Present one recommendation as the only option | Present 2-3 options with trade-offs |
|
||||
| Write long paragraphs explaining trade-offs | Use tables and bullet points |
|
||||
|
||||
@@ -75,7 +75,7 @@ After successfully opening the PR, ALWAYS give a brief reminder in the user's la
|
||||
## Validation Strategy
|
||||
|
||||
- Default to enough validation to defend the PR, not the absolute minimum.
|
||||
- Prefer area-specific build, lint, or test commands when they exist.
|
||||
- Run `npm run ci` to run all build and lint checks.
|
||||
- Escalate to broader validation when the diff crosses packages, changes shared code, or affects release behavior.
|
||||
- Never claim checks that did not actually run.
|
||||
- You MAY note what you ran and the results below the Testing section in the PR body, but do NOT check the template checkboxes.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: CI
|
||||
permissions:
|
||||
contents: read
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -17,6 +17,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v6
|
||||
@@ -24,18 +26,8 @@ jobs:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
|
||||
# test on default version of npm
|
||||
# - 9.6~10.8 on node@20
|
||||
# - 11.3~11.6 on node@24
|
||||
|
||||
- name: Node and NPM version
|
||||
run: node --version && npm --version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npx eslint . && npx prettier --check **/*.ts
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: CI checks
|
||||
run: node scripts/ci.js
|
||||
|
||||
+1
-2
@@ -37,8 +37,7 @@ package.json.bak
|
||||
.wxt
|
||||
|
||||
# AI
|
||||
.agent
|
||||
.claude
|
||||
.cursor
|
||||
.gemini
|
||||
CLAUDE.md
|
||||
.codex
|
||||
@@ -0,0 +1,8 @@
|
||||
# Prompt templates (formatted manually for LLM readability)
|
||||
*prompt.md
|
||||
|
||||
# Generated
|
||||
packages/extension/.wxt
|
||||
|
||||
# Vendored
|
||||
**/components/ui
|
||||
Vendored
+3
-1
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"agentic",
|
||||
"contenteditable",
|
||||
"deepseek",
|
||||
"historychange",
|
||||
@@ -22,7 +23,7 @@
|
||||
"packages/*/node_modules": true
|
||||
},
|
||||
"markdownlint.config": {
|
||||
// "comment": "Relaxed rules",
|
||||
// Relaxed rules
|
||||
"default": true,
|
||||
"whitespace": false,
|
||||
"line_length": false,
|
||||
@@ -36,6 +37,7 @@
|
||||
"ol-prefix": false,
|
||||
"no-duplicate-heading": false
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"js/ts.tsdk.path": "node_modules/typescript/lib",
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||
|
||||
@@ -21,9 +21,10 @@ Internal packages:
|
||||
npm start # Start website dev server
|
||||
npm run build # Build all packages
|
||||
npm run build:libs # Build all libraries
|
||||
npm run typecheck # Typecheck all packages (libs + website + extension)
|
||||
npm run build:ext # Build and zip the extension package
|
||||
npm run typecheck # Typecheck all packages
|
||||
npm run test # Run unit tests across all workspaces
|
||||
npm run lint # ESLint
|
||||
npm run zip -w @page-agent/ext # Zip the extension package
|
||||
```
|
||||
|
||||
## Architecture
|
||||
@@ -38,7 +39,7 @@ packages/
|
||||
├── page-agent/ # npm: "page-agent" entry class (with UI + controller + demo builds)
|
||||
├── website/ # @page-agent/website (private)
|
||||
├── llms/ # @page-agent/llms
|
||||
├── extension/ # Browser extension (WXT + React)
|
||||
├── extension/ # Browser extension
|
||||
├── page-controller/ # @page-agent/page-controller
|
||||
└── ui/ # @page-agent/ui
|
||||
```
|
||||
@@ -125,6 +126,20 @@ const pageInfo = await this.pageController.getPageInfo()
|
||||
2. Expose via async method in `PageController.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
|
||||
|
||||
- Explicit typing for exported/public APIs
|
||||
|
||||
@@ -13,7 +13,9 @@ The GUI Agent Living in Your Webpage. Control web interfaces with natural langua
|
||||
|
||||
<a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank"><b>📖 Docs</b></a> | <a href="https://news.ycombinator.com/item?id=47264138" target="_blank"><b>📢 HN Discussion</b></a> | <a href="https://x.com/simonluvramen" target="_blank"><b>𝕏 Follow on X</b></a>
|
||||
|
||||
<video id="demo-video" src="https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2" controls crossorigin muted></video>
|
||||
<!-- demo video -->
|
||||
|
||||
https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2
|
||||
|
||||
---
|
||||
|
||||
@@ -50,8 +52,10 @@ Fastest way to try PageAgent with our free Demo LLM:
|
||||
|
||||
| Mirrors | URL |
|
||||
| ------- | ---------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.8.0/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.8.0/files/dist/iife/page-agent.demo.js |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.9.1/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.9.1/files/dist/iife/page-agent.demo.js |
|
||||
|
||||
Add `?autoInit=false` to load the script without creating the demo agent automatically. You can then instantiate it with `new window.PageAgent(...)`.
|
||||
|
||||
### NPM Installation
|
||||
|
||||
|
||||
@@ -5,6 +5,76 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.9.0] - 2026-06-08
|
||||
|
||||
### Features
|
||||
|
||||
- **Robust abort handling** - Rewrote the aborting system; sync tools, loop execution, and LLM clients now correctly respect task abort signals (`ctx.signal`). Also decoupled `AbortError` from `InvokeError` in `@page-agent/llms`.
|
||||
- **Claude Opus 4.8 support** - Added support for Claude Opus 4.8 model.
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Concurrency guard** - Prevented concurrent `execute()` calls on a single PageAgent/Core instance to avoid race conditions.
|
||||
- **Model recommendations refresh** - Updated default and tested model list recommendations.
|
||||
- **Test coverage** - Added comprehensive Vitest unit tests for the `@page-agent/llms` package.
|
||||
- **Improved documentation** - Added website documentation for the `ctx.signal` abort contract and `execute()` concurrency rules.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **DTS bundle fix** - Fixed a packaging bug where global type declarations were incorrectly bundled into `.d.ts` outputs.
|
||||
- **Website sidebar fix** - Normalized trailing slashes in the website's sidebar location comparison.
|
||||
|
||||
## [1.8.2] - 2026-05-11
|
||||
|
||||
### Features
|
||||
|
||||
- **IIFE demo control** - Added `showPanel` and `autoInit` switches to the IIFE CDN script to control whether the UI panel automatically displays or initializes on load.
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Build toolchain modernization** - Upgraded build infrastructure to Vite 8.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **TypeScript `InvokeErrorType` fix** - Separated the value and type space for `InvokeErrorType` to resolve TypeScript compilation issues.
|
||||
- **Website chunking fix** - Restored working code-splitting with `manualChunks` for the documentation website.
|
||||
|
||||
## [1.8.1] - 2026-04-27
|
||||
|
||||
### Features
|
||||
|
||||
- **GPT-5.4 & Qwen 3.6 support** - Added support for `gpt-5.4` and `qwen3.6-max/flash` in the recommended LLM list.
|
||||
- **Custom LLM request hook** - Added a `transformRequestBody` hook to allow custom modification of payloads before sending requests to LLM providers.
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Accessibility (a11y) enhancements** - Added descriptive accessible labels to `ConfigPanel` input fields and icon buttons in compliance with WCAG 4.1.2.
|
||||
- **UI polish** - Improved `HistoryList` loading and empty states, and added helpful tooltips for actions.
|
||||
- **Prompt caching guidance** - Added website documentation for prompt caching optimization.
|
||||
- **Build speedups** - Added parallel build scripts to accelerate local development compilation.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **DeepSeek tool choice fix** - Disabled explicit `tool_choice` for DeepSeek models to avoid API compatibility errors.
|
||||
- **MCP version advertising** - MCP server now correctly advertises its package version.
|
||||
|
||||
## [1.8.0] - 2026-04-15
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **TypeScript 6 & ESLint 10 upgrade** - Major toolchain modernization. Upgraded the entire monorepo to TypeScript 6 and ESLint 10 with source-first monorepo resolution (library exports resolve to source files directly during local development).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **MCP security hardening** - Bound the MCP HTTP + WebSocket server to `localhost` only.
|
||||
- **Extension UI refinement** - Made the history panel height responsive to the viewport and improved the result card readability by increasing font size.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **SimulatorMask memory leak** - Fixed a memory leak by ensuring the `requestAnimationFrame` loop is cancelled when disposing the SimulatorMask.
|
||||
- **Autofixer format fix** - Corrected the fallback action format for `autoFixer` when waiting.
|
||||
- **IIFE scope protection** - Fixed a name collision in IIFE builds by preventing global helper function re-declarations.
|
||||
|
||||
## [1.7.1] - 2026-04-04
|
||||
|
||||
### Features
|
||||
|
||||
+23
-23
@@ -16,22 +16,22 @@ appearance, race, religion, or sexual identity and orientation.
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
- The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
@@ -85,19 +85,19 @@ available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.ht
|
||||
|
||||
有助于创造正面环境的行为包括但不限于:
|
||||
|
||||
* 使用友好和包容性语言
|
||||
* 尊重不同的观点和经历
|
||||
* 耐心地接受建设性批评
|
||||
* 关注对社区最有利的事情
|
||||
* 友善对待其他社区成员
|
||||
- 使用友好和包容性语言
|
||||
- 尊重不同的观点和经历
|
||||
- 耐心地接受建设性批评
|
||||
- 关注对社区最有利的事情
|
||||
- 友善对待其他社区成员
|
||||
|
||||
身为参与者不能接受的行为包括但不限于:
|
||||
|
||||
* 使用与性有关的言语或是图像,以及不受欢迎的性骚扰
|
||||
* 捣乱/煽动/造谣的行为或进行侮辱/贬损的评论,人身攻击及政治攻击
|
||||
* 公开或私下的骚扰
|
||||
* 未经许可地发布他人的个人资料,例如住址或是电子地址
|
||||
* 其他可以被合理地认定为不恰当或者违反职业操守的行为
|
||||
- 使用与性有关的言语或是图像,以及不受欢迎的性骚扰
|
||||
- 捣乱/煽动/造谣的行为或进行侮辱/贬损的评论,人身攻击及政治攻击
|
||||
- 公开或私下的骚扰
|
||||
- 未经许可地发布他人的个人资料,例如住址或是电子地址
|
||||
- 其他可以被合理地认定为不恰当或者违反职业操守的行为
|
||||
|
||||
## 我们的责任
|
||||
|
||||
|
||||
+7
-4
@@ -13,7 +13,9 @@
|
||||
|
||||
<a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank"><b>📖 Docs</b></a> | <a href="https://news.ycombinator.com/item?id=47264138" target="_blank"><b>📢 HN Discussion</b></a> | <a href="https://x.com/simonluvramen" target="_blank"><b>𝕏 Follow on X</b></a>
|
||||
|
||||
<video id="demo-video" src="https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2" controls crossorigin muted></video>
|
||||
<!-- demo video -->
|
||||
|
||||
https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2
|
||||
|
||||
---
|
||||
|
||||
@@ -49,8 +51,10 @@
|
||||
|
||||
| Mirrors | URL |
|
||||
| ------- | ---------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.8.0/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.8.0/files/dist/iife/page-agent.demo.js |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.9.1/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.9.1/files/dist/iife/page-agent.demo.js |
|
||||
|
||||
在 URL 后添加 `?autoInit=false` 可只加载脚本,不自动创建 Demo Agent;之后可通过 `new window.PageAgent(...)` 手动初始化。
|
||||
|
||||
### NPM 安装
|
||||
|
||||
@@ -106,4 +110,3 @@ this project possible.
|
||||
---
|
||||
|
||||
**⭐ 如果觉得 PageAgent 有用或有趣,请给项目点个星!**
|
||||
|
||||
|
||||
+21
-33
@@ -10,52 +10,41 @@ For contribution rules and expectations, see [../CONTRIBUTING.md](../CONTRIBUTIN
|
||||
|
||||
1. **Prerequisites**
|
||||
- `macOS` / `Linux` / `WSL`
|
||||
- `node.js >= 24` with `npm >= 11`
|
||||
- `node.js ^22.13 || >=24` with `npm >= 11`
|
||||
- An editor that supports `ts/eslint/prettier`
|
||||
- Make sure `eslint`, `prettier` and `commitlint` work well. Un-linted code won't pass the CI.
|
||||
|
||||
2. **Setup**
|
||||
|
||||
```bash
|
||||
npm i
|
||||
npm start # Start demo and documentation site
|
||||
npm run build # Build libs and website
|
||||
npm i # Or `npm ci` if you don't want to change the lockfile
|
||||
npm start # Start website dev server
|
||||
npm run build # Build everything
|
||||
```
|
||||
|
||||
## 📦 Project Structure
|
||||
|
||||
This is a **monorepo** with npm workspaces containing **4 main packages**:
|
||||
This is a **monorepo** with npm workspaces.
|
||||
|
||||
- **Page Agent** (`packages/page-agent/`) - Main entry with built-in UI Panel, published as `page-agent` on npm
|
||||
Published packages:
|
||||
|
||||
- **Page Agent** (`packages/page-agent/`) - Main entry with built-in UI Panel (npm: `page-agent`)
|
||||
- **MCP** (`packages/mcp/`) - MCP server for browser control via Page Agent extension (npm: `@page-agent/mcp`)
|
||||
- **Core** (`packages/core/`) - Core agent logic without UI (npm: `@page-agent/core`)
|
||||
- **Extension** (`packages/extension/`) - Chrome extension for multi-page tasks and browser-level automation
|
||||
- **Website** (`packages/website/`) - React documentation and landing page. Also as demo and test page for the core lib. private package `@page-agent/website`
|
||||
- **LLMs** (`packages/llms/`) - LLM client with reflection-before-action mental model
|
||||
- **Page Controller** (`packages/page-controller/`) - DOM operations and visual feedback, independent of LLM
|
||||
- **UI** (`packages/ui/`) - Panel and i18n, decoupled from PageAgent
|
||||
|
||||
> We use a simplified monorepo solution with `native npm-workspace + ts reference + vite alias`. No fancy tooling. Hoisting is required.
|
||||
>
|
||||
> - When developing. Use alias so that we don't have to pre-build.
|
||||
> - When bundling. Use external and disable ts `paths` alias.
|
||||
> - When bundling `IIFE` and `Website`. Bundle everything together.
|
||||
Applications:
|
||||
|
||||
- **Extension** (`packages/extension/`) - Browser extension (WXT + React)
|
||||
- **Website** (`packages/website/`) - React docs, landing page, and dev playground (private)
|
||||
|
||||
> Source-first monorepo with `npm workspaces + ts references + vite alias`. Library `package.json` exports point to `src/*.ts` during development, and point to `dist/*.js` when published. `workspaces` in root `package.json` must be in topological order.
|
||||
|
||||
## 🤖 AGENTS.md Alias
|
||||
|
||||
If your AI assistant does not support [AGENTS.md](https://agents.md/). Add an alias for it:
|
||||
|
||||
- claude-code (`CLAUDE.md`)
|
||||
|
||||
```markdown
|
||||
@AGENTS.md
|
||||
```
|
||||
|
||||
- antigravity (`.agent/rules/alias.md`)
|
||||
|
||||
```markdown
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
@../../AGENTS.md
|
||||
```
|
||||
If your AI assistant does not support [AGENTS.md](https://agents.md/). Add an alias for it.
|
||||
|
||||
## 🔧 Development Workflows
|
||||
|
||||
@@ -85,9 +74,8 @@ If your AI assistant does not support [AGENTS.md](https://agents.md/). Add an al
|
||||
### Extension Development
|
||||
|
||||
```bash
|
||||
# make sure you ran `npm run build:libs` first and every time you changed the core libs
|
||||
npm run dev -w @page-agent/ext
|
||||
npm run zip -w @page-agent/ext
|
||||
npm run dev:ext
|
||||
npm run build:ext
|
||||
```
|
||||
|
||||
- Update `packages/extension/docs/extension_api.md` for API integration details
|
||||
|
||||
Generated
+1877
-3153
File diff suppressed because it is too large
Load Diff
+33
-25
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.1",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/page-controller",
|
||||
@@ -22,51 +22,59 @@
|
||||
},
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"engines": {
|
||||
"node": "^22.13.0 || >=24"
|
||||
"node": "^22.22.1 || >=24",
|
||||
"npm": "^11.6.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "npm run dev --workspace=@page-agent/website",
|
||||
"dev:ext": "npm run dev -w @page-agent/ext",
|
||||
"dev:demo": "npm run dev:demo --workspace=page-agent",
|
||||
"build": "npm run build:libs && npm run build:website",
|
||||
"build:libs": "npm run build --workspaces --if-present",
|
||||
"build": "node scripts/build.js",
|
||||
"build:libs": "node scripts/build-libs.js",
|
||||
"build:website": "npm run build:website --workspace=@page-agent/website",
|
||||
"build:ext": "npm run build:libs && npm run zip -w @page-agent/ext",
|
||||
"build:ext": "npm run zip -w @page-agent/ext",
|
||||
"version": "node scripts/sync-version.js",
|
||||
"postpublish": "npm run postpublish --workspaces --if-present",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json && tsc --noEmit -p packages/extension/tsconfig.json",
|
||||
"test": "npm test --workspaces --if-present",
|
||||
"lint": "eslint .",
|
||||
"ci": "node scripts/ci.js",
|
||||
"cleanup": "rm -rf packages/*/dist && rm -rf packages/*/.output",
|
||||
"prepare": "husky || true"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.5.0",
|
||||
"@commitlint/config-conventional": "^20.5.0",
|
||||
"@eslint-react/eslint-plugin": "^4.2.3",
|
||||
"@commitlint/cli": "^21.0.1",
|
||||
"@commitlint/config-conventional": "^21.0.1",
|
||||
"@eslint-react/eslint-plugin": "^5.8.16",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@microsoft/api-extractor": "^7.58.2",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@microsoft/api-extractor": "^7.58.8",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/node": "^25.6.0",
|
||||
"@vitejs/plugin-react-swc": "^4.3.0",
|
||||
"@types/node": "^25.9.2",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"chalk": "^5.6.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"concurrently": "^10.0.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"eslint": "^10.2.0",
|
||||
"globals": "^17.5.0",
|
||||
"eslint": "^10.4.0",
|
||||
"globals": "^17.6.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.4.0",
|
||||
"prettier": "^3.8.2",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"unplugin-dts": "^1.0.0-beta.6",
|
||||
"vite": "^7.3.2",
|
||||
"vite-bundle-analyzer": "^1.3.7",
|
||||
"vite-plugin-css-injected-by-js": "^4.0.1"
|
||||
"lint-staged": "^17.0.5",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.61.0",
|
||||
"unplugin-dts": "^1.0.1",
|
||||
"vite": "^8.0.14",
|
||||
"vite-plugin-css-injected-by-js": "^5.0.1",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"overrides": {
|
||||
"typescript": "^6.0.2",
|
||||
"@vitejs/plugin-react": "^5.2.0"
|
||||
"typescript": "^6.0.3",
|
||||
"node-notifier": {
|
||||
"uuid": "11.1.1"
|
||||
},
|
||||
"web-ext-run": {
|
||||
"tmp": "0.2.7"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,cjs,cts,mjs,mts}": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@page-agent/core",
|
||||
"private": false,
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.1",
|
||||
"type": "module",
|
||||
"main": "./src/PageAgentCore.ts",
|
||||
"types": "./src/PageAgentCore.ts",
|
||||
@@ -48,19 +48,20 @@
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"dev:iife": "concurrently \"vite build --config vite.iife.config.js --watch\" \"npx serve dist/iife -p 5174\"",
|
||||
"prepublishOnly": "node ../../scripts/pre-publish.js",
|
||||
"postpublish": "node ../../scripts/post-publish.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"@page-agent/llms": "1.8.0",
|
||||
"@page-agent/page-controller": "1.8.0"
|
||||
"@page-agent/llms": "1.9.1",
|
||||
"@page-agent/page-controller": "1.9.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"zod": "^4.3.6"
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
MacroToolResult,
|
||||
} from './types'
|
||||
import { assert, fetchLlmsTxt, normalizeResponse, uid, waitFor } from './utils'
|
||||
import { assert, fetchLlmsTxt, normalizeResponse, suppress, uid, waitFor } from './utils'
|
||||
|
||||
export { tool, type PageAgentTool } from './tools'
|
||||
export type * from './types'
|
||||
@@ -42,7 +42,7 @@ export type PageAgentCoreConfig = AgentConfig & { pageController: PageController
|
||||
* - loop
|
||||
*
|
||||
* ## Event System
|
||||
* - `statuschange` - Agent status transitions (idle → running → completed/error)
|
||||
* - `statuschange` - Agent status transitions (idle → running → completed/error/stopped)
|
||||
* - `historychange` - History events updated (persistent, part of agent memory)
|
||||
* - `activity` - Real-time activity feedback (transient, for UI only)
|
||||
* - `dispose` - Agent cleanup triggered
|
||||
@@ -73,17 +73,28 @@ export class PageAgentCore extends EventTarget {
|
||||
disposed = false
|
||||
|
||||
/**
|
||||
* Callback for when agent needs user input (ask_user tool)
|
||||
* If not set, ask_user tool will be 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 `signal` aborts.
|
||||
* @example onAskUser: (q) => window.prompt(q) || ''
|
||||
*/
|
||||
onAskUser?: (question: string) => Promise<string>
|
||||
onAskUser?: (question: string, options?: { signal: AbortSignal }) => Promise<string>
|
||||
|
||||
#status: AgentStatus = 'idle'
|
||||
#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()
|
||||
#observations: string[] = []
|
||||
|
||||
/** Resolves when the current run has fully settled. Awaited by `stop()`. */
|
||||
#running: Promise<void> = Promise.resolve()
|
||||
#lastResult: ExecutionResult | null = null
|
||||
|
||||
/** internal states during a single task execution */
|
||||
#states = {
|
||||
/** Accumulated wait time in seconds */
|
||||
@@ -103,11 +114,14 @@ export class PageAgentCore extends EventTarget {
|
||||
this.tools = new Map(tools)
|
||||
this.pageController = config.pageController
|
||||
|
||||
// Listen to LLM retry events
|
||||
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 })
|
||||
// Also push to history for panel rendering
|
||||
this.history.push({
|
||||
type: 'error',
|
||||
message: String(lastError),
|
||||
rawResponse: (lastError as InvokeError).rawResponse,
|
||||
})
|
||||
this.history.push({
|
||||
type: 'retry',
|
||||
message: `LLM retry attempt ${attempt} of ${maxAttempts}`,
|
||||
@@ -116,19 +130,6 @@ export class PageAgentCore extends EventTarget {
|
||||
})
|
||||
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) {
|
||||
for (const [name, tool] of Object.entries(this.config.customTools)) {
|
||||
@@ -150,13 +151,19 @@ export class PageAgentCore extends EventTarget {
|
||||
return this.#status
|
||||
}
|
||||
|
||||
/** Result of the most recent run, or `null` before the first run completes. */
|
||||
get lastResult(): ExecutionResult | null {
|
||||
return this.#lastResult
|
||||
}
|
||||
|
||||
/** Emit statuschange event */
|
||||
#emitStatusChange(): void {
|
||||
this.dispatchEvent(new Event('statuschange'))
|
||||
}
|
||||
|
||||
/** Emit historychange event */
|
||||
#emitHistoryChange(): void {
|
||||
#emitHistoryChange(pushHistoricalEvent?: HistoricalEvent): void {
|
||||
if (pushHistoricalEvent) this.history.push(pushHistoricalEvent)
|
||||
this.dispatchEvent(new Event('historychange'))
|
||||
}
|
||||
|
||||
@@ -186,165 +193,184 @@ export class PageAgentCore extends EventTarget {
|
||||
this.#observations.push(content)
|
||||
}
|
||||
|
||||
/** Stop the current task. Agent remains reusable. */
|
||||
stop() {
|
||||
this.pageController.cleanUpHighlights()
|
||||
this.pageController.hideMask()
|
||||
/**
|
||||
* Stop the current task and wait until the run has fully settled (including lifecycle hooks).
|
||||
* @note never await .stop() in a lifecycle hook.
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (this.#status !== 'running') return
|
||||
this.#abortController.abort()
|
||||
await this.#running
|
||||
}
|
||||
|
||||
/**
|
||||
* external errors (pre-checks/config/hooks) will threw;
|
||||
* agent errors will be caught and added to history, and return a failed result
|
||||
*/
|
||||
async execute(task: string): Promise<ExecutionResult> {
|
||||
// pre-checks
|
||||
if (this.disposed) throw new Error('PageAgent has been disposed. Create a new instance.')
|
||||
if (this.#status === 'running') throw new Error('A task is already running.')
|
||||
if (!task) throw new Error('Task is required')
|
||||
|
||||
this.task = task
|
||||
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
|
||||
if (!this.onAskUser) {
|
||||
this.tools.delete('ask_user')
|
||||
}
|
||||
if (!this.onAskUser) this.tools.delete('ask_user')
|
||||
|
||||
const onBeforeStep = this.config.onBeforeStep
|
||||
const onAfterStep = this.config.onAfterStep
|
||||
const onBeforeTask = this.config.onBeforeTask
|
||||
const onAfterTask = this.config.onAfterTask
|
||||
|
||||
await onBeforeTask?.(this)
|
||||
|
||||
// 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 }
|
||||
const stepDelay = this.config.stepDelay ?? 0.4
|
||||
const maxSteps = this.config.maxSteps
|
||||
|
||||
let step = 0
|
||||
let taskResult: ExecutionResult
|
||||
let finalStatus: AgentStatus = 'error'
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
console.group(`step: ${step}`)
|
||||
await suppress(() => this.pageController.showMask())
|
||||
|
||||
// graceful exit
|
||||
try {
|
||||
await onBeforeTask?.(this)
|
||||
|
||||
while (true) {
|
||||
await onBeforeStep?.(this, step)
|
||||
|
||||
// observe
|
||||
// handle internal agent errors
|
||||
try {
|
||||
console.group(`step: ${step}`)
|
||||
|
||||
console.log(chalk.blue.bold('👀 Observing...'))
|
||||
// @note It's convenient to treat stepDelay as part of the next step.
|
||||
// Maybe move it to a dedicated try block for better semantics?
|
||||
if (step > 0) await waitFor(stepDelay, signal)
|
||||
|
||||
this.#states.browserState = await this.pageController.getBrowserState()
|
||||
await this.#handleObservations(step)
|
||||
signal.throwIfAborted()
|
||||
|
||||
// assemble prompts
|
||||
// observe
|
||||
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: this.#getSystemPrompt() },
|
||||
{ role: 'user' as const, content: await this.#assembleUserPrompt() },
|
||||
]
|
||||
console.log(chalk.blue.bold('👀 Observing...'))
|
||||
|
||||
const macroTool = { AgentOutput: this.#packMacroTool() }
|
||||
this.#states.browserState = await this.pageController.getBrowserState()
|
||||
await this.#handleObservations(step)
|
||||
|
||||
// invoke LLM
|
||||
// assemble prompts
|
||||
|
||||
console.log(chalk.blue.bold('🧠 Thinking...'))
|
||||
this.#emitActivity({ type: 'thinking' })
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: this.#getSystemPrompt() },
|
||||
{ role: 'user' as const, content: await this.#assembleUserPrompt() },
|
||||
]
|
||||
|
||||
const result = await this.#llm.invoke(messages, macroTool, this.#abortController.signal, {
|
||||
toolChoiceName: 'AgentOutput',
|
||||
normalizeResponse: (res) => normalizeResponse(res, this.tools),
|
||||
})
|
||||
const macroTool = { AgentOutput: this.#packMacroTool() }
|
||||
|
||||
// assemble history
|
||||
// invoke LLM
|
||||
|
||||
const macroResult = result.toolResult as MacroToolResult
|
||||
const input = macroResult.input
|
||||
const output = macroResult.output
|
||||
const reflection: Partial<AgentReflection> = {
|
||||
evaluation_previous_goal: input.evaluation_previous_goal,
|
||||
memory: input.memory,
|
||||
next_goal: input.next_goal,
|
||||
}
|
||||
const actionName = Object.keys(input.action)[0]
|
||||
const action: AgentStepEvent['action'] = {
|
||||
name: actionName,
|
||||
input: input.action[actionName],
|
||||
output: output,
|
||||
}
|
||||
console.log(chalk.blue.bold('🧠 Thinking...'))
|
||||
this.#emitActivity({ type: 'thinking' })
|
||||
|
||||
this.history.push({
|
||||
type: 'step',
|
||||
stepIndex: step,
|
||||
reflection,
|
||||
action,
|
||||
usage: result.usage,
|
||||
rawResponse: result.rawResponse,
|
||||
rawRequest: result.rawRequest,
|
||||
} as AgentStepEvent)
|
||||
this.#emitHistoryChange()
|
||||
const result = await this.#llm.invoke(messages, macroTool, signal, {
|
||||
toolChoiceName: 'AgentOutput',
|
||||
normalizeResponse: (res) => normalizeResponse(res, this.tools),
|
||||
})
|
||||
|
||||
//
|
||||
// assemble history
|
||||
|
||||
await onAfterStep?.(this, this.history)
|
||||
|
||||
console.groupEnd()
|
||||
|
||||
// finish task if done
|
||||
|
||||
if (actionName === 'done') {
|
||||
const success = action.input?.success ?? false
|
||||
const text = action.input?.text || 'no text provided'
|
||||
console.log(chalk.green.bold('Task completed'), success, text)
|
||||
this.#onDone(success)
|
||||
const result: ExecutionResult = {
|
||||
success,
|
||||
data: text,
|
||||
history: this.history,
|
||||
const macroResult = result.toolResult as MacroToolResult
|
||||
const input = macroResult.input
|
||||
const output = macroResult.output
|
||||
const reflection: Partial<AgentReflection> = {
|
||||
evaluation_previous_goal: input.evaluation_previous_goal,
|
||||
memory: input.memory,
|
||||
next_goal: input.next_goal,
|
||||
}
|
||||
const actionName = Object.keys(input.action)[0]
|
||||
const action: AgentStepEvent['action'] = {
|
||||
name: actionName,
|
||||
input: input.action[actionName],
|
||||
output: output,
|
||||
}
|
||||
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)
|
||||
const errorMessage = isAbortError ? 'Task stopped' : String(error)
|
||||
this.#emitActivity({ type: 'error', message: errorMessage })
|
||||
this.history.push({ type: 'error', message: errorMessage, rawResponse: error })
|
||||
this.#emitHistoryChange()
|
||||
this.#onDone(false)
|
||||
const result: ExecutionResult = {
|
||||
success: false,
|
||||
data: errorMessage,
|
||||
history: this.history,
|
||||
}
|
||||
await onAfterTask?.(this, result)
|
||||
return result
|
||||
}
|
||||
this.#emitHistoryChange({
|
||||
type: 'step',
|
||||
stepIndex: step,
|
||||
reflection,
|
||||
action,
|
||||
usage: result.usage,
|
||||
rawResponse: result.rawResponse,
|
||||
rawRequest: result.rawRequest,
|
||||
})
|
||||
|
||||
step++
|
||||
if (step > this.config.maxSteps) {
|
||||
const errorMessage = 'Step count exceeded maximum limit'
|
||||
this.history.push({ type: 'error', message: errorMessage })
|
||||
this.#emitHistoryChange()
|
||||
this.#onDone(false)
|
||||
const result: ExecutionResult = {
|
||||
success: false,
|
||||
data: errorMessage,
|
||||
history: this.history,
|
||||
}
|
||||
await onAfterTask?.(this, result)
|
||||
return result
|
||||
}
|
||||
if (actionName === 'done') {
|
||||
const success = action.input?.success ?? false
|
||||
const data = action.input?.text || 'no text provided'
|
||||
console.log(chalk.green.bold('Task completed'), success, data)
|
||||
taskResult = { success, data, history: this.history }
|
||||
this.#lastResult = taskResult
|
||||
finalStatus = 'completed'
|
||||
break
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// catch block must not throw error. otherwise the error may be overridden if finally block also throws error.
|
||||
|
||||
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!',
|
||||
inputSchema: macroToolSchema as z.ZodType<MacroToolInput>,
|
||||
execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
|
||||
// abort
|
||||
if (this.#abortController.signal.aborted) throw new Error('AbortError')
|
||||
const signal = this.#abortController.signal
|
||||
signal.throwIfAborted()
|
||||
|
||||
console.log(chalk.blue.bold('MacroTool input'), input)
|
||||
const action = input.action
|
||||
@@ -411,8 +437,9 @@ export class PageAgentCore extends EventTarget {
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
// Execute tool, bind `this` to PageAgent
|
||||
const result = await tool.execute.bind(this)(toolInput)
|
||||
const result = await tool.execute.bind(this)(toolInput, { signal })
|
||||
// Enforce abort even if the tool ignored the signal and resolved normally.
|
||||
signal.throwIfAborted()
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result)
|
||||
@@ -619,13 +646,6 @@ export class PageAgentCore extends EventTarget {
|
||||
return prompt
|
||||
}
|
||||
|
||||
#onDone(success = true) {
|
||||
this.pageController.cleanUpHighlights()
|
||||
this.pageController.hideMask() // No await - fire and forget
|
||||
this.#setStatus(success ? 'completed' : 'error')
|
||||
this.#abortController.abort()
|
||||
}
|
||||
|
||||
dispose() {
|
||||
console.log('Disposing PageAgent...')
|
||||
this.disposed = true
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
system_prompt.md
|
||||
@@ -70,7 +70,6 @@ Strictly follow these rules while using the browser and navigating the web:
|
||||
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
|
||||
- All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed.
|
||||
- If a captcha appears, tell user you can not solve captcha. Finish the task and ask user to solve it.
|
||||
- If expected elements are missing, try scrolling, or navigating back.
|
||||
- If the page is not fully loaded, use the `wait` action.
|
||||
- Do not repeat one action for more than 3 times unless some conditions changed.
|
||||
- If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field.
|
||||
|
||||
@@ -7,6 +7,14 @@ import * as z from 'zod/v4'
|
||||
import type { PageAgentCore } from '../PageAgentCore'
|
||||
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
|
||||
*/
|
||||
@@ -14,7 +22,7 @@ export interface PageAgentTool<TParams = any> {
|
||||
// name: string
|
||||
description: string
|
||||
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> {
|
||||
@@ -50,14 +58,16 @@ tools.set(
|
||||
inputSchema: z.object({
|
||||
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
|
||||
const lastTimeUpdate = await this.pageController.getLastUpdateTime()
|
||||
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000)
|
||||
const secondsSinceLastUpdate = (Date.now() - lastTimeUpdate) / 1000
|
||||
const actualWaitTime = Math.max(0, input.seconds - secondsSinceLastUpdate)
|
||||
console.log(`actualWaitTime: ${actualWaitTime} seconds`)
|
||||
await waitFor(actualWaitTime)
|
||||
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({
|
||||
question: z.string(),
|
||||
}),
|
||||
execute: async function (this: PageAgentCore, input) {
|
||||
execute: async function (this: PageAgentCore, input, { signal }) {
|
||||
if (!this.onAskUser) {
|
||||
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}`
|
||||
},
|
||||
})
|
||||
@@ -173,12 +183,15 @@ tools.set(
|
||||
'execute_javascript',
|
||||
tool({
|
||||
description:
|
||||
'Execute JavaScript code on the current page. Supports async/await syntax. Use with caution!',
|
||||
'Execute JavaScript code on the current page. Supports async/await syntax. Use with caution! ' +
|
||||
'An `AbortSignal` named `signal` is available in scope: long-running async code MUST honor it ' +
|
||||
'(e.g. `await fetch(url, { signal })`, or `signal.throwIfAborted()` in loops)',
|
||||
inputSchema: z.object({
|
||||
script: z.string(),
|
||||
}),
|
||||
execute: async function (this: PageAgentCore, input) {
|
||||
const result = await this.pageController.executeJavascript(input.script)
|
||||
execute: async function (this: PageAgentCore, input, { signal }) {
|
||||
const result = await this.pageController.executeJavascript(input.script, signal)
|
||||
signal.throwIfAborted()
|
||||
return result.message
|
||||
},
|
||||
})
|
||||
@@ -186,5 +199,4 @@ tools.set(
|
||||
|
||||
// @todo send_keys
|
||||
// @todo upload_file
|
||||
// @todo go_back
|
||||
// @todo extract_structured_data
|
||||
|
||||
@@ -262,9 +262,9 @@ export type HistoricalEvent =
|
||||
| AgentErrorEvent
|
||||
|
||||
/**
|
||||
* Agent execution status
|
||||
* Agent lifecycle status.
|
||||
*/
|
||||
export type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
|
||||
export type AgentStatus = 'idle' | 'running' | 'completed' | 'error' | 'stopped'
|
||||
|
||||
/**
|
||||
* Agent activity - transient state for immediate UI feedback.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { InvokeError, InvokeErrorType } from '@page-agent/llms'
|
||||
import { InvokeError, InvokeErrorTypes } from '@page-agent/llms'
|
||||
import chalk from 'chalk'
|
||||
import * as z from 'zod/v4'
|
||||
|
||||
@@ -137,7 +137,7 @@ function validateAction(action: any, tools: Map<string, PageAgentTool>): any {
|
||||
if (!tool) {
|
||||
const available = Array.from(tools.keys()).join(', ')
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
InvokeErrorTypes.INVALID_TOOL_ARGS,
|
||||
`Unknown action "${toolName}". Available: ${available}`
|
||||
)
|
||||
}
|
||||
@@ -159,7 +159,7 @@ function validateAction(action: any, tools: Map<string, PageAgentTool>): any {
|
||||
const result = schema.safeParse(value)
|
||||
if (!result.success) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
InvokeErrorTypes.INVALID_TOOL_ARGS,
|
||||
`Invalid input for action "${toolName}": ${z.prettifyError(result.error)}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,28 @@ import chalk from 'chalk'
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
plugins: [
|
||||
dts({
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/**/*.test.ts'],
|
||||
bundleTypes: true,
|
||||
compilerOptions: {
|
||||
composite: true,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
name: 'core',
|
||||
include: ['src/**/*.test.ts'],
|
||||
silent: 'passed-only',
|
||||
},
|
||||
})
|
||||
@@ -1,2 +0,0 @@
|
||||
.wxt
|
||||
src/components/ui
|
||||
@@ -42,33 +42,28 @@ localStorage.setItem('PageAgentExtUserAuthToken', 'your-token')
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
AgentActivity,
|
||||
AgentStatus,
|
||||
ExecutionResult,
|
||||
HistoricalEvent,
|
||||
} from '@page-agent/core'
|
||||
import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core'
|
||||
|
||||
// Wait for extension injection (up to 1 second)
|
||||
async function waitForExtension(timeout = 1000): Promise<boolean> {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeout) {
|
||||
if (window.PAGE_AGENT_EXT) return true
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
}
|
||||
return false
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeout) {
|
||||
if (window.PAGE_AGENT_EXT) return true
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Usage
|
||||
if (await waitForExtension()) {
|
||||
const result = await window.PAGE_AGENT_EXT!.execute('Click the login button', {
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: 'your-api-key',
|
||||
model: 'gpt-5.2',
|
||||
onStatusChange: (status) => console.log('Status:', status),
|
||||
onActivity: (activity) => console.log('Activity:', activity),
|
||||
})
|
||||
console.log('Result:', result)
|
||||
const result = await window.PAGE_AGENT_EXT!.execute('Click the login button', {
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: 'your-api-key',
|
||||
model: 'gpt-5.2',
|
||||
onStatusChange: (status) => console.log('Status:', status),
|
||||
onActivity: (activity) => console.log('Activity:', activity),
|
||||
})
|
||||
console.log('Result:', result)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -90,10 +85,10 @@ Execute one agent task.
|
||||
|
||||
Parameters:
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
| ---- | ---- | -------- | ----------- |
|
||||
| `task` | `string` | Yes | Task description |
|
||||
| `config` | `ExecuteConfig` | Yes | LLM settings, options, and callbacks |
|
||||
| Name | Type | Required | Description |
|
||||
| -------- | --------------- | -------- | ------------------------------------ |
|
||||
| `task` | `string` | Yes | Task description |
|
||||
| `config` | `ExecuteConfig` | Yes | LLM settings, options, and callbacks |
|
||||
|
||||
Returns: `Promise<ExecutionResult>`
|
||||
|
||||
@@ -106,33 +101,28 @@ Stop the current task.
|
||||
Install `@page-agent/core` for complete types:
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
AgentActivity,
|
||||
AgentStatus,
|
||||
ExecutionResult,
|
||||
HistoricalEvent,
|
||||
} from '@page-agent/core'
|
||||
import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core'
|
||||
|
||||
export interface ExecuteConfig {
|
||||
baseURL: string
|
||||
model: string
|
||||
apiKey?: string
|
||||
baseURL: string
|
||||
model: string
|
||||
apiKey?: string
|
||||
|
||||
// Global system-level instructions for the agent.
|
||||
// Equivalent to AgentConfig.instructions.system.
|
||||
systemInstruction?: string
|
||||
// Global system-level instructions for the agent.
|
||||
// Equivalent to AgentConfig.instructions.system.
|
||||
systemInstruction?: string
|
||||
|
||||
// Include the initial tab where page JS starts. Default: true.
|
||||
includeInitialTab?: boolean
|
||||
// Include the initial tab where page JS starts. Default: true.
|
||||
includeInitialTab?: boolean
|
||||
|
||||
// Control all unpinned tabs in the window instead of only the tab group.
|
||||
// When enabled, agent sees and can switch to every non-pinned tab.
|
||||
// Default: false. Experimental.
|
||||
experimentalIncludeAllTabs?: boolean
|
||||
// Control all unpinned tabs in the window instead of only the tab group.
|
||||
// When enabled, agent sees and can switch to every non-pinned tab.
|
||||
// Default: false. Experimental.
|
||||
experimentalIncludeAllTabs?: boolean
|
||||
|
||||
onStatusChange?: (status: AgentStatus) => void
|
||||
onActivity?: (activity: AgentActivity) => void
|
||||
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
||||
onStatusChange?: (status: AgentStatus) => void
|
||||
onActivity?: (activity: AgentActivity) => void
|
||||
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
||||
}
|
||||
|
||||
export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
|
||||
@@ -141,38 +131,38 @@ export type Execute = (task: string, config: ExecuteConfig) => Promise<Execution
|
||||
`AgentStatus`
|
||||
|
||||
```typescript
|
||||
type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
|
||||
type AgentStatus = 'idle' | 'running' | 'completed' | 'error' | 'stopped'
|
||||
```
|
||||
|
||||
`AgentActivity`
|
||||
|
||||
```typescript
|
||||
type AgentActivity =
|
||||
| { type: 'thinking' }
|
||||
| { type: 'executing'; tool: string; input: unknown }
|
||||
| { type: 'executed'; tool: string; input: unknown; output: string; duration: number }
|
||||
| { type: 'retrying'; attempt: number; maxAttempts: number }
|
||||
| { type: 'error'; message: string }
|
||||
| { type: 'thinking' }
|
||||
| { type: 'executing'; tool: string; input: unknown }
|
||||
| { type: 'executed'; tool: string; input: unknown; output: string; duration: number }
|
||||
| { type: 'retrying'; attempt: number; maxAttempts: number }
|
||||
| { type: 'error'; message: string }
|
||||
```
|
||||
|
||||
`HistoricalEvent`
|
||||
|
||||
```typescript
|
||||
type HistoricalEvent =
|
||||
| { type: 'step'; stepIndex: number; reflection: AgentReflection; action: Action }
|
||||
| { type: 'observation'; content: string }
|
||||
| { type: 'user_takeover' }
|
||||
| { type: 'retry'; message: string; attempt: number; maxAttempts: number }
|
||||
| { type: 'error'; message: string; rawResponse?: unknown }
|
||||
| { type: 'step'; stepIndex: number; reflection: AgentReflection; action: Action }
|
||||
| { type: 'observation'; content: string }
|
||||
| { type: 'user_takeover' }
|
||||
| { type: 'retry'; message: string; attempt: number; maxAttempts: number }
|
||||
| { type: 'error'; message: string; rawResponse?: unknown }
|
||||
```
|
||||
|
||||
`ExecutionResult`
|
||||
|
||||
```typescript
|
||||
interface ExecutionResult {
|
||||
success: boolean
|
||||
data: string
|
||||
history: HistoricalEvent[]
|
||||
success: boolean
|
||||
data: string
|
||||
history: HistoricalEvent[]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -182,15 +172,15 @@ interface ExecutionResult {
|
||||
|
||||
```typescript
|
||||
const result = await window.PAGE_AGENT_EXT!.execute(
|
||||
'Fill in the email field with test@example.com and click Submit',
|
||||
{
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: process.env.OPENAI_API_KEY!,
|
||||
model: 'gpt-5.2',
|
||||
includeInitialTab: false, // Optional: exclude current tab
|
||||
onStatusChange: (status) => console.log(status),
|
||||
onActivity: (activity) => console.log(activity),
|
||||
}
|
||||
'Fill in the email field with test@example.com and click Submit',
|
||||
{
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: process.env.OPENAI_API_KEY!,
|
||||
model: 'gpt-5.2',
|
||||
includeInitialTab: false, // Optional: exclude current tab
|
||||
onStatusChange: (status) => console.log(status),
|
||||
onActivity: (activity) => console.log(activity),
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
@@ -205,35 +195,30 @@ window.PAGE_AGENT_EXT!.stop()
|
||||
If you are not importing `@page-agent/core`, add:
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
AgentActivity,
|
||||
AgentStatus,
|
||||
ExecutionResult,
|
||||
HistoricalEvent,
|
||||
} from '@page-agent/core'
|
||||
import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core'
|
||||
|
||||
interface ExecuteConfig {
|
||||
baseURL: string
|
||||
model: string
|
||||
apiKey?: string
|
||||
baseURL: string
|
||||
model: string
|
||||
apiKey?: string
|
||||
|
||||
systemInstruction?: string
|
||||
systemInstruction?: string
|
||||
|
||||
includeInitialTab?: boolean
|
||||
experimentalIncludeAllTabs?: boolean
|
||||
onStatusChange?: (status: AgentStatus) => void
|
||||
onActivity?: (activity: AgentActivity) => void
|
||||
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
||||
includeInitialTab?: boolean
|
||||
experimentalIncludeAllTabs?: boolean
|
||||
onStatusChange?: (status: AgentStatus) => void
|
||||
onActivity?: (activity: AgentActivity) => void
|
||||
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
PAGE_AGENT_EXT_VERSION?: string
|
||||
PAGE_AGENT_EXT?: {
|
||||
version: string
|
||||
execute: Execute
|
||||
stop: () => void
|
||||
interface Window {
|
||||
PAGE_AGENT_EXT_VERSION?: string
|
||||
PAGE_AGENT_EXT?: {
|
||||
version: string
|
||||
execute: Execute
|
||||
stop: () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@page-agent/ext",
|
||||
"private": true,
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wxt",
|
||||
@@ -10,38 +10,38 @@
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-hover-card": "^1.1.16",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-label": "^2.1.9",
|
||||
"@radix-ui/react-separator": "^1.1.9",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/chrome": "^0.1.40",
|
||||
"@types/react": "^19.2.14",
|
||||
"@radix-ui/react-switch": "^1.3.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/chrome": "^0.1.43",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@wxt-dev/module-react": "^1.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-react": "^1.8.0",
|
||||
"motion": "^12.38.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"motion": "^12.40.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"rough-notation": "^0.5.1",
|
||||
"simple-icons": "^16.16.0",
|
||||
"simple-icons": "^16.23.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"wxt": "^0.20.20"
|
||||
"wxt": "^0.20.26"
|
||||
},
|
||||
"dependencies": {
|
||||
"@page-agent/core": "1.8.0",
|
||||
"@page-agent/llms": "1.8.0",
|
||||
"@page-agent/page-controller": "1.8.0",
|
||||
"@page-agent/ui": "1.8.0",
|
||||
"@page-agent/core": "1.9.1",
|
||||
"@page-agent/llms": "1.9.1",
|
||||
"@page-agent/page-controller": "1.9.1",
|
||||
"@page-agent/ui": "1.9.1",
|
||||
"ai-motion": "^0.4.8",
|
||||
"chalk": "^5.6.2"
|
||||
},
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Page Agent Ext"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "AI-powered browser automation assistant. Control web pages with natural language."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Open Page Agent"
|
||||
}
|
||||
"extName": {
|
||||
"message": "Page Agent Ext"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "AI-powered browser automation assistant. Control web pages with natural language."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Open Page Agent"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Page Agent Ext"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "AI 驱动的浏览器自动化助手,用自然语言控制网页。"
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "打开 Page Agent"
|
||||
}
|
||||
"extName": {
|
||||
"message": "Page Agent Ext"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "AI 驱动的浏览器自动化助手,用自然语言控制网页。"
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "打开 Page Agent"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
system_prompt.md
|
||||
@@ -40,43 +40,27 @@ export class MultiPageAgent extends PageAgentCore {
|
||||
const experimentalIncludeAllTabs = config.experimentalIncludeAllTabs ?? false
|
||||
|
||||
/**
|
||||
* Project agent status into chrome.storage. The content script polls
|
||||
* `isAgentRunning` + `agentHeartbeat` (eventually consistent by design).
|
||||
*
|
||||
* When the agent is in side-panel and user closed the side-panel.
|
||||
* There is no chance for isAgentRunning to be set false.
|
||||
* (unload event doesn't work well in side panel.)
|
||||
* (I'm trying not to use long-lived connection because the lifecycle of a sw is hard to predict.)
|
||||
* This heartbeat mechanism acts as a backup.
|
||||
*/
|
||||
let heartBeatInterval: null | number = null
|
||||
let heartBeatInterval: number | null = null
|
||||
|
||||
super({
|
||||
...config,
|
||||
// Disabled: AbortSignal cannot cross contexts
|
||||
experimentalScriptExecutionTool: false,
|
||||
pageController: pageController as any,
|
||||
customTools: customTools,
|
||||
customSystemPrompt: systemPrompt,
|
||||
|
||||
onBeforeTask: async (agent) => {
|
||||
await tabsController.init(agent.task, { includeInitialTab, experimentalIncludeAllTabs })
|
||||
|
||||
heartBeatInterval = window.setInterval(() => {
|
||||
chrome.storage.local.set({
|
||||
agentHeartbeat: Date.now(),
|
||||
})
|
||||
}, 1_000)
|
||||
|
||||
await chrome.storage.local.set({
|
||||
isAgentRunning: true,
|
||||
})
|
||||
},
|
||||
|
||||
onAfterTask: async () => {
|
||||
if (heartBeatInterval) {
|
||||
window.clearInterval(heartBeatInterval)
|
||||
heartBeatInterval = null
|
||||
}
|
||||
|
||||
await chrome.storage.local.set({
|
||||
isAgentRunning: false,
|
||||
})
|
||||
},
|
||||
|
||||
onBeforeStep: async (agent) => {
|
||||
@@ -87,16 +71,28 @@ export class MultiPageAgent extends PageAgentCore {
|
||||
|
||||
onDispose: () => {
|
||||
if (heartBeatInterval) {
|
||||
window.clearInterval(heartBeatInterval)
|
||||
clearInterval(heartBeatInterval)
|
||||
heartBeatInterval = null
|
||||
}
|
||||
|
||||
chrome.storage.local.set({
|
||||
isAgentRunning: false,
|
||||
})
|
||||
chrome.storage.local.set({ isAgentRunning: false }).catch(console.error)
|
||||
|
||||
tabsController.dispose()
|
||||
},
|
||||
})
|
||||
|
||||
this.addEventListener('statuschange', () => {
|
||||
const running = this.status === 'running'
|
||||
|
||||
if (running && !heartBeatInterval) {
|
||||
heartBeatInterval = window.setInterval(() => {
|
||||
void chrome.storage.local.set({ agentHeartbeat: Date.now() })
|
||||
}, 1_000)
|
||||
} else if (!running && heartBeatInterval) {
|
||||
clearInterval(heartBeatInterval)
|
||||
heartBeatInterval = null
|
||||
}
|
||||
|
||||
chrome.storage.local.set({ isAgentRunning: running }).catch(console.error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,9 +133,7 @@ export class RemotePageController {
|
||||
return this.remoteCallDomAction('scroll_horizontally', args)
|
||||
}
|
||||
|
||||
async executeJavascript(...args: any[]): Promise<DomActionReturn> {
|
||||
return this.remoteCallDomAction('execute_javascript', args)
|
||||
}
|
||||
// `execute_javascript` is intentionally not implemented: AbortSignal cannot cross context
|
||||
|
||||
/** @note Managed by content script via storage polling. */
|
||||
async showMask(): Promise<void> {}
|
||||
|
||||
@@ -42,7 +42,7 @@ export class TabsController {
|
||||
throw new Error('TabsController already disposed')
|
||||
}
|
||||
|
||||
this.currentTabId = null
|
||||
await this.updateCurrentTabId(null)
|
||||
this.disposed = false
|
||||
this.port = undefined
|
||||
this.portRetries = 0
|
||||
|
||||
@@ -72,7 +72,6 @@ Strictly follow these rules while using the browser and navigating the web:
|
||||
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
|
||||
- All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed.
|
||||
- If a captcha appears, tell user you can not solve captcha. Finish the task and ask user to solve it.
|
||||
- If expected elements are missing, try scrolling, or navigating back.
|
||||
- If the page is not fully loaded, use the `wait` action.
|
||||
- Do not repeat one action for more than 3 times unless some conditions changed.
|
||||
- If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field.
|
||||
|
||||
@@ -80,7 +80,7 @@ export function useAgent(): UseAgentResult {
|
||||
const handleStatusChange = (e: Event) => {
|
||||
const newStatus = agent.status as AgentStatus
|
||||
setStatus(newStatus)
|
||||
if (newStatus === 'idle' || newStatus === 'completed' || newStatus === 'error') {
|
||||
if (newStatus !== 'running') {
|
||||
setActivity(null)
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,6 @@ export function useAgent(): UseAgentResult {
|
||||
|
||||
const execute = useCallback(async (task: string) => {
|
||||
const agent = agentRef.current
|
||||
console.log('🚀 [useAgent] start executing task:', task)
|
||||
if (!agent) throw new Error('Agent not initialized')
|
||||
|
||||
setCurrentTask(task)
|
||||
|
||||
@@ -123,6 +123,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
size="icon-sm"
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-3 cursor-pointer"
|
||||
aria-label="Back"
|
||||
>
|
||||
<CornerUpLeft className="size-3.5" />
|
||||
</Button>
|
||||
@@ -130,12 +131,15 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
|
||||
{/* User Auth Token Section */}
|
||||
<div className="flex flex-col gap-1.5 p-3 bg-muted/50 rounded-md border">
|
||||
<label className="text-xs font-medium text-muted-foreground">User Auth Token</label>
|
||||
<label htmlFor="user-auth-token" className="text-xs font-medium text-muted-foreground">
|
||||
User Auth Token
|
||||
</label>
|
||||
<p className="text-[10px] text-muted-foreground mb-1">
|
||||
Give a website the ability to call this extension.
|
||||
</p>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
id="user-auth-token"
|
||||
readOnly
|
||||
value={
|
||||
userAuthToken
|
||||
@@ -152,6 +156,8 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
className="h-8 w-8 shrink-0 cursor-pointer"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
disabled={!userAuthToken}
|
||||
aria-label={showToken ? 'Hide token' : 'Show token'}
|
||||
aria-pressed={showToken}
|
||||
>
|
||||
{showToken ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
|
||||
</Button>
|
||||
@@ -161,9 +167,13 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
className="h-8 w-8 shrink-0 cursor-pointer"
|
||||
onClick={handleCopyToken}
|
||||
disabled={!userAuthToken}
|
||||
aria-label="Copy token"
|
||||
>
|
||||
{copied ? <span className="">✓</span> : <Copy className="size-3" />}
|
||||
</Button>
|
||||
<span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
|
||||
{copied ? 'Token copied' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -171,6 +181,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
<a
|
||||
href="/hub.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between p-3 rounded-md border bg-muted/50 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
|
||||
>
|
||||
Manage Page Agent Hub
|
||||
@@ -178,8 +189,11 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
</a>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">Base URL</label>
|
||||
<label htmlFor="base-url" className="text-xs text-muted-foreground">
|
||||
Base URL
|
||||
</label>
|
||||
<Input
|
||||
id="base-url"
|
||||
placeholder="https://api.openai.com/v1"
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
@@ -204,8 +218,11 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">Model</label>
|
||||
<label htmlFor="model" className="text-xs text-muted-foreground">
|
||||
Model
|
||||
</label>
|
||||
<Input
|
||||
id="model"
|
||||
placeholder="gpt-5.1"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
@@ -214,9 +231,12 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">API Key</label>
|
||||
<label htmlFor="api-key" className="text-xs text-muted-foreground">
|
||||
API Key
|
||||
</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
id="api-key"
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
// placeholder="sk-..."
|
||||
value={apiKey}
|
||||
@@ -228,6 +248,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 cursor-pointer"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
|
||||
>
|
||||
{showApiKey ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
|
||||
</Button>
|
||||
@@ -260,8 +281,11 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
{advancedOpen && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">Max Steps</label>
|
||||
<label htmlFor="max-steps" className="text-xs text-muted-foreground">
|
||||
Max Steps
|
||||
</label>
|
||||
<Input
|
||||
id="max-steps"
|
||||
type="number"
|
||||
placeholder="40"
|
||||
min={1}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { ArrowDownToLine, ArrowLeft, CheckCircle, RotateCcw, Trash2, XCircle } from 'lucide-react'
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
History,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -29,8 +37,13 @@ export function HistoryList({
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setSessions(await listSessions())
|
||||
setLoading(false)
|
||||
try {
|
||||
setSessions(await listSessions())
|
||||
} catch (err) {
|
||||
console.error('[HistoryList] Failed to load sessions:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -57,7 +70,14 @@ export function HistoryList({
|
||||
<div className="flex flex-col h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Button variant="ghost" size="icon-sm" onClick={onBack} className="cursor-pointer">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onBack}
|
||||
className="cursor-pointer"
|
||||
aria-label="Back"
|
||||
title="Back"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
</Button>
|
||||
<span className="text-sm font-medium flex-1">History</span>
|
||||
@@ -80,14 +100,23 @@ export function HistoryList({
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-32 text-xs text-muted-foreground">
|
||||
Loading...
|
||||
<div className="flex flex-col" aria-label="Loading history" aria-busy="true">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-2 px-3 py-2.5 border-b">
|
||||
<div className="size-3.5 mt-0.5 rounded-full bg-muted animate-pulse shrink-0" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="h-2.5 bg-muted animate-pulse rounded w-3/4" />
|
||||
<div className="h-2 bg-muted animate-pulse rounded w-1/3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && sessions.length === 0 && (
|
||||
<div className="flex items-center justify-center h-32 text-xs text-muted-foreground">
|
||||
No history yet
|
||||
<div className="flex flex-col items-center justify-center h-40 gap-2 text-muted-foreground">
|
||||
<History className="size-8 opacity-30" />
|
||||
<p className="text-xs">No history yet</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ function CopyButton({ text, label }: { text: string; label: string }) {
|
||||
function extractPrompt(rawRequest: unknown, role: 'system' | 'user'): string | null {
|
||||
const messages = (rawRequest as { messages?: { role: string; content?: unknown }[] })?.messages
|
||||
if (!messages) return null
|
||||
if (!Array.isArray(messages)) return null
|
||||
const msg =
|
||||
role === 'system'
|
||||
? messages.find((m) => m.role === role)
|
||||
|
||||
@@ -14,6 +14,7 @@ export function StatusDot({ status }: { status: AgentStatus }) {
|
||||
running: 'bg-blue-500',
|
||||
completed: 'bg-green-500',
|
||||
error: 'bg-destructive',
|
||||
stopped: 'bg-muted-foreground',
|
||||
}[status]
|
||||
|
||||
const label = {
|
||||
@@ -21,6 +22,7 @@ export function StatusDot({ status }: { status: AgentStatus }) {
|
||||
running: 'Running',
|
||||
completed: 'Done',
|
||||
error: 'Error',
|
||||
stopped: 'Stopped',
|
||||
}[status]
|
||||
|
||||
return (
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function App() {
|
||||
|
||||
if (
|
||||
prev === 'running' &&
|
||||
(status === 'completed' || status === 'error') &&
|
||||
(status === 'completed' || status === 'error' || status === 'stopped') &&
|
||||
history.length > 0 &&
|
||||
currentTask
|
||||
) {
|
||||
@@ -147,6 +147,8 @@ export default function App() {
|
||||
size="icon-sm"
|
||||
onClick={() => setView({ name: 'history' })}
|
||||
className="cursor-pointer"
|
||||
aria-label="History"
|
||||
title="History"
|
||||
>
|
||||
<History className="size-3.5" />
|
||||
</Button>
|
||||
@@ -155,6 +157,8 @@ export default function App() {
|
||||
size="icon-sm"
|
||||
onClick={() => setView({ name: 'config' })}
|
||||
className="cursor-pointer"
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="size-3.5" />
|
||||
</Button>
|
||||
@@ -205,6 +209,8 @@ export default function App() {
|
||||
variant="destructive"
|
||||
onClick={handleStop}
|
||||
className="size-7"
|
||||
aria-label="Stop task"
|
||||
title="Stop task"
|
||||
>
|
||||
<Square className="size-3" />
|
||||
</InputGroupButton>
|
||||
@@ -215,6 +221,8 @@ export default function App() {
|
||||
onClick={() => handleSubmit()}
|
||||
disabled={!inputValue.trim()}
|
||||
className="size-7 cursor-pointer"
|
||||
aria-label="Send"
|
||||
title="Send"
|
||||
>
|
||||
<Send className="size-3" />
|
||||
</InputGroupButton>
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface SessionRecord {
|
||||
id: string
|
||||
task: string
|
||||
history: HistoricalEvent[]
|
||||
status: 'completed' | 'error'
|
||||
status: 'completed' | 'error' | 'stopped'
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@page-agent/llms",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.1",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
@@ -43,6 +43,7 @@
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"prepublishOnly": "node ../../scripts/pre-publish.js",
|
||||
"postpublish": "node ../../scripts/post-publish.js"
|
||||
},
|
||||
@@ -53,6 +54,6 @@
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"zod": "^4.3.6"
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
import * as z from 'zod/v4'
|
||||
|
||||
import { InvokeError, InvokeErrorType } from './errors'
|
||||
import { InvokeError, InvokeErrorTypes } from './errors'
|
||||
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
|
||||
import { modelPatch, zodToOpenAITool } from './utils'
|
||||
|
||||
@@ -25,6 +25,8 @@ export class OpenAIClient implements LLMClient {
|
||||
abortSignal?: AbortSignal,
|
||||
options?: InvokeOptions
|
||||
): Promise<InvokeResult> {
|
||||
abortSignal?.throwIfAborted()
|
||||
|
||||
// 1. Convert tools to OpenAI format
|
||||
const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t))
|
||||
|
||||
@@ -45,6 +47,17 @@ export class OpenAIClient implements LLMClient {
|
||||
}
|
||||
|
||||
modelPatch(requestBody)
|
||||
let transformedBody: Record<string, unknown> | undefined
|
||||
try {
|
||||
transformedBody = this.config.transformRequestBody(requestBody)
|
||||
} catch (error) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorTypes.CONFIG_ERROR,
|
||||
`transformRequestBody failed: ${(error as Error).message}`,
|
||||
error
|
||||
)
|
||||
}
|
||||
const finalRequestBody = transformedBody ?? requestBody
|
||||
|
||||
// 2. Call API
|
||||
let response: Response
|
||||
@@ -55,56 +68,69 @@ export class OpenAIClient implements LLMClient {
|
||||
'Content-Type': 'application/json',
|
||||
...(this.config.apiKey && { Authorization: `Bearer ${this.config.apiKey}` }),
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
body: JSON.stringify(finalRequestBody),
|
||||
signal: abortSignal,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
const isAbortError = (error as any)?.name === 'AbortError'
|
||||
const errorMessage = isAbortError ? 'Network request aborted' : 'Network request failed'
|
||||
if (!isAbortError) console.error(error)
|
||||
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, errorMessage, error)
|
||||
if ((error as any)?.name === 'AbortError') throw error
|
||||
console.error(error)
|
||||
throw new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'Network request failed', error)
|
||||
}
|
||||
|
||||
// 3. Handle HTTP errors
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch()
|
||||
const errorMessage =
|
||||
(errorData as { error?: { message?: string } }).error?.message || response.statusText
|
||||
let errorData: any
|
||||
try {
|
||||
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) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.AUTH_ERROR,
|
||||
InvokeErrorTypes.AUTH_ERROR,
|
||||
`Authentication failed: ${errorMessage}`,
|
||||
errorData
|
||||
)
|
||||
}
|
||||
if (response.status === 429) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.RATE_LIMIT,
|
||||
InvokeErrorTypes.RATE_LIMIT,
|
||||
`Rate limit exceeded: ${errorMessage}`,
|
||||
errorData
|
||||
)
|
||||
}
|
||||
if (response.status >= 500) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.SERVER_ERROR,
|
||||
InvokeErrorTypes.SERVER_ERROR,
|
||||
`Server error: ${errorMessage}`,
|
||||
errorData
|
||||
)
|
||||
}
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.UNKNOWN,
|
||||
InvokeErrorTypes.UNKNOWN,
|
||||
`HTTP ${response.status}: ${errorMessage}`,
|
||||
errorData
|
||||
)
|
||||
}
|
||||
|
||||
// 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]
|
||||
if (!choice) {
|
||||
throw new InvokeError(InvokeErrorType.UNKNOWN, 'No choices in response', data)
|
||||
throw new InvokeError(InvokeErrorTypes.INVALID_SCHEMA, 'No choices in response', data)
|
||||
}
|
||||
|
||||
// Check finish_reason
|
||||
@@ -115,21 +141,21 @@ export class OpenAIClient implements LLMClient {
|
||||
break
|
||||
case 'length':
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.CONTEXT_LENGTH,
|
||||
InvokeErrorTypes.CONTEXT_LENGTH,
|
||||
'Response truncated: max tokens reached',
|
||||
undefined,
|
||||
data
|
||||
)
|
||||
case 'content_filter':
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.CONTENT_FILTER,
|
||||
InvokeErrorTypes.CONTENT_FILTER,
|
||||
'Content filtered by safety system',
|
||||
undefined,
|
||||
data
|
||||
)
|
||||
default:
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.UNKNOWN,
|
||||
InvokeErrorTypes.INVALID_SCHEMA,
|
||||
`Unexpected finish_reason: ${choice.finish_reason}`,
|
||||
undefined,
|
||||
data
|
||||
@@ -144,7 +170,7 @@ export class OpenAIClient implements LLMClient {
|
||||
const toolCallName = normalizedChoice?.message?.tool_calls?.[0]?.function?.name
|
||||
if (!toolCallName) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.NO_TOOL_CALL,
|
||||
InvokeErrorTypes.NO_TOOL_CALL,
|
||||
'No tool call found in response',
|
||||
undefined,
|
||||
data
|
||||
@@ -154,7 +180,7 @@ export class OpenAIClient implements LLMClient {
|
||||
const tool = tools[toolCallName]
|
||||
if (!tool) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.UNKNOWN,
|
||||
InvokeErrorTypes.UNKNOWN,
|
||||
`Tool "${toolCallName}" not found in tools`,
|
||||
undefined,
|
||||
data
|
||||
@@ -165,7 +191,7 @@ export class OpenAIClient implements LLMClient {
|
||||
const argString = normalizedChoice.message?.tool_calls?.[0]?.function?.arguments
|
||||
if (!argString) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
InvokeErrorTypes.INVALID_TOOL_ARGS,
|
||||
'No tool call arguments found',
|
||||
undefined,
|
||||
data
|
||||
@@ -177,7 +203,7 @@ export class OpenAIClient implements LLMClient {
|
||||
parsedArgs = JSON.parse(argString)
|
||||
} catch (error) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
InvokeErrorTypes.INVALID_TOOL_ARGS,
|
||||
'Failed to parse tool arguments as JSON',
|
||||
error,
|
||||
data
|
||||
@@ -189,7 +215,7 @@ export class OpenAIClient implements LLMClient {
|
||||
if (!validation.success) {
|
||||
console.error(z.prettifyError(validation.error))
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
InvokeErrorTypes.INVALID_TOOL_ARGS,
|
||||
'Tool arguments validation failed',
|
||||
validation.error,
|
||||
data
|
||||
@@ -201,11 +227,12 @@ export class OpenAIClient implements LLMClient {
|
||||
let toolResult: unknown
|
||||
try {
|
||||
toolResult = await tool.execute(toolInput)
|
||||
} catch (e) {
|
||||
} catch (error: unknown) {
|
||||
if ((error as any)?.name === 'AbortError') throw error
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.TOOL_EXECUTION_ERROR,
|
||||
`Tool execution failed: ${(e as Error).message}`,
|
||||
e,
|
||||
InvokeErrorTypes.TOOL_EXECUTION_ERROR,
|
||||
`Tool execution failed: ${(error as Error)?.message}`,
|
||||
error,
|
||||
data
|
||||
)
|
||||
}
|
||||
@@ -225,7 +252,7 @@ export class OpenAIClient implements LLMClient {
|
||||
reasoningTokens: data.usage?.completion_tokens_details?.reasoning_tokens,
|
||||
},
|
||||
rawResponse: data,
|
||||
rawRequest: requestBody,
|
||||
rawRequest: finalRequestBody,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-20
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Error types and error handling for LLM invocations
|
||||
* Error types and error handling for LLM invocations.
|
||||
*/
|
||||
|
||||
export const InvokeErrorType = {
|
||||
export const InvokeErrorTypes = {
|
||||
// Retryable
|
||||
NETWORK_ERROR: 'network_error', // Network error, retry
|
||||
RATE_LIMIT: 'rate_limit', // Rate limit, retry
|
||||
@@ -10,16 +10,31 @@ export const InvokeErrorType = {
|
||||
NO_TOOL_CALL: 'no_tool_call', // Model did not call tool
|
||||
INVALID_TOOL_ARGS: 'invalid_tool_args', // Tool args don't match schema
|
||||
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',
|
||||
|
||||
// Non-retryable
|
||||
CONFIG_ERROR: 'config_error', // Invalid local configuration or hook
|
||||
AUTH_ERROR: 'auth_error', // Authentication failed
|
||||
CONTEXT_LENGTH: 'context_length', // Prompt too long
|
||||
CONTENT_FILTER: 'content_filter', // Content filtered
|
||||
} as const
|
||||
|
||||
export type InvokeErrorType = (typeof InvokeErrorType)[keyof typeof InvokeErrorType]
|
||||
type InvokeErrorType = (typeof InvokeErrorTypes)[keyof typeof InvokeErrorTypes]
|
||||
|
||||
const RETRYABLE_TYPES: readonly InvokeErrorType[] = [
|
||||
InvokeErrorTypes.NETWORK_ERROR,
|
||||
InvokeErrorTypes.RATE_LIMIT,
|
||||
InvokeErrorTypes.SERVER_ERROR,
|
||||
InvokeErrorTypes.NO_TOOL_CALL,
|
||||
InvokeErrorTypes.INVALID_TOOL_ARGS,
|
||||
InvokeErrorTypes.TOOL_EXECUTION_ERROR,
|
||||
InvokeErrorTypes.INVALID_RESPONSE,
|
||||
InvokeErrorTypes.INVALID_SCHEMA,
|
||||
InvokeErrorTypes.UNKNOWN,
|
||||
]
|
||||
|
||||
export class InvokeError extends Error {
|
||||
type: InvokeErrorType
|
||||
@@ -34,24 +49,8 @@ export class InvokeError extends Error {
|
||||
super(message)
|
||||
this.name = 'InvokeError'
|
||||
this.type = type
|
||||
this.retryable = this.isRetryable(type, rawError)
|
||||
this.retryable = RETRYABLE_TYPES.includes(type)
|
||||
this.rawError = rawError
|
||||
this.rawResponse = rawResponse
|
||||
}
|
||||
|
||||
private isRetryable(type: InvokeErrorType, rawError?: unknown): boolean {
|
||||
const isAbortError = (rawError as any)?.name === 'AbortError'
|
||||
if (isAbortError) return false
|
||||
|
||||
const retryableTypes: InvokeErrorType[] = [
|
||||
InvokeErrorType.NETWORK_ERROR,
|
||||
InvokeErrorType.RATE_LIMIT,
|
||||
InvokeErrorType.SERVER_ERROR,
|
||||
InvokeErrorType.NO_TOOL_CALL,
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
InvokeErrorType.TOOL_EXECUTION_ERROR,
|
||||
InvokeErrorType.UNKNOWN,
|
||||
]
|
||||
return retryableTypes.includes(type)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { InvokeError, InvokeErrorTypes, LLM } from './index'
|
||||
import type { LLMClient } from './types'
|
||||
|
||||
function makeLLM(maxRetries = 2): LLM {
|
||||
return new LLM({
|
||||
baseURL: 'http://test.local/v1',
|
||||
model: 'gpt-5',
|
||||
maxRetries,
|
||||
})
|
||||
}
|
||||
|
||||
function abortError(): Error {
|
||||
const err = new Error('aborted')
|
||||
err.name = 'AbortError'
|
||||
return err
|
||||
}
|
||||
|
||||
describe('LLM.invoke retry behavior', () => {
|
||||
let llm: LLM
|
||||
let client: { invoke: ReturnType<typeof vi.fn> }
|
||||
const signal = new AbortController().signal
|
||||
|
||||
beforeEach(() => {
|
||||
llm = makeLLM(2)
|
||||
client = { invoke: vi.fn() }
|
||||
llm.client = client as unknown as LLMClient
|
||||
})
|
||||
|
||||
it('returns immediately on first success', async () => {
|
||||
client.invoke.mockResolvedValueOnce('ok')
|
||||
const retryListener = vi.fn()
|
||||
llm.addEventListener('retry', retryListener)
|
||||
|
||||
await expect(llm.invoke([], {}, signal)).resolves.toBe('ok')
|
||||
expect(client.invoke).toHaveBeenCalledOnce()
|
||||
expect(retryListener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries up to maxRetries on retryable errors, then throws last error', async () => {
|
||||
const retryable = new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'boom')
|
||||
client.invoke
|
||||
.mockRejectedValueOnce(retryable)
|
||||
.mockRejectedValueOnce(retryable)
|
||||
.mockRejectedValueOnce(retryable)
|
||||
|
||||
await expect(llm.invoke([], {}, signal)).rejects.toBe(retryable)
|
||||
// 1 initial + 2 retries = 3 attempts total
|
||||
expect(client.invoke).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('succeeds on retry after transient failure', async () => {
|
||||
const retryable = new InvokeError(InvokeErrorTypes.RATE_LIMIT, 'slow down')
|
||||
client.invoke.mockRejectedValueOnce(retryable).mockResolvedValueOnce('ok')
|
||||
|
||||
await expect(llm.invoke([], {}, signal)).resolves.toBe('ok')
|
||||
expect(client.invoke).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('emits "retry" events with attempt count and lastError', async () => {
|
||||
const err1 = new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'first')
|
||||
const err2 = new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'second')
|
||||
client.invoke
|
||||
.mockRejectedValueOnce(err1)
|
||||
.mockRejectedValueOnce(err2)
|
||||
.mockResolvedValueOnce('ok')
|
||||
|
||||
const events: { attempt: number; maxAttempts: number; lastError: Error }[] = []
|
||||
llm.addEventListener('retry', (e) => {
|
||||
events.push((e as CustomEvent).detail)
|
||||
})
|
||||
|
||||
await llm.invoke([], {}, signal)
|
||||
|
||||
expect(events).toEqual([
|
||||
{ attempt: 1, maxAttempts: 2, lastError: err1 },
|
||||
{ attempt: 2, maxAttempts: 2, lastError: err2 },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not retry on AbortError, throws immediately', async () => {
|
||||
const err = abortError()
|
||||
client.invoke.mockRejectedValueOnce(err)
|
||||
|
||||
await expect(llm.invoke([], {}, signal)).rejects.toBe(err)
|
||||
expect(client.invoke).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not retry on non-retryable InvokeError (AUTH_ERROR)', async () => {
|
||||
const err = new InvokeError(InvokeErrorTypes.AUTH_ERROR, 'bad token')
|
||||
client.invoke.mockRejectedValueOnce(err)
|
||||
|
||||
await expect(llm.invoke([], {}, signal)).rejects.toBe(err)
|
||||
expect(client.invoke).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not retry on non-retryable InvokeError (CONFIG_ERROR)', async () => {
|
||||
const err = new InvokeError(InvokeErrorTypes.CONFIG_ERROR, 'bad config')
|
||||
client.invoke.mockRejectedValueOnce(err)
|
||||
|
||||
await expect(llm.invoke([], {}, signal)).rejects.toBe(err)
|
||||
expect(client.invoke).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retries plain (non-InvokeError) errors as unknown failures', async () => {
|
||||
// Plain errors are treated as retryable by withRetry (only InvokeError carries retryable flag)
|
||||
const plain = new TypeError('weird')
|
||||
client.invoke.mockRejectedValueOnce(plain).mockResolvedValueOnce('ok')
|
||||
|
||||
await expect(llm.invoke([], {}, signal)).resolves.toBe('ok')
|
||||
expect(client.invoke).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
+22
-43
@@ -1,9 +1,9 @@
|
||||
import { OpenAIClient } from './OpenAIClient'
|
||||
import { DEFAULT_TEMPERATURE, LLM_MAX_RETRIES } from './constants'
|
||||
import { InvokeError, InvokeErrorType } from './errors'
|
||||
import { InvokeError, InvokeErrorTypes } from './errors'
|
||||
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
|
||||
|
||||
export { InvokeError, InvokeErrorType }
|
||||
export { InvokeError, InvokeErrorTypes }
|
||||
export type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool }
|
||||
|
||||
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
|
||||
@@ -21,6 +21,7 @@ export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
|
||||
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
|
||||
}
|
||||
@@ -49,65 +50,43 @@ export class LLM extends EventTarget {
|
||||
abortSignal: AbortSignal,
|
||||
options?: InvokeOptions
|
||||
): Promise<InvokeResult> {
|
||||
return await withRetry(
|
||||
async () => {
|
||||
// in case user aborted before invoking
|
||||
if (abortSignal.aborted) throw new Error('AbortError')
|
||||
|
||||
const result = await this.client.invoke(messages, tools, abortSignal, options)
|
||||
|
||||
return result
|
||||
return await withRetry(async () => this.client.invoke(messages, tools, abortSignal, options), {
|
||||
maxRetries: this.config.maxRetries,
|
||||
onRetry: (attempt, lastError) => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('retry', {
|
||||
detail: { attempt, maxAttempts: this.config.maxRetries, lastError },
|
||||
})
|
||||
)
|
||||
},
|
||||
// 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>(
|
||||
fn: () => Promise<T>,
|
||||
settings: {
|
||||
maxRetries: number
|
||||
onRetry: (attempt: number) => void
|
||||
onError: (error: Error) => void
|
||||
onRetry: (attempt: number, lastError: Error) => void
|
||||
}
|
||||
): Promise<T> {
|
||||
let attempt = 0
|
||||
let lastError: Error | null = null
|
||||
while (attempt <= settings.maxRetries) {
|
||||
if (attempt > 0) {
|
||||
settings.onRetry(attempt)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error: unknown) {
|
||||
// do not retry if aborted by user
|
||||
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 as any)?.name === 'AbortError') throw error
|
||||
if (error instanceof InvokeError && !error.retryable) throw error
|
||||
|
||||
lastError = error as Error
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError!
|
||||
}
|
||||
|
||||
@@ -95,6 +95,16 @@ export interface LLMConfig {
|
||||
temperature?: number
|
||||
maxRetries?: number
|
||||
|
||||
/**
|
||||
* Transform the final request body before sending it to the provider.
|
||||
* Use this to implement provider-specific request tweaks such as caching hints or custom flags.
|
||||
*
|
||||
* Return a new object, or mutate the input object and return undefined.
|
||||
*/
|
||||
transformRequestBody?: (
|
||||
requestBody: Record<string, unknown>
|
||||
) => Record<string, unknown> | undefined
|
||||
|
||||
/**
|
||||
* remove the tool_choice field from the request.
|
||||
* @note fix "Invalid tool_choice type: 'object'" for some LLMs.
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { modelPatch } from './utils'
|
||||
|
||||
/**
|
||||
* Baseline request body used as starting point for each provider test.
|
||||
* Mirrors what OpenAIClient builds before calling modelPatch.
|
||||
*/
|
||||
function baseBody(model: string) {
|
||||
return {
|
||||
model,
|
||||
temperature: 0.7,
|
||||
messages: [],
|
||||
tools: [],
|
||||
parallel_tool_calls: false,
|
||||
tool_choice: 'required' as unknown,
|
||||
}
|
||||
}
|
||||
|
||||
describe('modelPatch', () => {
|
||||
it('returns body unchanged when model is missing', () => {
|
||||
const body = { temperature: 0.7 }
|
||||
expect(modelPatch(body)).toBe(body)
|
||||
expect(body).toEqual({ temperature: 0.7 })
|
||||
})
|
||||
|
||||
it('qwen: bumps temperature and disables thinking', () => {
|
||||
const body = baseBody('qwen-max')
|
||||
modelPatch(body)
|
||||
expect(body.temperature).toBe(1.0)
|
||||
expect(body).toMatchObject({ enable_thinking: false })
|
||||
})
|
||||
|
||||
it('qwen: keeps higher caller-provided temperature', () => {
|
||||
const body = baseBody('qwen-max')
|
||||
body.temperature = 1.5
|
||||
modelPatch(body)
|
||||
expect(body.temperature).toBe(1.5)
|
||||
})
|
||||
|
||||
it('claude: disables thinking and converts tool_choice "required" -> { type: "any" }', () => {
|
||||
const body = baseBody('claude-3-5-sonnet')
|
||||
modelPatch(body)
|
||||
expect(body).toMatchObject({
|
||||
thinking: { type: 'disabled' },
|
||||
tool_choice: { type: 'any' },
|
||||
})
|
||||
})
|
||||
|
||||
it('claude: converts named tool_choice to { type: "tool", name }', () => {
|
||||
const body = baseBody('claude-3-5-sonnet')
|
||||
body.tool_choice = { type: 'function', function: { name: 'doStuff' } }
|
||||
modelPatch(body)
|
||||
expect(body.tool_choice).toEqual({ type: 'tool', name: 'doStuff' })
|
||||
})
|
||||
|
||||
it('claude-opus-4-7: drops temperature', () => {
|
||||
const body = baseBody('claude-opus-4-7')
|
||||
modelPatch(body)
|
||||
expect(body).not.toHaveProperty('temperature')
|
||||
})
|
||||
|
||||
it('claude-opus-47 (alt id form): drops temperature', () => {
|
||||
// Provider sometimes ships ids with the dot stripped; modelPatch normalizes.
|
||||
const body = baseBody('claude-opus-47-20251029')
|
||||
modelPatch(body)
|
||||
expect(body).not.toHaveProperty('temperature')
|
||||
})
|
||||
|
||||
it('claude-opus-4-8: drops temperature', () => {
|
||||
const body = baseBody('claude-opus-4-8')
|
||||
modelPatch(body)
|
||||
expect(body).not.toHaveProperty('temperature')
|
||||
})
|
||||
|
||||
it('claude-opus-48 (alt id form): drops temperature', () => {
|
||||
const body = baseBody('claude-opus-48-20251210')
|
||||
modelPatch(body)
|
||||
expect(body).not.toHaveProperty('temperature')
|
||||
})
|
||||
|
||||
it('grok: removes tool_choice and disables reasoning/thinking', () => {
|
||||
const body = baseBody('grok-4')
|
||||
modelPatch(body)
|
||||
expect(body).not.toHaveProperty('tool_choice')
|
||||
expect(body).toMatchObject({
|
||||
thinking: { type: 'disabled', effort: 'minimal' },
|
||||
reasoning: { enabled: false, effort: 'low' },
|
||||
})
|
||||
})
|
||||
|
||||
it('gpt-5: sets verbosity=low and reasoning_effort=low', () => {
|
||||
const body = baseBody('gpt-5')
|
||||
modelPatch(body)
|
||||
expect(body).toMatchObject({ verbosity: 'low', reasoning_effort: 'low' })
|
||||
})
|
||||
|
||||
it('gpt-5-mini: low effort, temperature=1', () => {
|
||||
const body = baseBody('gpt-5-mini')
|
||||
modelPatch(body)
|
||||
expect(body).toMatchObject({
|
||||
verbosity: 'low',
|
||||
reasoning_effort: 'low',
|
||||
temperature: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('gpt-5.1 (gpt-51): disables reasoning', () => {
|
||||
const body = baseBody('gpt-5.1')
|
||||
modelPatch(body)
|
||||
expect(body).toMatchObject({ verbosity: 'low', reasoning_effort: 'none' })
|
||||
})
|
||||
|
||||
it('gpt-5.4 (gpt-54): drops reasoning_effort', () => {
|
||||
const body = baseBody('gpt-5.4')
|
||||
modelPatch(body)
|
||||
expect(body).toMatchObject({ verbosity: 'low' })
|
||||
expect(body).not.toHaveProperty('reasoning_effort')
|
||||
})
|
||||
|
||||
it('gpt-5.5 (gpt-55): drops reasoning_effort and temperature', () => {
|
||||
const body = baseBody('gpt-5.5')
|
||||
modelPatch(body)
|
||||
expect(body).toMatchObject({ verbosity: 'low' })
|
||||
expect(body).not.toHaveProperty('reasoning_effort')
|
||||
expect(body).not.toHaveProperty('temperature')
|
||||
})
|
||||
|
||||
it('gemini: sets reasoning_effort=minimal', () => {
|
||||
const body = baseBody('gemini-2.5-pro')
|
||||
modelPatch(body)
|
||||
expect(body).toMatchObject({ reasoning_effort: 'minimal' })
|
||||
})
|
||||
|
||||
it('deepseek: removes tool_choice', () => {
|
||||
const body = baseBody('deepseek-chat')
|
||||
modelPatch(body)
|
||||
expect(body).not.toHaveProperty('tool_choice')
|
||||
})
|
||||
|
||||
it('minimax: clamps temperature into (0, 1] and removes parallel_tool_calls', () => {
|
||||
const body = baseBody('minimax-m2')
|
||||
body.temperature = 0
|
||||
modelPatch(body)
|
||||
expect(body.temperature).toBeGreaterThan(0)
|
||||
expect(body.temperature).toBeLessThanOrEqual(1)
|
||||
expect(body).not.toHaveProperty('parallel_tool_calls')
|
||||
})
|
||||
|
||||
it('minimax: caps temperature at 1', () => {
|
||||
const body = baseBody('minimax-m2')
|
||||
body.temperature = 2
|
||||
modelPatch(body)
|
||||
expect(body.temperature).toBe(1)
|
||||
})
|
||||
|
||||
it('normalizes provider-prefixed model id (openai/gpt-5)', () => {
|
||||
const body = baseBody('openai/gpt-5')
|
||||
modelPatch(body)
|
||||
expect(body).toMatchObject({ verbosity: 'low', reasoning_effort: 'low' })
|
||||
})
|
||||
})
|
||||
@@ -53,6 +53,18 @@ export function modelPatch(body: Record<string, any>) {
|
||||
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') ||
|
||||
modelName.startsWith('claude-opus-4-8') ||
|
||||
modelName.startsWith('claude-opus-48')
|
||||
) {
|
||||
debug('Applying Claude-4.7/4.8 patch: remove temperature')
|
||||
delete body.temperature
|
||||
}
|
||||
}
|
||||
|
||||
if (modelName.startsWith('grok')) {
|
||||
@@ -74,10 +86,12 @@ export function modelPatch(body: Record<string, any>) {
|
||||
debug('Applying GPT-51 patch: disable reasoning')
|
||||
body.reasoning_effort = 'none'
|
||||
} else if (modelName.startsWith('gpt-54')) {
|
||||
debug(
|
||||
'Applying GPT-5.4 patch: skip reasoning_effort because chat/completions rejects it with function tools'
|
||||
)
|
||||
debug('Applying GPT-5.4 patch: remove reasoning_effort')
|
||||
delete body.reasoning_effort
|
||||
} else if (modelName.startsWith('gpt-55')) {
|
||||
debug('Applying GPT-5.4 patch: remove reasoning_effort and temperature')
|
||||
delete body.reasoning_effort
|
||||
delete body.temperature
|
||||
} else if (modelName.startsWith('gpt-5-mini')) {
|
||||
debug('Applying GPT-5-mini patch: set reasoning effort to low, temperature to 1')
|
||||
body.reasoning_effort = 'low'
|
||||
@@ -93,6 +107,11 @@ export function modelPatch(body: Record<string, any>) {
|
||||
body.reasoning_effort = 'minimal'
|
||||
}
|
||||
|
||||
if (modelName.startsWith('deepseek')) {
|
||||
debug('Applying DeepSeek patch: remove tool_choice')
|
||||
delete body.tool_choice
|
||||
}
|
||||
|
||||
if (modelName.startsWith('minimax')) {
|
||||
debug('Applying MiniMax patch: clamp temperature to (0, 1]')
|
||||
// MiniMax API rejects temperature = 0; clamp to a small positive value
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export default defineConfig({
|
||||
clearScreen: false,
|
||||
plugins: [
|
||||
dts({
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/**/*.test.ts'],
|
||||
bundleTypes: true,
|
||||
compilerOptions: {
|
||||
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',
|
||||
},
|
||||
})
|
||||
@@ -36,11 +36,11 @@ Same format — add the config to the MCP settings of your client.
|
||||
|
||||
## MCP Tools
|
||||
|
||||
| Tool | Input | Description |
|
||||
| -------------- | ------------------ | ---------------------------------------------------- |
|
||||
| Tool | Input | Description |
|
||||
| -------------- | ------------------ | ----------------------------------------------------- |
|
||||
| `execute_task` | `{ task: string }` | Execute a browser task in natural language. Blocking. |
|
||||
| `get_status` | — | Returns `{ connected, busy }` |
|
||||
| `stop_task` | — | Stop the currently running task. |
|
||||
| `get_status` | — | Returns `{ connected, busy }` |
|
||||
| `stop_task` | — | Stop the currently running task. |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
@@ -88,7 +88,6 @@ src/
|
||||
## Dev
|
||||
|
||||
```bash
|
||||
npm run build:libs
|
||||
npm run dev:ext
|
||||
npx @modelcontextprotocol/inspector node packages/mcp/src/index.js
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@page-agent/mcp",
|
||||
"private": false,
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.1",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"page-agent-mcp": "src/index.js"
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.3.6"
|
||||
"ws": "^8.21.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
import { exec } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { platform } from 'node:os'
|
||||
import * as z from 'zod/v4'
|
||||
|
||||
@@ -9,6 +10,7 @@ import { HubBridge } from './hub-bridge.js'
|
||||
|
||||
const env = process.env
|
||||
const port = parseInt(env.PORT || '38401')
|
||||
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const llmConfig = {}
|
||||
@@ -30,7 +32,7 @@ exec(`${cmd} "${url}"`, (err) => {
|
||||
|
||||
// --- MCP server (stdio) ---
|
||||
|
||||
const mcpServer = new McpServer({ name: 'page-agent', version: '1.5.8' })
|
||||
const mcpServer = new McpServer({ name: 'page-agent', version })
|
||||
|
||||
mcpServer.registerTool(
|
||||
'execute_task',
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="https://img.alicdn.com/imgextra/i1/O1CN01mRGret1QrKiu7CFJI_!!6000000002029-2-tps-64-64.png" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="https://img.alicdn.com/imgextra/i1/O1CN01mRGret1QrKiu7CFJI_!!6000000002029-2-tps-64-64.png"
|
||||
/>
|
||||
<title>Page Agent MCP Launcher</title>
|
||||
<style>
|
||||
* {
|
||||
@@ -172,16 +175,26 @@
|
||||
If the extension is outdated, please update it to the latest version.
|
||||
</li>
|
||||
<li data-i18n="tip_other_browser">
|
||||
If the extension is not installed in this browser, open this page from the
|
||||
browser that has it installed.
|
||||
If the extension is not installed in this browser, open this page from the browser that
|
||||
has it installed.
|
||||
</li>
|
||||
<li data-i18n="tip_refresh">Refresh this page after installing or updating.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="links">
|
||||
<a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank" data-i18n="link_docs">Docs</a>
|
||||
<a href="https://github.com/alibaba/page-agent/issues" target="_blank" data-i18n="link_issues">Report an Issue</a>
|
||||
<a
|
||||
href="https://alibaba.github.io/page-agent/docs/introduction/overview"
|
||||
target="_blank"
|
||||
data-i18n="link_docs"
|
||||
>Docs</a
|
||||
>
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent/issues"
|
||||
target="_blank"
|
||||
data-i18n="link_issues"
|
||||
>Report an Issue</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -197,8 +210,7 @@
|
||||
install_sub: 'Page Agent 需要安装最新版浏览器插件才能运行。',
|
||||
install_btn: '从 Chrome 应用商店安装',
|
||||
tip_outdated: '如果插件版本过旧,请更新到最新版本。',
|
||||
tip_other_browser:
|
||||
'如果该浏览器中未安装插件,请从装有插件的浏览器打开此页面。',
|
||||
tip_other_browser: '如果该浏览器中未安装插件,请从装有插件的浏览器打开此页面。',
|
||||
tip_refresh: '安装或更新后,请刷新此页面。',
|
||||
link_docs: '文档',
|
||||
link_issues: '问题反馈',
|
||||
@@ -220,13 +232,9 @@
|
||||
if (!globalThis.chrome?.runtime?.sendMessage) {
|
||||
showInstall()
|
||||
} else {
|
||||
chrome.runtime.sendMessage(
|
||||
EXT_ID,
|
||||
{ type: 'OPEN_HUB', wsPort },
|
||||
(response) => {
|
||||
if (chrome.runtime.lastError || !response?.ok) showInstall()
|
||||
}
|
||||
)
|
||||
chrome.runtime.sendMessage(EXT_ID, { type: 'OPEN_HUB', wsPort }, (response) => {
|
||||
if (chrome.runtime.lastError || !response?.ok) showInstall()
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
showInstall()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "page-agent",
|
||||
"private": false,
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.1",
|
||||
"type": "module",
|
||||
"main": "./src/PageAgent.ts",
|
||||
"types": "./src/PageAgent.ts",
|
||||
@@ -54,16 +54,16 @@
|
||||
"postpublish": "node ../../scripts/post-publish.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@page-agent/core": "1.8.0",
|
||||
"@page-agent/llms": "1.8.0",
|
||||
"@page-agent/page-controller": "1.8.0",
|
||||
"@page-agent/ui": "1.8.0",
|
||||
"@page-agent/core": "1.9.1",
|
||||
"@page-agent/llms": "1.9.1",
|
||||
"@page-agent/page-controller": "1.9.1",
|
||||
"@page-agent/ui": "1.9.1",
|
||||
"chalk": "^5.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"zod": "^4.3.6"
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
*/
|
||||
import { PageAgent, type PageAgentConfig } from './PageAgent'
|
||||
|
||||
const currentScript = document.currentScript as HTMLScriptElement | null
|
||||
const currentScriptURL = currentScript?.src ? new URL(currentScript.src) : null
|
||||
const autoInit = currentScriptURL?.searchParams.get('autoInit') !== 'false'
|
||||
|
||||
// Clean up existing instances to prevent multiple injections from bookmarklet
|
||||
if (window.pageAgent) {
|
||||
if (autoInit && window.pageAgent) {
|
||||
window.pageAgent.dispose()
|
||||
}
|
||||
|
||||
@@ -17,32 +21,35 @@ const DEMO_MODEL = 'qwen3.5-plus'
|
||||
const DEMO_BASE_URL = 'https://page-ag-testing-ohftxirgbn.cn-shanghai.fcapp.run'
|
||||
const DEMO_API_KEY = 'NA'
|
||||
|
||||
const currentScript = document.currentScript as HTMLScriptElement | null
|
||||
|
||||
// in case document.x is not ready yet
|
||||
setTimeout(() => {
|
||||
let config: PageAgentConfig
|
||||
if (autoInit) {
|
||||
setTimeout(() => {
|
||||
let config: PageAgentConfig
|
||||
let showPanel = true
|
||||
|
||||
if (currentScript) {
|
||||
console.log('🚀 page-agent.js detected current script:', currentScript.src)
|
||||
const url = new URL(currentScript.src)
|
||||
const model = url.searchParams.get('model') || DEMO_MODEL
|
||||
const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL
|
||||
const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY
|
||||
const language = (url.searchParams.get('lang') as 'zh-CN' | 'en-US') || 'zh-CN'
|
||||
config = { model, baseURL, apiKey, language }
|
||||
} else {
|
||||
console.log('🚀 page-agent.js no current script detected, using default demo config')
|
||||
config = {
|
||||
model: import.meta.env.LLM_MODEL_NAME ? import.meta.env.LLM_MODEL_NAME : DEMO_MODEL,
|
||||
baseURL: import.meta.env.LLM_BASE_URL ? import.meta.env.LLM_BASE_URL : DEMO_BASE_URL,
|
||||
apiKey: import.meta.env.LLM_API_KEY ? import.meta.env.LLM_API_KEY : DEMO_API_KEY,
|
||||
if (currentScriptURL) {
|
||||
const url = currentScriptURL
|
||||
const model = url.searchParams.get('model') || DEMO_MODEL
|
||||
const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL
|
||||
const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY
|
||||
const language = (url.searchParams.get('lang') as 'zh-CN' | 'en-US') || 'zh-CN'
|
||||
showPanel = ((url.searchParams.get('showPanel') as 'true' | 'false') || 'true') === 'true'
|
||||
config = { model, baseURL, apiKey, language }
|
||||
} else {
|
||||
console.log('🚀 page-agent.js no current script detected, using default demo config')
|
||||
config = {
|
||||
model: import.meta.env.LLM_MODEL_NAME ? import.meta.env.LLM_MODEL_NAME : DEMO_MODEL,
|
||||
baseURL: import.meta.env.LLM_BASE_URL ? import.meta.env.LLM_BASE_URL : DEMO_BASE_URL,
|
||||
apiKey: import.meta.env.LLM_API_KEY ? import.meta.env.LLM_API_KEY : DEMO_API_KEY,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create agent
|
||||
window.pageAgent = new PageAgent(config)
|
||||
window.pageAgent.panel.show()
|
||||
// Create agent
|
||||
window.pageAgent = new PageAgent(config)
|
||||
if (showPanel) {
|
||||
window.pageAgent.panel.show()
|
||||
}
|
||||
|
||||
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
|
||||
})
|
||||
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ export default defineConfig({
|
||||
clearScreen: false,
|
||||
plugins: [
|
||||
dts({
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/**/*.test.ts'],
|
||||
bundleTypes: true,
|
||||
compilerOptions: {
|
||||
composite: true,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { config as dotenvConfig } from 'dotenv'
|
||||
import { dirname, resolve } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig } from 'vite'
|
||||
// import { analyzer } from 'vite-bundle-analyzer'
|
||||
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@page-agent/page-controller",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.1",
|
||||
"type": "module",
|
||||
"main": "./src/PageController.ts",
|
||||
"types": "./src/PageController.ts",
|
||||
@@ -42,10 +42,14 @@
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"prepublishOnly": "node ../../scripts/pre-publish.js",
|
||||
"postpublish": "node ../../scripts/post-publish.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"ai-motion": "^0.4.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"happy-dom": "^20.10.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { PageController } from './PageController'
|
||||
|
||||
describe('PageController', () => {
|
||||
it('constructs and exposes the current url', async () => {
|
||||
const controller = new PageController()
|
||||
expect(controller).toBeInstanceOf(PageController)
|
||||
expect(await controller.getCurrentUrl()).toBe(window.location.href)
|
||||
})
|
||||
|
||||
describe('executeJavascript', () => {
|
||||
it('runs a script and returns its result', async () => {
|
||||
const controller = new PageController()
|
||||
const result = await controller.executeJavascript('return 1 + 2')
|
||||
expect(result).toMatchObject({ success: true })
|
||||
expect(result.message).toContain('3')
|
||||
})
|
||||
|
||||
it('exposes the abort signal to the script scope', async () => {
|
||||
const controller = new PageController()
|
||||
const controllerSignal = new AbortController()
|
||||
controllerSignal.abort()
|
||||
|
||||
const result = await controller.executeJavascript(
|
||||
'return signal.aborted',
|
||||
controllerSignal.signal
|
||||
)
|
||||
expect(result).toMatchObject({ success: true })
|
||||
expect(result.message).toContain('true')
|
||||
})
|
||||
|
||||
it('reports a syntax error as a failed result', async () => {
|
||||
const controller = new PageController()
|
||||
const result = await controller.executeJavascript('return (')
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toContain('❌')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -376,13 +376,15 @@ export class PageController extends EventTarget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute arbitrary JavaScript on the page
|
||||
* Execute arbitrary JavaScript on the page.
|
||||
* The optional `signal` is exposed to the script scope so cooperative code
|
||||
* can abort promptly when the task is stopped.
|
||||
*/
|
||||
async executeJavascript(script: string): Promise<ActionResult> {
|
||||
async executeJavascript(script: string, signal?: AbortSignal): Promise<ActionResult> {
|
||||
try {
|
||||
// Wrap script in async function to support await
|
||||
const asyncFunction = eval(`(async () => { ${script} })`)
|
||||
const result = await asyncFunction()
|
||||
// Wrap script in async function to support await, exposing `signal`.
|
||||
const asyncFunction = eval(`(async (signal) => { ${script} })`)
|
||||
const result = await asyncFunction(signal)
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Executed JavaScript. Result: ${result}`,
|
||||
|
||||
@@ -336,11 +336,13 @@ export async function scrollVertically(scroll_amount: number, element?: HTMLElem
|
||||
|
||||
const dy = scroll_amount
|
||||
const bigEnough = (el: HTMLElement) => el.clientHeight >= window.innerHeight * 0.5
|
||||
const canScroll = (el: HTMLElement | null) =>
|
||||
el &&
|
||||
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowY) &&
|
||||
el.scrollHeight > el.clientHeight &&
|
||||
bigEnough(el)
|
||||
const canScroll = (el: HTMLElement | null): boolean =>
|
||||
Boolean(
|
||||
el &&
|
||||
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowY) &&
|
||||
el.scrollHeight > el.clientHeight &&
|
||||
bigEnough(el)
|
||||
)
|
||||
|
||||
// @deprecated Heuristic container search.
|
||||
// Unreliable in multi-panel layouts. Should guide LLMs to use indexed scroll for consistency.
|
||||
@@ -479,11 +481,13 @@ export async function scrollHorizontally(scroll_amount: number, element?: HTMLEl
|
||||
const dx = scroll_amount
|
||||
|
||||
const bigEnough = (el: HTMLElement) => el.clientWidth >= window.innerWidth * 0.5
|
||||
const canScroll = (el: HTMLElement | null) =>
|
||||
el &&
|
||||
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowX) &&
|
||||
el.scrollWidth > el.clientWidth &&
|
||||
bigEnough(el)
|
||||
const canScroll = (el: HTMLElement | null): boolean =>
|
||||
Boolean(
|
||||
el &&
|
||||
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowX) &&
|
||||
el.scrollWidth > el.clientWidth &&
|
||||
bigEnough(el)
|
||||
)
|
||||
|
||||
// @deprecated Same heuristic container search as scrollVertically.
|
||||
// TODO: Remove once LLMs reliably use indexed scrolling via data-scrollable.
|
||||
|
||||
@@ -552,8 +552,6 @@ export default (
|
||||
scrollData: scrollData,
|
||||
})
|
||||
|
||||
console.log('scrollData!!!', scrollData)
|
||||
|
||||
return scrollData
|
||||
}
|
||||
|
||||
|
||||
@@ -209,7 +209,6 @@ export class SimulatorMask extends EventTarget {
|
||||
|
||||
dispose() {
|
||||
this.#disposed = true
|
||||
console.log('dispose SimulatorMask')
|
||||
this.motion?.dispose()
|
||||
this.wrapper.remove()
|
||||
this.dispatchEvent(new Event('dispose'))
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
/**
|
||||
* A comprehensive function to determine if the page is currently in a dark theme.
|
||||
* Heuristic check. Only work for common patterns. Return false by default.
|
||||
*/
|
||||
export function isPageDark() {
|
||||
try {
|
||||
if (hasDarkModeClass()) return true
|
||||
if (hasDarkModeDataAttribute()) return true
|
||||
if (isColorSchemeDark()) return true
|
||||
if (isBackgroundDark()) return true
|
||||
if (isMainContentBackgroundDark()) return true
|
||||
if (isTextColorLight()) return true
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.warn('Error determining if page is dark:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for common dark mode CSS classes on the html or body elements.
|
||||
* @returns {boolean} - True if a common dark mode class is found.
|
||||
*/
|
||||
function hasDarkModeClass() {
|
||||
const DEFAULT_DARK_MODE_CLASSES = ['dark', 'dark-mode', 'theme-dark', 'night', 'night-mode']
|
||||
@@ -15,57 +34,47 @@ function hasDarkModeClass() {
|
||||
}
|
||||
}
|
||||
|
||||
// Some sites use data attributes
|
||||
const darkThemeAttribute = htmlElement.getAttribute('data-theme')
|
||||
if (darkThemeAttribute?.toLowerCase().includes('dark')) {
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Some UI frameworks use data attributes to indicate theme
|
||||
*/
|
||||
function hasDarkModeDataAttribute() {
|
||||
const htmlElement = document.documentElement
|
||||
const bodyElement = document.body || document.documentElement // can be null in some cases
|
||||
|
||||
const dataAttrs = ['data-theme', 'data-color-mode', 'data-bs-theme', 'data-mui-color-scheme']
|
||||
for (const attr of dataAttrs) {
|
||||
const bodyValue = bodyElement?.getAttribute(attr)
|
||||
const htmlValue = htmlElement.getAttribute(attr)
|
||||
|
||||
if (bodyValue?.toLowerCase() === 'dark' || htmlValue?.toLowerCase() === 'dark') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an RGB or RGBA color string and returns an object with r, g, b properties.
|
||||
* @param {string} colorString - e.g., "rgb(34, 34, 34)" or "rgba(0, 0, 0, 0.5)"
|
||||
* @returns {{r: number, g: number, b: number}|null}
|
||||
* Checks the CSS `color-scheme` property and `<meta name="color-scheme">` tag.
|
||||
* Only "dark"/"only dark" counts as dark; "light dark" is ambiguous and ignored.
|
||||
*/
|
||||
function parseRgbColor(colorString: string) {
|
||||
const rgbMatch = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(colorString)
|
||||
if (!rgbMatch) {
|
||||
return null // Not a valid rgb/rgba string
|
||||
}
|
||||
return {
|
||||
r: parseInt(rgbMatch[1]),
|
||||
g: parseInt(rgbMatch[2]),
|
||||
b: parseInt(rgbMatch[3]),
|
||||
}
|
||||
}
|
||||
function isColorSchemeDark() {
|
||||
// Check <meta name="color-scheme" content="dark">
|
||||
const meta = document.querySelector<HTMLMetaElement>('meta[name="color-scheme"]')
|
||||
const metaContent = meta?.content.toLowerCase()
|
||||
if (metaContent === 'dark' || metaContent === 'only dark') return true
|
||||
|
||||
/**
|
||||
* Determines if a color is "dark" based on its calculated luminance.
|
||||
* @param {string} colorString - The CSS color string (e.g., "rgb(50, 50, 50)").
|
||||
* @param {number} threshold - A value between 0 and 255. Colors with luminance below this will be considered dark. Default is 128.
|
||||
* @returns {boolean} - True if the color is considered dark.
|
||||
*/
|
||||
function isColorDark(colorString: string, threshold = 128) {
|
||||
if (!colorString || colorString === 'transparent' || colorString.startsWith('rgba(0, 0, 0, 0)')) {
|
||||
return false // Transparent is not dark
|
||||
}
|
||||
|
||||
const rgb = parseRgbColor(colorString)
|
||||
if (!rgb) {
|
||||
return false // Could not parse color
|
||||
}
|
||||
|
||||
// Calculate perceived luminance using the standard formula
|
||||
const luminance = 0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b
|
||||
|
||||
return luminance < threshold
|
||||
// Check the computed color-scheme CSS property on :root
|
||||
const rootStyle = window.getComputedStyle(document.documentElement)
|
||||
const colorScheme = rootStyle.getPropertyValue('color-scheme').trim().toLowerCase()
|
||||
return colorScheme === 'dark' || colorScheme === 'only dark'
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the background color of the body element to determine if the page is dark.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isBackgroundDark() {
|
||||
// We check both <html> and <body> because some pages set the color on <html>
|
||||
@@ -88,28 +97,85 @@ function isBackgroundDark() {
|
||||
}
|
||||
|
||||
/**
|
||||
* A comprehensive function to determine if the page is currently in a dark theme.
|
||||
* It combines class checking and background color analysis.
|
||||
* @returns {boolean} - True if the page is likely dark.
|
||||
* Checks if the text color on the body is light, which implies a dark background.
|
||||
*/
|
||||
export function isPageDark() {
|
||||
try {
|
||||
// Strategy 1: Check for common dark mode classes
|
||||
if (hasDarkModeClass()) {
|
||||
return true
|
||||
}
|
||||
function isTextColorLight() {
|
||||
/** Luminance (0-255) above which body text is considered light */
|
||||
const LIGHT_TEXT_LUMINANCE = 200
|
||||
|
||||
// Strategy 2: Analyze the computed background color
|
||||
if (isBackgroundDark()) {
|
||||
return true
|
||||
}
|
||||
const bodyStyle = window.getComputedStyle(document.body || document.documentElement)
|
||||
const luminance = getLuminance(bodyStyle.color)
|
||||
|
||||
// @TODO add more checks here, e.g., analyzing text color,
|
||||
// or checking the background of major layout elements like <main> or #app.
|
||||
// Light text has high luminance (e.g. white text on dark bg)
|
||||
return luminance !== null && luminance > LIGHT_TEXT_LUMINANCE
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.warn('Error determining if page is dark:', error)
|
||||
return false
|
||||
/**
|
||||
* Checks the background color of major layout elements (#app, #root, etc.).
|
||||
* Many SPAs render into a container that may have its own dark background while
|
||||
* <body> remains transparent.
|
||||
*/
|
||||
function isMainContentBackgroundDark() {
|
||||
const { innerWidth: vw, innerHeight: vh } = window
|
||||
const minArea = vw * vh * 0.5
|
||||
|
||||
const selectors = ['#app', '#root', '#__next']
|
||||
for (const selector of selectors) {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) continue
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width * rect.height < minArea) continue
|
||||
|
||||
if (isColorDark(window.getComputedStyle(el).backgroundColor)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- utils ---
|
||||
|
||||
/**
|
||||
* Parses an RGB or RGBA color string and returns an object with r, g, b properties.
|
||||
* @param {string} colorString - e.g., "rgb(34, 34, 34)" or "rgba(0, 0, 0, 0.5)"
|
||||
* @returns {{r: number, g: number, b: number}|null}
|
||||
*/
|
||||
function parseRgbColor(colorString: string) {
|
||||
const rgbMatch = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(colorString)
|
||||
if (!rgbMatch) {
|
||||
return null // Not a valid rgb/rgba string
|
||||
}
|
||||
return {
|
||||
r: parseInt(rgbMatch[1]),
|
||||
g: parseInt(rgbMatch[2]),
|
||||
b: parseInt(rgbMatch[3]),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the perceived luminance (0-255) of a CSS color string.
|
||||
* @param {string} colorString - e.g., "rgb(50, 50, 50)" or "rgba(0, 0, 0, 0.5)"
|
||||
* @returns {number|null} - The luminance, or null if the color is transparent or unparseable.
|
||||
*/
|
||||
function getLuminance(colorString: string): number | null {
|
||||
if (!colorString || colorString === 'transparent' || colorString.startsWith('rgba(0, 0, 0, 0)')) {
|
||||
return null // Transparent has no meaningful luminance
|
||||
}
|
||||
|
||||
const rgb = parseRgbColor(colorString)
|
||||
if (!rgb) {
|
||||
return null // Could not parse color
|
||||
}
|
||||
|
||||
// Standard perceived luminance formula
|
||||
return 0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a color is "dark" based on its calculated luminance.
|
||||
* @param {string} colorString - The CSS color string (e.g., "rgb(50, 50, 50)").
|
||||
* @param {number} threshold - A value between 0 and 255. Colors with luminance below this will be considered dark. Default is 128.
|
||||
*/
|
||||
function isColorDark(colorString: string, threshold = 128) {
|
||||
const luminance = getLuminance(colorString)
|
||||
return luminance !== null && luminance < threshold
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export default defineConfig({
|
||||
clearScreen: false,
|
||||
plugins: [
|
||||
dts({
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/**/*.test.ts'],
|
||||
bundleTypes: true,
|
||||
compilerOptions: {
|
||||
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",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.1",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
|
||||
@@ -68,7 +68,7 @@ export class Panel {
|
||||
this.#i18n = new I18n(config.language ?? 'en-US')
|
||||
|
||||
// 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
|
||||
this.#wrapper = this.#createWrapper()
|
||||
@@ -100,10 +100,10 @@ export class Panel {
|
||||
#handleStatusChange(): void {
|
||||
const status = this.#agent.status
|
||||
|
||||
// Map agent status to UI indicator type
|
||||
const indicatorType =
|
||||
status === 'running' ? 'thinking' : status === 'idle' ? 'thinking' : status
|
||||
this.#updateStatusIndicator(indicatorType)
|
||||
// Map agent status to UI indicator. A `completed` run whose result reports
|
||||
// failure shows as error; other statuses map to their own indicator.
|
||||
const failed = status === 'completed' && this.#agent.lastResult?.success === false
|
||||
this.#updateStatusIndicator(failed ? 'error' : status)
|
||||
|
||||
// Morph action button: running = stop (■), not running = close (X)
|
||||
if (status === 'running') {
|
||||
@@ -121,7 +121,7 @@ export class Panel {
|
||||
}
|
||||
|
||||
// Handle completion
|
||||
if (status === 'completed' || status === 'error') {
|
||||
if (status === 'completed' || status === 'error' || status === 'stopped') {
|
||||
if (!this.#isExpanded) {
|
||||
this.#expand()
|
||||
}
|
||||
@@ -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> {
|
||||
return new Promise((resolve) => {
|
||||
#askUser(question: string, signal?: AbortSignal): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Set `waiting for user answer` state
|
||||
this.#isWaitingForUserAnswer = true
|
||||
this.#userAnswerResolver = resolve
|
||||
@@ -195,6 +197,27 @@ export class Panel {
|
||||
this.#scrollToBottom()
|
||||
|
||||
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
|
||||
*/
|
||||
#handleUserAnswer(input: string): void {
|
||||
// Remove temporary question cards (only direct children for safety)
|
||||
Array.from(this.#historySection.children).forEach((child) => {
|
||||
if (child.getAttribute('data-temp-card') === 'true') {
|
||||
child.remove()
|
||||
}
|
||||
})
|
||||
this.#removeTempCards()
|
||||
|
||||
// Reset state
|
||||
this.#isWaitingForUserAnswer = false
|
||||
@@ -358,7 +376,7 @@ export class Panel {
|
||||
}
|
||||
|
||||
const status = this.#agent.status
|
||||
const isTaskEnded = status === 'completed' || status === 'error'
|
||||
const isTaskEnded = status === 'completed' || status === 'error' || status === 'stopped'
|
||||
|
||||
// Only show input area after task completion if configured to do so
|
||||
if (isTaskEnded) {
|
||||
@@ -541,13 +559,23 @@ export class Panel {
|
||||
}
|
||||
|
||||
#updateStatusIndicator(
|
||||
type: 'thinking' | 'executing' | 'executed' | 'retrying' | 'completed' | 'error'
|
||||
type:
|
||||
| 'idle'
|
||||
| 'running'
|
||||
| 'thinking'
|
||||
| 'executing'
|
||||
| 'executed'
|
||||
| 'retrying'
|
||||
| 'completed'
|
||||
| 'error'
|
||||
| 'stopped'
|
||||
): void {
|
||||
// Clear all status classes
|
||||
// `running` animates like thinking; `idle`/`stopped` use the neutral base.
|
||||
const variant = type === 'running' ? 'thinking' : type
|
||||
this.#indicator.className = styles.indicator
|
||||
|
||||
// Add corresponding status class
|
||||
this.#indicator.classList.add(styles[type])
|
||||
if (variant !== 'idle' && variant !== 'stopped') {
|
||||
this.#indicator.classList.add(styles[variant])
|
||||
}
|
||||
}
|
||||
|
||||
#scrollToBottom(): void {
|
||||
|
||||
@@ -22,14 +22,17 @@ export type AgentActivity =
|
||||
* This enables decoupling and allows any agent implementation to work with Panel.
|
||||
*
|
||||
* Events:
|
||||
* - 'statuschange': Agent status changed (idle/running/completed/error)
|
||||
* - 'statuschange': Agent status changed
|
||||
* - 'historychange': Historical events updated (persisted)
|
||||
* - 'activity': Transient activity for immediate UI feedback (thinking/executing/etc)
|
||||
* - 'dispose': Agent is being disposed
|
||||
*/
|
||||
export interface PanelAgentAdapter extends EventTarget {
|
||||
/** Current agent status */
|
||||
readonly status: 'idle' | 'running' | 'completed' | 'error'
|
||||
readonly status: 'idle' | 'running' | 'completed' | 'error' | 'stopped'
|
||||
|
||||
/** Result of the most recent run, or `null` before the first run completes */
|
||||
readonly lastResult: { success: boolean } | null
|
||||
|
||||
/** History of agent events */
|
||||
readonly history: readonly {
|
||||
@@ -60,16 +63,18 @@ export interface PanelAgentAdapter extends EventTarget {
|
||||
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.
|
||||
* 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(task: string): Promise<unknown>
|
||||
|
||||
/** Stop the current task (agent remains reusable) */
|
||||
stop(): void
|
||||
stop(): Promise<void>
|
||||
|
||||
/** Dispose the agent (terminal, cannot be reused) */
|
||||
dispose(): void
|
||||
|
||||
@@ -14,6 +14,8 @@ export default defineConfig({
|
||||
clearScreen: false,
|
||||
plugins: [
|
||||
dts({
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/**/*.test.ts'],
|
||||
bundleTypes: true,
|
||||
compilerOptions: {
|
||||
composite: true,
|
||||
|
||||
@@ -49,10 +49,34 @@
|
||||
<div id="sk">
|
||||
<p class="sk-text" id="sk-text">Loading...</p>
|
||||
<style>
|
||||
#sk{display:flex;align-items:center;justify-content:center;min-height:100vh;background:linear-gradient(135deg,#eff6ff,#f5f3ff)}
|
||||
@media(prefers-color-scheme:dark){#sk{background:linear-gradient(135deg,#111827,#1f2937)}}
|
||||
.sk-text{font:400 14px/1 system-ui,sans-serif;color:#94a3b8;animation:skf 2s ease-in-out infinite}
|
||||
@keyframes skf{0%,100%{opacity:.6}50%{opacity:.3}}
|
||||
#sk {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #eff6ff, #f5f3ff);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#sk {
|
||||
background: linear-gradient(135deg, #111827, #1f2937);
|
||||
}
|
||||
}
|
||||
.sk-text {
|
||||
font:
|
||||
400 14px/1 system-ui,
|
||||
sans-serif;
|
||||
color: #94a3b8;
|
||||
animation: skf 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes skf {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@page-agent/website",
|
||||
"private": true,
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
@@ -10,25 +10,25 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-separator": "^1.1.9",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@types/react": "^19.2.14",
|
||||
"@radix-ui/react-switch": "^1.3.0",
|
||||
"@radix-ui/react-tooltip": "^1.2.9",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"motion": "^12.38.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"motion": "^12.40.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"rough-notation": "^0.5.1",
|
||||
"simple-icons": "^16.16.0",
|
||||
"simple-icons": "^16.23.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"wouter": "^3.9.0"
|
||||
"wouter": "^3.10.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ComponentPropsWithoutRef, useEffect, useRef } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Level = 2 | 3
|
||||
type Level = 2 | 3 | 4
|
||||
|
||||
interface HeadingProps extends Omit<ComponentPropsWithoutRef<'h2'>, 'children'> {
|
||||
id: string
|
||||
@@ -11,8 +11,9 @@ interface HeadingProps extends Omit<ComponentPropsWithoutRef<'h2'>, 'children'>
|
||||
}
|
||||
|
||||
const levelStyles = {
|
||||
2: { tag: 'h2', className: 'text-2xl font-semibold mb-4' },
|
||||
3: { tag: 'h3', className: 'text-xl font-semibold mb-3' },
|
||||
2: { tag: 'h2', className: 'text-3xl font-semibold mb-4' },
|
||||
3: { tag: 'h3', className: 'text-[1.375rem] font-semibold mb-3' },
|
||||
4: { tag: 'h4', className: 'text-[1.0625rem] font-medium mb-2' },
|
||||
} as const
|
||||
|
||||
export function Heading({ id, level = 2, className, children, ...props }: HeadingProps) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Demo build (auto-init with demo LLM, for quick testing)
|
||||
export const CDN_DEMO_URL =
|
||||
'https://cdn.jsdelivr.net/npm/page-agent@1.8.0/dist/iife/page-agent.demo.js'
|
||||
'https://cdn.jsdelivr.net/npm/page-agent@1.9.1/dist/iife/page-agent.demo.js'
|
||||
export const CDN_DEMO_CN_URL =
|
||||
'https://registry.npmmirror.com/page-agent/1.8.0/files/dist/iife/page-agent.demo.js'
|
||||
'https://registry.npmmirror.com/page-agent/1.9.1/files/dist/iife/page-agent.demo.js'
|
||||
|
||||
// Demo LLM for website testing (homepage quick trial uses flash)
|
||||
export const DEMO_MODEL = 'qwen3.5-flash'
|
||||
|
||||
@@ -95,7 +95,7 @@ body {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 标题使用中等字重(相对细体更重,但比默认 bold 更轻) */
|
||||
/* 文档标题层级需要比正文标题更清晰,避免 h2/h3/h4 视觉差异过小。 */
|
||||
.prose h1,
|
||||
.prose h2,
|
||||
.prose h3,
|
||||
@@ -103,6 +103,33 @@ body {
|
||||
.prose h5,
|
||||
.prose h6 {
|
||||
font-weight: 480;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.prose h2[id] {
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.45rem;
|
||||
border-bottom: 1px solid rgba(23, 23, 23, 0.12);
|
||||
font-size: 1.875rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.prose h3[id] {
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 1.375rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose h4[id] {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 560;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
.dark .prose h2[id] {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
/* strong/b 也用中等字重 */
|
||||
|
||||
@@ -22,7 +22,8 @@ interface NavSection {
|
||||
|
||||
export default function DocsLayout({ children }: DocsLayoutProps) {
|
||||
const { isZh } = useLanguage()
|
||||
const [location] = useLocation()
|
||||
const [rawLocation] = useLocation()
|
||||
const location = rawLocation.replace(/\/+$/, '')
|
||||
|
||||
const navigationSections: NavSection[] = [
|
||||
{
|
||||
|
||||
@@ -128,8 +128,8 @@ export default function CustomUIDocs() {
|
||||
name: 'statuschange',
|
||||
type: 'Event',
|
||||
description: isZh
|
||||
? 'Agent 状态变化 (idle → running → completed/error)'
|
||||
: 'Agent status changes (idle → running → completed/error)',
|
||||
? 'Agent 状态变化 (idle → running → completed/error/stopped)'
|
||||
: 'Agent status changes (idle → running → completed/error/stopped)',
|
||||
},
|
||||
{
|
||||
name: 'historychange',
|
||||
@@ -257,7 +257,8 @@ const root = createRoot(document.getElementById('my-ui')!)
|
||||
root.render(<MyAgentUI agent={agent} />)
|
||||
|
||||
// 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
|
||||
await agent.execute('Fill the form with test data')
|
||||
|
||||
@@ -156,6 +156,34 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
defaultValue: '3',
|
||||
description: isZh ? 'API 调用失败时的最大重试次数' : 'Maximum retries on API failure',
|
||||
},
|
||||
{
|
||||
name: 'transformRequestBody',
|
||||
type: '(requestBody) => Record<string, unknown> | undefined',
|
||||
description: isZh ? (
|
||||
<>
|
||||
在请求发送前转换最终 request body。可用于处理供应商特定的缓存提示或私有参数。查看{' '}
|
||||
<Link
|
||||
href="/features/models#prompt-caching"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
主动缓存示例
|
||||
</Link>
|
||||
。
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Transform the final request body before sending it. Useful for provider-specific
|
||||
cache hints or private request parameters. See{' '}
|
||||
<Link
|
||||
href="/features/models#prompt-caching"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
prompt caching examples
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'disableNamedToolChoice',
|
||||
type: 'boolean',
|
||||
@@ -253,8 +281,8 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-4">
|
||||
<p className="text-amber-800 dark:text-amber-200 text-sm">
|
||||
{isZh
|
||||
? '这些接口高度实验性,可能在未来版本中发生变化。'
|
||||
: 'These APIs are highly experimental and may change in future versions. '}
|
||||
? '这些接口高度实验性,可能在未来版本中发生变化。钩子中抛出的错误会使任务失败并从 execute() 抛出;如不希望影响任务,请在钩子内部自行捕获。'
|
||||
: 'These APIs are highly experimental and may change in future versions. Errors thrown from hooks fail the run and propagate from execute(); catch errors inside the hook if the task should not be affected.'}
|
||||
</p>
|
||||
</div>
|
||||
<APIReference
|
||||
@@ -297,7 +325,7 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
properties={[
|
||||
{
|
||||
name: 'status',
|
||||
type: "'idle' | 'running' | 'completed' | 'error'",
|
||||
type: "'idle' | 'running' | 'completed' | 'error' | 'stopped'",
|
||||
description: isZh ? '当前 Agent 执行状态' : 'Current agent execution status',
|
||||
},
|
||||
{
|
||||
@@ -326,10 +354,10 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
},
|
||||
{
|
||||
name: 'onAskUser',
|
||||
type: '(question: string) => Promise<string>',
|
||||
type: '(question: string, options?: { signal: AbortSignal }) => Promise<string>',
|
||||
description: isZh
|
||||
? 'Agent 需要用户输入时的回调。未设置则禁用 ask_user 工具。'
|
||||
: 'Callback when agent needs user input. If not set, ask_user tool is disabled.',
|
||||
? '当 agent 需要向用户提问时调用。未设置则禁用 `ask_user` 工具。实现应在 options.signal 触发 abort 时 reject promise。'
|
||||
: 'Called when the agent needs to ask the user questions. If unset, the `ask_user` tool will be disabled. Implementations should reject the promise when options.signal aborts.',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -345,15 +373,15 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
name: 'execute(task)',
|
||||
type: 'Promise<ExecutionResult>',
|
||||
description: isZh
|
||||
? '执行任务并返回结果。包含 success、data 和 history 字段。'
|
||||
: 'Execute a task and return result. Contains success, data, and history fields.',
|
||||
? '执行任务并返回结果(包含 success、data 和 history 字段)。若已有任务在运行则抛出错误——不支持并发执行。'
|
||||
: '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()',
|
||||
type: 'void',
|
||||
type: 'Promise<void>',
|
||||
description: isZh
|
||||
? '停止当前任务。Agent 仍可复用。'
|
||||
: 'Stop the current task. Agent remains reusable.',
|
||||
? '停止当前任务,并在任务完全结束后 resolve。Agent 仍可复用。'
|
||||
: 'Stop the current task; resolves once the run has fully settled. Agent remains reusable.',
|
||||
},
|
||||
{
|
||||
name: 'dispose()',
|
||||
@@ -387,8 +415,8 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
name: 'statuschange',
|
||||
type: 'Event',
|
||||
description: isZh
|
||||
? 'Agent 状态变化时触发 (idle → running → completed/error)'
|
||||
: 'Fired when agent status changes (idle → running → completed/error)',
|
||||
? 'Agent 状态变化时触发 (idle → running → completed/error/stopped)'
|
||||
: 'Fired when agent status changes (idle → running → completed/error/stopped)',
|
||||
},
|
||||
{
|
||||
name: 'historychange',
|
||||
|
||||
@@ -38,8 +38,8 @@ import { z } from 'zod/v4'`}
|
||||
</Heading>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
{isZh
|
||||
? '使用 tool() 辅助函数定义自定义工具,每个工具包含 description、inputSchema 和 execute 三个属性。'
|
||||
: 'Use the tool() helper to define custom tools with description, inputSchema, and execute.'}
|
||||
? '使用 tool() 辅助函数定义自定义工具,每个工具包含 description、inputSchema 和 execute 三个属性。异步工具必须 honor ctx.signal。'
|
||||
: 'Use the tool() helper to define custom tools with description, inputSchema, and execute. Async tools must honor ctx.signal.'}
|
||||
</p>
|
||||
|
||||
<CodeEditor
|
||||
@@ -56,10 +56,11 @@ const pageAgent = new PageAgent({
|
||||
productId: z.string(),
|
||||
quantity: z.number().min(1).default(1),
|
||||
}),
|
||||
execute: async function (input) {
|
||||
execute: async function (input, { signal }) {
|
||||
await fetch('/api/cart', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
signal, // honor cancellation
|
||||
})
|
||||
return \`Added \${input.quantity}x \${input.productId} to cart.\`
|
||||
},
|
||||
@@ -72,9 +73,10 @@ const pageAgent = new PageAgent({
|
||||
query: z.string(),
|
||||
limit: z.number().max(10).default(3),
|
||||
}),
|
||||
execute: async function (input) {
|
||||
execute: async function (input, { signal }) {
|
||||
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()
|
||||
return JSON.stringify(articles)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Fragment } from 'react'
|
||||
import { Link } from 'wouter'
|
||||
|
||||
import CodeEditor from '@/components/CodeEditor'
|
||||
import { Heading } from '@/components/Heading'
|
||||
@@ -6,9 +7,10 @@ import { useLanguage } from '@/i18n/context'
|
||||
|
||||
const BASELINE = new Set([
|
||||
'gpt-5.1',
|
||||
'gpt-5.4-mini',
|
||||
'claude-haiku-4.5',
|
||||
'gemini-3-flash',
|
||||
'deepseek-3.2',
|
||||
'gemini-3.5-flash',
|
||||
'deepseek-v4-flash',
|
||||
'qwen3.5-plus',
|
||||
'qwen3.5-flash',
|
||||
])
|
||||
@@ -16,17 +18,40 @@ const BASELINE = new Set([
|
||||
// Models grouped by brand, newest first
|
||||
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-coder-next',
|
||||
'qwen-3-max',
|
||||
'qwen-3-plus',
|
||||
],
|
||||
OpenAI: ['gpt-5.4', 'gpt-5.2', 'gpt-5.1', 'gpt-5', 'gpt-5-mini', 'gpt-4.1', 'gpt-4.1-mini'],
|
||||
DeepSeek: ['deepseek-3.2'],
|
||||
Google: ['gemini-3-pro', 'gemini-3-flash', 'gemini-2.5'],
|
||||
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-flash-lite',
|
||||
'gemini-3-pro',
|
||||
'gemini-3-flash',
|
||||
'gemini-2.5',
|
||||
],
|
||||
Anthropic: [
|
||||
'claude-opus-4.8',
|
||||
'claude-opus-4.7',
|
||||
'claude-opus-4.6',
|
||||
'claude-opus-4.5',
|
||||
'claude-sonnet-4.5',
|
||||
@@ -124,46 +149,6 @@ const pageAgent = new PageAgent({
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="mb-10">
|
||||
<Heading id="production-authentication" className="text-2xl font-semibold mb-4">
|
||||
{isZh ? '🔐 生产环境鉴权' : '🔐 Production Authentication'}
|
||||
</Heading>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
{isZh
|
||||
? '如果你只是将它用作个人助手,可以直接连接你的 LLM 服务。'
|
||||
: 'If you only use it as a personal assistant, you can connect to your LLM service directly.'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
{isZh ? (
|
||||
<>
|
||||
如果你计划将它集成到你的 Web 应用中,建议搭建一个后端代理来转发 LLM 请求,并使用{' '}
|
||||
<code>customFetch</code> 携带 Cookie 或其他鉴权信息:
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
If you plan to integrate it into your web app, it's better to have a backend proxy for
|
||||
the LLM and use <code>customFetch</code> to authenticate the request with cookies or
|
||||
other methods:
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<CodeEditor
|
||||
code={`const agent = new PageAgent({
|
||||
baseURL: '/api/llm-proxy',
|
||||
model: 'gpt-5.1',
|
||||
customFetch: (url, init) =>
|
||||
fetch(url, { ...init, credentials: 'include' }),
|
||||
});`}
|
||||
/>
|
||||
<div className="mt-4 bg-yellow-50 dark:bg-yellow-950/20 border-l-4 border-yellow-500 p-4 rounded-r-lg">
|
||||
<p className="text-sm font-semibold text-yellow-900 dark:text-yellow-200">
|
||||
{isZh
|
||||
? '⚠️ 永远不要把真实的 LLM API Key 提交到前端代码中'
|
||||
: '⚠️ NEVER commit real LLM API keys to your frontend code'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-10">
|
||||
<Heading id="free-testing-api">{isZh ? '免费测试接口' : 'Free Testing API'}</Heading>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
@@ -213,7 +198,139 @@ LLM_MODEL_NAME="qwen3.5-plus"`}
|
||||
</section>
|
||||
|
||||
<section className="mb-10">
|
||||
<Heading id="local-runtimes">{isZh ? '本地运行时' : 'Local Runtimes'}</Heading>
|
||||
<Heading id="production-authentication" className="text-2xl font-semibold mb-4">
|
||||
{isZh ? '🔐 生产环境鉴权' : '🔐 Production Authentication'}
|
||||
</Heading>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
{isZh
|
||||
? '如果你只是将它用作个人助手,可以直接连接你的 LLM 服务。'
|
||||
: 'If you only use it as a personal assistant, you can connect to your LLM service directly.'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
{isZh ? (
|
||||
<>
|
||||
如果你计划将它集成到你的 Web 应用中,建议搭建一个后端代理来转发 LLM 请求,并使用{' '}
|
||||
<code>customFetch</code> 携带 Cookie 或其他鉴权信息:
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
If you plan to integrate it into your web app, it's better to have a backend proxy for
|
||||
the LLM and use <code>customFetch</code> to authenticate the request with cookies or
|
||||
other methods:
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<CodeEditor
|
||||
code={`const agent = new PageAgent({
|
||||
baseURL: '/api/llm-proxy',
|
||||
model: 'gpt-5.1',
|
||||
customFetch: (url, init) =>
|
||||
fetch(url, { ...init, credentials: 'include' }),
|
||||
});`}
|
||||
/>
|
||||
<div className="mt-4 bg-yellow-50 dark:bg-yellow-950/20 border-l-4 border-yellow-500 p-4 rounded-r-lg">
|
||||
<p className="text-sm font-semibold text-yellow-900 dark:text-yellow-200">
|
||||
{isZh
|
||||
? '⚠️ 永远不要把真实的 LLM API Key 提交到前端代码中'
|
||||
: '⚠️ NEVER commit real LLM API keys to your frontend code'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-10">
|
||||
<Heading id="prompt-caching">{isZh ? '主动缓存' : 'Prompt Caching'}</Heading>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
{isZh ? (
|
||||
<>
|
||||
一些 LLM 能从主动缓存中受益很多。由于各个供应商的主动缓存接口不同,推荐使用{' '}
|
||||
<Link
|
||||
href="/advanced/page-agent-core#configuration"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
transformRequestBody
|
||||
</Link>{' '}
|
||||
为你的模型供应商配置缓存提示。
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Some LLMs benefit significantly from prompt caching. Because each provider exposes
|
||||
caching differently, use{' '}
|
||||
<Link
|
||||
href="/advanced/page-agent-core#configuration"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
transformRequestBody
|
||||
</Link>{' '}
|
||||
to add provider-specific cache hints.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<Heading id="prompt-caching-claude" level={3}>
|
||||
Claude
|
||||
</Heading>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
{isZh
|
||||
? 'Claude 支持全局 Automatic prompt caching。使用兼容 Claude 的代理时,只需要在请求体顶层添加 cache_control。'
|
||||
: 'Claude supports global Automatic prompt caching. When using a Claude-compatible proxy, add cache_control at the top level of the request body.'}
|
||||
</p>
|
||||
<CodeEditor
|
||||
language="typescript"
|
||||
code={`const pageAgent = new PageAgent({
|
||||
baseURL: 'https://your-claude-proxy.example/v1',
|
||||
apiKey: 'your-api-key',
|
||||
model: 'claude-sonnet-4.5',
|
||||
transformRequestBody: (requestBody) => ({
|
||||
...requestBody,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
}),
|
||||
});`}
|
||||
/>
|
||||
</section>
|
||||
<section>
|
||||
<Heading id="prompt-caching-qwen" level={3}>
|
||||
{isZh ? '阿里云百炼 Qwen' : 'Alibaba Cloud Bailian Qwen'}
|
||||
</Heading>
|
||||
<CodeEditor
|
||||
language="typescript"
|
||||
code={`const pageAgent = new PageAgent({
|
||||
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
apiKey: 'your-api-key',
|
||||
model: 'qwen3.5-plus',
|
||||
transformRequestBody: (requestBody) => {
|
||||
const [systemMessage, ...restMessages] = requestBody.messages
|
||||
|
||||
if (systemMessage.role !== 'system' || typeof systemMessage.content !== 'string') {
|
||||
return requestBody
|
||||
}
|
||||
|
||||
return {
|
||||
...requestBody,
|
||||
messages: [
|
||||
{
|
||||
...systemMessage,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: systemMessage.content,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
},
|
||||
...restMessages,
|
||||
],
|
||||
}
|
||||
},
|
||||
});`}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-10">
|
||||
<Heading id="local-runtimes">{isZh ? '本地 LLMs' : 'Local LLMs'}</Heading>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
{isZh
|
||||
? '通过 Ollama、LM Studio 等本地 OpenAI-compatible 运行时接入 PageAgent,实现离线或局域网部署。'
|
||||
|
||||
@@ -58,6 +58,11 @@ export default function QuickStart() {
|
||||
code={`<script src="DEMO_CDN_URL" crossorigin="true"></script>`}
|
||||
language="html"
|
||||
/>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
|
||||
{isZh
|
||||
? '在 URL 后添加 ?autoInit=false 可只加载脚本,不自动创建 Demo Agent;之后可通过 new window.PageAgent(...) 手动初始化。'
|
||||
: 'Add ?autoInit=false to load the script without creating the demo agent automatically. You can then instantiate it with new window.PageAgent(...).'}
|
||||
</p>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
|
||||
@@ -77,9 +77,7 @@ export default function HeroSection() {
|
||||
instructions: {
|
||||
system: 'You are a helpful assistant on PageAgent website.',
|
||||
getPageInstructions: (url: string) => {
|
||||
const hint = url.includes('page-agent') ? 'This is PageAgent demo page.' : undefined
|
||||
console.log('[instructions] getPageInstructions:', url, '->', hint)
|
||||
return hint
|
||||
return url.includes('page-agent') ? 'This is PageAgent demo page.' : undefined
|
||||
},
|
||||
},
|
||||
|
||||
@@ -98,8 +96,7 @@ export default function HeroSection() {
|
||||
})
|
||||
}
|
||||
|
||||
const result = await win.pageAgent.execute(task)
|
||||
console.log(result)
|
||||
await win.pageAgent.execute(task)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import process from 'node:process'
|
||||
@@ -82,8 +82,10 @@ export default defineConfig(({ mode }) => ({
|
||||
handler(message)
|
||||
},
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['react', 'react-dom', 'wouter'],
|
||||
manualChunks(id) {
|
||||
if (/[\\/]node_modules[\\/](react|react-dom|wouter)([\\/]|$)/.test(id)) {
|
||||
return 'vendor'
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Equivalent to: npm run build --workspaces --if-present
|
||||
*
|
||||
* Reads the workspace list from root package.json, filters to those with a
|
||||
* "build" script, and runs them all concurrently via parallelTask.
|
||||
*/
|
||||
import { readFileSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { parallelTask } from './parallel-task.js'
|
||||
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const rootPkg = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf-8'))
|
||||
|
||||
const tasks = rootPkg.workspaces
|
||||
.map((ws) => {
|
||||
const dir = join(rootDir, ws)
|
||||
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8'))
|
||||
return pkg.scripts?.build ? { label: pkg.name, command: 'npm run build', cwd: dir } : null
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
await parallelTask(tasks, { timeoutMs: 120_000 })
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Full build pipeline. Equivalent to:
|
||||
* npm run cleanup && npm run build --workspaces --if-present
|
||||
* && npm run build:website -w @page-agent/website
|
||||
* && npm run zip -w @page-agent/ext
|
||||
*
|
||||
* 1. cleanup
|
||||
* 2. build everything in parallel (libs + website + extension)
|
||||
*/
|
||||
import chalk from 'chalk'
|
||||
import { execSync } from 'child_process'
|
||||
import { readFileSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { parallelTask } from './parallel-task.js'
|
||||
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const rootPkg = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf-8'))
|
||||
|
||||
// Step 1: cleanup
|
||||
console.log(chalk.bgBlue.white.bold(' ▸ cleanup '))
|
||||
execSync('npm run cleanup', { cwd: rootDir, stdio: 'inherit' })
|
||||
|
||||
// Step 2: build all in parallel
|
||||
console.log(chalk.bgBlue.white.bold(' ▸ build '))
|
||||
const tasks = rootPkg.workspaces
|
||||
.map((ws) => {
|
||||
const dir = join(rootDir, ws)
|
||||
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8'))
|
||||
return pkg.scripts?.build ? { label: pkg.name, command: 'npm run build', cwd: dir } : null
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
tasks.push(
|
||||
{
|
||||
label: '@page-agent/website',
|
||||
command: 'npm run build:website',
|
||||
cwd: join(rootDir, 'packages/website'),
|
||||
},
|
||||
{ label: '@page-agent/ext', command: 'npm run zip', cwd: join(rootDir, 'packages/extension') }
|
||||
)
|
||||
|
||||
await parallelTask(tasks, { timeoutMs: 120_000 })
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CI check script. Run locally before commit or in GitHub Actions.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/ci.js # run all checks
|
||||
* node scripts/ci.js --no-build # skip build step
|
||||
*/
|
||||
import chalk from 'chalk'
|
||||
import { execSync } from 'child_process'
|
||||
|
||||
import { parallelTask } from './parallel-task.js'
|
||||
|
||||
const args = new Set(process.argv.slice(2))
|
||||
const skipBuild = args.has('--no-build')
|
||||
|
||||
function run(label, command) {
|
||||
console.log(chalk.bgBlue.white.bold(` ▸ ${label} `))
|
||||
execSync(command, { stdio: 'inherit' })
|
||||
}
|
||||
|
||||
function isMainBranch() {
|
||||
if (process.env.GITHUB_REF) return process.env.GITHUB_REF === 'refs/heads/main'
|
||||
try {
|
||||
return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim() === 'main'
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Commitlint — skip on main
|
||||
if (isMainBranch()) {
|
||||
console.log(chalk.dim(' ▸ commitlint (skipped on main)'))
|
||||
} else {
|
||||
const from = execSync('git merge-base origin/main HEAD', { encoding: 'utf-8' }).trim()
|
||||
run('commitlint', `npx commitlint --from ${from} --to HEAD`)
|
||||
}
|
||||
|
||||
// 2. Lint + Format + Typecheck + Test in parallel
|
||||
console.log(chalk.bgBlue.white.bold(' ▸ lint + format + typecheck + test '))
|
||||
await parallelTask(
|
||||
[
|
||||
{ label: 'lint', command: 'npm run lint' },
|
||||
{ label: 'format', command: 'npx prettier --check .' },
|
||||
{ label: 'typecheck', command: 'npm run typecheck' },
|
||||
{ label: 'test', command: 'npm test' },
|
||||
],
|
||||
{ timeoutMs: 120_000 }
|
||||
)
|
||||
|
||||
// 3. Build
|
||||
if (skipBuild) {
|
||||
console.log(chalk.dim(' ▸ build (skipped)'))
|
||||
} else {
|
||||
run('build', 'npm run build')
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import chalk from 'chalk'
|
||||
import { spawn } from 'child_process'
|
||||
|
||||
/**
|
||||
* Run multiple shell commands in parallel with progress reporting.
|
||||
*
|
||||
* @param {{ label: string, command: string, cwd?: string }[]} tasks
|
||||
* @param {{ timeoutMs?: number }} options - Default timeout 30s per task
|
||||
* @returns {Promise<void>} Rejects (process.exit) if any task fails
|
||||
*/
|
||||
export async function parallelTask(tasks, options = {}) {
|
||||
const { timeoutMs = 30_000 } = options
|
||||
const total = tasks.length
|
||||
|
||||
const bgColors = [
|
||||
chalk.bgCyan,
|
||||
chalk.bgMagenta,
|
||||
chalk.bgBlue,
|
||||
chalk.bgYellow,
|
||||
chalk.bgGreenBright,
|
||||
]
|
||||
const fgOnBg = [chalk.black, chalk.white, chalk.white, chalk.black, chalk.black]
|
||||
|
||||
let done = 0
|
||||
let failed = 0
|
||||
|
||||
const spinner = ['◐', '◓', '◑', '◒']
|
||||
let tick = 0
|
||||
|
||||
const printProgress = () => {
|
||||
const running = total - done - failed
|
||||
const s = spinner[tick++ % spinner.length]
|
||||
const status = failed
|
||||
? `${running} running, ${done} done, ${chalk.bgRed.white.bold(` ${failed} failed `)}`
|
||||
: `${running} running, ${done} done`
|
||||
process.stderr.write(`\r${chalk.bgCyan.black.bold(` ${s} ${done}/${total} `)} ${status} `)
|
||||
}
|
||||
|
||||
const timer = setInterval(printProgress, 1000)
|
||||
printProgress()
|
||||
|
||||
/** @type {{ label: string, output: string, exitCode: number | null, timedOut: boolean }[]} */
|
||||
const results = await Promise.all(
|
||||
tasks.map(
|
||||
(task) =>
|
||||
new Promise((resolve) => {
|
||||
const chunks = /** @type {Buffer[]} */ ([])
|
||||
|
||||
const child = spawn('sh', ['-c', task.command], {
|
||||
cwd: task.cwd,
|
||||
env: { ...process.env, FORCE_COLOR: '1', NO_COLOR: '' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
child.stdout.on('data', (d) => chunks.push(d))
|
||||
child.stderr.on('data', (d) => chunks.push(d))
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill('SIGTERM')
|
||||
}, timeoutMs)
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
clearTimeout(timeout)
|
||||
const timedOut = signal === 'SIGTERM'
|
||||
const exitCode = timedOut ? 1 : code
|
||||
|
||||
if (exitCode === 0) done++
|
||||
else failed++
|
||||
|
||||
resolve({
|
||||
label: task.label,
|
||||
output: Buffer.concat(chunks).toString(),
|
||||
exitCode,
|
||||
timedOut,
|
||||
})
|
||||
})
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
clearInterval(timer)
|
||||
process.stderr.write('\r\x1b[K')
|
||||
|
||||
const failedTasks = /** @type {typeof results} */ ([])
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const r = results[i]
|
||||
if (r.exitCode !== 0) {
|
||||
failedTasks.push(r)
|
||||
continue
|
||||
}
|
||||
const bg = bgColors[i % bgColors.length]
|
||||
const fg = fgOnBg[i % fgOnBg.length]
|
||||
const banner = bg(fg.bold(` ✔ ${r.label} `))
|
||||
console.log(`\n${banner}`)
|
||||
if (r.output.trim()) process.stdout.write(r.output)
|
||||
}
|
||||
|
||||
if (failedTasks.length) {
|
||||
for (const r of failedTasks) {
|
||||
const banner = chalk.bgRed(
|
||||
chalk.white.bold(` ✘ ${r.label} ${r.timedOut ? '· timed out' : '· failed'} `)
|
||||
)
|
||||
console.log(`\n${banner}`)
|
||||
if (r.output.trim()) process.stdout.write(r.output)
|
||||
}
|
||||
const summary = failedTasks.map((t) => t.label).join(', ')
|
||||
console.error(
|
||||
`\n${chalk.bgRed.white.bold(` ✘ ${failedTasks.length}/${total} failed: ${summary} `)}`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`\n${chalk.bgGreen.black.bold(` ✔ All ${total} tasks completed `)}`)
|
||||
}
|
||||
Reference in New Issue
Block a user