Compare commits
210 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5591dd3ea | |||
| 62a0d988ce | |||
| dc41969503 | |||
| 9d9a4b9698 | |||
| b171bf210f | |||
| c39678dece | |||
| 6c1f552606 | |||
| dcad67d4b4 | |||
| 15f3af406c | |||
| b50abcc4ec | |||
| 20f1b1f51e | |||
| cd035cd30f | |||
| a7cc935453 | |||
| 349609614b | |||
| ad680cee99 | |||
| e95b2d6a28 | |||
| f55c1b984b | |||
| 5ab34a2f50 | |||
| de24d97a22 | |||
| 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 | |||
| 75bc69c0c0 | |||
| 0d9b9e4b9a | |||
| 78a31ef5d1 | |||
| 98005e323a | |||
| f3c253c044 | |||
| 02f63ec04d | |||
| 583f757f0a | |||
| 53c9149d06 | |||
| 64a90e3b3a | |||
| abfa903c9c | |||
| 6dd0ceab73 | |||
| eede819ea8 | |||
| ce3047428a | |||
| 3456f45590 | |||
| 4f71f065df | |||
| 005bc8ab44 | |||
| cd2d33a9f6 | |||
| 80e96d0b9e | |||
| 2c3b27e337 | |||
| b6691b4732 | |||
| a557a85d28 | |||
| eeb5b6a5af | |||
| ad31e2b750 | |||
| e98d80b6a0 | |||
| 93b1e52376 | |||
| 66a85c0dd3 | |||
| 50ce56a4f6 | |||
| 5aeb40abac | |||
| 8c06cdc898 | |||
| dfde89aa70 | |||
| 49b3316a5f | |||
| c35f367b39 | |||
| 3459836a14 | |||
| b7b5b6db30 | |||
| 05d16313c7 | |||
| a3a96d85d5 | |||
| c89042f142 | |||
| 2e18bd862d | |||
| 0bc47a997d | |||
| bcc7dfea2d | |||
| ba242d3a1b | |||
| fe82b11285 | |||
| bdf79b7c10 | |||
| 24eefdef23 | |||
| 252f550c04 | |||
| 628c249d45 | |||
| 61d598142d | |||
| c0510b2235 | |||
| ca0005c825 | |||
| 17e92b6200 | |||
| 2e0cbef034 | |||
| 95b6785f05 | |||
| 2917941b13 | |||
| 31c496cc65 | |||
| cef39d5090 | |||
| 3063b2a06d | |||
| d66a390516 | |||
| aae6c93f11 | |||
| 2f5476b76c | |||
| 88ea2c6708 | |||
| 37760a97df | |||
| de76d97303 | |||
| 92196ac793 | |||
| 80dda37f8c | |||
| 5e7fe18463 | |||
| 8ad4d19232 | |||
| 2f92a9cb32 |
@@ -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,102 @@
|
||||
---
|
||||
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. **Never check "Testing" checkboxes.** The template Testing checkboxes (browser tested, no console errors, types/doc added) require manual browser verification that only a human can perform. They MUST remain unchecked. You MAY add a supplementary note below the Testing section listing what automated validation you actually ran and the results.
|
||||
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.
|
||||
|
||||
## 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.
|
||||
- **Lint and build**: Run lint/build for the affected area. 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. Leave all "Testing" and "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.
|
||||
- Run `npm run ci` to run all build and lint checks.
|
||||
- Escalate to broader validation when the diff crosses packages, changes shared code, or affects release behavior.
|
||||
- Never claim checks that did not actually run.
|
||||
- You MAY note what you ran and the results below the Testing section in the PR body, but do NOT check the template checkboxes.
|
||||
|
||||
## 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" and "Testing" checkboxes are unchecked (`- [ ]`) in the PR body. Double-check: if any `- [x]` appears in these sections, it is a violation — fix it before submitting.
|
||||
- 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
|
||||
description: Report a bug
|
||||
title: '[Bug] '
|
||||
labels: ['bug']
|
||||
type: Bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
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)
|
||||
- [Contributing Guide](https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md)
|
||||
|
||||
> 为了让更多人参与讨论,请尽量使用英文提交 Issue,感谢理解 🙏
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What happened?
|
||||
placeholder: Describe the bug and expected behavior
|
||||
label: What happened? / 问题描述
|
||||
description: Describe what you expected vs what actually happened.
|
||||
placeholder: 'A clear description of the bug.'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: code
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Code
|
||||
render: typescript
|
||||
placeholder: Minimal reproduction code
|
||||
label: How to reproduce / 如何复现
|
||||
description: Provide steps, URL, config or code snippet to reproduce the bug.
|
||||
placeholder: '1. Open URL: https://...'
|
||||
validations:
|
||||
required: false
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
placeholder: '0.0.0'
|
||||
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser
|
||||
placeholder: 'Chrome 120, Firefox 119, etc.'
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: version
|
||||
placeholder: '0.0.0'
|
||||
validations:
|
||||
required: false
|
||||
placeholder: 'e.g. Chrome 130'
|
||||
|
||||
- type: checkboxes
|
||||
id: community
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Community Communication / 社区沟通
|
||||
description: Confirm you will communicate respectfully and constructively / 确认将以礼貌、建设性的方式沟通
|
||||
label: Before submitting
|
||||
options:
|
||||
- label: I will be polite and respectful. / 我会保持礼貌与尊重。
|
||||
required: true
|
||||
- label: I will share constructive, actionable suggestions. / 我会提供建设性、可行动的建议。
|
||||
required: true
|
||||
- label: I have read the Code of Conduct. / 我已阅读行为准则。
|
||||
required: true
|
||||
- label: I have searched existing issues and this is not a duplicate.
|
||||
required: true
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
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 / 贡献指南
|
||||
url: https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md
|
||||
about: How to contribute code and ideas. 如何进行贡献与提交代码。
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
name: Feature Request
|
||||
description: Suggest a feature
|
||||
title: '[Feature] '
|
||||
labels: ['enhancement']
|
||||
type: Feature
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
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)
|
||||
- [Contributing Guide](https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md)
|
||||
|
||||
> 为了让更多人参与讨论,请尽量使用英文提交 Issue,感谢理解 🙏
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Feature Description / 功能描述
|
||||
description: Describe the problem, solution, and any API changes. / 描述问题、解决方案以及 API 变更。
|
||||
description: Describe the problem, solution, and any API changes.
|
||||
placeholder: |
|
||||
**Problem**:
|
||||
What problem does this solve?
|
||||
|
||||
**Solution**:
|
||||
How should this work?
|
||||
|
||||
**Proposed API**:
|
||||
```typescript
|
||||
// code here
|
||||
```
|
||||
validations:
|
||||
required: true
|
||||
@@ -34,14 +30,13 @@ body:
|
||||
- type: checkboxes
|
||||
id: community
|
||||
attributes:
|
||||
label: Community Communication / 社区沟通
|
||||
description: Confirm you will communicate respectfully and constructively / 确认将以礼貌、建设性的方式沟通
|
||||
label: Before submitting
|
||||
options:
|
||||
- label: I will be polite and respectful. / 我会保持礼貌与尊重。
|
||||
required: true
|
||||
- label: I will share constructive, actionable suggestions. / 我会提供建设性、可行动的建议。
|
||||
required: true
|
||||
- label: I have read the CODE_OF_CONDUCT.md and CONTRIBUTING.md. / 我已阅读行为准则。
|
||||
required: true
|
||||
- label: I have searched existing issues and this is not a duplicate.
|
||||
required: true
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
Brief description of changes.
|
||||
|
||||
Closes #(issue)
|
||||
|
||||
## Type
|
||||
|
||||
- [ ] Breaking change
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature / Improvement
|
||||
- [ ] Refactor
|
||||
- [ ] Documentation
|
||||
- [ ] Website
|
||||
- [ ] Demo / Testing
|
||||
- [ ] Breaking change
|
||||
- [ ] Refactor / Chores
|
||||
- [ ] Documentation / Website / Demo / Testing
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -18,8 +18,6 @@ Brief description of changes.
|
||||
- [ ] No console errors
|
||||
- [ ] Types/doc added
|
||||
|
||||
Closes #(issue)
|
||||
|
||||
## Requirements / 要求
|
||||
|
||||
- [ ] 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: '/'
|
||||
schedule:
|
||||
interval: 'weekly'
|
||||
labels:
|
||||
- 'dependencies'
|
||||
groups:
|
||||
# 生产依赖 - 小版本更新
|
||||
production-dependencies:
|
||||
dependency-type: 'production'
|
||||
update-types:
|
||||
- 'minor'
|
||||
- 'patch'
|
||||
|
||||
# 开发依赖 - 小版本更新
|
||||
development-dependencies:
|
||||
dependency-type: 'development'
|
||||
update-types:
|
||||
- 'minor'
|
||||
- 'patch'
|
||||
|
||||
# Major 更新单独处理(不分组,需要人工审查)
|
||||
# 安全更新也不分组,Dependabot 会自动优先创建独立 PR
|
||||
development-major:
|
||||
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'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'weekly'
|
||||
labels:
|
||||
- 'dependencies'
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: CI
|
||||
permissions:
|
||||
contents: read
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -17,6 +17,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v6
|
||||
@@ -24,18 +26,8 @@ jobs:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
|
||||
# test on default version of npm
|
||||
# - 9.6~10.8 on node@20
|
||||
# - 11.3~11.6 on node@24
|
||||
|
||||
- name: Node and NPM version
|
||||
run: node --version && npm --version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npx eslint . && npx prettier --check **/*.ts
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: CI checks
|
||||
run: node scripts/ci.js
|
||||
|
||||
@@ -28,13 +28,13 @@ jobs:
|
||||
run: npm run build:website
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: './packages/website/dist'
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
||||
+3
-2
@@ -25,6 +25,9 @@ dist-ssr
|
||||
*.sw?
|
||||
.qoder
|
||||
|
||||
# publish backup
|
||||
package.json.bak
|
||||
|
||||
# env files
|
||||
.env
|
||||
.env.*
|
||||
@@ -34,8 +37,6 @@ dist-ssr
|
||||
.wxt
|
||||
|
||||
# AI
|
||||
.agent
|
||||
.claude
|
||||
.cursor
|
||||
.gemini
|
||||
CLAUDE.md
|
||||
@@ -0,0 +1,8 @@
|
||||
# Prompt templates (formatted manually for LLM readability)
|
||||
*prompt.md
|
||||
|
||||
# Generated
|
||||
packages/extension/.wxt
|
||||
|
||||
# Vendored
|
||||
**/components/ui
|
||||
Vendored
+6
-2
@@ -22,7 +22,7 @@
|
||||
"packages/*/node_modules": true
|
||||
},
|
||||
"markdownlint.config": {
|
||||
// "comment": "Relaxed rules",
|
||||
// Relaxed rules
|
||||
"default": true,
|
||||
"whitespace": false,
|
||||
"line_length": false,
|
||||
@@ -35,5 +35,9 @@
|
||||
"blanks-around-lists": false,
|
||||
"ol-prefix": false,
|
||||
"no-duplicate-heading": false
|
||||
}
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"js/ts.tsdk.path": "node_modules/typescript/lib",
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
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
|
||||
- **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`**
|
||||
|
||||
Internal packages:
|
||||
@@ -18,18 +18,19 @@ Internal packages:
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
npm start # Start website dev server
|
||||
npm run build # Build all packages
|
||||
npm run build:libs # Build all libraries
|
||||
npm run lint # ESLint with TypeScript strict rules
|
||||
npm run zip -w @page-agent/ext # Zip the extension package
|
||||
npm start # Start website dev server
|
||||
npm run build # Build all packages
|
||||
npm run build:libs # Build all libraries
|
||||
npm run build:ext # Build and zip the extension package
|
||||
npm run typecheck # Typecheck all packages
|
||||
npm run lint # ESLint
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### 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/
|
||||
@@ -37,7 +38,7 @@ packages/
|
||||
├── page-agent/ # npm: "page-agent" entry class (with UI + controller + demo builds)
|
||||
├── website/ # @page-agent/website (private)
|
||||
├── llms/ # @page-agent/llms
|
||||
├── extension/ # Browser extension (WXT + React)
|
||||
├── extension/ # Browser extension
|
||||
├── page-controller/ # @page-agent/page-controller
|
||||
└── ui/ # @page-agent/ui
|
||||
```
|
||||
|
||||
+13
-171
@@ -2,42 +2,13 @@
|
||||
|
||||
♥️ We welcome contributions from everyone.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 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.
|
||||
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).
|
||||
|
||||
## 🤝 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
|
||||
- Search existing issues before creating new ones
|
||||
@@ -46,147 +17,24 @@ This is a **monorepo** with npm workspaces containing **4 main packages**:
|
||||
|
||||
### Code Contributions
|
||||
|
||||
1. **Fork and Clone**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-username/page-agent.git
|
||||
cd page-agent
|
||||
```
|
||||
|
||||
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
|
||||
1. Follow existing code style and patterns
|
||||
2. Update documentation as needed
|
||||
3. Add JSDoc for public APIs
|
||||
4. Build and lint everything
|
||||
5. Test in our demo website, and on other websites if applicable
|
||||
6. Include screenshots for UI changes
|
||||
|
||||
### 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**.
|
||||
- We have a [website/AGENTS.md](packages/website/AGENTS.md) for that.
|
||||
- Vibe coding is **NOT** allowed for the core lib!!!
|
||||
- NEVER try to vibe coding the MV3 extension!!! It is HELL.
|
||||
- Make sure your AI references `AGENTS.md` and `website/AGENTS.md` for better quality.
|
||||
- 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
|
||||
|
||||
- Breaking changes and large PRs without prior discussion
|
||||
- Heavy dependencies to core libs
|
||||
- Contributions without proper testing
|
||||
- Code that doesn't follow project conventions
|
||||
- Dependencies or code with licenses incompatible with MIT
|
||||
- Bot or AI-generated pull requests without meaningful human involvement
|
||||
|
||||
@@ -194,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.
|
||||
|
||||
> 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! 🎉
|
||||
|
||||
@@ -25,15 +25,16 @@ The GUI Agent Living in Your Webpage. Control web interfaces with natural langua
|
||||
- **📖 Text-based DOM manipulation**
|
||||
- No screenshots. No multi-modal LLMs or special permissions needed.
|
||||
- **🧠 Bring your own LLMs**
|
||||
- **🎨 Pretty UI with human-in-the-loop**
|
||||
- **🐙 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
|
||||
|
||||
## 💡 Use Cases
|
||||
|
||||
- **SaaS AI Copilot** — Ship an AI copilot in your product in lines of code. No backend rewrite.
|
||||
- **Smart Form Filling** — Turn 20-click workflows into one sentence. Perfect for ERP, CRM, and admin systems.
|
||||
- **Accessibility** — Make any web app accessible through natural language. Voice commands, screen readers, zero barrier.
|
||||
- **Multi-page Agent** — Extend your own 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 [chrome extension](https://alibaba.github.io/page-agent/docs/features/chrome-extension).
|
||||
- **MCP** - Allow your agent clients to control your browser.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
@@ -49,8 +50,8 @@ Fastest way to try PageAgent with our free Demo LLM:
|
||||
|
||||
| Mirrors | URL |
|
||||
| ------- | ---------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.5.8/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.5.8/files/dist/iife/page-agent.demo.js |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.8.1/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.8.1/files/dist/iife/page-agent.demo.js |
|
||||
|
||||
### NPM Installation
|
||||
|
||||
@@ -73,13 +74,27 @@ await agent.execute('Click the login button')
|
||||
|
||||
For more programmatic usage, see [📖 Documentations](https://alibaba.github.io/page-agent/docs/introduction/overview).
|
||||
|
||||
## 🌟 Awesome Page Agent
|
||||
|
||||
Built something cool with PageAgent? Add it here! Open a PR to share your project.
|
||||
|
||||
> These are community projects — not maintained or endorsed by us. Use at your own discretion.
|
||||
|
||||
| Project | Description |
|
||||
| -------- | ----------------------------------------------------------- |
|
||||
| _Yours?_ | [Open a PR](https://github.com/alibaba/page-agent/pulls) 🙌 |
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions from the community! 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 [Code of Conduct](docs/CODE_OF_CONDUCT.md) before contributing.
|
||||
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 agents without substantial human involvement will not be accepted, and bot accounts may be blocked.
|
||||
Contributions generated entirely by **bots or AI** without substantial human involvement will **not be accepted**.
|
||||
|
||||
## ⚖️ License
|
||||
|
||||
[MIT License](LICENSE)
|
||||
|
||||
## 👏 Acknowledgments
|
||||
|
||||
@@ -97,23 +112,8 @@ Licensed under the MIT License
|
||||
We gratefully acknowledge the browser-use project and its contributors for their
|
||||
excellent work on web automation and DOM interaction patterns that helped make
|
||||
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!**
|
||||
|
||||
<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>
|
||||
|
||||
+75
-36
@@ -5,6 +5,81 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.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
|
||||
|
||||
### Features
|
||||
|
||||
- **Beta MCP support** - New `@page-agent/mcp` package lets MCP clients such as Claude Desktop and Copilot control the browser through the Page Agent extension
|
||||
- **Better iframe handling** - Same-origin iframe elements are handled more reliably during DOM extraction and actions
|
||||
- **Extension history workflows** - Users can rerun past tasks, export history sessions as JSON, and approve MCP-triggered tasks before execution
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Unified versioning across packages** - The extension now follows the root workspace version. Changelog entries are no longer split into a separate extension version section
|
||||
- **Configurable `stepDelay`** - Agent pacing between steps is now configurable via `stepDelay`
|
||||
- **Optional API key** - `apiKey` can now be omitted for compatible deployments that do not require one
|
||||
- **Optional named tool choice** - Tool invocation can disable named tool choice for providers that behave better without it
|
||||
- **Better rich-text input support** - Improved `contenteditable` handling with better event dispatching and `execCommand` fallback for more editors
|
||||
- **More flexible DOM extraction** - `includeAttributes` now supports wildcards, `contenteditable` is included by default, and heuristically interactive elements expose more useful attributes
|
||||
- **MiniMax model support** - Added MiniMax compatibility, with the default recommendation updated to `MiniMax-M2.7`
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed Safari issues when `requestIdleCallback` is unavailable
|
||||
- Avoid throwing when `webgl2` initialization fails
|
||||
- Improved OpenAI-compatible request patches for GPT-5.4 chat tools and MiniMax temperature/tool-call compatibility
|
||||
- Fixed several UI polish issues in the extension and website, including cursor and layout regressions
|
||||
|
||||
## [1.5.1] - 2026-03-05
|
||||
|
||||
### Breaking Changes
|
||||
@@ -153,42 +228,6 @@ PageAgent is now ready for production use. The API is stable and breaking change
|
||||
- **Ask User Tool** - Agent can ask users for clarification
|
||||
- **i18n Support** - English and Chinese localization
|
||||
|
||||
### Configuration
|
||||
|
||||
```typescript
|
||||
// Version 1.0.0
|
||||
interface PageAgentConfig {
|
||||
// LLM Configuration (required)
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
temperature?: number
|
||||
maxRetries?: number
|
||||
customFetch?: typeof fetch
|
||||
|
||||
// Agent Configuration
|
||||
language?: 'en-US' | 'zh-CN'
|
||||
maxSteps?: number // default: 20
|
||||
customTools?: Record<string, PageAgentTool> // experimental
|
||||
instructions?: InstructionsConfig
|
||||
transformPageContent?: (content: string) => string | Promise<string>
|
||||
experimentalScriptExecutionTool?: boolean // default: false
|
||||
|
||||
// Lifecycle Hooks (experimental)
|
||||
onBeforeTask?: (agent, result) => void
|
||||
onAfterTask?: (agent, result) => void
|
||||
onBeforeStep?: (agent, stepCount) => void
|
||||
onAfterStep?: (agent, history) => void
|
||||
onDispose?: (agent, reason?) => void
|
||||
|
||||
// Page Controller Configuration
|
||||
enableMask?: boolean // default: true
|
||||
viewportExpansion?: number
|
||||
interactiveBlacklist?: Element[]
|
||||
interactiveWhitelist?: Element[]
|
||||
}
|
||||
```
|
||||
|
||||
### Packages
|
||||
|
||||
| Package | Description |
|
||||
|
||||
+23
-23
@@ -16,22 +16,22 @@ appearance, race, religion, or sexual identity and orientation.
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
- The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
@@ -85,19 +85,19 @@ available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.ht
|
||||
|
||||
有助于创造正面环境的行为包括但不限于:
|
||||
|
||||
* 使用友好和包容性语言
|
||||
* 尊重不同的观点和经历
|
||||
* 耐心地接受建设性批评
|
||||
* 关注对社区最有利的事情
|
||||
* 友善对待其他社区成员
|
||||
- 使用友好和包容性语言
|
||||
- 尊重不同的观点和经历
|
||||
- 耐心地接受建设性批评
|
||||
- 关注对社区最有利的事情
|
||||
- 友善对待其他社区成员
|
||||
|
||||
身为参与者不能接受的行为包括但不限于:
|
||||
|
||||
* 使用与性有关的言语或是图像,以及不受欢迎的性骚扰
|
||||
* 捣乱/煽动/造谣的行为或进行侮辱/贬损的评论,人身攻击及政治攻击
|
||||
* 公开或私下的骚扰
|
||||
* 未经许可地发布他人的个人资料,例如住址或是电子地址
|
||||
* 其他可以被合理地认定为不恰当或者违反职业操守的行为
|
||||
- 使用与性有关的言语或是图像,以及不受欢迎的性骚扰
|
||||
- 捣乱/煽动/造谣的行为或进行侮辱/贬损的评论,人身攻击及政治攻击
|
||||
- 公开或私下的骚扰
|
||||
- 未经许可地发布他人的个人资料,例如住址或是电子地址
|
||||
- 其他可以被合理地认定为不恰当或者违反职业操守的行为
|
||||
|
||||
## 我们的责任
|
||||
|
||||
|
||||
+16
-25
@@ -20,20 +20,20 @@
|
||||
## ✨ Features
|
||||
|
||||
- **🎯 轻松集成**
|
||||
- 无需 `浏览器插件` / `Python` / `无头浏览器`。
|
||||
- 纯页面内 JavaScript,一切都在你的网页中完成。
|
||||
- 无需 `浏览器插件` / `Python` / `无头浏览器`,纯页面内 JavaScript
|
||||
- **📖 基于文本的 DOM 操作**
|
||||
- 无需截图,无需多模态模型或特殊权限。
|
||||
- **🧠 用你自己的 LLM**
|
||||
- **🎨 精美 UI,支持人机协同**
|
||||
- **🐙 可选的 [Chrome 扩展](https://alibaba.github.io/page-agent/docs/features/chrome-extension),支持跨页面任务。**
|
||||
- 无需截图,无需多模态模型或特殊权限
|
||||
- **🧠 自备 LLM**
|
||||
- 🐙 可选的 [Chrome 扩展](https://alibaba.github.io/page-agent/docs/features/chrome-extension),支持跨页面任务
|
||||
- [MCP Server (Beta)](https://alibaba.github.io/page-agent/docs/features/mcp-server)
|
||||
|
||||
## 💡 应用场景
|
||||
|
||||
- **SaaS AI 副驾驶** — 几行代码为你的产品加上 AI 副驾驶,无需重写后端。
|
||||
- **SaaS AI Copilot** — 几行代码为你的产品加上 AI 副驾驶,无需重写后端。
|
||||
- **智能表单填写** — 把 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 加入浏览器控制能力。
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
@@ -49,8 +49,8 @@
|
||||
|
||||
| Mirrors | URL |
|
||||
| ------- | ---------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.5.8/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.5.8/files/dist/iife/page-agent.demo.js |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.8.1/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.8.1/files/dist/iife/page-agent.demo.js |
|
||||
|
||||
### NPM 安装
|
||||
|
||||
@@ -75,11 +75,13 @@ await agent.execute('点击登录按钮')
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎社区贡献!请参阅 [CONTRIBUTING.md](../CONTRIBUTING.md) 了解安装与贡献指南。请在贡献前阅读[行为准则](CODE_OF_CONDUCT.md)。
|
||||
欢迎社区贡献!请参阅 [CONTRIBUTING.md](../CONTRIBUTING.md) 了解安装与贡献指南。
|
||||
|
||||
我们不接受未经实质性人类参与、完全由 Bot 或 Agent 自动生成的代码,机器人账号可能被禁止参与互动。
|
||||
提交 issue 或 PR 之前,请先阅读[作者声明](https://github.com/alibaba/page-agent/issues/349)和[行为准则](CODE_OF_CONDUCT.md)。
|
||||
|
||||
## 👏 致谢
|
||||
我们不接受未经实质性人类参与、完全由 Bot 或 Agent 自动生成的代码。
|
||||
|
||||
## 👏 声明与致谢
|
||||
|
||||
本项目基于 **[`browser-use`](https://github.com/browser-use/browser-use)** 的优秀工作构建。
|
||||
|
||||
@@ -95,23 +97,12 @@ Licensed under the MIT License
|
||||
We gratefully acknowledge the browser-use project and its contributors for their
|
||||
excellent work on web automation and DOM interaction patterns that helped make
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
**⭐ 如果觉得 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?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.
|
||||
+6
-29
@@ -1,8 +1,5 @@
|
||||
import eslintReact from '@eslint-react/eslint-plugin'
|
||||
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 globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
@@ -15,44 +12,24 @@ export default defineConfig([
|
||||
'**/.wxt',
|
||||
'**/.output',
|
||||
]),
|
||||
{
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
},
|
||||
rules: reactHooks.configs.recommended.rules,
|
||||
},
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
// reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
eslintReact.configs['recommended-typescript'],
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
// project: ['./tsconfig.json'],
|
||||
// project: ['./packages/*/tsconfig.json'],
|
||||
// tsconfigRootDir: import.meta.dirname,
|
||||
projectService: true,
|
||||
},
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
// Add any additional rules here
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||
@@ -72,14 +49,14 @@ export default defineConfig([
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
'@typescript-eslint/no-unsafe-return': '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/use-unknown-in-catch-callback-variable': 'off',
|
||||
'@typescript-eslint/no-unnecessary-type-parameters': 'off',
|
||||
|
||||
// 'require-await': 'off',
|
||||
'@typescript-eslint/require-await': '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
+3264
-1724
File diff suppressed because it is too large
Load Diff
+31
-30
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"version": "1.5.8",
|
||||
"version": "1.8.1",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/page-controller",
|
||||
@@ -9,6 +9,7 @@
|
||||
"packages/llms",
|
||||
"packages/core",
|
||||
"packages/page-agent",
|
||||
"packages/mcp",
|
||||
"packages/extension",
|
||||
"packages/website"
|
||||
],
|
||||
@@ -21,52 +22,52 @@
|
||||
},
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
"node": "^22.13.0 || >=24"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "npm run dev --workspace=@page-agent/website",
|
||||
"dev:ext": "npm run dev -w @page-agent/ext",
|
||||
"dev:demo": "npm run dev:demo --workspace=page-agent",
|
||||
"build": "npm run build:libs && npm run build:website",
|
||||
"build:libs": "npm run build --workspaces --if-present",
|
||||
"build": "node scripts/build.js",
|
||||
"build:libs": "node scripts/build-libs.js",
|
||||
"build:website": "npm run build:website --workspace=@page-agent/website",
|
||||
"build:ext": "npm run build:libs && npm run zip -w @page-agent/ext",
|
||||
"build:ext": "npm run zip -w @page-agent/ext",
|
||||
"version": "node scripts/sync-version.js",
|
||||
"postpublish": "npm run postpublish --workspaces --if-present",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json && tsc --noEmit -p packages/extension/tsconfig.json",
|
||||
"lint": "eslint .",
|
||||
"cleanup": "rm -rf packages/*/dist",
|
||||
"prepare": "husky"
|
||||
"ci": "node scripts/ci.js",
|
||||
"cleanup": "rm -rf packages/*/dist && rm -rf packages/*/.output",
|
||||
"prepare": "husky || true"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.4.3",
|
||||
"@commitlint/config-conventional": "^20.4.3",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@microsoft/api-extractor": "^7.57.7",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@commitlint/cli": "^20.5.0",
|
||||
"@commitlint/config-conventional": "^20.5.0",
|
||||
"@eslint-react/eslint-plugin": "^4.2.3",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@microsoft/api-extractor": "^7.58.6",
|
||||
"@tailwindcss/vite": "^4.2.3",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/node": "^25.4.0",
|
||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@vitejs/plugin-react-swc": "^4.3.0",
|
||||
"chalk": "^5.6.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-dom": "^2.13.0",
|
||||
"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",
|
||||
"dotenv": "^17.4.2",
|
||||
"eslint": "^10.2.0",
|
||||
"globals": "^17.5.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.3.2",
|
||||
"prettier": "^3.8.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"lint-staged": "^16.4.0",
|
||||
"prettier": "^3.8.2",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.59.0",
|
||||
"unplugin-dts": "^1.0.0-beta.6",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-css-injected-by-js": "^4.0.1",
|
||||
"vite-bundle-analyzer": "^1.3.6"
|
||||
"vite": "^7.3.2",
|
||||
"vite-bundle-analyzer": "^1.3.7",
|
||||
"vite-plugin-css-injected-by-js": "^4.0.1"
|
||||
},
|
||||
"overrides": {
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^6.0.2",
|
||||
"@vitejs/plugin-react": "^5.2.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,cjs,cts,mjs,mts}": [
|
||||
|
||||
+22
-12
@@ -1,16 +1,26 @@
|
||||
{
|
||||
"name": "@page-agent/core",
|
||||
"private": false,
|
||||
"version": "1.5.8",
|
||||
"version": "1.8.1",
|
||||
"type": "module",
|
||||
"main": "./dist/esm/page-agent-core.js",
|
||||
"module": "./dist/esm/page-agent-core.js",
|
||||
"types": "./dist/esm/PageAgentCore.d.ts",
|
||||
"main": "./src/PageAgentCore.ts",
|
||||
"types": "./src/PageAgentCore.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/esm/PageAgentCore.d.ts",
|
||||
"import": "./dist/esm/page-agent-core.js",
|
||||
"default": "./dist/esm/page-agent-core.js"
|
||||
"types": "./src/PageAgentCore.ts",
|
||||
"default": "./src/PageAgentCore.ts"
|
||||
}
|
||||
},
|
||||
"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": [
|
||||
@@ -39,18 +49,18 @@
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"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))\"",
|
||||
"postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||
"prepublishOnly": "node ../../scripts/pre-publish.js",
|
||||
"postpublish": "node ../../scripts/post-publish.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"@page-agent/llms": "1.5.8",
|
||||
"@page-agent/page-controller": "1.5.8"
|
||||
"@page-agent/llms": "1.8.1",
|
||||
"@page-agent/page-controller": "1.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"zod": "^4.3.5"
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
system_prompt.md
|
||||
@@ -131,7 +131,8 @@ tools.set(
|
||||
tools.set(
|
||||
'scroll',
|
||||
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({
|
||||
down: z.boolean().default(true),
|
||||
num_pages: z.number().min(0).max(10).optional().default(0.1),
|
||||
@@ -155,7 +156,7 @@ tools.set(
|
||||
'scroll_horizontally',
|
||||
tool({
|
||||
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({
|
||||
right: z.boolean().default(true),
|
||||
pixels: z.number().int().min(0),
|
||||
|
||||
@@ -19,7 +19,7 @@ const log = console.log.bind(console, chalk.yellow('[autoFixer]'))
|
||||
* - etc.
|
||||
*/
|
||||
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]
|
||||
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
|
||||
if (!resolvedArguments.action) {
|
||||
log(`#5: fixing tool_call`)
|
||||
resolvedArguments.action = { name: 'wait', input: { seconds: 1 } }
|
||||
resolvedArguments.action = { wait: { seconds: 1 } }
|
||||
}
|
||||
|
||||
// pack back to standard format
|
||||
|
||||
@@ -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",
|
||||
"compilerOptions": {
|
||||
"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"]
|
||||
}
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"],
|
||||
"references": [
|
||||
//
|
||||
{ "path": "../llms" },
|
||||
{ "path": "../page-controller" }
|
||||
]
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
|
||||
@@ -11,13 +11,18 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
export default defineConfig({
|
||||
clearScreen: false,
|
||||
plugins: [
|
||||
dts({ tsconfigPath: './tsconfig.dts.json', bundleTypes: true }),
|
||||
dts({
|
||||
bundleTypes: true,
|
||||
compilerOptions: {
|
||||
composite: true,
|
||||
noEmit: false,
|
||||
emitDeclarationOnly: true,
|
||||
declaration: true,
|
||||
},
|
||||
}),
|
||||
cssInjectedByJsPlugin({ relativeCSSInjection: true }),
|
||||
],
|
||||
publicDir: false,
|
||||
esbuild: {
|
||||
keepNames: true,
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/PageAgentCore.ts'),
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
.wxt
|
||||
src/components/ui
|
||||
@@ -42,33 +42,28 @@ localStorage.setItem('PageAgentExtUserAuthToken', 'your-token')
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
AgentActivity,
|
||||
AgentStatus,
|
||||
ExecutionResult,
|
||||
HistoricalEvent,
|
||||
} from '@page-agent/core'
|
||||
import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core'
|
||||
|
||||
// Wait for extension injection (up to 1 second)
|
||||
async function waitForExtension(timeout = 1000): Promise<boolean> {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeout) {
|
||||
if (window.PAGE_AGENT_EXT) return true
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
}
|
||||
return false
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeout) {
|
||||
if (window.PAGE_AGENT_EXT) return true
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Usage
|
||||
if (await waitForExtension()) {
|
||||
const result = await window.PAGE_AGENT_EXT!.execute('Click the login button', {
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: 'your-api-key',
|
||||
model: 'gpt-5.2',
|
||||
onStatusChange: (status) => console.log('Status:', status),
|
||||
onActivity: (activity) => console.log('Activity:', activity),
|
||||
})
|
||||
console.log('Result:', result)
|
||||
const result = await window.PAGE_AGENT_EXT!.execute('Click the login button', {
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: 'your-api-key',
|
||||
model: 'gpt-5.2',
|
||||
onStatusChange: (status) => console.log('Status:', status),
|
||||
onActivity: (activity) => console.log('Activity:', activity),
|
||||
})
|
||||
console.log('Result:', result)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -90,10 +85,10 @@ Execute one agent task.
|
||||
|
||||
Parameters:
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
| ---- | ---- | -------- | ----------- |
|
||||
| `task` | `string` | Yes | Task description |
|
||||
| `config` | `ExecuteConfig` | Yes | LLM settings, options, and callbacks |
|
||||
| Name | Type | Required | Description |
|
||||
| -------- | --------------- | -------- | ------------------------------------ |
|
||||
| `task` | `string` | Yes | Task description |
|
||||
| `config` | `ExecuteConfig` | Yes | LLM settings, options, and callbacks |
|
||||
|
||||
Returns: `Promise<ExecutionResult>`
|
||||
|
||||
@@ -106,24 +101,28 @@ Stop the current task.
|
||||
Install `@page-agent/core` for complete types:
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
AgentActivity,
|
||||
AgentStatus,
|
||||
ExecutionResult,
|
||||
HistoricalEvent,
|
||||
} from '@page-agent/core'
|
||||
import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core'
|
||||
|
||||
export interface ExecuteConfig {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
baseURL: string
|
||||
model: string
|
||||
apiKey?: string
|
||||
|
||||
// Include the initial tab where page JS starts. Default: true.
|
||||
includeInitialTab?: boolean
|
||||
// Global system-level instructions for the agent.
|
||||
// Equivalent to AgentConfig.instructions.system.
|
||||
systemInstruction?: string
|
||||
|
||||
onStatusChange?: (status: AgentStatus) => void
|
||||
onActivity?: (activity: AgentActivity) => void
|
||||
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
||||
// Include the initial tab where page JS starts. Default: true.
|
||||
includeInitialTab?: boolean
|
||||
|
||||
// Control all unpinned tabs in the window instead of only the tab group.
|
||||
// When enabled, agent sees and can switch to every non-pinned tab.
|
||||
// Default: false. Experimental.
|
||||
experimentalIncludeAllTabs?: boolean
|
||||
|
||||
onStatusChange?: (status: AgentStatus) => void
|
||||
onActivity?: (activity: AgentActivity) => void
|
||||
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
||||
}
|
||||
|
||||
export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
|
||||
@@ -139,31 +138,31 @@ type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
|
||||
|
||||
```typescript
|
||||
type AgentActivity =
|
||||
| { type: 'thinking' }
|
||||
| { type: 'executing'; tool: string; input: unknown }
|
||||
| { type: 'executed'; tool: string; input: unknown; output: string; duration: number }
|
||||
| { type: 'retrying'; attempt: number; maxAttempts: number }
|
||||
| { type: 'error'; message: string }
|
||||
| { type: 'thinking' }
|
||||
| { type: 'executing'; tool: string; input: unknown }
|
||||
| { type: 'executed'; tool: string; input: unknown; output: string; duration: number }
|
||||
| { type: 'retrying'; attempt: number; maxAttempts: number }
|
||||
| { type: 'error'; message: string }
|
||||
```
|
||||
|
||||
`HistoricalEvent`
|
||||
|
||||
```typescript
|
||||
type HistoricalEvent =
|
||||
| { type: 'step'; stepIndex: number; reflection: AgentReflection; action: Action }
|
||||
| { type: 'observation'; content: string }
|
||||
| { type: 'user_takeover' }
|
||||
| { type: 'retry'; message: string; attempt: number; maxAttempts: number }
|
||||
| { type: 'error'; message: string; rawResponse?: unknown }
|
||||
| { type: 'step'; stepIndex: number; reflection: AgentReflection; action: Action }
|
||||
| { type: 'observation'; content: string }
|
||||
| { type: 'user_takeover' }
|
||||
| { type: 'retry'; message: string; attempt: number; maxAttempts: number }
|
||||
| { type: 'error'; message: string; rawResponse?: unknown }
|
||||
```
|
||||
|
||||
`ExecutionResult`
|
||||
|
||||
```typescript
|
||||
interface ExecutionResult {
|
||||
success: boolean
|
||||
data: string
|
||||
history: HistoricalEvent[]
|
||||
success: boolean
|
||||
data: string
|
||||
history: HistoricalEvent[]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -173,15 +172,15 @@ interface ExecutionResult {
|
||||
|
||||
```typescript
|
||||
const result = await window.PAGE_AGENT_EXT!.execute(
|
||||
'Fill in the email field with test@example.com and click Submit',
|
||||
{
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: process.env.OPENAI_API_KEY!,
|
||||
model: 'gpt-5.2',
|
||||
includeInitialTab: false, // Optional: exclude current tab
|
||||
onStatusChange: (status) => console.log(status),
|
||||
onActivity: (activity) => console.log(activity),
|
||||
}
|
||||
'Fill in the email field with test@example.com and click Submit',
|
||||
{
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: process.env.OPENAI_API_KEY!,
|
||||
model: 'gpt-5.2',
|
||||
includeInitialTab: false, // Optional: exclude current tab
|
||||
onStatusChange: (status) => console.log(status),
|
||||
onActivity: (activity) => console.log(activity),
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
@@ -196,31 +195,30 @@ window.PAGE_AGENT_EXT!.stop()
|
||||
If you are not importing `@page-agent/core`, add:
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
AgentActivity,
|
||||
AgentStatus,
|
||||
ExecutionResult,
|
||||
HistoricalEvent,
|
||||
} from '@page-agent/core'
|
||||
import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core'
|
||||
|
||||
interface ExecuteConfig {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
includeInitialTab?: boolean
|
||||
onStatusChange?: (status: AgentStatus) => void
|
||||
onActivity?: (activity: AgentActivity) => void
|
||||
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
||||
baseURL: string
|
||||
model: string
|
||||
apiKey?: string
|
||||
|
||||
systemInstruction?: string
|
||||
|
||||
includeInitialTab?: boolean
|
||||
experimentalIncludeAllTabs?: boolean
|
||||
onStatusChange?: (status: AgentStatus) => void
|
||||
onActivity?: (activity: AgentActivity) => void
|
||||
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
PAGE_AGENT_EXT_VERSION?: string
|
||||
PAGE_AGENT_EXT?: {
|
||||
version: string
|
||||
execute: Execute
|
||||
stop: () => void
|
||||
interface Window {
|
||||
PAGE_AGENT_EXT_VERSION?: string
|
||||
PAGE_AGENT_EXT?: {
|
||||
version: string
|
||||
execute: Execute
|
||||
stop: () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@page-agent/ext",
|
||||
"private": true,
|
||||
"version": "1.5.8",
|
||||
"version": "1.8.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wxt",
|
||||
@@ -16,32 +16,32 @@
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@types/chrome": "^0.1.37",
|
||||
"@tailwindcss/vite": "^4.2.3",
|
||||
"@types/chrome": "^0.1.40",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"@wxt-dev/module-react": "^1.2.1",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@wxt-dev/module-react": "^1.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-react": "^0.577.0",
|
||||
"motion": "^12.35.2",
|
||||
"lucide-react": "^1.8.0",
|
||||
"motion": "^12.38.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"rough-notation": "^0.5.1",
|
||||
"simple-icons": "^16.11.0",
|
||||
"simple-icons": "^16.16.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"wxt": "^0.20.18"
|
||||
"wxt": "^0.20.25"
|
||||
},
|
||||
"dependencies": {
|
||||
"@page-agent/core": "1.5.8",
|
||||
"@page-agent/llms": "1.5.8",
|
||||
"@page-agent/page-controller": "1.5.8",
|
||||
"@page-agent/ui": "1.5.8",
|
||||
"@page-agent/core": "1.8.1",
|
||||
"@page-agent/llms": "1.8.1",
|
||||
"@page-agent/page-controller": "1.8.1",
|
||||
"@page-agent/ui": "1.8.1",
|
||||
"ai-motion": "^0.4.8",
|
||||
"chalk": "^5.6.2"
|
||||
},
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Page Agent Ext"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "AI-powered browser automation assistant. Control web pages with natural language."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Open Page Agent"
|
||||
}
|
||||
"extName": {
|
||||
"message": "Page Agent Ext"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "AI-powered browser automation assistant. Control web pages with natural language."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Open Page Agent"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Page Agent Ext"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "AI 驱动的浏览器自动化助手,用自然语言控制网页。"
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "打开 Page Agent"
|
||||
}
|
||||
"extName": {
|
||||
"message": "Page Agent Ext"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "AI 驱动的浏览器自动化助手,用自然语言控制网页。"
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "打开 Page Agent"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
system_prompt.md
|
||||
@@ -11,13 +11,18 @@ function detectLanguage(): 'en-US' | 'zh-CN' {
|
||||
return lang.startsWith('zh') ? 'zh-CN' : 'en-US'
|
||||
}
|
||||
|
||||
interface MultiPageAgentConfig extends AgentConfig {
|
||||
includeInitialTab?: boolean
|
||||
experimentalIncludeAllTabs?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* MultiPageAgent
|
||||
* - use with extension
|
||||
* - can be used from a side panel or a content script
|
||||
*/
|
||||
export class MultiPageAgent extends PageAgentCore {
|
||||
constructor(config: AgentConfig & { includeInitialTab?: boolean }) {
|
||||
constructor(config: MultiPageAgentConfig) {
|
||||
// multi page controller
|
||||
const tabsController = new TabsController()
|
||||
const pageController = new RemotePageController(tabsController)
|
||||
@@ -31,8 +36,8 @@ export class MultiPageAgent extends PageAgentCore {
|
||||
`Default working language: **${targetLanguage}**`
|
||||
)
|
||||
|
||||
// include initial tab for controlling
|
||||
const includeInitialTab = config.includeInitialTab ?? true
|
||||
const experimentalIncludeAllTabs = config.experimentalIncludeAllTabs ?? false
|
||||
|
||||
/**
|
||||
* When the agent is in side-panel and user closed the side-panel.
|
||||
@@ -50,7 +55,7 @@ export class MultiPageAgent extends PageAgentCore {
|
||||
customSystemPrompt: systemPrompt,
|
||||
|
||||
onBeforeTask: async (agent) => {
|
||||
await tabsController.init(agent.task, includeInitialTab)
|
||||
await tabsController.init(agent.task, { includeInitialTab, experimentalIncludeAllTabs })
|
||||
|
||||
heartBeatInterval = window.setInterval(() => {
|
||||
chrome.storage.local.set({
|
||||
@@ -75,6 +80,7 @@ export class MultiPageAgent extends PageAgentCore {
|
||||
},
|
||||
|
||||
onBeforeStep: async (agent) => {
|
||||
if (!tabsController.currentTabId) return
|
||||
// make sure the current tab is loaded before the step starts
|
||||
await tabsController.waitUntilTabLoaded(tabsController.currentTabId!)
|
||||
},
|
||||
|
||||
@@ -10,9 +10,7 @@ export function handlePageControlMessage(
|
||||
): true | undefined {
|
||||
const PREFIX = '[RemotePageController.background]'
|
||||
|
||||
function debug(...messages: any[]) {
|
||||
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
|
||||
}
|
||||
const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`)
|
||||
|
||||
const { action, payload, targetTabId } = message
|
||||
|
||||
|
||||
@@ -19,7 +19,10 @@ export function initPageController() {
|
||||
|
||||
function getPC(): PageController {
|
||||
if (!pageController) {
|
||||
pageController = new PageController({ enableMask: false, viewportExpansion: 400 })
|
||||
pageController = new PageController({
|
||||
enableMask: false,
|
||||
viewportExpansion: 400,
|
||||
})
|
||||
}
|
||||
return pageController
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@ import type { TabsController } from './TabsController'
|
||||
|
||||
const PREFIX = '[RemotePageController]'
|
||||
|
||||
function debug(...messages: any[]) {
|
||||
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
|
||||
}
|
||||
const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`)
|
||||
|
||||
function sendMessage(message: {
|
||||
type: 'PAGE_CONTROL'
|
||||
@@ -58,9 +56,7 @@ export class RemotePageController {
|
||||
}
|
||||
|
||||
async getBrowserState(): Promise<BrowserState> {
|
||||
if (!this.currentTabId) throw new Error('tabsController not initialized.')
|
||||
|
||||
let browserState = {} as BrowserState
|
||||
let browserState: BrowserState
|
||||
debug('getBrowserState', this.currentTabId)
|
||||
|
||||
const currentUrl = await this.getCurrentUrl()
|
||||
@@ -178,7 +174,7 @@ interface DomActionReturn {
|
||||
/**
|
||||
* Check if a URL can run content scripts.
|
||||
*/
|
||||
function isContentScriptAllowed(url: string | undefined): boolean {
|
||||
export function isContentScriptAllowed(url: string | undefined): boolean {
|
||||
if (!url) return false
|
||||
|
||||
const restrictedPatterns = [
|
||||
|
||||
@@ -5,9 +5,7 @@ import type { TabAction } from './TabsController'
|
||||
|
||||
const PREFIX = '[TabsController.background]'
|
||||
|
||||
function debug(...messages: any[]) {
|
||||
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
|
||||
}
|
||||
const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`)
|
||||
|
||||
export function handleTabControlMessage(
|
||||
message: { type: 'TAB_CONTROL'; action: TabAction; payload: any },
|
||||
@@ -20,11 +18,10 @@ export function handleTabControlMessage(
|
||||
case 'get_active_tab': {
|
||||
debug('get_active_tab')
|
||||
chrome.tabs
|
||||
.query({ active: true, currentWindow: true })
|
||||
.query({ active: true })
|
||||
.then((tabs) => {
|
||||
const tabId = tabs.length > 0 ? tabs[0].id || null : null
|
||||
debug('get_active_tab: success', tabId)
|
||||
sendResponse({ success: true, tabId })
|
||||
debug('get_active_tab: success', tabs)
|
||||
sendResponse({ success: true, tab: tabs[0] })
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||
@@ -63,7 +60,7 @@ export function handleTabControlMessage(
|
||||
case 'create_tab_group': {
|
||||
debug('create_tab_group', payload)
|
||||
chrome.tabs
|
||||
.group({ tabIds: payload.tabIds })
|
||||
.group({ tabIds: payload.tabIds, createProperties: { windowId: payload.windowId } })
|
||||
.then((groupId) => {
|
||||
debug('create_tab_group: success', groupId)
|
||||
sendResponse({ success: true, groupId })
|
||||
@@ -114,47 +111,59 @@ export function handleTabControlMessage(
|
||||
return true // async response
|
||||
}
|
||||
|
||||
case 'get_window_tabs': {
|
||||
debug('get_window_tabs', payload)
|
||||
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:
|
||||
sendResponse({ error: `Unknown action: ${action}` })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export function setupTabChangeEvents() {
|
||||
console.log('[TabsController.background] setupTabChangeEvents')
|
||||
const tabEventPorts = new Set<chrome.runtime.Port>()
|
||||
|
||||
function broadcastTabEvent(message: object) {
|
||||
for (const port of tabEventPorts) {
|
||||
port.postMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Port-based tab events: agents connect via `chrome.runtime.connect({ name: 'tab-events' })`
|
||||
* and receive tab change events through the port. Works for both extension pages and content scripts.
|
||||
*/
|
||||
export function setupTabEventsPort() {
|
||||
chrome.runtime.onConnect.addListener((port) => {
|
||||
if (port.name !== 'tab-events') return
|
||||
|
||||
debug('port connected', port.sender?.tab?.id ?? port.sender?.url)
|
||||
tabEventPorts.add(port)
|
||||
|
||||
port.onDisconnect.addListener(() => {
|
||||
debug('port disconnected')
|
||||
tabEventPorts.delete(port)
|
||||
})
|
||||
})
|
||||
|
||||
chrome.tabs.onCreated.addListener((tab) => {
|
||||
debug('onCreated', tab)
|
||||
chrome.runtime
|
||||
.sendMessage({ type: 'TAB_CHANGE', action: 'created', payload: { tab } })
|
||||
.catch((error) => {
|
||||
debug('onCreated error:', error)
|
||||
})
|
||||
broadcastTabEvent({ action: 'created', payload: { tab } })
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
broadcastTabEvent({ action: 'removed', payload: { tabId, removeInfo } })
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
broadcastTabEvent({ action: 'updated', payload: { tabId, changeInfo, tab } })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { isContentScriptAllowed } from './RemotePageController'
|
||||
|
||||
const PREFIX = '[TabsController]'
|
||||
|
||||
function debug(...messages: any[]) {
|
||||
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
|
||||
}
|
||||
const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`)
|
||||
|
||||
function sendMessage(message: {
|
||||
type: 'TAB_CONTROL'
|
||||
@@ -20,101 +20,102 @@ function sendMessage(message: {
|
||||
* - live in the agent env (extension page or content script)
|
||||
* - no chrome apis. call sw for tab operations
|
||||
*/
|
||||
export class TabsController extends EventTarget {
|
||||
export class TabsController {
|
||||
currentTabId: number | null = null
|
||||
|
||||
private disposed = false
|
||||
private port?: chrome.runtime.Port
|
||||
private portRetries = 0
|
||||
|
||||
private windowId: number | null = null
|
||||
private tabs: TabMeta[] = []
|
||||
private initialTabId: number | null = null
|
||||
private tabGroupId: number | null = null
|
||||
private experimentalIncludeAllTabs = false
|
||||
private task: string = ''
|
||||
|
||||
async init(task: string, includeInitialTab: boolean = true) {
|
||||
debug('init', task, includeInitialTab)
|
||||
async init(task: string, options: TabsInitOptions = {}) {
|
||||
const { includeInitialTab = true, experimentalIncludeAllTabs = false } = options
|
||||
debug('init', task, options)
|
||||
|
||||
if (this.disposed) {
|
||||
throw new Error('TabsController already disposed')
|
||||
}
|
||||
|
||||
this.task = task
|
||||
this.tabs = []
|
||||
this.currentTabId = null
|
||||
this.disposed = false
|
||||
this.port = undefined
|
||||
this.portRetries = 0
|
||||
|
||||
this.windowId = null
|
||||
this.tabs = []
|
||||
this.tabGroupId = null
|
||||
this.initialTabId = null
|
||||
this.experimentalIncludeAllTabs = experimentalIncludeAllTabs
|
||||
this.task = task
|
||||
|
||||
const result = await sendMessage({
|
||||
const activeTabResult = await sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'get_active_tab',
|
||||
})
|
||||
|
||||
this.initialTabId = result.tabId
|
||||
this.initialTabId = activeTabResult.tab?.id
|
||||
this.windowId = activeTabResult.tab?.windowId
|
||||
|
||||
if (!this.initialTabId) {
|
||||
throw new Error('Failed to get initial tab ID')
|
||||
if (!this.initialTabId || !this.windowId) {
|
||||
if (activeTabResult.error) {
|
||||
throw new Error(activeTabResult.error)
|
||||
} else {
|
||||
throw new Error('Failed to get active tab')
|
||||
}
|
||||
}
|
||||
|
||||
if (includeInitialTab) {
|
||||
this.currentTabId = this.initialTabId
|
||||
this.connectTabEvents()
|
||||
|
||||
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({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'get_tab_info',
|
||||
payload: { tabId: this.initialTabId },
|
||||
})
|
||||
|
||||
this.tabs.push({
|
||||
id: result.tabId,
|
||||
isInitial: true,
|
||||
url: info.url,
|
||||
title: info.title,
|
||||
status: info.status,
|
||||
})
|
||||
if (isContentScriptAllowed(info.url) && !info.pinned) {
|
||||
this.currentTabId = this.initialTabId
|
||||
|
||||
await this.createTabGroup([this.initialTabId])
|
||||
this.addTab({
|
||||
id: this.initialTabId,
|
||||
isInitial: true,
|
||||
url: info.url,
|
||||
title: info.title,
|
||||
status: info.status,
|
||||
})
|
||||
|
||||
await this.createTabGroup([this.initialTabId])
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -132,7 +133,7 @@ export class TabsController extends EventTarget {
|
||||
|
||||
const tabId = result.tabId as number
|
||||
|
||||
this.tabs.push({
|
||||
this.addTab({
|
||||
id: tabId,
|
||||
isInitial: false,
|
||||
})
|
||||
@@ -205,7 +206,7 @@ export class TabsController extends EventTarget {
|
||||
const result = await sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'create_tab_group',
|
||||
payload: { tabIds },
|
||||
payload: { tabIds, windowId: this.windowId },
|
||||
})
|
||||
|
||||
if (!result?.success) {
|
||||
@@ -228,6 +229,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) {
|
||||
debug('updateCurrentTabId', tabId)
|
||||
|
||||
@@ -284,9 +290,77 @@ export class TabsController extends EventTarget {
|
||||
await waitUntil(() => tab.status === 'complete', 4_000)
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.dispatchEvent(new Event('dispose'))
|
||||
/**
|
||||
* Connect to background SW via port to receive tab change events.
|
||||
*
|
||||
* @note Port is 1:1 (runtime.connect → background SW has no frames),
|
||||
* so onDisconnect fires exactly once and we can safely reconnect.
|
||||
* Reconnection may miss events during the gap.
|
||||
* TODO: refresh this.tabs from background after reconnect to stay consistent.
|
||||
*/
|
||||
private connectTabEvents() {
|
||||
this.port = chrome.runtime.connect({ name: 'tab-events' })
|
||||
|
||||
this.port.onMessage.addListener((message: any) => {
|
||||
if (this.disposed) return
|
||||
this.portRetries = 0
|
||||
|
||||
if (message.action === 'created') {
|
||||
const tab = message.payload.tab as chrome.tabs.Tab
|
||||
const shouldTrack = this.experimentalIncludeAllTabs || tab.groupId === this.tabGroupId
|
||||
if (shouldTrack && tab.id != null) {
|
||||
this.addTab({ id: tab.id, isInitial: false })
|
||||
this.switchToTab(tab.id)
|
||||
}
|
||||
} else if (message.action === 'removed') {
|
||||
const { tabId } = message.payload as { tabId: number }
|
||||
const targetTab = this.tabs.find((t) => t.id === tabId)
|
||||
if (targetTab) {
|
||||
this.tabs = this.tabs.filter((t) => t.id !== tabId)
|
||||
if (this.currentTabId === tabId) {
|
||||
const newCurrentTab = this.tabs[this.tabs.length - 1] || null
|
||||
if (newCurrentTab) {
|
||||
this.switchToTab(newCurrentTab.id)
|
||||
} else {
|
||||
this.updateCurrentTabId(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (message.action === 'updated') {
|
||||
const { tabId, tab } = message.payload as { tabId: number; tab: chrome.tabs.Tab }
|
||||
const targetTab = this.tabs.find((t) => t.id === tabId)
|
||||
if (targetTab) {
|
||||
targetTab.url = tab.url
|
||||
targetTab.title = tab.title
|
||||
targetTab.status = tab.status
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.port.onDisconnect.addListener(() => {
|
||||
this.port = undefined
|
||||
if (this.disposed) return
|
||||
if (this.portRetries >= 7) {
|
||||
console.error(PREFIX, 'tab events port failed after 7 retries, giving up')
|
||||
return
|
||||
}
|
||||
debug('port disconnected, reconnecting...')
|
||||
this.portRetries++
|
||||
this.connectTabEvents()
|
||||
})
|
||||
}
|
||||
|
||||
dispose() {
|
||||
debug('dispose')
|
||||
this.disposed = true
|
||||
this.port?.disconnect()
|
||||
this.port = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export interface TabsInitOptions {
|
||||
includeInitialTab?: boolean
|
||||
experimentalIncludeAllTabs?: boolean
|
||||
}
|
||||
|
||||
export type TabAction =
|
||||
@@ -298,6 +372,7 @@ export type TabAction =
|
||||
| 'add_tab_to_group'
|
||||
| 'close_tab'
|
||||
| 'get_tab_title'
|
||||
| 'get_window_tabs'
|
||||
|
||||
interface TabMeta {
|
||||
id: number
|
||||
|
||||
@@ -3,12 +3,12 @@ import type { LLMConfig } from '@page-agent/llms'
|
||||
// Demo LLM for testing
|
||||
export const DEMO_MODEL = 'qwen3.5-plus'
|
||||
export const DEMO_BASE_URL = 'https://page-ag-testing-ohftxirgbn.cn-shanghai.fcapp.run'
|
||||
export const DEMO_API_KEY = 'NA'
|
||||
// export const DEMO_API_KEY = 'NA'
|
||||
|
||||
export const DEMO_CONFIG: LLMConfig = {
|
||||
apiKey: DEMO_API_KEY,
|
||||
baseURL: DEMO_BASE_URL,
|
||||
model: DEMO_MODEL,
|
||||
// apiKey: DEMO_API_KEY,
|
||||
}
|
||||
|
||||
/** Legacy testing endpoints that should be auto-migrated to DEMO_BASE_URL */
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import type {
|
||||
AgentActivity,
|
||||
AgentStatus,
|
||||
ExecutionResult,
|
||||
HistoricalEvent,
|
||||
SupportedLanguage,
|
||||
} from '@page-agent/core'
|
||||
@@ -20,6 +21,8 @@ export interface AdvancedConfig {
|
||||
maxSteps?: number
|
||||
systemInstruction?: string
|
||||
experimentalLlmsTxt?: boolean
|
||||
experimentalIncludeAllTabs?: boolean
|
||||
disableNamedToolChoice?: boolean
|
||||
}
|
||||
|
||||
export interface ExtConfig extends LLMConfig, AdvancedConfig {
|
||||
@@ -32,7 +35,7 @@ export interface UseAgentResult {
|
||||
activity: AgentActivity | null
|
||||
currentTask: string
|
||||
config: ExtConfig | null
|
||||
execute: (task: string) => Promise<void>
|
||||
execute: (task: string) => Promise<ExecutionResult>
|
||||
stop: () => void
|
||||
configure: (config: ExtConfig) => Promise<void>
|
||||
}
|
||||
@@ -105,12 +108,11 @@ export function useAgent(): UseAgentResult {
|
||||
|
||||
const execute = useCallback(async (task: string) => {
|
||||
const agent = agentRef.current
|
||||
console.log('🚀 [useAgent] start executing task:', task)
|
||||
if (!agent) throw new Error('Agent not initialized')
|
||||
|
||||
setCurrentTask(task)
|
||||
setHistory([])
|
||||
await agent.execute(task)
|
||||
return agent.execute(task)
|
||||
}, [])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
@@ -123,6 +125,8 @@ export function useAgent(): UseAgentResult {
|
||||
maxSteps,
|
||||
systemInstruction,
|
||||
experimentalLlmsTxt,
|
||||
experimentalIncludeAllTabs,
|
||||
disableNamedToolChoice,
|
||||
...llmConfig
|
||||
}: ExtConfig) => {
|
||||
await chrome.storage.local.set({ llmConfig })
|
||||
@@ -131,7 +135,13 @@ export function useAgent(): UseAgentResult {
|
||||
} else {
|
||||
await chrome.storage.local.remove('language')
|
||||
}
|
||||
const advancedConfig: AdvancedConfig = { maxSteps, systemInstruction, experimentalLlmsTxt }
|
||||
const advancedConfig: AdvancedConfig = {
|
||||
maxSteps,
|
||||
systemInstruction,
|
||||
experimentalLlmsTxt,
|
||||
experimentalIncludeAllTabs,
|
||||
disableNamedToolChoice,
|
||||
}
|
||||
await chrome.storage.local.set({ advancedConfig })
|
||||
setConfig({ ...llmConfig, ...advancedConfig, language })
|
||||
},
|
||||
|
||||
+79
-22
@@ -1,18 +1,20 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
Copy,
|
||||
CornerUpLeft,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FoldVertical,
|
||||
HatGlasses,
|
||||
Home,
|
||||
Loader2,
|
||||
Scale,
|
||||
UnfoldVertical,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { siGithub } from 'simple-icons'
|
||||
|
||||
import { DEMO_API_KEY, DEMO_BASE_URL, DEMO_MODEL, isTestingEndpoint } from '@/agent/constants'
|
||||
import { DEMO_BASE_URL, DEMO_MODEL, isTestingEndpoint } from '@/agent/constants'
|
||||
import type { ExtConfig, LanguagePreference } from '@/agent/useAgent'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -25,31 +27,41 @@ interface ConfigPanelProps {
|
||||
}
|
||||
|
||||
export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
const [apiKey, setApiKey] = useState(config?.apiKey || DEMO_API_KEY)
|
||||
const [baseURL, setBaseURL] = useState(config?.baseURL || DEMO_BASE_URL)
|
||||
const [model, setModel] = useState(config?.model || DEMO_MODEL)
|
||||
const [apiKey, setApiKey] = useState(config?.apiKey)
|
||||
const [language, setLanguage] = useState<LanguagePreference>(config?.language)
|
||||
const [maxSteps, setMaxSteps] = useState<number | undefined>(config?.maxSteps)
|
||||
const [maxSteps, setMaxSteps] = useState(config?.maxSteps)
|
||||
const [systemInstruction, setSystemInstruction] = useState(config?.systemInstruction ?? '')
|
||||
const [experimentalLlmsTxt, setExperimentalLlmsTxt] = useState(
|
||||
config?.experimentalLlmsTxt ?? false
|
||||
)
|
||||
const [experimentalIncludeAllTabs, setExperimentalIncludeAllTabs] = useState(
|
||||
config?.experimentalIncludeAllTabs ?? false
|
||||
)
|
||||
const [disableNamedToolChoice, setDisableNamedToolChoice] = useState(
|
||||
config?.disableNamedToolChoice ?? false
|
||||
)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [userAuthToken, setUserAuthToken] = useState<string>('')
|
||||
const [userAuthToken, setUserAuthToken] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [showToken, setShowToken] = useState(false)
|
||||
const [showApiKey, setShowApiKey] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setApiKey(config?.apiKey || DEMO_API_KEY)
|
||||
const [prevConfig, setPrevConfig] = useState(config)
|
||||
if (prevConfig !== config) {
|
||||
setPrevConfig(config)
|
||||
setBaseURL(config?.baseURL || DEMO_BASE_URL)
|
||||
setModel(config?.model || DEMO_MODEL)
|
||||
setApiKey(config?.apiKey)
|
||||
setLanguage(config?.language)
|
||||
setMaxSteps(config?.maxSteps)
|
||||
setSystemInstruction(config?.systemInstruction ?? '')
|
||||
setExperimentalLlmsTxt(config?.experimentalLlmsTxt ?? false)
|
||||
}, [config])
|
||||
setExperimentalIncludeAllTabs(config?.experimentalIncludeAllTabs ?? false)
|
||||
setDisableNamedToolChoice(config?.disableNamedToolChoice ?? false)
|
||||
}
|
||||
|
||||
// Poll for user auth token every second until found
|
||||
useEffect(() => {
|
||||
@@ -94,6 +106,8 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
maxSteps: maxSteps || undefined,
|
||||
systemInstruction: systemInstruction || undefined,
|
||||
experimentalLlmsTxt,
|
||||
experimentalIncludeAllTabs,
|
||||
disableNamedToolChoice,
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
@@ -109,6 +123,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
size="icon-sm"
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-3 cursor-pointer"
|
||||
aria-label="Back"
|
||||
>
|
||||
<CornerUpLeft className="size-3.5" />
|
||||
</Button>
|
||||
@@ -116,12 +131,15 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
|
||||
{/* User Auth Token Section */}
|
||||
<div className="flex flex-col gap-1.5 p-3 bg-muted/50 rounded-md border">
|
||||
<label className="text-xs font-medium text-muted-foreground">User Auth Token</label>
|
||||
<label htmlFor="user-auth-token" className="text-xs font-medium text-muted-foreground">
|
||||
User Auth Token
|
||||
</label>
|
||||
<p className="text-[10px] text-muted-foreground mb-1">
|
||||
Give a website the ability to call this extension.
|
||||
</p>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
id="user-auth-token"
|
||||
readOnly
|
||||
value={
|
||||
userAuthToken
|
||||
@@ -138,6 +156,8 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
className="h-8 w-8 shrink-0 cursor-pointer"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
disabled={!userAuthToken}
|
||||
aria-label={showToken ? 'Hide token' : 'Show token'}
|
||||
aria-pressed={showToken}
|
||||
>
|
||||
{showToken ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
|
||||
</Button>
|
||||
@@ -147,15 +167,33 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
className="h-8 w-8 shrink-0 cursor-pointer"
|
||||
onClick={handleCopyToken}
|
||||
disabled={!userAuthToken}
|
||||
aria-label="Copy token"
|
||||
>
|
||||
{copied ? <span className="">✓</span> : <Copy className="size-3" />}
|
||||
</Button>
|
||||
<span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
|
||||
{copied ? 'Token copied' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hub link */}
|
||||
<a
|
||||
href="/hub.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between p-3 rounded-md border bg-muted/50 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
|
||||
>
|
||||
Manage Page Agent Hub
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">Base URL</label>
|
||||
<label htmlFor="base-url" className="text-xs text-muted-foreground">
|
||||
Base URL
|
||||
</label>
|
||||
<Input
|
||||
id="base-url"
|
||||
placeholder="https://api.openai.com/v1"
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
@@ -167,7 +205,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
{isTestingEndpoint(baseURL) && (
|
||||
<div className="p-2.5 rounded-md border border-amber-500/30 bg-amber-500/5 text-[11px] text-muted-foreground leading-relaxed">
|
||||
<Scale className="size-3 inline-block mr-1 -mt-0.5 text-amber-600" />
|
||||
You are using the free testing API. By using this service you agree to the{' '}
|
||||
You are using our testing API. By using this you agree to the{' '}
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md"
|
||||
target="_blank"
|
||||
@@ -176,14 +214,16 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
>
|
||||
Terms of Use & Privacy Policy
|
||||
</a>
|
||||
. No sensitive data. No guaranteed availability.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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
|
||||
placeholder="gpt-5.2"
|
||||
id="model"
|
||||
placeholder="gpt-5.1"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="text-xs h-8"
|
||||
@@ -191,11 +231,14 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">API Key</label>
|
||||
<label htmlFor="api-key" className="text-xs text-muted-foreground">
|
||||
API Key
|
||||
</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
id="api-key"
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
placeholder="sk-..."
|
||||
// placeholder="sk-..."
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="text-xs h-8"
|
||||
@@ -205,6 +248,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 cursor-pointer"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
|
||||
>
|
||||
{showApiKey ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
|
||||
</Button>
|
||||
@@ -212,7 +256,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">Language</label>
|
||||
<label className="text-xs text-muted-foreground">Response Language</label>
|
||||
<select
|
||||
value={language ?? ''}
|
||||
onChange={(e) => setLanguage((e.target.value || undefined) as LanguagePreference)}
|
||||
@@ -231,17 +275,17 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground cursor-pointer mt-1 font-bold"
|
||||
>
|
||||
Advanced
|
||||
<ChevronDown
|
||||
className="size-3 transition-transform"
|
||||
style={{ transform: advancedOpen ? 'rotate(0deg)' : 'rotate(90deg)' }}
|
||||
/>
|
||||
{advancedOpen ? <FoldVertical className="size-3" /> : <UnfoldVertical className="size-3" />}
|
||||
</button>
|
||||
|
||||
{advancedOpen && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">Max Steps</label>
|
||||
<label htmlFor="max-steps" className="text-xs text-muted-foreground">
|
||||
Max Steps
|
||||
</label>
|
||||
<Input
|
||||
id="max-steps"
|
||||
type="number"
|
||||
placeholder="40"
|
||||
min={1}
|
||||
@@ -263,10 +307,23 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-xs text-muted-foreground">Disable named tool_choice</span>
|
||||
<Switch checked={disableNamedToolChoice} onCheckedChange={setDisableNamedToolChoice} />
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-xs text-muted-foreground">Experimental llms.txt support</span>
|
||||
<Switch checked={experimentalLlmsTxt} onCheckedChange={setExperimentalLlmsTxt} />
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
+32
-4
@@ -1,12 +1,20 @@
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { ArrowLeft, RotateCcw, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { type SessionRecord, getSession } from '@/lib/db'
|
||||
import { type SessionRecord, deleteSession, getSession } from '@/lib/db'
|
||||
|
||||
import { EventCard } from './cards'
|
||||
|
||||
export function HistoryDetail({ sessionId, onBack }: { sessionId: string; onBack: () => void }) {
|
||||
export function HistoryDetail({
|
||||
sessionId,
|
||||
onBack,
|
||||
onRerun,
|
||||
}: {
|
||||
sessionId: string
|
||||
onBack: () => void
|
||||
onRerun: (task: string) => void
|
||||
}) {
|
||||
const [session, setSession] = useState<SessionRecord | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,12 +45,32 @@ export function HistoryDetail({ sessionId, onBack }: { sessionId: string; onBack
|
||||
<div className="text-xs font-medium" title={session.task}>
|
||||
{session.task}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRerun(session.task)}
|
||||
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
Run again
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await deleteSession(sessionId)
|
||||
onBack()
|
||||
}}
|
||||
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-destructive transition-colors cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events (read-only) */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{session.history.map((event, index) => (
|
||||
// eslint-disable-next-line react-x/no-array-index-key
|
||||
<EventCard key={index} event={event} />
|
||||
))}
|
||||
</div>
|
||||
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
History,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { type SessionRecord, clearSessions, deleteSession, listSessions } from '@/lib/db'
|
||||
import { downloadHistoryExport } from '@/lib/history-export'
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const seconds = Math.floor((Date.now() - ts) / 1000)
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
export function HistoryList({
|
||||
onSelect,
|
||||
onBack,
|
||||
onRerun,
|
||||
}: {
|
||||
onSelect: (id: string) => void
|
||||
onBack: () => void
|
||||
onRerun: (task: string) => void
|
||||
}) {
|
||||
const [sessions, setSessions] = useState<SessionRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setSessions(await listSessions())
|
||||
} catch (err) {
|
||||
console.error('[HistoryList] Failed to load sessions:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation()
|
||||
await deleteSession(id)
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id))
|
||||
}
|
||||
|
||||
const handleExport = (e: React.MouseEvent, session: SessionRecord) => {
|
||||
e.stopPropagation()
|
||||
downloadHistoryExport(session.task, session.createdAt, session.history)
|
||||
}
|
||||
|
||||
const handleRerun = (e: React.MouseEvent, task: string) => {
|
||||
e.stopPropagation()
|
||||
onRerun(task)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onBack}
|
||||
className="cursor-pointer"
|
||||
aria-label="Back"
|
||||
title="Back"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
</Button>
|
||||
<span className="text-sm font-medium flex-1">History</span>
|
||||
{sessions.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await clearSessions()
|
||||
setSessions([])
|
||||
}}
|
||||
className="text-[10px] text-muted-foreground hover:text-destructive cursor-pointer h-6 px-2"
|
||||
>
|
||||
<Trash2 className="size-3 mr-1" />
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && (
|
||||
<div className="flex flex-col" aria-label="Loading history" aria-busy="true">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-2 px-3 py-2.5 border-b">
|
||||
<div className="size-3.5 mt-0.5 rounded-full bg-muted animate-pulse shrink-0" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="h-2.5 bg-muted animate-pulse rounded w-3/4" />
|
||||
<div className="h-2 bg-muted animate-pulse rounded w-1/3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && sessions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-40 gap-2 text-muted-foreground">
|
||||
<History className="size-8 opacity-30" />
|
||||
<p className="text-xs">No history yet</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(session.id)}
|
||||
className="w-full text-left px-3 py-2.5 border-b hover:bg-muted/50 transition-colors cursor-pointer flex items-start gap-2 group"
|
||||
>
|
||||
{/* Status icon */}
|
||||
{session.status === 'completed' ? (
|
||||
<CheckCircle className="size-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<XCircle className="size-3.5 text-destructive shrink-0 mt-0.5" />
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate">{session.task}</p>
|
||||
<div className="flex items-center mt-0.5">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{timeAgo(session.createdAt)} · {session.history.length} steps
|
||||
</p>
|
||||
<div className="flex items-center gap-0.5 ml-auto opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleRerun(e, session.task)}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
title="Run task again"
|
||||
aria-label={`Run history task again: ${session.task}`}
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleExport(e, session)}
|
||||
className="p-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
title="Export history JSON"
|
||||
aria-label={`Export history for ${session.task}`}
|
||||
>
|
||||
<ArrowDownToLine className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleDelete(e, session.id)}
|
||||
className="p-0.5 text-muted-foreground hover:text-destructive transition-colors cursor-pointer"
|
||||
title="Delete history"
|
||||
aria-label={`Delete history for ${session.task}`}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+2
-1
@@ -54,7 +54,7 @@ function ResultCard({
|
||||
Result: {success ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
</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}
|
||||
</div>
|
||||
)
|
||||
@@ -146,6 +146,7 @@ function CopyButton({ text, label }: { text: string; label: string }) {
|
||||
function extractPrompt(rawRequest: unknown, role: 'system' | 'user'): string | null {
|
||||
const messages = (rawRequest as { messages?: { role: string; content?: unknown }[] })?.messages
|
||||
if (!messages) return null
|
||||
if (!Array.isArray(messages)) return null
|
||||
const msg =
|
||||
role === 'system'
|
||||
? messages.find((m) => m.role === role)
|
||||
+2
-1
@@ -84,7 +84,7 @@ export function MotionOverlay({ active }: { active: boolean }) {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="pointer-events-none absolute inset-0 z-10 opacity-60"
|
||||
className="pointer-events-none absolute inset-0 z-10 opacity-60 overflow-hidden"
|
||||
style={{ display: active ? undefined : 'none' }}
|
||||
/>
|
||||
)
|
||||
@@ -111,6 +111,7 @@ export function EmptyState() {
|
||||
]}
|
||||
cursorStyle="underscore"
|
||||
loop
|
||||
startOnView={false}
|
||||
typeSpeed={20}
|
||||
deleteSpeed={10}
|
||||
pauseDelay={3000}
|
||||
@@ -8,7 +8,7 @@ function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimi
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { handlePageControlMessage } from '@/agent/RemotePageController.background'
|
||||
import { handleTabControlMessage, setupTabChangeEvents } from '@/agent/TabsController.background'
|
||||
import { handleTabControlMessage, setupTabEventsPort } from '@/agent/TabsController.background'
|
||||
|
||||
export default defineBackground(() => {
|
||||
console.log('[Background] Service Worker started')
|
||||
|
||||
// tab change events
|
||||
|
||||
setupTabChangeEvents()
|
||||
setupTabEventsPort()
|
||||
|
||||
// generate user auth token
|
||||
|
||||
@@ -30,7 +30,34 @@ export default defineBackground(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// external messages (from localhost launcher page via externally_connectable)
|
||||
|
||||
chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'OPEN_HUB') {
|
||||
openOrFocusHubTab(message.wsPort).then(() => {
|
||||
if (sender.tab?.id) chrome.tabs.remove(sender.tab.id)
|
||||
sendResponse({ ok: true })
|
||||
})
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
// setup
|
||||
|
||||
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
|
||||
})
|
||||
|
||||
async function openOrFocusHubTab(wsPort: number) {
|
||||
const hubUrl = chrome.runtime.getURL('hub.html')
|
||||
const existing = await chrome.tabs.query({ url: `${hubUrl}*` })
|
||||
|
||||
if (existing.length > 0 && existing[0].id) {
|
||||
await chrome.tabs.update(existing[0].id, {
|
||||
active: true,
|
||||
url: `${hubUrl}?ws=${wsPort}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await chrome.tabs.create({ url: `${hubUrl}?ws=${wsPort}`, pinned: true })
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ async function exposeAgentToPage() {
|
||||
let multiPageAgent: InstanceType<typeof MultiPageAgent> | null = null
|
||||
|
||||
window.addEventListener('message', async (e) => {
|
||||
if (e.source !== window) return
|
||||
|
||||
const data = e.data
|
||||
if (typeof data !== 'object' || data === null) return
|
||||
if (data.channel !== 'PAGE_AGENT_EXT_REQUEST') return
|
||||
@@ -70,11 +72,15 @@ async function exposeAgentToPage() {
|
||||
|
||||
try {
|
||||
const { task, config } = payload
|
||||
const { systemInstruction, ...agentConfig } = config
|
||||
|
||||
// Dispose old instance before creating new one
|
||||
multiPageAgent?.dispose()
|
||||
|
||||
multiPageAgent = new MultiPageAgent(config)
|
||||
multiPageAgent = new MultiPageAgent({
|
||||
...agentConfig,
|
||||
instructions: systemInstruction ? { system: systemInstruction } : undefined,
|
||||
})
|
||||
|
||||
// events
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { FoldVertical, Plug, PlugZap, Square, UnfoldVertical, Unplug } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useAgent } from '@/agent/useAgent'
|
||||
import { ActivityCard, EventCard } from '@/components/cards'
|
||||
import { Logo, MotionOverlay, StatusDot } from '@/components/misc'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
|
||||
import { useHubWs } from './hub-ws'
|
||||
|
||||
export default function App() {
|
||||
const { status, history, activity, currentTask, config, execute, stop, configure } = useAgent()
|
||||
const { wsState } = useHubWs(execute, stop, configure, config)
|
||||
|
||||
const historyRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (historyRef.current) {
|
||||
historyRef.current.scrollTop = historyRef.current.scrollHeight
|
||||
}
|
||||
}, [history, activity])
|
||||
|
||||
const isRunning = status === 'running'
|
||||
const WsIcon = wsState === 'connected' ? PlugZap : wsState === 'connecting' ? Plug : Unplug
|
||||
const wsLabel = {
|
||||
connected: 'Connected',
|
||||
connecting: 'Connecting…',
|
||||
disconnected: new URLSearchParams(location.search).get('ws') ? 'Disconnected' : 'No connection',
|
||||
}[wsState]
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
{/* Left — Protocol docs */}
|
||||
<aside className="w-80 shrink-0 border-r flex flex-col bg-muted/20">
|
||||
<a
|
||||
href="https://alibaba.github.io/page-agent/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-5 h-12 border-b hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<Logo className="size-5" />
|
||||
<span className="text-sm font-semibold tracking-tight">Page Agent Hub</span>
|
||||
<span className="text-[9px] font-medium uppercase tracking-wider text-amber-600 bg-amber-500/10 border border-amber-500/30 rounded px-1.5 py-0.5">
|
||||
Beta
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-6">
|
||||
<div className="text-xs text-muted-foreground leading-relaxed space-y-2">
|
||||
<p>
|
||||
Page Agent Hub lets local apps (e.g. MCP servers) control the Page Agent extension via
|
||||
WebSocket.
|
||||
</p>
|
||||
<p>
|
||||
Check out the official{' '}
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent/tree/main/packages/mcp"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
MCP server package
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HubConfig />
|
||||
|
||||
<ProtocolDocsCollapsible />
|
||||
</div>
|
||||
|
||||
<div className="border-t px-5 py-3 text-[10px] text-muted-foreground/60 flex items-center justify-between">
|
||||
<span className="font-mono">v{__VERSION__}</span>
|
||||
<span>
|
||||
Built with ♥️ by{' '}
|
||||
<a
|
||||
href="https://github.com/gaomeng1900"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
@Simon
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Right — Live session */}
|
||||
<main className="flex-1 flex flex-col min-w-0 relative">
|
||||
<MotionOverlay active={isRunning} />
|
||||
|
||||
<header className="flex items-center justify-between border-b px-5 h-12">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<WsIcon className="size-3.5" />
|
||||
<span>{wsLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusDot status={status} />
|
||||
{isRunning && (
|
||||
<Button variant="destructive" size="sm" onClick={stop} className="h-7 text-xs">
|
||||
<Square className="size-3 mr-1" />
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Task banner */}
|
||||
{currentTask && (
|
||||
<div className="border-b px-5 py-2 bg-muted/30">
|
||||
<div className="text-[10px] text-muted-foreground uppercase tracking-wide">
|
||||
Current Task
|
||||
</div>
|
||||
<div className="text-sm font-medium truncate" title={currentTask}>
|
||||
{currentTask}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event stream */}
|
||||
<div ref={historyRef} className="flex-1 overflow-y-auto p-5 space-y-2">
|
||||
{!currentTask && history.length === 0 && !isRunning && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
|
||||
<WsIcon className="size-10 opacity-30" />
|
||||
<p className="text-sm">
|
||||
{wsState === 'connected'
|
||||
? 'Waiting for task from external caller…'
|
||||
: 'No active session'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{history.map((event, index) => (
|
||||
<EventCard key={index} event={event} />
|
||||
))}
|
||||
|
||||
{activity && <ActivityCard activity={activity} />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HubConfig() {
|
||||
const [allowAll, setAllowAll] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
chrome.storage.local.get('allowAllHubConnection').then((r) => {
|
||||
setAllowAll(r.allowAllHubConnection === true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggle = (checked: boolean) => {
|
||||
setAllowAll(checked)
|
||||
chrome.storage.local.set({ allowAllHubConnection: checked })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-[11px] font-semibold text-foreground/80 uppercase tracking-wider mb-2">
|
||||
Config
|
||||
</h3>
|
||||
<div className="group/hub relative">
|
||||
<label
|
||||
className={`flex items-center justify-between p-3 rounded-md border cursor-pointer text-xs ${allowAll ? 'bg-amber-500/10 border-amber-500/30 text-amber-600' : 'bg-muted/50 text-muted-foreground'}`}
|
||||
>
|
||||
Auto-approve connections
|
||||
<Switch
|
||||
checked={allowAll}
|
||||
onCheckedChange={toggle}
|
||||
className={allowAll ? 'data-[state=checked]:bg-amber-500' : ''}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* hide with invisible absolute opacity-0*/}
|
||||
<div className="group-hover/hub:visible group-hover/hub:opacity-100 transition-opacity duration-150 left-0 right-0 top-full z-10 pt-2">
|
||||
<div className="relative p-2.5 rounded-md border border-border bg-background/60 backdrop-blur-md shadow-2xl text-muted-foreground text-xs leading-relaxed">
|
||||
<div className="absolute -top-1.5 left-5 size-3 rotate-45 rounded-[1px] border-l border-t border-border bg-background/60 backdrop-blur-md" />
|
||||
By default, each connection requires your approval before running tasks. <br />
|
||||
Enable this to skip per-session approval.
|
||||
<br />
|
||||
<span className="font-semibold">* Use with caution!</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProtocolDocsCollapsible() {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-1 text-[11px] font-semibold text-foreground/80 uppercase tracking-wider cursor-pointer"
|
||||
>
|
||||
Docs
|
||||
{open ? <FoldVertical className="size-3" /> : <UnfoldVertical className="size-3" />}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="mt-3 space-y-4 text-xs text-muted-foreground">
|
||||
<p className="text-[10px]">
|
||||
Connect via <code className="text-[10px]">hub.html?ws=PORT</code>
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h4 className="text-[11px] font-medium text-foreground/60 mb-1.5">Flow</h4>
|
||||
<ol className="list-decimal list-inside space-y-1 text-[11px] leading-relaxed">
|
||||
<li>Hub opens WS to caller's server</li>
|
||||
<li>
|
||||
Sends <code className="text-[10px]">ready</code>
|
||||
</li>
|
||||
<li>
|
||||
Caller sends <code className="text-[10px]">execute</code> with task
|
||||
</li>
|
||||
<li>Hub runs agent, streams events</li>
|
||||
<li>
|
||||
Hub sends <code className="text-[10px]">result</code> or{' '}
|
||||
<code className="text-[10px]">error</code>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 className="text-[11px] font-medium text-foreground/60 mb-1.5">Caller → Hub</h4>
|
||||
<pre className="bg-muted/50 rounded-md p-3 font-mono text-[10px] leading-relaxed whitespace-pre-wrap">
|
||||
{`{ type: "execute", task: string, config?: object }
|
||||
{ type: "stop" }`}
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 className="text-[11px] font-medium text-foreground/60 mb-1.5">Hub → Caller</h4>
|
||||
<pre className="bg-muted/50 rounded-md p-3 font-mono text-[10px] leading-relaxed whitespace-pre-wrap">
|
||||
{`{ type: "ready" }
|
||||
{ type: "result", success: boolean, data: string }
|
||||
{ type: "error", message: string }`}
|
||||
</pre>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Hub WebSocket Protocol
|
||||
*
|
||||
* Hub connects as WS client to `ws://localhost:{port}`.
|
||||
* All messages are JSON. One task at a time.
|
||||
*
|
||||
* Inbound (Caller → Hub):
|
||||
* { type: "execute", task: string, config?: object }
|
||||
* { type: "stop" }
|
||||
*
|
||||
* Outbound (Hub → Caller):
|
||||
* { type: "ready" }
|
||||
* { type: "result", success: boolean, data: string }
|
||||
* { type: "error", message: string }
|
||||
*/
|
||||
import type { ExecutionResult } from '@page-agent/core'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { ExtConfig } from '@/agent/useAgent'
|
||||
|
||||
// --- Protocol types ---
|
||||
|
||||
interface ExecuteMessage {
|
||||
type: 'execute'
|
||||
task: string
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface StopMessage {
|
||||
type: 'stop'
|
||||
}
|
||||
|
||||
type InboundMessage = ExecuteMessage | StopMessage
|
||||
|
||||
interface ReadyMessage {
|
||||
type: 'ready'
|
||||
}
|
||||
|
||||
interface ResultMessage {
|
||||
type: 'result'
|
||||
success: boolean
|
||||
data: string
|
||||
}
|
||||
|
||||
interface ErrorMessage {
|
||||
type: 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
type OutboundMessage = ReadyMessage | ResultMessage | ErrorMessage
|
||||
|
||||
export type HubWsState = 'connecting' | 'connected' | 'disconnected'
|
||||
|
||||
// --- HubWs class ---
|
||||
|
||||
export interface HubWsHandlers {
|
||||
onExecute: (
|
||||
task: string,
|
||||
config?: Record<string, unknown>
|
||||
) => Promise<{ success: boolean; data: string }>
|
||||
onStop: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Framework-agnostic WebSocket client for Hub.
|
||||
* Connects to an external WS server, receives tasks, dispatches to handlers,
|
||||
* and sends results back. No React, no DOM.
|
||||
*/
|
||||
export class HubWs {
|
||||
#ws: WebSocket | null = null
|
||||
#state: HubWsState = 'disconnected'
|
||||
#busy = false
|
||||
#approved = false
|
||||
#handlers: HubWsHandlers
|
||||
#port: number
|
||||
#onStateChange: (state: HubWsState) => void
|
||||
|
||||
constructor(port: number, handlers: HubWsHandlers, onStateChange: (state: HubWsState) => void) {
|
||||
this.#port = port
|
||||
this.#handlers = handlers
|
||||
this.#onStateChange = onStateChange
|
||||
}
|
||||
|
||||
get state() {
|
||||
return this.#state
|
||||
}
|
||||
|
||||
get busy() {
|
||||
return this.#busy
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.#ws) return
|
||||
this.#setState('connecting')
|
||||
|
||||
const ws = new WebSocket(`ws://localhost:${this.#port}`)
|
||||
this.#ws = ws
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
this.#setState('connected')
|
||||
this.#send({ type: 'ready' })
|
||||
})
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
this.#ws = null
|
||||
this.#busy = false
|
||||
this.#approved = false
|
||||
this.#setState('disconnected')
|
||||
})
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
this.#handleMessage(event.data as string)
|
||||
})
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.#ws?.close()
|
||||
this.#ws = null
|
||||
this.#busy = false
|
||||
this.#approved = false
|
||||
this.#setState('disconnected')
|
||||
}
|
||||
|
||||
#setState(state: HubWsState) {
|
||||
if (this.#state === state) return
|
||||
this.#state = state
|
||||
this.#onStateChange(state)
|
||||
}
|
||||
|
||||
#send(msg: OutboundMessage) {
|
||||
if (this.#ws?.readyState === WebSocket.OPEN) {
|
||||
this.#ws.send(JSON.stringify(msg))
|
||||
}
|
||||
}
|
||||
|
||||
async #handleMessage(raw: string) {
|
||||
let msg: InboundMessage
|
||||
try {
|
||||
msg = JSON.parse(raw)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await this.#checkApproval())) {
|
||||
this.#send({ type: 'error', message: 'User denied the connection request.' })
|
||||
return
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case 'execute':
|
||||
this.#handleExecute(msg)
|
||||
break
|
||||
case 'stop':
|
||||
this.#handlers.onStop()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async #checkApproval(): Promise<boolean> {
|
||||
if (this.#approved) return true
|
||||
|
||||
const { allowAllHubConnection } = await chrome.storage.local.get('allowAllHubConnection')
|
||||
if (allowAllHubConnection === true) {
|
||||
this.#approved = true
|
||||
return true
|
||||
}
|
||||
|
||||
const ok = window.confirm(
|
||||
'An external application is requesting to control your browser via Page Agent Ext.\nAllow this session?'
|
||||
)
|
||||
if (ok) this.#approved = true
|
||||
return ok
|
||||
}
|
||||
|
||||
async #handleExecute(msg: ExecuteMessage) {
|
||||
if (this.#busy) {
|
||||
this.#send({ type: 'error', message: 'Hub is busy with another task' })
|
||||
return
|
||||
}
|
||||
|
||||
this.#busy = true
|
||||
try {
|
||||
const result = await this.#handlers.onExecute(msg.task, msg.config)
|
||||
this.#send({ type: 'result', success: result.success, data: result.data })
|
||||
} catch (err) {
|
||||
this.#send({ type: 'error', message: err instanceof Error ? err.message : String(err) })
|
||||
} finally {
|
||||
this.#busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- React hook ---
|
||||
|
||||
/**
|
||||
* React hook that bridges HubWs to the agent's execute/stop/configure.
|
||||
* Handles the config-before-execute dance internally.
|
||||
*/
|
||||
export function useHubWs(
|
||||
execute: (task: string) => Promise<ExecutionResult>,
|
||||
stop: () => void,
|
||||
configure: (config: ExtConfig) => Promise<void>,
|
||||
config: ExtConfig | null
|
||||
): { wsState: HubWsState } {
|
||||
const wsPort = new URLSearchParams(location.search).get('ws')
|
||||
const [wsState, setWsState] = useState<HubWsState>(() => (wsPort ? 'connecting' : 'disconnected'))
|
||||
const hubWsRef = useRef<HubWs | null>(null)
|
||||
|
||||
const latestRef = useRef({ execute, stop, configure, config })
|
||||
useEffect(() => {
|
||||
latestRef.current = { execute, stop, configure, config }
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!wsPort) return
|
||||
|
||||
const hubWs = new HubWs(
|
||||
Number(wsPort),
|
||||
{
|
||||
onExecute: async (task, incomingConfig) => {
|
||||
const { execute, configure, config } = latestRef.current
|
||||
if (incomingConfig) {
|
||||
await configure({ ...config, ...incomingConfig } as ExtConfig)
|
||||
}
|
||||
const result = await execute(task)
|
||||
return { success: result.success, data: result.data }
|
||||
},
|
||||
onStop: () => latestRef.current.stop(),
|
||||
},
|
||||
setWsState
|
||||
)
|
||||
|
||||
hubWs.connect()
|
||||
hubWsRef.current = hubWs
|
||||
|
||||
return () => {
|
||||
hubWs.disconnect()
|
||||
hubWsRef.current = null
|
||||
}
|
||||
}, [wsPort])
|
||||
|
||||
return { wsState }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/assets/page-agent-64.png" />
|
||||
<title>Page Agent Hub</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary'
|
||||
|
||||
import App from './App'
|
||||
|
||||
import '@/assets/index.css'
|
||||
|
||||
const syncDarkMode = () => {
|
||||
document.documentElement.classList.toggle(
|
||||
'dark',
|
||||
matchMedia('(prefers-color-scheme: dark)').matches
|
||||
)
|
||||
}
|
||||
syncDarkMode()
|
||||
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncDarkMode)
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -4,8 +4,14 @@ export type Execute = (task: string, config: ExecuteConfig) => Promise<Execution
|
||||
|
||||
export interface ExecuteConfig {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: 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.
|
||||
@@ -13,6 +19,9 @@ export interface ExecuteConfig {
|
||||
*/
|
||||
includeInitialTab?: boolean
|
||||
|
||||
/** Control all unpinned tabs in the window instead of only the tab group. */
|
||||
experimentalIncludeAllTabs?: boolean
|
||||
|
||||
onStatusChange?: (status: AgentStatus) => void
|
||||
onActivity?: (activity: AgentActivity) => void
|
||||
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
||||
@@ -30,13 +39,14 @@ export default defineUnlistedScript(() => {
|
||||
if (task.trim().length === 0) throw new Error('Task cannot be empty')
|
||||
if (!config) throw new Error('Config is required')
|
||||
if (!config.baseURL) throw new Error('Config must have a baseURL')
|
||||
if (!config.apiKey) throw new Error('Config must have an apiKey')
|
||||
if (!config.model) throw new Error('Config must have a model')
|
||||
|
||||
const id = getId()
|
||||
|
||||
const promise = new Promise<ExecutionResult>((resolve, reject) => {
|
||||
function handleMessage(e: MessageEvent) {
|
||||
if (e.source !== window) return
|
||||
|
||||
const data = e.data
|
||||
if (typeof data !== 'object' || data === null) return
|
||||
if (data.channel !== 'PAGE_AGENT_EXT_RESPONSE') return
|
||||
@@ -85,9 +95,11 @@ export default defineUnlistedScript(() => {
|
||||
task,
|
||||
config: {
|
||||
baseURL: config.baseURL,
|
||||
apiKey: config.apiKey,
|
||||
model: config.model,
|
||||
apiKey: config.apiKey,
|
||||
systemInstruction: config.systemInstruction,
|
||||
includeInitialTab: config.includeInitialTab,
|
||||
experimentalIncludeAllTabs: config.experimentalIncludeAllTabs,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { History, Send, Settings, Square } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { ConfigPanel } from '@/components/ConfigPanel'
|
||||
import { HistoryDetail } from '@/components/HistoryDetail'
|
||||
import { HistoryList } from '@/components/HistoryList'
|
||||
import { ActivityCard, EventCard } from '@/components/cards'
|
||||
import { EmptyState, Logo, MotionOverlay, StatusDot } from '@/components/misc'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
InputGroup,
|
||||
@@ -11,11 +16,6 @@ import {
|
||||
import { saveSession } from '@/lib/db'
|
||||
|
||||
import { useAgent } from '../../agent/useAgent'
|
||||
import { ConfigPanel } from './components/ConfigPanel'
|
||||
import { HistoryDetail } from './components/HistoryDetail'
|
||||
import { HistoryList } from './components/HistoryList'
|
||||
import { ActivityCard, EventCard } from './components/cards'
|
||||
import { EmptyState, Logo, MotionOverlay, StatusDot } from './components/misc'
|
||||
|
||||
type View =
|
||||
| { name: 'chat' }
|
||||
@@ -56,19 +56,27 @@ export default function App() {
|
||||
}
|
||||
}, [history, activity])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e?: React.SyntheticEvent) => {
|
||||
e?.preventDefault()
|
||||
if (!inputValue.trim() || status === 'running') return
|
||||
const runTask = useCallback(
|
||||
(task: string) => {
|
||||
const normalizedTask = task.trim()
|
||||
if (!normalizedTask || status === 'running') return
|
||||
|
||||
const taskToExecute = inputValue.trim()
|
||||
setInputValue('')
|
||||
setView({ name: 'chat' })
|
||||
|
||||
execute(taskToExecute).catch((error) => {
|
||||
execute(normalizedTask).catch((error) => {
|
||||
console.error('[SidePanel] Failed to execute task:', error)
|
||||
})
|
||||
},
|
||||
[inputValue, status, execute]
|
||||
[execute, status]
|
||||
)
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e?: React.SyntheticEvent) => {
|
||||
e?.preventDefault()
|
||||
runTask(inputValue)
|
||||
},
|
||||
[inputValue, runTask]
|
||||
)
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
@@ -103,12 +111,19 @@ export default function App() {
|
||||
<HistoryList
|
||||
onSelect={(id) => setView({ name: 'history-detail', sessionId: id })}
|
||||
onBack={() => setView({ name: 'chat' })}
|
||||
onRerun={runTask}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (view.name === 'history-detail') {
|
||||
return <HistoryDetail sessionId={view.sessionId} onBack={() => setView({ name: 'history' })} />
|
||||
return (
|
||||
<HistoryDetail
|
||||
sessionId={view.sessionId}
|
||||
onBack={() => setView({ name: 'history' })}
|
||||
onRerun={runTask}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Chat view ---
|
||||
@@ -132,6 +147,8 @@ export default function App() {
|
||||
size="icon-sm"
|
||||
onClick={() => setView({ name: 'history' })}
|
||||
className="cursor-pointer"
|
||||
aria-label="History"
|
||||
title="History"
|
||||
>
|
||||
<History className="size-3.5" />
|
||||
</Button>
|
||||
@@ -140,6 +157,8 @@ export default function App() {
|
||||
size="icon-sm"
|
||||
onClick={() => setView({ name: 'config' })}
|
||||
className="cursor-pointer"
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="size-3.5" />
|
||||
</Button>
|
||||
@@ -163,7 +182,6 @@ export default function App() {
|
||||
{showEmptyState && <EmptyState />}
|
||||
|
||||
{history.map((event, index) => (
|
||||
// eslint-disable-next-line react-x/no-array-index-key
|
||||
<EventCard key={index} event={event} />
|
||||
))}
|
||||
|
||||
@@ -191,6 +209,8 @@ export default function App() {
|
||||
variant="destructive"
|
||||
onClick={handleStop}
|
||||
className="size-7"
|
||||
aria-label="Stop task"
|
||||
title="Stop task"
|
||||
>
|
||||
<Square className="size-3" />
|
||||
</InputGroupButton>
|
||||
@@ -201,6 +221,8 @@ export default function App() {
|
||||
onClick={() => handleSubmit()}
|
||||
disabled={!inputValue.trim()}
|
||||
className="size-7 cursor-pointer"
|
||||
aria-label="Send"
|
||||
title="Send"
|
||||
>
|
||||
<Send className="size-3" />
|
||||
</InputGroupButton>
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { ArrowLeft, CheckCircle, Trash2, XCircle } from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { type SessionRecord, clearSessions, deleteSession, listSessions } from '@/lib/db'
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const seconds = Math.floor((Date.now() - ts) / 1000)
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
export function HistoryList({
|
||||
onSelect,
|
||||
onBack,
|
||||
}: {
|
||||
onSelect: (id: string) => void
|
||||
onBack: () => void
|
||||
}) {
|
||||
const [sessions, setSessions] = useState<SessionRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setSessions(await listSessions())
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation()
|
||||
await deleteSession(id)
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Button variant="ghost" size="icon-sm" onClick={onBack} className="cursor-pointer">
|
||||
<ArrowLeft className="size-3.5" />
|
||||
</Button>
|
||||
<span className="text-sm font-medium flex-1">History</span>
|
||||
{sessions.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await clearSessions()
|
||||
setSessions([])
|
||||
}}
|
||||
className="text-[10px] text-muted-foreground hover:text-destructive cursor-pointer h-6 px-2"
|
||||
>
|
||||
<Trash2 className="size-3 mr-1" />
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-32 text-xs text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && sessions.length === 0 && (
|
||||
<div className="flex items-center justify-center h-32 text-xs text-muted-foreground">
|
||||
No history yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(session.id)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onSelect(session.id)}
|
||||
className="w-full text-left px-3 py-2.5 border-b hover:bg-muted/50 transition-colors cursor-pointer flex items-start gap-2 group"
|
||||
>
|
||||
{/* Status icon */}
|
||||
{session.status === 'completed' ? (
|
||||
<CheckCircle className="size-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<XCircle className="size-3.5 text-destructive shrink-0 mt-0.5" />
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate">{session.task}</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
{timeAgo(session.createdAt)} · {session.history.length} steps
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleDelete(e, session.id)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:text-destructive cursor-pointer shrink-0"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary'
|
||||
|
||||
import App from './App'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
|
||||
import '@/assets/index.css'
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { HistoricalEvent } from '@page-agent/core'
|
||||
|
||||
const EXPORT_FILE_PREFIX = 'page-agent-history'
|
||||
const MAX_TASK_SLUG_LENGTH = 40
|
||||
|
||||
export function serializeHistoryExport(history: HistoricalEvent[]): string {
|
||||
return `${JSON.stringify(history, null, 2)}\n`
|
||||
}
|
||||
|
||||
export function buildHistoryExportFilename(task: string, createdAt: number): string {
|
||||
const taskSlug = sanitizeTaskForFilename(task)
|
||||
const timestamp = formatTimestampForFilename(createdAt)
|
||||
|
||||
return taskSlug
|
||||
? `${EXPORT_FILE_PREFIX}-${taskSlug}-${timestamp}.json`
|
||||
: `${EXPORT_FILE_PREFIX}-${timestamp}.json`
|
||||
}
|
||||
|
||||
export function downloadHistoryExport(
|
||||
task: string,
|
||||
createdAt: number,
|
||||
history: HistoricalEvent[]
|
||||
): void {
|
||||
const filename = buildHistoryExportFilename(task, createdAt)
|
||||
const content = serializeHistoryExport(history)
|
||||
const blob = new Blob([content], { type: 'application/json;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.click()
|
||||
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function sanitizeTaskForFilename(task: string): string {
|
||||
return task
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, MAX_TASK_SLUG_LENGTH)
|
||||
}
|
||||
|
||||
function formatTimestampForFilename(createdAt: number): string {
|
||||
const date = new Date(createdAt)
|
||||
const year = date.getFullYear()
|
||||
const month = pad(date.getMonth() + 1)
|
||||
const day = pad(date.getDate())
|
||||
const hours = pad(date.getHours())
|
||||
const minutes = pad(date.getMinutes())
|
||||
const seconds = pad(date.getSeconds())
|
||||
|
||||
return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`
|
||||
}
|
||||
|
||||
function pad(value: number): string {
|
||||
return value.toString().padStart(2, '0')
|
||||
}
|
||||
@@ -3,26 +3,11 @@
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||
"useDefineForClassFields": true,
|
||||
"noEmit": false,
|
||||
"allowImportingTsExtensions": false,
|
||||
"strictNullChecks": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"types": ["node", "chrome"],
|
||||
"paths": {
|
||||
// Self root
|
||||
"@/*": ["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"]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"references": [
|
||||
//
|
||||
{ "path": "../llms" },
|
||||
{ "path": "../page-controller" },
|
||||
{ "path": "../core" },
|
||||
{ "path": "../ui" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export default defineConfig({
|
||||
artifactTemplate: 'page-agent-ext-{{version}}-{{browser}}.zip',
|
||||
},
|
||||
manifest: {
|
||||
key: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqbzT0iTYeYlnCvDJIGDnGU8oarJgZILDzSfLi/ufuSxXEPDKuMyD892GhvrMCZNVHS11Sh6NYUOc/PcUOhtaR2urHtcNkrpSJNV10zUamY7fxBdVEkOucfyLu8INVy+teis62MoRWYPaUPkfZUjrLGW8MsZ9aFzARfu9GGDEp2EAYsWDN6w6vyz9LJ82pm542EWnVT4MjmDPgvYFCWGBtaU/dfHD+GAX6URJFapsCvryVURKJ+76c/GO9/I3EX1IBfbY6dec78bLCMvVxiTmiv36KyGPwX1OpakW8IiCpXWdbAxjm+plbYlp5t5zTyyoE3sOSFeXsBH0Kg27o8GcvQIDAQAB',
|
||||
default_locale: 'en',
|
||||
name: '__MSG_extName__',
|
||||
description: '__MSG_extDescription__',
|
||||
@@ -61,5 +62,8 @@ export default defineConfig({
|
||||
side_panel: {
|
||||
default_path: 'sidepanel/index.html',
|
||||
},
|
||||
externally_connectable: {
|
||||
matches: ['http://localhost/*'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
+20
-10
@@ -1,15 +1,25 @@
|
||||
{
|
||||
"name": "@page-agent/llms",
|
||||
"version": "1.5.8",
|
||||
"version": "1.8.1",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-agent-llms.js",
|
||||
"module": "./dist/lib/page-agent-llms.js",
|
||||
"types": "./dist/lib/index.d.ts",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/lib/index.d.ts",
|
||||
"import": "./dist/lib/page-agent-llms.js",
|
||||
"default": "./dist/lib/page-agent-llms.js"
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"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": [
|
||||
@@ -33,8 +43,8 @@
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
||||
"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": {
|
||||
"chalk": "^5.6.2"
|
||||
@@ -43,6 +53,6 @@
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"zod": "^4.3.5"
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,19 +29,33 @@ export class OpenAIClient implements LLMClient {
|
||||
const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t))
|
||||
|
||||
// Build request body
|
||||
|
||||
let toolChoice: unknown = 'required'
|
||||
if (options?.toolChoiceName && !this.config.disableNamedToolChoice) {
|
||||
toolChoice = { type: 'function', function: { name: options.toolChoiceName } }
|
||||
}
|
||||
|
||||
const requestBody: Record<string, unknown> = {
|
||||
model: this.config.model,
|
||||
temperature: this.config.temperature,
|
||||
messages,
|
||||
tools: openaiTools,
|
||||
parallel_tool_calls: false,
|
||||
// Require tool call: specific tool if provided, otherwise any tool
|
||||
tool_choice: options?.toolChoiceName
|
||||
? { type: 'function', function: { name: options.toolChoiceName } }
|
||||
: 'required',
|
||||
tool_choice: toolChoice,
|
||||
}
|
||||
|
||||
modelPatch(requestBody)
|
||||
let transformedBody: Record<string, unknown> | undefined
|
||||
try {
|
||||
transformedBody = this.config.transformRequestBody(requestBody)
|
||||
} catch (error) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.CONFIG_ERROR,
|
||||
`transformRequestBody failed: ${(error as Error).message}`,
|
||||
error
|
||||
)
|
||||
}
|
||||
const finalRequestBody = transformedBody ?? requestBody
|
||||
|
||||
// 2. Call API
|
||||
let response: Response
|
||||
@@ -50,9 +64,9 @@ export class OpenAIClient implements LLMClient {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
...(this.config.apiKey && { Authorization: `Bearer ${this.config.apiKey}` }),
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
body: JSON.stringify(finalRequestBody),
|
||||
signal: abortSignal,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
@@ -222,7 +236,7 @@ export class OpenAIClient implements LLMClient {
|
||||
reasoningTokens: data.usage?.completion_tokens_details?.reasoning_tokens,
|
||||
},
|
||||
rawResponse: data,
|
||||
rawRequest: requestBody,
|
||||
rawRequest: finalRequestBody,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export const InvokeErrorType = {
|
||||
UNKNOWN: 'unknown',
|
||||
|
||||
// Non-retryable
|
||||
CONFIG_ERROR: 'config_error', // Invalid local configuration or hook
|
||||
AUTH_ERROR: 'auth_error', // Authentication failed
|
||||
CONTEXT_LENGTH: 'context_length', // Prompt too long
|
||||
CONTENT_FILTER: 'content_filter', // Content filtered
|
||||
|
||||
@@ -8,19 +8,21 @@ export type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool }
|
||||
|
||||
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
|
||||
// Runtime validation as defensive programming (types already guarantee these)
|
||||
if (!config.baseURL || !config.apiKey || !config.model) {
|
||||
if (!config.baseURL || !config.model) {
|
||||
throw new Error(
|
||||
'[PageAgent] LLM configuration required. Please provide: baseURL, apiKey, model. ' +
|
||||
'[PageAgent] LLM configuration required. Please provide: baseURL, model. ' +
|
||||
'See: https://alibaba.github.io/page-agent/docs/features/models'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
baseURL: config.baseURL,
|
||||
apiKey: config.apiKey,
|
||||
model: config.model,
|
||||
apiKey: config.apiKey || '',
|
||||
temperature: config.temperature ?? DEFAULT_TEMPERATURE,
|
||||
maxRetries: config.maxRetries ?? LLM_MAX_RETRIES,
|
||||
transformRequestBody: config.transformRequestBody ?? ((requestBody) => requestBody),
|
||||
disableNamedToolChoice: config.disableNamedToolChoice ?? false,
|
||||
customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,12 +89,28 @@ export interface InvokeResult<TResult = unknown> {
|
||||
*/
|
||||
export interface LLMConfig {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
apiKey?: string
|
||||
|
||||
temperature?: number
|
||||
maxRetries?: number
|
||||
|
||||
/**
|
||||
* Transform the final request body before sending it to the provider.
|
||||
* Use this to implement provider-specific request tweaks such as caching hints or custom flags.
|
||||
*
|
||||
* Return a new object, or mutate the input object and return undefined.
|
||||
*/
|
||||
transformRequestBody?: (
|
||||
requestBody: Record<string, unknown>
|
||||
) => Record<string, unknown> | undefined
|
||||
|
||||
/**
|
||||
* remove the tool_choice field from the request.
|
||||
* @note fix "Invalid tool_choice type: 'object'" for some LLMs.
|
||||
*/
|
||||
disableNamedToolChoice?: boolean
|
||||
|
||||
/**
|
||||
* Custom fetch function for LLM API requests.
|
||||
* Use this to customize headers, credentials, proxy, etc.
|
||||
|
||||
@@ -53,6 +53,13 @@ export function modelPatch(body: Record<string, any>) {
|
||||
debug('Applying Claude patch: convert tool_choice format')
|
||||
body.tool_choice = { type: 'tool', name: body.tool_choice.function.name }
|
||||
}
|
||||
|
||||
// TODO: Claude naming pattern has changed
|
||||
// needs proper handling
|
||||
if (modelName.startsWith('claude-opus-4-7') || modelName.startsWith('claude-opus-47')) {
|
||||
debug('Applying Claude-4.7 patch: remove temperature')
|
||||
delete body.temperature
|
||||
}
|
||||
}
|
||||
|
||||
if (modelName.startsWith('grok')) {
|
||||
@@ -74,10 +81,12 @@ export function modelPatch(body: Record<string, any>) {
|
||||
debug('Applying GPT-51 patch: disable reasoning')
|
||||
body.reasoning_effort = 'none'
|
||||
} else if (modelName.startsWith('gpt-54')) {
|
||||
debug(
|
||||
'Applying GPT-5.4 patch: skip reasoning_effort because chat/completions rejects it with function tools'
|
||||
)
|
||||
debug('Applying GPT-5.4 patch: remove reasoning_effort')
|
||||
delete body.reasoning_effort
|
||||
} else if (modelName.startsWith('gpt-55')) {
|
||||
debug('Applying GPT-5.4 patch: remove reasoning_effort and temperature')
|
||||
delete body.reasoning_effort
|
||||
delete body.temperature
|
||||
} else if (modelName.startsWith('gpt-5-mini')) {
|
||||
debug('Applying GPT-5-mini patch: set reasoning effort to low, temperature to 1')
|
||||
body.reasoning_effort = 'low'
|
||||
@@ -93,6 +102,20 @@ export function modelPatch(body: Record<string, any>) {
|
||||
body.reasoning_effort = 'minimal'
|
||||
}
|
||||
|
||||
if (modelName.startsWith('deepseek')) {
|
||||
debug('Applying DeepSeek patch: remove tool_choice')
|
||||
delete body.tool_choice
|
||||
}
|
||||
|
||||
if (modelName.startsWith('minimax')) {
|
||||
debug('Applying MiniMax patch: clamp temperature to (0, 1]')
|
||||
// MiniMax API rejects temperature = 0; clamp to a small positive value
|
||||
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
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
|
||||
@@ -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,11 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||
"noEmit": false,
|
||||
"allowImportingTsExtensions": false,
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist"
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
|
||||
@@ -11,11 +11,18 @@ console.log(chalk.cyan(`📦 Building @page-agent/llms`))
|
||||
|
||||
export default defineConfig({
|
||||
clearScreen: false,
|
||||
plugins: [dts({ tsconfigPath: './tsconfig.dts.json', bundleTypes: true })],
|
||||
plugins: [
|
||||
dts({
|
||||
bundleTypes: true,
|
||||
compilerOptions: {
|
||||
composite: true,
|
||||
noEmit: false,
|
||||
emitDeclarationOnly: true,
|
||||
declaration: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
publicDir: false,
|
||||
esbuild: {
|
||||
keepNames: true,
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/index.ts'),
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# @page-agent/mcp
|
||||
|
||||
MCP server that lets AI agent clients (Claude Desktop, Copilot, etc.) control your browser through the [Page Agent](https://github.com/alibaba/page-agent) extension.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js >= 20
|
||||
- [Page Agent Extension](https://chromewebstore.google.com/detail/page-agent-ext/akldabonmimlicnjlflnapfeklbfemhj) installed in Chrome
|
||||
- An LLM API key (OpenAI-compatible)
|
||||
|
||||
## Installation
|
||||
|
||||
### Claude Desktop
|
||||
|
||||
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"page-agent": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@page-agent/mcp"],
|
||||
"env": {
|
||||
"LLM_BASE_URL": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"LLM_API_KEY": "sk-xxx",
|
||||
"LLM_MODEL_NAME": "qwen3.5-plus"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cursor / Copilot
|
||||
|
||||
Same format — add the config to the MCP settings of your client.
|
||||
|
||||
## MCP Tools
|
||||
|
||||
| Tool | Input | Description |
|
||||
| -------------- | ------------------ | ----------------------------------------------------- |
|
||||
| `execute_task` | `{ task: string }` | Execute a browser task in natural language. Blocking. |
|
||||
| `get_status` | — | Returns `{ connected, busy }` |
|
||||
| `stop_task` | — | Stop the currently running task. |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ---------------- | ------- | --------------------- |
|
||||
| `LLM_BASE_URL` | — | LLM API base URL |
|
||||
| `LLM_API_KEY` | — | LLM API key |
|
||||
| `LLM_MODEL_NAME` | — | Model name |
|
||||
| `PORT` | `38401` | HTTP + WebSocket port |
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
┌──────────────┐ stdio ┌──────────────────┐ WebSocket ┌──────────────┐
|
||||
│ Claude / │◄────────►│ @page-agent/mcp │◄────────────►│ Hub tab │
|
||||
│ Copilot │ (MCP) │ (Node.js) │ (localhost) │ (extension) │
|
||||
└──────────────┘ └──────────────────┘ └──────┬───────┘
|
||||
│ │
|
||||
│ HTTP │ useAgent
|
||||
▼ ▼
|
||||
┌──────────────────┐ ┌──────────────┐
|
||||
│ Launcher page │ │ MultiPage │
|
||||
│ (localhost:PORT) │ │ Agent │
|
||||
└──────────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
1. Agent client starts the MCP server via stdio (`npx @page-agent/mcp`).
|
||||
2. Server starts HTTP + WS on `localhost:PORT`, opens the launcher page in browser.
|
||||
3. Launcher page triggers the extension to open a **hub tab** (`hub.html?ws=PORT`).
|
||||
4. Hub connects to the WS server. MCP tools now proxy tasks to the hub.
|
||||
|
||||
The hub tab speaks a generic WebSocket protocol (defined in `hub-ws.ts` in the extension package) and has no knowledge of MCP. See the hub's protocol docs for message format details.
|
||||
|
||||
## Architecture
|
||||
|
||||
Pure JS ESM, no build step. Source files are the published artifacts.
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.js # CLI entry: MCP server (stdio) + opens launcher
|
||||
├── hub-bridge.js # HTTP server + WebSocket bridge to hub tab
|
||||
└── launcher.html # Bootstrap page: detects extension, triggers hub open
|
||||
```
|
||||
|
||||
## Dev
|
||||
|
||||
```bash
|
||||
npm run dev:ext
|
||||
npx @modelcontextprotocol/inspector node packages/mcp/src/index.js
|
||||
```
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@page-agent/mcp",
|
||||
"private": false,
|
||||
"version": "1.8.1",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"page-agent-mcp": "src/index.js"
|
||||
},
|
||||
"files": [
|
||||
"src/"
|
||||
],
|
||||
"description": "MCP server for controlling the browser via Page Agent extension",
|
||||
"keywords": [
|
||||
"page-agent",
|
||||
"mcp",
|
||||
"browser-automation",
|
||||
"chrome-extension"
|
||||
],
|
||||
"author": "Simon<gaomeng1900>",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/alibaba/page-agent.git",
|
||||
"directory": "packages/mcp"
|
||||
},
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync } from 'node:fs'
|
||||
import http from 'node:http'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { WebSocketServer } from 'ws'
|
||||
|
||||
const EXT_ID = 'akldabonmimlicnjlflnapfeklbfemhj'
|
||||
const STORE_URL = `https://chromewebstore.google.com/detail/page-agent-ext/${EXT_ID}`
|
||||
const LOOPBACK_HOST = 'localhost'
|
||||
|
||||
const launcherTemplate = readFileSync(
|
||||
fileURLToPath(new URL('./launcher.html', import.meta.url)),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
/**
|
||||
* HTTP + WebSocket bridge to the hub.html extension tab.
|
||||
* - HTTP serves the launcher page (triggers extension to open hub)
|
||||
* - WS carries execute/stop commands and result/error responses
|
||||
*/
|
||||
export class HubBridge {
|
||||
/** @type {number} */
|
||||
port
|
||||
|
||||
/** @type {http.Server} */
|
||||
#httpServer
|
||||
|
||||
/** @type {WebSocketServer} */
|
||||
#wss
|
||||
|
||||
/** @type {import('ws').WebSocket | null} */
|
||||
#hub = null
|
||||
|
||||
/** @type {{ resolve: (r: {success: boolean, data: string}) => void, reject: (e: Error) => void } | null} */
|
||||
#pendingTask = null
|
||||
|
||||
/** @param {number} port */
|
||||
constructor(port) {
|
||||
this.port = port
|
||||
this.#httpServer = http.createServer((_req, res) => {
|
||||
const html = launcherTemplate
|
||||
.replaceAll('__EXT_ID__', EXT_ID)
|
||||
.replaceAll('__STORE_URL__', STORE_URL)
|
||||
.replaceAll('__WS_PORT__', String(port))
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
||||
res.end(html)
|
||||
})
|
||||
this.#wss = new WebSocketServer({ server: this.#httpServer })
|
||||
this.#wss.on('connection', (ws) => this.#onConnection(ws))
|
||||
}
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
async start() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#httpServer.on('error', (/** @type {NodeJS.ErrnoException} */ err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
reject(
|
||||
new Error(`Port ${this.port} is in use. Another Page Agent MCP server may be running.`)
|
||||
)
|
||||
} else {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
this.#httpServer.listen(this.port, LOOPBACK_HOST, () => {
|
||||
console.error(`[page-agent-mcp] HTTP + WS on http://${LOOPBACK_HOST}:${this.port}`)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
get connected() {
|
||||
return this.#hub?.readyState === 1
|
||||
}
|
||||
|
||||
get busy() {
|
||||
return this.#pendingTask !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} task
|
||||
* @param {Record<string, unknown>} [config]
|
||||
* @returns {Promise<{success: boolean, data: string}>}
|
||||
*/
|
||||
async executeTask(task, config) {
|
||||
if (!this.connected) throw new Error('Hub is not connected. Is the extension running?')
|
||||
if (this.#pendingTask) throw new Error('Agent is already running a task.')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#pendingTask = { resolve, reject }
|
||||
this.#hub.send(JSON.stringify({ type: 'execute', task, config }))
|
||||
})
|
||||
}
|
||||
|
||||
stopTask() {
|
||||
if (this.connected) {
|
||||
this.#hub.send(JSON.stringify({ type: 'stop' }))
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Add version checking
|
||||
|
||||
/** @param {import('ws').WebSocket} ws */
|
||||
#onConnection(ws) {
|
||||
if (this.#hub && this.#hub.readyState === 1) {
|
||||
ws.close(4000, 'Another hub is already connected')
|
||||
return
|
||||
}
|
||||
|
||||
this.#hub = ws
|
||||
console.error('[page-agent-mcp] Hub connected')
|
||||
|
||||
ws.on('message', (/** @type {Buffer} */ rawData) => {
|
||||
/** @type {{ type: string, success?: boolean, data?: string, message?: string }} */
|
||||
let msg
|
||||
try {
|
||||
msg = JSON.parse(rawData.toString('utf-8'))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === 'result') {
|
||||
this.#pendingTask?.resolve({ success: msg.success ?? false, data: msg.data ?? '' })
|
||||
this.#pendingTask = null
|
||||
} else if (msg.type === 'error') {
|
||||
this.#pendingTask?.reject(new Error(msg.message ?? 'Unknown error from hub'))
|
||||
this.#pendingTask = null
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('close', () => {
|
||||
console.error('[page-agent-mcp] Hub disconnected')
|
||||
if (this.#hub === ws) this.#hub = null
|
||||
if (this.#pendingTask) {
|
||||
this.#pendingTask.reject(new Error('Hub disconnected while task was running'))
|
||||
this.#pendingTask = null
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Executable
+100
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env node
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
import { exec } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { platform } from 'node:os'
|
||||
import * as z from 'zod/v4'
|
||||
|
||||
import { HubBridge } from './hub-bridge.js'
|
||||
|
||||
const env = process.env
|
||||
const port = parseInt(env.PORT || '38401')
|
||||
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const llmConfig = {}
|
||||
if (env.LLM_BASE_URL) llmConfig.baseURL = env.LLM_BASE_URL
|
||||
if (env.LLM_MODEL_NAME) llmConfig.model = env.LLM_MODEL_NAME
|
||||
if (env.LLM_API_KEY) llmConfig.apiKey = env.LLM_API_KEY
|
||||
|
||||
// --- Hub bridge (HTTP + WebSocket) ---
|
||||
|
||||
const hub = new HubBridge(port)
|
||||
await hub.start()
|
||||
|
||||
// Open launcher in default browser
|
||||
const url = `http://localhost:${port}`
|
||||
const cmd = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'start ""' : 'xdg-open'
|
||||
exec(`${cmd} "${url}"`, (err) => {
|
||||
if (err) console.error(`[page-agent-mcp] Could not open browser: ${err.message}`)
|
||||
})
|
||||
|
||||
// --- MCP server (stdio) ---
|
||||
|
||||
const mcpServer = new McpServer({ name: 'page-agent', version })
|
||||
|
||||
mcpServer.registerTool(
|
||||
'execute_task',
|
||||
{
|
||||
description: "Execute a task in user's browser.",
|
||||
inputSchema: {
|
||||
task: z
|
||||
.string()
|
||||
.describe(
|
||||
'Task description. Give specific instructions for the task. Steps preferable. And the information you want to get after the task is done.'
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ task }) => {
|
||||
try {
|
||||
const config = Object.keys(llmConfig).length > 0 ? llmConfig : undefined
|
||||
const result = await hub.executeTask(task, config)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: result.success
|
||||
? `Task completed.\n\n${result.data}`
|
||||
: `Task failed.\n\n${result.data}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mcpServer.registerTool(
|
||||
'get_status',
|
||||
{
|
||||
description: 'Check the current status of the Page Agent hub.',
|
||||
},
|
||||
async () => ({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ connected: hub.connected, busy: hub.busy }, null, 2),
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
mcpServer.registerTool(
|
||||
'stop_task',
|
||||
{
|
||||
description: 'Stop the currently running browser automation task.',
|
||||
},
|
||||
async () => {
|
||||
hub.stopTask()
|
||||
return { content: [{ type: 'text', text: 'Stop signal sent.' }] }
|
||||
}
|
||||
)
|
||||
|
||||
const transport = new StdioServerTransport()
|
||||
await mcpServer.connect(transport)
|
||||
console.error('[page-agent-mcp] MCP server ready (stdio)')
|
||||
@@ -0,0 +1,245 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="https://img.alicdn.com/imgextra/i1/O1CN01mRGret1QrKiu7CFJI_!!6000000002029-2-tps-64-64.png"
|
||||
/>
|
||||
<title>Page Agent MCP Launcher</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
background: #09090b;
|
||||
color: #e5e5e5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.card {
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
.logo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 18px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.badge {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: #52525b;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.sub {
|
||||
font-size: 0.875rem;
|
||||
color: #a1a1aa;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #27272a;
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
a {
|
||||
color: #60a5fa;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.install {
|
||||
display: none;
|
||||
}
|
||||
.install.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin-top: 1.75rem;
|
||||
text-align: left;
|
||||
background: #18181b;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.tips li {
|
||||
font-size: 0.8125rem;
|
||||
color: #a1a1aa;
|
||||
line-height: 1.7;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
.tips li + li {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.store-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
margin-top: 1.5rem;
|
||||
padding: 0.625rem 1.5rem;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.store-btn:hover {
|
||||
background: #1d4ed8;
|
||||
text-decoration: none;
|
||||
}
|
||||
.store-btn img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.links {
|
||||
margin-top: 1.75rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.links a {
|
||||
color: #71717a;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.links a:hover {
|
||||
color: #a1a1aa;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<img
|
||||
class="logo"
|
||||
src="https://img.alicdn.com/imgextra/i3/O1CN01JPT4Fj1FJTfmHfNxO_!!6000000000466-49-tps-512-512.webp"
|
||||
alt="Page Agent"
|
||||
/>
|
||||
<div class="badge">Page Agent MCP Launcher</div>
|
||||
|
||||
<div id="connecting">
|
||||
<div class="spinner"></div>
|
||||
<h1 data-i18n="connecting_title">Connecting to Page Agent</h1>
|
||||
<p class="sub" data-i18n="connecting_sub">Opening the hub in your browser…</p>
|
||||
</div>
|
||||
|
||||
<div id="install" class="install">
|
||||
<h1 data-i18n="install_title">Extension Required</h1>
|
||||
<p class="sub" data-i18n="install_sub">
|
||||
Page Agent requires the latest browser extension to work.
|
||||
</p>
|
||||
|
||||
<a class="store-btn" href="__STORE_URL__" target="_blank">
|
||||
<img
|
||||
src="https://img.alicdn.com/imgextra/i3/O1CN01JpW0Vo1sR3FpiZKFM_!!6000000005762-55-tps-192-192.svg"
|
||||
alt=""
|
||||
/>
|
||||
<span data-i18n="install_btn">Install from Chrome Web Store</span>
|
||||
</a>
|
||||
|
||||
<ul class="tips">
|
||||
<li data-i18n="tip_outdated">
|
||||
If the extension is outdated, please update it to the latest version.
|
||||
</li>
|
||||
<li data-i18n="tip_other_browser">
|
||||
If the extension is not installed in this browser, open this page from the browser that
|
||||
has it installed.
|
||||
</li>
|
||||
<li data-i18n="tip_refresh">Refresh this page after installing or updating.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="links">
|
||||
<a
|
||||
href="https://alibaba.github.io/page-agent/docs/introduction/overview"
|
||||
target="_blank"
|
||||
data-i18n="link_docs"
|
||||
>Docs</a
|
||||
>
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent/issues"
|
||||
target="_blank"
|
||||
data-i18n="link_issues"
|
||||
>Report an Issue</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{
|
||||
const EXT_ID = '__EXT_ID__'
|
||||
const wsPort = __WS_PORT__
|
||||
|
||||
const zh = {
|
||||
connecting_title: '正在连接 Page Agent',
|
||||
connecting_sub: '正在浏览器中打开 Hub…',
|
||||
install_title: '需要安装浏览器插件',
|
||||
install_sub: 'Page Agent 需要安装最新版浏览器插件才能运行。',
|
||||
install_btn: '从 Chrome 应用商店安装',
|
||||
tip_outdated: '如果插件版本过旧,请更新到最新版本。',
|
||||
tip_other_browser: '如果该浏览器中未安装插件,请从装有插件的浏览器打开此页面。',
|
||||
tip_refresh: '安装或更新后,请刷新此页面。',
|
||||
link_docs: '文档',
|
||||
link_issues: '问题反馈',
|
||||
}
|
||||
|
||||
if (/^zh\b/i.test(navigator.language)) {
|
||||
document.querySelectorAll('[data-i18n]').forEach((el) => {
|
||||
const key = el.getAttribute('data-i18n')
|
||||
if (zh[key]) el.textContent = zh[key]
|
||||
})
|
||||
}
|
||||
|
||||
const showInstall = () => {
|
||||
document.getElementById('connecting').style.display = 'none'
|
||||
document.getElementById('install').classList.add('show')
|
||||
}
|
||||
|
||||
try {
|
||||
if (!globalThis.chrome?.runtime?.sendMessage) {
|
||||
showInstall()
|
||||
} else {
|
||||
chrome.runtime.sendMessage(EXT_ID, { type: 'OPEN_HUB', wsPort }, (response) => {
|
||||
if (chrome.runtime.lastError || !response?.ok) showInstall()
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
showInstall()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,16 +1,26 @@
|
||||
{
|
||||
"name": "page-agent",
|
||||
"private": false,
|
||||
"version": "1.5.8",
|
||||
"version": "1.8.1",
|
||||
"type": "module",
|
||||
"main": "./dist/esm/page-agent.js",
|
||||
"module": "./dist/esm/page-agent.js",
|
||||
"types": "./dist/esm/PageAgent.d.ts",
|
||||
"main": "./src/PageAgent.ts",
|
||||
"types": "./src/PageAgent.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/esm/PageAgent.d.ts",
|
||||
"import": "./dist/esm/page-agent.js",
|
||||
"default": "./dist/esm/page-agent.js"
|
||||
"types": "./src/PageAgent.ts",
|
||||
"default": "./src/PageAgent.ts"
|
||||
}
|
||||
},
|
||||
"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": [
|
||||
@@ -40,20 +50,20 @@
|
||||
"build": "vite build && npm run build:demo",
|
||||
"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\"",
|
||||
"prepublishOnly": "node -e \"const fs=require('fs');['README.md','LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
||||
"postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||
"prepublishOnly": "node ../../scripts/pre-publish.js",
|
||||
"postpublish": "node ../../scripts/post-publish.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@page-agent/core": "1.5.8",
|
||||
"@page-agent/llms": "1.5.8",
|
||||
"@page-agent/page-controller": "1.5.8",
|
||||
"@page-agent/ui": "1.5.8",
|
||||
"@page-agent/core": "1.8.1",
|
||||
"@page-agent/llms": "1.8.1",
|
||||
"@page-agent/page-controller": "1.8.1",
|
||||
"@page-agent/ui": "1.8.1",
|
||||
"chalk": "^5.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"zod": "^4.3.5"
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
*/
|
||||
import { type AgentConfig, PageAgentCore } from '@page-agent/core'
|
||||
import { PageController, type PageControllerConfig } from '@page-agent/page-controller'
|
||||
import { Panel } from '@page-agent/ui'
|
||||
import { Panel, type PanelConfig } from '@page-agent/ui'
|
||||
|
||||
export * from '@page-agent/core'
|
||||
|
||||
export type PageAgentConfig = AgentConfig & PageControllerConfig
|
||||
export type PageAgentConfig = AgentConfig & PageControllerConfig & Omit<PanelConfig, 'language'>
|
||||
|
||||
export class PageAgent extends PageAgentCore {
|
||||
panel: Panel
|
||||
@@ -23,6 +23,7 @@ export class PageAgent extends PageAgentCore {
|
||||
|
||||
this.panel = new Panel(this, {
|
||||
language: config.language,
|
||||
promptForNextTask: config.promptForNextTask,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,10 @@ const DEMO_MODEL = 'qwen3.5-plus'
|
||||
const DEMO_BASE_URL = 'https://page-ag-testing-ohftxirgbn.cn-shanghai.fcapp.run'
|
||||
const DEMO_API_KEY = 'NA'
|
||||
|
||||
const currentScript = document.currentScript as HTMLScriptElement | null
|
||||
|
||||
// in case document.x is not ready yet
|
||||
setTimeout(() => {
|
||||
const currentScript = document.currentScript as HTMLScriptElement | null
|
||||
let config: PageAgentConfig
|
||||
|
||||
if (currentScript) {
|
||||
|
||||
@@ -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",
|
||||
"compilerOptions": {
|
||||
"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"]
|
||||
}
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"],
|
||||
"references": [
|
||||
//
|
||||
{ "path": "../llms" },
|
||||
{ "path": "../page-controller" },
|
||||
{ "path": "../core" },
|
||||
{ "path": "../ui" }
|
||||
]
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
|
||||
@@ -11,13 +11,18 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
export default defineConfig({
|
||||
clearScreen: false,
|
||||
plugins: [
|
||||
dts({ tsconfigPath: './tsconfig.dts.json', bundleTypes: true }),
|
||||
dts({
|
||||
bundleTypes: true,
|
||||
compilerOptions: {
|
||||
composite: true,
|
||||
noEmit: false,
|
||||
emitDeclarationOnly: true,
|
||||
declaration: true,
|
||||
},
|
||||
}),
|
||||
cssInjectedByJsPlugin({ relativeCSSInjection: true }),
|
||||
],
|
||||
publicDir: false,
|
||||
esbuild: {
|
||||
keepNames: true,
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/PageAgent.ts'),
|
||||
|
||||
@@ -9,7 +9,7 @@ import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// Load .env from repo root
|
||||
dotenvConfig({ path: resolve(__dirname, '../../.env') })
|
||||
dotenvConfig({ path: resolve(__dirname, '../../.env'), quiet: true })
|
||||
|
||||
// UMD Bundle for CDN
|
||||
// - alias all local packages so that they can be build in
|
||||
@@ -21,17 +21,6 @@ export default defineConfig(() => ({
|
||||
// analyzer()
|
||||
],
|
||||
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: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/demo.ts'),
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
{
|
||||
"name": "@page-agent/page-controller",
|
||||
"version": "1.5.8",
|
||||
"version": "1.8.1",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-controller.js",
|
||||
"module": "./dist/lib/page-controller.js",
|
||||
"types": "./dist/lib/PageController.d.ts",
|
||||
"main": "./src/PageController.ts",
|
||||
"types": "./src/PageController.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/lib/PageController.d.ts",
|
||||
"import": "./dist/lib/page-controller.js",
|
||||
"default": "./dist/lib/page-controller.js"
|
||||
"types": "./src/PageController.ts",
|
||||
"default": "./src/PageController.ts"
|
||||
}
|
||||
},
|
||||
"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": [
|
||||
@@ -32,8 +42,8 @@
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
||||
"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": {
|
||||
"ai-motion": "^0.4.8"
|
||||
|
||||
@@ -18,6 +18,7 @@ import * as dom from './dom'
|
||||
import type { FlatDomTree, InteractiveElementDomNode } from './dom/dom_tree/type'
|
||||
import { getPageInfo } from './dom/getPageInfo'
|
||||
import { patchReact } from './patches/react'
|
||||
import { isAnchorElement } from './utils'
|
||||
|
||||
/**
|
||||
* Configuration for PageController
|
||||
@@ -184,7 +185,7 @@ export class PageController extends EventTarget {
|
||||
|
||||
const blacklist = [
|
||||
...(this.config.interactiveBlacklist || []),
|
||||
...document.querySelectorAll('[data-page-agent-not-interactive]').values(),
|
||||
...Array.from(document.querySelectorAll('[data-page-agent-not-interactive]')),
|
||||
]
|
||||
|
||||
this.flatTree = dom.getFlatTree({
|
||||
@@ -192,7 +193,11 @@ export class PageController extends EventTarget {
|
||||
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 = dom.getSelectorMap(this.flatTree)
|
||||
@@ -217,6 +222,7 @@ export class PageController extends EventTarget {
|
||||
* Clean up all element highlights
|
||||
*/
|
||||
async cleanUpHighlights(): Promise<void> {
|
||||
console.log('[PageController] cleanUpHighlights')
|
||||
dom.cleanUpHighlights()
|
||||
}
|
||||
|
||||
@@ -243,7 +249,7 @@ export class PageController extends EventTarget {
|
||||
await clickElement(element)
|
||||
|
||||
// Handle links that open in new tabs
|
||||
if (element instanceof HTMLAnchorElement && element.target === '_blank') {
|
||||
if (isAnchorElement(element) && element.target === '_blank') {
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Clicked element (${elemText ?? index}). ⚠️ Link opened in a new tab.`,
|
||||
@@ -320,11 +326,11 @@ export class PageController extends EventTarget {
|
||||
|
||||
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 message = await scrollVertically(down, scrollAmount, element)
|
||||
const message = await scrollVertically(scrollAmount, element)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -355,7 +361,7 @@ export class PageController extends EventTarget {
|
||||
|
||||
const element = index !== undefined ? getElementByIndex(this.selectorMap, index) : null
|
||||
|
||||
const message = await scrollHorizontally(right, scrollAmount, element)
|
||||
const message = await scrollHorizontally(scrollAmount, element)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -423,3 +429,5 @@ export class PageController extends EventTarget {
|
||||
this.mask = null
|
||||
}
|
||||
}
|
||||
|
||||
export * from './actions'
|
||||
|
||||
@@ -3,27 +3,22 @@
|
||||
* All rights reserved.
|
||||
*/
|
||||
import type { InteractiveElementDomNode } from './dom/dom_tree/type'
|
||||
|
||||
// ======= general utils =======
|
||||
|
||||
async function waitFor(seconds: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
|
||||
}
|
||||
|
||||
// ======= dom utils =======
|
||||
|
||||
export async function movePointerToElement(element: HTMLElement) {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const x = rect.left + rect.width / 2
|
||||
const y = rect.top + rect.height / 2
|
||||
|
||||
window.dispatchEvent(new CustomEvent('PageAgent::MovePointerTo', { detail: { x, y } }))
|
||||
|
||||
await waitFor(0.3)
|
||||
}
|
||||
import {
|
||||
clickPointer,
|
||||
disablePassThrough,
|
||||
enablePassThrough,
|
||||
getNativeValueSetter,
|
||||
isHTMLElement,
|
||||
isInputElement,
|
||||
isSelectElement,
|
||||
isTextAreaElement,
|
||||
movePointerToElement,
|
||||
waitFor,
|
||||
} from './utils'
|
||||
|
||||
/**
|
||||
* Get the HTMLElement by index from a selectorMap.
|
||||
* @private Internal method, subject to change at any time.
|
||||
*/
|
||||
export function getElementByIndex(
|
||||
selectorMap: Map<number, InteractiveElementDomNode>,
|
||||
@@ -39,7 +34,7 @@ export function getElementByIndex(
|
||||
throw new Error(`Element at index ${index} does not have a reference`)
|
||||
}
|
||||
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
if (!isHTMLElement(element)) {
|
||||
throw new Error(`Element at index ${index} is not an HTMLElement`)
|
||||
}
|
||||
|
||||
@@ -50,64 +45,92 @@ let lastClickedElement: HTMLElement | null = null
|
||||
|
||||
function blurLastClickedElement() {
|
||||
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.dispatchEvent(
|
||||
new MouseEvent('mouseout', { bubbles: true, cancelable: true })
|
||||
)
|
||||
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.
|
||||
*/
|
||||
export async function clickElement(element: HTMLElement) {
|
||||
blurLastClickedElement()
|
||||
|
||||
lastClickedElement = element
|
||||
|
||||
await scrollIntoViewIfNeeded(element)
|
||||
await movePointerToElement(element)
|
||||
window.dispatchEvent(new CustomEvent('PageAgent::ClickPointer'))
|
||||
const frame = element.ownerDocument.defaultView?.frameElement
|
||||
if (frame) await scrollIntoViewIfNeeded(frame)
|
||||
|
||||
const rect = element.getBoundingClientRect()
|
||||
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)
|
||||
|
||||
// hover it
|
||||
element.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true, cancelable: true }))
|
||||
element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true }))
|
||||
// Hit-test to find the deepest element at click coordinates, matching
|
||||
// real browser behavior where events target the innermost element.
|
||||
// @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
|
||||
element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }))
|
||||
const pointerOpts = {
|
||||
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
|
||||
element.focus()
|
||||
// Hover — pointer events first, then mouse events (spec order)
|
||||
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 }))
|
||||
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||
// Press
|
||||
target.dispatchEvent(new PointerEvent('pointerdown', pointerOpts))
|
||||
target.dispatchEvent(new MouseEvent('mousedown', mouseOpts))
|
||||
|
||||
// dispatch a click event
|
||||
// element.click()
|
||||
// Focus is not part of the standard pointer/mouse event sequence
|
||||
// "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)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value'
|
||||
)!.set!
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
'value'
|
||||
)!.set!
|
||||
|
||||
/**
|
||||
* @private Internal method, subject to change at any time.
|
||||
*/
|
||||
export async function inputTextElement(element: HTMLElement, text: string) {
|
||||
const isContentEditable = element.isContentEditable
|
||||
if (
|
||||
!(element instanceof HTMLInputElement) &&
|
||||
!(element instanceof HTMLTextAreaElement) &&
|
||||
!isContentEditable
|
||||
) {
|
||||
if (!isInputElement(element) && !isTextAreaElement(element) && !isContentEditable) {
|
||||
throw new Error('Element is not an input, textarea, or contenteditable')
|
||||
}
|
||||
|
||||
@@ -119,9 +142,12 @@ export async function inputTextElement(element: HTMLElement, text: string) {
|
||||
// - Monaco/CodeMirror: Require direct JS instance access. No universal way to obtain.
|
||||
// - Draft.js: Not responsive to synthetic/execCommand/Range/DataTransfer. Unmaintained.
|
||||
//
|
||||
// Strategy: Try Plan A (synthetic events) first, then verify and fall back
|
||||
// to Plan B (execCommand) if the text wasn't actually inserted.
|
||||
//
|
||||
// Plan A: Dispatch synthetic events
|
||||
// Works: LinkedIn, React contenteditable, Quill.
|
||||
// Fails: Slate.js
|
||||
// Works: React contenteditable, Quill.
|
||||
// Fails: Slate.js, some contenteditable editors that ignore synthetic events.
|
||||
// Sequence: beforeinput -> mutation -> input -> change -> blur
|
||||
|
||||
// Dispatch beforeinput + mutation + input for clearing
|
||||
@@ -164,22 +190,37 @@ export async function inputTextElement(element: HTMLElement, text: string) {
|
||||
)
|
||||
}
|
||||
|
||||
// Verify Plan A worked by checking if the text was actually inserted
|
||||
const planASucceeded = element.innerText.trim() === text.trim()
|
||||
|
||||
if (!planASucceeded) {
|
||||
// Plan B: execCommand fallback (deprecated but widely supported)
|
||||
// Works: Quill, Slate.js, react contenteditable components.
|
||||
// This approach integrates with the browser's undo stack and is handled
|
||||
// natively by most rich-text editors.
|
||||
element.focus()
|
||||
|
||||
// Select all existing content and delete it
|
||||
const doc = element.ownerDocument
|
||||
const selection = (doc.defaultView || window).getSelection()
|
||||
const range = doc.createRange()
|
||||
range.selectNodeContents(element)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
doc.execCommand('delete', false)
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
doc.execCommand('insertText', false, text)
|
||||
}
|
||||
|
||||
// Dispatch change event (for good measure)
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
|
||||
// Trigger blur for validation
|
||||
element.blur()
|
||||
|
||||
// Plan B: execCommand (deprecated but works better for some editors)
|
||||
// Works: LinkedIn, Quill, Slate.js, react contenteditable components
|
||||
//
|
||||
// document.execCommand('selectAll')
|
||||
// document.execCommand('delete')
|
||||
// document.execCommand('insertText', false, text)
|
||||
} else if (element instanceof HTMLTextAreaElement) {
|
||||
nativeTextAreaValueSetter.call(element, text)
|
||||
} else {
|
||||
nativeInputValueSetter.call(element, text)
|
||||
getNativeValueSetter(element as HTMLInputElement | HTMLTextAreaElement).call(element, text)
|
||||
}
|
||||
|
||||
// Only dispatch shared input event for non-contenteditable (contenteditable has its own)
|
||||
@@ -194,9 +235,10 @@ export async function inputTextElement(element: HTMLElement, text: string) {
|
||||
|
||||
/**
|
||||
* @todo browser-use version is very complex and supports menu tags, need to follow up
|
||||
* @private Internal method, subject to change at any time.
|
||||
*/
|
||||
export async function selectOptionElement(selectElement: HTMLSelectElement, optionText: string) {
|
||||
if (!(selectElement instanceof HTMLSelectElement)) {
|
||||
if (!isSelectElement(selectElement)) {
|
||||
throw new Error('Element is not a select element')
|
||||
}
|
||||
|
||||
@@ -213,11 +255,14 @@ export async function selectOptionElement(selectElement: HTMLSelectElement, opti
|
||||
await waitFor(0.1) // Wait to ensure change event processing completes
|
||||
}
|
||||
|
||||
interface ScrollableElement extends HTMLElement {
|
||||
interface ScrollableElement extends Element {
|
||||
scrollIntoViewIfNeeded?: (centerIfNeeded?: boolean) => void
|
||||
}
|
||||
|
||||
export async function scrollIntoViewIfNeeded(element: HTMLElement) {
|
||||
/**
|
||||
* @private Internal method, subject to change at any time.
|
||||
*/
|
||||
export async function scrollIntoViewIfNeeded(element: Element) {
|
||||
const el = element as ScrollableElement
|
||||
if (typeof el.scrollIntoViewIfNeeded === 'function') {
|
||||
el.scrollIntoViewIfNeeded()
|
||||
@@ -229,11 +274,7 @@ export async function scrollIntoViewIfNeeded(element: HTMLElement) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrollVertically(
|
||||
down: boolean,
|
||||
scroll_amount: number,
|
||||
element?: HTMLElement | null
|
||||
) {
|
||||
export async function scrollVertically(scroll_amount: number, element?: HTMLElement | null) {
|
||||
// Element-specific scrolling if element is provided
|
||||
if (element) {
|
||||
const targetElement = element
|
||||
@@ -246,7 +287,10 @@ export async function scrollVertically(
|
||||
|
||||
while (currentElement && attempts < 10) {
|
||||
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
|
||||
|
||||
if (hasScrollableY && canScrollVertically) {
|
||||
@@ -298,9 +342,20 @@ export async function scrollVertically(
|
||||
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
|
||||
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
|
||||
: Array.from(document.querySelectorAll<HTMLElement>('*')).find(canScroll) ||
|
||||
@@ -331,6 +386,10 @@ export async function scrollVertically(
|
||||
return `✅ Scrolled page by ${scrolled}px.`
|
||||
} else {
|
||||
// Container scroll
|
||||
|
||||
const warningMsg = `The document is not scrollable. Falling back to container scroll.`
|
||||
console.log(`[PageController] ${warningMsg}`)
|
||||
|
||||
const scrollBefore = el!.scrollTop
|
||||
const scrollMax = el!.scrollHeight - el!.clientHeight
|
||||
|
||||
@@ -342,26 +401,22 @@ export async function scrollVertically(
|
||||
|
||||
if (Math.abs(scrolled) < 1) {
|
||||
return dy > 0
|
||||
? `⚠️ 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 bottom of container (${el!.tagName}), cannot scroll down further.`
|
||||
: `⚠️ ${warningMsg} Already at the top of container (${el!.tagName}), cannot scroll up further.`
|
||||
}
|
||||
|
||||
const reachedBottom = dy > 0 && scrollAfter >= scrollMax - 1
|
||||
const reachedTop = dy < 0 && scrollAfter <= 1
|
||||
|
||||
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)
|
||||
return `✅ 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. Reached the top.`
|
||||
return `✅ ${warningMsg} Scrolled container (${el!.tagName}) by ${scrolled}px.`
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrollHorizontally(
|
||||
right: boolean,
|
||||
scroll_amount: number,
|
||||
element?: HTMLElement | null
|
||||
) {
|
||||
export async function scrollHorizontally(scroll_amount: number, element?: HTMLElement | null) {
|
||||
// Element-specific scrolling if element is provided
|
||||
if (element) {
|
||||
const targetElement = element
|
||||
@@ -370,11 +425,14 @@ export async function scrollHorizontally(
|
||||
let scrolledElement: HTMLElement | null = null
|
||||
let scrollDelta = 0
|
||||
let attempts = 0
|
||||
const dx = right ? scroll_amount : -scroll_amount
|
||||
const dx = scroll_amount
|
||||
|
||||
while (currentElement && attempts < 10) {
|
||||
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
|
||||
|
||||
if (hasScrollableX && canScrollHorizontally) {
|
||||
@@ -418,7 +476,8 @@ export async function scrollHorizontally(
|
||||
|
||||
// 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 canScroll = (el: HTMLElement | null) =>
|
||||
el &&
|
||||
@@ -426,6 +485,9 @@ export async function scrollHorizontally(
|
||||
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
|
||||
while (el && !canScroll(el) && el !== document.body) el = el.parentElement
|
||||
|
||||
@@ -460,6 +522,9 @@ export async function scrollHorizontally(
|
||||
return `✅ Scrolled page horizontally by ${scrolled}px.`
|
||||
} else {
|
||||
// Container scroll
|
||||
const warningMsg = `The document is not scrollable. Falling back to container scroll.`
|
||||
console.log(`[PageController] ${warningMsg}`)
|
||||
|
||||
const scrollBefore = el!.scrollLeft
|
||||
const scrollMax = el!.scrollWidth - el!.clientWidth
|
||||
|
||||
@@ -471,17 +536,17 @@ export async function scrollHorizontally(
|
||||
|
||||
if (Math.abs(scrolled) < 1) {
|
||||
return dx > 0
|
||||
? `⚠️ 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 right edge of container (${el!.tagName}), cannot scroll right further.`
|
||||
: `⚠️ ${warningMsg} Already at the left edge of container (${el!.tagName}), cannot scroll left further.`
|
||||
}
|
||||
|
||||
const reachedRight = dx > 0 && scrollAfter >= scrollMax - 1
|
||||
const reachedLeft = dx < 0 && scrollAfter <= 1
|
||||
|
||||
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)
|
||||
return `✅ 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}) by ${scrolled}px. Reached the left edge.`
|
||||
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
|
||||
@@ -18,6 +18,7 @@
|
||||
* @edit improve `sampleRect`, filter out rects with 0 area
|
||||
* @edit exclude aria-hidden elements
|
||||
* @edit make sure attributes exist for interactive candidates.
|
||||
* @edit fix "aria-*" attributes check
|
||||
*/
|
||||
|
||||
export default (
|
||||
@@ -502,11 +503,16 @@ export default (
|
||||
const overflowX = style.overflowX
|
||||
const overflowY = style.overflowY
|
||||
|
||||
// Check scrollable distances
|
||||
// scrollbar-width/scrollbar-gutter are only set on elements designed to scroll;
|
||||
// their presence signals scroll intent even when overflow is hidden (e.g. overflow: auto on :hover)
|
||||
const hasScrollbarSignal =
|
||||
(style.scrollbarWidth && style.scrollbarWidth !== 'auto') ||
|
||||
(style.scrollbarGutter && style.scrollbarGutter !== 'auto')
|
||||
|
||||
const scrollableX = overflowX === 'auto' || overflowX === 'scroll'
|
||||
const scrollableY = overflowY === 'auto' || overflowY === 'scroll'
|
||||
|
||||
if (!scrollableX && !scrollableY) {
|
||||
if (!scrollableX && !scrollableY && !hasScrollbarSignal) {
|
||||
return null // Not scrollable in any direction
|
||||
}
|
||||
|
||||
@@ -520,11 +526,11 @@ export default (
|
||||
return null // Not scrollable
|
||||
}
|
||||
|
||||
if (!scrollableY && scrollWidth < threshold) {
|
||||
if (!scrollableY && !hasScrollbarSignal && scrollWidth < threshold) {
|
||||
return null // Not scrollable horizontally
|
||||
}
|
||||
|
||||
if (!scrollableX && scrollHeight < threshold) {
|
||||
if (!scrollableX && !hasScrollbarSignal && scrollHeight < threshold) {
|
||||
return null // Not scrollable vertically
|
||||
}
|
||||
|
||||
@@ -546,6 +552,8 @@ export default (
|
||||
scrollData: scrollData,
|
||||
})
|
||||
|
||||
console.log('scrollData!!!', scrollData)
|
||||
|
||||
return scrollData
|
||||
}
|
||||
|
||||
@@ -1143,6 +1151,31 @@ export default (
|
||||
* @param {HTMLElement} element - The element to check.
|
||||
* @returns {boolean} Whether the element is an interactive candidate.
|
||||
*/
|
||||
|
||||
// @edit fix "aria-*" attributes check
|
||||
const INTERACTIVE_ARIA_ATTRS = [
|
||||
'aria-expanded',
|
||||
'aria-checked',
|
||||
'aria-selected',
|
||||
'aria-pressed',
|
||||
'aria-haspopup',
|
||||
'aria-controls',
|
||||
'aria-owns',
|
||||
'aria-activedescendant',
|
||||
'aria-valuenow',
|
||||
'aria-valuetext',
|
||||
'aria-valuemax',
|
||||
'aria-valuemin',
|
||||
'aria-autocomplete',
|
||||
]
|
||||
|
||||
function hasInteractiveAria(el) {
|
||||
for (let i = 0; i < INTERACTIVE_ARIA_ATTRS.length; i++) {
|
||||
if (el.hasAttribute(INTERACTIVE_ARIA_ATTRS[i])) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isInteractiveCandidate(element) {
|
||||
if (!element || element.nodeType !== Node.ELEMENT_NODE) return false
|
||||
|
||||
@@ -1167,7 +1200,7 @@ export default (
|
||||
element.hasAttribute('onclick') ||
|
||||
element.hasAttribute('role') ||
|
||||
element.hasAttribute('tabindex') ||
|
||||
element.hasAttribute('aria-') ||
|
||||
hasInteractiveAria(element) ||
|
||||
element.hasAttribute('data-action') ||
|
||||
element.getAttribute('contenteditable') === 'true'
|
||||
|
||||
@@ -1185,8 +1218,9 @@ export default (
|
||||
'details',
|
||||
'label',
|
||||
'option',
|
||||
'li',
|
||||
])
|
||||
const INTERACTIVE_ROLES = new Set([
|
||||
const DISTINCT_INTERACTIVE_ROLES = new Set([
|
||||
'button',
|
||||
'link',
|
||||
'menuitem',
|
||||
@@ -1202,6 +1236,9 @@ export default (
|
||||
'searchbox',
|
||||
'textbox',
|
||||
'listbox',
|
||||
'listitem',
|
||||
'treeitem',
|
||||
'row',
|
||||
'option',
|
||||
'scrollbar',
|
||||
])
|
||||
@@ -1278,7 +1315,7 @@ export default (
|
||||
return true
|
||||
}
|
||||
// Check interactive roles
|
||||
if (role && INTERACTIVE_ROLES.has(role)) {
|
||||
if (role && DISTINCT_INTERACTIVE_ROLES.has(role)) {
|
||||
return true
|
||||
}
|
||||
// Check contenteditable
|
||||
@@ -1297,6 +1334,10 @@ export default (
|
||||
if (element.hasAttribute('onclick') || typeof element.onclick === 'function') {
|
||||
return true
|
||||
}
|
||||
// ARIA state attributes imply the element manages its own interaction state
|
||||
if (hasInteractiveAria(element)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// return false
|
||||
|
||||
@@ -1352,6 +1393,12 @@ export default (
|
||||
return true
|
||||
}
|
||||
|
||||
// Scrollable containers are always distinct — the LLM needs their index for targeted scrolling.
|
||||
// Check extraData (already set by isScrollableElement in isInteractiveElement) to avoid redundant layout reads.
|
||||
if (extraData.get(element)?.scrollable) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Default to false: if it's interactive but doesn't match above,
|
||||
// assume it triggers the same action as the parent.
|
||||
return false
|
||||
|
||||
@@ -28,8 +28,27 @@ export interface DomConfig {
|
||||
includeAttributes?: string[]
|
||||
highlightOpacity?: number
|
||||
highlightLabelOpacity?: number
|
||||
|
||||
/**
|
||||
* Preserve semantic landmark tags in dehydrated output even if not interactive
|
||||
* @note maybe confusing for LLM combining with page scrolling, use with caution
|
||||
**/
|
||||
keepSemanticTags?: boolean
|
||||
}
|
||||
|
||||
// TODO: corresponding roles
|
||||
const SEMANTIC_TAGS = new Set([
|
||||
'nav',
|
||||
'menu',
|
||||
// 'main',
|
||||
'header',
|
||||
'footer',
|
||||
'aside',
|
||||
// 'article',
|
||||
// 'form',
|
||||
'dialog',
|
||||
])
|
||||
|
||||
/**
|
||||
* 用于检测可交互元素是否是新出现的。
|
||||
*/
|
||||
@@ -171,7 +190,11 @@ interface TreeNode {
|
||||
*
|
||||
* @todo 数据脱敏过滤器
|
||||
*/
|
||||
export function flatTreeToString(flatTree: FlatDomTree, includeAttributes?: string[]): string {
|
||||
export function flatTreeToString(
|
||||
flatTree: FlatDomTree,
|
||||
includeAttributes: string[] = [],
|
||||
keepSemanticTags = false
|
||||
): string {
|
||||
const DEFAULT_INCLUDE_ATTRIBUTES = [
|
||||
'title',
|
||||
'type',
|
||||
@@ -203,7 +226,7 @@ export function flatTreeToString(flatTree: FlatDomTree, includeAttributes?: stri
|
||||
'contenteditable',
|
||||
]
|
||||
|
||||
const includeAttrs = [...(includeAttributes || []), ...DEFAULT_INCLUDE_ATTRIBUTES]
|
||||
const includeAttrs = [...includeAttributes, ...DEFAULT_INCLUDE_ATTRIBUTES]
|
||||
|
||||
// Helper function to cap text length
|
||||
const capTextLength = (text: string, maxLength: number): string => {
|
||||
@@ -294,6 +317,8 @@ export function flatTreeToString(flatTree: FlatDomTree, includeAttributes?: stri
|
||||
const depthStr = '\t'.repeat(depth)
|
||||
|
||||
if (node.type === 'element') {
|
||||
const isSemantic = keepSemanticTags && node.tagName && SEMANTIC_TAGS.has(node.tagName)
|
||||
|
||||
// Add element with highlight_index
|
||||
if (node.highlightIndex !== undefined) {
|
||||
nextDepth += 1
|
||||
@@ -391,10 +416,30 @@ export function flatTreeToString(flatTree: FlatDomTree, includeAttributes?: stri
|
||||
result.push(line)
|
||||
}
|
||||
|
||||
// Process children regardless
|
||||
// special treatment for semantic tags
|
||||
// even if they are not interactive, we can keep them for clear context
|
||||
|
||||
const emitSemantic = isSemantic && node.highlightIndex === undefined
|
||||
// to check if this tag is empty
|
||||
const mark = emitSemantic ? result.length : -1
|
||||
|
||||
if (emitSemantic) {
|
||||
result.push(`${depthStr}<${node.tagName}>`)
|
||||
nextDepth += 1
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
processNode(child, nextDepth, result)
|
||||
}
|
||||
|
||||
if (emitSemantic) {
|
||||
// empty tag should be removed
|
||||
if (result.length === mark + 1) {
|
||||
result.pop()
|
||||
} else {
|
||||
result.push(`${depthStr}</${node.tagName}>`)
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'text') {
|
||||
// Add text only if it doesn't have a highlighted parent
|
||||
if (hasParentWithHighlightIndex(node)) {
|
||||
|
||||
@@ -5,11 +5,13 @@ import { isPageDark } from './checkDarkMode'
|
||||
import styles from './SimulatorMask.module.css'
|
||||
import cursorStyles from './cursor.module.css'
|
||||
|
||||
export class SimulatorMask {
|
||||
export class SimulatorMask extends EventTarget {
|
||||
shown: boolean = false
|
||||
wrapper = document.createElement('div')
|
||||
motion: Motion | null = null
|
||||
|
||||
#disposed = false
|
||||
|
||||
#cursor = document.createElement('div')
|
||||
|
||||
#currentCursorX = 0
|
||||
@@ -19,6 +21,8 @@ export class SimulatorMask {
|
||||
#targetCursorY = 0
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.wrapper.id = 'page-agent-runtime_simulator-mask'
|
||||
this.wrapper.className = styles.wrapper
|
||||
this.wrapper.setAttribute('data-browser-use-ignore', 'true')
|
||||
@@ -74,13 +78,34 @@ export class SimulatorMask {
|
||||
|
||||
this.#moveCursorToTarget()
|
||||
|
||||
window.addEventListener('PageAgent::MovePointerTo', (event: Event) => {
|
||||
// global events
|
||||
// @note Mask should be isolated from the rest of the code.
|
||||
// Global events are easier to manage and cleanup.
|
||||
|
||||
const movePointerToListener = (event: Event) => {
|
||||
const { x, y } = (event as CustomEvent).detail
|
||||
this.setCursorPosition(x, y)
|
||||
})
|
||||
|
||||
window.addEventListener('PageAgent::ClickPointer', (event: Event) => {
|
||||
}
|
||||
const clickPointerListener = () => {
|
||||
this.triggerClickAnimation()
|
||||
}
|
||||
const enablePassThroughListener = () => {
|
||||
this.wrapper.style.pointerEvents = 'none'
|
||||
}
|
||||
const disablePassThroughListener = () => {
|
||||
this.wrapper.style.pointerEvents = 'auto'
|
||||
}
|
||||
|
||||
window.addEventListener('PageAgent::MovePointerTo', movePointerToListener)
|
||||
window.addEventListener('PageAgent::ClickPointer', clickPointerListener)
|
||||
window.addEventListener('PageAgent::EnablePassThrough', enablePassThroughListener)
|
||||
window.addEventListener('PageAgent::DisablePassThrough', disablePassThroughListener)
|
||||
|
||||
this.addEventListener('dispose', () => {
|
||||
window.removeEventListener('PageAgent::MovePointerTo', movePointerToListener)
|
||||
window.removeEventListener('PageAgent::ClickPointer', clickPointerListener)
|
||||
window.removeEventListener('PageAgent::EnablePassThrough', enablePassThroughListener)
|
||||
window.removeEventListener('PageAgent::DisablePassThrough', disablePassThroughListener)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -106,6 +131,8 @@ export class SimulatorMask {
|
||||
}
|
||||
|
||||
#moveCursorToTarget() {
|
||||
if (this.#disposed) return
|
||||
|
||||
const newX = this.#currentCursorX + (this.#targetCursorX - this.#currentCursorX) * 0.2
|
||||
const newY = this.#currentCursorY + (this.#targetCursorY - this.#currentCursorY) * 0.2
|
||||
|
||||
@@ -133,11 +160,15 @@ export class SimulatorMask {
|
||||
}
|
||||
|
||||
setCursorPosition(x: number, y: number) {
|
||||
if (this.#disposed) return
|
||||
|
||||
this.#targetCursorX = x
|
||||
this.#targetCursorY = y
|
||||
}
|
||||
|
||||
triggerClickAnimation() {
|
||||
if (this.#disposed) return
|
||||
|
||||
this.#cursor.classList.remove(cursorStyles.clicking)
|
||||
// Force reflow to restart animation
|
||||
void this.#cursor.offsetHeight
|
||||
@@ -145,7 +176,7 @@ export class SimulatorMask {
|
||||
}
|
||||
|
||||
show() {
|
||||
if (this.shown) return
|
||||
if (this.shown || this.#disposed) return
|
||||
|
||||
this.shown = true
|
||||
this.motion?.start()
|
||||
@@ -163,7 +194,7 @@ export class SimulatorMask {
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (!this.shown) return
|
||||
if (!this.shown || this.#disposed) return
|
||||
|
||||
this.shown = false
|
||||
this.motion?.fadeOut()
|
||||
@@ -177,7 +208,10 @@ export class SimulatorMask {
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.#disposed = true
|
||||
console.log('dispose SimulatorMask')
|
||||
this.motion?.dispose()
|
||||
this.wrapper.remove()
|
||||
this.dispatchEvent(new Event('dispose'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// ======= type guards =======
|
||||
// @note instanceof fails for elements inside iframes
|
||||
|
||||
export function isHTMLElement(el: unknown): el is HTMLElement {
|
||||
// @todo either specify to HTMLElement or allow Element here.
|
||||
return !!el && (el as Node).nodeType === 1
|
||||
}
|
||||
|
||||
export function isInputElement(el: Element): el is HTMLInputElement {
|
||||
return el?.nodeType === 1 && el.tagName === 'INPUT'
|
||||
}
|
||||
|
||||
export function isTextAreaElement(el: Element): el is HTMLTextAreaElement {
|
||||
return el?.nodeType === 1 && el.tagName === 'TEXTAREA'
|
||||
}
|
||||
|
||||
export function isSelectElement(el: Element): el is HTMLSelectElement {
|
||||
return el?.nodeType === 1 && el.tagName === 'SELECT'
|
||||
}
|
||||
|
||||
export function isAnchorElement(el: Element): el is HTMLAnchorElement {
|
||||
return el?.nodeType === 1 && el.tagName === 'A'
|
||||
}
|
||||
|
||||
// ======= iframe helpers =======
|
||||
|
||||
/** Iframe offset for translating element coordinates to top-frame viewport. */
|
||||
export function getIframeOffset(element: HTMLElement): { x: number; y: number } {
|
||||
const frame = element.ownerDocument.defaultView?.frameElement as HTMLElement | null
|
||||
if (!frame) return { x: 0, y: 0 }
|
||||
const rect = frame.getBoundingClientRect()
|
||||
return { x: rect.left, y: rect.top }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get native value setter from the element's own prototype (iframe-safe).
|
||||
* @note for React
|
||||
*/
|
||||
export function getNativeValueSetter(element: HTMLInputElement | HTMLTextAreaElement) {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
return Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element) as object, 'value')!
|
||||
.set as (v: string) => void
|
||||
}
|
||||
|
||||
// ======= general utils =======
|
||||
|
||||
export async function waitFor(seconds: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
|
||||
}
|
||||
|
||||
// ======= mask events =======
|
||||
|
||||
/**
|
||||
* Move the visual pointer to a position within an element.
|
||||
* @param x - x coordinate in the element's document viewport
|
||||
* @param y - y coordinate in the element's document viewport
|
||||
*/
|
||||
export async function movePointerToElement(element: HTMLElement, x: number, y: number) {
|
||||
const offset = getIframeOffset(element)
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('PageAgent::MovePointerTo', {
|
||||
detail: { x: x + offset.x, y: y + offset.y },
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(0.3)
|
||||
}
|
||||
|
||||
export async function clickPointer() {
|
||||
window.dispatchEvent(new CustomEvent('PageAgent::ClickPointer'))
|
||||
}
|
||||
|
||||
export async function enablePassThrough() {
|
||||
window.dispatchEvent(new CustomEvent('PageAgent::EnablePassThrough'))
|
||||
}
|
||||
|
||||
export async function disablePassThrough() {
|
||||
window.dispatchEvent(new CustomEvent('PageAgent::DisablePassThrough'))
|
||||
}
|
||||
@@ -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,11 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||
"noEmit": false,
|
||||
"allowImportingTsExtensions": false,
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist"
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.js"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
|
||||
@@ -13,13 +13,18 @@ console.log(chalk.cyan(`📦 Building @page-agent/page-controller`))
|
||||
export default defineConfig({
|
||||
clearScreen: false,
|
||||
plugins: [
|
||||
dts({ tsconfigPath: './tsconfig.dts.json', bundleTypes: true }),
|
||||
dts({
|
||||
bundleTypes: true,
|
||||
compilerOptions: {
|
||||
composite: true,
|
||||
noEmit: false,
|
||||
emitDeclarationOnly: true,
|
||||
declaration: true,
|
||||
},
|
||||
}),
|
||||
cssInjectedByJsPlugin({ relativeCSSInjection: true }),
|
||||
],
|
||||
publicDir: false,
|
||||
esbuild: {
|
||||
keepNames: true,
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/PageController.ts'),
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
{
|
||||
"name": "@page-agent/ui",
|
||||
"version": "1.5.8",
|
||||
"version": "1.8.1",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-agent-ui.js",
|
||||
"module": "./dist/lib/page-agent-ui.js",
|
||||
"types": "./dist/lib/index.d.ts",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/lib/index.d.ts",
|
||||
"import": "./dist/lib/page-agent-ui.js",
|
||||
"default": "./dist/lib/page-agent-ui.js"
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"main": "./dist/lib/page-agent-ui.js",
|
||||
"module": "./dist/lib/page-agent-ui.js",
|
||||
"types": "./dist/lib/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/lib/index.d.ts",
|
||||
"import": "./dist/lib/page-agent-ui.js",
|
||||
"default": "./dist/lib/page-agent-ui.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
@@ -32,7 +42,7 @@
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
||||
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||
"prepublishOnly": "node ../../scripts/pre-publish.js",
|
||||
"postpublish": "node ../../scripts/post-publish.js"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user