Compare commits
162 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa4664dfa5 | |||
| 4122735bba | |||
| 1cab66b898 | |||
| d5c994708c | |||
| 4c76a5f1d2 | |||
| e8c8ab0d78 | |||
| 9705fb964e | |||
| 29fee0012a | |||
| 33dcf67252 | |||
| ab7e9e9a89 | |||
| d77ac0322d | |||
| 3162ac5b45 | |||
| 2c67f3498b | |||
| 7ee27b4962 | |||
| cad49d5d1f | |||
| 35ff6d44fe | |||
| 9609592be3 | |||
| 08624ec245 | |||
| 8bc2483ab5 | |||
| 68245e8b68 | |||
| f73831036d | |||
| f8af56032a | |||
| 60fe4c4c84 | |||
| 4ce6d9e5c7 | |||
| 9a75ead6ad | |||
| 10276c6e67 | |||
| 5aeb67f6d6 | |||
| 556bc7448a | |||
| 04b84b6e8a | |||
| 2163a55cc7 | |||
| a47e12c0e0 | |||
| d1c3dc811e | |||
| 8ad719744a | |||
| 83fee37fb4 | |||
| b41535ef5e | |||
| 8ad1174f20 | |||
| 026171715c | |||
| 8b902e1e10 | |||
| 1f45e74378 | |||
| 585e7a4900 | |||
| 04a397c19e | |||
| ae4276c624 | |||
| 7579def667 | |||
| 187d5deccb | |||
| fb221b4535 | |||
| 5058c96a78 | |||
| 2b679d85cb | |||
| 57f64347ac | |||
| fb4b8fd067 | |||
| 2bed20cd85 | |||
| c48c7c524e | |||
| 32f27e73e3 | |||
| 71738f1966 | |||
| e44455ba80 | |||
| d2ee260d82 | |||
| 1f738bc41c | |||
| 2802ab3dea | |||
| e6a2e07640 | |||
| 235fa1bc26 | |||
| c04c02723d | |||
| 77468e4ab4 | |||
| 7f9cb61fc2 | |||
| 76fea5b285 | |||
| a796453c6e | |||
| a1b0dd90eb | |||
| fb8c42daa4 | |||
| 8eaa784a8a | |||
| 7dffaa4718 | |||
| 0f3f639497 | |||
| 67b1874836 | |||
| 56c09a9ae9 | |||
| e65c7c9601 | |||
| a646da80f5 | |||
| d3797109bf | |||
| 4e881f7eb0 | |||
| 768b07e28c | |||
| 4828f9f726 | |||
| edb769b826 | |||
| e270ba15b5 | |||
| c19891926b | |||
| 0438bf6265 | |||
| 255e8fc861 | |||
| 4690aefec5 | |||
| 8f9a637bdb | |||
| c2d6a864f8 | |||
| 1deaa2147e | |||
| 052a302a08 | |||
| 9ed0a09194 | |||
| 73810b3ed8 | |||
| 01c9cc9ea7 | |||
| a7beabbbe2 | |||
| b3ff0a1a25 | |||
| daae28db34 | |||
| 797c238862 | |||
| 270791bb54 | |||
| 8ec460f431 | |||
| 8c3a5f8188 | |||
| 4ee773bef9 | |||
| 3a2f0a41ec | |||
| 64bb1f1a56 | |||
| 2afcf13fbb | |||
| f617728fc3 | |||
| 9376abfe2a | |||
| 2f343bcc56 | |||
| 4108f67079 | |||
| 0ba3bbd67f | |||
| 552987eb56 | |||
| 99ef08ac79 | |||
| 4445bec08a | |||
| 6530f0ef40 | |||
| f8676a5cc2 | |||
| 7b623c5b22 | |||
| 029f64e4bc | |||
| 78b6e2ad3c | |||
| 112f9fff39 | |||
| 1c82030a21 | |||
| f378972ef8 | |||
| 68ae73d4e6 | |||
| 874b302860 | |||
| 90ff96ca15 | |||
| 3c0d55fa33 | |||
| 9891e02215 | |||
| 823195ad94 | |||
| fd12fb9f1b | |||
| fb8de08c39 | |||
| 738a62e96c | |||
| e7b93efda0 | |||
| 0b6f0a6068 | |||
| cc16d39e00 | |||
| 7eceff26ef | |||
| 61a791f759 | |||
| 6ecd6e61f6 | |||
| 1e13385f1f | |||
| dec9fe8b3d | |||
| ebd8ec5ae7 | |||
| e525e4c6dd | |||
| 903c2a736c | |||
| 482190777c | |||
| ebc785d1fe | |||
| ba932283f2 | |||
| f05bf68f61 | |||
| 35ab12a20a | |||
| fd7c54a695 | |||
| eafd834daf | |||
| f575ae0fa4 | |||
| 797c9f8575 | |||
| fb3ac4d454 | |||
| c629d706ec | |||
| 7896786311 | |||
| 820a2654ff | |||
| 8d45ab8436 | |||
| 855020636b | |||
| ff82603839 | |||
| 90270fb76c | |||
| c9edeaea44 | |||
| 757fe07b4d | |||
| f04fa706f7 | |||
| 9daf914a89 | |||
| c8e5ff3010 | |||
| c6f09375f8 | |||
| cc33297cc4 | |||
| 8556745645 |
@@ -13,7 +13,7 @@ Turn a working tree diff into a clean branch, commit, and pull request.
|
||||
|
||||
1. **Follow the PR template exactly.** The template is at `.github/PULL_REQUEST_TEMPLATE.md`. Read it and copy its full structure into the PR body. Do NOT remove, reorder, or skip any section or checkbox.
|
||||
2. **Never check "Requirements / 要求" checkboxes.** These are human-only declarations — the Code of Conduct acknowledgment and the AI authorship declaration can only be truthfully made by the human submitter. They MUST remain unchecked (`- [ ]`) in the PR body you generate. No exceptions, no workarounds, even if the user explicitly asks you to check them. The user goes to GitHub and checks them in person after verifying each statement is true.
|
||||
3. **Never check "Testing" checkboxes.** The template Testing checkboxes (browser tested, no console errors, types/doc added) require manual browser verification that only a human can perform. They MUST remain unchecked. You MAY add a supplementary note below the Testing section listing what automated validation you actually ran and the results.
|
||||
3. **Only check Testing checkboxes you actually verified.** The "`npm run ci` passes" checkbox MAY be checked only if you ran `npm run ci` in this session and it passed. All other Testing checkboxes (browser tested, no console errors, types/doc added) require manual verification that only a human can perform — they MUST remain unchecked. You MAY add a supplementary note below the Testing section listing what automated validation you actually ran and the results.
|
||||
4. **Never fabricate information.** Do not claim tests passed, commands ran, or checks succeeded unless you actually executed them and observed the output in this session. If you did not run it, do not mention it.
|
||||
5. **PR output must be concise.** PR title: one line. "What" section: 1–2 sentences max. No walls of text, no redundant explanations. Let the diff speak.
|
||||
|
||||
@@ -24,6 +24,13 @@ Before attempting to push or open a PR, verify that the necessary tools are avai
|
||||
- Check that `gh` CLI is installed and authenticated (`gh auth status`). If not available, stop and ask the user to install and authenticate GitHub CLI first.
|
||||
- If the workflow uses MCP tools for GitHub operations, verify the MCP server is accessible.
|
||||
|
||||
## Shell Permissions
|
||||
|
||||
`gh` reads credentials from the OS keyring. A sandboxed shell cannot reach the keyring and will report a false auth failure.
|
||||
|
||||
- Run every `gh` command (`gh auth status`, `gh pr create`, `gh pr view`, …) and every `git push` / `git fetch` that talks to GitHub with full permissions on the **first** attempt (`required_permissions: ["all"]` for the Shell tool). Do not probe these in the sandbox first.
|
||||
- Local read-only git inspection (`git status`, `git diff`, `git log`, `git branch`) may stay sandboxed.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Read contribution rules.** Read `CONTRIBUTING.md` and any package-level instructions (e.g. `packages/*/AGENTS.md`) relevant to the changed files.
|
||||
@@ -34,7 +41,7 @@ Before attempting to push or open a PR, verify that the necessary tools are avai
|
||||
- **Commit hygiene**: Every new commit must follow the repo's conventional commit style. Squash or reword if needed.
|
||||
- **Author identity**: Verify `git config user.name` and `git config user.email` are set and the email looks real (not empty, not `noreply` unless intentional). Warn the user if not.
|
||||
- **No leftover artifacts**: Check for debug logs, `.only` in tests, conflict markers, or temp files in the diff.
|
||||
- **Lint and build**: Run lint/build for the affected area. Record results honestly.
|
||||
- **CI**: Run `npm run ci` and make sure it passes. Record results honestly.
|
||||
5. **Warn if the PR looks too hasty.** If any of these are true, pause and warn the user before proceeding:
|
||||
- Large diff with no description of intent provided
|
||||
- Changes touch core lib or extension (where vibe coding is prohibited per `CONTRIBUTING.md`)
|
||||
@@ -47,7 +54,7 @@ Before attempting to push or open a PR, verify that the necessary tools are avai
|
||||
- If on `main` or a default branch, create a new branch from it with a short kebab-case name: prefix (`fix/`, `feat/`, `docs/`, `refactor/`, `chore/`) + concrete topic words.
|
||||
7. **Stage and commit.** Stage only the intended files. Use `type(scope): subject` format. Keep the subject specific and compact.
|
||||
8. **Push.** Push with `-u origin HEAD`.
|
||||
9. **Open PR.** Use `gh pr create` with the full PR template structure. Fill in "What" and "Type" sections based on the actual diff. Leave all "Testing" and "Requirements" checkboxes unchecked.
|
||||
9. **Open PR.** Use `gh pr create` with the full PR template structure. Fill in "What" and "Type" sections based on the actual diff. Check "`npm run ci` passes" only if it actually passed in this session; leave all other "Testing" and all "Requirements" checkboxes unchecked.
|
||||
10. **Report results.** Tell the user: branch name, commit hash, PR link, and what validation actually ran (with pass/fail).
|
||||
|
||||
## Post-Submission Reminder
|
||||
@@ -75,7 +82,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.
|
||||
- Run `npm run ci` to run all build and lint checks.
|
||||
- Always run `npm run ci` (build, lint, typecheck, tests) before opening the PR. It must pass.
|
||||
- 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.
|
||||
@@ -97,6 +104,6 @@ After successfully opening the PR, ALWAYS give a brief reminder in the user's la
|
||||
- All commit authors have proper name and email configured.
|
||||
- The PR title matches the commit intent.
|
||||
- The PR body follows the template structure completely.
|
||||
- All "Requirements" and "Testing" checkboxes are unchecked (`- [ ]`) in the PR body. Double-check: if any `- [x]` appears in these sections, it is a violation — fix it before submitting.
|
||||
- All "Requirements" checkboxes and all human-only "Testing" checkboxes are unchecked (`- [ ]`) in the PR body. The only checkbox you may check is "`npm run ci` passes", and only if it truly passed.
|
||||
- The reported validation is accurate — nothing fabricated.
|
||||
- The post-submission reminder was delivered in the user's language, concisely and accurately.
|
||||
|
||||
@@ -14,8 +14,8 @@ Closes #(issue)
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] `npm run ci` passes
|
||||
- [ ] Tested in modern browsers
|
||||
- [ ] No console errors
|
||||
- [ ] Types/doc added
|
||||
|
||||
## Requirements / 要求
|
||||
|
||||
@@ -3,27 +3,21 @@ permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [24]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js ${{ matrix.node-version }}
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Deploy Demo
|
||||
name: Deploy Website
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -6,14 +6,14 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: page-agent-trusted
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build demo
|
||||
- name: Build website
|
||||
run: npm run build:website
|
||||
|
||||
- name: Setup Pages
|
||||
@@ -0,0 +1,29 @@
|
||||
# Runs only for code already on main, on the self-hosted runner.
|
||||
# PR-triggered CI must stay on GitHub-hosted runners (see ci.yml).
|
||||
name: Main CI
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: page-agent-trusted
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: CI checks
|
||||
run: node scripts/ci.js
|
||||
@@ -11,9 +11,11 @@ permissions:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
# Only the repo owner may trigger a release, even if tag protection is bypassed
|
||||
if: github.actor == 'gaomeng1900'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -21,9 +23,9 @@ jobs:
|
||||
node-version: 24
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
# Ensure npm 11.5.1 or later is installed
|
||||
# Stay on the latest npm 11 release
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
run: npm install -g npm@11
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -40,3 +40,4 @@ package.json.bak
|
||||
.claude
|
||||
.cursor
|
||||
.gemini
|
||||
.codex
|
||||
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"version": "0.2",
|
||||
"language": "en",
|
||||
"globRoot": "..",
|
||||
"ignorePaths": [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/dist-ssr/**",
|
||||
"**/.output/**",
|
||||
"**/.wxt/**",
|
||||
"**/*.svg"
|
||||
],
|
||||
"words": [
|
||||
"activedescendant",
|
||||
"adex",
|
||||
"agentactivity",
|
||||
"agentic",
|
||||
"akldabonmimlicnjlflnapfeklbfemhj",
|
||||
"aliyun",
|
||||
"Bailian",
|
||||
"Bento",
|
||||
"BYOK",
|
||||
"Chromewebstore",
|
||||
"contenteditable",
|
||||
"deepseek",
|
||||
"executionresult",
|
||||
"fieldsets",
|
||||
"gaomeng",
|
||||
"Googlechrome",
|
||||
"Grabbable",
|
||||
"Gregor",
|
||||
"haspopup",
|
||||
"highspeed",
|
||||
"historicalevent",
|
||||
"historychange",
|
||||
"HITL",
|
||||
"hrefs",
|
||||
"IDBP",
|
||||
"innerhtml",
|
||||
"instructionsconfig",
|
||||
"kimi",
|
||||
"languagedetector",
|
||||
"Lian",
|
||||
"llms",
|
||||
"magicui",
|
||||
"maxlength",
|
||||
"menuitemcheckbox",
|
||||
"menuitemradio",
|
||||
"modelcontextprotocol",
|
||||
"multipageagent",
|
||||
"nesw",
|
||||
"npmmirror",
|
||||
"Ollama",
|
||||
"onwarn",
|
||||
"opensource",
|
||||
"openrouter",
|
||||
"pageagent",
|
||||
"pageagentcore",
|
||||
"pageagentext",
|
||||
"postpublish",
|
||||
"qwen",
|
||||
"reactid",
|
||||
"reactroot",
|
||||
"retryable",
|
||||
"shadcn",
|
||||
"sidepanel",
|
||||
"spinbutton",
|
||||
"staticity",
|
||||
"statuschange",
|
||||
"Supabase",
|
||||
"toggleable",
|
||||
"trivago",
|
||||
"Uncapitalize",
|
||||
"unparseable",
|
||||
"valuenow",
|
||||
"valuetext",
|
||||
"WCAG",
|
||||
"webgl",
|
||||
"wouter",
|
||||
"Zunic"
|
||||
]
|
||||
}
|
||||
Vendored
-34
@@ -1,41 +1,7 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"contenteditable",
|
||||
"deepseek",
|
||||
"historychange",
|
||||
"HITL",
|
||||
"innerhtml",
|
||||
"languagedetector",
|
||||
"llms",
|
||||
"magicui",
|
||||
"npmmirror",
|
||||
"onwarn",
|
||||
"opensource",
|
||||
"qwen",
|
||||
"retryable",
|
||||
"shadcn",
|
||||
"sidepanel",
|
||||
"statuschange",
|
||||
"wouter"
|
||||
],
|
||||
"files.exclude": {
|
||||
"packages/*/node_modules": true
|
||||
},
|
||||
"markdownlint.config": {
|
||||
// Relaxed rules
|
||||
"default": true,
|
||||
"whitespace": false,
|
||||
"line_length": false,
|
||||
"ul-indent": false,
|
||||
"no-inline-html": false,
|
||||
"no-bare-urls": false,
|
||||
"fenced-code-language": false,
|
||||
"first-line-h1": false,
|
||||
"block-spacing": false,
|
||||
"blanks-around-lists": false,
|
||||
"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",
|
||||
|
||||
@@ -23,6 +23,7 @@ npm run build # Build all packages
|
||||
npm run build:libs # Build all libraries
|
||||
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
|
||||
```
|
||||
|
||||
@@ -125,6 +126,21 @@ 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.
|
||||
- **Live tests** (hit real external APIs, slow/costly): name them `*.live.test.ts`, exclude them from the package's `test` script, and expose them via a `test:live` script. Root `npm run test:live` runs all of them; they never run in `npm test` or CI. Template: `packages/llms`.
|
||||
- **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
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
# Page Agent
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://img.alicdn.com/imgextra/i4/O1CN01qKig1P1FnhpFKNdi6_!!6000000000532-2-tps-1280-256.png">
|
||||
<img alt="Page Agent Banner" src="https://img.alicdn.com/imgextra/i1/O1CN01NCMKXj1Gn4tkFTsxf_!!6000000000666-2-tps-1280-256.png">
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://page-agent.github.io/assets/readme/banner-dark.png">
|
||||
<img alt="Page Agent Banner" src="https://page-agent.github.io/assets/readme/banner-light.png">
|
||||
</picture>
|
||||
|
||||
[](https://opensource.org/licenses/MIT) [](http://www.typescriptlang.org/) [](https://bundlephobia.com/package/page-agent) [](https://www.npmjs.com/package/page-agent) [](https://github.com/alibaba/page-agent)
|
||||
[](https://github.com/alibaba/page-agent/actions/workflows/main-ci.yml)
|
||||
[](https://www.npmjs.com/package/page-agent)
|
||||
[](https://www.npmjs.com/package/page-agent)
|
||||
[](https://bundlephobia.com/package/page-agent)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](http://www.typescriptlang.org/)
|
||||
[](https://chromewebstore.google.com/detail/page-agent-ext/akldabonmimlicnjlflnapfeklbfemhj)
|
||||
[](https://github.com/alibaba/page-agent)
|
||||
|
||||
The GUI Agent Living in Your Webpage. Control web interfaces with natural language.
|
||||
The GUI Agent Living in Your Webpage. One script gives any web page its own AI agent.
|
||||
|
||||
<a href="https://trendshift.io/repositories/22551?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-22551" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/22551" alt="alibaba%2Fpage-agent | Trendshift" width="180"/></a>
|
||||
|
||||
🌐 **English** | [中文](./docs/README-zh.md)
|
||||
|
||||
<a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank"><b>📖 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)
|
||||
|
||||
---
|
||||
|
||||
@@ -25,6 +36,7 @@ The GUI Agent Living in Your Webpage. Control web interfaces with natural langua
|
||||
- **📖 Text-based DOM manipulation**
|
||||
- No screenshots. No multi-modal LLMs or special permissions needed.
|
||||
- **🧠 Bring your own LLMs**
|
||||
- Works with most mainstream models, including locally deployed ones. See [supported models](https://alibaba.github.io/page-agent/docs/features/models).
|
||||
- **🐙 Optional [chrome extension](https://alibaba.github.io/page-agent/docs/features/chrome-extension) for multi-page tasks.**
|
||||
- And an [MCP Server (Beta)](https://alibaba.github.io/page-agent/docs/features/mcp-server) to control it from outside
|
||||
|
||||
@@ -33,7 +45,7 @@ The GUI Agent Living in Your Webpage. Control web interfaces with natural langua
|
||||
- **SaaS AI Copilot** — Ship an AI copilot in your product in lines of code. No backend rewrite.
|
||||
- **Smart Form Filling** — Turn 20-click workflows into one sentence. Perfect for ERP, CRM, and admin systems.
|
||||
- **Accessibility** — Make any web app accessible through natural language. Voice commands, screen readers, zero barrier.
|
||||
- **Multi-page Agent** — Extend your own web agent's reach across browser tabs [chrome extension](https://alibaba.github.io/page-agent/docs/features/chrome-extension).
|
||||
- **Multi-page Agent** — Extend your own web agent's reach across browser tabs via the [Chrome extension](https://alibaba.github.io/page-agent/docs/features/chrome-extension).
|
||||
- **MCP** - Allow your agent clients to control your browser.
|
||||
|
||||
## 🚀 Quick Start
|
||||
@@ -43,15 +55,19 @@ The GUI Agent Living in Your Webpage. Control web interfaces with natural langua
|
||||
Fastest way to try PageAgent with our free Demo LLM:
|
||||
|
||||
```html
|
||||
<script src="{URL}" crossorigin="true"></script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/page-agent@1.12.1/dist/iife/page-agent.demo.js"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
```
|
||||
|
||||
> **⚠️ For technical evaluation only.** This demo CDN uses our free [testing LLM API](https://alibaba.github.io/page-agent/docs/features/models#free-testing-api). By using it, you agree to its [terms](https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md).
|
||||
> Add `?autoInit=false` to load the script without creating the demo agent automatically. You can then instantiate it with `new window.PageAgent(...)`.
|
||||
|
||||
| Mirrors | URL |
|
||||
| ------- | ---------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.8.1/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.8.1/files/dist/iife/page-agent.demo.js |
|
||||
| Mirrors | URL |
|
||||
| ------- | ----------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.12.1/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.12.1/files/dist/iife/page-agent.demo.js |
|
||||
|
||||
### NPM Installation
|
||||
|
||||
@@ -74,20 +90,12 @@ await agent.execute('Click the login button')
|
||||
|
||||
For more programmatic usage, see [📖 Documentations](https://alibaba.github.io/page-agent/docs/introduction/overview).
|
||||
|
||||
## 🌟 Awesome Page Agent
|
||||
|
||||
Built something cool with PageAgent? Add it here! Open a PR to share your project.
|
||||
|
||||
> These are community projects — not maintained or endorsed by us. Use at your own discretion.
|
||||
|
||||
| Project | Description |
|
||||
| -------- | ----------------------------------------------------------- |
|
||||
| _Yours?_ | [Open a PR](https://github.com/alibaba/page-agent/pulls) 🙌 |
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions from the community! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines and [docs/developer-guide.md](docs/developer-guide.md) for local development workflows.
|
||||
|
||||
Built something cool with PageAgent? Share it in [Show and Tell](https://github.com/alibaba/page-agent/discussions/categories/show-and-tell). 🙌
|
||||
|
||||
Please read the [maintainer's note](https://github.com/alibaba/page-agent/issues/349) on principles and current state.
|
||||
|
||||
Contributions generated entirely by **bots or AI** without substantial human involvement will **not be accepted**.
|
||||
@@ -114,6 +122,4 @@ excellent work on web automation and DOM interaction patterns that helped make
|
||||
this project possible.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**⭐ Star this repo if you find PageAgent helpful!**
|
||||
|
||||
@@ -5,6 +5,122 @@ 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.12.0] - 2026-07-09
|
||||
|
||||
- **Stateless extension tab sync** - TabsController pulls tab state on demand instead of long-lived ports, so the MV3 service worker stays stateless and survives idle kills without stalling the side panel.
|
||||
- **Tab status in agent context** - Tab summaries now include each tab's loading status for the LLM.
|
||||
|
||||
## [1.11.0] - 2026-07-03
|
||||
|
||||
### Features
|
||||
|
||||
- **LLM model patching rework** - Rewrote per-model request patching for GPT, Claude, Qwen, Gemini, and DeepSeek, and refreshed the recommended model list.
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Deprecate `LLMConfig.temperature`** - Warn when set; use `transformRequestBody` for models that still accept it.
|
||||
- **Extension limitations docs** - Added a limitations section to extension documentation.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Multi-window extension tabs** - Resolved the active tab/window from the caller's window context instead of the global active tab.
|
||||
- **chat-latest model compatibility** - Skip `reasoning_effort` and `temperature` patches for `*-chat-latest` models.
|
||||
- **OpenRouter defaults** - Do not enable reasoning by default on OpenRouter.
|
||||
- **Asset URLs** - Migrated `img.alicdn.com` links to `page-agent.github.io`.
|
||||
|
||||
## [1.10.0] - 2026-06-15
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **Agent run lifecycle rework** - `stop()` is now async and resolves only after the run fully settles. Run status is decoupled from task outcome: a new `stopped` state was added, and LLM self-reported failures now end as `completed`. Lifecycle hooks re-throw instead of folding errors into the result, agent errors are recorded in history, and `agent.lastResult` was added.
|
||||
|
||||
### Features
|
||||
|
||||
- **Abortable JavaScript execution** - `execute_javascript` now honors the `AbortSignal`.
|
||||
- **Leaner agent prompts** - Simplified the waiting-response flow and removed navigation-back instructions to reduce LLM cognitive load.
|
||||
- **MultiPageAgent safety** - Disabled `ScriptExecutionTool` for `MultiPageAgent`.
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Dark mode detection** - Refined detection heuristics and made `isMainContentDark` less aggressive by checking `html` and `body` data attributes independently.
|
||||
- **Extension lifecycle robustness** - Drove heartbeat and running state from status changes, cleared stale activity on any non-running status, handled the stopped lifecycle state, and cleared `currentTabId` on `TabsController.init`.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Accurate wait reporting** - Wait steps now report the actual wait duration.
|
||||
- **Scroll predicates** - Scroll predicates now return booleans.
|
||||
- **Docs** - Fixed the broken demo video on GitHub.
|
||||
|
||||
## [1.9.0] - 2026-06-08
|
||||
|
||||
### Features
|
||||
|
||||
- **Robust abort handling** - Rewrote the aborting system; sync tools, loop execution, and LLM clients now correctly respect task abort signals (`ctx.signal`). Also decoupled `AbortError` from `InvokeError` in `@page-agent/llms`.
|
||||
- **Claude Opus 4.8 support** - Added support for Claude Opus 4.8 model.
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Concurrency guard** - Prevented concurrent `execute()` calls on a single PageAgent/Core instance to avoid race conditions.
|
||||
- **Model recommendations refresh** - Updated default and tested model list recommendations.
|
||||
- **Test coverage** - Added comprehensive Vitest unit tests for the `@page-agent/llms` package.
|
||||
- **Improved documentation** - Added website documentation for the `ctx.signal` abort contract and `execute()` concurrency rules.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **DTS bundle fix** - Fixed a packaging bug where global type declarations were incorrectly bundled into `.d.ts` outputs.
|
||||
- **Website sidebar fix** - Normalized trailing slashes in the website's sidebar location comparison.
|
||||
|
||||
## [1.8.2] - 2026-05-11
|
||||
|
||||
### Features
|
||||
|
||||
- **IIFE demo control** - Added `showPanel` and `autoInit` switches to the IIFE CDN script to control whether the UI panel automatically displays or initializes on load.
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Build toolchain modernization** - Upgraded build infrastructure to Vite 8.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **TypeScript `InvokeErrorType` fix** - Separated the value and type space for `InvokeErrorType` to resolve TypeScript compilation issues.
|
||||
- **Website chunking fix** - Restored working code-splitting with `manualChunks` for the documentation website.
|
||||
|
||||
## [1.8.1] - 2026-04-27
|
||||
|
||||
### Features
|
||||
|
||||
- **GPT-5.4 & Qwen 3.6 support** - Added support for `gpt-5.4` and `qwen3.6-max/flash` in the recommended LLM list.
|
||||
- **Custom LLM request hook** - Added a `transformRequestBody` hook to allow custom modification of payloads before sending requests to LLM providers.
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Accessibility (a11y) enhancements** - Added descriptive accessible labels to `ConfigPanel` input fields and icon buttons in compliance with WCAG 4.1.2.
|
||||
- **UI polish** - Improved `HistoryList` loading and empty states, and added helpful tooltips for actions.
|
||||
- **Prompt caching guidance** - Added website documentation for prompt caching optimization.
|
||||
- **Build speedups** - Added parallel build scripts to accelerate local development compilation.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **DeepSeek tool choice fix** - Disabled explicit `tool_choice` for DeepSeek models to avoid API compatibility errors.
|
||||
- **MCP version advertising** - MCP server now correctly advertises its package version.
|
||||
|
||||
## [1.8.0] - 2026-04-15
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **TypeScript 6 & ESLint 10 upgrade** - Major toolchain modernization. Upgraded the entire monorepo to TypeScript 6 and ESLint 10 with source-first monorepo resolution (library exports resolve to source files directly during local development).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **MCP security hardening** - Bound the MCP HTTP + WebSocket server to `localhost` only.
|
||||
- **Extension UI refinement** - Made the history panel height responsive to the viewport and improved the result card readability by increasing font size.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **SimulatorMask memory leak** - Fixed a memory leak by ensuring the `requestAnimationFrame` loop is cancelled when disposing the SimulatorMask.
|
||||
- **Autofixer format fix** - Corrected the fallback action format for `autoFixer` when waiting.
|
||||
- **IIFE scope protection** - Fixed a name collision in IIFE builds by preventing global helper function re-declarations.
|
||||
|
||||
## [1.7.1] - 2026-04-04
|
||||
|
||||
### Features
|
||||
|
||||
+27
-11
@@ -1,19 +1,30 @@
|
||||
# Page Agent
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://img.alicdn.com/imgextra/i4/O1CN01qKig1P1FnhpFKNdi6_!!6000000000532-2-tps-1280-256.png">
|
||||
<img alt="Page Agent Banner" src="https://img.alicdn.com/imgextra/i1/O1CN01NCMKXj1Gn4tkFTsxf_!!6000000000666-2-tps-1280-256.png">
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://page-agent.github.io/assets/readme/banner-dark.png">
|
||||
<img alt="Page Agent Banner" src="https://page-agent.github.io/assets/readme/banner-light.png">
|
||||
</picture>
|
||||
|
||||
[](https://opensource.org/licenses/MIT) [](http://www.typescriptlang.org/) [](https://bundlephobia.com/package/page-agent) [](https://www.npmjs.com/package/page-agent) [](https://github.com/alibaba/page-agent)
|
||||
[](https://github.com/alibaba/page-agent/actions/workflows/main-ci.yml)
|
||||
[](https://www.npmjs.com/package/page-agent)
|
||||
[](https://www.npmjs.com/package/page-agent)
|
||||
[](https://bundlephobia.com/package/page-agent)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](http://www.typescriptlang.org/)
|
||||
[](https://chromewebstore.google.com/detail/page-agent-ext/akldabonmimlicnjlflnapfeklbfemhj)
|
||||
[](https://github.com/alibaba/page-agent)
|
||||
|
||||
纯 JS 实现的 GUI agent。使用自然语言操作你的 Web 应用。无须后端、客户端、浏览器插件。
|
||||
|
||||
<a href="https://trendshift.io/repositories/22551?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-22551" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/22551" alt="alibaba%2Fpage-agent | Trendshift" width="180"/></a>
|
||||
|
||||
🌐 [English](../README.md) | **中文**
|
||||
|
||||
<a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank"><b>📖 Docs</b></a> | <a href="https://news.ycombinator.com/item?id=47264138" target="_blank"><b>📢 HN Discussion</b></a> | <a href="https://x.com/simonluvramen" target="_blank"><b>𝕏 Follow on X</b></a>
|
||||
|
||||
<video id="demo-video" src="https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2" controls crossorigin muted></video>
|
||||
<!-- demo video -->
|
||||
|
||||
[](https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2)
|
||||
|
||||
---
|
||||
|
||||
@@ -24,6 +35,7 @@
|
||||
- **📖 基于文本的 DOM 操作**
|
||||
- 无需截图,无需多模态模型或特殊权限
|
||||
- **🧠 自备 LLM**
|
||||
- 支持多数主流模型,包括本地部署模型。参见[支持的模型](https://alibaba.github.io/page-agent/docs/features/models)。
|
||||
- 🐙 可选的 [Chrome 扩展](https://alibaba.github.io/page-agent/docs/features/chrome-extension),支持跨页面任务
|
||||
- [MCP Server (Beta)](https://alibaba.github.io/page-agent/docs/features/mcp-server)
|
||||
|
||||
@@ -42,15 +54,19 @@
|
||||
通过我们免费的 Demo LLM 快速体验 PageAgent:
|
||||
|
||||
```html
|
||||
<script src="{URL}" crossorigin="true"></script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/page-agent@1.12.1/dist/iife/page-agent.demo.js"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
```
|
||||
|
||||
> **⚠️ 仅用于技术评估。** 该 Demo CDN 使用了免费的[测试 LLM API](https://alibaba.github.io/page-agent/docs/features/models#free-testing-api),使用即表示您同意其[条款](https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md)。
|
||||
> 在 URL 后添加 `?autoInit=false` 可只加载脚本,不自动创建 Demo Agent;之后可通过 `new window.PageAgent(...)` 手动初始化。
|
||||
|
||||
| Mirrors | URL |
|
||||
| ------- | ---------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.8.1/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.8.1/files/dist/iife/page-agent.demo.js |
|
||||
| Mirrors | URL |
|
||||
| ------- | ----------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.12.1/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.12.1/files/dist/iife/page-agent.demo.js |
|
||||
|
||||
### NPM 安装
|
||||
|
||||
@@ -81,6 +97,8 @@ await agent.execute('点击登录按钮')
|
||||
|
||||
我们不接受未经实质性人类参与、完全由 Bot 或 Agent 自动生成的代码。
|
||||
|
||||
用 PageAgent 做了有趣的东西?欢迎到 [Show and Tell](https://github.com/alibaba/page-agent/discussions/categories/show-and-tell) 分享。🙌
|
||||
|
||||
## 👏 声明与致谢
|
||||
|
||||
本项目基于 **[`browser-use`](https://github.com/browser-use/browser-use)** 的优秀工作构建。
|
||||
@@ -103,6 +121,4 @@ this project possible.
|
||||
|
||||
[MIT License](../LICENSE)
|
||||
|
||||
---
|
||||
|
||||
**⭐ 如果觉得 PageAgent 有用或有趣,请给项目点个星!**
|
||||
|
||||
@@ -91,7 +91,7 @@ npm run build:ext
|
||||
- Add a new bookmark
|
||||
|
||||
```javascript
|
||||
javascript:(function(){var s=document.createElement('script');s.src=`http://localhost:5174/page-agent.demo.js?t=${Math.random()}`;s.onload=()=>console.log(%27PageAgent ready!%27);document.head.appendChild(s);})();
|
||||
javascript:(function(){var s=document.createElement('script');s.src=`http://localhost:5174/page-agent.demo.js?lang=en-US&t=${Math.random()}`;s.onload=()=>console.log(%27PageAgent ready!%27);document.head.appendChild(s);})();
|
||||
```
|
||||
|
||||
- Click the bookmark on any page to load Page-Agent
|
||||
|
||||
@@ -53,6 +53,7 @@ export default defineConfig([
|
||||
'@typescript-eslint/use-unknown-in-catch-callback-variable': 'off',
|
||||
'@typescript-eslint/no-unnecessary-type-parameters': 'off',
|
||||
'@typescript-eslint/require-await': 'off',
|
||||
'@typescript-eslint/no-deprecated': 'off',
|
||||
'@eslint-react/dom-no-missing-button-type': 'off',
|
||||
'@eslint-react/no-nested-component-definitions': 'off',
|
||||
'@eslint-react/no-array-index-key': 'off',
|
||||
|
||||
Generated
+2425
-3664
File diff suppressed because it is too large
Load Diff
+28
-22
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"version": "1.8.1",
|
||||
"version": "1.12.1",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/page-controller",
|
||||
@@ -22,7 +22,8 @@
|
||||
},
|
||||
"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",
|
||||
@@ -35,39 +36,41 @@
|
||||
"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",
|
||||
"test:live": "npm run test:live --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.2.1",
|
||||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@eslint-react/eslint-plugin": "^5.13.2",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@microsoft/api-extractor": "^7.58.6",
|
||||
"@tailwindcss/vite": "^4.2.3",
|
||||
"@microsoft/api-extractor": "^7.58.9",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/node": "^25.6.0",
|
||||
"@vitejs/plugin-react-swc": "^4.3.0",
|
||||
"@types/node": "^26.1.1",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"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.6.0",
|
||||
"globals": "^17.7.0",
|
||||
"happy-dom": "^20.10.6",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.4.0",
|
||||
"prettier": "^3.8.2",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.59.0",
|
||||
"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.8",
|
||||
"prettier": "^3.9.5",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.63.0",
|
||||
"unplugin-dts": "^1.0.3",
|
||||
"vite": "^8.1.4",
|
||||
"vite-plugin-css-injected-by-js": "^5.0.1",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"overrides": {
|
||||
"typescript": "^6.0.2",
|
||||
"@vitejs/plugin-react": "^5.2.0"
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,cjs,cts,mjs,mts}": [
|
||||
@@ -80,6 +83,9 @@
|
||||
],
|
||||
"*.css": [
|
||||
"npx prettier --write --ignore-unknown"
|
||||
],
|
||||
"*.md": [
|
||||
"npx prettier --write --ignore-unknown"
|
||||
]
|
||||
},
|
||||
"commitlint": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@page-agent/core",
|
||||
"private": false,
|
||||
"version": "1.8.1",
|
||||
"version": "1.12.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.1",
|
||||
"@page-agent/page-controller": "1.8.1"
|
||||
"@page-agent/llms": "1.12.1",
|
||||
"@page-agent/page-controller": "1.12.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
|
||||
|
||||
@@ -37,7 +37,7 @@ and system messages wrapped in <sys> tag.
|
||||
<user_request>
|
||||
USER REQUEST: This is your ultimate objective and always remains visible.
|
||||
- This has the highest priority. Make the user happy.
|
||||
- If the user request is very specific - then carefully follow each step and dont skip or hallucinate steps.
|
||||
- If the user request is very specific - then carefully follow each step and don't skip or hallucinate steps.
|
||||
- If the task is open ended you can plan yourself how to get it done.
|
||||
</user_request>
|
||||
|
||||
@@ -70,7 +70,6 @@ Strictly follow these rules while using the browser and navigating the web:
|
||||
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
|
||||
- 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
|
||||
|
||||
@@ -255,16 +255,12 @@ export interface AgentErrorEvent {
|
||||
* Union type for all history events
|
||||
*/
|
||||
export type HistoricalEvent =
|
||||
| AgentStepEvent
|
||||
| ObservationEvent
|
||||
| UserTakeoverEvent
|
||||
| RetryEvent
|
||||
| AgentErrorEvent
|
||||
AgentStepEvent | ObservationEvent | UserTakeoverEvent | RetryEvent | AgentErrorEvent
|
||||
|
||||
/**
|
||||
* Agent execution status
|
||||
* Agent lifecycle status.
|
||||
*/
|
||||
export type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
|
||||
export type AgentStatus = 'idle' | 'running' | 'completed' | 'error' | 'stopped'
|
||||
|
||||
/**
|
||||
* Agent activity - transient state for immediate UI feedback.
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -96,6 +96,10 @@ Returns: `Promise<ExecutionResult>`
|
||||
|
||||
Stop the current task.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Normal browser windows only.** This extension relies on tab group API which does not work for pop-up window or PWA App window.
|
||||
|
||||
## Types
|
||||
|
||||
Install `@page-agent/core` for complete types:
|
||||
@@ -131,7 +135,7 @@ export type Execute = (task: string, config: ExecuteConfig) => Promise<Execution
|
||||
`AgentStatus`
|
||||
|
||||
```typescript
|
||||
type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
|
||||
type AgentStatus = 'idle' | 'running' | 'completed' | 'error' | 'stopped'
|
||||
```
|
||||
|
||||
`AgentActivity`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@page-agent/ext",
|
||||
"private": true,
|
||||
"version": "1.8.1",
|
||||
"version": "1.12.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.19",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@tailwindcss/vite": "^4.2.3",
|
||||
"@types/chrome": "^0.1.40",
|
||||
"@types/react": "^19.2.14",
|
||||
"@radix-ui/react-label": "^2.1.11",
|
||||
"@radix-ui/react-separator": "^1.1.11",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-switch": "^1.3.3",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/chrome": "^0.2.2",
|
||||
"@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.24.0",
|
||||
"motion": "^12.42.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"rough-notation": "^0.5.1",
|
||||
"simple-icons": "^16.16.0",
|
||||
"simple-icons": "^16.25.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"wxt": "^0.20.25"
|
||||
"wxt": "^0.20.27"
|
||||
},
|
||||
"dependencies": {
|
||||
"@page-agent/core": "1.8.1",
|
||||
"@page-agent/llms": "1.8.1",
|
||||
"@page-agent/page-controller": "1.8.1",
|
||||
"@page-agent/ui": "1.8.1",
|
||||
"@page-agent/core": "1.12.1",
|
||||
"@page-agent/llms": "1.12.1",
|
||||
"@page-agent/page-controller": "1.12.1",
|
||||
"@page-agent/ui": "1.12.1",
|
||||
"ai-motion": "^0.4.8",
|
||||
"chalk": "^5.6.2"
|
||||
},
|
||||
|
||||
@@ -40,63 +40,61 @@ 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) => {
|
||||
// pull latest tab state so that tabs changes can be observed
|
||||
await tabsController.syncTabs()
|
||||
if (!tabsController.currentTabId) return
|
||||
// make sure the current tab is loaded before the step starts
|
||||
await tabsController.waitUntilTabLoaded(tabsController.currentTabId!)
|
||||
await tabsController.waitUntilTabLoaded(tabsController.currentTabId)
|
||||
},
|
||||
|
||||
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> {}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/**
|
||||
* background logics for TabsController
|
||||
*
|
||||
* Keep this stateless: pure request/response handlers only, no in-memory
|
||||
* state, no ports, no event pushing. MV3 SW should be killed and restarted at
|
||||
* any time (idle timeout, extension update) without special handling.
|
||||
*/
|
||||
import type { TabAction } from './TabsController'
|
||||
|
||||
@@ -7,6 +11,39 @@ const PREFIX = '[TabsController.background]'
|
||||
|
||||
const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`)
|
||||
|
||||
/**
|
||||
* Resolve active tab.
|
||||
*
|
||||
* - `tabs.query({ active: true })` does not work in multi-window scenarios.
|
||||
* - Extension pages (side panel, hub tab) can resolve their own windowId.
|
||||
* We just find the active tab within that window.
|
||||
* - Content scripts (PAGE_AGENT_EXT) can't self-report a windowId.
|
||||
* Chrome populates `sender.tab` for every content-script message,
|
||||
* which is the tab hosting the script.
|
||||
*/
|
||||
async function resolveActiveTab(
|
||||
payload: { windowId?: number } | undefined,
|
||||
sender: chrome.runtime.MessageSender
|
||||
): Promise<chrome.tabs.Tab> {
|
||||
const windowId = payload?.windowId
|
||||
|
||||
if (windowId != null) {
|
||||
debug('get_active_tab: resolving via caller-reported windowId', windowId)
|
||||
const [tab] = await chrome.tabs.query({ active: true, windowId })
|
||||
if (!tab) throw new Error(`No active tab found in window ${windowId}.`)
|
||||
return tab
|
||||
}
|
||||
|
||||
if (sender.tab) {
|
||||
debug('get_active_tab: resolving via sender.tab (content script)', sender.tab.id)
|
||||
return sender.tab
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Cannot resolve active tab: caller reported no windowId and is not a content script (no sender.tab).'
|
||||
)
|
||||
}
|
||||
|
||||
export function handleTabControlMessage(
|
||||
message: { type: 'TAB_CONTROL'; action: TabAction; payload: any },
|
||||
sender: chrome.runtime.MessageSender,
|
||||
@@ -16,12 +53,11 @@ export function handleTabControlMessage(
|
||||
|
||||
switch (action as TabAction) {
|
||||
case 'get_active_tab': {
|
||||
debug('get_active_tab')
|
||||
chrome.tabs
|
||||
.query({ active: true })
|
||||
.then((tabs) => {
|
||||
debug('get_active_tab: success', tabs)
|
||||
sendResponse({ success: true, tab: tabs[0] })
|
||||
debug('get_active_tab', payload)
|
||||
resolveActiveTab(payload, sender)
|
||||
.then((tab) => {
|
||||
debug('get_active_tab: success', tab)
|
||||
sendResponse({ success: true, tab })
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||
@@ -46,7 +82,7 @@ export function handleTabControlMessage(
|
||||
case 'open_new_tab': {
|
||||
debug('open_new_tab', payload)
|
||||
chrome.tabs
|
||||
.create({ url: payload.url, active: false })
|
||||
.create({ url: payload.url, windowId: payload.windowId, active: false })
|
||||
.then((newTab) => {
|
||||
debug('open_new_tab: success', newTab)
|
||||
sendResponse({ success: true, tabId: newTab.id })
|
||||
@@ -112,7 +148,6 @@ export function handleTabControlMessage(
|
||||
}
|
||||
|
||||
case 'get_window_tabs': {
|
||||
debug('get_window_tabs', payload)
|
||||
chrome.tabs
|
||||
.query({ windowId: payload.windowId })
|
||||
.then((tabs) => {
|
||||
@@ -129,41 +164,3 @@ export function handleTabControlMessage(
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const tabEventPorts = new Set<chrome.runtime.Port>()
|
||||
|
||||
function broadcastTabEvent(message: object) {
|
||||
for (const port of tabEventPorts) {
|
||||
port.postMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Port-based tab events: agents connect via `chrome.runtime.connect({ name: 'tab-events' })`
|
||||
* and receive tab change events through the port. Works for both extension pages and content scripts.
|
||||
*/
|
||||
export function setupTabEventsPort() {
|
||||
chrome.runtime.onConnect.addListener((port) => {
|
||||
if (port.name !== 'tab-events') return
|
||||
|
||||
debug('port connected', port.sender?.tab?.id ?? port.sender?.url)
|
||||
tabEventPorts.add(port)
|
||||
|
||||
port.onDisconnect.addListener(() => {
|
||||
debug('port disconnected')
|
||||
tabEventPorts.delete(port)
|
||||
})
|
||||
})
|
||||
|
||||
chrome.tabs.onCreated.addListener((tab) => {
|
||||
broadcastTabEvent({ action: 'created', payload: { tab } })
|
||||
})
|
||||
|
||||
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
|
||||
broadcastTabEvent({ action: 'removed', payload: { tabId, removeInfo } })
|
||||
})
|
||||
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
||||
broadcastTabEvent({ action: 'updated', payload: { tabId, changeInfo, tab } })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,19 +15,34 @@ function sendMessage(message: {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the window hosting this script's own context, when knowable.
|
||||
*
|
||||
* Extension pages (side panel, hub tab) have `chrome.windows` access and can
|
||||
* identify their own window directly via `getCurrent()`.
|
||||
* Content scripts have no `chrome.windows` access; they resolve `undefined`
|
||||
* here and the background script falls back to `sender.tab` instead.
|
||||
*/
|
||||
async function getOwnWindowId(): Promise<number | undefined> {
|
||||
if (typeof chrome.windows === 'undefined') return undefined
|
||||
const win = await chrome.windows.getCurrent()
|
||||
return win.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller for managing browser tabs.
|
||||
* - live in the agent env (extension page or content script)
|
||||
* - no chrome apis. call sw for tab operations
|
||||
* - store tabs states, pull tabs info and detect changes
|
||||
*/
|
||||
export class TabsController {
|
||||
currentTabId: number | null = null
|
||||
|
||||
private disposed = false
|
||||
private port?: chrome.runtime.Port
|
||||
private portRetries = 0
|
||||
|
||||
/* tracked window */
|
||||
private windowId: number | null = null
|
||||
/* tracked tabs */
|
||||
private tabs: TabMeta[] = []
|
||||
private initialTabId: number | null = null
|
||||
private tabGroupId: number | null = null
|
||||
@@ -42,10 +57,7 @@ export class TabsController {
|
||||
throw new Error('TabsController already disposed')
|
||||
}
|
||||
|
||||
this.currentTabId = null
|
||||
this.disposed = false
|
||||
this.port = undefined
|
||||
this.portRetries = 0
|
||||
await this.updateCurrentTabId(null)
|
||||
|
||||
this.windowId = null
|
||||
this.tabs = []
|
||||
@@ -57,6 +69,7 @@ export class TabsController {
|
||||
const activeTabResult = await sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'get_active_tab',
|
||||
payload: { windowId: await getOwnWindowId() },
|
||||
})
|
||||
|
||||
this.initialTabId = activeTabResult.tab?.id
|
||||
@@ -70,8 +83,6 @@ export class TabsController {
|
||||
}
|
||||
}
|
||||
|
||||
this.connectTabEvents()
|
||||
|
||||
if (experimentalIncludeAllTabs) {
|
||||
const allTabs = await sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
@@ -124,7 +135,7 @@ export class TabsController {
|
||||
const result = await sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'open_new_tab',
|
||||
payload: { url },
|
||||
payload: { url, windowId: this.windowId },
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
@@ -265,11 +276,14 @@ export class TabsController {
|
||||
}
|
||||
|
||||
async summarizeTabs(): Promise<string> {
|
||||
const summaries = [`| Tab ID | URL | Title | Current |`, `|-----|-----|-----|-----|`]
|
||||
const summaries = [
|
||||
`| Tab ID | URL | Title | Status | Current |`,
|
||||
`|-----|-----|-----|-----|-----|`,
|
||||
]
|
||||
for (const tab of this.tabs) {
|
||||
const { title, url } = await this.getTabInfo(tab.id)
|
||||
summaries.push(
|
||||
`| ${tab.id} | ${url} | ${title} | ${this.currentTabId === tab.id ? '✅' : ''} |`
|
||||
`| ${tab.id} | ${url} | ${title} | ${tab.status ?? '-'} | ${this.currentTabId === tab.id ? '✅' : ''} |`
|
||||
)
|
||||
}
|
||||
if (!this.tabs.length) {
|
||||
@@ -282,79 +296,96 @@ export class TabsController {
|
||||
async waitUntilTabLoaded(tabId: number): Promise<void> {
|
||||
const tab = this.tabs.find((t) => t.id === tabId)
|
||||
if (!tab) throw new Error(`Tab ID ${tabId} not found in tab list.`)
|
||||
|
||||
if (tab.status === 'unloaded') throw new Error(`Tab ID ${tabId} is unloaded.`)
|
||||
if (tab.status === 'complete') return
|
||||
|
||||
// When a tracked tab is closed or untracked.
|
||||
// The tab object will be removed from the tab list.
|
||||
// Finding the latest tab object is the only way to know if it's closed.
|
||||
|
||||
debug('waitUntilTabLoaded', tabId)
|
||||
await waitUntil(() => tab.status === 'complete', 4_000)
|
||||
await waitUntil(async () => {
|
||||
await this.syncTabs()
|
||||
const latest = this.tabs.find((t) => t.id === tabId)
|
||||
return !latest || latest.status !== 'loading'
|
||||
}, 4_000)
|
||||
|
||||
const latest = this.tabs.find((t) => t.id === tabId)
|
||||
if (latest?.status === 'unloaded') throw new Error(`Tab ID ${tabId} is unloaded.`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to background SW via port to receive tab change events.
|
||||
*
|
||||
* @note Port is 1:1 (runtime.connect → background SW has no frames),
|
||||
* so onDisconnect fires exactly once and we can safely reconnect.
|
||||
* Reconnection may miss events during the gap.
|
||||
* TODO: refresh this.tabs from background after reconnect to stay consistent.
|
||||
* Pull the window's tabs from the background.
|
||||
* Pulling is better than pushing. Long-lived ports are stateful troublemakers.
|
||||
*/
|
||||
private connectTabEvents() {
|
||||
this.port = chrome.runtime.connect({ name: 'tab-events' })
|
||||
async syncTabs(): Promise<void> {
|
||||
if (this.disposed || this.windowId == null) return
|
||||
|
||||
this.port.onMessage.addListener((message: any) => {
|
||||
if (this.disposed) return
|
||||
this.portRetries = 0
|
||||
|
||||
if (message.action === 'created') {
|
||||
const tab = message.payload.tab as chrome.tabs.Tab
|
||||
const shouldTrack = this.experimentalIncludeAllTabs || tab.groupId === this.tabGroupId
|
||||
if (shouldTrack && tab.id != null) {
|
||||
this.addTab({ id: tab.id, isInitial: false })
|
||||
this.switchToTab(tab.id)
|
||||
}
|
||||
} else if (message.action === 'removed') {
|
||||
const { tabId } = message.payload as { tabId: number }
|
||||
const targetTab = this.tabs.find((t) => t.id === tabId)
|
||||
if (targetTab) {
|
||||
this.tabs = this.tabs.filter((t) => t.id !== tabId)
|
||||
if (this.currentTabId === tabId) {
|
||||
const newCurrentTab = this.tabs[this.tabs.length - 1] || null
|
||||
if (newCurrentTab) {
|
||||
this.switchToTab(newCurrentTab.id)
|
||||
} else {
|
||||
this.updateCurrentTabId(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (message.action === 'updated') {
|
||||
const { tabId, tab } = message.payload as { tabId: number; tab: chrome.tabs.Tab }
|
||||
const targetTab = this.tabs.find((t) => t.id === tabId)
|
||||
if (targetTab) {
|
||||
targetTab.url = tab.url
|
||||
targetTab.title = tab.title
|
||||
targetTab.status = tab.status
|
||||
}
|
||||
}
|
||||
const result = await sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'get_window_tabs',
|
||||
payload: { windowId: this.windowId },
|
||||
})
|
||||
// sendMessage already logged the failure; keep the stale mirror
|
||||
if (!result?.success) return
|
||||
|
||||
this.port.onDisconnect.addListener(() => {
|
||||
this.port = undefined
|
||||
if (this.disposed) return
|
||||
if (this.portRetries >= 7) {
|
||||
console.error(PREFIX, 'tab events port failed after 7 retries, giving up')
|
||||
return
|
||||
const liveTabs = (result.tabs as chrome.tabs.Tab[]).filter((t) => t.id != null)
|
||||
const liveIds = new Set(liveTabs.map((t) => t.id!))
|
||||
|
||||
const closedIds = this.tabs.filter((t) => !liveIds.has(t.id)).map((t) => t.id)
|
||||
if (closedIds.length) {
|
||||
debug('syncTabs: tabs closed', closedIds)
|
||||
this.tabs = this.tabs.filter((t) => liveIds.has(t.id))
|
||||
}
|
||||
|
||||
const newTabs: TabMeta[] = []
|
||||
for (const live of liveTabs) {
|
||||
const tracked = this.tabs.find((t) => t.id === live.id)
|
||||
if (tracked) {
|
||||
tracked.url = live.url
|
||||
tracked.title = live.title
|
||||
tracked.status = live.status as TabMeta['status']
|
||||
} else if (this.shouldTrack(live)) {
|
||||
debug('syncTabs: new tab', live.id, live.url)
|
||||
const meta: TabMeta = {
|
||||
id: live.id!,
|
||||
isInitial: false,
|
||||
url: live.url,
|
||||
title: live.title,
|
||||
status: live.status,
|
||||
}
|
||||
this.addTab(meta)
|
||||
newTabs.push(meta)
|
||||
}
|
||||
debug('port disconnected, reconnecting...')
|
||||
this.portRetries++
|
||||
this.connectTabEvents()
|
||||
})
|
||||
}
|
||||
|
||||
// Follow the page like a user would: focus the newest tab it opened.
|
||||
// If the current tab is gone, fall back to the last tracked one.
|
||||
if (newTabs.length) {
|
||||
await this.switchToTab(newTabs[newTabs.length - 1].id)
|
||||
} else if (this.currentTabId != null && !this.tabs.find((t) => t.id === this.currentTabId)) {
|
||||
const fallback = this.tabs[this.tabs.length - 1]
|
||||
if (fallback) {
|
||||
await this.switchToTab(fallback.id)
|
||||
} else {
|
||||
debug('syncTabs: no fallback tab found, updating current tab to null')
|
||||
await this.updateCurrentTabId(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private shouldTrack(tab: chrome.tabs.Tab): boolean {
|
||||
if (this.tabGroupId != null && tab.groupId === this.tabGroupId) return true
|
||||
return (
|
||||
this.experimentalIncludeAllTabs &&
|
||||
tab.windowId === this.windowId &&
|
||||
!tab.pinned &&
|
||||
isContentScriptAllowed(tab.url)
|
||||
)
|
||||
}
|
||||
|
||||
dispose() {
|
||||
debug('dispose')
|
||||
this.disposed = true
|
||||
this.port?.disconnect()
|
||||
this.port = undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,29 +423,33 @@ function randomColor(): TabGroupColor {
|
||||
|
||||
/**
|
||||
* Wait until condition becomes true
|
||||
* @returns Returns when condition becomes true, throws otherwise
|
||||
* @param timeoutMS Timeout in milliseconds, default 1 minutes, throws error on timeout
|
||||
* @param error Error object to reject on timeout. If not provided, will resolve with false
|
||||
* @returns Returns when condition becomes true, false if timeout
|
||||
* @param timeoutMS Timeout in milliseconds, default 1 minutes
|
||||
* @param throwIfTimeout Reject on timeout instead of resolving with `false`
|
||||
*/
|
||||
export async function waitUntil(
|
||||
async function waitUntil(
|
||||
check: () => boolean | Promise<boolean>,
|
||||
timeoutMS = 60_000,
|
||||
error?: string
|
||||
throwIfTimeout = false
|
||||
): Promise<boolean> {
|
||||
if (await check()) return true
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now()
|
||||
const poll = async () => {
|
||||
if (await check()) return resolve(true)
|
||||
if (Date.now() - start > timeoutMS) {
|
||||
if (error) {
|
||||
return reject(new Error(error))
|
||||
} else {
|
||||
return resolve(false)
|
||||
try {
|
||||
if (await check()) return resolve(true)
|
||||
if (Date.now() - start > timeoutMS) {
|
||||
if (throwIfTimeout) {
|
||||
return reject(new Error(`waitUntil timed out after ${timeoutMS}ms`))
|
||||
} else {
|
||||
return resolve(false)
|
||||
}
|
||||
}
|
||||
setTimeout(poll, 100)
|
||||
} catch (err) {
|
||||
reject(err instanceof Error ? err : new Error(String(err)))
|
||||
}
|
||||
setTimeout(poll, 100)
|
||||
}
|
||||
setTimeout(poll, 100)
|
||||
})
|
||||
|
||||
@@ -37,7 +37,7 @@ and system messages wrapped in <sys> tag.
|
||||
<user_request>
|
||||
USER REQUEST: This is your ultimate objective and always remains visible.
|
||||
- This has the highest priority. Make the user happy.
|
||||
- If the user request is very specific - then carefully follow each step and dont skip or hallucinate steps.
|
||||
- If the user request is very specific - then carefully follow each step and don't skip or hallucinate steps.
|
||||
- If the task is open ended you can plan yourself how to get it done.
|
||||
</user_request>
|
||||
|
||||
@@ -72,7 +72,6 @@ Strictly follow these rules while using the browser and navigating the web:
|
||||
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
|
||||
- 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { handlePageControlMessage } from '@/agent/RemotePageController.background'
|
||||
import { handleTabControlMessage, setupTabEventsPort } from '@/agent/TabsController.background'
|
||||
import { handleTabControlMessage } from '@/agent/TabsController.background'
|
||||
|
||||
export default defineBackground(() => {
|
||||
console.log('[Background] Service Worker started')
|
||||
|
||||
// tab change events
|
||||
|
||||
setupTabEventsPort()
|
||||
|
||||
// generate user auth token
|
||||
|
||||
chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => {
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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.1",
|
||||
"version": "1.12.1",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
@@ -43,6 +43,8 @@
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --project llms",
|
||||
"test:live": "vitest run --project llms:live",
|
||||
"prepublishOnly": "node ../../scripts/pre-publish.js",
|
||||
"postpublish": "node ../../scripts/post-publish.js"
|
||||
},
|
||||
@@ -53,6 +55,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,18 +3,25 @@
|
||||
*/
|
||||
import * as z from 'zod/v4'
|
||||
|
||||
import { InvokeError, InvokeErrorType } from './errors'
|
||||
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
|
||||
import { InvokeError, InvokeErrorTypes } from './errors'
|
||||
import type {
|
||||
InvokeOptions,
|
||||
InvokeResult,
|
||||
LLMClient,
|
||||
Message,
|
||||
ResolvedLLMConfig,
|
||||
Tool,
|
||||
} from './types'
|
||||
import { modelPatch, zodToOpenAITool } from './utils'
|
||||
|
||||
/**
|
||||
* Client for OpenAI compatible APIs
|
||||
*/
|
||||
export class OpenAIClient implements LLMClient {
|
||||
config: Required<LLMConfig>
|
||||
config: ResolvedLLMConfig
|
||||
private fetch: typeof globalThis.fetch
|
||||
|
||||
constructor(config: Required<LLMConfig>) {
|
||||
constructor(config: ResolvedLLMConfig) {
|
||||
this.config = config
|
||||
this.fetch = config.customFetch
|
||||
}
|
||||
@@ -25,6 +32,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))
|
||||
|
||||
@@ -37,20 +46,24 @@ export class OpenAIClient implements LLMClient {
|
||||
|
||||
const requestBody: Record<string, unknown> = {
|
||||
model: this.config.model,
|
||||
temperature: this.config.temperature,
|
||||
messages,
|
||||
tools: openaiTools,
|
||||
parallel_tool_calls: false,
|
||||
tool_choice: toolChoice,
|
||||
}
|
||||
// Only sent if the caller explicitly set it. Most new models throw if this is set.
|
||||
if (this.config.temperature !== undefined) {
|
||||
requestBody.temperature = this.config.temperature
|
||||
}
|
||||
|
||||
modelPatch(requestBody, this.config.baseURL)
|
||||
|
||||
modelPatch(requestBody)
|
||||
let transformedBody: Record<string, unknown> | undefined
|
||||
try {
|
||||
transformedBody = this.config.transformRequestBody(requestBody)
|
||||
} catch (error) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.CONFIG_ERROR,
|
||||
InvokeErrorTypes.CONFIG_ERROR,
|
||||
`transformRequestBody failed: ${(error as Error).message}`,
|
||||
error
|
||||
)
|
||||
@@ -70,52 +83,65 @@ export class OpenAIClient implements LLMClient {
|
||||
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
|
||||
@@ -126,21 +152,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
|
||||
@@ -155,7 +181,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
|
||||
@@ -165,7 +191,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
|
||||
@@ -176,7 +202,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
|
||||
@@ -188,7 +214,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
|
||||
@@ -200,7 +226,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
|
||||
@@ -212,11 +238,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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// Internal constants
|
||||
export const LLM_MAX_RETRIES = 2
|
||||
export const DEFAULT_TEMPERATURE = 0.7 // higher randomness helps auto-recovery
|
||||
+18
-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,6 +10,8 @@ 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',
|
||||
|
||||
@@ -20,7 +22,19 @@ export const InvokeErrorType = {
|
||||
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
|
||||
@@ -35,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)
|
||||
})
|
||||
})
|
||||
+62
-67
@@ -1,34 +1,23 @@
|
||||
import { OpenAIClient } from './OpenAIClient'
|
||||
import { DEFAULT_TEMPERATURE, LLM_MAX_RETRIES } from './constants'
|
||||
import { InvokeError, InvokeErrorType } from './errors'
|
||||
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
|
||||
import { InvokeError, InvokeErrorTypes } from './errors'
|
||||
import type {
|
||||
InvokeOptions,
|
||||
InvokeResult,
|
||||
LLMClient,
|
||||
LLMConfig,
|
||||
Message,
|
||||
ResolvedLLMConfig,
|
||||
Tool,
|
||||
} from './types'
|
||||
|
||||
export { InvokeError, InvokeErrorType }
|
||||
export { InvokeError, InvokeErrorTypes }
|
||||
export type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool }
|
||||
|
||||
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
|
||||
// Runtime validation as defensive programming (types already guarantee these)
|
||||
if (!config.baseURL || !config.model) {
|
||||
throw new Error(
|
||||
'[PageAgent] LLM configuration required. Please provide: baseURL, model. ' +
|
||||
'See: https://alibaba.github.io/page-agent/docs/features/models'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
baseURL: config.baseURL,
|
||||
model: config.model,
|
||||
apiKey: config.apiKey || '',
|
||||
temperature: config.temperature ?? DEFAULT_TEMPERATURE,
|
||||
maxRetries: config.maxRetries ?? LLM_MAX_RETRIES,
|
||||
transformRequestBody: config.transformRequestBody ?? ((requestBody) => requestBody),
|
||||
disableNamedToolChoice: config.disableNamedToolChoice ?? false,
|
||||
customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM module
|
||||
*/
|
||||
export class LLM extends EventTarget {
|
||||
config: Required<LLMConfig>
|
||||
config: ResolvedLLMConfig
|
||||
client: LLMClient
|
||||
|
||||
constructor(config: LLMConfig) {
|
||||
@@ -50,65 +39,71 @@ 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!
|
||||
}
|
||||
|
||||
export function parseLLMConfig(config: LLMConfig): ResolvedLLMConfig {
|
||||
// Runtime validation as defensive programming (types already guarantee these)
|
||||
if (!config.baseURL || !config.model) {
|
||||
throw new Error(
|
||||
'[PageAgent] LLM configuration required. Please provide: baseURL, model. ' +
|
||||
'See: https://alibaba.github.io/page-agent/docs/features/models'
|
||||
)
|
||||
}
|
||||
|
||||
if (config.temperature !== undefined) {
|
||||
console.warn(
|
||||
'[PageAgent] LLMConfig.temperature is deprecated and will be removed in a future version. ' +
|
||||
'Use transformRequestBody to set it only for models you have verified accept it.'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
baseURL: config.baseURL,
|
||||
model: config.model,
|
||||
apiKey: config.apiKey || '',
|
||||
temperature: config.temperature,
|
||||
maxRetries: config.maxRetries ?? 2,
|
||||
transformRequestBody: config.transformRequestBody ?? ((requestBody) => requestBody),
|
||||
disableNamedToolChoice: config.disableNamedToolChoice ?? false,
|
||||
customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Live compatibility test — hits real provider APIs.
|
||||
*
|
||||
* Purpose: verify request formatting and `modelPatch` (see `utils.ts`) work
|
||||
* for every model below, across the providers that serve them. Not a
|
||||
* correctness/quality eval — the tool call is trivial and forced via
|
||||
* `toolChoiceName`, so a failure here means the request/response shape is
|
||||
* wrong for that model, not that the model is "dumb".
|
||||
*
|
||||
* Tests `OpenAIClient` directly (not the `LLM` retry wrapper), so a failure
|
||||
* always reflects the very first request/response — no retry can mask it.
|
||||
*
|
||||
* Per the `*.live.test.ts` convention this suite is excluded from `npm test`
|
||||
* (slow, costs tokens) — run it manually with `npm run test:live`. Each
|
||||
* provider's tests skip (not fail) when its `TESTING_*_KEY` env var is
|
||||
* absent. Keys live in the repo-root `.env`:
|
||||
*
|
||||
* TESTING_OPENROUTER_KEY=...
|
||||
* TESTING_DEEPSEEK_KEY=...
|
||||
* TESTING_ALIYUN_KEY=...
|
||||
*/
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import { dirname, resolve } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as z from 'zod/v4'
|
||||
|
||||
import { OpenAIClient } from './OpenAIClient'
|
||||
import { parseLLMConfig } from './index'
|
||||
import type { Message, Tool } from './types'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
dotenvConfig({ path: resolve(__dirname, '../../../.env'), quiet: true })
|
||||
|
||||
const TEST_TIMEOUT = 30_000
|
||||
|
||||
/**
|
||||
* Mirrors `packages/website/src/pages/docs/features/models/page.tsx`.
|
||||
* This package cannot depend on the website, so the list is duplicated here.
|
||||
* Keep both lists in sync manually when models are added or renamed.
|
||||
*/
|
||||
const MODEL_GROUPS: Record<string, string[]> = {
|
||||
Qwen: [
|
||||
'qwen3.7-max',
|
||||
'qwen3.7-plus',
|
||||
'qwen3.6-max',
|
||||
'qwen3.6-plus',
|
||||
'qwen3.6-flash',
|
||||
'qwen3.5-plus',
|
||||
'qwen3.5-flash',
|
||||
'qwen3-max',
|
||||
],
|
||||
OpenAI: [
|
||||
'gpt-5.6-sol',
|
||||
'gpt-5.6-terra',
|
||||
'gpt-5.6-luna',
|
||||
'gpt-5.5',
|
||||
'gpt-5.4',
|
||||
'gpt-5.4-mini',
|
||||
'gpt-5.4-nano',
|
||||
'gpt-5.2',
|
||||
'gpt-5.1',
|
||||
'gpt-5',
|
||||
'gpt-5-mini',
|
||||
'gpt-4.1',
|
||||
'gpt-4.1-mini',
|
||||
],
|
||||
DeepSeek: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-3.2'],
|
||||
Google: [
|
||||
'gemini-3.5-flash',
|
||||
'gemini-3.1-pro',
|
||||
'gemini-3.1-flash-lite',
|
||||
'gemini-2.5-pro',
|
||||
'gemini-2.5-flash',
|
||||
],
|
||||
Anthropic: [
|
||||
'claude-sonnet-5',
|
||||
'claude-opus-4-8',
|
||||
'claude-opus-4-7',
|
||||
'claude-opus-4-6',
|
||||
'claude-opus-4-5',
|
||||
'claude-sonnet-4-5',
|
||||
'claude-haiku-4-5',
|
||||
],
|
||||
MiniMax: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.5'],
|
||||
xAI: ['grok-4.5', 'grok-4.3', 'grok-build-0.1'],
|
||||
Tencent: ['hy3'],
|
||||
MoonshotAI: ['kimi-k2.7-code', 'kimi-k2.6', 'kimi-k2.5'],
|
||||
'Z.AI': ['glm-5.2', 'glm-5.1', 'glm-5', 'glm-4.7'],
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenRouter lists every model as `<vendor-slug>/<model-id>`, lowercase.
|
||||
* See the commented-out entries in the repo-root `.env` for real examples,
|
||||
* e.g. `x-ai/grok-4.1-fast`, `qwen/qwen3-coder-next`, `deepseek/deepseek-v3.2-exp`.
|
||||
*/
|
||||
const OPENROUTER_VENDOR_SLUG: Record<string, string> = {
|
||||
Qwen: 'qwen',
|
||||
OpenAI: 'openai',
|
||||
DeepSeek: 'deepseek',
|
||||
Google: 'google',
|
||||
Anthropic: 'anthropic',
|
||||
MiniMax: 'minimax',
|
||||
xAI: 'x-ai',
|
||||
Tencent: 'tencent',
|
||||
MoonshotAI: 'moonshotai',
|
||||
'Z.AI': 'z-ai',
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides for models whose OpenRouter id doesn't match the
|
||||
* `<vendor-slug>/<lowercased-name>` heuristic — dated snapshots, "-preview"
|
||||
* suffixes, "v"-prefixed versions, or dots instead of hyphens in the
|
||||
* version number. Verified against `GET https://openrouter.ai/api/v1/models`
|
||||
* on 2026-07-10; re-check when models are added to `MODEL_GROUPS`.
|
||||
*/
|
||||
const OPENROUTER_ID_OVERRIDES: Record<string, string> = {
|
||||
'qwen3.6-max': 'qwen/qwen3.6-max-preview',
|
||||
'qwen3.5-plus': 'qwen/qwen3.5-plus-20260420',
|
||||
'qwen3.5-flash': 'qwen/qwen3.5-flash-02-23',
|
||||
'deepseek-3.2': 'deepseek/deepseek-v3.2',
|
||||
'gemini-3.1-pro': 'google/gemini-3.1-pro-preview',
|
||||
'claude-opus-4-8': 'anthropic/claude-opus-4.8',
|
||||
'claude-opus-4-7': 'anthropic/claude-opus-4.7',
|
||||
'claude-opus-4-6': 'anthropic/claude-opus-4.6',
|
||||
'claude-opus-4-5': 'anthropic/claude-opus-4.5',
|
||||
'claude-sonnet-4-5': 'anthropic/claude-sonnet-4.5',
|
||||
'claude-haiku-4-5': 'anthropic/claude-haiku-4.5',
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a model's OpenRouter id from its brand (a `MODEL_GROUPS` key) and
|
||||
* native model name.
|
||||
*/
|
||||
function toOpenRouterModelId(brand: string, model: string): string {
|
||||
if (model in OPENROUTER_ID_OVERRIDES) return OPENROUTER_ID_OVERRIDES[model]
|
||||
const slug = OPENROUTER_VENDOR_SLUG[brand]
|
||||
if (!slug) throw new Error(`No OpenRouter vendor slug mapped for brand "${brand}"`)
|
||||
return `${slug}/${model.toLowerCase()}`
|
||||
}
|
||||
|
||||
const PROVIDERS = {
|
||||
openrouter: {
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
apiKey: process.env.TESTING_OPENROUTER_KEY,
|
||||
},
|
||||
aliyun: {
|
||||
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
apiKey: process.env.TESTING_ALIYUN_KEY,
|
||||
},
|
||||
deepseek: {
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: process.env.TESTING_DEEPSEEK_KEY,
|
||||
},
|
||||
} as const
|
||||
|
||||
const ECHO_TOOL: Tool<{ message: string }, string> = {
|
||||
description: 'Echo back the given message in uppercase.',
|
||||
inputSchema: z.object({ message: z.string() }),
|
||||
execute: async ({ message }) => message.toUpperCase(),
|
||||
}
|
||||
|
||||
const PROMPT: Message[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Call the "echo" tool with message set to "ping". You must call the tool.',
|
||||
},
|
||||
]
|
||||
|
||||
async function expectEchoToolCall(baseURL: string, apiKey: string, model: string) {
|
||||
const client = new OpenAIClient(parseLLMConfig({ baseURL, apiKey, model }))
|
||||
const result = await client.invoke(PROMPT, { echo: ECHO_TOOL }, new AbortController().signal, {
|
||||
toolChoiceName: 'echo',
|
||||
})
|
||||
expect(result.toolResult).toBe('PING')
|
||||
}
|
||||
|
||||
describe.concurrent('OpenRouter — all listed models', () => {
|
||||
const { baseURL, apiKey } = PROVIDERS.openrouter
|
||||
|
||||
for (const [brand, models] of Object.entries(MODEL_GROUPS)) {
|
||||
for (const model of models) {
|
||||
const id = toOpenRouterModelId(brand, model)
|
||||
it.skipIf(!apiKey)(
|
||||
id,
|
||||
async () => {
|
||||
await expectEchoToolCall(baseURL, apiKey!, id)
|
||||
},
|
||||
TEST_TIMEOUT
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Aliyun native ids that don't match the display name in MODEL_GROUPS.
|
||||
const ALIYUN_ID_OVERRIDES: Record<string, string> = {
|
||||
'qwen3.6-max': 'qwen3.6-max-preview',
|
||||
}
|
||||
|
||||
describe.concurrent('Aliyun DashScope — Qwen native', () => {
|
||||
const { baseURL, apiKey } = PROVIDERS.aliyun
|
||||
|
||||
for (const model of MODEL_GROUPS.Qwen) {
|
||||
const id = ALIYUN_ID_OVERRIDES[model] ?? model
|
||||
it.skipIf(!apiKey)(
|
||||
id,
|
||||
async () => {
|
||||
await expectEchoToolCall(baseURL, apiKey!, id)
|
||||
},
|
||||
TEST_TIMEOUT
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
describe.concurrent('DeepSeek — native', () => {
|
||||
const { baseURL, apiKey } = PROVIDERS.deepseek
|
||||
// deepseek-3.2 isn't served on DeepSeek's own API (only via OpenRouter
|
||||
// resellers) — its official API only accepts deepseek-v4-pro/-flash.
|
||||
const nativeModels = MODEL_GROUPS.DeepSeek.filter((model) => model !== 'deepseek-3.2')
|
||||
|
||||
for (const model of nativeModels) {
|
||||
it.skipIf(!apiKey)(
|
||||
model,
|
||||
async () => {
|
||||
await expectEchoToolCall(baseURL, apiKey!, model)
|
||||
},
|
||||
TEST_TIMEOUT
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -92,7 +92,12 @@ export interface LLMConfig {
|
||||
model: string
|
||||
apiKey?: string
|
||||
|
||||
/**
|
||||
* @deprecated No longer a standard parameter; many models reject it outright.
|
||||
* Use `transformRequestBody` to set it only for models you've verified.
|
||||
*/
|
||||
temperature?: number
|
||||
|
||||
maxRetries?: number
|
||||
|
||||
/**
|
||||
@@ -118,3 +123,5 @@ export interface LLMConfig {
|
||||
*/
|
||||
customFetch?: typeof globalThis.fetch
|
||||
}
|
||||
|
||||
export type ResolvedLLMConfig = Required<Omit<LLMConfig, 'temperature'>> & { temperature?: number }
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { modelPatch, normalizeModelName } from './utils'
|
||||
|
||||
describe('normalizeModelName', () => {
|
||||
it.each([
|
||||
['gpt-5.2', 'gpt-52'],
|
||||
['gpt_5_2', 'gpt52'],
|
||||
['GPT-52-2026-01-01', 'gpt-52-2026-01-01'],
|
||||
['openai/gpt-5.2-chat', 'gpt-52-chat'],
|
||||
['claude_sonnet4_5', 'claudesonnet45'],
|
||||
])('%s -> %s', (input, expected) => {
|
||||
expect(normalizeModelName(input)).toBe(expected)
|
||||
})
|
||||
})
|
||||
+145
-62
@@ -24,96 +24,167 @@ export function zodToOpenAITool(name: string, tool: Tool) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch model specific parameters
|
||||
* @note in-place modification
|
||||
* Patch model specific parameters. Only patches known models.
|
||||
*
|
||||
* @purpose
|
||||
* - Reconcile the differences in the parameter schema each model accepts.
|
||||
* - Disable thinking/reasoning, or lower it to the minimum where a full disable is impossible.
|
||||
* - Minimize returned tokens.
|
||||
* - Raise temperature for known smaller models to improve auto-recovery odds.
|
||||
* @note Honor temperature if explicitly set by the user
|
||||
*
|
||||
* @todo Need vendor-specific patches.
|
||||
* Local and 3rd-party hosted models may have different schema.
|
||||
*/
|
||||
export function modelPatch(body: Record<string, any>) {
|
||||
export function modelPatch(body: Record<string, any>, baseURL?: string) {
|
||||
const model: string = body.model || ''
|
||||
if (!model) return body
|
||||
|
||||
const provider = getProvider(baseURL)
|
||||
|
||||
const modelName = normalizeModelName(model)
|
||||
|
||||
if (modelName.startsWith('qwen')) {
|
||||
debug('Applying Qwen patch: use higher temperature for auto fixing')
|
||||
body.temperature = Math.max(body.temperature || 0, 1.0)
|
||||
debug('Patch Qwen: disable thinking')
|
||||
body.enable_thinking = false
|
||||
if (body.temperature === undefined && !/max|plus/.test(modelName)) {
|
||||
debug('Patch Qwen: raise temperature to 1.0')
|
||||
body.temperature = 1.0
|
||||
}
|
||||
}
|
||||
|
||||
if (modelName.startsWith('claude')) {
|
||||
debug('Applying Claude patch: disable thinking')
|
||||
if (modelName.startsWith('deepseek')) {
|
||||
debug('Patch DeepSeek: disable thinking, remove tool_choice')
|
||||
body.thinking = { type: 'disabled' }
|
||||
|
||||
// Convert tool_choice to Claude format
|
||||
if (body.tool_choice === 'required') {
|
||||
// 'required' -> { type: 'any' } (must call some tool)
|
||||
debug('Applying Claude patch: convert tool_choice "required" to { type: "any" }')
|
||||
body.tool_choice = { type: 'any' }
|
||||
} else if (body.tool_choice?.function?.name) {
|
||||
// { type: 'function', function: { name: '...' } } -> { type: 'tool', name: '...' }
|
||||
debug('Applying Claude patch: convert tool_choice format')
|
||||
body.tool_choice = { type: 'tool', name: body.tool_choice.function.name }
|
||||
}
|
||||
|
||||
// TODO: Claude naming pattern has changed
|
||||
// needs proper handling
|
||||
if (modelName.startsWith('claude-opus-4-7') || modelName.startsWith('claude-opus-47')) {
|
||||
debug('Applying Claude-4.7 patch: remove temperature')
|
||||
delete body.temperature
|
||||
}
|
||||
}
|
||||
|
||||
if (modelName.startsWith('grok')) {
|
||||
debug('Applying Grok patch: removing tool_choice')
|
||||
delete body.tool_choice
|
||||
debug('Applying Grok patch: disable reasoning and thinking')
|
||||
body.thinking = { type: 'disabled', effort: 'minimal' }
|
||||
body.reasoning = { enabled: false, effort: 'low' }
|
||||
}
|
||||
|
||||
if (modelName.startsWith('gpt')) {
|
||||
debug('Applying GPT patch: set verbosity to low')
|
||||
body.verbosity = 'low'
|
||||
if (modelName.startsWith('gpt-5')) {
|
||||
body.verbosity = 'low'
|
||||
}
|
||||
|
||||
if (modelName.startsWith('gpt-52')) {
|
||||
debug('Applying GPT-52 patch: disable reasoning')
|
||||
body.reasoning_effort = 'none'
|
||||
} else if (modelName.startsWith('gpt-51')) {
|
||||
debug('Applying GPT-51 patch: disable reasoning')
|
||||
body.reasoning_effort = 'none'
|
||||
} else if (modelName.startsWith('gpt-54')) {
|
||||
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')
|
||||
// Since gpt-5.4, /chat/completions rejects any explicit reasoning_effort
|
||||
// when function tools are present. Newer models are expected to follow.
|
||||
// - gpt-5.1 / gpt-5.2 can fully disable reasoning
|
||||
// - gpt-5 / -mini / -nano bottom out at "minimal"
|
||||
// - everything else (gpt-4.x, chat-latest, gpt-5.4+) must not receive it
|
||||
if (modelName.includes('chat-latest')) {
|
||||
debug('Patch chat-latest: omit 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'
|
||||
body.temperature = 1
|
||||
} else if (modelName.startsWith('gpt-5')) {
|
||||
debug('Applying GPT-5 patch: set reasoning effort to low')
|
||||
} else if (/^gpt-5[12](-|$)/.test(modelName)) {
|
||||
debug('Patch GPT-5.1/5.2: reasoning_effort=none')
|
||||
body.reasoning_effort = 'none'
|
||||
} else if (/^gpt-5(-|$)/.test(modelName)) {
|
||||
debug('Patch GPT-5: reasoning_effort=minimal')
|
||||
body.reasoning_effort = 'minimal'
|
||||
} else {
|
||||
debug('Patch GPT: omit reasoning_effort')
|
||||
delete body.reasoning_effort
|
||||
}
|
||||
}
|
||||
|
||||
if (modelName.startsWith('claude')) {
|
||||
if (/opus|sonnet|haiku/.test(modelName)) {
|
||||
debug('Patch Claude: disable thinking')
|
||||
body.thinking = { type: 'disabled' }
|
||||
|
||||
if (provider !== 'openrouter') {
|
||||
// Convert tool_choice to Claude format
|
||||
if (body.tool_choice === 'required') {
|
||||
// 'required' -> { type: 'any' } (must call some tool)
|
||||
debug('Applying Claude patch: convert tool_choice "required" to { type: "any" }')
|
||||
body.tool_choice = { type: 'any' }
|
||||
} else if (body.tool_choice?.function?.name) {
|
||||
// { type: 'function', function: { name: '...' } } -> { type: 'tool', name: '...' }
|
||||
debug('Applying Claude patch: convert tool_choice format')
|
||||
body.tool_choice = { type: 'tool', name: body.tool_choice.function.name }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debug('Patch Claude: reasoning_effort=low')
|
||||
body.reasoning_effort = 'low'
|
||||
|
||||
// Fable and mythos can not disable adaptive thinking.
|
||||
// Claude does not support tool_choice with extended thinking.
|
||||
// These 2 concepts are blurred. Basically no tool_choice with thinking.
|
||||
delete body.tool_choice
|
||||
}
|
||||
}
|
||||
|
||||
if (modelName.startsWith('gemini')) {
|
||||
debug('Applying Gemini patch: set reasoning effort to minimal')
|
||||
body.reasoning_effort = 'minimal'
|
||||
debug('Patch Gemini: reasoning_effort=low')
|
||||
body.reasoning_effort = 'low'
|
||||
if (/^gemini-25(?!.*pro)/.test(modelName)) {
|
||||
debug('Patch Gemini 2.5 non-Pro: reasoning_effort=none')
|
||||
body.reasoning_effort = 'none'
|
||||
} else if (
|
||||
modelName.startsWith('gemini-35-flash') ||
|
||||
modelName.startsWith('gemini-31-flash-lite') ||
|
||||
modelName.startsWith('gemini-3-flash')
|
||||
) {
|
||||
debug('Patch Gemini 3.x Flash/Lite: reasoning_effort=minimal')
|
||||
body.reasoning_effort = 'minimal'
|
||||
}
|
||||
}
|
||||
|
||||
if (modelName.startsWith('deepseek')) {
|
||||
debug('Applying DeepSeek patch: remove tool_choice')
|
||||
delete body.tool_choice
|
||||
if (modelName.startsWith('glm')) {
|
||||
debug('Patch GLM: disable thinking')
|
||||
body.thinking = { type: 'disabled' }
|
||||
}
|
||||
|
||||
if (modelName.startsWith('hy')) {
|
||||
debug('Patch Hunyuan: disable thinking, reasoning_effort=low')
|
||||
body.thinking = { type: 'disabled' }
|
||||
body.reasoning_effort = 'low'
|
||||
}
|
||||
|
||||
if (modelName.startsWith('grok')) {
|
||||
if (/^grok-4-?3/.test(modelName)) {
|
||||
debug('Patch Grok 4.3: reasoning_effort=none')
|
||||
body.reasoning_effort = 'none'
|
||||
} else if (modelName.startsWith('grok-3-mini') || modelName.startsWith('grok-code-fast')) {
|
||||
debug('Patch Grok mini/code: reasoning_effort=low')
|
||||
body.reasoning_effort = 'low'
|
||||
}
|
||||
}
|
||||
|
||||
if (modelName.startsWith('kimi')) {
|
||||
if (!modelName.includes('code')) {
|
||||
// kimi-k2.7-code cannot disable thinking
|
||||
debug('Patch Kimi: disable thinking')
|
||||
body.thinking = { type: 'disabled' }
|
||||
}
|
||||
}
|
||||
|
||||
if (modelName.startsWith('minimax')) {
|
||||
debug('Applying MiniMax patch: clamp temperature to (0, 1]')
|
||||
// MiniMax API rejects temperature = 0; clamp to a small positive value
|
||||
body.temperature = Math.max(body.temperature || 0, 0.01)
|
||||
if (body.temperature > 1) body.temperature = 1
|
||||
// MiniMax does not support parallel_tool_calls
|
||||
debug('Patch MiniMax: remove parallel_tool_calls')
|
||||
delete body.parallel_tool_calls
|
||||
|
||||
if (modelName.includes('m3')) {
|
||||
// Only M3 can disable thinking
|
||||
debug('Patch MiniMax: disable thinking')
|
||||
body.thinking = { type: 'disabled' }
|
||||
}
|
||||
}
|
||||
|
||||
// provider patches
|
||||
|
||||
if (provider === 'openrouter') {
|
||||
// openrouter use reasoning object instead of reasoning_effort
|
||||
|
||||
const reasoningEffort = body.reasoning_effort
|
||||
const reasoningDisabled =
|
||||
body.thinking?.type === 'disabled' ||
|
||||
body.enable_thinking === false ||
|
||||
reasoningEffort === 'none'
|
||||
|
||||
if (reasoningDisabled) {
|
||||
body.reasoning = { enabled: false }
|
||||
} else if (reasoningEffort) {
|
||||
body.reasoning = { enabled: true, effort: reasoningEffort }
|
||||
}
|
||||
}
|
||||
|
||||
return body
|
||||
@@ -134,7 +205,7 @@ export function modelPatch(body: Record<string, any>) {
|
||||
* They should be treated as the same model.
|
||||
* Normalize them to `gpt-52`
|
||||
*/
|
||||
function normalizeModelName(modelName: string): string {
|
||||
export function normalizeModelName(modelName: string): string {
|
||||
let normalizedName = modelName.toLowerCase()
|
||||
|
||||
// remove prefix before '/'
|
||||
@@ -150,3 +221,15 @@ function normalizeModelName(modelName: string): string {
|
||||
|
||||
return normalizedName
|
||||
}
|
||||
|
||||
export function getProvider(baseURL?: string): 'openrouter' | undefined {
|
||||
if (!baseURL) return undefined
|
||||
try {
|
||||
const url = new URL(baseURL)
|
||||
const hostname = url.hostname
|
||||
if (hostname === 'openrouter.ai') return 'openrouter'
|
||||
return undefined
|
||||
} catch (e) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"compilerOptions": {
|
||||
"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,27 @@
|
||||
import { configDefaults, defineConfig } from 'vitest/config'
|
||||
|
||||
// Convention: `*.live.test.ts` hits real provider APIs (slow, costs tokens)
|
||||
// and only runs via `npm run test:live`. Everything else runs on `npm test`.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// Suppress console output from passing tests; failed tests still get their logs.
|
||||
silent: 'passed-only',
|
||||
projects: [
|
||||
{
|
||||
test: {
|
||||
name: 'llms',
|
||||
include: ['src/**/*.test.ts'],
|
||||
exclude: [...configDefaults.exclude, 'src/**/*.live.test.ts'],
|
||||
},
|
||||
},
|
||||
{
|
||||
test: {
|
||||
name: 'llms:live',
|
||||
include: ['src/**/*.live.test.ts'],
|
||||
// Keep live provider suites under OpenRouter's ~20 req/min free-route cap.
|
||||
maxConcurrency: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@page-agent/mcp",
|
||||
"private": false,
|
||||
"version": "1.8.1",
|
||||
"version": "1.12.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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
<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://page-agent.github.io/assets/brand/page-agent-color-64.png" />
|
||||
<title>Page Agent MCP Launcher</title>
|
||||
<style>
|
||||
* {
|
||||
@@ -145,7 +142,7 @@
|
||||
<div class="card">
|
||||
<img
|
||||
class="logo"
|
||||
src="https://img.alicdn.com/imgextra/i3/O1CN01JPT4Fj1FJTfmHfNxO_!!6000000000466-49-tps-512-512.webp"
|
||||
src="https://page-agent.github.io/assets/brand/page-agent-color-512.webp"
|
||||
alt="Page Agent"
|
||||
/>
|
||||
<div class="badge">Page Agent MCP Launcher</div>
|
||||
@@ -164,7 +161,7 @@
|
||||
|
||||
<a class="store-btn" href="__STORE_URL__" target="_blank">
|
||||
<img
|
||||
src="https://img.alicdn.com/imgextra/i3/O1CN01JpW0Vo1sR3FpiZKFM_!!6000000005762-55-tps-192-192.svg"
|
||||
src="https://page-agent.github.io/assets/third-party/chrome-web-store-192.svg"
|
||||
alt=""
|
||||
/>
|
||||
<span data-i18n="install_btn">Install from Chrome Web Store</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "page-agent",
|
||||
"private": false,
|
||||
"version": "1.8.1",
|
||||
"version": "1.12.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.1",
|
||||
"@page-agent/llms": "1.8.1",
|
||||
"@page-agent/page-controller": "1.8.1",
|
||||
"@page-agent/ui": "1.8.1",
|
||||
"@page-agent/core": "1.12.1",
|
||||
"@page-agent/llms": "1.12.1",
|
||||
"@page-agent/page-controller": "1.12.1",
|
||||
"@page-agent/ui": "1.12.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.1",
|
||||
"version": "1.12.1",
|
||||
"type": "module",
|
||||
"main": "./src/PageController.ts",
|
||||
"types": "./src/PageController.ts",
|
||||
@@ -42,6 +42,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"
|
||||
},
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -208,9 +208,7 @@ export async function inputTextElement(element: HTMLElement, text: string) {
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
doc.execCommand('delete', false)
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
doc.execCommand('insertText', false, text)
|
||||
}
|
||||
|
||||
@@ -336,11 +334,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 +479,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
|
||||
}
|
||||
|
||||
@@ -600,14 +598,12 @@ export default (
|
||||
isAnyRectVisible = true
|
||||
|
||||
// Viewport check for this rect
|
||||
if (
|
||||
!(
|
||||
rect.bottom < -viewportExpansion ||
|
||||
rect.top > window.innerHeight + viewportExpansion ||
|
||||
rect.right < -viewportExpansion ||
|
||||
rect.left > window.innerWidth + viewportExpansion
|
||||
)
|
||||
) {
|
||||
if (!(
|
||||
rect.bottom < -viewportExpansion ||
|
||||
rect.top > window.innerHeight + viewportExpansion ||
|
||||
rect.right < -viewportExpansion ||
|
||||
rect.left > window.innerWidth + viewportExpansion
|
||||
)) {
|
||||
isAnyRectInViewport = true
|
||||
break // Found a visible rect in viewport, no need to check others
|
||||
}
|
||||
@@ -987,12 +983,10 @@ export default (
|
||||
rect.height > 0 &&
|
||||
!(
|
||||
// Only check non-empty rects
|
||||
(
|
||||
rect.bottom < -viewportExpansion ||
|
||||
rect.top > window.innerHeight + viewportExpansion ||
|
||||
rect.right < -viewportExpansion ||
|
||||
rect.left > window.innerWidth + viewportExpansion
|
||||
)
|
||||
rect.bottom < -viewportExpansion ||
|
||||
rect.top > window.innerHeight + viewportExpansion ||
|
||||
rect.right < -viewportExpansion ||
|
||||
rect.left > window.innerWidth + viewportExpansion
|
||||
)
|
||||
) {
|
||||
isAnyRectInViewport = true
|
||||
@@ -1105,14 +1099,12 @@ export default (
|
||||
for (const rect of rects) {
|
||||
if (rect.width === 0 || rect.height === 0) continue // Skip empty rects
|
||||
|
||||
if (
|
||||
!(
|
||||
rect.bottom < -viewportExpansion ||
|
||||
rect.top > window.innerHeight + viewportExpansion ||
|
||||
rect.right < -viewportExpansion ||
|
||||
rect.left > window.innerWidth + viewportExpansion
|
||||
)
|
||||
) {
|
||||
if (!(
|
||||
rect.bottom < -viewportExpansion ||
|
||||
rect.top > window.innerHeight + viewportExpansion ||
|
||||
rect.right < -viewportExpansion ||
|
||||
rect.left > window.innerWidth + viewportExpansion
|
||||
)) {
|
||||
return true // Found at least one rect in the viewport
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.1",
|
||||
"version": "1.12.1",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import styles from './motion.module.css'
|
||||
|
||||
export function createMotion() {
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.className = styles.wrapper
|
||||
|
||||
{
|
||||
const colorWrapper = document.createElement('div')
|
||||
colorWrapper.className = styles.colorWrapper
|
||||
wrapper.appendChild(colorWrapper)
|
||||
|
||||
const layerA = document.createElement('div')
|
||||
layerA.className = styles.colorLayer + ' ' + styles.layerA
|
||||
colorWrapper.appendChild(layerA)
|
||||
|
||||
const layerB = document.createElement('div')
|
||||
layerB.className = styles.colorLayer + ' ' + styles.layerB
|
||||
colorWrapper.appendChild(layerB)
|
||||
|
||||
const layerC = document.createElement('div')
|
||||
layerC.className = styles.colorLayer + ' ' + styles.layerC
|
||||
colorWrapper.appendChild(layerC)
|
||||
}
|
||||
|
||||
{
|
||||
const borderWrapper = document.createElement('div')
|
||||
borderWrapper.className = styles.borderWrapper
|
||||
wrapper.appendChild(borderWrapper)
|
||||
|
||||
const layerA = document.createElement('div')
|
||||
layerA.className = styles.borderLayer + ' ' + styles.layerA
|
||||
borderWrapper.appendChild(layerA)
|
||||
|
||||
const layerB = document.createElement('div')
|
||||
layerB.className = styles.borderLayer + ' ' + styles.layerB
|
||||
borderWrapper.appendChild(layerB)
|
||||
|
||||
const layerC = document.createElement('div')
|
||||
layerC.className = styles.borderLayer + ' ' + styles.layerC
|
||||
borderWrapper.appendChild(layerC)
|
||||
}
|
||||
|
||||
function show() {
|
||||
wrapper.classList.remove(styles.exit)
|
||||
wrapper.classList.remove(styles.entry)
|
||||
// Force reflow to restart animation
|
||||
void wrapper.offsetHeight
|
||||
wrapper.classList.add(styles.entry)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
wrapper.classList.remove(styles.entry)
|
||||
wrapper.classList.remove(styles.exit)
|
||||
// Force reflow to restart animation
|
||||
void wrapper.offsetHeight
|
||||
wrapper.classList.add(styles.exit)
|
||||
}
|
||||
|
||||
return {
|
||||
element: wrapper,
|
||||
show,
|
||||
hide,
|
||||
}
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
.wrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
|
||||
transform-origin: center;
|
||||
|
||||
--color-1: rgb(57, 182, 255);
|
||||
--color-2: rgb(189, 69, 251);
|
||||
--color-3: rgb(255, 87, 51);
|
||||
--color-4: rgb(255, 214, 0);
|
||||
|
||||
--blend-mode: screen;
|
||||
}
|
||||
|
||||
.colorLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
/* 变亮混合模式 */
|
||||
/* mix-blend-mode: screen; */
|
||||
/* mix-blend-mode: overlay; */
|
||||
/* mix-blend-mode: multiply; */
|
||||
mix-blend-mode: add;
|
||||
|
||||
/* 边框遮罩 - 中间透明,边缘不透明 */
|
||||
mask-image: url(https://img.alicdn.com/imgextra/i2/O1CN01iW1wfX1C0ICvoPbTq_!!6000000000018-2-tps-512-512.png);
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: calc(100% + 10px) calc(100% + 10px);
|
||||
}
|
||||
|
||||
.borderWrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
/* filter: blur(10px); */
|
||||
}
|
||||
|
||||
.borderLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
/* 变亮混合模式 */
|
||||
/* mix-blend-mode: overlay; */
|
||||
mix-blend-mode: add;
|
||||
|
||||
mask-image:
|
||||
linear-gradient(
|
||||
to right,
|
||||
black 0px,
|
||||
black 2px,
|
||||
transparent 2px,
|
||||
transparent calc(100% - 2px),
|
||||
black calc(100% - 2px),
|
||||
black 100%
|
||||
),
|
||||
linear-gradient(
|
||||
to top,
|
||||
black 0px,
|
||||
black 2px,
|
||||
transparent 2px,
|
||||
transparent calc(100% - 2px),
|
||||
black calc(100% - 2px),
|
||||
black 100%
|
||||
);
|
||||
|
||||
mask-composite: add;
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: 100% 100%;
|
||||
|
||||
/* filter: blur(100px); */
|
||||
}
|
||||
|
||||
.blueLayer {
|
||||
&.colorLayer {
|
||||
mask-position: left -5px top -5px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
/* inset: 0; */
|
||||
width: calc(max(100vw, 100vh) * 1.5);
|
||||
height: 600px;
|
||||
top: calc(50% - 300px);
|
||||
left: 50%;
|
||||
filter: blur(100px);
|
||||
background: rgb(57, 182, 255);
|
||||
animation: rotate-clockwise 4s linear infinite;
|
||||
animation-delay: -3s;
|
||||
}
|
||||
}
|
||||
|
||||
.purpleLayer {
|
||||
&.colorLayer {
|
||||
mask-position: left -3px top -7px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
/* inset: 0; */
|
||||
width: calc(max(100vw, 100vh) * 1.5);
|
||||
height: 600px;
|
||||
top: calc(50% - 300px);
|
||||
left: 50%;
|
||||
filter: blur(100px);
|
||||
background: rgb(189, 69, 251);
|
||||
animation: rotate-clockwise 4s linear infinite;
|
||||
animation-delay: -2s;
|
||||
}
|
||||
}
|
||||
|
||||
.orangeLayer {
|
||||
/* opacity: 0.5; */
|
||||
|
||||
&.colorLayer {
|
||||
mask-position: left -7px top -2px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
/* inset: 0; */
|
||||
width: calc(max(100vw, 100vh) * 1.5);
|
||||
height: 600px;
|
||||
top: calc(50% - 300px);
|
||||
left: 50%;
|
||||
filter: blur(100px);
|
||||
background: rgb(255, 87, 51);
|
||||
animation: rotate-counter-clockwise 3s linear infinite;
|
||||
animation-delay: -2s;
|
||||
}
|
||||
}
|
||||
|
||||
.yellowLayer {
|
||||
/* opacity: 0.5; */
|
||||
|
||||
&.colorLayer {
|
||||
mask-position: left -6px top -4px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
/* inset: 0; */
|
||||
width: calc(max(100vw, 100vh) * 1.5);
|
||||
height: 600px;
|
||||
top: calc(50% - 300px);
|
||||
left: 50%;
|
||||
filter: blur(100px);
|
||||
background: rgb(255, 214, 0);
|
||||
animation: rotate-counter-clockwise 4s linear infinite;
|
||||
animation-delay: -1s;
|
||||
}
|
||||
}
|
||||
|
||||
/* 旋转动画 */
|
||||
@keyframes rotate-clockwise {
|
||||
0% {
|
||||
transform: translateX(-50%) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate-counter-clockwise {
|
||||
0% {
|
||||
transform: translateX(-50%) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%) rotate(-360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wrapper-entry {
|
||||
from {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
rgb(57, 182, 255)
|
||||
rgb(189, 69, 251)
|
||||
rgb(255, 87, 51)
|
||||
rgb(255, 214, 0)
|
||||
*/
|
||||
|
||||
@keyframes mask-running {
|
||||
from {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mask-running-reverse {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
}
|
||||
|
||||
.colorWrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
.colorLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
mix-blend-mode: var(--blend-mode);
|
||||
|
||||
/* 边框遮罩 - 中间透明,边缘不透明 */
|
||||
mask-image: url(https://img.alicdn.com/imgextra/i2/O1CN01iW1wfX1C0ICvoPbTq_!!6000000000018-2-tps-512-512.png);
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: 100% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.borderWrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
--blend-mode: lighten;
|
||||
|
||||
.borderLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
mix-blend-mode: var(--blend-mode);
|
||||
|
||||
mask-border: url(https://img.alicdn.com/imgextra/i3/O1CN01bFjRug1yssyWEUbKL_!!6000000006635-2-tps-256-256.png)
|
||||
25;
|
||||
-webkit-mask-box-image: url(https://img.alicdn.com/imgextra/i3/O1CN01bFjRug1yssyWEUbKL_!!6000000006635-2-tps-256-256.png)
|
||||
25;
|
||||
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: 100% 100%;
|
||||
|
||||
background-color: var(--color-2);
|
||||
}
|
||||
}
|
||||
|
||||
.entry .colorWrapper,
|
||||
.entry .borderWrapper {
|
||||
animation: wrapper-entry 0.8s ease-in-out forwards;
|
||||
}
|
||||
|
||||
.exit .colorWrapper,
|
||||
.exit .borderWrapper {
|
||||
animation: wrapper-entry 0.8s ease-in-out reverse forwards;
|
||||
}
|
||||
|
||||
.layerA {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
&::before {
|
||||
mix-blend-mode: var(--blend-mode);
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: -100%;
|
||||
top: 0;
|
||||
background-image: linear-gradient(
|
||||
to right bottom,
|
||||
transparent,
|
||||
var(--color-1),
|
||||
transparent,
|
||||
var(--color-1),
|
||||
transparent
|
||||
);
|
||||
animation: mask-running 2s linear infinite;
|
||||
}
|
||||
|
||||
&::after {
|
||||
mix-blend-mode: var(--blend-mode);
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
background-image: linear-gradient(
|
||||
to right bottom,
|
||||
transparent,
|
||||
var(--color-1),
|
||||
transparent,
|
||||
var(--color-1),
|
||||
transparent
|
||||
);
|
||||
animation: mask-running 2s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.layerB {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
&::before {
|
||||
mix-blend-mode: var(--blend-mode);
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: -100%;
|
||||
top: 0;
|
||||
background: linear-gradient(
|
||||
to right top,
|
||||
transparent,
|
||||
var(--color-2),
|
||||
transparent,
|
||||
var(--color-2),
|
||||
transparent
|
||||
);
|
||||
animation: mask-running-reverse 3s linear infinite;
|
||||
}
|
||||
|
||||
&::after {
|
||||
mix-blend-mode: var(--blend-mode);
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
background: linear-gradient(
|
||||
to right top,
|
||||
transparent,
|
||||
var(--color-2),
|
||||
transparent,
|
||||
var(--color-2),
|
||||
transparent
|
||||
);
|
||||
animation: mask-running-reverse 3s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.layerC {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
opacity: 0.5;
|
||||
|
||||
&::before {
|
||||
mix-blend-mode: var(--blend-mode);
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: -100%;
|
||||
top: 0;
|
||||
background: linear-gradient(
|
||||
to right top,
|
||||
transparent,
|
||||
var(--color-3),
|
||||
transparent,
|
||||
var(--color-3),
|
||||
transparent
|
||||
);
|
||||
animation: mask-running 1s linear infinite;
|
||||
}
|
||||
|
||||
&::after {
|
||||
mix-blend-mode: var(--blend-mode);
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
background: linear-gradient(
|
||||
to right top,
|
||||
transparent,
|
||||
var(--color-3),
|
||||
transparent,
|
||||
var(--color-3),
|
||||
transparent
|
||||
);
|
||||
animation: mask-running 1s linear infinite;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
This is the CSS implementation of ai-motion.
|
||||
|
||||
Easy to use but Terrible performance. Causing full screen glitching in some browsers.
|
||||
|
||||
Use it only in a small area.
|
||||
@@ -68,7 +68,7 @@ export class Panel {
|
||||
this.#i18n = new I18n(config.language ?? 'en-US')
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="https://img.alicdn.com/imgextra/i2/O1CN012eGDRI1X6nnMt9clU_!!6000000002875-49-tps-64-64.webp"
|
||||
type="image/webp"
|
||||
href="https://page-agent.github.io/assets/brand/page-agent-color-64.webp"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PageAgent - The GUI Agent Living in Your Webpage</title>
|
||||
@@ -19,7 +19,7 @@
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://img.alicdn.com/imgextra/i3/O1CN01JPT4Fj1FJTfmHfNxO_!!6000000000466-49-tps-512-512.webp"
|
||||
content="https://page-agent.github.io/assets/brand/page-agent-color-512.webp"
|
||||
/>
|
||||
<meta property="og:url" content="https://alibaba.github.io/page-agent" />
|
||||
<meta property="og:type" content="website" />
|
||||
@@ -33,6 +33,7 @@
|
||||
<meta property="og:locale:alternate" content="zh_CN" />
|
||||
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<!-- cspell:disable -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-HCGRJTN3HM"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || []
|
||||
@@ -43,6 +44,7 @@
|
||||
|
||||
gtag('config', 'G-HCGRJTN3HM')
|
||||
</script>
|
||||
<!-- cspell:enable -->
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@page-agent/website",
|
||||
"private": true,
|
||||
"version": "1.8.1",
|
||||
"version": "1.12.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-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-separator": "^1.1.11",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-switch": "^1.3.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.12",
|
||||
"@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.24.0",
|
||||
"motion": "^12.42.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"rough-notation": "^0.5.1",
|
||||
"simple-icons": "^16.16.0",
|
||||
"simple-icons": "^16.25.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"wouter": "^3.9.0"
|
||||
"wouter": "^3.10.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function Header() {
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<img
|
||||
src="https://img.alicdn.com/imgextra/i2/O1CN01HB8ylu1uozANEMZw2_!!6000000006085-49-tps-128-128.webp"
|
||||
src="https://page-agent.github.io/assets/brand/page-agent-color-128.webp"
|
||||
alt="PageAgent Logo"
|
||||
className="w-10 h-10 rounded-xl group-hover:scale-110 transition-transform duration-200"
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Demo build (auto-init with demo LLM, for quick testing)
|
||||
export const CDN_DEMO_URL =
|
||||
'https://cdn.jsdelivr.net/npm/page-agent@1.8.1/dist/iife/page-agent.demo.js'
|
||||
'https://cdn.jsdelivr.net/npm/page-agent@1.12.1/dist/iife/page-agent.demo.js'
|
||||
export const CDN_DEMO_CN_URL =
|
||||
'https://registry.npmmirror.com/page-agent/1.8.1/files/dist/iife/page-agent.demo.js'
|
||||
'https://registry.npmmirror.com/page-agent/1.12.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'
|
||||
|
||||
@@ -9,13 +9,15 @@ export function useGitHubStars() {
|
||||
|
||||
useEffect(() => {
|
||||
if (cached !== null) return
|
||||
fetch(STATS_URL)
|
||||
const controller = new AbortController()
|
||||
fetch(STATS_URL, { signal: controller.signal })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
cached = data.stargazers_count ?? null
|
||||
setStars(cached)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
return stars
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -143,13 +143,6 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
required: false,
|
||||
description: 'LLM AK',
|
||||
},
|
||||
{
|
||||
name: 'temperature',
|
||||
type: 'number',
|
||||
description: isZh
|
||||
? '模型温度参数,控制输出随机性'
|
||||
: 'Model temperature, controls output randomness',
|
||||
},
|
||||
{
|
||||
name: 'maxRetries',
|
||||
type: 'number',
|
||||
@@ -281,8 +274,8 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-4">
|
||||
<p className="text-amber-800 dark:text-amber-200 text-sm">
|
||||
{isZh
|
||||
? '这些接口高度实验性,可能在未来版本中发生变化。'
|
||||
: 'These APIs are highly experimental and may change in future versions. '}
|
||||
? '这些接口高度实验性,可能在未来版本中发生变化。钩子中抛出的错误会使任务失败并从 execute() 抛出;如不希望影响任务,请在钩子内部自行捕获。'
|
||||
: 'These APIs are highly experimental and may change in future versions. Errors thrown from hooks fail the run and propagate from execute(); catch errors inside the hook if the task should not be affected.'}
|
||||
</p>
|
||||
</div>
|
||||
<APIReference
|
||||
@@ -325,7 +318,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',
|
||||
},
|
||||
{
|
||||
@@ -354,10 +347,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.',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -373,15 +366,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()',
|
||||
@@ -415,8 +408,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)
|
||||
|
||||
@@ -6,10 +6,10 @@ import { Heading } from '@/components/Heading'
|
||||
import { useLanguage } from '@/i18n/context'
|
||||
|
||||
const BASELINE = new Set([
|
||||
'gpt-5.1',
|
||||
'gpt-5.4-mini',
|
||||
'claude-haiku-4.5',
|
||||
'gemini-3.1-flash-lite',
|
||||
'gpt-5.4-nano',
|
||||
'claude-haiku-4-5',
|
||||
'gemini-3.5-flash',
|
||||
'deepseek-v4-flash',
|
||||
'qwen3.5-plus',
|
||||
'qwen3.5-flash',
|
||||
@@ -18,16 +18,20 @@ 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',
|
||||
'qwen3-max',
|
||||
// 'qwen3-coder-next', // low success rate
|
||||
],
|
||||
OpenAI: [
|
||||
'gpt-5.6-sol',
|
||||
'gpt-5.6-terra',
|
||||
'gpt-5.6-luna',
|
||||
'gpt-5.5',
|
||||
'gpt-5.4',
|
||||
'gpt-5.4-mini',
|
||||
@@ -40,19 +44,32 @@ const MODEL_GROUPS: Record<string, string[]> = {
|
||||
'gpt-4.1-mini',
|
||||
],
|
||||
DeepSeek: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-3.2'],
|
||||
Google: ['gemini-3.1-flash-lite', 'gemini-3-pro', 'gemini-3-flash', 'gemini-2.5'],
|
||||
Anthropic: [
|
||||
'claude-opus-4.7',
|
||||
'claude-opus-4.6',
|
||||
'claude-opus-4.5',
|
||||
'claude-sonnet-4.5',
|
||||
'claude-haiku-4.5',
|
||||
'claude-sonnet-3.5',
|
||||
Google: [
|
||||
'gemini-3.5-flash',
|
||||
'gemini-3.1-pro',
|
||||
'gemini-3.1-flash-lite',
|
||||
'gemini-2.5-pro',
|
||||
'gemini-2.5-flash',
|
||||
],
|
||||
MiniMax: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'],
|
||||
xAI: ['grok-4.1-fast', 'grok-4', 'grok-code-fast'],
|
||||
MoonshotAI: ['kimi-k2.5'],
|
||||
'Z.AI': ['glm-5', 'glm-4.7'],
|
||||
Anthropic: [
|
||||
'claude-sonnet-5',
|
||||
'claude-fable-5',
|
||||
'claude-opus-4-8',
|
||||
'claude-opus-4-7',
|
||||
'claude-opus-4-6',
|
||||
'claude-opus-4-5',
|
||||
'claude-sonnet-4-5',
|
||||
'claude-haiku-4-5',
|
||||
],
|
||||
MiniMax: [
|
||||
// 'MiniMax-M3', low success rate
|
||||
'MiniMax-M2.7',
|
||||
'MiniMax-M2.5',
|
||||
],
|
||||
xAI: ['grok-4.5', 'grok-4.3', 'grok-build-0.1'],
|
||||
Tencent: ['hy3'],
|
||||
MoonshotAI: ['kimi-k2.7-code', 'kimi-k2.6', 'kimi-k2.5'],
|
||||
'Z.AI': ['glm-5.2', 'glm-5.1', 'glm-5', 'glm-4.7'],
|
||||
}
|
||||
|
||||
const ModelBadge = ({ model, baseline }: { model: string; baseline?: boolean }) => (
|
||||
@@ -82,7 +99,7 @@ export default function Models() {
|
||||
|
||||
<section className="mb-10">
|
||||
<Heading id="tested-models" className="text-2xl font-semibold mb-3">
|
||||
{isZh ? '已测试模型' : 'Tested Models'}
|
||||
{isZh ? '模型列表' : 'Model List'}
|
||||
</Heading>
|
||||
<div className="bg-linear-to-br from-emerald-50 to-cyan-50 dark:from-emerald-950/30 dark:to-cyan-950/30 rounded-xl p-6 border border-emerald-200/50 dark:border-emerald-800/50">
|
||||
<div className="grid grid-cols-[5rem_1fr] gap-x-3 gap-y-3 items-start">
|
||||
@@ -115,8 +132,8 @@ export default function Models() {
|
||||
</li>
|
||||
<li>
|
||||
{isZh
|
||||
? 'ToolCall 能力较弱的模型可能返回错误的格式,常见错误能够自动恢复,建议设置较高的 temperature'
|
||||
: 'Models with weaker ToolCall capabilities may return incorrect formats. Common errors usually auto-recover. Higher temperature recommended'}
|
||||
? 'ToolCall 能力较弱的模型可能返回错误的格式,常见错误能够自动恢复'
|
||||
: 'Models with weaker ToolCall capabilities may return incorrect formats. Common errors usually auto-recover'}
|
||||
</li>
|
||||
<li>
|
||||
{isZh
|
||||
@@ -272,7 +289,7 @@ LLM_MODEL_NAME="qwen3.5-plus"`}
|
||||
code={`const pageAgent = new PageAgent({
|
||||
baseURL: 'https://your-claude-proxy.example/v1',
|
||||
apiKey: 'your-api-key',
|
||||
model: 'claude-sonnet-4.5',
|
||||
model: 'claude-sonnet-5',
|
||||
transformRequestBody: (requestBody) => ({
|
||||
...requestBody,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -17,11 +17,17 @@ import { useLanguage } from '../../i18n/context'
|
||||
|
||||
let pageAgentModule: Promise<typeof import('page-agent')> | null = null
|
||||
|
||||
function getInjection(useCN?: boolean) {
|
||||
const cdn = useCN ? CDN_DEMO_CN_URL : CDN_DEMO_URL
|
||||
/**
|
||||
* Get the bookmarklet injection script
|
||||
* @param cdnSource Which CDN mirror to use
|
||||
* @param isZh Whether to use Chinese language
|
||||
*/
|
||||
function getInjection(cdnSource: 'china' | 'international', isZh?: boolean) {
|
||||
const cdn = cdnSource === 'china' ? CDN_DEMO_CN_URL : CDN_DEMO_URL
|
||||
const locale = isZh ? 'zh-CN' : 'en-US'
|
||||
|
||||
const injection = encodeURI(
|
||||
`javascript:(function(){var s=document.createElement('script');s.src=\`${cdn}?t=\${Math.random()}\`;s.setAttribute('crossorigin', true);s.type="text/javascript";s.onload=()=>console.log('PageAgent script loaded!');document.body.appendChild(s);})();`
|
||||
`javascript:(function(){var s=document.createElement('script');s.src=\`${cdn}?lang=${locale}&t=\${Math.random()}\`;s.setAttribute('crossorigin', true);s.type="text/javascript";s.onload=()=>console.log('PageAgent script loaded!');document.body.appendChild(s);})();`
|
||||
)
|
||||
|
||||
return `
|
||||
@@ -298,7 +304,7 @@ export default function HeroSection() {
|
||||
</select>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getInjection(cdnSource === 'china'),
|
||||
__html: getInjection(cdnSource, isZh),
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function OneMoreThingSection() {
|
||||
className="group inline-flex items-center gap-3 px-8 py-4 bg-linear-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-medium rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105"
|
||||
>
|
||||
<img
|
||||
src="https://img.alicdn.com/imgextra/i3/O1CN01JpW0Vo1sR3FpiZKFM_!!6000000005762-55-tps-192-192.svg"
|
||||
src="https://page-agent.github.io/assets/third-party/chrome-web-store-192.svg"
|
||||
alt="Chrome Web Store"
|
||||
className="w-7 h-7"
|
||||
/>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+3
-2
@@ -36,13 +36,14 @@ if (isMainBranch()) {
|
||||
run('commitlint', `npx commitlint --from ${from} --to HEAD`)
|
||||
}
|
||||
|
||||
// 2. Lint + Format + Typecheck in parallel
|
||||
console.log(chalk.bgBlue.white.bold(' ▸ lint + format + typecheck '))
|
||||
// 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 }
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user