Compare commits
305 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| f5591dd3ea | |||
| 62a0d988ce | |||
| dc41969503 | |||
| 9d9a4b9698 | |||
| b171bf210f | |||
| c39678dece | |||
| 6c1f552606 | |||
| dcad67d4b4 | |||
| 15f3af406c | |||
| b50abcc4ec | |||
| 20f1b1f51e | |||
| cd035cd30f | |||
| a7cc935453 | |||
| 349609614b | |||
| ad680cee99 | |||
| e95b2d6a28 | |||
| f55c1b984b | |||
| 5ab34a2f50 | |||
| de24d97a22 | |||
| c6f09375f8 | |||
| cc33297cc4 | |||
| 8556745645 | |||
| 317b2a1f10 | |||
| 7bc2fa4d59 | |||
| 9d5c5a8969 | |||
| 052d302f16 | |||
| bb4da7fd86 | |||
| 359e6eadc8 | |||
| e41a2609b3 | |||
| b23c909953 | |||
| 79bf9e2aee | |||
| d9d8d558a9 | |||
| 6afd3dcbdf | |||
| 5809fe3249 | |||
| e626e0b8ce | |||
| 284098ff0d | |||
| 90e1c297f4 | |||
| 23adbea744 | |||
| 9af3a3f73e | |||
| cc27ff9305 | |||
| 8a75c15557 | |||
| e9eaf44bdd | |||
| 2ad2d06d01 | |||
| 8bf7abfe76 | |||
| 099a1251cd | |||
| 354397a1b4 | |||
| a1d9e11c05 | |||
| 33538cca05 | |||
| 50f4866142 | |||
| 28a2ebb817 | |||
| 7a08022c72 | |||
| 78c829d955 | |||
| 5008345ce8 | |||
| a1b7684bf2 | |||
| da67f3b07e | |||
| c72ff95670 | |||
| d7a38abdbd | |||
| 6fed95b365 | |||
| 0099c1ca8b | |||
| c5c7e8af55 | |||
| 83476da97f | |||
| 4b6b591849 | |||
| b5ec9c4406 | |||
| ca197d14e9 | |||
| acfb336a9a | |||
| 4d27d49752 | |||
| b46693f525 | |||
| c3720ce6b3 | |||
| f68c73c5e9 | |||
| 58d992d875 | |||
| e9784a16d0 | |||
| ebe3a3f7c3 | |||
| 9ebb629aca | |||
| 4f80ec1459 | |||
| 9104064e8c | |||
| a43e653a74 | |||
| 3bffd76626 | |||
| 015101d6ae | |||
| 8904e602dd | |||
| d384967e41 | |||
| 531b59f538 | |||
| bef26c3feb | |||
| c48dd538a0 | |||
| 950767c1bf | |||
| 12b6001d53 | |||
| 2c1972ed89 | |||
| 362bf822f7 | |||
| 4734e2df26 | |||
| c2648c1749 | |||
| 7071a6c26a | |||
| 303ece84fd | |||
| 8a0266ce8d | |||
| cb55daf47d | |||
| 43415d3139 | |||
| 9cc9edf17e | |||
| e290e2f67a | |||
| 4272939217 | |||
| 148bdb6839 | |||
| 68cee4cc64 | |||
| 7e9027167d | |||
| 6630774dc9 | |||
| 0ca1c8de0b | |||
| 85a33ac1a4 | |||
| b8fd1aaebc | |||
| 13d952dc9d | |||
| 1f2f5a44d3 | |||
| 8159aa58a6 | |||
| 2bdb3be81c | |||
| bde630f55d | |||
| 3efef0ec42 | |||
| 1141287733 | |||
| ec08b05da1 | |||
| 5ea2edd13c | |||
| 1999bbeda9 | |||
| b3ec571341 | |||
| d0de8ad838 | |||
| 0402a6be03 | |||
| 6823e04ca9 | |||
| 9ae1527201 | |||
| 2b20b48dff | |||
| 296459924a | |||
| 8eee3b27e2 | |||
| 32d6f0c74b | |||
| 6e4094565a | |||
| da2b7b5a8f | |||
| 3207c3ffc1 | |||
| 784627a7c1 | |||
| 46d6c02c95 | |||
| 6d46719fe8 | |||
| f2b6c9dfd2 | |||
| c465fd8aa4 | |||
| 1eef785a61 | |||
| 0b4eb6b49a | |||
| cba54967b8 | |||
| 23dc3b7f7e | |||
| 49b137981c | |||
| cad033d63b | |||
| 312952ec41 | |||
| 52edd78cd4 | |||
| a7c9999d83 | |||
| 7f8ddde748 | |||
| e1fede1194 | |||
| 2322d6c46b | |||
| ac72f55731 | |||
| 5485e852bd | |||
| 48fb65d00b | |||
| 005bc8ab44 |
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
name: git-cleanup
|
||||||
|
description: Clean up local git branches and remotes accumulated from PR reviews. Use when the user asks to clean branches, remove stale remotes, or tidy up the local git state.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Git Cleanup
|
||||||
|
|
||||||
|
Clean local branches (except `main` and current branch) and non-origin remotes.
|
||||||
|
Useful after reviewing multiple PRs that leave behind tracking branches and contributor remotes.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### 1. Survey
|
||||||
|
|
||||||
|
Run these in parallel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git branch
|
||||||
|
git remote
|
||||||
|
git status --short
|
||||||
|
git stash list
|
||||||
|
```
|
||||||
|
|
||||||
|
Present a summary of how many branches and remotes will be removed.
|
||||||
|
|
||||||
|
### 2. Safety Checks — Ask Before Proceeding
|
||||||
|
|
||||||
|
**Stop and ask for confirmation if any of these are true:**
|
||||||
|
|
||||||
|
- Current branch is NOT `main`
|
||||||
|
- Working tree has uncommitted changes or stashes
|
||||||
|
- Any branch is ahead of its upstream
|
||||||
|
|
||||||
|
List unmerged branches separately and let the user decide.
|
||||||
|
|
||||||
|
### 3. Delete Branches
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Safe delete first (fails on unmerged branches)
|
||||||
|
git branch | grep -v '^\*' | grep -v '^\s*main$' | xargs git branch -d 2>&1
|
||||||
|
|
||||||
|
# Force-delete only with explicit user approval
|
||||||
|
git branch -D <branch>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Remove Remotes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git remote | grep -v '^origin$' | xargs -I{} git remote remove {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Confirm
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git branch && echo "---" && git remote
|
||||||
|
```
|
||||||
|
|
||||||
|
Report what was cleaned up.
|
||||||
|
|
||||||
|
## Key Rules
|
||||||
|
|
||||||
|
- **NEVER** delete `main` or the current checked-out branch
|
||||||
|
- **NEVER** force-delete unmerged branches without user confirmation
|
||||||
|
- If in doubt, ask
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
name: pre-impl-discussion
|
||||||
|
description: "Conduct a thorough pre-implementation discussion before making significant changes. Use when the user wants to discuss, plan, or evaluate a change before implementing it — especially when they say words like 'discuss', 'evaluate', 'plan', or 'let's talk about'."
|
||||||
|
argument-hint: 'Describe the change to evaluate'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pre-Implementation Discussion
|
||||||
|
|
||||||
|
Facilitate a structured, collaborative discussion to evaluate a proposed change **before any implementation begins**.
|
||||||
|
|
||||||
|
## Golden Rule
|
||||||
|
|
||||||
|
**Do NOT implement anything until the user explicitly confirms the final plan and says to proceed.** Not even "let me try on a branch" — that's implementation. The user will tell you when discussion is over.
|
||||||
|
|
||||||
|
## Interaction Style
|
||||||
|
|
||||||
|
- **Concise during discussion.** Each intermediate response should be short and focused on the current question. Do NOT repeat the full plan in every response.
|
||||||
|
- **Complete when finalizing.** Once the plan is mature and the user asks for it, present a single comprehensive (but terse) summary for final review.
|
||||||
|
- **Ask, don't assume.** When uncertain about project context, constraints, or preferences — ask. The user prefers collaborative discussion over receiving a pre-baked answer.
|
||||||
|
- **Challenge the premise.** Question whether the proposed change is the right one. Suggest simpler alternatives if they exist.
|
||||||
|
- **Match the user's language.** Reply in the same language the user writes in.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
### 1. Understand the Current State
|
||||||
|
|
||||||
|
Before forming any opinion:
|
||||||
|
|
||||||
|
- **Read the relevant source files** — configs, code, docs that relate to the change
|
||||||
|
- **Understand the project structure** — what's published vs private, what environments things target, existing constraints
|
||||||
|
- **Map the impact surface** — which files, packages, or systems would be affected
|
||||||
|
- **Estimate implementation cost** — how many files to touch, how much config to rewrite, what could break
|
||||||
|
|
||||||
|
Do NOT skip this step. Do NOT rely on assumptions about what "most projects" do.
|
||||||
|
|
||||||
|
### 2. Research Thoroughly
|
||||||
|
|
||||||
|
- **Consult official sources** — docs, changelogs, migration guides, GitHub issues
|
||||||
|
- **Verify every claim with evidence.** Don't say "X supports Y" without a source. If unsure, say so.
|
||||||
|
- **Search in parallel** to save time — batch independent queries
|
||||||
|
|
||||||
|
Common pitfalls:
|
||||||
|
|
||||||
|
- Assuming compatibility without checking actual version constraints
|
||||||
|
- Confusing roadmap/aspirations with actual released state
|
||||||
|
- Missing transitive constraints (a dependency of a dependency)
|
||||||
|
|
||||||
|
### 3. Present Findings (Concise)
|
||||||
|
|
||||||
|
Share a **brief** assessment. Tables work well for comparisons. Highlight **blockers** and **unknowns** prominently — don't bury them in paragraphs.
|
||||||
|
|
||||||
|
### 4. Identify Key Decisions
|
||||||
|
|
||||||
|
Surface the decisions the user needs to make. Present them as clear choices with trade-offs, not as a recommendation monologue.
|
||||||
|
|
||||||
|
For each decision point:
|
||||||
|
|
||||||
|
- What are the options? (2-3 max)
|
||||||
|
- What does each option cost or give up?
|
||||||
|
- What's your lean and why? (one sentence)
|
||||||
|
|
||||||
|
### 5. Iterate
|
||||||
|
|
||||||
|
The user will ask follow-up questions, raise concerns, or challenge assumptions. For each round:
|
||||||
|
|
||||||
|
- **Research if needed** — don't answer from instinct when facts are available
|
||||||
|
- **Answer only what was asked** — don't re-present the entire plan
|
||||||
|
- **Update your mental model** based on user feedback
|
||||||
|
|
||||||
|
Common mistakes in this phase:
|
||||||
|
|
||||||
|
- Repeating the full plan after every small clarification
|
||||||
|
- Answering a narrow question with a broad redesign
|
||||||
|
- Treating user questions as confirmation to proceed
|
||||||
|
|
||||||
|
### 6. Finalize the Plan
|
||||||
|
|
||||||
|
When the user signals the discussion is converging, present the **complete plan once**:
|
||||||
|
|
||||||
|
- **What changes** — concrete list of actions (table or bullet points)
|
||||||
|
- **Impact surface** — what files/systems are affected, estimated effort
|
||||||
|
- **What does NOT change** — explicitly state scope boundaries
|
||||||
|
- **Risks or open items** — anything that needs testing or further verification
|
||||||
|
|
||||||
|
Keep it terse. Tables over paragraphs. No explanations the user already heard during discussion.
|
||||||
|
|
||||||
|
### 7. Wait for Confirmation
|
||||||
|
|
||||||
|
After presenting the final plan, **stop and wait**. The user will either:
|
||||||
|
|
||||||
|
- Confirm → then (and only then) proceed to implementation
|
||||||
|
- Ask more questions → go back to step 5
|
||||||
|
- Modify scope → update the plan and re-present
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
| Don't | Do Instead |
|
||||||
|
| --------------------------------------------------- | ---------------------------------------- |
|
||||||
|
| Start implementation "to test" without confirmation | Present findings and wait |
|
||||||
|
| Repeat the full plan in every response | Answer the specific question asked |
|
||||||
|
| Say "X should work" without checking | Say "I need to verify X" and research it |
|
||||||
|
| Assume project structure or constraints | Read the actual files |
|
||||||
|
| Present one recommendation as the only option | Present 2-3 options with trade-offs |
|
||||||
|
| Write long paragraphs explaining trade-offs | Use tables and bullet points |
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
name: submit-pr-from-current-changes
|
||||||
|
description: 'Create a branch, commit existing local changes, push them, and open a pull request. Use when submitting current work as a PR.'
|
||||||
|
argument-hint: 'Short summary of the current changes to submit'
|
||||||
|
user-invocable: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Submit PR From Current Changes
|
||||||
|
|
||||||
|
Turn a working tree diff into a clean branch, commit, and pull request.
|
||||||
|
|
||||||
|
## Hard Rules
|
||||||
|
|
||||||
|
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. **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.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before attempting to push or open a PR, verify that the necessary tools are available:
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
2. **Inspect the diff.** Review changed files, scope, and impact. Understand what the change does before writing anything.
|
||||||
|
3. **Inspect repo conventions.** Check recent branch names (`git branch -r --sort=-committerdate | head -20`), recent commit messages (`git log --oneline -20`), and the PR template.
|
||||||
|
4. **Pre-submission health checks** (run all before creating the PR):
|
||||||
|
- **Unified theme**: All changes should serve one purpose. If unrelated changes are mixed in, ask the user to split or confirm.
|
||||||
|
- **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.
|
||||||
|
- **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`)
|
||||||
|
- Multiple unrelated concerns in one diff
|
||||||
|
- No validation has been run at all
|
||||||
|
- Remind the user: _"This project does not accept low-quality or AI-generated PRs without meaningful human review. Please review your changes carefully."_
|
||||||
|
6. **Branch.** Check the current branch first:
|
||||||
|
- If already on a non-main feature branch with a valid name (matching `type/topic` convention), reuse it.
|
||||||
|
- If the branch name does not follow repo conventions (e.g. missing prefix, unclear topic), ask the user whether to rename or create a new one.
|
||||||
|
- 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. 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
|
||||||
|
|
||||||
|
After successfully opening the PR, ALWAYS give a brief reminder in the user's language. Keep it concise and natural, but make sure it clearly tells the user:
|
||||||
|
|
||||||
|
1. They need to test the changes themselves in the browser.
|
||||||
|
2. They need to go to the PR page and check the Testing and Requirements checkboxes only after verifying each item.
|
||||||
|
3. The PR will not enter review until those checkboxes are checked.
|
||||||
|
4. The project does not accept autonomously AI-generated PRs, so they should only check the AI declaration if it is truthful.
|
||||||
|
|
||||||
|
## Branch Naming
|
||||||
|
|
||||||
|
- Prefer short kebab-case names with a repo-consistent prefix such as `fix/`, `feat/`, `docs/`, `refactor/`, or `chore/`.
|
||||||
|
- Match the change type first, then the smallest useful topic.
|
||||||
|
- Prefer concrete topic words over issue text dumps.
|
||||||
|
|
||||||
|
## Commit Style
|
||||||
|
|
||||||
|
- Use the repository's prevailing commit style (conventional commits).
|
||||||
|
- Use `type(scope): subject` when scope is clear from the changed area.
|
||||||
|
- Keep the subject specific and compact.
|
||||||
|
- If multiple commits exist on the branch, each one must independently follow conventions.
|
||||||
|
|
||||||
|
## Validation Strategy
|
||||||
|
|
||||||
|
- Default to enough validation to defend the PR, not the absolute minimum.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Decision Points
|
||||||
|
|
||||||
|
- No local changes → stop, say so.
|
||||||
|
- `gh` CLI not available → stop, ask user to install it.
|
||||||
|
- Unrelated files mixed → stage only the intended subset or ask whether the work should be split.
|
||||||
|
- If the repo has area-specific instructions, read them before naming the branch or writing the PR.
|
||||||
|
- Change is small but high-risk → prefer broader validation.
|
||||||
|
- Change is narrowly scoped and low-risk → run the most relevant checks, not arbitrary unrelated ones.
|
||||||
|
- Template has declarations you cannot truthfully check → leave unchecked, tell the user why.
|
||||||
|
|
||||||
|
## Completion Checks
|
||||||
|
|
||||||
|
- The branch exists remotely and tracks upstream.
|
||||||
|
- The commit message(s) match repo style.
|
||||||
|
- 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" 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.
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
name: update-changelog
|
||||||
|
description: 'Update docs/CHANGELOG.md from git history, GitHub releases, and code diffs. Use when: writing release notes, syncing the latest changelog entry, summarizing a new tag, or keeping changelog wording concise and consistent.'
|
||||||
|
argument-hint: 'Describe the target, for example: latest version only'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Update Changelog
|
||||||
|
|
||||||
|
Update `docs/CHANGELOG.md` from repository evidence instead of guesswork.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- Add the newest release entry to `docs/CHANGELOG.md`
|
||||||
|
- Sync changelog text with GitHub Releases
|
||||||
|
- Summarize the latest tag from git history or code diffs
|
||||||
|
|
||||||
|
## Defaults
|
||||||
|
|
||||||
|
- Keep the wording brief
|
||||||
|
- Only add the latest missing version unless the user explicitly asks to backfill older releases
|
||||||
|
- Prefer GitHub sources first, especially Releases and tag metadata
|
||||||
|
- Skip `Features` / `Improvements` / `Bug Fixes` headings when the release only has a few clear items
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
### 1. Read the local style
|
||||||
|
|
||||||
|
- Open `docs/CHANGELOG.md`
|
||||||
|
- Match the existing tone, bullet style, section ordering, and date format
|
||||||
|
|
||||||
|
### 2. Determine the target release
|
||||||
|
|
||||||
|
- Read the root `package.json` version and compare it with the top changelog entry
|
||||||
|
- Find the previous tag for the target version
|
||||||
|
- If the latest version is already documented, stop and report that no changelog update is needed
|
||||||
|
|
||||||
|
### 3. Gather evidence
|
||||||
|
|
||||||
|
Prefer these sources in order:
|
||||||
|
|
||||||
|
1. GitHub release notes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GH_PAGER=cat gh release view v<version> --repo <owner>/<repo> --json tagName,name,publishedAt,body
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Tag date
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log -1 --format=%cs v<version>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Commit history between tags
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git --no-pager log --format='%h %s' --no-merges v<previous>..v<version>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Diff scope when commit subjects are vague
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git --no-pager diff --name-only v<previous>..v<version>
|
||||||
|
git --no-pager diff --stat v<previous>..v<version>
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Read touched files directly only when the user-visible change is still unclear
|
||||||
|
|
||||||
|
### 4. Distill what belongs in the changelog
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
- User-visible features
|
||||||
|
- Important behavior changes
|
||||||
|
- Bug fixes that improve reliability, compatibility, or developer experience
|
||||||
|
- Small docs updates only when they materially change supported setups or onboarding
|
||||||
|
|
||||||
|
Exclude unless explicitly requested:
|
||||||
|
|
||||||
|
- Pure version bumps
|
||||||
|
- Routine dependency updates
|
||||||
|
- Internal refactors with no visible impact
|
||||||
|
- Mechanical formatting noise
|
||||||
|
|
||||||
|
### 5. Choose the structure
|
||||||
|
|
||||||
|
- If the release has 1 to 3 clear points, write flat bullets directly under the version heading
|
||||||
|
- If the release has several distinct items, use short headings such as `### Features`, `### Improvements`, and `### Bug Fixes`
|
||||||
|
- Do not force categories when they make the entry longer or noisier
|
||||||
|
|
||||||
|
### 6. Write the entry
|
||||||
|
|
||||||
|
- Add or update only the requested release entry
|
||||||
|
- Keep bullets short and concrete
|
||||||
|
- Reuse project terminology from nearby entries
|
||||||
|
- Avoid marketing language
|
||||||
|
- Do not copy noisy GitHub release text verbatim
|
||||||
|
|
||||||
|
### 7. Verify
|
||||||
|
|
||||||
|
- Check dates, version numbers, and tag boundaries
|
||||||
|
- Ensure Markdown structure matches nearby entries
|
||||||
|
- Confirm no intermediate versions were added unless requested
|
||||||
|
|
||||||
|
## Completion Checks
|
||||||
|
|
||||||
|
- The newest requested version is documented
|
||||||
|
- The wording is concise and consistent with the surrounding changelog
|
||||||
|
- The entry is backed by GitHub releases, git history, or code diff evidence
|
||||||
|
- Low-signal internal changes were left out
|
||||||
@@ -1,61 +1,59 @@
|
|||||||
name: Bug Report
|
name: Bug Report
|
||||||
description: Report a bug
|
description: Report a bug
|
||||||
title: '[Bug] '
|
title: '[Bug] '
|
||||||
labels: ['bug']
|
type: Bug
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for your interest in improving the project! Before submitting, please read our guidelines.
|
Thanks for your interest in improving the project! Before submitting, please search for existing issues.
|
||||||
感谢您对改进项目的兴趣!提交前请阅读我们的指南。
|
感谢参与我们的社区!请先搜索现有 Issue,避免重复提交。
|
||||||
|
|
||||||
- [Code of Conduct](https://github.com/alibaba/page-agent/blob/main/docs/CODE_OF_CONDUCT.md)
|
- [Code of Conduct](https://github.com/alibaba/page-agent/blob/main/docs/CODE_OF_CONDUCT.md)
|
||||||
- [Contributing Guide](https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md)
|
- [Contributing Guide](https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md)
|
||||||
|
|
||||||
|
> 为了让更多人参与讨论,请尽量使用英文提交 Issue,感谢理解 🙏
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
label: What happened?
|
label: What happened? / 问题描述
|
||||||
placeholder: Describe the bug and expected behavior
|
description: Describe what you expected vs what actually happened.
|
||||||
|
placeholder: 'A clear description of the bug.'
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: code
|
id: reproduction
|
||||||
attributes:
|
attributes:
|
||||||
label: Code
|
label: How to reproduce / 如何复现
|
||||||
render: typescript
|
description: Provide steps, URL, config or code snippet to reproduce the bug.
|
||||||
placeholder: Minimal reproduction code
|
placeholder: '1. Open URL: https://...'
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
placeholder: '0.0.0'
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: browser
|
id: browser
|
||||||
attributes:
|
attributes:
|
||||||
label: Browser
|
label: Browser
|
||||||
placeholder: 'Chrome 120, Firefox 119, etc.'
|
placeholder: 'e.g. Chrome 130'
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: version
|
|
||||||
attributes:
|
|
||||||
label: version
|
|
||||||
placeholder: '0.0.0'
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: community
|
id: checklist
|
||||||
attributes:
|
attributes:
|
||||||
label: Community Communication / 社区沟通
|
label: Before submitting
|
||||||
description: Confirm you will communicate respectfully and constructively / 确认将以礼貌、建设性的方式沟通
|
|
||||||
options:
|
options:
|
||||||
- label: I will be polite and respectful. / 我会保持礼貌与尊重。
|
- label: I will be polite and respectful. / 我会保持礼貌与尊重。
|
||||||
required: true
|
required: true
|
||||||
- label: I will share constructive, actionable suggestions. / 我会提供建设性、可行动的建议。
|
|
||||||
required: true
|
|
||||||
- label: I have read the Code of Conduct. / 我已阅读行为准则。
|
- label: I have read the Code of Conduct. / 我已阅读行为准则。
|
||||||
required: true
|
required: true
|
||||||
|
- label: I have searched existing issues and this is not a duplicate.
|
||||||
|
required: true
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Questions & Ideas / 问题与想法(Discussions)
|
|
||||||
url: https://github.com/alibaba/page-agent/discussions
|
|
||||||
about: Use Discussions for Q&A and ideation. 使用 Discussions 进行问答与想法交流。
|
|
||||||
- name: Security Report / 安全问题报告
|
|
||||||
url: https://github.com/alibaba/page-agent/security/policy
|
|
||||||
about: Report security vulnerabilities responsibly. 通过安全页面报告漏洞。
|
|
||||||
- name: Contributing Guide / 贡献指南
|
- name: Contributing Guide / 贡献指南
|
||||||
url: https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md
|
url: https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md
|
||||||
about: How to contribute code and ideas. 如何进行贡献与提交代码。
|
about: How to contribute code and ideas. 如何进行贡献与提交代码。
|
||||||
|
|||||||
@@ -1,32 +1,28 @@
|
|||||||
name: Feature Request
|
name: Feature Request
|
||||||
description: Suggest a feature
|
description: Suggest a feature
|
||||||
title: '[Feature] '
|
title: '[Feature] '
|
||||||
labels: ['enhancement']
|
type: Feature
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for your interest in improving the project! Before submitting, please read our guidelines.
|
Thanks for your interest in improving the project! Before submitting, please search for existing issues.
|
||||||
感谢您对改进项目的兴趣!提交前请阅读我们的指南。
|
感谢参与我们的社区!请先搜索现有 Issue,避免重复提交。
|
||||||
|
|
||||||
- [Code of Conduct](https://github.com/alibaba/page-agent/blob/main/docs/CODE_OF_CONDUCT.md)
|
- [Code of Conduct](https://github.com/alibaba/page-agent/blob/main/docs/CODE_OF_CONDUCT.md)
|
||||||
- [Contributing Guide](https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md)
|
- [Contributing Guide](https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md)
|
||||||
|
|
||||||
|
> 为了让更多人参与讨论,请尽量使用英文提交 Issue,感谢理解 🙏
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
label: Feature Description / 功能描述
|
label: Feature Description / 功能描述
|
||||||
description: Describe the problem, solution, and any API changes. / 描述问题、解决方案以及 API 变更。
|
description: Describe the problem, solution, and any API changes.
|
||||||
placeholder: |
|
placeholder: |
|
||||||
**Problem**:
|
**Problem**:
|
||||||
What problem does this solve?
|
|
||||||
|
|
||||||
**Solution**:
|
**Solution**:
|
||||||
How should this work?
|
|
||||||
|
|
||||||
**Proposed API**:
|
**Proposed API**:
|
||||||
```typescript
|
|
||||||
// code here
|
|
||||||
```
|
```
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
@@ -34,14 +30,13 @@ body:
|
|||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: community
|
id: community
|
||||||
attributes:
|
attributes:
|
||||||
label: Community Communication / 社区沟通
|
label: Before submitting
|
||||||
description: Confirm you will communicate respectfully and constructively / 确认将以礼貌、建设性的方式沟通
|
|
||||||
options:
|
options:
|
||||||
- label: I will be polite and respectful. / 我会保持礼貌与尊重。
|
- label: I will be polite and respectful. / 我会保持礼貌与尊重。
|
||||||
required: true
|
required: true
|
||||||
- label: I will share constructive, actionable suggestions. / 我会提供建设性、可行动的建议。
|
|
||||||
required: true
|
|
||||||
- label: I have read the CODE_OF_CONDUCT.md and CONTRIBUTING.md. / 我已阅读行为准则。
|
- label: I have read the CODE_OF_CONDUCT.md and CONTRIBUTING.md. / 我已阅读行为准则。
|
||||||
required: true
|
required: true
|
||||||
|
- label: I have searched existing issues and this is not a duplicate.
|
||||||
|
required: true
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -2,24 +2,22 @@
|
|||||||
|
|
||||||
Brief description of changes.
|
Brief description of changes.
|
||||||
|
|
||||||
|
Closes #(issue)
|
||||||
|
|
||||||
## Type
|
## Type
|
||||||
|
|
||||||
|
- [ ] Breaking change
|
||||||
- [ ] Bug fix
|
- [ ] Bug fix
|
||||||
- [ ] Feature / Improvement
|
- [ ] Feature / Improvement
|
||||||
- [ ] Refactor
|
- [ ] Refactor / Chores
|
||||||
- [ ] Documentation
|
- [ ] Documentation / Website / Demo / Testing
|
||||||
- [ ] Website
|
|
||||||
- [ ] Demo / Testing
|
|
||||||
- [ ] Breaking change
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
- [ ] `npm run ci` passes
|
||||||
- [ ] Tested in modern browsers
|
- [ ] Tested in modern browsers
|
||||||
- [ ] No console errors
|
|
||||||
- [ ] Types/doc added
|
- [ ] Types/doc added
|
||||||
|
|
||||||
Closes #(issue)
|
|
||||||
|
|
||||||
## Requirements / 要求
|
## Requirements / 要求
|
||||||
|
|
||||||
- [ ] I have read and follow the [Code of Conduct](../docs/CODE_OF_CONDUCT.md) and [Contributing Guide](../CONTRIBUTING.md) . / 我已阅读并遵守行为准则。
|
- [ ] I have read and follow the [Code of Conduct](../docs/CODE_OF_CONDUCT.md) and [Contributing Guide](../CONTRIBUTING.md) . / 我已阅读并遵守行为准则。
|
||||||
|
|||||||
+11
-4
@@ -4,28 +4,35 @@ updates:
|
|||||||
directory: '/'
|
directory: '/'
|
||||||
schedule:
|
schedule:
|
||||||
interval: 'weekly'
|
interval: 'weekly'
|
||||||
|
labels:
|
||||||
|
- 'dependencies'
|
||||||
groups:
|
groups:
|
||||||
# 生产依赖 - 小版本更新
|
|
||||||
production-dependencies:
|
production-dependencies:
|
||||||
dependency-type: 'production'
|
dependency-type: 'production'
|
||||||
update-types:
|
update-types:
|
||||||
- 'minor'
|
- 'minor'
|
||||||
- 'patch'
|
- 'patch'
|
||||||
|
|
||||||
# 开发依赖 - 小版本更新
|
|
||||||
development-dependencies:
|
development-dependencies:
|
||||||
dependency-type: 'development'
|
dependency-type: 'development'
|
||||||
update-types:
|
update-types:
|
||||||
- 'minor'
|
- 'minor'
|
||||||
- 'patch'
|
- 'patch'
|
||||||
|
|
||||||
# Major 更新单独处理(不分组,需要人工审查)
|
development-major:
|
||||||
# 安全更新也不分组,Dependabot 会自动优先创建独立 PR
|
dependency-type: 'development'
|
||||||
|
update-types:
|
||||||
|
- 'major'
|
||||||
|
|
||||||
|
# Production major updates are intentionally ungrouped for individual review.
|
||||||
|
# Security updates are also ungrouped — Dependabot prioritizes them automatically.
|
||||||
|
|
||||||
- package-ecosystem: 'github-actions'
|
- package-ecosystem: 'github-actions'
|
||||||
directory: '/'
|
directory: '/'
|
||||||
schedule:
|
schedule:
|
||||||
interval: 'weekly'
|
interval: 'weekly'
|
||||||
|
labels:
|
||||||
|
- 'dependencies'
|
||||||
groups:
|
groups:
|
||||||
github-actions:
|
github-actions:
|
||||||
patterns:
|
patterns:
|
||||||
|
|||||||
@@ -1,41 +1,27 @@
|
|||||||
|
name: CI
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node-version: [24]
|
|
||||||
|
|
||||||
steps:
|
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
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: 24
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
# test on default version of npm
|
|
||||||
# - 9.6~10.8 on node@20
|
|
||||||
# - 11.3~11.6 on node@24
|
|
||||||
|
|
||||||
- name: Node and NPM version
|
|
||||||
run: node --version && npm --version
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm ci
|
||||||
|
|
||||||
- name: Lint
|
- name: CI checks
|
||||||
run: npx eslint . && npx prettier --check **/*.ts
|
run: node scripts/ci.js
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: npm run build
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Deploy Demo
|
name: Deploy Website
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -6,14 +6,14 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: page-agent-trusted
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pages: write
|
pages: write
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
@@ -24,17 +24,17 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build demo
|
- name: Build website
|
||||||
run: npm run build:website
|
run: npm run build:website
|
||||||
|
|
||||||
- name: Setup Pages
|
- name: Setup Pages
|
||||||
uses: actions/configure-pages@v5
|
uses: actions/configure-pages@v6
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-pages-artifact@v4
|
uses: actions/upload-pages-artifact@v5
|
||||||
with:
|
with:
|
||||||
path: './packages/website/dist'
|
path: './packages/website/dist'
|
||||||
|
|
||||||
- name: Deploy to GitHub Pages
|
- name: Deploy to GitHub Pages
|
||||||
id: deployment
|
id: deployment
|
||||||
uses: actions/deploy-pages@v4
|
uses: actions/deploy-pages@v5
|
||||||
@@ -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:
|
jobs:
|
||||||
release:
|
release:
|
||||||
|
# Only the repo owner may trigger a release, even if tag protection is bypassed
|
||||||
|
if: github.actor == 'gaomeng1900'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
@@ -21,9 +23,9 @@ jobs:
|
|||||||
node-version: 24
|
node-version: 24
|
||||||
registry-url: 'https://registry.npmjs.org'
|
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
|
- name: Update npm
|
||||||
run: npm install -g npm@latest
|
run: npm install -g npm@11
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|||||||
+4
-2
@@ -25,6 +25,9 @@ dist-ssr
|
|||||||
*.sw?
|
*.sw?
|
||||||
.qoder
|
.qoder
|
||||||
|
|
||||||
|
# publish backup
|
||||||
|
package.json.bak
|
||||||
|
|
||||||
# env files
|
# env files
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
@@ -34,8 +37,7 @@ dist-ssr
|
|||||||
.wxt
|
.wxt
|
||||||
|
|
||||||
# AI
|
# AI
|
||||||
.agent
|
|
||||||
.claude
|
.claude
|
||||||
.cursor
|
.cursor
|
||||||
.gemini
|
.gemini
|
||||||
CLAUDE.md
|
.codex
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Prompt templates (formatted manually for LLM readability)
|
||||||
|
*prompt.md
|
||||||
|
|
||||||
|
# Generated
|
||||||
|
packages/extension/.wxt
|
||||||
|
|
||||||
|
# Vendored
|
||||||
|
**/components/ui
|
||||||
Vendored
+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
+4
-34
@@ -1,39 +1,9 @@
|
|||||||
{
|
{
|
||||||
"cSpell.words": [
|
|
||||||
"contenteditable",
|
|
||||||
"deepseek",
|
|
||||||
"historychange",
|
|
||||||
"HITL",
|
|
||||||
"innerhtml",
|
|
||||||
"languagedetector",
|
|
||||||
"llms",
|
|
||||||
"magicui",
|
|
||||||
"npmmirror",
|
|
||||||
"onwarn",
|
|
||||||
"opensource",
|
|
||||||
"qwen",
|
|
||||||
"retryable",
|
|
||||||
"shadcn",
|
|
||||||
"sidepanel",
|
|
||||||
"statuschange",
|
|
||||||
"wouter"
|
|
||||||
],
|
|
||||||
"files.exclude": {
|
"files.exclude": {
|
||||||
"packages/*/node_modules": true
|
"packages/*/node_modules": true
|
||||||
},
|
},
|
||||||
"markdownlint.config": {
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
// "comment": "Relaxed rules",
|
"js/ts.tsdk.path": "node_modules/typescript/lib",
|
||||||
"default": true,
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"whitespace": false,
|
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
This is a **monorepo** with npm workspaces:
|
This is a **monorepo** with npm workspaces:
|
||||||
|
|
||||||
- **Page Agent** (`packages/page-agent/`) - Main entry with built-in UI Panel, published as `page-agent` on npm
|
- **Page Agent** (`packages/page-agent/`) - Main entry with built-in UI Panel, published as `page-agent` on npm
|
||||||
- **Extension** (`packages/extension/`) - Browser extension (WXT + React) 🚧 WIP
|
- **Extension** (`packages/extension/`) - Browser extension (WXT + React)
|
||||||
- **Website** (`packages/website/`) - React docs and landing page. **When working on website, follow `packages/website/AGENTS.md`**
|
- **Website** (`packages/website/`) - React docs and landing page. **When working on website, follow `packages/website/AGENTS.md`**
|
||||||
|
|
||||||
Internal packages:
|
Internal packages:
|
||||||
@@ -18,18 +18,20 @@ Internal packages:
|
|||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm start # Start website dev server
|
npm start # Start website dev server
|
||||||
npm run build # Build all packages
|
npm run build # Build all packages
|
||||||
npm run build:libs # Build all libraries
|
npm run build:libs # Build all libraries
|
||||||
npm run lint # ESLint with TypeScript strict rules
|
npm run build:ext # Build and zip the extension package
|
||||||
npm run zip -w @page-agent/ext # Zip the extension package
|
npm run typecheck # Typecheck all packages
|
||||||
|
npm run test # Run unit tests across all workspaces
|
||||||
|
npm run lint # ESLint
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Monorepo Structure
|
### Monorepo Structure
|
||||||
|
|
||||||
Simple monorepo solution: TypeScript references + Vite aliases. Update tsconfig and vite config when adding/removing packages.
|
Source-first monorepo: library `package.json` exports point to `src/*.ts` during development. At publish time, `scripts/pre-publish.js` promotes `publishConfig` fields to top-level (swapping to `dist/`), and `scripts/post-publish.js` restores the originals.
|
||||||
|
|
||||||
```
|
```
|
||||||
packages/
|
packages/
|
||||||
@@ -37,7 +39,7 @@ packages/
|
|||||||
├── page-agent/ # npm: "page-agent" entry class (with UI + controller + demo builds)
|
├── page-agent/ # npm: "page-agent" entry class (with UI + controller + demo builds)
|
||||||
├── website/ # @page-agent/website (private)
|
├── website/ # @page-agent/website (private)
|
||||||
├── llms/ # @page-agent/llms
|
├── llms/ # @page-agent/llms
|
||||||
├── extension/ # Browser extension (WXT + React)
|
├── extension/ # Browser extension
|
||||||
├── page-controller/ # @page-agent/page-controller
|
├── page-controller/ # @page-agent/page-controller
|
||||||
└── ui/ # @page-agent/ui
|
└── ui/ # @page-agent/ui
|
||||||
```
|
```
|
||||||
@@ -124,6 +126,21 @@ const pageInfo = await this.pageController.getPageInfo()
|
|||||||
2. Expose via async method in `PageController.ts`
|
2. Expose via async method in `PageController.ts`
|
||||||
3. Export from `packages/page-controller/src/index.ts`
|
3. Export from `packages/page-controller/src/index.ts`
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Framework**: Vitest (unit tests only for now; future E2E goes to `packages/e2e/` with Playwright)
|
||||||
|
- **Location**: co-located, `src/foo.test.ts` next to `src/foo.ts`
|
||||||
|
- **Coverage today**: `packages/llms` only — other packages will follow incrementally
|
||||||
|
- **Adding tests to a new package**: create `vitest.config.ts` in the package and add a `"test": "vitest run"` script. Root `npm test` and `node scripts/ci.js` pick it up through npm workspaces.
|
||||||
|
- **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
|
## Code Standards
|
||||||
|
|
||||||
- Explicit typing for exported/public APIs
|
- Explicit typing for exported/public APIs
|
||||||
|
|||||||
+13
-173
@@ -1,45 +1,14 @@
|
|||||||
# Contributing to PageAgent
|
# Contributing to PageAgent
|
||||||
|
|
||||||
> **[A note from the maintainer](https://github.com/alibaba/page-agent/issues/349)** — Please read before opening issues or PRs.
|
|
||||||
|
|
||||||
♥️ We welcome contributions from everyone.
|
♥️ We welcome contributions from everyone.
|
||||||
|
|
||||||
## 🚀 Quick Start
|
For local development workflows, setup, local LLM config, extension development, testing on other websites, and more details, see [docs/developer-guide.md](docs/developer-guide.md).
|
||||||
|
|
||||||
### Development Setup
|
|
||||||
|
|
||||||
1. **Prerequisites**
|
|
||||||
- `macOS` / `Linux` / `WSL`
|
|
||||||
- `node.js >= 20` with `npm >= 10`
|
|
||||||
- An editor that supports `ts/eslint/prettier`
|
|
||||||
- Make sure `eslint`, `prettier` and `commitlint` work well. Un-linted code won't pass the CI.
|
|
||||||
|
|
||||||
2. **Setup**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm i
|
|
||||||
npm start # Start demo and documentation site
|
|
||||||
npm run build # Build libs and website
|
|
||||||
```
|
|
||||||
|
|
||||||
### Project Structure
|
|
||||||
|
|
||||||
This is a **monorepo** with npm workspaces containing **4 main packages**:
|
|
||||||
|
|
||||||
- **Page Agent** (`packages/page-agent/`) - Main entry with built-in UI Panel, published as `page-agent` on npm
|
|
||||||
- **Core** (`packages/core/`) - Core agent logic without UI (npm: `@page-agent/core`)
|
|
||||||
- **Extension** (`packages/extension/`) - Chrome extension for multi-page tasks and browser-level automation
|
|
||||||
- **Website** (`packages/website/`) - React documentation and landing page. Also as demo and test page for the core lib. private package `@page-agent/website`
|
|
||||||
|
|
||||||
> We use a simplified monorepo solution with `native npm-workspace + ts reference + vite alias`. No fancy tooling. Hoisting is required.
|
|
||||||
>
|
|
||||||
> - When developing. Use alias so that we don't have to pre-build.
|
|
||||||
> - When bundling. Use external and disable ts `paths` alias.
|
|
||||||
> - When bundling `IIFE` and `Website`. Bundle everything together.
|
|
||||||
|
|
||||||
## 🤝 How to Contribute
|
## 🤝 How to Contribute
|
||||||
|
|
||||||
### Reporting Issues
|
> **[Maintainer's Note](https://github.com/alibaba/page-agent/issues/349)**
|
||||||
|
|
||||||
|
### Opening Issues
|
||||||
|
|
||||||
- Use the GitHub issue tracker to report bugs or request features
|
- Use the GitHub issue tracker to report bugs or request features
|
||||||
- Search existing issues before creating new ones
|
- Search existing issues before creating new ones
|
||||||
@@ -48,147 +17,24 @@ This is a **monorepo** with npm workspaces containing **4 main packages**:
|
|||||||
|
|
||||||
### Code Contributions
|
### Code Contributions
|
||||||
|
|
||||||
1. **Fork and Clone**
|
1. Follow existing code style and patterns
|
||||||
|
2. Update documentation as needed
|
||||||
```bash
|
3. Add JSDoc for public APIs
|
||||||
git clone https://github.com/your-username/page-agent.git
|
4. Build and lint everything
|
||||||
cd page-agent
|
5. Test in our demo website, and on other websites if applicable
|
||||||
```
|
6. Include screenshots for UI changes
|
||||||
|
|
||||||
2. **Create Feature Branch**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git checkout -b feat/your-feature-name
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Make Changes**
|
|
||||||
- Follow existing code style and patterns
|
|
||||||
- Add tests for new functionality
|
|
||||||
- Update documentation as needed
|
|
||||||
|
|
||||||
4. **Test Your Changes**
|
|
||||||
- Build and lint everything.
|
|
||||||
- Test in our demo website
|
|
||||||
- Test it on other websites if applicable
|
|
||||||
- `@TODO: test suite`
|
|
||||||
|
|
||||||
5. **Commit and Push**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add .
|
|
||||||
git commit -m "feat: add awesome feature"
|
|
||||||
git push origin feat/your-feature-name
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Create Pull Request**
|
|
||||||
- Provide clear description of changes
|
|
||||||
- Link related issues
|
|
||||||
- Include screenshots for UI changes
|
|
||||||
|
|
||||||
## 📝 Code Style
|
|
||||||
|
|
||||||
### General Guidelines
|
|
||||||
|
|
||||||
- Use TypeScript for type safety
|
|
||||||
- Follow existing naming conventions
|
|
||||||
- Write meaningful commit messages
|
|
||||||
- Keep functions small and focused
|
|
||||||
- Add JSDoc for public APIs
|
|
||||||
|
|
||||||
### Vibe Coding with AI
|
### Vibe Coding with AI
|
||||||
|
|
||||||
> [Vibe coding](https://en.wikipedia.org/wiki/Vibe_coding)
|
- Vibe coding is **NOT** allowed for the core lib or the extension!!!
|
||||||
|
|
||||||
- Vibe coding is **RECOMMENDED** when maintaining **the demo, the website, the UI and tests**.
|
- Vibe coding is **RECOMMENDED** when maintaining **the demo, the website, the UI and tests**.
|
||||||
- We have a [website/AGENTS.md](packages/website/AGENTS.md) for that.
|
- Make sure your AI references `AGENTS.md` and `website/AGENTS.md` for better quality.
|
||||||
- Vibe coding is **NOT** allowed for the core lib!!!
|
|
||||||
- NEVER try to vibe coding the MV3 extension!!! It is HELL.
|
|
||||||
- Review anything AI wrote before make a commit. You are the author of anything you commit. NOT AI.
|
- Review anything AI wrote before make a commit. You are the author of anything you commit. NOT AI.
|
||||||
|
|
||||||
If your AI assistant does not support [AGENTS.md](https://agents.md/). Add an alias for it:
|
|
||||||
|
|
||||||
- claude-code (`CLAUDE.md`)
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
@AGENTS.md
|
|
||||||
```
|
|
||||||
|
|
||||||
- antigravity (`.agent/rules/alias.md`)
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
trigger: always_on
|
|
||||||
---
|
|
||||||
|
|
||||||
@../../AGENTS.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Development Workflows
|
|
||||||
|
|
||||||
### Test With Your Own LLM API
|
|
||||||
|
|
||||||
- Create a `.env` file in the repo root with your LLM API config
|
|
||||||
|
|
||||||
```env
|
|
||||||
LLM_MODEL_NAME=gpt-5.2
|
|
||||||
LLM_API_KEY=your-api-key
|
|
||||||
LLM_BASE_URL=https://api.your-llm-provider.com/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Ollama example** (tested on 0.15 + qwen3:14b, RTX3090 24GB):
|
|
||||||
|
|
||||||
```env
|
|
||||||
LLM_BASE_URL="http://localhost:11434/v1"
|
|
||||||
LLM_API_KEY="NA"
|
|
||||||
LLM_MODEL_NAME="qwen3:14b"
|
|
||||||
```
|
|
||||||
|
|
||||||
> @see https://alibaba.github.io/page-agent/docs/features/models#ollama for configuration
|
|
||||||
|
|
||||||
- **Restart the dev server** to load new env vars
|
|
||||||
- If not provided, the demo will use the free testing proxy by default. By using it, you agree to its [terms](https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md).
|
|
||||||
|
|
||||||
### Extension Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# make sure you ran `npm run build:libs` first
|
|
||||||
# and every time you changed the core libs
|
|
||||||
npm run dev -w @page-agent/ext
|
|
||||||
npm run zip -w @page-agent/ext
|
|
||||||
```
|
|
||||||
|
|
||||||
- Update `packages/extension/docs/extension_api.md` for API integration details
|
|
||||||
|
|
||||||
### Testing on Other Websites
|
|
||||||
|
|
||||||
- Start and serve a local `iife` script
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev:demo # Serving IIFE with auto rebuild at http://localhost:5174/page-agent.demo.js
|
|
||||||
```
|
|
||||||
|
|
||||||
- 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);})();
|
|
||||||
```
|
|
||||||
|
|
||||||
- Click the bookmark on any page to load Page-Agent
|
|
||||||
|
|
||||||
> Warning: AK in your local `.env` will be inlined in the iife script. Be very careful when you distribute the script.
|
|
||||||
|
|
||||||
### Adding Documentation
|
|
||||||
|
|
||||||
Ask an AI to help you add documentation to the `website/` package. Follow the existing style.
|
|
||||||
|
|
||||||
> Our AGENTS.md file and guardrails are designed for this purpose. But please be careful and review anything AI generated.
|
|
||||||
|
|
||||||
## 🚫 What We Don't Accept
|
## 🚫 What We Don't Accept
|
||||||
|
|
||||||
- Breaking changes and large PRs without prior discussion
|
- Breaking changes and large PRs without prior discussion
|
||||||
- Heavy dependencies to core libs
|
- Heavy dependencies to core libs
|
||||||
- Contributions without proper testing
|
|
||||||
- Code that doesn't follow project conventions
|
|
||||||
- Dependencies or code with licenses incompatible with MIT
|
- Dependencies or code with licenses incompatible with MIT
|
||||||
- Bot or AI-generated pull requests without meaningful human involvement
|
- Bot or AI-generated pull requests without meaningful human involvement
|
||||||
|
|
||||||
@@ -196,12 +42,6 @@ Ask an AI to help you add documentation to the `website/` package. Follow the ex
|
|||||||
|
|
||||||
By contributing to this project, you agree that your contributions will be licensed under the MIT License.
|
By contributing to this project, you agree that your contributions will be licensed under the MIT License.
|
||||||
|
|
||||||
> CLA is optional.
|
---
|
||||||
|
|
||||||
## 💬 Questions?
|
|
||||||
|
|
||||||
- Open a GitHub issue for technical questions
|
|
||||||
- Check existing documentation and issues first
|
|
||||||
- Be respectful and constructive in discussions
|
|
||||||
|
|
||||||
Thank you for helping make PageAgent better! 🎉
|
Thank you for helping make PageAgent better! 🎉
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
# Page Agent
|
# Page Agent
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://img.alicdn.com/imgextra/i4/O1CN01qKig1P1FnhpFKNdi6_!!6000000000532-2-tps-1280-256.png">
|
<source media="(prefers-color-scheme: dark)" srcset="https://page-agent.github.io/assets/readme/banner-dark.png">
|
||||||
<img alt="Page Agent Banner" src="https://img.alicdn.com/imgextra/i1/O1CN01NCMKXj1Gn4tkFTsxf_!!6000000000666-2-tps-1280-256.png">
|
<img alt="Page Agent Banner" src="https://page-agent.github.io/assets/readme/banner-light.png">
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT) [](http://www.typescriptlang.org/) [](https://bundlephobia.com/package/page-agent) [](https://www.npmjs.com/package/page-agent) [](https://github.com/alibaba/page-agent)
|
[](https://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)
|
🌐 **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>
|
<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**
|
- **📖 Text-based DOM manipulation**
|
||||||
- No screenshots. No multi-modal LLMs or special permissions needed.
|
- No screenshots. No multi-modal LLMs or special permissions needed.
|
||||||
- **🧠 Bring your own LLMs**
|
- **🧠 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.**
|
- **🐙 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
|
- And an [MCP Server (Beta)](https://alibaba.github.io/page-agent/docs/features/mcp-server) to control it from outside
|
||||||
|
|
||||||
@@ -33,7 +45,8 @@ 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.
|
- **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.
|
- **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.
|
- **Accessibility** — Make any web app accessible through natural language. Voice commands, screen readers, zero barrier.
|
||||||
- **Multi-page Agent** — Extend your own agent's reach across browser tabs with the optional [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
|
## 🚀 Quick Start
|
||||||
|
|
||||||
@@ -42,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:
|
Fastest way to try PageAgent with our free Demo LLM:
|
||||||
|
|
||||||
```html
|
```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).
|
> **⚠️ 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 |
|
| Mirrors | URL |
|
||||||
| ------- | ---------------------------------------------------------------------------------- |
|
| ------- | ----------------------------------------------------------------------------------- |
|
||||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.6.2/dist/iife/page-agent.demo.js |
|
| 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.6.2/files/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
|
### NPM Installation
|
||||||
|
|
||||||
@@ -75,11 +92,17 @@ For more programmatic usage, see [📖 Documentations](https://alibaba.github.io
|
|||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
We welcome contributions from the community! Follow our instructions in [CONTRIBUTING.md](CONTRIBUTING.md) for setup and guidelines.
|
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.
|
||||||
|
|
||||||
Please read the [maintainer note](https://github.com/alibaba/page-agent/issues/349) and [Code of Conduct](docs/CODE_OF_CONDUCT.md) before opening issues or PRs.
|
Built something cool with PageAgent? Share it in [Show and Tell](https://github.com/alibaba/page-agent/discussions/categories/show-and-tell). 🙌
|
||||||
|
|
||||||
Contributions generated entirely by **bots or agents** without substantial human involvement will **not be accepted**.
|
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**.
|
||||||
|
|
||||||
|
## ⚖️ License
|
||||||
|
|
||||||
|
[MIT License](LICENSE)
|
||||||
|
|
||||||
## 👏 Acknowledgments
|
## 👏 Acknowledgments
|
||||||
|
|
||||||
@@ -97,23 +120,6 @@ Licensed under the MIT License
|
|||||||
We gratefully acknowledge the browser-use project and its contributors for their
|
We gratefully acknowledge the browser-use project and its contributors for their
|
||||||
excellent work on web automation and DOM interaction patterns that helped make
|
excellent work on web automation and DOM interaction patterns that helped make
|
||||||
this project possible.
|
this project possible.
|
||||||
|
|
||||||
Third-party dependencies and their licenses can be found in the package.json
|
|
||||||
file and in the node_modules directory after installation.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📄 License
|
|
||||||
|
|
||||||
[MIT License](LICENSE)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**⭐ Star this repo if you find PageAgent helpful!**
|
**⭐ Star this repo if you find PageAgent helpful!**
|
||||||
|
|
||||||
<a href="https://www.star-history.com/?repos=alibaba%2Fpage-agent&type=date">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&theme=dark&legend=top-left&v=7" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&legend=top-left&v=7" />
|
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&legend=top-left&v=7" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|||||||
@@ -5,6 +5,172 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.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
|
||||||
|
|
||||||
|
- **Optional `keepSemanticTags`** - Added an experimental `keepSemanticTags` config to preserve semantic structure in PageController output
|
||||||
|
- **Per-task extension system instructions** - Extension `ExecuteConfig` now supports `systemInstruction`
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- **Smarter scroll handling** - Scroll container detection and scroll direction handling are more reliable
|
||||||
|
- **Better accessibility-aware element detection** - Interactive candidates with supported ARIA attributes and `role="listitem"` are recognized more accurately
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixed iframe-origin filtering for extension `postMessage` listeners
|
||||||
|
- Avoided a `currentScript` null pointer during deferred initialization
|
||||||
|
|
||||||
|
## [1.7.0] - 2026-03-31
|
||||||
|
|
||||||
|
- **More reliable click actions** - Click handling now reuses pointer coordinates, verifies targets with `elementFromPoint`, and behaves better on layered layouts
|
||||||
|
- **Better mask event handling** - `SimulatorMask` now supports passthrough events when automation should not fully swallow input
|
||||||
|
- Fixed a `SimulatorMask` memory leak
|
||||||
|
|
||||||
|
## [1.6.3] - 2026-03-30
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Experimental all-tabs control** - Extension can include and control all browser tabs via `experimentalIncludeAllTabs`
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- **Calmer empty state motion** - Disabled the EmptyState auto-start animation in the extension UI
|
||||||
|
- **Cleaner extension docs** - Simplified setup and tab-control documentation across the README and developer guide
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixed new-tab detection from content scripts
|
||||||
|
- Fixed tab deduplication and multi-window handling in the extension
|
||||||
|
|
||||||
|
## [1.6.2] - 2026-03-25
|
||||||
|
|
||||||
|
- **Longer task input** - The UI task input now accepts up to 1000 characters
|
||||||
|
- **Contributor docs refresh** - Added a maintainer note and refreshed contributor-facing documentation
|
||||||
|
- Fixed lint issues in the release pipeline
|
||||||
|
|
||||||
|
## [1.6.1] - 2026-03-22
|
||||||
|
|
||||||
|
- **Internal PageController action exports** - PageController actions are now exposed as internal methods for easier reuse across packages
|
||||||
|
- **Expanded docs** - Added MCP docs and clarified project limitations and homepage details
|
||||||
|
|
||||||
## [1.6.0] - 2026-03-21
|
## [1.6.0] - 2026-03-21
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+23
-23
@@ -16,22 +16,22 @@ appearance, race, religion, or sexual identity and orientation.
|
|||||||
Examples of behavior that contributes to creating a positive environment
|
Examples of behavior that contributes to creating a positive environment
|
||||||
include:
|
include:
|
||||||
|
|
||||||
* Using welcoming and inclusive language
|
- Using welcoming and inclusive language
|
||||||
* Being respectful of differing viewpoints and experiences
|
- Being respectful of differing viewpoints and experiences
|
||||||
* Gracefully accepting constructive criticism
|
- Gracefully accepting constructive criticism
|
||||||
* Focusing on what is best for the community
|
- Focusing on what is best for the community
|
||||||
* Showing empathy towards other community members
|
- Showing empathy towards other community members
|
||||||
|
|
||||||
Examples of unacceptable behavior by participants include:
|
Examples of unacceptable behavior by participants include:
|
||||||
|
|
||||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
- The use of sexualized language or imagery and unwelcome sexual attention or
|
||||||
advances
|
advances
|
||||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||||
* Public or private harassment
|
- Public or private harassment
|
||||||
* Publishing others' private information, such as a physical or electronic
|
- Publishing others' private information, such as a physical or electronic
|
||||||
address, without explicit permission
|
address, without explicit permission
|
||||||
* Other conduct which could reasonably be considered inappropriate in a
|
- Other conduct which could reasonably be considered inappropriate in a
|
||||||
professional setting
|
professional setting
|
||||||
|
|
||||||
## Our Responsibilities
|
## Our Responsibilities
|
||||||
|
|
||||||
@@ -85,19 +85,19 @@ available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.ht
|
|||||||
|
|
||||||
有助于创造正面环境的行为包括但不限于:
|
有助于创造正面环境的行为包括但不限于:
|
||||||
|
|
||||||
* 使用友好和包容性语言
|
- 使用友好和包容性语言
|
||||||
* 尊重不同的观点和经历
|
- 尊重不同的观点和经历
|
||||||
* 耐心地接受建设性批评
|
- 耐心地接受建设性批评
|
||||||
* 关注对社区最有利的事情
|
- 关注对社区最有利的事情
|
||||||
* 友善对待其他社区成员
|
- 友善对待其他社区成员
|
||||||
|
|
||||||
身为参与者不能接受的行为包括但不限于:
|
身为参与者不能接受的行为包括但不限于:
|
||||||
|
|
||||||
* 使用与性有关的言语或是图像,以及不受欢迎的性骚扰
|
- 使用与性有关的言语或是图像,以及不受欢迎的性骚扰
|
||||||
* 捣乱/煽动/造谣的行为或进行侮辱/贬损的评论,人身攻击及政治攻击
|
- 捣乱/煽动/造谣的行为或进行侮辱/贬损的评论,人身攻击及政治攻击
|
||||||
* 公开或私下的骚扰
|
- 公开或私下的骚扰
|
||||||
* 未经许可地发布他人的个人资料,例如住址或是电子地址
|
- 未经许可地发布他人的个人资料,例如住址或是电子地址
|
||||||
* 其他可以被合理地认定为不恰当或者违反职业操守的行为
|
- 其他可以被合理地认定为不恰当或者违反职业操守的行为
|
||||||
|
|
||||||
## 我们的责任
|
## 我们的责任
|
||||||
|
|
||||||
|
|||||||
+33
-27
@@ -1,19 +1,30 @@
|
|||||||
# Page Agent
|
# Page Agent
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://img.alicdn.com/imgextra/i4/O1CN01qKig1P1FnhpFKNdi6_!!6000000000532-2-tps-1280-256.png">
|
<source media="(prefers-color-scheme: dark)" srcset="https://page-agent.github.io/assets/readme/banner-dark.png">
|
||||||
<img alt="Page Agent Banner" src="https://img.alicdn.com/imgextra/i1/O1CN01NCMKXj1Gn4tkFTsxf_!!6000000000666-2-tps-1280-256.png">
|
<img alt="Page Agent Banner" src="https://page-agent.github.io/assets/readme/banner-light.png">
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT) [](http://www.typescriptlang.org/) [](https://bundlephobia.com/package/page-agent) [](https://www.npmjs.com/package/page-agent) [](https://github.com/alibaba/page-agent)
|
[](https://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 应用。无须后端、客户端、浏览器插件。
|
纯 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) | **中文**
|
🌐 [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>
|
<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 操作**
|
- **📖 基于文本的 DOM 操作**
|
||||||
- 无需截图,无需多模态模型或特殊权限
|
- 无需截图,无需多模态模型或特殊权限
|
||||||
- **🧠 自备 LLM**
|
- **🧠 自备 LLM**
|
||||||
|
- 支持多数主流模型,包括本地部署模型。参见[支持的模型](https://alibaba.github.io/page-agent/docs/features/models)。
|
||||||
- 🐙 可选的 [Chrome 扩展](https://alibaba.github.io/page-agent/docs/features/chrome-extension),支持跨页面任务
|
- 🐙 可选的 [Chrome 扩展](https://alibaba.github.io/page-agent/docs/features/chrome-extension),支持跨页面任务
|
||||||
- [MCP Server (Beta)](https://alibaba.github.io/page-agent/docs/features/mcp-server)
|
- [MCP Server (Beta)](https://alibaba.github.io/page-agent/docs/features/mcp-server)
|
||||||
|
|
||||||
@@ -32,7 +44,8 @@
|
|||||||
- **SaaS AI Copilot** — 几行代码为你的产品加上 AI 副驾驶,无需重写后端。
|
- **SaaS AI Copilot** — 几行代码为你的产品加上 AI 副驾驶,无需重写后端。
|
||||||
- **智能表单填写** — 把 20 次点击变成一句话。ERP、CRM、管理后台的最佳拍档。
|
- **智能表单填写** — 把 20 次点击变成一句话。ERP、CRM、管理后台的最佳拍档。
|
||||||
- **无障碍增强** — 用自然语言让任何网页无障碍。语音指令、屏幕阅读器,零门槛。
|
- **无障碍增强** — 用自然语言让任何网页无障碍。语音指令、屏幕阅读器,零门槛。
|
||||||
- **跨页面 Agent** — 通过可选的 [Chrome 扩展](https://alibaba.github.io/page-agent/docs/features/chrome-extension),让你自己的 Agent 跨标签页工作。
|
- **跨页面 Agent** — 通过可选的 [Chrome 扩展](https://alibaba.github.io/page-agent/docs/features/chrome-extension),让你自己的 Web Agent 跨标签页工作。
|
||||||
|
- 通过 MCP 为现有 Agent 加入浏览器控制能力。
|
||||||
|
|
||||||
## 🚀 快速开始
|
## 🚀 快速开始
|
||||||
|
|
||||||
@@ -41,15 +54,19 @@
|
|||||||
通过我们免费的 Demo LLM 快速体验 PageAgent:
|
通过我们免费的 Demo LLM 快速体验 PageAgent:
|
||||||
|
|
||||||
```html
|
```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)。
|
> **⚠️ 仅用于技术评估。** 该 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 |
|
| Mirrors | URL |
|
||||||
| ------- | ---------------------------------------------------------------------------------- |
|
| ------- | ----------------------------------------------------------------------------------- |
|
||||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.6.2/dist/iife/page-agent.demo.js |
|
| 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.6.2/files/dist/iife/page-agent.demo.js |
|
| China | https://registry.npmmirror.com/page-agent/1.12.1/files/dist/iife/page-agent.demo.js |
|
||||||
|
|
||||||
### NPM 安装
|
### NPM 安装
|
||||||
|
|
||||||
@@ -76,11 +93,13 @@ await agent.execute('点击登录按钮')
|
|||||||
|
|
||||||
欢迎社区贡献!请参阅 [CONTRIBUTING.md](../CONTRIBUTING.md) 了解安装与贡献指南。
|
欢迎社区贡献!请参阅 [CONTRIBUTING.md](../CONTRIBUTING.md) 了解安装与贡献指南。
|
||||||
|
|
||||||
提交 issue 或 PR 之前,请先阅读[维护者说明](https://github.com/alibaba/page-agent/issues/349)和[行为准则](CODE_OF_CONDUCT.md)。
|
提交 issue 或 PR 之前,请先阅读[作者声明](https://github.com/alibaba/page-agent/issues/349)和[行为准则](CODE_OF_CONDUCT.md)。
|
||||||
|
|
||||||
我们不接受未经实质性人类参与、完全由 Bot 或 Agent 自动生成的代码,机器人账号可能被禁止参与互动。
|
我们不接受未经实质性人类参与、完全由 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)** 的优秀工作构建。
|
本项目基于 **[`browser-use`](https://github.com/browser-use/browser-use)** 的优秀工作构建。
|
||||||
|
|
||||||
@@ -96,23 +115,10 @@ Licensed under the MIT License
|
|||||||
We gratefully acknowledge the browser-use project and its contributors for their
|
We gratefully acknowledge the browser-use project and its contributors for their
|
||||||
excellent work on web automation and DOM interaction patterns that helped make
|
excellent work on web automation and DOM interaction patterns that helped make
|
||||||
this project possible.
|
this project possible.
|
||||||
|
|
||||||
Third-party dependencies and their licenses can be found in the package.json
|
|
||||||
file and in the node_modules directory after installation.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📄 许可证
|
## ⚖️ 许可证
|
||||||
|
|
||||||
[MIT License](../LICENSE)
|
[MIT License](../LICENSE)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**⭐ 如果觉得 PageAgent 有用或有趣,请给项目点个星!**
|
**⭐ 如果觉得 PageAgent 有用或有趣,请给项目点个星!**
|
||||||
|
|
||||||
<a href="https://www.star-history.com/?repos=alibaba%2Fpage-agent&type=date">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&theme=dark&legend=top-left&v=7" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&legend=top-left&v=7" />
|
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&legend=top-left&v=7" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Developer Guide
|
||||||
|
|
||||||
|
This file is for local development workflows.
|
||||||
|
|
||||||
|
For contribution rules and expectations, see [../CONTRIBUTING.md](../CONTRIBUTING.md).
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
1. **Prerequisites**
|
||||||
|
- `macOS` / `Linux` / `WSL`
|
||||||
|
- `node.js ^22.13 || >=24` with `npm >= 11`
|
||||||
|
- An editor that supports `ts/eslint/prettier`
|
||||||
|
- Make sure `eslint`, `prettier` and `commitlint` work well. Un-linted code won't pass the CI.
|
||||||
|
|
||||||
|
2. **Setup**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i # Or `npm ci` if you don't want to change the lockfile
|
||||||
|
npm start # Start website dev server
|
||||||
|
npm run build # Build everything
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 Project Structure
|
||||||
|
|
||||||
|
This is a **monorepo** with npm workspaces.
|
||||||
|
|
||||||
|
Published packages:
|
||||||
|
|
||||||
|
- **Page Agent** (`packages/page-agent/`) - Main entry with built-in UI Panel (npm: `page-agent`)
|
||||||
|
- **MCP** (`packages/mcp/`) - MCP server for browser control via Page Agent extension (npm: `@page-agent/mcp`)
|
||||||
|
- **Core** (`packages/core/`) - Core agent logic without UI (npm: `@page-agent/core`)
|
||||||
|
- **LLMs** (`packages/llms/`) - LLM client with reflection-before-action mental model
|
||||||
|
- **Page Controller** (`packages/page-controller/`) - DOM operations and visual feedback, independent of LLM
|
||||||
|
- **UI** (`packages/ui/`) - Panel and i18n, decoupled from PageAgent
|
||||||
|
|
||||||
|
Applications:
|
||||||
|
|
||||||
|
- **Extension** (`packages/extension/`) - Browser extension (WXT + React)
|
||||||
|
- **Website** (`packages/website/`) - React docs, landing page, and dev playground (private)
|
||||||
|
|
||||||
|
> Source-first monorepo with `npm workspaces + ts references + vite alias`. Library `package.json` exports point to `src/*.ts` during development, and point to `dist/*.js` when published. `workspaces` in root `package.json` must be in topological order.
|
||||||
|
|
||||||
|
## 🤖 AGENTS.md Alias
|
||||||
|
|
||||||
|
If your AI assistant does not support [AGENTS.md](https://agents.md/). Add an alias for it.
|
||||||
|
|
||||||
|
## 🔧 Development Workflows
|
||||||
|
|
||||||
|
### Test With Your Own LLM API
|
||||||
|
|
||||||
|
- Create a `.env` file in the repo root with your LLM API config
|
||||||
|
|
||||||
|
```env
|
||||||
|
LLM_MODEL_NAME=gpt-5.2
|
||||||
|
LLM_API_KEY=your-api-key
|
||||||
|
LLM_BASE_URL=https://api.your-llm-provider.com/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Ollama example** (tested on 0.15 + qwen3:14b, RTX3090 24GB):
|
||||||
|
|
||||||
|
```env
|
||||||
|
LLM_BASE_URL="http://localhost:11434/v1"
|
||||||
|
LLM_API_KEY="NA"
|
||||||
|
LLM_MODEL_NAME="qwen3:14b"
|
||||||
|
```
|
||||||
|
|
||||||
|
> @see https://alibaba.github.io/page-agent/docs/features/models#ollama for configuration
|
||||||
|
|
||||||
|
- **Restart the dev server** to load new env vars
|
||||||
|
- If not provided, the demo will use the free testing proxy by default. By using it, you agree to its [terms](./terms-and-privacy.md).
|
||||||
|
|
||||||
|
### Extension Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:ext
|
||||||
|
npm run build:ext
|
||||||
|
```
|
||||||
|
|
||||||
|
- Update `packages/extension/docs/extension_api.md` for API integration details
|
||||||
|
|
||||||
|
### Testing on Other Websites
|
||||||
|
|
||||||
|
- Start and serve a local `iife` script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:demo # Serving IIFE with auto rebuild at http://localhost:5174/page-agent.demo.js
|
||||||
|
```
|
||||||
|
|
||||||
|
- Add a new bookmark
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
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
|
||||||
|
|
||||||
|
> Warning: AK in your local `.env` will be inlined in the iife script. Be very careful when you distribute the script.
|
||||||
|
|
||||||
|
### Adding Documentation
|
||||||
|
|
||||||
|
Ask an AI to help you add documentation to the `website/` package. Follow the existing style.
|
||||||
|
|
||||||
|
> Our AGENTS.md file and guardrails are designed for this purpose. But please be careful and review anything AI generated.
|
||||||
+7
-29
@@ -1,8 +1,5 @@
|
|||||||
|
import eslintReact from '@eslint-react/eslint-plugin'
|
||||||
import js from '@eslint/js'
|
import js from '@eslint/js'
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
import globals from 'globals'
|
import globals from 'globals'
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint'
|
||||||
@@ -15,44 +12,24 @@ export default defineConfig([
|
|||||||
'**/.wxt',
|
'**/.wxt',
|
||||||
'**/.output',
|
'**/.output',
|
||||||
]),
|
]),
|
||||||
{
|
|
||||||
plugins: {
|
|
||||||
'react-hooks': reactHooks,
|
|
||||||
},
|
|
||||||
rules: reactHooks.configs.recommended.rules,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended,
|
||||||
// reactHooks.configs['recommended-latest'],
|
|
||||||
reactRefresh.configs.vite,
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
...tseslint.configs.recommendedTypeChecked,
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
...tseslint.configs.strictTypeChecked,
|
...tseslint.configs.strictTypeChecked,
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
...tseslint.configs.stylisticTypeChecked,
|
...tseslint.configs.stylisticTypeChecked,
|
||||||
|
eslintReact.configs['recommended-typescript'],
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
// project: ['./tsconfig.json'],
|
|
||||||
// project: ['./packages/*/tsconfig.json'],
|
|
||||||
// tsconfigRootDir: import.meta.dirname,
|
|
||||||
projectService: true,
|
projectService: true,
|
||||||
},
|
},
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
// Add any additional rules here
|
|
||||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||||
@@ -72,14 +49,15 @@ export default defineConfig([
|
|||||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||||
'@typescript-eslint/no-unsafe-return': 'off',
|
'@typescript-eslint/no-unsafe-return': 'off',
|
||||||
'@typescript-eslint/restrict-plus-operands': 'off',
|
'@typescript-eslint/restrict-plus-operands': 'off',
|
||||||
'react-dom/no-missing-button-type': 'off',
|
|
||||||
'react-x/no-nested-component-definitions': 'off',
|
|
||||||
'@typescript-eslint/prefer-optional-chain': 'off',
|
'@typescript-eslint/prefer-optional-chain': 'off',
|
||||||
'@typescript-eslint/use-unknown-in-catch-callback-variable': 'off',
|
'@typescript-eslint/use-unknown-in-catch-callback-variable': 'off',
|
||||||
'@typescript-eslint/no-unnecessary-type-parameters': 'off',
|
'@typescript-eslint/no-unnecessary-type-parameters': 'off',
|
||||||
|
|
||||||
// 'require-await': 'off',
|
|
||||||
'@typescript-eslint/require-await': 'off',
|
'@typescript-eslint/require-await': 'off',
|
||||||
|
'@typescript-eslint/no-deprecated': 'off',
|
||||||
|
'@eslint-react/dom-no-missing-button-type': 'off',
|
||||||
|
'@eslint-react/no-nested-component-definitions': 'off',
|
||||||
|
'@eslint-react/no-array-index-key': 'off',
|
||||||
|
'@eslint-react/dom-no-dangerously-set-innerhtml': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
Generated
+3275
-3659
File diff suppressed because it is too large
Load Diff
+38
-32
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "root",
|
"name": "root",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.6.2",
|
"version": "1.12.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/page-controller",
|
"packages/page-controller",
|
||||||
@@ -22,52 +22,55 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://alibaba.github.io/page-agent/",
|
"homepage": "https://alibaba.github.io/page-agent/",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
"node": "^22.22.1 || >=24",
|
||||||
|
"npm": "^11.6.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "npm run dev --workspace=@page-agent/website",
|
"start": "npm run dev --workspace=@page-agent/website",
|
||||||
"dev:ext": "npm run dev -w @page-agent/ext",
|
"dev:ext": "npm run dev -w @page-agent/ext",
|
||||||
"dev:demo": "npm run dev:demo --workspace=page-agent",
|
"dev:demo": "npm run dev:demo --workspace=page-agent",
|
||||||
"build": "npm run build:libs && npm run build:website",
|
"build": "node scripts/build.js",
|
||||||
"build:libs": "npm run build --workspaces --if-present",
|
"build:libs": "node scripts/build-libs.js",
|
||||||
"build:website": "npm run build:website --workspace=@page-agent/website",
|
"build:website": "npm run build:website --workspace=@page-agent/website",
|
||||||
"build:ext": "npm run build:libs && npm run zip -w @page-agent/ext",
|
"build:ext": "npm run zip -w @page-agent/ext",
|
||||||
"version": "node scripts/sync-version.js",
|
"version": "node scripts/sync-version.js",
|
||||||
|
"postpublish": "npm run postpublish --workspaces --if-present",
|
||||||
|
"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 .",
|
"lint": "eslint .",
|
||||||
"cleanup": "rm -rf packages/*/dist",
|
"ci": "node scripts/ci.js",
|
||||||
"prepare": "husky"
|
"cleanup": "rm -rf packages/*/dist && rm -rf packages/*/.output",
|
||||||
|
"prepare": "husky || true"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^20.5.0",
|
"@commitlint/cli": "^21.2.0",
|
||||||
"@commitlint/config-conventional": "^20.5.0",
|
"@commitlint/config-conventional": "^21.2.0",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint-react/eslint-plugin": "^5.11.2",
|
||||||
"@microsoft/api-extractor": "^7.57.7",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@microsoft/api-extractor": "^7.58.9",
|
||||||
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^26.1.0",
|
||||||
"@vitejs/plugin-react-swc": "^4.3.0",
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^10.0.3",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.4.2",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^10.6.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"globals": "^17.7.0",
|
||||||
"eslint-plugin-react-dom": "^2.13.0",
|
"happy-dom": "^20.10.6",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
|
||||||
"eslint-plugin-react-x": "^2.13.0",
|
|
||||||
"globals": "^17.4.0",
|
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.4.0",
|
"lint-staged": "^17.0.8",
|
||||||
"prettier": "^3.8.0",
|
"prettier": "^3.9.4",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.57.2",
|
"typescript-eslint": "^8.63.0",
|
||||||
"unplugin-dts": "^1.0.0-beta.6",
|
"unplugin-dts": "^1.0.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^8.1.3",
|
||||||
"vite-plugin-css-injected-by-js": "^4.0.1",
|
"vite-plugin-css-injected-by-js": "^5.0.1",
|
||||||
"vite-bundle-analyzer": "^1.3.6"
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,ts,cjs,cts,mjs,mts}": [
|
"*.{js,ts,cjs,cts,mjs,mts}": [
|
||||||
@@ -80,6 +83,9 @@
|
|||||||
],
|
],
|
||||||
"*.css": [
|
"*.css": [
|
||||||
"npx prettier --write --ignore-unknown"
|
"npx prettier --write --ignore-unknown"
|
||||||
|
],
|
||||||
|
"*.md": [
|
||||||
|
"npx prettier --write --ignore-unknown"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"commitlint": {
|
"commitlint": {
|
||||||
|
|||||||
+23
-12
@@ -1,16 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/core",
|
"name": "@page-agent/core",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "1.6.2",
|
"version": "1.12.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/esm/page-agent-core.js",
|
"main": "./src/PageAgentCore.ts",
|
||||||
"module": "./dist/esm/page-agent-core.js",
|
"types": "./src/PageAgentCore.ts",
|
||||||
"types": "./dist/esm/PageAgentCore.d.ts",
|
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/esm/PageAgentCore.d.ts",
|
"types": "./src/PageAgentCore.ts",
|
||||||
"import": "./dist/esm/page-agent-core.js",
|
"default": "./src/PageAgentCore.ts"
|
||||||
"default": "./dist/esm/page-agent-core.js"
|
}
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"main": "./dist/esm/page-agent-core.js",
|
||||||
|
"module": "./dist/esm/page-agent-core.js",
|
||||||
|
"types": "./dist/esm/PageAgentCore.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/esm/PageAgentCore.d.ts",
|
||||||
|
"import": "./dist/esm/page-agent-core.js",
|
||||||
|
"default": "./dist/esm/page-agent-core.js"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
@@ -38,19 +48,20 @@
|
|||||||
"homepage": "https://alibaba.github.io/page-agent/",
|
"homepage": "https://alibaba.github.io/page-agent/",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"test": "vitest run",
|
||||||
"dev:iife": "concurrently \"vite build --config vite.iife.config.js --watch\" \"npx serve dist/iife -p 5174\"",
|
"dev:iife": "concurrently \"vite build --config vite.iife.config.js --watch\" \"npx serve dist/iife -p 5174\"",
|
||||||
"prepublishOnly": "node -e \"const fs=require('fs');['README.md','LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
"prepublishOnly": "node ../../scripts/pre-publish.js",
|
||||||
"postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
"postpublish": "node ../../scripts/post-publish.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"@page-agent/llms": "1.6.2",
|
"@page-agent/llms": "1.12.1",
|
||||||
"@page-agent/page-controller": "1.6.2"
|
"@page-agent/page-controller": "1.12.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"zod": "^4.3.5"
|
"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,
|
MacroToolInput,
|
||||||
MacroToolResult,
|
MacroToolResult,
|
||||||
} from './types'
|
} from './types'
|
||||||
import { assert, fetchLlmsTxt, normalizeResponse, uid, waitFor } from './utils'
|
import { assert, fetchLlmsTxt, normalizeResponse, suppress, uid, waitFor } from './utils'
|
||||||
|
|
||||||
export { tool, type PageAgentTool } from './tools'
|
export { tool, type PageAgentTool } from './tools'
|
||||||
export type * from './types'
|
export type * from './types'
|
||||||
@@ -42,7 +42,7 @@ export type PageAgentCoreConfig = AgentConfig & { pageController: PageController
|
|||||||
* - loop
|
* - loop
|
||||||
*
|
*
|
||||||
* ## Event System
|
* ## Event System
|
||||||
* - `statuschange` - Agent status transitions (idle → running → completed/error)
|
* - `statuschange` - Agent status transitions (idle → running → completed/error/stopped)
|
||||||
* - `historychange` - History events updated (persistent, part of agent memory)
|
* - `historychange` - History events updated (persistent, part of agent memory)
|
||||||
* - `activity` - Real-time activity feedback (transient, for UI only)
|
* - `activity` - Real-time activity feedback (transient, for UI only)
|
||||||
* - `dispose` - Agent cleanup triggered
|
* - `dispose` - Agent cleanup triggered
|
||||||
@@ -73,17 +73,28 @@ export class PageAgentCore extends EventTarget {
|
|||||||
disposed = false
|
disposed = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Callback for when agent needs user input (ask_user tool)
|
* Called when the agent needs to ask the user questions.
|
||||||
* If not set, ask_user tool will be disabled
|
* If unset, the `ask_user` tool will be disabled.
|
||||||
|
* Implementations should reject the promise when `signal` aborts.
|
||||||
* @example onAskUser: (q) => window.prompt(q) || ''
|
* @example onAskUser: (q) => window.prompt(q) || ''
|
||||||
*/
|
*/
|
||||||
onAskUser?: (question: string) => Promise<string>
|
onAskUser?: (question: string, options?: { signal: AbortSignal }) => Promise<string>
|
||||||
|
|
||||||
#status: AgentStatus = 'idle'
|
#status: AgentStatus = 'idle'
|
||||||
#llm: LLM
|
#llm: LLM
|
||||||
|
/**
|
||||||
|
* Task cancellation primitive: its signal reaches the LLM fetch, tools
|
||||||
|
* (via `ctx.signal`) and async callbacks. Aborted only by `stop`/`dispose`
|
||||||
|
* (during a task) or task setup, always WITHOUT a reason so `signal.reason`
|
||||||
|
* stays a standard `AbortError`.
|
||||||
|
*/
|
||||||
#abortController = new AbortController()
|
#abortController = new AbortController()
|
||||||
#observations: string[] = []
|
#observations: string[] = []
|
||||||
|
|
||||||
|
/** Resolves when the current run has fully settled. Awaited by `stop()`. */
|
||||||
|
#running: Promise<void> = Promise.resolve()
|
||||||
|
#lastResult: ExecutionResult | null = null
|
||||||
|
|
||||||
/** internal states during a single task execution */
|
/** internal states during a single task execution */
|
||||||
#states = {
|
#states = {
|
||||||
/** Accumulated wait time in seconds */
|
/** Accumulated wait time in seconds */
|
||||||
@@ -103,11 +114,14 @@ export class PageAgentCore extends EventTarget {
|
|||||||
this.tools = new Map(tools)
|
this.tools = new Map(tools)
|
||||||
this.pageController = config.pageController
|
this.pageController = config.pageController
|
||||||
|
|
||||||
// Listen to LLM retry events
|
|
||||||
this.#llm.addEventListener('retry', (e) => {
|
this.#llm.addEventListener('retry', (e) => {
|
||||||
const { attempt, maxAttempts } = (e as CustomEvent).detail
|
const { attempt, maxAttempts, lastError } = (e as CustomEvent).detail
|
||||||
this.#emitActivity({ type: 'retrying', attempt, maxAttempts })
|
this.#emitActivity({ type: 'retrying', attempt, maxAttempts })
|
||||||
// Also push to history for panel rendering
|
this.history.push({
|
||||||
|
type: 'error',
|
||||||
|
message: String(lastError),
|
||||||
|
rawResponse: (lastError as InvokeError).rawResponse,
|
||||||
|
})
|
||||||
this.history.push({
|
this.history.push({
|
||||||
type: 'retry',
|
type: 'retry',
|
||||||
message: `LLM retry attempt ${attempt} of ${maxAttempts}`,
|
message: `LLM retry attempt ${attempt} of ${maxAttempts}`,
|
||||||
@@ -116,19 +130,6 @@ export class PageAgentCore extends EventTarget {
|
|||||||
})
|
})
|
||||||
this.#emitHistoryChange()
|
this.#emitHistoryChange()
|
||||||
})
|
})
|
||||||
this.#llm.addEventListener('error', (e) => {
|
|
||||||
const error = (e as CustomEvent).detail.error as Error | InvokeError
|
|
||||||
if ((error as any)?.rawError?.name === 'AbortError') return
|
|
||||||
const message = String(error)
|
|
||||||
this.#emitActivity({ type: 'error', message })
|
|
||||||
// Also push to history for panel rendering
|
|
||||||
this.history.push({
|
|
||||||
type: 'error',
|
|
||||||
message,
|
|
||||||
rawResponse: (error as InvokeError).rawResponse,
|
|
||||||
})
|
|
||||||
this.#emitHistoryChange()
|
|
||||||
})
|
|
||||||
|
|
||||||
if (this.config.customTools) {
|
if (this.config.customTools) {
|
||||||
for (const [name, tool] of Object.entries(this.config.customTools)) {
|
for (const [name, tool] of Object.entries(this.config.customTools)) {
|
||||||
@@ -150,13 +151,19 @@ export class PageAgentCore extends EventTarget {
|
|||||||
return this.#status
|
return this.#status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of the most recent run, or `null` before the first run completes. */
|
||||||
|
get lastResult(): ExecutionResult | null {
|
||||||
|
return this.#lastResult
|
||||||
|
}
|
||||||
|
|
||||||
/** Emit statuschange event */
|
/** Emit statuschange event */
|
||||||
#emitStatusChange(): void {
|
#emitStatusChange(): void {
|
||||||
this.dispatchEvent(new Event('statuschange'))
|
this.dispatchEvent(new Event('statuschange'))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Emit historychange event */
|
/** Emit historychange event */
|
||||||
#emitHistoryChange(): void {
|
#emitHistoryChange(pushHistoricalEvent?: HistoricalEvent): void {
|
||||||
|
if (pushHistoricalEvent) this.history.push(pushHistoricalEvent)
|
||||||
this.dispatchEvent(new Event('historychange'))
|
this.dispatchEvent(new Event('historychange'))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,165 +193,184 @@ export class PageAgentCore extends EventTarget {
|
|||||||
this.#observations.push(content)
|
this.#observations.push(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop the current task. Agent remains reusable. */
|
/**
|
||||||
stop() {
|
* Stop the current task and wait until the run has fully settled (including lifecycle hooks).
|
||||||
this.pageController.cleanUpHighlights()
|
* @note never await .stop() in a lifecycle hook.
|
||||||
this.pageController.hideMask()
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (this.#status !== 'running') return
|
||||||
this.#abortController.abort()
|
this.#abortController.abort()
|
||||||
|
await this.#running
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* external errors (pre-checks/config/hooks) will threw;
|
||||||
|
* agent errors will be caught and added to history, and return a failed result
|
||||||
|
*/
|
||||||
async execute(task: string): Promise<ExecutionResult> {
|
async execute(task: string): Promise<ExecutionResult> {
|
||||||
|
// pre-checks
|
||||||
if (this.disposed) throw new Error('PageAgent has been disposed. Create a new instance.')
|
if (this.disposed) throw new Error('PageAgent has been disposed. Create a new instance.')
|
||||||
|
if (this.#status === 'running') throw new Error('A task is already running.')
|
||||||
if (!task) throw new Error('Task is required')
|
if (!task) throw new Error('Task is required')
|
||||||
|
|
||||||
this.task = task
|
this.task = task
|
||||||
this.taskId = uid()
|
this.taskId = uid()
|
||||||
|
|
||||||
|
this.history = []
|
||||||
|
this.#observations = []
|
||||||
|
this.#states = { totalWaitTime: 0, lastURL: '', browserState: null }
|
||||||
|
this.#abortController = new AbortController()
|
||||||
|
const signal = this.#abortController.signal
|
||||||
|
|
||||||
|
let resolveRunning!: () => void
|
||||||
|
this.#running = new Promise<void>((r) => (resolveRunning = r))
|
||||||
|
|
||||||
|
this.#setStatus('running')
|
||||||
|
this.#emitHistoryChange()
|
||||||
|
|
||||||
// Disable ask_user tool if onAskUser is not set
|
// Disable ask_user tool if onAskUser is not set
|
||||||
if (!this.onAskUser) {
|
if (!this.onAskUser) this.tools.delete('ask_user')
|
||||||
this.tools.delete('ask_user')
|
|
||||||
}
|
|
||||||
|
|
||||||
const onBeforeStep = this.config.onBeforeStep
|
const onBeforeStep = this.config.onBeforeStep
|
||||||
const onAfterStep = this.config.onAfterStep
|
const onAfterStep = this.config.onAfterStep
|
||||||
const onBeforeTask = this.config.onBeforeTask
|
const onBeforeTask = this.config.onBeforeTask
|
||||||
const onAfterTask = this.config.onAfterTask
|
const onAfterTask = this.config.onAfterTask
|
||||||
|
const stepDelay = this.config.stepDelay ?? 0.4
|
||||||
await onBeforeTask?.(this)
|
const maxSteps = this.config.maxSteps
|
||||||
|
|
||||||
// Show mask
|
|
||||||
await this.pageController.showMask()
|
|
||||||
|
|
||||||
if (this.#abortController) {
|
|
||||||
this.#abortController.abort()
|
|
||||||
this.#abortController = new AbortController()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.history = []
|
|
||||||
this.#setStatus('running')
|
|
||||||
this.#emitHistoryChange()
|
|
||||||
this.#observations = []
|
|
||||||
|
|
||||||
// Reset internal states
|
|
||||||
this.#states = { totalWaitTime: 0, lastURL: '', browserState: null }
|
|
||||||
|
|
||||||
let step = 0
|
let step = 0
|
||||||
|
let taskResult: ExecutionResult
|
||||||
|
let finalStatus: AgentStatus = 'error'
|
||||||
|
|
||||||
while (true) {
|
await suppress(() => this.pageController.showMask())
|
||||||
try {
|
|
||||||
console.group(`step: ${step}`)
|
|
||||||
|
|
||||||
|
// graceful exit
|
||||||
|
try {
|
||||||
|
await onBeforeTask?.(this)
|
||||||
|
|
||||||
|
while (true) {
|
||||||
await onBeforeStep?.(this, step)
|
await onBeforeStep?.(this, step)
|
||||||
|
|
||||||
// observe
|
// handle internal agent errors
|
||||||
|
try {
|
||||||
|
console.group(`step: ${step}`)
|
||||||
|
|
||||||
console.log(chalk.blue.bold('👀 Observing...'))
|
// @note It's convenient to treat stepDelay as part of the next step.
|
||||||
|
// Maybe move it to a dedicated try block for better semantics?
|
||||||
|
if (step > 0) await waitFor(stepDelay, signal)
|
||||||
|
|
||||||
this.#states.browserState = await this.pageController.getBrowserState()
|
signal.throwIfAborted()
|
||||||
await this.#handleObservations(step)
|
|
||||||
|
|
||||||
// assemble prompts
|
// observe
|
||||||
|
|
||||||
const messages = [
|
console.log(chalk.blue.bold('👀 Observing...'))
|
||||||
{ role: 'system' as const, content: this.#getSystemPrompt() },
|
|
||||||
{ role: 'user' as const, content: await this.#assembleUserPrompt() },
|
|
||||||
]
|
|
||||||
|
|
||||||
const macroTool = { AgentOutput: this.#packMacroTool() }
|
this.#states.browserState = await this.pageController.getBrowserState()
|
||||||
|
await this.#handleObservations(step)
|
||||||
|
|
||||||
// invoke LLM
|
// assemble prompts
|
||||||
|
|
||||||
console.log(chalk.blue.bold('🧠 Thinking...'))
|
const messages = [
|
||||||
this.#emitActivity({ type: 'thinking' })
|
{ role: 'system' as const, content: this.#getSystemPrompt() },
|
||||||
|
{ role: 'user' as const, content: await this.#assembleUserPrompt() },
|
||||||
|
]
|
||||||
|
|
||||||
const result = await this.#llm.invoke(messages, macroTool, this.#abortController.signal, {
|
const macroTool = { AgentOutput: this.#packMacroTool() }
|
||||||
toolChoiceName: 'AgentOutput',
|
|
||||||
normalizeResponse: (res) => normalizeResponse(res, this.tools),
|
|
||||||
})
|
|
||||||
|
|
||||||
// assemble history
|
// invoke LLM
|
||||||
|
|
||||||
const macroResult = result.toolResult as MacroToolResult
|
console.log(chalk.blue.bold('🧠 Thinking...'))
|
||||||
const input = macroResult.input
|
this.#emitActivity({ type: 'thinking' })
|
||||||
const output = macroResult.output
|
|
||||||
const reflection: Partial<AgentReflection> = {
|
|
||||||
evaluation_previous_goal: input.evaluation_previous_goal,
|
|
||||||
memory: input.memory,
|
|
||||||
next_goal: input.next_goal,
|
|
||||||
}
|
|
||||||
const actionName = Object.keys(input.action)[0]
|
|
||||||
const action: AgentStepEvent['action'] = {
|
|
||||||
name: actionName,
|
|
||||||
input: input.action[actionName],
|
|
||||||
output: output,
|
|
||||||
}
|
|
||||||
|
|
||||||
this.history.push({
|
const result = await this.#llm.invoke(messages, macroTool, signal, {
|
||||||
type: 'step',
|
toolChoiceName: 'AgentOutput',
|
||||||
stepIndex: step,
|
normalizeResponse: (res) => normalizeResponse(res, this.tools),
|
||||||
reflection,
|
})
|
||||||
action,
|
|
||||||
usage: result.usage,
|
|
||||||
rawResponse: result.rawResponse,
|
|
||||||
rawRequest: result.rawRequest,
|
|
||||||
} as AgentStepEvent)
|
|
||||||
this.#emitHistoryChange()
|
|
||||||
|
|
||||||
//
|
// assemble history
|
||||||
|
|
||||||
await onAfterStep?.(this, this.history)
|
const macroResult = result.toolResult as MacroToolResult
|
||||||
|
const input = macroResult.input
|
||||||
console.groupEnd()
|
const output = macroResult.output
|
||||||
|
const reflection: Partial<AgentReflection> = {
|
||||||
// finish task if done
|
evaluation_previous_goal: input.evaluation_previous_goal,
|
||||||
|
memory: input.memory,
|
||||||
if (actionName === 'done') {
|
next_goal: input.next_goal,
|
||||||
const success = action.input?.success ?? false
|
}
|
||||||
const text = action.input?.text || 'no text provided'
|
const actionName = Object.keys(input.action)[0]
|
||||||
console.log(chalk.green.bold('Task completed'), success, text)
|
const action: AgentStepEvent['action'] = {
|
||||||
this.#onDone(success)
|
name: actionName,
|
||||||
const result: ExecutionResult = {
|
input: input.action[actionName],
|
||||||
success,
|
output: output,
|
||||||
data: text,
|
|
||||||
history: this.history,
|
|
||||||
}
|
}
|
||||||
await onAfterTask?.(this, result)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.groupEnd() // to prevent nested groups
|
|
||||||
const isAbortError = (error as any)?.rawError?.name === 'AbortError'
|
|
||||||
|
|
||||||
console.error('Task failed', error)
|
this.#emitHistoryChange({
|
||||||
const errorMessage = isAbortError ? 'Task stopped' : String(error)
|
type: 'step',
|
||||||
this.#emitActivity({ type: 'error', message: errorMessage })
|
stepIndex: step,
|
||||||
this.history.push({ type: 'error', message: errorMessage, rawResponse: error })
|
reflection,
|
||||||
this.#emitHistoryChange()
|
action,
|
||||||
this.#onDone(false)
|
usage: result.usage,
|
||||||
const result: ExecutionResult = {
|
rawResponse: result.rawResponse,
|
||||||
success: false,
|
rawRequest: result.rawRequest,
|
||||||
data: errorMessage,
|
})
|
||||||
history: this.history,
|
|
||||||
}
|
|
||||||
await onAfterTask?.(this, result)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
step++
|
if (actionName === 'done') {
|
||||||
if (step > this.config.maxSteps) {
|
const success = action.input?.success ?? false
|
||||||
const errorMessage = 'Step count exceeded maximum limit'
|
const data = action.input?.text || 'no text provided'
|
||||||
this.history.push({ type: 'error', message: errorMessage })
|
console.log(chalk.green.bold('Task completed'), success, data)
|
||||||
this.#emitHistoryChange()
|
taskResult = { success, data, history: this.history }
|
||||||
this.#onDone(false)
|
this.#lastResult = taskResult
|
||||||
const result: ExecutionResult = {
|
finalStatus = 'completed'
|
||||||
success: false,
|
break
|
||||||
data: errorMessage,
|
}
|
||||||
history: this.history,
|
} catch (error: unknown) {
|
||||||
}
|
// catch block must not throw error. otherwise the error may be overridden if finally block also throws error.
|
||||||
await onAfterTask?.(this, result)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
await waitFor(this.config.stepDelay ?? 0.4)
|
const isAbortError = (error as any)?.name === 'AbortError'
|
||||||
|
if (!isAbortError) console.error('Task failed', error)
|
||||||
|
const message = isAbortError ? 'Task aborted' : String(error)
|
||||||
|
this.#emitActivity({ type: 'error', message: message })
|
||||||
|
this.#emitHistoryChange({ type: 'error', message: message, rawResponse: error })
|
||||||
|
taskResult = { success: false, data: message, history: this.history }
|
||||||
|
this.#lastResult = taskResult
|
||||||
|
finalStatus = isAbortError ? 'stopped' : 'error'
|
||||||
|
break
|
||||||
|
} finally {
|
||||||
|
// finally block runs before the break above.
|
||||||
|
|
||||||
|
console.groupEnd()
|
||||||
|
// @note hook may throw error.
|
||||||
|
// which will override the `break` above and be handled as an external error.
|
||||||
|
// as expected.
|
||||||
|
await onAfterStep?.(this, this.history)
|
||||||
|
}
|
||||||
|
|
||||||
|
step++
|
||||||
|
if (step > maxSteps) {
|
||||||
|
const message = 'Step count exceeded maximum limit'
|
||||||
|
console.error(message)
|
||||||
|
this.#emitActivity({ type: 'error', message: message })
|
||||||
|
this.#emitHistoryChange({ type: 'error', message: message })
|
||||||
|
taskResult = { success: false, data: message, history: this.history }
|
||||||
|
this.#lastResult = taskResult
|
||||||
|
finalStatus = 'error'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} // while
|
||||||
|
|
||||||
|
await onAfterTask?.(this, taskResult)
|
||||||
|
|
||||||
|
return taskResult
|
||||||
|
} catch (error) {
|
||||||
|
this.#emitActivity({ type: 'error', message: String(error) })
|
||||||
|
finalStatus = 'error'
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
await suppress(() => this.pageController.cleanUpHighlights())
|
||||||
|
await suppress(() => this.pageController.hideMask())
|
||||||
|
this.#abortController.abort()
|
||||||
|
resolveRunning()
|
||||||
|
this.#setStatus(finalStatus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,8 +404,8 @@ export class PageAgentCore extends EventTarget {
|
|||||||
description: 'You MUST call this tool every step!',
|
description: 'You MUST call this tool every step!',
|
||||||
inputSchema: macroToolSchema as z.ZodType<MacroToolInput>,
|
inputSchema: macroToolSchema as z.ZodType<MacroToolInput>,
|
||||||
execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
|
execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
|
||||||
// abort
|
const signal = this.#abortController.signal
|
||||||
if (this.#abortController.signal.aborted) throw new Error('AbortError')
|
signal.throwIfAborted()
|
||||||
|
|
||||||
console.log(chalk.blue.bold('MacroTool input'), input)
|
console.log(chalk.blue.bold('MacroTool input'), input)
|
||||||
const action = input.action
|
const action = input.action
|
||||||
@@ -411,8 +437,9 @@ export class PageAgentCore extends EventTarget {
|
|||||||
|
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
|
|
||||||
// Execute tool, bind `this` to PageAgent
|
const result = await tool.execute.bind(this)(toolInput, { signal })
|
||||||
const result = await tool.execute.bind(this)(toolInput)
|
// Enforce abort even if the tool ignored the signal and resolved normally.
|
||||||
|
signal.throwIfAborted()
|
||||||
|
|
||||||
const duration = Date.now() - startTime
|
const duration = Date.now() - startTime
|
||||||
console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result)
|
console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result)
|
||||||
@@ -619,13 +646,6 @@ export class PageAgentCore extends EventTarget {
|
|||||||
return prompt
|
return prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
#onDone(success = true) {
|
|
||||||
this.pageController.cleanUpHighlights()
|
|
||||||
this.pageController.hideMask() // No await - fire and forget
|
|
||||||
this.#setStatus(success ? 'completed' : 'error')
|
|
||||||
this.#abortController.abort()
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
console.log('Disposing PageAgent...')
|
console.log('Disposing PageAgent...')
|
||||||
this.disposed = true
|
this.disposed = true
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
system_prompt.md
|
|
||||||
@@ -37,7 +37,7 @@ and system messages wrapped in <sys> tag.
|
|||||||
<user_request>
|
<user_request>
|
||||||
USER REQUEST: This is your ultimate objective and always remains visible.
|
USER REQUEST: This is your ultimate objective and always remains visible.
|
||||||
- This has the highest priority. Make the user happy.
|
- This has the highest priority. Make the user happy.
|
||||||
- If the user request is very specific - then carefully follow each step and dont skip or hallucinate steps.
|
- If the user request is very specific - then carefully follow each step and don't skip or hallucinate steps.
|
||||||
- If the task is open ended you can plan yourself how to get it done.
|
- If the task is open ended you can plan yourself how to get it done.
|
||||||
</user_request>
|
</user_request>
|
||||||
|
|
||||||
@@ -70,7 +70,6 @@ Strictly follow these rules while using the browser and navigating the web:
|
|||||||
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
|
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
|
||||||
- All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed.
|
- All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed.
|
||||||
- If a captcha appears, tell user you can not solve captcha. Finish the task and ask user to solve it.
|
- If a captcha appears, tell user you can not solve captcha. Finish the task and ask user to solve it.
|
||||||
- If expected elements are missing, try scrolling, or navigating back.
|
|
||||||
- If the page is not fully loaded, use the `wait` action.
|
- If the page is not fully loaded, use the `wait` action.
|
||||||
- Do not repeat one action for more than 3 times unless some conditions changed.
|
- Do not repeat one action for more than 3 times unless some conditions changed.
|
||||||
- If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field.
|
- If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field.
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ import * as z from 'zod/v4'
|
|||||||
import type { PageAgentCore } from '../PageAgentCore'
|
import type { PageAgentCore } from '../PageAgentCore'
|
||||||
import { waitFor } from '../utils'
|
import { waitFor } from '../utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-invocation context passed to every tool execution.
|
||||||
|
* Tools MUST honor `signal` to support cooperative cancellation.
|
||||||
|
*/
|
||||||
|
export interface ToolContext {
|
||||||
|
signal: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal tool definition that has access to PageAgent `this` context
|
* Internal tool definition that has access to PageAgent `this` context
|
||||||
*/
|
*/
|
||||||
@@ -14,7 +22,7 @@ export interface PageAgentTool<TParams = any> {
|
|||||||
// name: string
|
// name: string
|
||||||
description: string
|
description: string
|
||||||
inputSchema: z.ZodType<TParams>
|
inputSchema: z.ZodType<TParams>
|
||||||
execute: (this: PageAgentCore, args: TParams) => Promise<string>
|
execute: (this: PageAgentCore, args: TParams, ctx: ToolContext) => Promise<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tool<TParams>(options: PageAgentTool<TParams>): PageAgentTool<TParams> {
|
export function tool<TParams>(options: PageAgentTool<TParams>): PageAgentTool<TParams> {
|
||||||
@@ -50,14 +58,16 @@ tools.set(
|
|||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
seconds: z.number().min(1).max(10).default(1),
|
seconds: z.number().min(1).max(10).default(1),
|
||||||
}),
|
}),
|
||||||
execute: async function (this: PageAgentCore, input) {
|
execute: async function (this: PageAgentCore, input, { signal }) {
|
||||||
// try to subtract LLM calling time from the actual wait time
|
// try to subtract LLM calling time from the actual wait time
|
||||||
const lastTimeUpdate = await this.pageController.getLastUpdateTime()
|
const lastTimeUpdate = await this.pageController.getLastUpdateTime()
|
||||||
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000)
|
const secondsSinceLastUpdate = (Date.now() - lastTimeUpdate) / 1000
|
||||||
|
const actualWaitTime = Math.max(0, input.seconds - secondsSinceLastUpdate)
|
||||||
console.log(`actualWaitTime: ${actualWaitTime} seconds`)
|
console.log(`actualWaitTime: ${actualWaitTime} seconds`)
|
||||||
await waitFor(actualWaitTime)
|
await waitFor(actualWaitTime, signal)
|
||||||
|
|
||||||
return `✅ Waited for ${input.seconds} seconds.`
|
const waitedSeconds = (secondsSinceLastUpdate + actualWaitTime).toFixed(2)
|
||||||
|
return `✅ Waited for ${waitedSeconds} seconds.`
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -70,11 +80,11 @@ tools.set(
|
|||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
question: z.string(),
|
question: z.string(),
|
||||||
}),
|
}),
|
||||||
execute: async function (this: PageAgentCore, input) {
|
execute: async function (this: PageAgentCore, input, { signal }) {
|
||||||
if (!this.onAskUser) {
|
if (!this.onAskUser) {
|
||||||
throw new Error('ask_user tool requires onAskUser callback to be set')
|
throw new Error('ask_user tool requires onAskUser callback to be set')
|
||||||
}
|
}
|
||||||
const answer = await this.onAskUser(input.question)
|
const answer = await this.onAskUser(input.question, { signal })
|
||||||
return `User answered: ${answer}`
|
return `User answered: ${answer}`
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -131,7 +141,8 @@ tools.set(
|
|||||||
tools.set(
|
tools.set(
|
||||||
'scroll',
|
'scroll',
|
||||||
tool({
|
tool({
|
||||||
description: 'Scroll the page vertically. Use index for scroll elements (dropdowns/custom UI).',
|
description:
|
||||||
|
'Scroll vertically. Without index: scrolls the document. With index: scrolls the container at that index (or its nearest scrollable ancestor). Use index of a data-scrollable element to scroll a specific area.',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
down: z.boolean().default(true),
|
down: z.boolean().default(true),
|
||||||
num_pages: z.number().min(0).max(10).optional().default(0.1),
|
num_pages: z.number().min(0).max(10).optional().default(0.1),
|
||||||
@@ -155,7 +166,7 @@ tools.set(
|
|||||||
'scroll_horizontally',
|
'scroll_horizontally',
|
||||||
tool({
|
tool({
|
||||||
description:
|
description:
|
||||||
'Scroll the page horizontally, or within a specific element by index. Useful for wide tables.',
|
'Scroll horizontally. Without index: scrolls the document. With index: scrolls the container at that index (or its nearest scrollable ancestor). Use index of a data-scrollable element to scroll a specific area.',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
right: z.boolean().default(true),
|
right: z.boolean().default(true),
|
||||||
pixels: z.number().int().min(0),
|
pixels: z.number().int().min(0),
|
||||||
@@ -172,12 +183,15 @@ tools.set(
|
|||||||
'execute_javascript',
|
'execute_javascript',
|
||||||
tool({
|
tool({
|
||||||
description:
|
description:
|
||||||
'Execute JavaScript code on the current page. Supports async/await syntax. Use with caution!',
|
'Execute JavaScript code on the current page. Supports async/await syntax. Use with caution! ' +
|
||||||
|
'An `AbortSignal` named `signal` is available in scope: long-running async code MUST honor it ' +
|
||||||
|
'(e.g. `await fetch(url, { signal })`, or `signal.throwIfAborted()` in loops)',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
script: z.string(),
|
script: z.string(),
|
||||||
}),
|
}),
|
||||||
execute: async function (this: PageAgentCore, input) {
|
execute: async function (this: PageAgentCore, input, { signal }) {
|
||||||
const result = await this.pageController.executeJavascript(input.script)
|
const result = await this.pageController.executeJavascript(input.script, signal)
|
||||||
|
signal.throwIfAborted()
|
||||||
return result.message
|
return result.message
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -185,5 +199,4 @@ tools.set(
|
|||||||
|
|
||||||
// @todo send_keys
|
// @todo send_keys
|
||||||
// @todo upload_file
|
// @todo upload_file
|
||||||
// @todo go_back
|
|
||||||
// @todo extract_structured_data
|
// @todo extract_structured_data
|
||||||
|
|||||||
@@ -255,16 +255,12 @@ export interface AgentErrorEvent {
|
|||||||
* Union type for all history events
|
* Union type for all history events
|
||||||
*/
|
*/
|
||||||
export type HistoricalEvent =
|
export type HistoricalEvent =
|
||||||
| AgentStepEvent
|
AgentStepEvent | ObservationEvent | UserTakeoverEvent | RetryEvent | AgentErrorEvent
|
||||||
| ObservationEvent
|
|
||||||
| UserTakeoverEvent
|
|
||||||
| RetryEvent
|
|
||||||
| AgentErrorEvent
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent execution status
|
* Agent lifecycle status.
|
||||||
*/
|
*/
|
||||||
export type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
|
export type AgentStatus = 'idle' | 'running' | 'completed' | 'error' | 'stopped'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent activity - transient state for immediate UI feedback.
|
* Agent activity - transient state for immediate UI feedback.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { InvokeError, InvokeErrorType } from '@page-agent/llms'
|
import { InvokeError, InvokeErrorTypes } from '@page-agent/llms'
|
||||||
import chalk from 'chalk'
|
import chalk from 'chalk'
|
||||||
import * as z from 'zod/v4'
|
import * as z from 'zod/v4'
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ const log = console.log.bind(console, chalk.yellow('[autoFixer]'))
|
|||||||
* - etc.
|
* - etc.
|
||||||
*/
|
*/
|
||||||
export function normalizeResponse(response: any, tools?: Map<string, PageAgentTool>): any {
|
export function normalizeResponse(response: any, tools?: Map<string, PageAgentTool>): any {
|
||||||
let resolvedArguments = null as any
|
let resolvedArguments: any
|
||||||
|
|
||||||
const choice = (response as { choices?: Choice[] }).choices?.[0]
|
const choice = (response as { choices?: Choice[] }).choices?.[0]
|
||||||
if (!choice) throw new Error('No choices in response')
|
if (!choice) throw new Error('No choices in response')
|
||||||
@@ -93,7 +93,7 @@ export function normalizeResponse(response: any, tools?: Map<string, PageAgentTo
|
|||||||
// fix incomplete formats
|
// fix incomplete formats
|
||||||
if (!resolvedArguments.action) {
|
if (!resolvedArguments.action) {
|
||||||
log(`#5: fixing tool_call`)
|
log(`#5: fixing tool_call`)
|
||||||
resolvedArguments.action = { name: 'wait', input: { seconds: 1 } }
|
resolvedArguments.action = { wait: { seconds: 1 } }
|
||||||
}
|
}
|
||||||
|
|
||||||
// pack back to standard format
|
// pack back to standard format
|
||||||
@@ -137,7 +137,7 @@ function validateAction(action: any, tools: Map<string, PageAgentTool>): any {
|
|||||||
if (!tool) {
|
if (!tool) {
|
||||||
const available = Array.from(tools.keys()).join(', ')
|
const available = Array.from(tools.keys()).join(', ')
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
InvokeErrorTypes.INVALID_TOOL_ARGS,
|
||||||
`Unknown action "${toolName}". Available: ${available}`
|
`Unknown action "${toolName}". Available: ${available}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,7 @@ function validateAction(action: any, tools: Map<string, PageAgentTool>): any {
|
|||||||
const result = schema.safeParse(value)
|
const result = schema.safeParse(value)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
InvokeErrorTypes.INVALID_TOOL_ARGS,
|
||||||
`Invalid input for action "${toolName}": ${z.prettifyError(result.error)}`
|
`Invalid input for action "${toolName}": ${z.prettifyError(result.error)}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,28 @@ import chalk from 'chalk'
|
|||||||
|
|
||||||
export * from './autoFixer'
|
export * from './autoFixer'
|
||||||
|
|
||||||
export async function waitFor(seconds: number): Promise<void> {
|
/**
|
||||||
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
|
* Wait for `seconds`. If a `signal` is provided, the wait is cancellable:
|
||||||
|
* aborting rejects with the signal's reason (an `AbortError`).
|
||||||
|
*/
|
||||||
|
export async function waitFor(seconds: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
if (!signal) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signal.throwIfAborted()
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
signal.removeEventListener('abort', onAbort)
|
||||||
|
resolve()
|
||||||
|
}, seconds * 1000)
|
||||||
|
const onAbort = () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
// reason is a DOMException AbortError.
|
||||||
|
reject(signal.reason as DOMException)
|
||||||
|
}
|
||||||
|
signal.addEventListener('abort', onAbort, { once: true })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -109,3 +129,15 @@ export function assert(condition: unknown, message?: string, silent?: boolean):
|
|||||||
throw new Error(errorMessage)
|
throw new Error(errorMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suppress errors from a function.
|
||||||
|
*/
|
||||||
|
export async function suppress<T>(fn: () => T | Promise<T>): Promise<Awaited<T> | undefined> {
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
// @workaround DTS bug
|
|
||||||
// dts do not work with monorepo path mapping
|
|
||||||
// disable path mapping for it
|
|
||||||
"paths": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,8 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
|
||||||
"noEmit": false,
|
|
||||||
"allowImportingTsExtensions": false,
|
|
||||||
"baseUrl": ".",
|
|
||||||
"outDir": "dist",
|
|
||||||
"paths": {
|
|
||||||
//
|
|
||||||
"@page-agent/llms": ["../llms/src/index.ts"],
|
|
||||||
"@page-agent/page-controller": ["../page-controller/src/PageController.ts"]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["dist", "node_modules"],
|
"exclude": ["dist", "node_modules"]
|
||||||
"references": [
|
|
||||||
//
|
|
||||||
{ "path": "../llms" },
|
|
||||||
{ "path": "../page-controller" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,20 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
plugins: [
|
plugins: [
|
||||||
dts({ tsconfigPath: './tsconfig.dts.json', bundleTypes: true }),
|
dts({
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
exclude: ['src/**/*.test.ts'],
|
||||||
|
bundleTypes: true,
|
||||||
|
compilerOptions: {
|
||||||
|
composite: true,
|
||||||
|
noEmit: false,
|
||||||
|
emitDeclarationOnly: true,
|
||||||
|
declaration: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
cssInjectedByJsPlugin({ relativeCSSInjection: true }),
|
cssInjectedByJsPlugin({ relativeCSSInjection: true }),
|
||||||
],
|
],
|
||||||
publicDir: false,
|
publicDir: false,
|
||||||
esbuild: {
|
|
||||||
keepNames: true,
|
|
||||||
},
|
|
||||||
build: {
|
build: {
|
||||||
lib: {
|
lib: {
|
||||||
entry: resolve(__dirname, 'src/PageAgentCore.ts'),
|
entry: resolve(__dirname, 'src/PageAgentCore.ts'),
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
name: 'core',
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
silent: 'passed-only',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
.wxt
|
|
||||||
src/components/ui
|
|
||||||
@@ -42,33 +42,28 @@ localStorage.setItem('PageAgentExtUserAuthToken', 'your-token')
|
|||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import type {
|
import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core'
|
||||||
AgentActivity,
|
|
||||||
AgentStatus,
|
|
||||||
ExecutionResult,
|
|
||||||
HistoricalEvent,
|
|
||||||
} from '@page-agent/core'
|
|
||||||
|
|
||||||
// Wait for extension injection (up to 1 second)
|
// Wait for extension injection (up to 1 second)
|
||||||
async function waitForExtension(timeout = 1000): Promise<boolean> {
|
async function waitForExtension(timeout = 1000): Promise<boolean> {
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
while (Date.now() - start < timeout) {
|
while (Date.now() - start < timeout) {
|
||||||
if (window.PAGE_AGENT_EXT) return true
|
if (window.PAGE_AGENT_EXT) return true
|
||||||
await new Promise((r) => setTimeout(r, 100))
|
await new Promise((r) => setTimeout(r, 100))
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage
|
// Usage
|
||||||
if (await waitForExtension()) {
|
if (await waitForExtension()) {
|
||||||
const result = await window.PAGE_AGENT_EXT!.execute('Click the login button', {
|
const result = await window.PAGE_AGENT_EXT!.execute('Click the login button', {
|
||||||
baseURL: 'https://api.openai.com/v1',
|
baseURL: 'https://api.openai.com/v1',
|
||||||
apiKey: 'your-api-key',
|
apiKey: 'your-api-key',
|
||||||
model: 'gpt-5.2',
|
model: 'gpt-5.2',
|
||||||
onStatusChange: (status) => console.log('Status:', status),
|
onStatusChange: (status) => console.log('Status:', status),
|
||||||
onActivity: (activity) => console.log('Activity:', activity),
|
onActivity: (activity) => console.log('Activity:', activity),
|
||||||
})
|
})
|
||||||
console.log('Result:', result)
|
console.log('Result:', result)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -90,10 +85,10 @@ Execute one agent task.
|
|||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
|
|
||||||
| Name | Type | Required | Description |
|
| Name | Type | Required | Description |
|
||||||
| ---- | ---- | -------- | ----------- |
|
| -------- | --------------- | -------- | ------------------------------------ |
|
||||||
| `task` | `string` | Yes | Task description |
|
| `task` | `string` | Yes | Task description |
|
||||||
| `config` | `ExecuteConfig` | Yes | LLM settings, options, and callbacks |
|
| `config` | `ExecuteConfig` | Yes | LLM settings, options, and callbacks |
|
||||||
|
|
||||||
Returns: `Promise<ExecutionResult>`
|
Returns: `Promise<ExecutionResult>`
|
||||||
|
|
||||||
@@ -101,29 +96,37 @@ Returns: `Promise<ExecutionResult>`
|
|||||||
|
|
||||||
Stop the current task.
|
Stop the current task.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- **Normal browser windows only.** This extension relies on tab group API which does not work for pop-up window or PWA App window.
|
||||||
|
|
||||||
## Types
|
## Types
|
||||||
|
|
||||||
Install `@page-agent/core` for complete types:
|
Install `@page-agent/core` for complete types:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import type {
|
import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core'
|
||||||
AgentActivity,
|
|
||||||
AgentStatus,
|
|
||||||
ExecutionResult,
|
|
||||||
HistoricalEvent,
|
|
||||||
} from '@page-agent/core'
|
|
||||||
|
|
||||||
export interface ExecuteConfig {
|
export interface ExecuteConfig {
|
||||||
baseURL: string
|
baseURL: string
|
||||||
model: string
|
model: string
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
|
|
||||||
// Include the initial tab where page JS starts. Default: true.
|
// Global system-level instructions for the agent.
|
||||||
includeInitialTab?: boolean
|
// Equivalent to AgentConfig.instructions.system.
|
||||||
|
systemInstruction?: string
|
||||||
|
|
||||||
onStatusChange?: (status: AgentStatus) => void
|
// Include the initial tab where page JS starts. Default: true.
|
||||||
onActivity?: (activity: AgentActivity) => void
|
includeInitialTab?: boolean
|
||||||
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
|
||||||
|
// Control all unpinned tabs in the window instead of only the tab group.
|
||||||
|
// When enabled, agent sees and can switch to every non-pinned tab.
|
||||||
|
// Default: false. Experimental.
|
||||||
|
experimentalIncludeAllTabs?: boolean
|
||||||
|
|
||||||
|
onStatusChange?: (status: AgentStatus) => void
|
||||||
|
onActivity?: (activity: AgentActivity) => void
|
||||||
|
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
|
export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
|
||||||
@@ -132,38 +135,38 @@ export type Execute = (task: string, config: ExecuteConfig) => Promise<Execution
|
|||||||
`AgentStatus`
|
`AgentStatus`
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
|
type AgentStatus = 'idle' | 'running' | 'completed' | 'error' | 'stopped'
|
||||||
```
|
```
|
||||||
|
|
||||||
`AgentActivity`
|
`AgentActivity`
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
type AgentActivity =
|
type AgentActivity =
|
||||||
| { type: 'thinking' }
|
| { type: 'thinking' }
|
||||||
| { type: 'executing'; tool: string; input: unknown }
|
| { type: 'executing'; tool: string; input: unknown }
|
||||||
| { type: 'executed'; tool: string; input: unknown; output: string; duration: number }
|
| { type: 'executed'; tool: string; input: unknown; output: string; duration: number }
|
||||||
| { type: 'retrying'; attempt: number; maxAttempts: number }
|
| { type: 'retrying'; attempt: number; maxAttempts: number }
|
||||||
| { type: 'error'; message: string }
|
| { type: 'error'; message: string }
|
||||||
```
|
```
|
||||||
|
|
||||||
`HistoricalEvent`
|
`HistoricalEvent`
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
type HistoricalEvent =
|
type HistoricalEvent =
|
||||||
| { type: 'step'; stepIndex: number; reflection: AgentReflection; action: Action }
|
| { type: 'step'; stepIndex: number; reflection: AgentReflection; action: Action }
|
||||||
| { type: 'observation'; content: string }
|
| { type: 'observation'; content: string }
|
||||||
| { type: 'user_takeover' }
|
| { type: 'user_takeover' }
|
||||||
| { type: 'retry'; message: string; attempt: number; maxAttempts: number }
|
| { type: 'retry'; message: string; attempt: number; maxAttempts: number }
|
||||||
| { type: 'error'; message: string; rawResponse?: unknown }
|
| { type: 'error'; message: string; rawResponse?: unknown }
|
||||||
```
|
```
|
||||||
|
|
||||||
`ExecutionResult`
|
`ExecutionResult`
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface ExecutionResult {
|
interface ExecutionResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
data: string
|
data: string
|
||||||
history: HistoricalEvent[]
|
history: HistoricalEvent[]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -173,15 +176,15 @@ interface ExecutionResult {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const result = await window.PAGE_AGENT_EXT!.execute(
|
const result = await window.PAGE_AGENT_EXT!.execute(
|
||||||
'Fill in the email field with test@example.com and click Submit',
|
'Fill in the email field with test@example.com and click Submit',
|
||||||
{
|
{
|
||||||
baseURL: 'https://api.openai.com/v1',
|
baseURL: 'https://api.openai.com/v1',
|
||||||
apiKey: process.env.OPENAI_API_KEY!,
|
apiKey: process.env.OPENAI_API_KEY!,
|
||||||
model: 'gpt-5.2',
|
model: 'gpt-5.2',
|
||||||
includeInitialTab: false, // Optional: exclude current tab
|
includeInitialTab: false, // Optional: exclude current tab
|
||||||
onStatusChange: (status) => console.log(status),
|
onStatusChange: (status) => console.log(status),
|
||||||
onActivity: (activity) => console.log(activity),
|
onActivity: (activity) => console.log(activity),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -196,31 +199,30 @@ window.PAGE_AGENT_EXT!.stop()
|
|||||||
If you are not importing `@page-agent/core`, add:
|
If you are not importing `@page-agent/core`, add:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import type {
|
import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core'
|
||||||
AgentActivity,
|
|
||||||
AgentStatus,
|
|
||||||
ExecutionResult,
|
|
||||||
HistoricalEvent,
|
|
||||||
} from '@page-agent/core'
|
|
||||||
|
|
||||||
interface ExecuteConfig {
|
interface ExecuteConfig {
|
||||||
baseURL: string
|
baseURL: string
|
||||||
model: string
|
model: string
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
includeInitialTab?: boolean
|
|
||||||
onStatusChange?: (status: AgentStatus) => void
|
systemInstruction?: string
|
||||||
onActivity?: (activity: AgentActivity) => void
|
|
||||||
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
includeInitialTab?: boolean
|
||||||
|
experimentalIncludeAllTabs?: boolean
|
||||||
|
onStatusChange?: (status: AgentStatus) => void
|
||||||
|
onActivity?: (activity: AgentActivity) => void
|
||||||
|
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
PAGE_AGENT_EXT_VERSION?: string
|
PAGE_AGENT_EXT_VERSION?: string
|
||||||
PAGE_AGENT_EXT?: {
|
PAGE_AGENT_EXT?: {
|
||||||
version: string
|
version: string
|
||||||
execute: Execute
|
execute: Execute
|
||||||
stop: () => void
|
stop: () => void
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/ext",
|
"name": "@page-agent/ext",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.6.2",
|
"version": "1.12.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "wxt",
|
"dev": "wxt",
|
||||||
@@ -10,37 +10,38 @@
|
|||||||
"postinstall": "wxt prepare"
|
"postinstall": "wxt prepare"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
"@radix-ui/react-hover-card": "^1.1.18",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.11",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.11",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.3.0",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.3.2",
|
||||||
"@types/chrome": "^0.1.38",
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/chrome": "^0.2.2",
|
||||||
"@types/react-dom": "^19.2.1",
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
"@wxt-dev/module-react": "^1.2.2",
|
"@wxt-dev/module-react": "^1.2.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"lucide-react": "^1.0.1",
|
"lucide-react": "^1.23.0",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.42.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.7",
|
||||||
"rough-notation": "^0.5.1",
|
"rough-notation": "^0.5.1",
|
||||||
"simple-icons": "^16.13.0",
|
"simple-icons": "^16.25.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.3.2",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"wxt": "^0.20.20"
|
"wxt": "^0.20.27"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@page-agent/core": "1.6.2",
|
"@page-agent/core": "1.12.1",
|
||||||
"@page-agent/llms": "1.6.2",
|
"@page-agent/llms": "1.12.1",
|
||||||
"@page-agent/page-controller": "1.6.2",
|
"@page-agent/page-controller": "1.12.1",
|
||||||
"@page-agent/ui": "1.6.2",
|
"@page-agent/ui": "1.12.1",
|
||||||
"ai-motion": "^0.4.8",
|
"ai-motion": "^0.4.8",
|
||||||
"chalk": "^5.6.2"
|
"chalk": "^5.6.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"extName": {
|
"extName": {
|
||||||
"message": "Page Agent Ext"
|
"message": "Page Agent Ext"
|
||||||
},
|
},
|
||||||
"extDescription": {
|
"extDescription": {
|
||||||
"message": "AI-powered browser automation assistant. Control web pages with natural language."
|
"message": "AI-powered browser automation assistant. Control web pages with natural language."
|
||||||
},
|
},
|
||||||
"extActionTitle": {
|
"extActionTitle": {
|
||||||
"message": "Open Page Agent"
|
"message": "Open Page Agent"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"extName": {
|
"extName": {
|
||||||
"message": "Page Agent Ext"
|
"message": "Page Agent Ext"
|
||||||
},
|
},
|
||||||
"extDescription": {
|
"extDescription": {
|
||||||
"message": "AI 驱动的浏览器自动化助手,用自然语言控制网页。"
|
"message": "AI 驱动的浏览器自动化助手,用自然语言控制网页。"
|
||||||
},
|
},
|
||||||
"extActionTitle": {
|
"extActionTitle": {
|
||||||
"message": "打开 Page Agent"
|
"message": "打开 Page Agent"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
system_prompt.md
|
|
||||||
@@ -11,13 +11,18 @@ function detectLanguage(): 'en-US' | 'zh-CN' {
|
|||||||
return lang.startsWith('zh') ? 'zh-CN' : 'en-US'
|
return lang.startsWith('zh') ? 'zh-CN' : 'en-US'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MultiPageAgentConfig extends AgentConfig {
|
||||||
|
includeInitialTab?: boolean
|
||||||
|
experimentalIncludeAllTabs?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MultiPageAgent
|
* MultiPageAgent
|
||||||
* - use with extension
|
* - use with extension
|
||||||
* - can be used from a side panel or a content script
|
* - can be used from a side panel or a content script
|
||||||
*/
|
*/
|
||||||
export class MultiPageAgent extends PageAgentCore {
|
export class MultiPageAgent extends PageAgentCore {
|
||||||
constructor(config: AgentConfig & { includeInitialTab?: boolean }) {
|
constructor(config: MultiPageAgentConfig) {
|
||||||
// multi page controller
|
// multi page controller
|
||||||
const tabsController = new TabsController()
|
const tabsController = new TabsController()
|
||||||
const pageController = new RemotePageController(tabsController)
|
const pageController = new RemotePageController(tabsController)
|
||||||
@@ -31,67 +36,65 @@ export class MultiPageAgent extends PageAgentCore {
|
|||||||
`Default working language: **${targetLanguage}**`
|
`Default working language: **${targetLanguage}**`
|
||||||
)
|
)
|
||||||
|
|
||||||
// include initial tab for controlling
|
|
||||||
const includeInitialTab = config.includeInitialTab ?? true
|
const includeInitialTab = config.includeInitialTab ?? true
|
||||||
|
const experimentalIncludeAllTabs = config.experimentalIncludeAllTabs ?? false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Project agent status into chrome.storage. The content script polls
|
||||||
|
* `isAgentRunning` + `agentHeartbeat` (eventually consistent by design).
|
||||||
|
*
|
||||||
* When the agent is in side-panel and user closed the side-panel.
|
* When the agent is in side-panel and user closed the side-panel.
|
||||||
* There is no chance for isAgentRunning to be set false.
|
* There is no chance for isAgentRunning to be set false.
|
||||||
* (unload event doesn't work well in side panel.)
|
* (unload event doesn't work well in side panel.)
|
||||||
* (I'm trying not to use long-lived connection because the lifecycle of a sw is hard to predict.)
|
* (I'm trying not to use long-lived connection because the lifecycle of a sw is hard to predict.)
|
||||||
* This heartbeat mechanism acts as a backup.
|
* This heartbeat mechanism acts as a backup.
|
||||||
*/
|
*/
|
||||||
let heartBeatInterval: null | number = null
|
let heartBeatInterval: number | null = null
|
||||||
|
|
||||||
super({
|
super({
|
||||||
...config,
|
...config,
|
||||||
|
// Disabled: AbortSignal cannot cross contexts
|
||||||
|
experimentalScriptExecutionTool: false,
|
||||||
pageController: pageController as any,
|
pageController: pageController as any,
|
||||||
customTools: customTools,
|
customTools: customTools,
|
||||||
customSystemPrompt: systemPrompt,
|
customSystemPrompt: systemPrompt,
|
||||||
|
|
||||||
onBeforeTask: async (agent) => {
|
onBeforeTask: async (agent) => {
|
||||||
await tabsController.init(agent.task, includeInitialTab)
|
await tabsController.init(agent.task, { includeInitialTab, experimentalIncludeAllTabs })
|
||||||
|
|
||||||
heartBeatInterval = window.setInterval(() => {
|
|
||||||
chrome.storage.local.set({
|
|
||||||
agentHeartbeat: Date.now(),
|
|
||||||
})
|
|
||||||
}, 1_000)
|
|
||||||
|
|
||||||
await chrome.storage.local.set({
|
|
||||||
isAgentRunning: true,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
onAfterTask: async () => {
|
|
||||||
if (heartBeatInterval) {
|
|
||||||
window.clearInterval(heartBeatInterval)
|
|
||||||
heartBeatInterval = null
|
|
||||||
}
|
|
||||||
|
|
||||||
await chrome.storage.local.set({
|
|
||||||
isAgentRunning: false,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onBeforeStep: async (agent) => {
|
onBeforeStep: async (agent) => {
|
||||||
|
// pull latest tab state so that tabs changes can be observed
|
||||||
|
await tabsController.syncTabs()
|
||||||
if (!tabsController.currentTabId) return
|
if (!tabsController.currentTabId) return
|
||||||
// make sure the current tab is loaded before the step starts
|
// make sure the current tab is loaded before the step starts
|
||||||
await tabsController.waitUntilTabLoaded(tabsController.currentTabId!)
|
await tabsController.waitUntilTabLoaded(tabsController.currentTabId)
|
||||||
},
|
},
|
||||||
|
|
||||||
onDispose: () => {
|
onDispose: () => {
|
||||||
if (heartBeatInterval) {
|
if (heartBeatInterval) {
|
||||||
window.clearInterval(heartBeatInterval)
|
clearInterval(heartBeatInterval)
|
||||||
heartBeatInterval = null
|
heartBeatInterval = null
|
||||||
}
|
}
|
||||||
|
chrome.storage.local.set({ isAgentRunning: false }).catch(console.error)
|
||||||
chrome.storage.local.set({
|
|
||||||
isAgentRunning: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
tabsController.dispose()
|
tabsController.dispose()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.addEventListener('statuschange', () => {
|
||||||
|
const running = this.status === 'running'
|
||||||
|
|
||||||
|
if (running && !heartBeatInterval) {
|
||||||
|
heartBeatInterval = window.setInterval(() => {
|
||||||
|
void chrome.storage.local.set({ agentHeartbeat: Date.now() })
|
||||||
|
}, 1_000)
|
||||||
|
} else if (!running && heartBeatInterval) {
|
||||||
|
clearInterval(heartBeatInterval)
|
||||||
|
heartBeatInterval = null
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.storage.local.set({ isAgentRunning: running }).catch(console.error)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ export function handlePageControlMessage(
|
|||||||
): true | undefined {
|
): true | undefined {
|
||||||
const PREFIX = '[RemotePageController.background]'
|
const PREFIX = '[RemotePageController.background]'
|
||||||
|
|
||||||
function debug(...messages: any[]) {
|
const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`)
|
||||||
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { action, payload, targetTabId } = message
|
const { action, payload, targetTabId } = message
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ export function initPageController() {
|
|||||||
|
|
||||||
function getPC(): PageController {
|
function getPC(): PageController {
|
||||||
if (!pageController) {
|
if (!pageController) {
|
||||||
pageController = new PageController({ enableMask: false, viewportExpansion: 400 })
|
pageController = new PageController({
|
||||||
|
enableMask: false,
|
||||||
|
viewportExpansion: 400,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return pageController
|
return pageController
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import type { TabsController } from './TabsController'
|
|||||||
|
|
||||||
const PREFIX = '[RemotePageController]'
|
const PREFIX = '[RemotePageController]'
|
||||||
|
|
||||||
function debug(...messages: any[]) {
|
const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`)
|
||||||
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendMessage(message: {
|
function sendMessage(message: {
|
||||||
type: 'PAGE_CONTROL'
|
type: 'PAGE_CONTROL'
|
||||||
@@ -58,7 +56,7 @@ export class RemotePageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getBrowserState(): Promise<BrowserState> {
|
async getBrowserState(): Promise<BrowserState> {
|
||||||
let browserState = {} as BrowserState
|
let browserState: BrowserState
|
||||||
debug('getBrowserState', this.currentTabId)
|
debug('getBrowserState', this.currentTabId)
|
||||||
|
|
||||||
const currentUrl = await this.getCurrentUrl()
|
const currentUrl = await this.getCurrentUrl()
|
||||||
@@ -135,9 +133,7 @@ export class RemotePageController {
|
|||||||
return this.remoteCallDomAction('scroll_horizontally', args)
|
return this.remoteCallDomAction('scroll_horizontally', args)
|
||||||
}
|
}
|
||||||
|
|
||||||
async executeJavascript(...args: any[]): Promise<DomActionReturn> {
|
// `execute_javascript` is intentionally not implemented: AbortSignal cannot cross context
|
||||||
return this.remoteCallDomAction('execute_javascript', args)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @note Managed by content script via storage polling. */
|
/** @note Managed by content script via storage polling. */
|
||||||
async showMask(): Promise<void> {}
|
async showMask(): Promise<void> {}
|
||||||
|
|||||||
@@ -1,12 +1,47 @@
|
|||||||
/**
|
/**
|
||||||
* background logics for TabsController
|
* 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'
|
import type { TabAction } from './TabsController'
|
||||||
|
|
||||||
const PREFIX = '[TabsController.background]'
|
const PREFIX = '[TabsController.background]'
|
||||||
|
|
||||||
function debug(...messages: any[]) {
|
const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`)
|
||||||
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
|
|
||||||
|
/**
|
||||||
|
* Resolve active tab.
|
||||||
|
*
|
||||||
|
* - `tabs.query({ active: true })` does not work in multi-window scenarios.
|
||||||
|
* - Extension pages (side panel, hub tab) can resolve their own windowId.
|
||||||
|
* We just find the active tab within that window.
|
||||||
|
* - Content scripts (PAGE_AGENT_EXT) can't self-report a windowId.
|
||||||
|
* Chrome populates `sender.tab` for every content-script message,
|
||||||
|
* which is the tab hosting the script.
|
||||||
|
*/
|
||||||
|
async function resolveActiveTab(
|
||||||
|
payload: { windowId?: number } | undefined,
|
||||||
|
sender: chrome.runtime.MessageSender
|
||||||
|
): Promise<chrome.tabs.Tab> {
|
||||||
|
const windowId = payload?.windowId
|
||||||
|
|
||||||
|
if (windowId != null) {
|
||||||
|
debug('get_active_tab: resolving via caller-reported windowId', windowId)
|
||||||
|
const [tab] = await chrome.tabs.query({ active: true, windowId })
|
||||||
|
if (!tab) throw new Error(`No active tab found in window ${windowId}.`)
|
||||||
|
return tab
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sender.tab) {
|
||||||
|
debug('get_active_tab: resolving via sender.tab (content script)', sender.tab.id)
|
||||||
|
return sender.tab
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
'Cannot resolve active tab: caller reported no windowId and is not a content script (no sender.tab).'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleTabControlMessage(
|
export function handleTabControlMessage(
|
||||||
@@ -18,13 +53,11 @@ export function handleTabControlMessage(
|
|||||||
|
|
||||||
switch (action as TabAction) {
|
switch (action as TabAction) {
|
||||||
case 'get_active_tab': {
|
case 'get_active_tab': {
|
||||||
debug('get_active_tab')
|
debug('get_active_tab', payload)
|
||||||
chrome.tabs
|
resolveActiveTab(payload, sender)
|
||||||
.query({ active: true, currentWindow: true })
|
.then((tab) => {
|
||||||
.then((tabs) => {
|
debug('get_active_tab: success', tab)
|
||||||
const tabId = tabs.length > 0 ? tabs[0].id || null : null
|
sendResponse({ success: true, tab })
|
||||||
debug('get_active_tab: success', tabId)
|
|
||||||
sendResponse({ success: true, tabId })
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||||
@@ -49,7 +82,7 @@ export function handleTabControlMessage(
|
|||||||
case 'open_new_tab': {
|
case 'open_new_tab': {
|
||||||
debug('open_new_tab', payload)
|
debug('open_new_tab', payload)
|
||||||
chrome.tabs
|
chrome.tabs
|
||||||
.create({ url: payload.url, active: false })
|
.create({ url: payload.url, windowId: payload.windowId, active: false })
|
||||||
.then((newTab) => {
|
.then((newTab) => {
|
||||||
debug('open_new_tab: success', newTab)
|
debug('open_new_tab: success', newTab)
|
||||||
sendResponse({ success: true, tabId: newTab.id })
|
sendResponse({ success: true, tabId: newTab.id })
|
||||||
@@ -63,7 +96,7 @@ export function handleTabControlMessage(
|
|||||||
case 'create_tab_group': {
|
case 'create_tab_group': {
|
||||||
debug('create_tab_group', payload)
|
debug('create_tab_group', payload)
|
||||||
chrome.tabs
|
chrome.tabs
|
||||||
.group({ tabIds: payload.tabIds })
|
.group({ tabIds: payload.tabIds, createProperties: { windowId: payload.windowId } })
|
||||||
.then((groupId) => {
|
.then((groupId) => {
|
||||||
debug('create_tab_group: success', groupId)
|
debug('create_tab_group: success', groupId)
|
||||||
sendResponse({ success: true, groupId })
|
sendResponse({ success: true, groupId })
|
||||||
@@ -114,47 +147,20 @@ export function handleTabControlMessage(
|
|||||||
return true // async response
|
return true // async response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'get_window_tabs': {
|
||||||
|
chrome.tabs
|
||||||
|
.query({ windowId: payload.windowId })
|
||||||
|
.then((tabs) => {
|
||||||
|
sendResponse({ success: true, tabs })
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
sendResponse({ error: `Unknown action: ${action}` })
|
sendResponse({ error: `Unknown action: ${action}` })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setupTabChangeEvents() {
|
|
||||||
console.log('[TabsController.background] setupTabChangeEvents')
|
|
||||||
|
|
||||||
chrome.tabs.onCreated.addListener((tab) => {
|
|
||||||
debug('onCreated', tab)
|
|
||||||
chrome.runtime
|
|
||||||
.sendMessage({ type: 'TAB_CHANGE', action: 'created', payload: { tab } })
|
|
||||||
.catch((error) => {
|
|
||||||
debug('onCreated error:', error)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
|
|
||||||
debug('onRemoved', tabId, removeInfo)
|
|
||||||
chrome.runtime
|
|
||||||
.sendMessage({
|
|
||||||
type: 'TAB_CHANGE',
|
|
||||||
action: 'removed',
|
|
||||||
payload: { tabId, removeInfo },
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
debug('onRemoved error:', error)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
|
||||||
debug('onUpdated', tabId, changeInfo)
|
|
||||||
chrome.runtime
|
|
||||||
.sendMessage({
|
|
||||||
type: 'TAB_CHANGE',
|
|
||||||
action: 'updated',
|
|
||||||
payload: { tabId, changeInfo, tab },
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
debug('onUpdated error:', error)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import { isContentScriptAllowed } from './RemotePageController'
|
|||||||
|
|
||||||
const PREFIX = '[TabsController]'
|
const PREFIX = '[TabsController]'
|
||||||
|
|
||||||
function debug(...messages: any[]) {
|
const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`)
|
||||||
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendMessage(message: {
|
function sendMessage(message: {
|
||||||
type: 'TAB_CONTROL'
|
type: 'TAB_CONTROL'
|
||||||
@@ -17,51 +15,107 @@ function sendMessage(message: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the window hosting this script's own context, when knowable.
|
||||||
|
*
|
||||||
|
* Extension pages (side panel, hub tab) have `chrome.windows` access and can
|
||||||
|
* identify their own window directly via `getCurrent()`.
|
||||||
|
* Content scripts have no `chrome.windows` access; they resolve `undefined`
|
||||||
|
* here and the background script falls back to `sender.tab` instead.
|
||||||
|
*/
|
||||||
|
async function getOwnWindowId(): Promise<number | undefined> {
|
||||||
|
if (typeof chrome.windows === 'undefined') return undefined
|
||||||
|
const win = await chrome.windows.getCurrent()
|
||||||
|
return win.id
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controller for managing browser tabs.
|
* Controller for managing browser tabs.
|
||||||
* - live in the agent env (extension page or content script)
|
* - live in the agent env (extension page or content script)
|
||||||
* - no chrome apis. call sw for tab operations
|
* - no chrome apis. call sw for tab operations
|
||||||
|
* - store tabs states, pull tabs info and detect changes
|
||||||
*/
|
*/
|
||||||
export class TabsController extends EventTarget {
|
export class TabsController {
|
||||||
currentTabId: number | null = null
|
currentTabId: number | null = null
|
||||||
|
|
||||||
|
private disposed = false
|
||||||
|
|
||||||
|
/* tracked window */
|
||||||
|
private windowId: number | null = null
|
||||||
|
/* tracked tabs */
|
||||||
private tabs: TabMeta[] = []
|
private tabs: TabMeta[] = []
|
||||||
private initialTabId: number | null = null
|
private initialTabId: number | null = null
|
||||||
private tabGroupId: number | null = null
|
private tabGroupId: number | null = null
|
||||||
|
private experimentalIncludeAllTabs = false
|
||||||
private task: string = ''
|
private task: string = ''
|
||||||
|
|
||||||
async init(task: string, includeInitialTab: boolean = true) {
|
async init(task: string, options: TabsInitOptions = {}) {
|
||||||
debug('init', task, includeInitialTab)
|
const { includeInitialTab = true, experimentalIncludeAllTabs = false } = options
|
||||||
|
debug('init', task, options)
|
||||||
|
|
||||||
this.task = task
|
if (this.disposed) {
|
||||||
this.tabs = []
|
throw new Error('TabsController already disposed')
|
||||||
this.currentTabId = null
|
|
||||||
this.tabGroupId = null
|
|
||||||
this.initialTabId = null
|
|
||||||
|
|
||||||
const result = await sendMessage({
|
|
||||||
type: 'TAB_CONTROL',
|
|
||||||
action: 'get_active_tab',
|
|
||||||
})
|
|
||||||
|
|
||||||
this.initialTabId = result.tabId
|
|
||||||
|
|
||||||
if (!this.initialTabId) {
|
|
||||||
throw new Error('Failed to get initial tab ID')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeInitialTab) {
|
await this.updateCurrentTabId(null)
|
||||||
|
|
||||||
|
this.windowId = null
|
||||||
|
this.tabs = []
|
||||||
|
this.tabGroupId = null
|
||||||
|
this.initialTabId = null
|
||||||
|
this.experimentalIncludeAllTabs = experimentalIncludeAllTabs
|
||||||
|
this.task = task
|
||||||
|
|
||||||
|
const activeTabResult = await sendMessage({
|
||||||
|
type: 'TAB_CONTROL',
|
||||||
|
action: 'get_active_tab',
|
||||||
|
payload: { windowId: await getOwnWindowId() },
|
||||||
|
})
|
||||||
|
|
||||||
|
this.initialTabId = activeTabResult.tab?.id
|
||||||
|
this.windowId = activeTabResult.tab?.windowId
|
||||||
|
|
||||||
|
if (!this.initialTabId || !this.windowId) {
|
||||||
|
if (activeTabResult.error) {
|
||||||
|
throw new Error(activeTabResult.error)
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to get active tab')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (experimentalIncludeAllTabs) {
|
||||||
|
const allTabs = await sendMessage({
|
||||||
|
type: 'TAB_CONTROL',
|
||||||
|
action: 'get_window_tabs',
|
||||||
|
payload: { windowId: this.windowId },
|
||||||
|
})
|
||||||
|
for (const tab of allTabs.tabs as chrome.tabs.Tab[]) {
|
||||||
|
if (tab.id && !tab.pinned && isContentScriptAllowed(tab.url)) {
|
||||||
|
this.addTab({
|
||||||
|
id: tab.id,
|
||||||
|
isInitial: tab.id === this.initialTabId,
|
||||||
|
url: tab.url,
|
||||||
|
title: tab.title,
|
||||||
|
status: tab.status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.tabs.find((t) => t.id === this.initialTabId)) {
|
||||||
|
this.currentTabId = this.initialTabId
|
||||||
|
await this.createTabGroup([this.initialTabId])
|
||||||
|
}
|
||||||
|
} else if (includeInitialTab) {
|
||||||
const info = await sendMessage({
|
const info = await sendMessage({
|
||||||
type: 'TAB_CONTROL',
|
type: 'TAB_CONTROL',
|
||||||
action: 'get_tab_info',
|
action: 'get_tab_info',
|
||||||
payload: { tabId: this.initialTabId },
|
payload: { tabId: this.initialTabId },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isContentScriptAllowed(info.url)) {
|
if (isContentScriptAllowed(info.url) && !info.pinned) {
|
||||||
this.currentTabId = this.initialTabId
|
this.currentTabId = this.initialTabId
|
||||||
|
|
||||||
this.tabs.push({
|
this.addTab({
|
||||||
id: result.tabId,
|
id: this.initialTabId,
|
||||||
isInitial: true,
|
isInitial: true,
|
||||||
url: info.url,
|
url: info.url,
|
||||||
title: info.title,
|
title: info.title,
|
||||||
@@ -73,52 +127,6 @@ export class TabsController extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.updateCurrentTabId(this.currentTabId)
|
await this.updateCurrentTabId(this.currentTabId)
|
||||||
|
|
||||||
const tabChangeHandler = (message: any): void => {
|
|
||||||
if (message.type !== 'TAB_CHANGE') {
|
|
||||||
// throw new Error(`[TabsController]: Invalid message type: ${message.type}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.action === 'created') {
|
|
||||||
const tab = message.payload.tab as chrome.tabs.Tab
|
|
||||||
if (tab.groupId === this.tabGroupId && tab.id != null) {
|
|
||||||
// Tab created in our controlled group
|
|
||||||
if (!this.tabs.find((t) => t.id === tab.id)) {
|
|
||||||
this.tabs.push({ 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
chrome.runtime.onMessage.addListener(tabChangeHandler)
|
|
||||||
|
|
||||||
this.addEventListener('dispose', () => {
|
|
||||||
chrome.runtime.onMessage.removeListener(tabChangeHandler)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async openNewTab(url: string): Promise<string> {
|
async openNewTab(url: string): Promise<string> {
|
||||||
@@ -127,7 +135,7 @@ export class TabsController extends EventTarget {
|
|||||||
const result = await sendMessage({
|
const result = await sendMessage({
|
||||||
type: 'TAB_CONTROL',
|
type: 'TAB_CONTROL',
|
||||||
action: 'open_new_tab',
|
action: 'open_new_tab',
|
||||||
payload: { url },
|
payload: { url, windowId: this.windowId },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -136,7 +144,7 @@ export class TabsController extends EventTarget {
|
|||||||
|
|
||||||
const tabId = result.tabId as number
|
const tabId = result.tabId as number
|
||||||
|
|
||||||
this.tabs.push({
|
this.addTab({
|
||||||
id: tabId,
|
id: tabId,
|
||||||
isInitial: false,
|
isInitial: false,
|
||||||
})
|
})
|
||||||
@@ -209,7 +217,7 @@ export class TabsController extends EventTarget {
|
|||||||
const result = await sendMessage({
|
const result = await sendMessage({
|
||||||
type: 'TAB_CONTROL',
|
type: 'TAB_CONTROL',
|
||||||
action: 'create_tab_group',
|
action: 'create_tab_group',
|
||||||
payload: { tabIds },
|
payload: { tabIds, windowId: this.windowId },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!result?.success) {
|
if (!result?.success) {
|
||||||
@@ -232,6 +240,11 @@ export class TabsController extends EventTarget {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private addTab(meta: TabMeta) {
|
||||||
|
if (this.tabs.find((t) => t.id === meta.id)) return
|
||||||
|
this.tabs.push(meta)
|
||||||
|
}
|
||||||
|
|
||||||
async updateCurrentTabId(tabId: number | null) {
|
async updateCurrentTabId(tabId: number | null) {
|
||||||
debug('updateCurrentTabId', tabId)
|
debug('updateCurrentTabId', tabId)
|
||||||
|
|
||||||
@@ -263,11 +276,14 @@ export class TabsController extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async summarizeTabs(): Promise<string> {
|
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) {
|
for (const tab of this.tabs) {
|
||||||
const { title, url } = await this.getTabInfo(tab.id)
|
const { title, url } = await this.getTabInfo(tab.id)
|
||||||
summaries.push(
|
summaries.push(
|
||||||
`| ${tab.id} | ${url} | ${title} | ${this.currentTabId === tab.id ? '✅' : ''} |`
|
`| ${tab.id} | ${url} | ${title} | ${tab.status ?? '-'} | ${this.currentTabId === tab.id ? '✅' : ''} |`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!this.tabs.length) {
|
if (!this.tabs.length) {
|
||||||
@@ -280,19 +296,104 @@ export class TabsController extends EventTarget {
|
|||||||
async waitUntilTabLoaded(tabId: number): Promise<void> {
|
async waitUntilTabLoaded(tabId: number): Promise<void> {
|
||||||
const tab = this.tabs.find((t) => t.id === tabId)
|
const tab = this.tabs.find((t) => t.id === tabId)
|
||||||
if (!tab) throw new Error(`Tab ID ${tabId} not found in tab list.`)
|
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
|
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)
|
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.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the window's tabs from the background.
|
||||||
|
* Pulling is better than pushing. Long-lived ports are stateful troublemakers.
|
||||||
|
*/
|
||||||
|
async syncTabs(): Promise<void> {
|
||||||
|
if (this.disposed || this.windowId == null) return
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
dispose() {
|
||||||
this.dispatchEvent(new Event('dispose'))
|
debug('dispose')
|
||||||
|
this.disposed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TabsInitOptions {
|
||||||
|
includeInitialTab?: boolean
|
||||||
|
experimentalIncludeAllTabs?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type TabAction =
|
export type TabAction =
|
||||||
| 'get_active_tab'
|
| 'get_active_tab'
|
||||||
| 'get_tab_info'
|
| 'get_tab_info'
|
||||||
@@ -302,6 +403,7 @@ export type TabAction =
|
|||||||
| 'add_tab_to_group'
|
| 'add_tab_to_group'
|
||||||
| 'close_tab'
|
| 'close_tab'
|
||||||
| 'get_tab_title'
|
| 'get_tab_title'
|
||||||
|
| 'get_window_tabs'
|
||||||
|
|
||||||
interface TabMeta {
|
interface TabMeta {
|
||||||
id: number
|
id: number
|
||||||
@@ -321,29 +423,33 @@ function randomColor(): TabGroupColor {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait until condition becomes true
|
* Wait until condition becomes true
|
||||||
* @returns Returns when condition becomes true, throws otherwise
|
* @returns Returns when condition becomes true, false if timeout
|
||||||
* @param timeoutMS Timeout in milliseconds, default 1 minutes, throws error on timeout
|
* @param timeoutMS Timeout in milliseconds, default 1 minutes
|
||||||
* @param error Error object to reject on timeout. If not provided, will resolve with false
|
* @param throwIfTimeout Reject on timeout instead of resolving with `false`
|
||||||
*/
|
*/
|
||||||
export async function waitUntil(
|
async function waitUntil(
|
||||||
check: () => boolean | Promise<boolean>,
|
check: () => boolean | Promise<boolean>,
|
||||||
timeoutMS = 60_000,
|
timeoutMS = 60_000,
|
||||||
error?: string
|
throwIfTimeout = false
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (await check()) return true
|
if (await check()) return true
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
if (await check()) return resolve(true)
|
try {
|
||||||
if (Date.now() - start > timeoutMS) {
|
if (await check()) return resolve(true)
|
||||||
if (error) {
|
if (Date.now() - start > timeoutMS) {
|
||||||
return reject(new Error(error))
|
if (throwIfTimeout) {
|
||||||
} else {
|
return reject(new Error(`waitUntil timed out after ${timeoutMS}ms`))
|
||||||
return resolve(false)
|
} else {
|
||||||
|
return resolve(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
setTimeout(poll, 100)
|
||||||
|
} catch (err) {
|
||||||
|
reject(err instanceof Error ? err : new Error(String(err)))
|
||||||
}
|
}
|
||||||
setTimeout(poll, 100)
|
|
||||||
}
|
}
|
||||||
setTimeout(poll, 100)
|
setTimeout(poll, 100)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ and system messages wrapped in <sys> tag.
|
|||||||
<user_request>
|
<user_request>
|
||||||
USER REQUEST: This is your ultimate objective and always remains visible.
|
USER REQUEST: This is your ultimate objective and always remains visible.
|
||||||
- This has the highest priority. Make the user happy.
|
- This has the highest priority. Make the user happy.
|
||||||
- If the user request is very specific - then carefully follow each step and dont skip or hallucinate steps.
|
- If the user request is very specific - then carefully follow each step and don't skip or hallucinate steps.
|
||||||
- If the task is open ended you can plan yourself how to get it done.
|
- If the task is open ended you can plan yourself how to get it done.
|
||||||
</user_request>
|
</user_request>
|
||||||
|
|
||||||
@@ -72,7 +72,6 @@ Strictly follow these rules while using the browser and navigating the web:
|
|||||||
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
|
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
|
||||||
- All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed.
|
- All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed.
|
||||||
- If a captcha appears, tell user you can not solve captcha. Finish the task and ask user to solve it.
|
- If a captcha appears, tell user you can not solve captcha. Finish the task and ask user to solve it.
|
||||||
- If expected elements are missing, try scrolling, or navigating back.
|
|
||||||
- If the page is not fully loaded, use the `wait` action.
|
- If the page is not fully loaded, use the `wait` action.
|
||||||
- Do not repeat one action for more than 3 times unless some conditions changed.
|
- Do not repeat one action for more than 3 times unless some conditions changed.
|
||||||
- If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field.
|
- If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface AdvancedConfig {
|
|||||||
maxSteps?: number
|
maxSteps?: number
|
||||||
systemInstruction?: string
|
systemInstruction?: string
|
||||||
experimentalLlmsTxt?: boolean
|
experimentalLlmsTxt?: boolean
|
||||||
|
experimentalIncludeAllTabs?: boolean
|
||||||
disableNamedToolChoice?: boolean
|
disableNamedToolChoice?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +80,7 @@ export function useAgent(): UseAgentResult {
|
|||||||
const handleStatusChange = (e: Event) => {
|
const handleStatusChange = (e: Event) => {
|
||||||
const newStatus = agent.status as AgentStatus
|
const newStatus = agent.status as AgentStatus
|
||||||
setStatus(newStatus)
|
setStatus(newStatus)
|
||||||
if (newStatus === 'idle' || newStatus === 'completed' || newStatus === 'error') {
|
if (newStatus !== 'running') {
|
||||||
setActivity(null)
|
setActivity(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,7 +108,6 @@ export function useAgent(): UseAgentResult {
|
|||||||
|
|
||||||
const execute = useCallback(async (task: string) => {
|
const execute = useCallback(async (task: string) => {
|
||||||
const agent = agentRef.current
|
const agent = agentRef.current
|
||||||
console.log('🚀 [useAgent] start executing task:', task)
|
|
||||||
if (!agent) throw new Error('Agent not initialized')
|
if (!agent) throw new Error('Agent not initialized')
|
||||||
|
|
||||||
setCurrentTask(task)
|
setCurrentTask(task)
|
||||||
@@ -125,6 +125,7 @@ export function useAgent(): UseAgentResult {
|
|||||||
maxSteps,
|
maxSteps,
|
||||||
systemInstruction,
|
systemInstruction,
|
||||||
experimentalLlmsTxt,
|
experimentalLlmsTxt,
|
||||||
|
experimentalIncludeAllTabs,
|
||||||
disableNamedToolChoice,
|
disableNamedToolChoice,
|
||||||
...llmConfig
|
...llmConfig
|
||||||
}: ExtConfig) => {
|
}: ExtConfig) => {
|
||||||
@@ -138,6 +139,7 @@ export function useAgent(): UseAgentResult {
|
|||||||
maxSteps,
|
maxSteps,
|
||||||
systemInstruction,
|
systemInstruction,
|
||||||
experimentalLlmsTxt,
|
experimentalLlmsTxt,
|
||||||
|
experimentalIncludeAllTabs,
|
||||||
disableNamedToolChoice,
|
disableNamedToolChoice,
|
||||||
}
|
}
|
||||||
await chrome.storage.local.set({ advancedConfig })
|
await chrome.storage.local.set({ advancedConfig })
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
const [experimentalLlmsTxt, setExperimentalLlmsTxt] = useState(
|
const [experimentalLlmsTxt, setExperimentalLlmsTxt] = useState(
|
||||||
config?.experimentalLlmsTxt ?? false
|
config?.experimentalLlmsTxt ?? false
|
||||||
)
|
)
|
||||||
|
const [experimentalIncludeAllTabs, setExperimentalIncludeAllTabs] = useState(
|
||||||
|
config?.experimentalIncludeAllTabs ?? false
|
||||||
|
)
|
||||||
const [disableNamedToolChoice, setDisableNamedToolChoice] = useState(
|
const [disableNamedToolChoice, setDisableNamedToolChoice] = useState(
|
||||||
config?.disableNamedToolChoice ?? false
|
config?.disableNamedToolChoice ?? false
|
||||||
)
|
)
|
||||||
@@ -46,7 +49,9 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
const [showToken, setShowToken] = useState(false)
|
const [showToken, setShowToken] = useState(false)
|
||||||
const [showApiKey, setShowApiKey] = useState(false)
|
const [showApiKey, setShowApiKey] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevConfig, setPrevConfig] = useState(config)
|
||||||
|
if (prevConfig !== config) {
|
||||||
|
setPrevConfig(config)
|
||||||
setBaseURL(config?.baseURL || DEMO_BASE_URL)
|
setBaseURL(config?.baseURL || DEMO_BASE_URL)
|
||||||
setModel(config?.model || DEMO_MODEL)
|
setModel(config?.model || DEMO_MODEL)
|
||||||
setApiKey(config?.apiKey)
|
setApiKey(config?.apiKey)
|
||||||
@@ -54,8 +59,9 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
setMaxSteps(config?.maxSteps)
|
setMaxSteps(config?.maxSteps)
|
||||||
setSystemInstruction(config?.systemInstruction ?? '')
|
setSystemInstruction(config?.systemInstruction ?? '')
|
||||||
setExperimentalLlmsTxt(config?.experimentalLlmsTxt ?? false)
|
setExperimentalLlmsTxt(config?.experimentalLlmsTxt ?? false)
|
||||||
|
setExperimentalIncludeAllTabs(config?.experimentalIncludeAllTabs ?? false)
|
||||||
setDisableNamedToolChoice(config?.disableNamedToolChoice ?? false)
|
setDisableNamedToolChoice(config?.disableNamedToolChoice ?? false)
|
||||||
}, [config])
|
}
|
||||||
|
|
||||||
// Poll for user auth token every second until found
|
// Poll for user auth token every second until found
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -100,6 +106,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
maxSteps: maxSteps || undefined,
|
maxSteps: maxSteps || undefined,
|
||||||
systemInstruction: systemInstruction || undefined,
|
systemInstruction: systemInstruction || undefined,
|
||||||
experimentalLlmsTxt,
|
experimentalLlmsTxt,
|
||||||
|
experimentalIncludeAllTabs,
|
||||||
disableNamedToolChoice,
|
disableNamedToolChoice,
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
@@ -116,6 +123,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute top-2 right-3 cursor-pointer"
|
className="absolute top-2 right-3 cursor-pointer"
|
||||||
|
aria-label="Back"
|
||||||
>
|
>
|
||||||
<CornerUpLeft className="size-3.5" />
|
<CornerUpLeft className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -123,12 +131,15 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
|
|
||||||
{/* User Auth Token Section */}
|
{/* User Auth Token Section */}
|
||||||
<div className="flex flex-col gap-1.5 p-3 bg-muted/50 rounded-md border">
|
<div className="flex flex-col gap-1.5 p-3 bg-muted/50 rounded-md border">
|
||||||
<label className="text-xs font-medium text-muted-foreground">User Auth Token</label>
|
<label htmlFor="user-auth-token" className="text-xs font-medium text-muted-foreground">
|
||||||
|
User Auth Token
|
||||||
|
</label>
|
||||||
<p className="text-[10px] text-muted-foreground mb-1">
|
<p className="text-[10px] text-muted-foreground mb-1">
|
||||||
Give a website the ability to call this extension.
|
Give a website the ability to call this extension.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<Input
|
<Input
|
||||||
|
id="user-auth-token"
|
||||||
readOnly
|
readOnly
|
||||||
value={
|
value={
|
||||||
userAuthToken
|
userAuthToken
|
||||||
@@ -145,6 +156,8 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
className="h-8 w-8 shrink-0 cursor-pointer"
|
className="h-8 w-8 shrink-0 cursor-pointer"
|
||||||
onClick={() => setShowToken(!showToken)}
|
onClick={() => setShowToken(!showToken)}
|
||||||
disabled={!userAuthToken}
|
disabled={!userAuthToken}
|
||||||
|
aria-label={showToken ? 'Hide token' : 'Show token'}
|
||||||
|
aria-pressed={showToken}
|
||||||
>
|
>
|
||||||
{showToken ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
|
{showToken ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -154,9 +167,13 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
className="h-8 w-8 shrink-0 cursor-pointer"
|
className="h-8 w-8 shrink-0 cursor-pointer"
|
||||||
onClick={handleCopyToken}
|
onClick={handleCopyToken}
|
||||||
disabled={!userAuthToken}
|
disabled={!userAuthToken}
|
||||||
|
aria-label="Copy token"
|
||||||
>
|
>
|
||||||
{copied ? <span className="">✓</span> : <Copy className="size-3" />}
|
{copied ? <span className="">✓</span> : <Copy className="size-3" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
<span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
|
||||||
|
{copied ? 'Token copied' : ''}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,6 +181,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
<a
|
<a
|
||||||
href="/hub.html"
|
href="/hub.html"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
className="flex items-center justify-between p-3 rounded-md border bg-muted/50 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
|
className="flex items-center justify-between p-3 rounded-md border bg-muted/50 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
|
||||||
>
|
>
|
||||||
Manage Page Agent Hub
|
Manage Page Agent Hub
|
||||||
@@ -171,8 +189,11 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-xs text-muted-foreground">Base URL</label>
|
<label htmlFor="base-url" className="text-xs text-muted-foreground">
|
||||||
|
Base URL
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
id="base-url"
|
||||||
placeholder="https://api.openai.com/v1"
|
placeholder="https://api.openai.com/v1"
|
||||||
value={baseURL}
|
value={baseURL}
|
||||||
onChange={(e) => setBaseURL(e.target.value)}
|
onChange={(e) => setBaseURL(e.target.value)}
|
||||||
@@ -197,8 +218,11 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-xs text-muted-foreground">Model</label>
|
<label htmlFor="model" className="text-xs text-muted-foreground">
|
||||||
|
Model
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
id="model"
|
||||||
placeholder="gpt-5.1"
|
placeholder="gpt-5.1"
|
||||||
value={model}
|
value={model}
|
||||||
onChange={(e) => setModel(e.target.value)}
|
onChange={(e) => setModel(e.target.value)}
|
||||||
@@ -207,9 +231,12 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-xs text-muted-foreground">API Key</label>
|
<label htmlFor="api-key" className="text-xs text-muted-foreground">
|
||||||
|
API Key
|
||||||
|
</label>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<Input
|
<Input
|
||||||
|
id="api-key"
|
||||||
type={showApiKey ? 'text' : 'password'}
|
type={showApiKey ? 'text' : 'password'}
|
||||||
// placeholder="sk-..."
|
// placeholder="sk-..."
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
@@ -221,6 +248,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 shrink-0 cursor-pointer"
|
className="h-8 w-8 shrink-0 cursor-pointer"
|
||||||
onClick={() => setShowApiKey(!showApiKey)}
|
onClick={() => setShowApiKey(!showApiKey)}
|
||||||
|
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
|
||||||
>
|
>
|
||||||
{showApiKey ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
|
{showApiKey ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -253,8 +281,11 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
{advancedOpen && (
|
{advancedOpen && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-xs text-muted-foreground">Max Steps</label>
|
<label htmlFor="max-steps" className="text-xs text-muted-foreground">
|
||||||
|
Max Steps
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
id="max-steps"
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="40"
|
placeholder="40"
|
||||||
min={1}
|
min={1}
|
||||||
@@ -285,6 +316,14 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
<span className="text-xs text-muted-foreground">Experimental llms.txt support</span>
|
<span className="text-xs text-muted-foreground">Experimental llms.txt support</span>
|
||||||
<Switch checked={experimentalLlmsTxt} onCheckedChange={setExperimentalLlmsTxt} />
|
<Switch checked={experimentalLlmsTxt} onCheckedChange={setExperimentalLlmsTxt} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between cursor-pointer">
|
||||||
|
<span className="text-xs text-muted-foreground">Experimental include all tabs</span>
|
||||||
|
<Switch
|
||||||
|
checked={experimentalIncludeAllTabs}
|
||||||
|
onCheckedChange={setExperimentalIncludeAllTabs}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ export function HistoryDetail({
|
|||||||
{/* Events (read-only) */}
|
{/* Events (read-only) */}
|
||||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||||
{session.history.map((event, index) => (
|
{session.history.map((event, index) => (
|
||||||
// eslint-disable-next-line react-x/no-array-index-key
|
|
||||||
<EventCard key={index} event={event} />
|
<EventCard key={index} event={event} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { ArrowDownToLine, ArrowLeft, CheckCircle, RotateCcw, Trash2, XCircle } from 'lucide-react'
|
import {
|
||||||
|
ArrowDownToLine,
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle,
|
||||||
|
History,
|
||||||
|
RotateCcw,
|
||||||
|
Trash2,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -29,12 +37,16 @@ export function HistoryList({
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setSessions(await listSessions())
|
try {
|
||||||
setLoading(false)
|
setSessions(await listSessions())
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[HistoryList] Failed to load sessions:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
load()
|
load()
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
@@ -58,7 +70,14 @@ export function HistoryList({
|
|||||||
<div className="flex flex-col h-screen bg-background">
|
<div className="flex flex-col h-screen bg-background">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="flex items-center gap-2 border-b px-3 py-2">
|
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||||
<Button variant="ghost" size="icon-sm" onClick={onBack} className="cursor-pointer">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onBack}
|
||||||
|
className="cursor-pointer"
|
||||||
|
aria-label="Back"
|
||||||
|
title="Back"
|
||||||
|
>
|
||||||
<ArrowLeft className="size-3.5" />
|
<ArrowLeft className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm font-medium flex-1">History</span>
|
<span className="text-sm font-medium flex-1">History</span>
|
||||||
@@ -81,14 +100,23 @@ export function HistoryList({
|
|||||||
{/* List */}
|
{/* List */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center h-32 text-xs text-muted-foreground">
|
<div className="flex flex-col" aria-label="Loading history" aria-busy="true">
|
||||||
Loading...
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2 px-3 py-2.5 border-b">
|
||||||
|
<div className="size-3.5 mt-0.5 rounded-full bg-muted animate-pulse shrink-0" />
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<div className="h-2.5 bg-muted animate-pulse rounded w-3/4" />
|
||||||
|
<div className="h-2 bg-muted animate-pulse rounded w-1/3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && sessions.length === 0 && (
|
{!loading && sessions.length === 0 && (
|
||||||
<div className="flex items-center justify-center h-32 text-xs text-muted-foreground">
|
<div className="flex flex-col items-center justify-center h-40 gap-2 text-muted-foreground">
|
||||||
No history yet
|
<History className="size-8 opacity-30" />
|
||||||
|
<p className="text-xs">No history yet</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ function ResultCard({
|
|||||||
Result: {success ? 'Success' : 'Failed'}
|
Result: {success ? 'Success' : 'Failed'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-[11px] text-muted-foreground pl-5 whitespace-pre-wrap">{text}</p>
|
<p className="text-[12px] text-foreground pl-5 whitespace-pre-wrap">{text}</p>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -146,6 +146,7 @@ function CopyButton({ text, label }: { text: string; label: string }) {
|
|||||||
function extractPrompt(rawRequest: unknown, role: 'system' | 'user'): string | null {
|
function extractPrompt(rawRequest: unknown, role: 'system' | 'user'): string | null {
|
||||||
const messages = (rawRequest as { messages?: { role: string; content?: unknown }[] })?.messages
|
const messages = (rawRequest as { messages?: { role: string; content?: unknown }[] })?.messages
|
||||||
if (!messages) return null
|
if (!messages) return null
|
||||||
|
if (!Array.isArray(messages)) return null
|
||||||
const msg =
|
const msg =
|
||||||
role === 'system'
|
role === 'system'
|
||||||
? messages.find((m) => m.role === role)
|
? messages.find((m) => m.role === role)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function StatusDot({ status }: { status: AgentStatus }) {
|
|||||||
running: 'bg-blue-500',
|
running: 'bg-blue-500',
|
||||||
completed: 'bg-green-500',
|
completed: 'bg-green-500',
|
||||||
error: 'bg-destructive',
|
error: 'bg-destructive',
|
||||||
|
stopped: 'bg-muted-foreground',
|
||||||
}[status]
|
}[status]
|
||||||
|
|
||||||
const label = {
|
const label = {
|
||||||
@@ -21,6 +22,7 @@ export function StatusDot({ status }: { status: AgentStatus }) {
|
|||||||
running: 'Running',
|
running: 'Running',
|
||||||
completed: 'Done',
|
completed: 'Done',
|
||||||
error: 'Error',
|
error: 'Error',
|
||||||
|
stopped: 'Stopped',
|
||||||
}[status]
|
}[status]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -111,6 +113,7 @@ export function EmptyState() {
|
|||||||
]}
|
]}
|
||||||
cursorStyle="underscore"
|
cursorStyle="underscore"
|
||||||
loop
|
loop
|
||||||
|
startOnView={false}
|
||||||
typeSpeed={20}
|
typeSpeed={20}
|
||||||
deleteSpeed={10}
|
deleteSpeed={10}
|
||||||
pauseDelay={3000}
|
pauseDelay={3000}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { handlePageControlMessage } from '@/agent/RemotePageController.background'
|
import { handlePageControlMessage } from '@/agent/RemotePageController.background'
|
||||||
import { handleTabControlMessage, setupTabChangeEvents } from '@/agent/TabsController.background'
|
import { handleTabControlMessage } from '@/agent/TabsController.background'
|
||||||
|
|
||||||
export default defineBackground(() => {
|
export default defineBackground(() => {
|
||||||
console.log('[Background] Service Worker started')
|
console.log('[Background] Service Worker started')
|
||||||
|
|
||||||
// tab change events
|
|
||||||
|
|
||||||
setupTabChangeEvents()
|
|
||||||
|
|
||||||
// generate user auth token
|
// generate user auth token
|
||||||
|
|
||||||
chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => {
|
chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => {
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ async function exposeAgentToPage() {
|
|||||||
let multiPageAgent: InstanceType<typeof MultiPageAgent> | null = null
|
let multiPageAgent: InstanceType<typeof MultiPageAgent> | null = null
|
||||||
|
|
||||||
window.addEventListener('message', async (e) => {
|
window.addEventListener('message', async (e) => {
|
||||||
|
if (e.source !== window) return
|
||||||
|
|
||||||
const data = e.data
|
const data = e.data
|
||||||
if (typeof data !== 'object' || data === null) return
|
if (typeof data !== 'object' || data === null) return
|
||||||
if (data.channel !== 'PAGE_AGENT_EXT_REQUEST') return
|
if (data.channel !== 'PAGE_AGENT_EXT_REQUEST') return
|
||||||
@@ -70,11 +72,15 @@ async function exposeAgentToPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { task, config } = payload
|
const { task, config } = payload
|
||||||
|
const { systemInstruction, ...agentConfig } = config
|
||||||
|
|
||||||
// Dispose old instance before creating new one
|
// Dispose old instance before creating new one
|
||||||
multiPageAgent?.dispose()
|
multiPageAgent?.dispose()
|
||||||
|
|
||||||
multiPageAgent = new MultiPageAgent(config)
|
multiPageAgent = new MultiPageAgent({
|
||||||
|
...agentConfig,
|
||||||
|
instructions: systemInstruction ? { system: systemInstruction } : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
// events
|
// events
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{history.map((event, index) => (
|
{history.map((event, index) => (
|
||||||
// eslint-disable-next-line react-x/no-array-index-key
|
|
||||||
<EventCard key={index} event={event} />
|
<EventCard key={index} event={event} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -206,9 +206,9 @@ export function useHubWs(
|
|||||||
const [wsState, setWsState] = useState<HubWsState>(() => (wsPort ? 'connecting' : 'disconnected'))
|
const [wsState, setWsState] = useState<HubWsState>(() => (wsPort ? 'connecting' : 'disconnected'))
|
||||||
const hubWsRef = useRef<HubWs | null>(null)
|
const hubWsRef = useRef<HubWs | null>(null)
|
||||||
|
|
||||||
const latest = useRef({ execute, stop, configure, config })
|
const latestRef = useRef({ execute, stop, configure, config })
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
latest.current = { execute, stop, configure, config }
|
latestRef.current = { execute, stop, configure, config }
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -218,14 +218,14 @@ export function useHubWs(
|
|||||||
Number(wsPort),
|
Number(wsPort),
|
||||||
{
|
{
|
||||||
onExecute: async (task, incomingConfig) => {
|
onExecute: async (task, incomingConfig) => {
|
||||||
const { execute, configure, config } = latest.current
|
const { execute, configure, config } = latestRef.current
|
||||||
if (incomingConfig) {
|
if (incomingConfig) {
|
||||||
await configure({ ...config, ...incomingConfig } as ExtConfig)
|
await configure({ ...config, ...incomingConfig } as ExtConfig)
|
||||||
}
|
}
|
||||||
const result = await execute(task)
|
const result = await execute(task)
|
||||||
return { success: result.success, data: result.data }
|
return { success: result.success, data: result.data }
|
||||||
},
|
},
|
||||||
onStop: () => latest.current.stop(),
|
onStop: () => latestRef.current.stop(),
|
||||||
},
|
},
|
||||||
setWsState
|
setWsState
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,12 +7,21 @@ export interface ExecuteConfig {
|
|||||||
model: string
|
model: string
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global system-level instructions for the agent.
|
||||||
|
* Equivalent to `AgentConfig.instructions.system`.
|
||||||
|
*/
|
||||||
|
systemInstruction?: string
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether to include the initial tab (that holds this main world script) in the task.
|
* Whether to include the initial tab (that holds this main world script) in the task.
|
||||||
* @default true
|
* @default true
|
||||||
*/
|
*/
|
||||||
includeInitialTab?: boolean
|
includeInitialTab?: boolean
|
||||||
|
|
||||||
|
/** Control all unpinned tabs in the window instead of only the tab group. */
|
||||||
|
experimentalIncludeAllTabs?: boolean
|
||||||
|
|
||||||
onStatusChange?: (status: AgentStatus) => void
|
onStatusChange?: (status: AgentStatus) => void
|
||||||
onActivity?: (activity: AgentActivity) => void
|
onActivity?: (activity: AgentActivity) => void
|
||||||
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
||||||
@@ -36,6 +45,8 @@ export default defineUnlistedScript(() => {
|
|||||||
|
|
||||||
const promise = new Promise<ExecutionResult>((resolve, reject) => {
|
const promise = new Promise<ExecutionResult>((resolve, reject) => {
|
||||||
function handleMessage(e: MessageEvent) {
|
function handleMessage(e: MessageEvent) {
|
||||||
|
if (e.source !== window) return
|
||||||
|
|
||||||
const data = e.data
|
const data = e.data
|
||||||
if (typeof data !== 'object' || data === null) return
|
if (typeof data !== 'object' || data === null) return
|
||||||
if (data.channel !== 'PAGE_AGENT_EXT_RESPONSE') return
|
if (data.channel !== 'PAGE_AGENT_EXT_RESPONSE') return
|
||||||
@@ -86,7 +97,9 @@ export default defineUnlistedScript(() => {
|
|||||||
baseURL: config.baseURL,
|
baseURL: config.baseURL,
|
||||||
model: config.model,
|
model: config.model,
|
||||||
apiKey: config.apiKey,
|
apiKey: config.apiKey,
|
||||||
|
systemInstruction: config.systemInstruction,
|
||||||
includeInitialTab: config.includeInitialTab,
|
includeInitialTab: config.includeInitialTab,
|
||||||
|
experimentalIncludeAllTabs: config.experimentalIncludeAllTabs,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function App() {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
prev === 'running' &&
|
prev === 'running' &&
|
||||||
(status === 'completed' || status === 'error') &&
|
(status === 'completed' || status === 'error' || status === 'stopped') &&
|
||||||
history.length > 0 &&
|
history.length > 0 &&
|
||||||
currentTask
|
currentTask
|
||||||
) {
|
) {
|
||||||
@@ -147,6 +147,8 @@ export default function App() {
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={() => setView({ name: 'history' })}
|
onClick={() => setView({ name: 'history' })}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
|
aria-label="History"
|
||||||
|
title="History"
|
||||||
>
|
>
|
||||||
<History className="size-3.5" />
|
<History className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -155,6 +157,8 @@ export default function App() {
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={() => setView({ name: 'config' })}
|
onClick={() => setView({ name: 'config' })}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
|
aria-label="Settings"
|
||||||
|
title="Settings"
|
||||||
>
|
>
|
||||||
<Settings className="size-3.5" />
|
<Settings className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -178,7 +182,6 @@ export default function App() {
|
|||||||
{showEmptyState && <EmptyState />}
|
{showEmptyState && <EmptyState />}
|
||||||
|
|
||||||
{history.map((event, index) => (
|
{history.map((event, index) => (
|
||||||
// eslint-disable-next-line react-x/no-array-index-key
|
|
||||||
<EventCard key={index} event={event} />
|
<EventCard key={index} event={event} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -206,6 +209,8 @@ export default function App() {
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={handleStop}
|
onClick={handleStop}
|
||||||
className="size-7"
|
className="size-7"
|
||||||
|
aria-label="Stop task"
|
||||||
|
title="Stop task"
|
||||||
>
|
>
|
||||||
<Square className="size-3" />
|
<Square className="size-3" />
|
||||||
</InputGroupButton>
|
</InputGroupButton>
|
||||||
@@ -216,6 +221,8 @@ export default function App() {
|
|||||||
onClick={() => handleSubmit()}
|
onClick={() => handleSubmit()}
|
||||||
disabled={!inputValue.trim()}
|
disabled={!inputValue.trim()}
|
||||||
className="size-7 cursor-pointer"
|
className="size-7 cursor-pointer"
|
||||||
|
aria-label="Send"
|
||||||
|
title="Send"
|
||||||
>
|
>
|
||||||
<Send className="size-3" />
|
<Send className="size-3" />
|
||||||
</InputGroupButton>
|
</InputGroupButton>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface SessionRecord {
|
|||||||
id: string
|
id: string
|
||||||
task: string
|
task: string
|
||||||
history: HistoricalEvent[]
|
history: HistoricalEvent[]
|
||||||
status: 'completed' | 'error'
|
status: 'completed' | 'error' | 'stopped'
|
||||||
createdAt: number
|
createdAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,26 +3,11 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"noEmit": false,
|
|
||||||
"allowImportingTsExtensions": false,
|
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"baseUrl": ".",
|
"types": ["node", "chrome"],
|
||||||
"paths": {
|
"paths": {
|
||||||
// Self root
|
"@/*": ["./src/*"]
|
||||||
"@/*": ["src/*"],
|
|
||||||
|
|
||||||
"@page-agent/llms": ["../llms/src/index.ts"],
|
|
||||||
"@page-agent/page-controller": ["../page-controller/src/PageController.ts"],
|
|
||||||
"@page-agent/core": ["../core/src/PageAgentCore.ts"],
|
|
||||||
"@page-agent/ui": ["../ui/src/index.ts"]
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"references": [
|
|
||||||
//
|
|
||||||
{ "path": "../llms" },
|
|
||||||
{ "path": "../page-controller" },
|
|
||||||
{ "path": "../core" },
|
|
||||||
{ "path": "../ui" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-10
@@ -1,15 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/llms",
|
"name": "@page-agent/llms",
|
||||||
"version": "1.6.2",
|
"version": "1.12.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/lib/page-agent-llms.js",
|
"main": "./src/index.ts",
|
||||||
"module": "./dist/lib/page-agent-llms.js",
|
"types": "./src/index.ts",
|
||||||
"types": "./dist/lib/index.d.ts",
|
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/lib/index.d.ts",
|
"types": "./src/index.ts",
|
||||||
"import": "./dist/lib/page-agent-llms.js",
|
"default": "./src/index.ts"
|
||||||
"default": "./dist/lib/page-agent-llms.js"
|
}
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"main": "./dist/lib/page-agent-llms.js",
|
||||||
|
"module": "./dist/lib/page-agent-llms.js",
|
||||||
|
"types": "./dist/lib/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/lib/index.d.ts",
|
||||||
|
"import": "./dist/lib/page-agent-llms.js",
|
||||||
|
"default": "./dist/lib/page-agent-llms.js"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
@@ -33,8 +43,10 @@
|
|||||||
"homepage": "https://alibaba.github.io/page-agent/",
|
"homepage": "https://alibaba.github.io/page-agent/",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
"test": "vitest run --project llms",
|
||||||
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
"test:live": "vitest run --project llms:live",
|
||||||
|
"prepublishOnly": "node ../../scripts/pre-publish.js",
|
||||||
|
"postpublish": "node ../../scripts/post-publish.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "^5.6.2"
|
"chalk": "^5.6.2"
|
||||||
@@ -43,6 +55,6 @@
|
|||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"zod": "^4.3.5"
|
"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 * as z from 'zod/v4'
|
||||||
|
|
||||||
import { InvokeError, InvokeErrorType } from './errors'
|
import { InvokeError, InvokeErrorTypes } from './errors'
|
||||||
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
|
import type {
|
||||||
|
InvokeOptions,
|
||||||
|
InvokeResult,
|
||||||
|
LLMClient,
|
||||||
|
Message,
|
||||||
|
ResolvedLLMConfig,
|
||||||
|
Tool,
|
||||||
|
} from './types'
|
||||||
import { modelPatch, zodToOpenAITool } from './utils'
|
import { modelPatch, zodToOpenAITool } from './utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client for OpenAI compatible APIs
|
* Client for OpenAI compatible APIs
|
||||||
*/
|
*/
|
||||||
export class OpenAIClient implements LLMClient {
|
export class OpenAIClient implements LLMClient {
|
||||||
config: Required<LLMConfig>
|
config: ResolvedLLMConfig
|
||||||
private fetch: typeof globalThis.fetch
|
private fetch: typeof globalThis.fetch
|
||||||
|
|
||||||
constructor(config: Required<LLMConfig>) {
|
constructor(config: ResolvedLLMConfig) {
|
||||||
this.config = config
|
this.config = config
|
||||||
this.fetch = config.customFetch
|
this.fetch = config.customFetch
|
||||||
}
|
}
|
||||||
@@ -25,6 +32,8 @@ export class OpenAIClient implements LLMClient {
|
|||||||
abortSignal?: AbortSignal,
|
abortSignal?: AbortSignal,
|
||||||
options?: InvokeOptions
|
options?: InvokeOptions
|
||||||
): Promise<InvokeResult> {
|
): Promise<InvokeResult> {
|
||||||
|
abortSignal?.throwIfAborted()
|
||||||
|
|
||||||
// 1. Convert tools to OpenAI format
|
// 1. Convert tools to OpenAI format
|
||||||
const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t))
|
const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t))
|
||||||
|
|
||||||
@@ -37,14 +46,29 @@ export class OpenAIClient implements LLMClient {
|
|||||||
|
|
||||||
const requestBody: Record<string, unknown> = {
|
const requestBody: Record<string, unknown> = {
|
||||||
model: this.config.model,
|
model: this.config.model,
|
||||||
temperature: this.config.temperature,
|
|
||||||
messages,
|
messages,
|
||||||
tools: openaiTools,
|
tools: openaiTools,
|
||||||
parallel_tool_calls: false,
|
parallel_tool_calls: false,
|
||||||
tool_choice: toolChoice,
|
tool_choice: toolChoice,
|
||||||
}
|
}
|
||||||
|
// Only sent if the caller explicitly set it. Most new models throw if this is set.
|
||||||
|
if (this.config.temperature !== undefined) {
|
||||||
|
requestBody.temperature = this.config.temperature
|
||||||
|
}
|
||||||
|
|
||||||
modelPatch(requestBody)
|
modelPatch(requestBody, this.config.baseURL)
|
||||||
|
|
||||||
|
let transformedBody: Record<string, unknown> | undefined
|
||||||
|
try {
|
||||||
|
transformedBody = this.config.transformRequestBody(requestBody)
|
||||||
|
} catch (error) {
|
||||||
|
throw new InvokeError(
|
||||||
|
InvokeErrorTypes.CONFIG_ERROR,
|
||||||
|
`transformRequestBody failed: ${(error as Error).message}`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const finalRequestBody = transformedBody ?? requestBody
|
||||||
|
|
||||||
// 2. Call API
|
// 2. Call API
|
||||||
let response: Response
|
let response: Response
|
||||||
@@ -55,56 +79,69 @@ export class OpenAIClient implements LLMClient {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(this.config.apiKey && { Authorization: `Bearer ${this.config.apiKey}` }),
|
...(this.config.apiKey && { Authorization: `Bearer ${this.config.apiKey}` }),
|
||||||
},
|
},
|
||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(finalRequestBody),
|
||||||
signal: abortSignal,
|
signal: abortSignal,
|
||||||
})
|
})
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const isAbortError = (error as any)?.name === 'AbortError'
|
if ((error as any)?.name === 'AbortError') throw error
|
||||||
const errorMessage = isAbortError ? 'Network request aborted' : 'Network request failed'
|
console.error(error)
|
||||||
if (!isAbortError) console.error(error)
|
throw new InvokeError(InvokeErrorTypes.NETWORK_ERROR, 'Network request failed', error)
|
||||||
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, errorMessage, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Handle HTTP errors
|
// 3. Handle HTTP errors
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch()
|
let errorData: any
|
||||||
const errorMessage =
|
try {
|
||||||
(errorData as { error?: { message?: string } }).error?.message || response.statusText
|
errorData = await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as any)?.name === 'AbortError') throw error
|
||||||
|
}
|
||||||
|
const errorMessage = errorData?.error?.message || response.statusText
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.AUTH_ERROR,
|
InvokeErrorTypes.AUTH_ERROR,
|
||||||
`Authentication failed: ${errorMessage}`,
|
`Authentication failed: ${errorMessage}`,
|
||||||
errorData
|
errorData
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (response.status === 429) {
|
if (response.status === 429) {
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.RATE_LIMIT,
|
InvokeErrorTypes.RATE_LIMIT,
|
||||||
`Rate limit exceeded: ${errorMessage}`,
|
`Rate limit exceeded: ${errorMessage}`,
|
||||||
errorData
|
errorData
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (response.status >= 500) {
|
if (response.status >= 500) {
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.SERVER_ERROR,
|
InvokeErrorTypes.SERVER_ERROR,
|
||||||
`Server error: ${errorMessage}`,
|
`Server error: ${errorMessage}`,
|
||||||
errorData
|
errorData
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.UNKNOWN,
|
InvokeErrorTypes.UNKNOWN,
|
||||||
`HTTP ${response.status}: ${errorMessage}`,
|
`HTTP ${response.status}: ${errorMessage}`,
|
||||||
errorData
|
errorData
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Parse and validate response
|
// 4. Parse and validate response
|
||||||
const data = await response.json()
|
let data: any
|
||||||
|
try {
|
||||||
|
data = await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as any)?.name === 'AbortError') throw error
|
||||||
|
throw new InvokeError(
|
||||||
|
InvokeErrorTypes.INVALID_RESPONSE,
|
||||||
|
'Response body is not valid JSON',
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const choice = data.choices?.[0]
|
const choice = data.choices?.[0]
|
||||||
if (!choice) {
|
if (!choice) {
|
||||||
throw new InvokeError(InvokeErrorType.UNKNOWN, 'No choices in response', data)
|
throw new InvokeError(InvokeErrorTypes.INVALID_SCHEMA, 'No choices in response', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check finish_reason
|
// Check finish_reason
|
||||||
@@ -115,21 +152,21 @@ export class OpenAIClient implements LLMClient {
|
|||||||
break
|
break
|
||||||
case 'length':
|
case 'length':
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.CONTEXT_LENGTH,
|
InvokeErrorTypes.CONTEXT_LENGTH,
|
||||||
'Response truncated: max tokens reached',
|
'Response truncated: max tokens reached',
|
||||||
undefined,
|
undefined,
|
||||||
data
|
data
|
||||||
)
|
)
|
||||||
case 'content_filter':
|
case 'content_filter':
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.CONTENT_FILTER,
|
InvokeErrorTypes.CONTENT_FILTER,
|
||||||
'Content filtered by safety system',
|
'Content filtered by safety system',
|
||||||
undefined,
|
undefined,
|
||||||
data
|
data
|
||||||
)
|
)
|
||||||
default:
|
default:
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.UNKNOWN,
|
InvokeErrorTypes.INVALID_SCHEMA,
|
||||||
`Unexpected finish_reason: ${choice.finish_reason}`,
|
`Unexpected finish_reason: ${choice.finish_reason}`,
|
||||||
undefined,
|
undefined,
|
||||||
data
|
data
|
||||||
@@ -144,7 +181,7 @@ export class OpenAIClient implements LLMClient {
|
|||||||
const toolCallName = normalizedChoice?.message?.tool_calls?.[0]?.function?.name
|
const toolCallName = normalizedChoice?.message?.tool_calls?.[0]?.function?.name
|
||||||
if (!toolCallName) {
|
if (!toolCallName) {
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.NO_TOOL_CALL,
|
InvokeErrorTypes.NO_TOOL_CALL,
|
||||||
'No tool call found in response',
|
'No tool call found in response',
|
||||||
undefined,
|
undefined,
|
||||||
data
|
data
|
||||||
@@ -154,7 +191,7 @@ export class OpenAIClient implements LLMClient {
|
|||||||
const tool = tools[toolCallName]
|
const tool = tools[toolCallName]
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.UNKNOWN,
|
InvokeErrorTypes.UNKNOWN,
|
||||||
`Tool "${toolCallName}" not found in tools`,
|
`Tool "${toolCallName}" not found in tools`,
|
||||||
undefined,
|
undefined,
|
||||||
data
|
data
|
||||||
@@ -165,7 +202,7 @@ export class OpenAIClient implements LLMClient {
|
|||||||
const argString = normalizedChoice.message?.tool_calls?.[0]?.function?.arguments
|
const argString = normalizedChoice.message?.tool_calls?.[0]?.function?.arguments
|
||||||
if (!argString) {
|
if (!argString) {
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
InvokeErrorTypes.INVALID_TOOL_ARGS,
|
||||||
'No tool call arguments found',
|
'No tool call arguments found',
|
||||||
undefined,
|
undefined,
|
||||||
data
|
data
|
||||||
@@ -177,7 +214,7 @@ export class OpenAIClient implements LLMClient {
|
|||||||
parsedArgs = JSON.parse(argString)
|
parsedArgs = JSON.parse(argString)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
InvokeErrorTypes.INVALID_TOOL_ARGS,
|
||||||
'Failed to parse tool arguments as JSON',
|
'Failed to parse tool arguments as JSON',
|
||||||
error,
|
error,
|
||||||
data
|
data
|
||||||
@@ -189,7 +226,7 @@ export class OpenAIClient implements LLMClient {
|
|||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
console.error(z.prettifyError(validation.error))
|
console.error(z.prettifyError(validation.error))
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
InvokeErrorTypes.INVALID_TOOL_ARGS,
|
||||||
'Tool arguments validation failed',
|
'Tool arguments validation failed',
|
||||||
validation.error,
|
validation.error,
|
||||||
data
|
data
|
||||||
@@ -201,11 +238,12 @@ export class OpenAIClient implements LLMClient {
|
|||||||
let toolResult: unknown
|
let toolResult: unknown
|
||||||
try {
|
try {
|
||||||
toolResult = await tool.execute(toolInput)
|
toolResult = await tool.execute(toolInput)
|
||||||
} catch (e) {
|
} catch (error: unknown) {
|
||||||
|
if ((error as any)?.name === 'AbortError') throw error
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.TOOL_EXECUTION_ERROR,
|
InvokeErrorTypes.TOOL_EXECUTION_ERROR,
|
||||||
`Tool execution failed: ${(e as Error).message}`,
|
`Tool execution failed: ${(error as Error)?.message}`,
|
||||||
e,
|
error,
|
||||||
data
|
data
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -225,7 +263,7 @@ export class OpenAIClient implements LLMClient {
|
|||||||
reasoningTokens: data.usage?.completion_tokens_details?.reasoning_tokens,
|
reasoningTokens: data.usage?.completion_tokens_details?.reasoning_tokens,
|
||||||
},
|
},
|
||||||
rawResponse: data,
|
rawResponse: data,
|
||||||
rawRequest: requestBody,
|
rawRequest: finalRequestBody,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
// Internal constants
|
|
||||||
export const LLM_MAX_RETRIES = 2
|
|
||||||
export const DEFAULT_TEMPERATURE = 0.7 // higher randomness helps auto-recovery
|
|
||||||
+19
-20
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Error types and error handling for LLM invocations
|
* Error types and error handling for LLM invocations.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const InvokeErrorType = {
|
export const InvokeErrorTypes = {
|
||||||
// Retryable
|
// Retryable
|
||||||
NETWORK_ERROR: 'network_error', // Network error, retry
|
NETWORK_ERROR: 'network_error', // Network error, retry
|
||||||
RATE_LIMIT: 'rate_limit', // Rate limit, retry
|
RATE_LIMIT: 'rate_limit', // Rate limit, retry
|
||||||
@@ -10,16 +10,31 @@ export const InvokeErrorType = {
|
|||||||
NO_TOOL_CALL: 'no_tool_call', // Model did not call tool
|
NO_TOOL_CALL: 'no_tool_call', // Model did not call tool
|
||||||
INVALID_TOOL_ARGS: 'invalid_tool_args', // Tool args don't match schema
|
INVALID_TOOL_ARGS: 'invalid_tool_args', // Tool args don't match schema
|
||||||
TOOL_EXECUTION_ERROR: 'tool_execution_error', // Tool execution error
|
TOOL_EXECUTION_ERROR: 'tool_execution_error', // Tool execution error
|
||||||
|
INVALID_RESPONSE: 'invalid_response', // Response body is not valid JSON
|
||||||
|
INVALID_SCHEMA: 'invalid_schema', // Response is valid JSON but doesn't match expected shape
|
||||||
|
|
||||||
UNKNOWN: 'unknown',
|
UNKNOWN: 'unknown',
|
||||||
|
|
||||||
// Non-retryable
|
// Non-retryable
|
||||||
|
CONFIG_ERROR: 'config_error', // Invalid local configuration or hook
|
||||||
AUTH_ERROR: 'auth_error', // Authentication failed
|
AUTH_ERROR: 'auth_error', // Authentication failed
|
||||||
CONTEXT_LENGTH: 'context_length', // Prompt too long
|
CONTEXT_LENGTH: 'context_length', // Prompt too long
|
||||||
CONTENT_FILTER: 'content_filter', // Content filtered
|
CONTENT_FILTER: 'content_filter', // Content filtered
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type InvokeErrorType = (typeof InvokeErrorType)[keyof typeof InvokeErrorType]
|
type InvokeErrorType = (typeof InvokeErrorTypes)[keyof typeof InvokeErrorTypes]
|
||||||
|
|
||||||
|
const RETRYABLE_TYPES: readonly InvokeErrorType[] = [
|
||||||
|
InvokeErrorTypes.NETWORK_ERROR,
|
||||||
|
InvokeErrorTypes.RATE_LIMIT,
|
||||||
|
InvokeErrorTypes.SERVER_ERROR,
|
||||||
|
InvokeErrorTypes.NO_TOOL_CALL,
|
||||||
|
InvokeErrorTypes.INVALID_TOOL_ARGS,
|
||||||
|
InvokeErrorTypes.TOOL_EXECUTION_ERROR,
|
||||||
|
InvokeErrorTypes.INVALID_RESPONSE,
|
||||||
|
InvokeErrorTypes.INVALID_SCHEMA,
|
||||||
|
InvokeErrorTypes.UNKNOWN,
|
||||||
|
]
|
||||||
|
|
||||||
export class InvokeError extends Error {
|
export class InvokeError extends Error {
|
||||||
type: InvokeErrorType
|
type: InvokeErrorType
|
||||||
@@ -34,24 +49,8 @@ export class InvokeError extends Error {
|
|||||||
super(message)
|
super(message)
|
||||||
this.name = 'InvokeError'
|
this.name = 'InvokeError'
|
||||||
this.type = type
|
this.type = type
|
||||||
this.retryable = this.isRetryable(type, rawError)
|
this.retryable = RETRYABLE_TYPES.includes(type)
|
||||||
this.rawError = rawError
|
this.rawError = rawError
|
||||||
this.rawResponse = rawResponse
|
this.rawResponse = rawResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
private isRetryable(type: InvokeErrorType, rawError?: unknown): boolean {
|
|
||||||
const isAbortError = (rawError as any)?.name === 'AbortError'
|
|
||||||
if (isAbortError) return false
|
|
||||||
|
|
||||||
const retryableTypes: InvokeErrorType[] = [
|
|
||||||
InvokeErrorType.NETWORK_ERROR,
|
|
||||||
InvokeErrorType.RATE_LIMIT,
|
|
||||||
InvokeErrorType.SERVER_ERROR,
|
|
||||||
InvokeErrorType.NO_TOOL_CALL,
|
|
||||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
|
||||||
InvokeErrorType.TOOL_EXECUTION_ERROR,
|
|
||||||
InvokeErrorType.UNKNOWN,
|
|
||||||
]
|
|
||||||
return retryableTypes.includes(type)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
-66
@@ -1,33 +1,23 @@
|
|||||||
import { OpenAIClient } from './OpenAIClient'
|
import { OpenAIClient } from './OpenAIClient'
|
||||||
import { DEFAULT_TEMPERATURE, LLM_MAX_RETRIES } from './constants'
|
import { InvokeError, InvokeErrorTypes } from './errors'
|
||||||
import { InvokeError, InvokeErrorType } from './errors'
|
import type {
|
||||||
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
|
InvokeOptions,
|
||||||
|
InvokeResult,
|
||||||
|
LLMClient,
|
||||||
|
LLMConfig,
|
||||||
|
Message,
|
||||||
|
ResolvedLLMConfig,
|
||||||
|
Tool,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
export { InvokeError, InvokeErrorType }
|
export { InvokeError, InvokeErrorTypes }
|
||||||
export type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool }
|
export type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool }
|
||||||
|
|
||||||
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
|
/**
|
||||||
// Runtime validation as defensive programming (types already guarantee these)
|
* LLM module
|
||||||
if (!config.baseURL || !config.model) {
|
*/
|
||||||
throw new Error(
|
|
||||||
'[PageAgent] LLM configuration required. Please provide: baseURL, model. ' +
|
|
||||||
'See: https://alibaba.github.io/page-agent/docs/features/models'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
baseURL: config.baseURL,
|
|
||||||
model: config.model,
|
|
||||||
apiKey: config.apiKey || '',
|
|
||||||
temperature: config.temperature ?? DEFAULT_TEMPERATURE,
|
|
||||||
maxRetries: config.maxRetries ?? LLM_MAX_RETRIES,
|
|
||||||
disableNamedToolChoice: config.disableNamedToolChoice ?? false,
|
|
||||||
customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class LLM extends EventTarget {
|
export class LLM extends EventTarget {
|
||||||
config: Required<LLMConfig>
|
config: ResolvedLLMConfig
|
||||||
client: LLMClient
|
client: LLMClient
|
||||||
|
|
||||||
constructor(config: LLMConfig) {
|
constructor(config: LLMConfig) {
|
||||||
@@ -49,65 +39,71 @@ export class LLM extends EventTarget {
|
|||||||
abortSignal: AbortSignal,
|
abortSignal: AbortSignal,
|
||||||
options?: InvokeOptions
|
options?: InvokeOptions
|
||||||
): Promise<InvokeResult> {
|
): Promise<InvokeResult> {
|
||||||
return await withRetry(
|
return await withRetry(async () => this.client.invoke(messages, tools, abortSignal, options), {
|
||||||
async () => {
|
maxRetries: this.config.maxRetries,
|
||||||
// in case user aborted before invoking
|
onRetry: (attempt, lastError) => {
|
||||||
if (abortSignal.aborted) throw new Error('AbortError')
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('retry', {
|
||||||
const result = await this.client.invoke(messages, tools, abortSignal, options)
|
detail: { attempt, maxAttempts: this.config.maxRetries, lastError },
|
||||||
|
})
|
||||||
return result
|
)
|
||||||
},
|
},
|
||||||
// retry settings
|
})
|
||||||
{
|
|
||||||
maxRetries: this.config.maxRetries,
|
|
||||||
onRetry: (attempt: number) => {
|
|
||||||
this.dispatchEvent(
|
|
||||||
new CustomEvent('retry', { detail: { attempt, maxAttempts: this.config.maxRetries } })
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onError: (error: Error) => {
|
|
||||||
this.dispatchEvent(new CustomEvent('error', { detail: { error } }))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry a function until it succeeds or reaches the maximum number of retries.
|
||||||
|
*/
|
||||||
async function withRetry<T>(
|
async function withRetry<T>(
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
settings: {
|
settings: {
|
||||||
maxRetries: number
|
maxRetries: number
|
||||||
onRetry: (attempt: number) => void
|
onRetry: (attempt: number, lastError: Error) => void
|
||||||
onError: (error: Error) => void
|
|
||||||
}
|
}
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
let attempt = 0
|
let attempt = 0
|
||||||
let lastError: Error | null = null
|
while (true) {
|
||||||
while (attempt <= settings.maxRetries) {
|
|
||||||
if (attempt > 0) {
|
|
||||||
settings.onRetry(attempt)
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await fn()
|
return await fn()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// do not retry if aborted by user
|
if ((error as any)?.name === 'AbortError') throw error
|
||||||
if ((error as any)?.rawError?.name === 'AbortError') throw error
|
|
||||||
|
|
||||||
console.error(error)
|
|
||||||
settings.onError(error as Error)
|
|
||||||
|
|
||||||
// do not retry if error is not retryable (InvokeError)
|
|
||||||
if (error instanceof InvokeError && !error.retryable) throw error
|
if (error instanceof InvokeError && !error.retryable) throw error
|
||||||
|
|
||||||
lastError = error as Error
|
|
||||||
attempt++
|
attempt++
|
||||||
|
if (attempt > settings.maxRetries) throw error
|
||||||
|
|
||||||
|
console.debug('[LLM] retryable failure, will retry:', error)
|
||||||
|
settings.onRetry(attempt, error as Error)
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
throw lastError!
|
|
||||||
|
export function parseLLMConfig(config: LLMConfig): ResolvedLLMConfig {
|
||||||
|
// Runtime validation as defensive programming (types already guarantee these)
|
||||||
|
if (!config.baseURL || !config.model) {
|
||||||
|
throw new Error(
|
||||||
|
'[PageAgent] LLM configuration required. Please provide: baseURL, model. ' +
|
||||||
|
'See: https://alibaba.github.io/page-agent/docs/features/models'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.temperature !== undefined) {
|
||||||
|
console.warn(
|
||||||
|
'[PageAgent] LLMConfig.temperature is deprecated and will be removed in a future version. ' +
|
||||||
|
'Use transformRequestBody to set it only for models you have verified accept it.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseURL: config.baseURL,
|
||||||
|
model: config.model,
|
||||||
|
apiKey: config.apiKey || '',
|
||||||
|
temperature: config.temperature,
|
||||||
|
maxRetries: config.maxRetries ?? 2,
|
||||||
|
transformRequestBody: config.transformRequestBody ?? ((requestBody) => requestBody),
|
||||||
|
disableNamedToolChoice: config.disableNamedToolChoice ?? false,
|
||||||
|
customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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,9 +92,24 @@ export interface LLMConfig {
|
|||||||
model: string
|
model: string
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated No longer a standard parameter; many models reject it outright.
|
||||||
|
* Use `transformRequestBody` to set it only for models you've verified.
|
||||||
|
*/
|
||||||
temperature?: number
|
temperature?: number
|
||||||
|
|
||||||
maxRetries?: number
|
maxRetries?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform the final request body before sending it to the provider.
|
||||||
|
* Use this to implement provider-specific request tweaks such as caching hints or custom flags.
|
||||||
|
*
|
||||||
|
* Return a new object, or mutate the input object and return undefined.
|
||||||
|
*/
|
||||||
|
transformRequestBody?: (
|
||||||
|
requestBody: Record<string, unknown>
|
||||||
|
) => Record<string, unknown> | undefined
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* remove the tool_choice field from the request.
|
* remove the tool_choice field from the request.
|
||||||
* @note fix "Invalid tool_choice type: 'object'" for some LLMs.
|
* @note fix "Invalid tool_choice type: 'object'" for some LLMs.
|
||||||
@@ -108,3 +123,5 @@ export interface LLMConfig {
|
|||||||
*/
|
*/
|
||||||
customFetch?: typeof globalThis.fetch
|
customFetch?: typeof globalThis.fetch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ResolvedLLMConfig = Required<Omit<LLMConfig, 'temperature'>> & { temperature?: number }
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { modelPatch, normalizeModelName } from './utils'
|
||||||
|
|
||||||
|
describe('normalizeModelName', () => {
|
||||||
|
it.each([
|
||||||
|
['gpt-5.2', 'gpt-52'],
|
||||||
|
['gpt_5_2', 'gpt52'],
|
||||||
|
['GPT-52-2026-01-01', 'gpt-52-2026-01-01'],
|
||||||
|
['openai/gpt-5.2-chat', 'gpt-52-chat'],
|
||||||
|
['claude_sonnet4_5', 'claudesonnet45'],
|
||||||
|
])('%s -> %s', (input, expected) => {
|
||||||
|
expect(normalizeModelName(input)).toBe(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
+148
-51
@@ -24,82 +24,167 @@ export function zodToOpenAITool(name: string, tool: Tool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Patch model specific parameters
|
* Patch model specific parameters. Only patches known models.
|
||||||
* @note in-place modification
|
*
|
||||||
|
* @purpose
|
||||||
|
* - Reconcile the differences in the parameter schema each model accepts.
|
||||||
|
* - Disable thinking/reasoning, or lower it to the minimum where a full disable is impossible.
|
||||||
|
* - Minimize returned tokens.
|
||||||
|
* - Raise temperature for known smaller models to improve auto-recovery odds.
|
||||||
|
* @note Honor temperature if explicitly set by the user
|
||||||
|
*
|
||||||
|
* @todo Need vendor-specific patches.
|
||||||
|
* Local and 3rd-party hosted models may have different schema.
|
||||||
*/
|
*/
|
||||||
export function modelPatch(body: Record<string, any>) {
|
export function modelPatch(body: Record<string, any>, baseURL?: string) {
|
||||||
const model: string = body.model || ''
|
const model: string = body.model || ''
|
||||||
if (!model) return body
|
if (!model) return body
|
||||||
|
|
||||||
|
const provider = getProvider(baseURL)
|
||||||
|
|
||||||
const modelName = normalizeModelName(model)
|
const modelName = normalizeModelName(model)
|
||||||
|
|
||||||
if (modelName.startsWith('qwen')) {
|
if (modelName.startsWith('qwen')) {
|
||||||
debug('Applying Qwen patch: use higher temperature for auto fixing')
|
debug('Patch Qwen: disable thinking')
|
||||||
body.temperature = Math.max(body.temperature || 0, 1.0)
|
|
||||||
body.enable_thinking = false
|
body.enable_thinking = false
|
||||||
}
|
if (body.temperature === undefined && !/max|plus/.test(modelName)) {
|
||||||
|
debug('Patch Qwen: raise temperature to 1.0')
|
||||||
if (modelName.startsWith('claude')) {
|
body.temperature = 1.0
|
||||||
debug('Applying Claude patch: disable thinking')
|
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modelName.startsWith('grok')) {
|
if (modelName.startsWith('deepseek')) {
|
||||||
debug('Applying Grok patch: removing tool_choice')
|
debug('Patch DeepSeek: disable thinking, remove tool_choice')
|
||||||
|
body.thinking = { type: 'disabled' }
|
||||||
delete body.tool_choice
|
delete body.tool_choice
|
||||||
debug('Applying Grok patch: disable reasoning and thinking')
|
|
||||||
body.thinking = { type: 'disabled', effort: 'minimal' }
|
|
||||||
body.reasoning = { enabled: false, effort: 'low' }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modelName.startsWith('gpt')) {
|
if (modelName.startsWith('gpt')) {
|
||||||
debug('Applying GPT patch: set verbosity to low')
|
if (modelName.startsWith('gpt-5')) {
|
||||||
body.verbosity = 'low'
|
body.verbosity = 'low'
|
||||||
|
}
|
||||||
|
|
||||||
if (modelName.startsWith('gpt-52')) {
|
// Since gpt-5.4, /chat/completions rejects any explicit reasoning_effort
|
||||||
debug('Applying GPT-52 patch: disable reasoning')
|
// when function tools are present. Newer models are expected to follow.
|
||||||
body.reasoning_effort = 'none'
|
// - gpt-5.1 / gpt-5.2 can fully disable reasoning
|
||||||
} else if (modelName.startsWith('gpt-51')) {
|
// - gpt-5 / -mini / -nano bottom out at "minimal"
|
||||||
debug('Applying GPT-51 patch: disable reasoning')
|
// - everything else (gpt-4.x, chat-latest, gpt-5.4+) must not receive it
|
||||||
body.reasoning_effort = 'none'
|
if (modelName.includes('chat-latest')) {
|
||||||
} else if (modelName.startsWith('gpt-54')) {
|
debug('Patch chat-latest: omit reasoning_effort and temperature')
|
||||||
debug(
|
|
||||||
'Applying GPT-5.4 patch: skip reasoning_effort because chat/completions rejects it with function tools'
|
|
||||||
)
|
|
||||||
delete body.reasoning_effort
|
delete body.reasoning_effort
|
||||||
} else if (modelName.startsWith('gpt-5-mini')) {
|
delete body.temperature
|
||||||
debug('Applying GPT-5-mini patch: set reasoning effort to low, temperature to 1')
|
} else if (/^gpt-5[12](-|$)/.test(modelName)) {
|
||||||
body.reasoning_effort = 'low'
|
debug('Patch GPT-5.1/5.2: reasoning_effort=none')
|
||||||
body.temperature = 1
|
body.reasoning_effort = 'none'
|
||||||
} else if (modelName.startsWith('gpt-5')) {
|
} else if (/^gpt-5(-|$)/.test(modelName)) {
|
||||||
debug('Applying GPT-5 patch: set reasoning effort to low')
|
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'
|
body.reasoning_effort = 'low'
|
||||||
|
|
||||||
|
// Fable and mythos can not disable adaptive thinking.
|
||||||
|
// Claude does not support tool_choice with extended thinking.
|
||||||
|
// These 2 concepts are blurred. Basically no tool_choice with thinking.
|
||||||
|
delete body.tool_choice
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modelName.startsWith('gemini')) {
|
if (modelName.startsWith('gemini')) {
|
||||||
debug('Applying Gemini patch: set reasoning effort to minimal')
|
debug('Patch Gemini: reasoning_effort=low')
|
||||||
body.reasoning_effort = 'minimal'
|
body.reasoning_effort = 'low'
|
||||||
|
if (/^gemini-25(?!.*pro)/.test(modelName)) {
|
||||||
|
debug('Patch Gemini 2.5 non-Pro: reasoning_effort=none')
|
||||||
|
body.reasoning_effort = 'none'
|
||||||
|
} else if (
|
||||||
|
modelName.startsWith('gemini-35-flash') ||
|
||||||
|
modelName.startsWith('gemini-31-flash-lite') ||
|
||||||
|
modelName.startsWith('gemini-3-flash')
|
||||||
|
) {
|
||||||
|
debug('Patch Gemini 3.x Flash/Lite: reasoning_effort=minimal')
|
||||||
|
body.reasoning_effort = 'minimal'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelName.startsWith('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')) {
|
if (modelName.startsWith('minimax')) {
|
||||||
debug('Applying MiniMax patch: clamp temperature to (0, 1]')
|
debug('Patch MiniMax: remove parallel_tool_calls')
|
||||||
// MiniMax API rejects temperature = 0; clamp to a small positive value
|
|
||||||
body.temperature = Math.max(body.temperature || 0, 0.01)
|
|
||||||
if (body.temperature > 1) body.temperature = 1
|
|
||||||
// MiniMax does not support parallel_tool_calls
|
|
||||||
delete body.parallel_tool_calls
|
delete body.parallel_tool_calls
|
||||||
|
|
||||||
|
if (modelName.includes('m3')) {
|
||||||
|
// Only M3 can disable thinking
|
||||||
|
debug('Patch MiniMax: disable thinking')
|
||||||
|
body.thinking = { type: 'disabled' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// provider patches
|
||||||
|
|
||||||
|
if (provider === 'openrouter') {
|
||||||
|
// openrouter use reasoning object instead of reasoning_effort
|
||||||
|
|
||||||
|
const reasoningEffort = body.reasoning_effort
|
||||||
|
const reasoningDisabled =
|
||||||
|
body.thinking?.type === 'disabled' ||
|
||||||
|
body.enable_thinking === false ||
|
||||||
|
reasoningEffort === 'none'
|
||||||
|
|
||||||
|
if (reasoningDisabled) {
|
||||||
|
body.reasoning = { enabled: false }
|
||||||
|
} else if (reasoningEffort) {
|
||||||
|
body.reasoning = { enabled: true, effort: reasoningEffort }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return body
|
return body
|
||||||
@@ -120,7 +205,7 @@ export function modelPatch(body: Record<string, any>) {
|
|||||||
* They should be treated as the same model.
|
* They should be treated as the same model.
|
||||||
* Normalize them to `gpt-52`
|
* Normalize them to `gpt-52`
|
||||||
*/
|
*/
|
||||||
function normalizeModelName(modelName: string): string {
|
export function normalizeModelName(modelName: string): string {
|
||||||
let normalizedName = modelName.toLowerCase()
|
let normalizedName = modelName.toLowerCase()
|
||||||
|
|
||||||
// remove prefix before '/'
|
// remove prefix before '/'
|
||||||
@@ -136,3 +221,15 @@ function normalizeModelName(modelName: string): string {
|
|||||||
|
|
||||||
return normalizedName
|
return normalizedName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getProvider(baseURL?: string): 'openrouter' | undefined {
|
||||||
|
if (!baseURL) return undefined
|
||||||
|
try {
|
||||||
|
const url = new URL(baseURL)
|
||||||
|
const hostname = url.hostname
|
||||||
|
if (hostname === 'openrouter.ai') return 'openrouter'
|
||||||
|
return undefined
|
||||||
|
} catch (e) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
// @workaround DTS bug
|
|
||||||
// dts do not work with monorepo path mapping
|
|
||||||
// disable path mapping for it
|
|
||||||
"paths": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
|
||||||
"noEmit": false,
|
|
||||||
"allowImportingTsExtensions": false,
|
|
||||||
"baseUrl": ".",
|
|
||||||
"outDir": "dist"
|
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["dist", "node_modules"]
|
"exclude": ["dist", "node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,20 @@ console.log(chalk.cyan(`📦 Building @page-agent/llms`))
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
plugins: [dts({ tsconfigPath: './tsconfig.dts.json', bundleTypes: true })],
|
plugins: [
|
||||||
|
dts({
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
exclude: ['src/**/*.test.ts'],
|
||||||
|
bundleTypes: true,
|
||||||
|
compilerOptions: {
|
||||||
|
composite: true,
|
||||||
|
noEmit: false,
|
||||||
|
emitDeclarationOnly: true,
|
||||||
|
declaration: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
publicDir: false,
|
publicDir: false,
|
||||||
esbuild: {
|
|
||||||
keepNames: true,
|
|
||||||
},
|
|
||||||
build: {
|
build: {
|
||||||
lib: {
|
lib: {
|
||||||
entry: resolve(__dirname, 'src/index.ts'),
|
entry: resolve(__dirname, 'src/index.ts'),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -36,11 +36,11 @@ Same format — add the config to the MCP settings of your client.
|
|||||||
|
|
||||||
## MCP Tools
|
## MCP Tools
|
||||||
|
|
||||||
| Tool | Input | Description |
|
| Tool | Input | Description |
|
||||||
| -------------- | ------------------ | ---------------------------------------------------- |
|
| -------------- | ------------------ | ----------------------------------------------------- |
|
||||||
| `execute_task` | `{ task: string }` | Execute a browser task in natural language. Blocking. |
|
| `execute_task` | `{ task: string }` | Execute a browser task in natural language. Blocking. |
|
||||||
| `get_status` | — | Returns `{ connected, busy }` |
|
| `get_status` | — | Returns `{ connected, busy }` |
|
||||||
| `stop_task` | — | Stop the currently running task. |
|
| `stop_task` | — | Stop the currently running task. |
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
@@ -88,7 +88,6 @@ src/
|
|||||||
## Dev
|
## Dev
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build:libs
|
|
||||||
npm run dev:ext
|
npm run dev:ext
|
||||||
npx @modelcontextprotocol/inspector node packages/mcp/src/index.js
|
npx @modelcontextprotocol/inspector node packages/mcp/src/index.js
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/mcp",
|
"name": "@page-agent/mcp",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "1.6.2",
|
"version": "1.12.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"page-agent-mcp": "src/index.js"
|
"page-agent-mcp": "src/index.js"
|
||||||
@@ -28,8 +28,8 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"ws": "^8.20.0",
|
"ws": "^8.21.0",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.4.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { WebSocketServer } from 'ws'
|
|||||||
|
|
||||||
const EXT_ID = 'akldabonmimlicnjlflnapfeklbfemhj'
|
const EXT_ID = 'akldabonmimlicnjlflnapfeklbfemhj'
|
||||||
const STORE_URL = `https://chromewebstore.google.com/detail/page-agent-ext/${EXT_ID}`
|
const STORE_URL = `https://chromewebstore.google.com/detail/page-agent-ext/${EXT_ID}`
|
||||||
|
const LOOPBACK_HOST = 'localhost'
|
||||||
|
|
||||||
const launcherTemplate = readFileSync(
|
const launcherTemplate = readFileSync(
|
||||||
fileURLToPath(new URL('./launcher.html', import.meta.url)),
|
fileURLToPath(new URL('./launcher.html', import.meta.url)),
|
||||||
@@ -60,8 +61,8 @@ export class HubBridge {
|
|||||||
reject(err)
|
reject(err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
this.#httpServer.listen(this.port, () => {
|
this.#httpServer.listen(this.port, LOOPBACK_HOST, () => {
|
||||||
console.error(`[page-agent-mcp] HTTP + WS on http://localhost:${this.port}`)
|
console.error(`[page-agent-mcp] HTTP + WS on http://${LOOPBACK_HOST}:${this.port}`)
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||||
import { exec } from 'node:child_process'
|
import { exec } from 'node:child_process'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
import { platform } from 'node:os'
|
import { platform } from 'node:os'
|
||||||
import * as z from 'zod/v4'
|
import * as z from 'zod/v4'
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ import { HubBridge } from './hub-bridge.js'
|
|||||||
|
|
||||||
const env = process.env
|
const env = process.env
|
||||||
const port = parseInt(env.PORT || '38401')
|
const port = parseInt(env.PORT || '38401')
|
||||||
|
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
||||||
|
|
||||||
/** @type {Record<string, string>} */
|
/** @type {Record<string, string>} */
|
||||||
const llmConfig = {}
|
const llmConfig = {}
|
||||||
@@ -30,7 +32,7 @@ exec(`${cmd} "${url}"`, (err) => {
|
|||||||
|
|
||||||
// --- MCP server (stdio) ---
|
// --- MCP server (stdio) ---
|
||||||
|
|
||||||
const mcpServer = new McpServer({ name: 'page-agent', version: '1.5.8' })
|
const mcpServer = new McpServer({ name: 'page-agent', version })
|
||||||
|
|
||||||
mcpServer.registerTool(
|
mcpServer.registerTool(
|
||||||
'execute_task',
|
'execute_task',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link 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>
|
<title>Page Agent MCP Launcher</title>
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<img
|
<img
|
||||||
class="logo"
|
class="logo"
|
||||||
src="https://img.alicdn.com/imgextra/i3/O1CN01JPT4Fj1FJTfmHfNxO_!!6000000000466-49-tps-512-512.webp"
|
src="https://page-agent.github.io/assets/brand/page-agent-color-512.webp"
|
||||||
alt="Page Agent"
|
alt="Page Agent"
|
||||||
/>
|
/>
|
||||||
<div class="badge">Page Agent MCP Launcher</div>
|
<div class="badge">Page Agent MCP Launcher</div>
|
||||||
@@ -161,7 +161,7 @@
|
|||||||
|
|
||||||
<a class="store-btn" href="__STORE_URL__" target="_blank">
|
<a class="store-btn" href="__STORE_URL__" target="_blank">
|
||||||
<img
|
<img
|
||||||
src="https://img.alicdn.com/imgextra/i3/O1CN01JpW0Vo1sR3FpiZKFM_!!6000000005762-55-tps-192-192.svg"
|
src="https://page-agent.github.io/assets/third-party/chrome-web-store-192.svg"
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
<span data-i18n="install_btn">Install from Chrome Web Store</span>
|
<span data-i18n="install_btn">Install from Chrome Web Store</span>
|
||||||
@@ -172,16 +172,26 @@
|
|||||||
If the extension is outdated, please update it to the latest version.
|
If the extension is outdated, please update it to the latest version.
|
||||||
</li>
|
</li>
|
||||||
<li data-i18n="tip_other_browser">
|
<li data-i18n="tip_other_browser">
|
||||||
If the extension is not installed in this browser, open this page from the
|
If the extension is not installed in this browser, open this page from the browser that
|
||||||
browser that has it installed.
|
has it installed.
|
||||||
</li>
|
</li>
|
||||||
<li data-i18n="tip_refresh">Refresh this page after installing or updating.</li>
|
<li data-i18n="tip_refresh">Refresh this page after installing or updating.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="links">
|
<div class="links">
|
||||||
<a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank" data-i18n="link_docs">Docs</a>
|
<a
|
||||||
<a href="https://github.com/alibaba/page-agent/issues" target="_blank" data-i18n="link_issues">Report an Issue</a>
|
href="https://alibaba.github.io/page-agent/docs/introduction/overview"
|
||||||
|
target="_blank"
|
||||||
|
data-i18n="link_docs"
|
||||||
|
>Docs</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="https://github.com/alibaba/page-agent/issues"
|
||||||
|
target="_blank"
|
||||||
|
data-i18n="link_issues"
|
||||||
|
>Report an Issue</a
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -197,8 +207,7 @@
|
|||||||
install_sub: 'Page Agent 需要安装最新版浏览器插件才能运行。',
|
install_sub: 'Page Agent 需要安装最新版浏览器插件才能运行。',
|
||||||
install_btn: '从 Chrome 应用商店安装',
|
install_btn: '从 Chrome 应用商店安装',
|
||||||
tip_outdated: '如果插件版本过旧,请更新到最新版本。',
|
tip_outdated: '如果插件版本过旧,请更新到最新版本。',
|
||||||
tip_other_browser:
|
tip_other_browser: '如果该浏览器中未安装插件,请从装有插件的浏览器打开此页面。',
|
||||||
'如果该浏览器中未安装插件,请从装有插件的浏览器打开此页面。',
|
|
||||||
tip_refresh: '安装或更新后,请刷新此页面。',
|
tip_refresh: '安装或更新后,请刷新此页面。',
|
||||||
link_docs: '文档',
|
link_docs: '文档',
|
||||||
link_issues: '问题反馈',
|
link_issues: '问题反馈',
|
||||||
@@ -220,13 +229,9 @@
|
|||||||
if (!globalThis.chrome?.runtime?.sendMessage) {
|
if (!globalThis.chrome?.runtime?.sendMessage) {
|
||||||
showInstall()
|
showInstall()
|
||||||
} else {
|
} else {
|
||||||
chrome.runtime.sendMessage(
|
chrome.runtime.sendMessage(EXT_ID, { type: 'OPEN_HUB', wsPort }, (response) => {
|
||||||
EXT_ID,
|
if (chrome.runtime.lastError || !response?.ok) showInstall()
|
||||||
{ type: 'OPEN_HUB', wsPort },
|
})
|
||||||
(response) => {
|
|
||||||
if (chrome.runtime.lastError || !response?.ok) showInstall()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
showInstall()
|
showInstall()
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "page-agent",
|
"name": "page-agent",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "1.6.2",
|
"version": "1.12.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/esm/page-agent.js",
|
"main": "./src/PageAgent.ts",
|
||||||
"module": "./dist/esm/page-agent.js",
|
"types": "./src/PageAgent.ts",
|
||||||
"types": "./dist/esm/PageAgent.d.ts",
|
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/esm/PageAgent.d.ts",
|
"types": "./src/PageAgent.ts",
|
||||||
"import": "./dist/esm/page-agent.js",
|
"default": "./src/PageAgent.ts"
|
||||||
"default": "./dist/esm/page-agent.js"
|
}
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"main": "./dist/esm/page-agent.js",
|
||||||
|
"module": "./dist/esm/page-agent.js",
|
||||||
|
"types": "./dist/esm/PageAgent.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/esm/PageAgent.d.ts",
|
||||||
|
"import": "./dist/esm/page-agent.js",
|
||||||
|
"default": "./dist/esm/page-agent.js"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
@@ -40,20 +50,20 @@
|
|||||||
"build": "vite build && npm run build:demo",
|
"build": "vite build && npm run build:demo",
|
||||||
"build:demo": "vite build --config vite.iife.config.js",
|
"build:demo": "vite build --config vite.iife.config.js",
|
||||||
"dev:demo": "concurrently \"vite build --config vite.iife.config.js --watch\" \"npx serve dist/iife -p 5174\"",
|
"dev:demo": "concurrently \"vite build --config vite.iife.config.js --watch\" \"npx serve dist/iife -p 5174\"",
|
||||||
"prepublishOnly": "node -e \"const fs=require('fs');['README.md','LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
"prepublishOnly": "node ../../scripts/pre-publish.js",
|
||||||
"postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
"postpublish": "node ../../scripts/post-publish.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@page-agent/core": "1.6.2",
|
"@page-agent/core": "1.12.1",
|
||||||
"@page-agent/llms": "1.6.2",
|
"@page-agent/llms": "1.12.1",
|
||||||
"@page-agent/page-controller": "1.6.2",
|
"@page-agent/page-controller": "1.12.1",
|
||||||
"@page-agent/ui": "1.6.2",
|
"@page-agent/ui": "1.12.1",
|
||||||
"chalk": "^5.6.2"
|
"chalk": "^5.6.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.4.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,12 @@
|
|||||||
*/
|
*/
|
||||||
import { PageAgent, type PageAgentConfig } from './PageAgent'
|
import { PageAgent, type PageAgentConfig } from './PageAgent'
|
||||||
|
|
||||||
|
const currentScript = document.currentScript as HTMLScriptElement | null
|
||||||
|
const currentScriptURL = currentScript?.src ? new URL(currentScript.src) : null
|
||||||
|
const autoInit = currentScriptURL?.searchParams.get('autoInit') !== 'false'
|
||||||
|
|
||||||
// Clean up existing instances to prevent multiple injections from bookmarklet
|
// Clean up existing instances to prevent multiple injections from bookmarklet
|
||||||
if (window.pageAgent) {
|
if (autoInit && window.pageAgent) {
|
||||||
window.pageAgent.dispose()
|
window.pageAgent.dispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,30 +22,34 @@ const DEMO_BASE_URL = 'https://page-ag-testing-ohftxirgbn.cn-shanghai.fcapp.run'
|
|||||||
const DEMO_API_KEY = 'NA'
|
const DEMO_API_KEY = 'NA'
|
||||||
|
|
||||||
// in case document.x is not ready yet
|
// in case document.x is not ready yet
|
||||||
setTimeout(() => {
|
if (autoInit) {
|
||||||
const currentScript = document.currentScript as HTMLScriptElement | null
|
setTimeout(() => {
|
||||||
let config: PageAgentConfig
|
let config: PageAgentConfig
|
||||||
|
let showPanel = true
|
||||||
|
|
||||||
if (currentScript) {
|
if (currentScriptURL) {
|
||||||
console.log('🚀 page-agent.js detected current script:', currentScript.src)
|
const url = currentScriptURL
|
||||||
const url = new URL(currentScript.src)
|
const model = url.searchParams.get('model') || DEMO_MODEL
|
||||||
const model = url.searchParams.get('model') || DEMO_MODEL
|
const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL
|
||||||
const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL
|
const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY
|
||||||
const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY
|
const language = (url.searchParams.get('lang') as 'zh-CN' | 'en-US') || 'zh-CN'
|
||||||
const language = (url.searchParams.get('lang') as 'zh-CN' | 'en-US') || 'zh-CN'
|
showPanel = ((url.searchParams.get('showPanel') as 'true' | 'false') || 'true') === 'true'
|
||||||
config = { model, baseURL, apiKey, language }
|
config = { model, baseURL, apiKey, language }
|
||||||
} else {
|
} else {
|
||||||
console.log('🚀 page-agent.js no current script detected, using default demo config')
|
console.log('🚀 page-agent.js no current script detected, using default demo config')
|
||||||
config = {
|
config = {
|
||||||
model: import.meta.env.LLM_MODEL_NAME ? import.meta.env.LLM_MODEL_NAME : DEMO_MODEL,
|
model: import.meta.env.LLM_MODEL_NAME ? import.meta.env.LLM_MODEL_NAME : DEMO_MODEL,
|
||||||
baseURL: import.meta.env.LLM_BASE_URL ? import.meta.env.LLM_BASE_URL : DEMO_BASE_URL,
|
baseURL: import.meta.env.LLM_BASE_URL ? import.meta.env.LLM_BASE_URL : DEMO_BASE_URL,
|
||||||
apiKey: import.meta.env.LLM_API_KEY ? import.meta.env.LLM_API_KEY : DEMO_API_KEY,
|
apiKey: import.meta.env.LLM_API_KEY ? import.meta.env.LLM_API_KEY : DEMO_API_KEY,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Create agent
|
// Create agent
|
||||||
window.pageAgent = new PageAgent(config)
|
window.pageAgent = new PageAgent(config)
|
||||||
window.pageAgent.panel.show()
|
if (showPanel) {
|
||||||
|
window.pageAgent.panel.show()
|
||||||
|
}
|
||||||
|
|
||||||
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
|
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
// @workaround DTS bug
|
|
||||||
// dts do not work with monorepo path mapping
|
|
||||||
// disable path mapping for it
|
|
||||||
"paths": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +1,8 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
|
||||||
"noEmit": false,
|
|
||||||
"allowImportingTsExtensions": false,
|
|
||||||
"baseUrl": ".",
|
|
||||||
"outDir": "dist",
|
|
||||||
"paths": {
|
|
||||||
//
|
|
||||||
"@page-agent/llms": ["../llms/src/index.ts"],
|
|
||||||
"@page-agent/page-controller": ["../page-controller/src/PageController.ts"],
|
|
||||||
"@page-agent/core": ["../core/src/PageAgentCore.ts"],
|
|
||||||
"@page-agent/ui": ["../ui/src/index.ts"]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["dist", "node_modules"],
|
"exclude": ["dist", "node_modules"]
|
||||||
"references": [
|
|
||||||
//
|
|
||||||
{ "path": "../llms" },
|
|
||||||
{ "path": "../page-controller" },
|
|
||||||
{ "path": "../core" },
|
|
||||||
{ "path": "../ui" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,20 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
plugins: [
|
plugins: [
|
||||||
dts({ tsconfigPath: './tsconfig.dts.json', bundleTypes: true }),
|
dts({
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
exclude: ['src/**/*.test.ts'],
|
||||||
|
bundleTypes: true,
|
||||||
|
compilerOptions: {
|
||||||
|
composite: true,
|
||||||
|
noEmit: false,
|
||||||
|
emitDeclarationOnly: true,
|
||||||
|
declaration: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
cssInjectedByJsPlugin({ relativeCSSInjection: true }),
|
cssInjectedByJsPlugin({ relativeCSSInjection: true }),
|
||||||
],
|
],
|
||||||
publicDir: false,
|
publicDir: false,
|
||||||
esbuild: {
|
|
||||||
keepNames: true,
|
|
||||||
},
|
|
||||||
build: {
|
build: {
|
||||||
lib: {
|
lib: {
|
||||||
entry: resolve(__dirname, 'src/PageAgent.ts'),
|
entry: resolve(__dirname, 'src/PageAgent.ts'),
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { config as dotenvConfig } from 'dotenv'
|
|||||||
import { dirname, resolve } from 'path'
|
import { dirname, resolve } from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
// import { analyzer } from 'vite-bundle-analyzer'
|
|
||||||
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
@@ -21,17 +20,6 @@ export default defineConfig(() => ({
|
|||||||
// analyzer()
|
// analyzer()
|
||||||
],
|
],
|
||||||
publicDir: false,
|
publicDir: false,
|
||||||
esbuild: {
|
|
||||||
keepNames: true,
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@page-agent/page-controller': resolve(__dirname, '../page-controller/src/PageController.ts'),
|
|
||||||
'@page-agent/llms': resolve(__dirname, '../llms/src/index.ts'),
|
|
||||||
'@page-agent/core': resolve(__dirname, '../core/src/PageAgentCore.ts'),
|
|
||||||
'@page-agent/ui': resolve(__dirname, '../ui/src/index.ts'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
build: {
|
build: {
|
||||||
lib: {
|
lib: {
|
||||||
entry: resolve(__dirname, 'src/demo.ts'),
|
entry: resolve(__dirname, 'src/demo.ts'),
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/page-controller",
|
"name": "@page-agent/page-controller",
|
||||||
"version": "1.6.2",
|
"version": "1.12.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/lib/page-controller.js",
|
"main": "./src/PageController.ts",
|
||||||
"module": "./dist/lib/page-controller.js",
|
"types": "./src/PageController.ts",
|
||||||
"types": "./dist/lib/PageController.d.ts",
|
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/lib/PageController.d.ts",
|
"types": "./src/PageController.ts",
|
||||||
"import": "./dist/lib/page-controller.js",
|
"default": "./src/PageController.ts"
|
||||||
"default": "./dist/lib/page-controller.js"
|
}
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"main": "./dist/lib/page-controller.js",
|
||||||
|
"module": "./dist/lib/page-controller.js",
|
||||||
|
"types": "./dist/lib/PageController.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/lib/PageController.d.ts",
|
||||||
|
"import": "./dist/lib/page-controller.js",
|
||||||
|
"default": "./dist/lib/page-controller.js"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
@@ -32,8 +42,9 @@
|
|||||||
"homepage": "https://alibaba.github.io/page-agent/",
|
"homepage": "https://alibaba.github.io/page-agent/",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
"test": "vitest run",
|
||||||
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
"prepublishOnly": "node ../../scripts/pre-publish.js",
|
||||||
|
"postpublish": "node ../../scripts/post-publish.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ai-motion": "^0.4.8"
|
"ai-motion": "^0.4.8"
|
||||||
|
|||||||
@@ -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('❌')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -185,7 +185,7 @@ export class PageController extends EventTarget {
|
|||||||
|
|
||||||
const blacklist = [
|
const blacklist = [
|
||||||
...(this.config.interactiveBlacklist || []),
|
...(this.config.interactiveBlacklist || []),
|
||||||
...document.querySelectorAll('[data-page-agent-not-interactive]').values(),
|
...Array.from(document.querySelectorAll('[data-page-agent-not-interactive]')),
|
||||||
]
|
]
|
||||||
|
|
||||||
this.flatTree = dom.getFlatTree({
|
this.flatTree = dom.getFlatTree({
|
||||||
@@ -193,7 +193,11 @@ export class PageController extends EventTarget {
|
|||||||
interactiveBlacklist: blacklist,
|
interactiveBlacklist: blacklist,
|
||||||
})
|
})
|
||||||
|
|
||||||
this.simplifiedHTML = dom.flatTreeToString(this.flatTree, this.config.includeAttributes)
|
this.simplifiedHTML = dom.flatTreeToString(
|
||||||
|
this.flatTree,
|
||||||
|
this.config.includeAttributes,
|
||||||
|
this.config.keepSemanticTags
|
||||||
|
)
|
||||||
|
|
||||||
this.selectorMap.clear()
|
this.selectorMap.clear()
|
||||||
this.selectorMap = dom.getSelectorMap(this.flatTree)
|
this.selectorMap = dom.getSelectorMap(this.flatTree)
|
||||||
@@ -218,6 +222,7 @@ export class PageController extends EventTarget {
|
|||||||
* Clean up all element highlights
|
* Clean up all element highlights
|
||||||
*/
|
*/
|
||||||
async cleanUpHighlights(): Promise<void> {
|
async cleanUpHighlights(): Promise<void> {
|
||||||
|
console.log('[PageController] cleanUpHighlights')
|
||||||
dom.cleanUpHighlights()
|
dom.cleanUpHighlights()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,11 +326,11 @@ export class PageController extends EventTarget {
|
|||||||
|
|
||||||
this.assertIndexed()
|
this.assertIndexed()
|
||||||
|
|
||||||
const scrollAmount = pixels ?? numPages * (down ? 1 : -1) * window.innerHeight
|
const scrollAmount = (pixels ?? numPages * window.innerHeight) * (down ? 1 : -1)
|
||||||
|
|
||||||
const element = index !== undefined ? getElementByIndex(this.selectorMap, index) : null
|
const element = index !== undefined ? getElementByIndex(this.selectorMap, index) : null
|
||||||
|
|
||||||
const message = await scrollVertically(down, scrollAmount, element)
|
const message = await scrollVertically(scrollAmount, element)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -356,7 +361,7 @@ export class PageController extends EventTarget {
|
|||||||
|
|
||||||
const element = index !== undefined ? getElementByIndex(this.selectorMap, index) : null
|
const element = index !== undefined ? getElementByIndex(this.selectorMap, index) : null
|
||||||
|
|
||||||
const message = await scrollHorizontally(right, scrollAmount, element)
|
const message = await scrollHorizontally(scrollAmount, element)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -371,13 +376,15 @@ export class PageController extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute arbitrary JavaScript on the page
|
* Execute arbitrary JavaScript on the page.
|
||||||
|
* The optional `signal` is exposed to the script scope so cooperative code
|
||||||
|
* can abort promptly when the task is stopped.
|
||||||
*/
|
*/
|
||||||
async executeJavascript(script: string): Promise<ActionResult> {
|
async executeJavascript(script: string, signal?: AbortSignal): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
// Wrap script in async function to support await
|
// Wrap script in async function to support await, exposing `signal`.
|
||||||
const asyncFunction = eval(`(async () => { ${script} })`)
|
const asyncFunction = eval(`(async (signal) => { ${script} })`)
|
||||||
const result = await asyncFunction()
|
const result = await asyncFunction(signal)
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `✅ Executed JavaScript. Result: ${result}`,
|
message: `✅ Executed JavaScript. Result: ${result}`,
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
*/
|
*/
|
||||||
import type { InteractiveElementDomNode } from './dom/dom_tree/type'
|
import type { InteractiveElementDomNode } from './dom/dom_tree/type'
|
||||||
import {
|
import {
|
||||||
|
clickPointer,
|
||||||
|
disablePassThrough,
|
||||||
|
enablePassThrough,
|
||||||
getNativeValueSetter,
|
getNativeValueSetter,
|
||||||
isHTMLElement,
|
isHTMLElement,
|
||||||
isInputElement,
|
isInputElement,
|
||||||
@@ -42,19 +45,20 @@ let lastClickedElement: HTMLElement | null = null
|
|||||||
|
|
||||||
function blurLastClickedElement() {
|
function blurLastClickedElement() {
|
||||||
if (lastClickedElement) {
|
if (lastClickedElement) {
|
||||||
|
lastClickedElement.dispatchEvent(new PointerEvent('pointerout', { bubbles: true }))
|
||||||
|
lastClickedElement.dispatchEvent(new PointerEvent('pointerleave', { bubbles: false }))
|
||||||
|
lastClickedElement.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }))
|
||||||
|
lastClickedElement.dispatchEvent(new MouseEvent('mouseleave', { bubbles: false }))
|
||||||
lastClickedElement.blur()
|
lastClickedElement.blur()
|
||||||
lastClickedElement.dispatchEvent(
|
|
||||||
new MouseEvent('mouseout', { bubbles: true, cancelable: true })
|
|
||||||
)
|
|
||||||
lastClickedElement.dispatchEvent(
|
|
||||||
new MouseEvent('mouseleave', { bubbles: false, cancelable: true })
|
|
||||||
)
|
|
||||||
lastClickedElement = null
|
lastClickedElement = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simulate a click on the element
|
* Simulate a full click following W3C Pointer Events + UI Events spec order:
|
||||||
|
* pointerover/enter → mouseover/enter → pointerdown → mousedown → [focus] →
|
||||||
|
* pointerup → mouseup → click
|
||||||
|
*
|
||||||
* @private Internal method, subject to change at any time.
|
* @private Internal method, subject to change at any time.
|
||||||
*/
|
*/
|
||||||
export async function clickElement(element: HTMLElement) {
|
export async function clickElement(element: HTMLElement) {
|
||||||
@@ -63,32 +67,62 @@ export async function clickElement(element: HTMLElement) {
|
|||||||
lastClickedElement = element
|
lastClickedElement = element
|
||||||
|
|
||||||
await scrollIntoViewIfNeeded(element)
|
await scrollIntoViewIfNeeded(element)
|
||||||
// Scroll the iframe element itself into view if needed
|
|
||||||
const frame = element.ownerDocument.defaultView?.frameElement
|
const frame = element.ownerDocument.defaultView?.frameElement
|
||||||
if (frame) await scrollIntoViewIfNeeded(frame)
|
if (frame) await scrollIntoViewIfNeeded(frame)
|
||||||
|
|
||||||
await movePointerToElement(element)
|
const rect = element.getBoundingClientRect()
|
||||||
window.dispatchEvent(new CustomEvent('PageAgent::ClickPointer'))
|
const x = rect.left + rect.width / 2
|
||||||
|
const y = rect.top + rect.height / 2
|
||||||
|
|
||||||
|
await movePointerToElement(element, x, y)
|
||||||
|
await clickPointer()
|
||||||
|
|
||||||
await waitFor(0.1)
|
await waitFor(0.1)
|
||||||
|
|
||||||
// hover it
|
// Hit-test to find the deepest element at click coordinates, matching
|
||||||
element.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true, cancelable: true }))
|
// real browser behavior where events target the innermost element.
|
||||||
element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true }))
|
// @note This may hit a element in the blacklist
|
||||||
|
// TODO: This is a temporary workaround. Should have been handled during dom extraction.
|
||||||
|
const doc = element.ownerDocument
|
||||||
|
await enablePassThrough()
|
||||||
|
const hitTarget = doc.elementFromPoint(x, y)
|
||||||
|
await disablePassThrough()
|
||||||
|
const target =
|
||||||
|
hitTarget instanceof HTMLElement && element.contains(hitTarget) ? hitTarget : element
|
||||||
|
|
||||||
// dispatch a sequence of events to ensure all listeners are triggered
|
const pointerOpts = {
|
||||||
element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }))
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
clientX: x,
|
||||||
|
clientY: y,
|
||||||
|
pointerType: 'mouse',
|
||||||
|
}
|
||||||
|
const mouseOpts = { bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0 }
|
||||||
|
|
||||||
// focus it to ensure it gets the click event
|
// Hover — pointer events first, then mouse events (spec order)
|
||||||
element.focus()
|
target.dispatchEvent(new PointerEvent('pointerover', pointerOpts))
|
||||||
|
target.dispatchEvent(new PointerEvent('pointerenter', { ...pointerOpts, bubbles: false }))
|
||||||
|
target.dispatchEvent(new MouseEvent('mouseover', mouseOpts))
|
||||||
|
target.dispatchEvent(new MouseEvent('mouseenter', { ...mouseOpts, bubbles: false }))
|
||||||
|
|
||||||
element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }))
|
// Press
|
||||||
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
target.dispatchEvent(new PointerEvent('pointerdown', pointerOpts))
|
||||||
|
target.dispatchEvent(new MouseEvent('mousedown', mouseOpts))
|
||||||
|
|
||||||
// dispatch a click event
|
// Focus is not part of the standard pointer/mouse event sequence
|
||||||
// element.click()
|
// "undefined and varies between user agents".
|
||||||
|
// We focus the original element (nearest focusable ancestor), not the hit-test target, matching browser behavior.
|
||||||
|
element.focus({ preventScroll: true })
|
||||||
|
|
||||||
await waitFor(0.2) // Wait to ensure click event processing completes
|
// Release
|
||||||
|
target.dispatchEvent(new PointerEvent('pointerup', pointerOpts))
|
||||||
|
target.dispatchEvent(new MouseEvent('mouseup', mouseOpts))
|
||||||
|
|
||||||
|
// Click — activation behavior (navigation, form submit, etc.) triggers
|
||||||
|
// via bubbling from target up to the interactive ancestor.
|
||||||
|
target.click()
|
||||||
|
|
||||||
|
await waitFor(0.2)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,9 +208,7 @@ export async function inputTextElement(element: HTMLElement, text: string) {
|
|||||||
selection?.removeAllRanges()
|
selection?.removeAllRanges()
|
||||||
selection?.addRange(range)
|
selection?.addRange(range)
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
||||||
doc.execCommand('delete', false)
|
doc.execCommand('delete', false)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
||||||
doc.execCommand('insertText', false, text)
|
doc.execCommand('insertText', false, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,14 +272,7 @@ export async function scrollIntoViewIfNeeded(element: Element) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function scrollVertically(scroll_amount: number, element?: HTMLElement | null) {
|
||||||
* @private Internal method, subject to change at any time.
|
|
||||||
*/
|
|
||||||
export async function scrollVertically(
|
|
||||||
down: boolean,
|
|
||||||
scroll_amount: number,
|
|
||||||
element?: HTMLElement | null
|
|
||||||
) {
|
|
||||||
// Element-specific scrolling if element is provided
|
// Element-specific scrolling if element is provided
|
||||||
if (element) {
|
if (element) {
|
||||||
const targetElement = element
|
const targetElement = element
|
||||||
@@ -260,7 +285,10 @@ export async function scrollVertically(
|
|||||||
|
|
||||||
while (currentElement && attempts < 10) {
|
while (currentElement && attempts < 10) {
|
||||||
const computedStyle = window.getComputedStyle(currentElement)
|
const computedStyle = window.getComputedStyle(currentElement)
|
||||||
const hasScrollableY = /(auto|scroll|overlay)/.test(computedStyle.overflowY)
|
const hasScrollableY =
|
||||||
|
/(auto|scroll|overlay)/.test(computedStyle.overflowY) ||
|
||||||
|
(computedStyle.scrollbarWidth && computedStyle.scrollbarWidth !== 'auto') ||
|
||||||
|
(computedStyle.scrollbarGutter && computedStyle.scrollbarGutter !== 'auto')
|
||||||
const canScrollVertically = currentElement.scrollHeight > currentElement.clientHeight
|
const canScrollVertically = currentElement.scrollHeight > currentElement.clientHeight
|
||||||
|
|
||||||
if (hasScrollableY && canScrollVertically) {
|
if (hasScrollableY && canScrollVertically) {
|
||||||
@@ -306,15 +334,28 @@ export async function scrollVertically(
|
|||||||
|
|
||||||
const dy = scroll_amount
|
const dy = scroll_amount
|
||||||
const bigEnough = (el: HTMLElement) => el.clientHeight >= window.innerHeight * 0.5
|
const bigEnough = (el: HTMLElement) => el.clientHeight >= window.innerHeight * 0.5
|
||||||
const canScroll = (el: HTMLElement | null) =>
|
const canScroll = (el: HTMLElement | null): boolean =>
|
||||||
el &&
|
Boolean(
|
||||||
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowY) &&
|
el &&
|
||||||
el.scrollHeight > el.clientHeight &&
|
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowY) &&
|
||||||
bigEnough(el)
|
el.scrollHeight > el.clientHeight &&
|
||||||
|
bigEnough(el)
|
||||||
|
)
|
||||||
|
|
||||||
|
// @deprecated Heuristic container search.
|
||||||
|
// Unreliable in multi-panel layouts. Should guide LLMs to use indexed scroll for consistency.
|
||||||
|
// TODO: remove this fallback
|
||||||
|
|
||||||
|
// try to find the nearest scrollable container
|
||||||
|
// document.activeElement is usually body.
|
||||||
|
// After a successful element.focus(), activeElement become the nearest focusable parent
|
||||||
|
|
||||||
let el: HTMLElement | null = document.activeElement as HTMLElement | null
|
let el: HTMLElement | null = document.activeElement as HTMLElement | null
|
||||||
while (el && !canScroll(el) && el !== document.body) el = el.parentElement
|
while (el && !canScroll(el) && el !== document.body) el = el.parentElement
|
||||||
|
|
||||||
|
// Something is wrong if it falls back to global '*' search
|
||||||
|
// TODO: Return error message instead of global '*' search
|
||||||
|
|
||||||
el = canScroll(el)
|
el = canScroll(el)
|
||||||
? el
|
? el
|
||||||
: Array.from(document.querySelectorAll<HTMLElement>('*')).find(canScroll) ||
|
: Array.from(document.querySelectorAll<HTMLElement>('*')).find(canScroll) ||
|
||||||
@@ -345,6 +386,10 @@ export async function scrollVertically(
|
|||||||
return `✅ Scrolled page by ${scrolled}px.`
|
return `✅ Scrolled page by ${scrolled}px.`
|
||||||
} else {
|
} else {
|
||||||
// Container scroll
|
// Container scroll
|
||||||
|
|
||||||
|
const warningMsg = `The document is not scrollable. Falling back to container scroll.`
|
||||||
|
console.log(`[PageController] ${warningMsg}`)
|
||||||
|
|
||||||
const scrollBefore = el!.scrollTop
|
const scrollBefore = el!.scrollTop
|
||||||
const scrollMax = el!.scrollHeight - el!.clientHeight
|
const scrollMax = el!.scrollHeight - el!.clientHeight
|
||||||
|
|
||||||
@@ -356,29 +401,22 @@ export async function scrollVertically(
|
|||||||
|
|
||||||
if (Math.abs(scrolled) < 1) {
|
if (Math.abs(scrolled) < 1) {
|
||||||
return dy > 0
|
return dy > 0
|
||||||
? `⚠️ Already at the bottom of container (${el!.tagName}), cannot scroll down further.`
|
? `⚠️ ${warningMsg} Already at the bottom of container (${el!.tagName}), cannot scroll down further.`
|
||||||
: `⚠️ Already at the top of container (${el!.tagName}), cannot scroll up further.`
|
: `⚠️ ${warningMsg} Already at the top of container (${el!.tagName}), cannot scroll up further.`
|
||||||
}
|
}
|
||||||
|
|
||||||
const reachedBottom = dy > 0 && scrollAfter >= scrollMax - 1
|
const reachedBottom = dy > 0 && scrollAfter >= scrollMax - 1
|
||||||
const reachedTop = dy < 0 && scrollAfter <= 1
|
const reachedTop = dy < 0 && scrollAfter <= 1
|
||||||
|
|
||||||
if (reachedBottom)
|
if (reachedBottom)
|
||||||
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the bottom.`
|
return `✅ ${warningMsg} Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the bottom.`
|
||||||
if (reachedTop)
|
if (reachedTop)
|
||||||
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the top.`
|
return `✅ ${warningMsg} Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the top.`
|
||||||
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px.`
|
return `✅ ${warningMsg} Scrolled container (${el!.tagName}) by ${scrolled}px.`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function scrollHorizontally(scroll_amount: number, element?: HTMLElement | null) {
|
||||||
* @private Internal method, subject to change at any time.
|
|
||||||
*/
|
|
||||||
export async function scrollHorizontally(
|
|
||||||
right: boolean,
|
|
||||||
scroll_amount: number,
|
|
||||||
element?: HTMLElement | null
|
|
||||||
) {
|
|
||||||
// Element-specific scrolling if element is provided
|
// Element-specific scrolling if element is provided
|
||||||
if (element) {
|
if (element) {
|
||||||
const targetElement = element
|
const targetElement = element
|
||||||
@@ -387,11 +425,14 @@ export async function scrollHorizontally(
|
|||||||
let scrolledElement: HTMLElement | null = null
|
let scrolledElement: HTMLElement | null = null
|
||||||
let scrollDelta = 0
|
let scrollDelta = 0
|
||||||
let attempts = 0
|
let attempts = 0
|
||||||
const dx = right ? scroll_amount : -scroll_amount
|
const dx = scroll_amount
|
||||||
|
|
||||||
while (currentElement && attempts < 10) {
|
while (currentElement && attempts < 10) {
|
||||||
const computedStyle = window.getComputedStyle(currentElement)
|
const computedStyle = window.getComputedStyle(currentElement)
|
||||||
const hasScrollableX = /(auto|scroll|overlay)/.test(computedStyle.overflowX)
|
const hasScrollableX =
|
||||||
|
/(auto|scroll|overlay)/.test(computedStyle.overflowX) ||
|
||||||
|
(computedStyle.scrollbarWidth && computedStyle.scrollbarWidth !== 'auto') ||
|
||||||
|
(computedStyle.scrollbarGutter && computedStyle.scrollbarGutter !== 'auto')
|
||||||
const canScrollHorizontally = currentElement.scrollWidth > currentElement.clientWidth
|
const canScrollHorizontally = currentElement.scrollWidth > currentElement.clientWidth
|
||||||
|
|
||||||
if (hasScrollableX && canScrollHorizontally) {
|
if (hasScrollableX && canScrollHorizontally) {
|
||||||
@@ -435,13 +476,19 @@ export async function scrollHorizontally(
|
|||||||
|
|
||||||
// Page-level scrolling (default or fallback)
|
// Page-level scrolling (default or fallback)
|
||||||
|
|
||||||
const dx = right ? scroll_amount : -scroll_amount
|
const dx = scroll_amount
|
||||||
|
|
||||||
const bigEnough = (el: HTMLElement) => el.clientWidth >= window.innerWidth * 0.5
|
const bigEnough = (el: HTMLElement) => el.clientWidth >= window.innerWidth * 0.5
|
||||||
const canScroll = (el: HTMLElement | null) =>
|
const canScroll = (el: HTMLElement | null): boolean =>
|
||||||
el &&
|
Boolean(
|
||||||
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowX) &&
|
el &&
|
||||||
el.scrollWidth > el.clientWidth &&
|
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowX) &&
|
||||||
bigEnough(el)
|
el.scrollWidth > el.clientWidth &&
|
||||||
|
bigEnough(el)
|
||||||
|
)
|
||||||
|
|
||||||
|
// @deprecated Same heuristic container search as scrollVertically.
|
||||||
|
// TODO: Remove once LLMs reliably use indexed scrolling via data-scrollable.
|
||||||
|
|
||||||
let el: HTMLElement | null = document.activeElement as HTMLElement | null
|
let el: HTMLElement | null = document.activeElement as HTMLElement | null
|
||||||
while (el && !canScroll(el) && el !== document.body) el = el.parentElement
|
while (el && !canScroll(el) && el !== document.body) el = el.parentElement
|
||||||
@@ -477,6 +524,9 @@ export async function scrollHorizontally(
|
|||||||
return `✅ Scrolled page horizontally by ${scrolled}px.`
|
return `✅ Scrolled page horizontally by ${scrolled}px.`
|
||||||
} else {
|
} else {
|
||||||
// Container scroll
|
// Container scroll
|
||||||
|
const warningMsg = `The document is not scrollable. Falling back to container scroll.`
|
||||||
|
console.log(`[PageController] ${warningMsg}`)
|
||||||
|
|
||||||
const scrollBefore = el!.scrollLeft
|
const scrollBefore = el!.scrollLeft
|
||||||
const scrollMax = el!.scrollWidth - el!.clientWidth
|
const scrollMax = el!.scrollWidth - el!.clientWidth
|
||||||
|
|
||||||
@@ -488,17 +538,17 @@ export async function scrollHorizontally(
|
|||||||
|
|
||||||
if (Math.abs(scrolled) < 1) {
|
if (Math.abs(scrolled) < 1) {
|
||||||
return dx > 0
|
return dx > 0
|
||||||
? `⚠️ Already at the right edge of container (${el!.tagName}), cannot scroll right further.`
|
? `⚠️ ${warningMsg} Already at the right edge of container (${el!.tagName}), cannot scroll right further.`
|
||||||
: `⚠️ Already at the left edge of container (${el!.tagName}), cannot scroll left further.`
|
: `⚠️ ${warningMsg} Already at the left edge of container (${el!.tagName}), cannot scroll left further.`
|
||||||
}
|
}
|
||||||
|
|
||||||
const reachedRight = dx > 0 && scrollAfter >= scrollMax - 1
|
const reachedRight = dx > 0 && scrollAfter >= scrollMax - 1
|
||||||
const reachedLeft = dx < 0 && scrollAfter <= 1
|
const reachedLeft = dx < 0 && scrollAfter <= 1
|
||||||
|
|
||||||
if (reachedRight)
|
if (reachedRight)
|
||||||
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the right edge.`
|
return `✅ ${warningMsg} Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the right edge.`
|
||||||
if (reachedLeft)
|
if (reachedLeft)
|
||||||
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the left edge.`
|
return `✅ ${warningMsg} Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the left edge.`
|
||||||
return `✅ Scrolled container (${el!.tagName}) horizontally by ${scrolled}px.`
|
return `✅ ${warningMsg} Scrolled container (${el!.tagName}) horizontally by ${scrolled}px.`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { FlatDomTree } from './type'
|
||||||
|
|
||||||
|
interface DomTreeArgs {
|
||||||
|
doHighlightElements?: boolean
|
||||||
|
focusHighlightIndex?: number
|
||||||
|
viewportExpansion?: number
|
||||||
|
debugMode?: boolean
|
||||||
|
interactiveBlacklist?: Element[]
|
||||||
|
interactiveWhitelist?: Element[]
|
||||||
|
highlightOpacity?: number
|
||||||
|
highlightLabelOpacity?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const domTree: (args?: DomTreeArgs) => FlatDomTree
|
||||||
|
|
||||||
|
export default domTree
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user