Compare commits
213 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 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 | |||
| 3d014f15e7 | |||
| ce8f0fdbd8 | |||
| 48fede38fd | |||
| f303ed5fc5 | |||
| 6dbc33efd9 | |||
| 53628532df | |||
| 2aae34d74a | |||
| e27d1fb57f | |||
| 8ad4d19232 | |||
| 0fa7dc0531 | |||
| 89546887bd | |||
| b0bcba2922 | |||
| 618ae8bf14 | |||
| 5ed5d574fd | |||
| bde9d9023a | |||
| c8a8858d24 | |||
| 6491118cde | |||
| b4acd02007 | |||
| 5695b3d6aa | |||
| 0a160697d1 | |||
| 11d3ca3d6e | |||
| ee88b23891 | |||
| ca68be7a56 | |||
| 4ad2abe997 | |||
| 8f08322721 | |||
| fb043e5a81 | |||
| 1628d48c97 | |||
| 2f92a9cb32 | |||
| 025cb3391e | |||
| 4aab7f991f | |||
| 67846db28f | |||
| cac55fa2bf | |||
| a74b7542ba | |||
| a9f462ed37 | |||
| c33b3d8278 | |||
| de3a6e4660 | |||
| e2c00b1bfc | |||
| 692ac4acca | |||
| c4bdd8fc4b | |||
| aa88221845 | |||
| b425e48afe | |||
| ff54b363aa | |||
| 4d931b4430 | |||
| 6054ca1217 | |||
| 7f9f0d1589 | |||
| ddcfa5b499 | |||
| 441b41c713 | |||
| ed71dd08b9 | |||
| 1c354ab5d3 | |||
| 151760713a | |||
| 4450d6302b | |||
| 2d055d3909 | |||
| 16da7d936d | |||
| efe08f445f | |||
| 4e7f755ae9 | |||
| 28bb2204e7 | |||
| 0c3b4592b2 | |||
| 222bbef670 | |||
| fa8ab451eb | |||
| b4ebb2b98f | |||
| c757270101 | |||
| 5873e68d63 | |||
| 0c124c3b44 | |||
| d46a57f8ef | |||
| 153fa23a71 | |||
| 5852054e3a | |||
| 5988d4ba24 | |||
| 0b6a698f6b | |||
| 01db520881 | |||
| 30b9956c4f | |||
| 4b37a3e538 | |||
| 928d8d2fb3 | |||
| 8ece20f54b | |||
| 26b4afca6a | |||
| 5f43b76b57 | |||
| 15d609aebc | |||
| d66bf8f3cf | |||
| d3b42036af | |||
| e448a43769 | |||
| 349de7bf48 | |||
| eec601e6b2 | |||
| 5b280a4a67 | |||
| 46deaf373f | |||
| e098d5701f | |||
| 21d4be0020 | |||
| 3f6a1856ac | |||
| 816e24a102 | |||
| 80e2a93a8c | |||
| 2f8096883f | |||
| 088f002583 | |||
| 27e9e78636 | |||
| a0c979602e | |||
| 3dc9edb8b5 | |||
| 32b68ab7c8 | |||
| 75a61c05c1 | |||
| bdfa98358b | |||
| dc8b38ccb9 | |||
| e1c288aaeb | |||
| 7a97de2a37 | |||
| 19b03b83ea | |||
| 598b144d06 | |||
| 1cd8a8de60 | |||
| 9beffca985 | |||
| 53db2069ce | |||
| 121104e13d | |||
| 0dacbda9da | |||
| 8bc27414a4 | |||
| 76509d93f0 | |||
| ff31b0c03e | |||
| 6b2ab73d65 | |||
| 2754023a80 | |||
| fa5c65db53 | |||
| 53c2c19d69 | |||
| e5437b445a | |||
| 09bdf9ddaf | |||
| b58d2a09ef | |||
| ce442742ba | |||
| 632f22962e | |||
| 72c3b12fb4 | |||
| 4807d550a3 | |||
| f9722f0619 | |||
| e1557a5d2e | |||
| 06280c2ba5 | |||
| 645a7ceb52 | |||
| a47c72ce84 | |||
| dcc6cd1cf3 | |||
| b4b9f6ce2d | |||
| 14e31e6ec4 | |||
| 69f2478fd7 | |||
| 5fec6846b0 | |||
| fe792f1ceb | |||
| d1d27a76de | |||
| 3e7e99420a |
@@ -23,3 +23,4 @@ Closes #(issue)
|
||||
## Requirements / 要求
|
||||
|
||||
- [ ] I have read and follow the [Code of Conduct](../docs/CODE_OF_CONDUCT.md) and [Contributing Guide](../CONTRIBUTING.md) . / 我已阅读并遵守行为准则。
|
||||
- [ ] This PR is NOT generated by a bot or AI agent acting autonomously. I have authored or meaningfully reviewed every change. / 此 PR 不是由 bot 或 AI 自主生成的,我已亲自编写或充分审查了每一处变更。
|
||||
|
||||
Vendored
+1
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"contenteditable",
|
||||
"deepseek",
|
||||
"historychange",
|
||||
"HITL",
|
||||
|
||||
+22
-188
@@ -1,41 +1,14 @@
|
||||
# Contributing to Page-Agent
|
||||
# Contributing to PageAgent
|
||||
|
||||
Thank you for your interest in contributing to Page-Agent! We welcome contributions from everyone.
|
||||
♥️ We welcome contributions from everyone.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. **Prerequisites**
|
||||
- `node.js >= 20` with `npm >= 10`
|
||||
- An editor that supports `ts/eslint/prettier`
|
||||
- Make sure `eslint`, `prettier` and `commitlint` work well
|
||||
|
||||
2. **Setup**
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm start # Start demo and documentation site
|
||||
```
|
||||
|
||||
### 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 to leave deps out.
|
||||
- When bundling `IIFE` and `Website`. Bundle everything including local packages.
|
||||
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
|
||||
@@ -44,170 +17,31 @@ We use a simplified monorepo solution with `native npm-workspace + ts reference
|
||||
|
||||
### Code Contributions
|
||||
|
||||
1. **Fork and Clone**
|
||||
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
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-username/page-agent.git
|
||||
cd page-agent
|
||||
```
|
||||
### Vibe Coding with AI
|
||||
|
||||
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**
|
||||
- Test in our demo website
|
||||
- Test it on other websites if applicable
|
||||
- `@TODO: test suite`
|
||||
|
||||
5. **Commit and Push**
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: add awesome feature"
|
||||
git push origin feat/your-feature-name
|
||||
```
|
||||
|
||||
6. **Create Pull Request**
|
||||
- Provide clear description of changes
|
||||
- Link related issues
|
||||
- Include screenshots for UI changes
|
||||
|
||||
## 📝 Code Style
|
||||
|
||||
### General Guidelines
|
||||
|
||||
- Use TypeScript for type safety
|
||||
- Follow existing naming conventions
|
||||
- Write meaningful commit messages
|
||||
- Keep functions small and focused
|
||||
- Add JSDoc for public APIs
|
||||
|
||||
### Vibe coding with AI
|
||||
|
||||
- It's **recommended** to heavily rely on AI (aka "vibe coding") when maintaining **demo pages and tests**.
|
||||
- Be very careful if AI ever touched the core lib!!!
|
||||
- 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**.
|
||||
- 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.
|
||||
- Update the [AI instructions](AGENTS.md) when structure changed.
|
||||
|
||||
If your lame 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"
|
||||
```
|
||||
|
||||
> ⚠️ Add `*` to `OLLAMA_ORIGINS` (403). Models < 10B unlikely strong enough. Requires tool_call support. Set context length > 15k (default 4k will NOT work): `$env:OLLAMA_CONTEXT_LENGTH=64000; ollama serve`
|
||||
|
||||
- Restart the dev server to load new env vars
|
||||
- If not provided, the demo will the free testing proxy by default
|
||||
|
||||
### Website Development
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
### Extension Development
|
||||
|
||||
```bash
|
||||
npm run dev -w @page-agent/ext
|
||||
npm run zip -w @page-agent/ext
|
||||
```
|
||||
|
||||
- Load extension in Chrome via `chrome://extensions` -> **Load unpacked**
|
||||
- Use `packages/extension/docs/extension_api.md` (EN) or `packages/extension/docs/extension_api_zh.md` (ZH) 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.
|
||||
|
||||
### 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.
|
||||
|
||||
## 🎯 Contribution Areas
|
||||
|
||||
We especially welcome contributions in:
|
||||
|
||||
- **Browser compatibility** improvements
|
||||
- **Performance optimizations** for DOM processing
|
||||
- **Documentation** and examples
|
||||
- **Testing** and quality assurance
|
||||
- **Accessibility** features
|
||||
- **Internationalization** support
|
||||
|
||||
## 🚫 What We Don't Accept
|
||||
|
||||
- Changes that break existing API compatibility (Discuss first)
|
||||
- Heavy dependencies to core library
|
||||
- Contributions without proper testing
|
||||
- Code that doesn't follow project conventions
|
||||
- Breaking changes and large PRs without prior discussion
|
||||
- Heavy dependencies to core libs
|
||||
- Dependencies or code with licenses incompatible with MIT
|
||||
- Bot or AI-generated pull requests without meaningful human involvement
|
||||
|
||||
## 📄 Legal
|
||||
|
||||
By contributing to this project, you agree that your contributions will be licensed under the MIT License.
|
||||
|
||||
> You may need to sign a github CLA before you create a PR.
|
||||
---
|
||||
|
||||
## 💬 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 Page-Agent better! 🎉
|
||||
Thank you for helping make PageAgent better! 🎉
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Alibaba
|
||||
Copyright (c) 2026 Simon
|
||||
Copyright (c) 2026 SimonLuvRamen
|
||||
Copyright (c) 2026 Alibaba Group Holding Limited
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
<img alt="Page Agent Banner" src="https://img.alicdn.com/imgextra/i1/O1CN01NCMKXj1Gn4tkFTsxf_!!6000000000666-2-tps-1280-256.png">
|
||||
</picture>
|
||||
|
||||
[](https://opensource.org/licenses/MIT) [](http://www.typescriptlang.org/) [](https://www.npmjs.com/package/page-agent) [](https://bundlephobia.com/package/page-agent) [](https://github.com/alibaba/page-agent)
|
||||
[](https://opensource.org/licenses/MIT) [](http://www.typescriptlang.org/) [](https://bundlephobia.com/package/page-agent) [](https://www.npmjs.com/package/page-agent) [](https://github.com/alibaba/page-agent)
|
||||
|
||||
The GUI Agent Living in Your Webpage. Control web interfaces with natural language.
|
||||
|
||||
🌐 **English** | [中文](./docs/README-zh.md)
|
||||
|
||||
👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank"><b>📖 Documentation</b></a>
|
||||
<a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank"><b>📖 Docs</b></a> | <a href="https://news.ycombinator.com/item?id=47264138" target="_blank"><b>📢 HN Discussion</b></a> | <a href="https://x.com/simonluvramen" target="_blank"><b>𝕏 Follow on X</b></a>
|
||||
|
||||
<video id="demo-video" src="https://github.com/user-attachments/assets/11aed429-b69c-49d5-8982-fa99b4a0e9a8" controls crossorigin muted></video>
|
||||
<video id="demo-video" src="https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2" controls crossorigin muted></video>
|
||||
|
||||
---
|
||||
|
||||
@@ -22,20 +22,19 @@ The GUI Agent Living in Your Webpage. Control web interfaces with natural langua
|
||||
- **🎯 Easy integration**
|
||||
- No need for `browser extension` / `python` / `headless browser`.
|
||||
- Just in-page javascript. Everything happens in your web page.
|
||||
- The best tool for your agent to control web pages.
|
||||
- **📖 Text-based DOM manipulation**
|
||||
- No screenshots. No OCR or multi-modal LLMs needed.
|
||||
- No special permissions required.
|
||||
- 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 needed.
|
||||
- **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 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
|
||||
|
||||
@@ -47,12 +46,12 @@ Fastest way to try PageAgent with our free Demo LLM:
|
||||
<script src="{URL}" crossorigin="true"></script>
|
||||
```
|
||||
|
||||
> **⚠️ For technical evaluation only.** This demo CDN uses our free [testing LLM API](https://alibaba.github.io/page-agent/docs/features/models#free-testing-api). By using it, you agree to its [terms](https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md).
|
||||
|
||||
| Mirrors | URL |
|
||||
| ------- | ---------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.4.0/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.4.0/files/dist/iife/page-agent.demo.js |
|
||||
|
||||
> **⚠️ For technical evaluation only.** This demo CDN uses our free [testing LLM API](https://alibaba.github.io/page-agent/docs/features/models#free-testing-api). By using it, you agree to its [terms](https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md).
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.6.3/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.6.3/files/dist/iife/page-agent.demo.js |
|
||||
|
||||
### NPM Installation
|
||||
|
||||
@@ -77,9 +76,15 @@ For more programmatic usage, see [📖 Documentations](https://alibaba.github.io
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions from the community! Follow our instructions in [CONTRIBUTING.md](CONTRIBUTING.md) for environment setup and local development.
|
||||
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 AI** without substantial human involvement will **not be accepted**.
|
||||
|
||||
## ⚖️ License
|
||||
|
||||
[MIT License](LICENSE)
|
||||
|
||||
## 👏 Acknowledgments
|
||||
|
||||
@@ -90,23 +95,24 @@ This project builds upon the excellent work of **[`browser-use`](https://github.
|
||||
```
|
||||
DOM processing components and prompt are derived from browser-use:
|
||||
|
||||
Browser Use
|
||||
Browser Use <https://github.com/browser-use/browser-use>
|
||||
Copyright (c) 2024 Gregor Zunic
|
||||
Licensed under the MIT License
|
||||
|
||||
Original browser-use project: <https://github.com/browser-use/browser-use>
|
||||
|
||||
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
|
||||
## 🌟 Awesome Page Agent
|
||||
|
||||
[MIT License](LICENSE)
|
||||
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) 🙌 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+82
-35
@@ -5,6 +5,88 @@ 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.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
|
||||
|
||||
- **`data-browser-use-ignore` → `data-page-agent-ignore`** - DOM ignore attribute renamed to match the project identity
|
||||
- **Config types restructured** - `PageAgentConfig` split into `AgentConfig` + `PageAgentCoreConfig`; config definitions moved from `config/index.ts` to `types.ts`
|
||||
- **Zod v3/v4 dual support** - Libraries now accept both `zod@^3.25` and `zod@^4.0` as peer dependencies
|
||||
|
||||
### Features
|
||||
|
||||
- **Experimental `llms.txt` support** - Agent can fetch and include a site's `llms.txt` in context. Enable via `experimentalLlmsTxt: true`
|
||||
|
||||
### Improvements
|
||||
|
||||
- Default `maxSteps` changed from 20 to 40 for better for complex tasks out of the box
|
||||
- Added 400ms wait between agent steps for page reactions
|
||||
- Increased click wait time (100ms → 200ms) for more reliable interactions
|
||||
- Removed debug `console.log` statements from scroll actions
|
||||
- Reset observations on new task start
|
||||
- Improved logging across packages
|
||||
|
||||
### Extension v0.1.9
|
||||
|
||||
> PageAgent 1.5.1
|
||||
|
||||
- **Advanced config panel** - New collapsible section exposing Max Steps, System Instruction, and experimental `llms.txt` toggle
|
||||
- Streamlined User Auth Token description
|
||||
- Moved testing API notice below auth token section
|
||||
|
||||
---
|
||||
|
||||
## [1.4.0] - 2026-02-27
|
||||
|
||||
### Features
|
||||
|
||||
- Update Terms of Use and Privacy Policy
|
||||
- **Robust tool-call validation** - Action inputs are now validated against tool schemas individually, producing clear error messages (e.g. `Invalid input for action "click_element_by_index"`) instead of unreadable union parse errors
|
||||
- **Primitive action input coercion** - Small models that output `{"click_element_by_index": 2}` instead of `{"click_element_by_index": {"index": 2}}` are now auto-corrected using tool schemas
|
||||
- **Qwen model updates** - Added `qwen3.5-plus` as the default free testing model; disabled `enable_thinking` for Qwen models to avoid incompatible responses
|
||||
- **Updated default LLM endpoint** - Migrated demo and extension to a new testing endpoint with legacy endpoint auto-migration
|
||||
|
||||
### Improvements
|
||||
|
||||
- Unified zod imports (`* as z`) across all packages for consistency
|
||||
- Better Zod error formatting with `z.prettifyError()` in LLM client
|
||||
- Exported `InvokeError` and `InvokeErrorType` as values (not just types) from `@page-agent/llms`
|
||||
- Exported `SupportedLanguage` type from `@page-agent/core`
|
||||
|
||||
### Extension v0.1.8
|
||||
|
||||
- **Language setting** - Added language selector (System / English / 中文) in config panel
|
||||
- **UI makeover** - New empty state with breathing glow and typing animation; ai-motion glow overlay while running; refined focus styles
|
||||
- **Testing endpoint notice** - Shows terms of use notice when using the free testing API
|
||||
- **Legacy endpoint migration** - Auto-migrates old Supabase testing endpoint to new endpoint on startup
|
||||
|
||||
---
|
||||
|
||||
## [1.3.0] - 2026-02-13
|
||||
|
||||
### Breaking Changes
|
||||
@@ -96,41 +178,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
|
||||
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
-27
@@ -5,37 +5,35 @@
|
||||
<img alt="Page Agent Banner" src="https://img.alicdn.com/imgextra/i1/O1CN01NCMKXj1Gn4tkFTsxf_!!6000000000666-2-tps-1280-256.png">
|
||||
</picture>
|
||||
|
||||
[](https://opensource.org/licenses/MIT) [](http://www.typescriptlang.org/) [](https://www.npmjs.com/package/page-agent) [](https://bundlephobia.com/package/page-agent) [](https://github.com/alibaba/page-agent)
|
||||
[](https://opensource.org/licenses/MIT) [](http://www.typescriptlang.org/) [](https://bundlephobia.com/package/page-agent) [](https://www.npmjs.com/package/page-agent) [](https://github.com/alibaba/page-agent)
|
||||
|
||||
纯 JS 实现的 GUI agent。使用自然语言操作你的 Web 应用。无须后端、客户端、浏览器插件。
|
||||
|
||||
🌐 [English](../README.md) | **中文**
|
||||
|
||||
👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank"><b>📖 Documentation</b></a>
|
||||
<a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank"><b>📖 Docs</b></a> | <a href="https://news.ycombinator.com/item?id=47264138" target="_blank"><b>📢 HN Discussion</b></a> | <a href="https://x.com/simonluvramen" target="_blank"><b>𝕏 Follow on X</b></a>
|
||||
|
||||
<video id="demo-video" src="https://github.com/user-attachments/assets/11aed429-b69c-49d5-8982-fa99b4a0e9a8" controls crossorigin muted></video>
|
||||
<video id="demo-video" src="https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2" controls crossorigin muted></video>
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🎯 轻松集成**
|
||||
- 无需 `浏览器插件` / `Python` / `无头浏览器`。
|
||||
- 纯页面内 JavaScript,一切都在你的网页中完成。
|
||||
- The best tool for your agent to control web pages.
|
||||
- 无需 `浏览器插件` / `Python` / `无头浏览器`,纯页面内 JavaScript
|
||||
- **📖 基于文本的 DOM 操作**
|
||||
- 无需截图,无需 OCR 或多模态模型。
|
||||
- 无需特殊权限。
|
||||
- **🧠 用你自己的 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 加入浏览器控制能力。
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
@@ -47,12 +45,12 @@
|
||||
<script src="{URL}" crossorigin="true"></script>
|
||||
```
|
||||
|
||||
> **⚠️ 仅用于技术评估。** 该 Demo CDN 使用了免费的[测试 LLM API](https://alibaba.github.io/page-agent/docs/features/models#free-testing-api),使用即表示您同意其[条款](https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md)。
|
||||
|
||||
| Mirrors | URL |
|
||||
| ------- | ---------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.4.0/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.4.0/files/dist/iife/page-agent.demo.js |
|
||||
|
||||
> **⚠️ 仅用于技术评估。** 该 Demo CDN 使用了免费的[测试 LLM API](https://alibaba.github.io/page-agent/docs/features/models#free-testing-api),使用即表示您同意其[条款](https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md)。
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.6.3/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.6.3/files/dist/iife/page-agent.demo.js |
|
||||
|
||||
### NPM 安装
|
||||
|
||||
@@ -77,11 +75,13 @@ await agent.execute('点击登录按钮')
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎社区贡献!请参阅 [CONTRIBUTING.md](../CONTRIBUTING.md) 了解环境配置和本地开发说明。
|
||||
欢迎社区贡献!请参阅 [CONTRIBUTING.md](../CONTRIBUTING.md) 了解安装与贡献指南。
|
||||
|
||||
请在贡献前阅读[行为准则](CODE_OF_CONDUCT.md)。
|
||||
提交 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)** 的优秀工作构建。
|
||||
|
||||
@@ -90,24 +90,20 @@ await agent.execute('点击登录按钮')
|
||||
```
|
||||
DOM processing components and prompt are derived from browser-use:
|
||||
|
||||
Browser Use
|
||||
Browser Use <https://github.com/browser-use/browser-use>
|
||||
Copyright (c) 2024 Gregor Zunic
|
||||
Licensed under the MIT License
|
||||
|
||||
Original browser-use project: <https://github.com/browser-use/browser-use>
|
||||
|
||||
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 有用或有趣,请给项目点个星!**
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We provide security fixes on a best-effort basis for:
|
||||
|
||||
| Version | Supported |
|
||||
| --------------------------------------------------------- | --------- |
|
||||
| `main` | Yes |
|
||||
| Latest npm release of `page-agent` and workspace packages | Yes |
|
||||
| Older releases | No |
|
||||
|
||||
Please upgrade to the latest release before reporting an issue against an older build.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.
|
||||
|
||||
Use GitHub's private vulnerability reporting flow:
|
||||
|
||||
- Open https://github.com/alibaba/page-agent/security/policy
|
||||
- Click `Report a vulnerability`
|
||||
|
||||
If private reporting is unavailable, open a minimal public issue only to request a private contact channel. Do not include exploit details.
|
||||
|
||||
## What to Include
|
||||
|
||||
- Affected package or feature
|
||||
- Exact version, commit, or build
|
||||
- Browser, OS, and runtime environment
|
||||
- Reproduction steps or a proof of concept
|
||||
- Expected impact
|
||||
|
||||
## Scope
|
||||
|
||||
We prioritize reports that show a real security boundary failure, such as:
|
||||
|
||||
- Unauthorized access to data, tokens, or extension capabilities
|
||||
- Bypassing explicit safety constraints
|
||||
- Sensitive data exposure caused by default behavior
|
||||
|
||||
The following usually do not qualify by themselves:
|
||||
|
||||
- Unsafe custom integrations that ignore documented safeguards
|
||||
- Intentionally embedding secrets into client-side builds
|
||||
- Reports against unsupported older versions
|
||||
|
||||
## Disclosure
|
||||
|
||||
Please avoid public disclosure until maintainers have had a reasonable chance to investigate and ship a fix.
|
||||
@@ -0,0 +1,117 @@
|
||||
# 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 >= 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.
|
||||
|
||||
## 🤖 AGENTS.md Alias
|
||||
|
||||
If your AI assistant does not support [AGENTS.md](https://agents.md/). Add an alias for it:
|
||||
|
||||
- claude-code (`CLAUDE.md`)
|
||||
|
||||
```markdown
|
||||
@AGENTS.md
|
||||
```
|
||||
|
||||
- antigravity (`.agent/rules/alias.md`)
|
||||
|
||||
```markdown
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
@../../AGENTS.md
|
||||
```
|
||||
|
||||
## 🔧 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
|
||||
# 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.
|
||||
+22
-20
@@ -1,42 +1,44 @@
|
||||
# Terms of Use & Privacy
|
||||
|
||||
**Last updated:** February 2026
|
||||
**Last updated:** March 2026
|
||||
|
||||
"We" in this document refers to the maintainers of the open-source Page Agent project (https://github.com/alibaba/page-agent). This document covers the Page Agent software itself and the testing services we provide — **not** any third-party product or service built with it.
|
||||
"We" in this document refers to the maintainers of the open-source Page Agent project (https://github.com/alibaba/page-agent). "The software" refers to Page Agent (the JavaScript library) and Page Agent Ext (the browser extension). This document covers the software itself and the testing API we provide — **not** any third-party product or service built with it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Open Source Software Privacy
|
||||
|
||||
Page Agent (PageAgent.js and Page Agent Extension) is a **client-side only** tool with a "Bring Your Own Key" (BYOK) architecture. The software itself does **not** include any backend service. The software does **not** collect or transmit any user data on its own, and the maintainers do **not** have access to your browsing activity, page content, or task instructions through the software.
|
||||
The software is a **client-side only** tool with a "Bring Your Own Key" (BYOK) architecture. The software itself does **not** include any backend service. The software does **not** collect or transmit any user data on its own, and we do **not** have access to your browsing activity, page content, or task instructions through the software.
|
||||
|
||||
All data transmission occurs **only** between your browser and the LLM provider you configure. You are in full control of which provider receives your data.
|
||||
|
||||
- You choose which LLM provider to use
|
||||
- You may configure your own API endpoint at any time
|
||||
- The project is open source and can be audited: https://github.com/alibaba/page-agent
|
||||
The project is open source under the [MIT License](https://github.com/alibaba/page-agent/blob/main/LICENSE) and can be audited at: https://github.com/alibaba/page-agent
|
||||
|
||||
---
|
||||
|
||||
## 2. Testing API and Demo Disclaimer & Terms of Use
|
||||
|
||||
To facilitate easy testing and technical evaluation, we provide a free testing LLM API. This API is used in the project homepage's live demo, the pre-built demo CDN bundles, and the browser extension's default configuration. Users may also use it independently for their own technical evaluation of page-agent.
|
||||
To facilitate easy testing and technical evaluation, we provide a free testing LLM API. This API is used in the project homepage's live demo, the pre-built demo CDN bundles, and the browser extension's default configuration. Users may also use it independently for their own technical evaluation of the software.
|
||||
|
||||
This free testing API is provided **strictly for technical evaluation and R&D purposes only**. It must not be used in any production environment. By using this service, you agree to the following terms:
|
||||
This free testing API is provided **strictly for technical evaluation and R&D purposes only**. It must not be used in any production environment. By using this API, you agree to the following terms:
|
||||
|
||||
**No Sensitive Data**: You are strictly prohibited from inputting any Personal Identifiable Information (PII), confidential business data, financial/medical records, or using this agent on web pages containing such sensitive information.
|
||||
- **Permitted Use Only**: This API must be used solely for technical evaluation of the software. Any other use — including integration into other products or services, unlawful activities, violation of the underlying LLM provider's usage policies, or automated scraping at scale — is strictly prohibited.
|
||||
|
||||
**Zero Data Retention**: We do not store, log, or use your prompts or webpage data (DOM) for model training. All data is processed in-transit and immediately discarded. However, we do temporarily process necessary, identity-agnostic network data (such as IP addresses) solely for rate-limiting and anti-abuse purposes.
|
||||
- **No Sensitive Data**: You are strictly prohibited from inputting any Personal Identifiable Information (PII), confidential business data, financial/medical records, or using this agent on web pages containing such sensitive information.
|
||||
|
||||
**Independent Infrastructure**: The `page-agent` open-source software is completely frontend-based with a "Bring Your Own Key" (BYOK) architecture and no built-in backend. To facilitate easy testing, the maintainers have independently purchased public cloud services from Alibaba Cloud China ([aliyun.com](https://www.aliyun.com) FC and BaiLian Qwen models). This project is not affiliated with, nor endorsed by, Alibaba Cloud.
|
||||
- **Data Processing**: We do not store or log your prompts, webpage data (HTML), or any submitted content, nor do we use such data for model training. All data is processed in-transit and immediately discarded. We perform in-memory request validation to prevent abuse of the testing API, and temporarily process IP addresses for rate-limiting purposes. No data from these processes is retained. Data is processed through Alibaba Cloud infrastructure, which is subject to its own privacy policy.
|
||||
|
||||
**No Guaranteed Availability**: This testing API may be rate-limited, degraded, or discontinued at any time without prior notice.
|
||||
- **Independent Infrastructure**: The software is completely frontend-based with a "Bring Your Own Key" (BYOK) architecture and **no built-in backend**. To facilitate easy testing, the maintainers have purchased public cloud services from Alibaba Cloud China ([aliyun.com](https://www.aliyun.com) Function Compute and BaiLian Qwen models). This project is not a product of, nor endorsed by, Alibaba Cloud.
|
||||
|
||||
**"AS IS" & Limitation of Liability**: This service is provided strictly on an "AS IS" and "AS AVAILABLE" basis, without any warranties. The maintainers bear no liability for any data loss, service interruption, or legal consequences arising from your use of this demo.
|
||||
- **No Guaranteed Availability**: This testing API may be rate-limited, degraded, or discontinued at any time without prior notice.
|
||||
|
||||
**Recommendation for Real Usage**: For secure and continuous usage, we strongly advise using the default BYOK mode with your own legally compliant commercial LLM API keys, or connecting to local, offline models (e.g., Ollama).
|
||||
- **"AS IS" & Limitation of Liability**: This service is provided strictly on an "AS IS" and "AS AVAILABLE" basis, without any warranties. The maintainers bear no liability for any data loss, service interruption, or legal consequences arising from your use of this service.
|
||||
|
||||
**Note**: This service processes data via servers located in Mainland China. If you are located in a region with strict data localization laws (such as the EU/EEA), please do not use this demo.
|
||||
- **Recommendation for Real Usage**: For secure and continuous usage, we strongly advise using the BYOK mode with your own legally compliant commercial LLM API keys, or connecting to local, offline models (e.g., Ollama).
|
||||
|
||||
**Note**: This free testing LLM API processes data via servers located in Mainland China. If you are located in a region with strict data localization laws (such as the EU/EEA), please do not use this API.
|
||||
|
||||
**Age Requirement**: The software and testing API are not intended for use by individuals under the age of 13 (or the minimum age of digital consent in your jurisdiction).
|
||||
|
||||
---
|
||||
|
||||
@@ -49,19 +51,19 @@ The extension performs DOM analysis and automation actions **locally in your bro
|
||||
Data is transmitted to external servers **only when you initiate an automation task**. When this occurs:
|
||||
|
||||
- Your task instructions (natural language commands)
|
||||
- Simplified page structure (cleaned DOM) of all pages under the extension's control
|
||||
- Simplified page structure (cleaned HTML) of all pages under the extension's control
|
||||
|
||||
are sent to the LLM API endpoint configured in **your settings**.
|
||||
|
||||
> **Note:** The DOM cleaning process simplifies page structure for AI readability but **does not guarantee removal of sensitive information** (e.g., visible text, form values, or personal data on the page). Please be mindful of the page content when initiating tasks.
|
||||
> **Note:** The HTML cleaning process simplifies page structure for AI readability but **does not guarantee removal of sensitive information** (e.g., visible text, form values, or personal data on the page). Please be mindful of the page content when initiating tasks.
|
||||
|
||||
**If you configure a third-party LLM provider** (e.g., OpenAI, Anthropic, or others), data is sent directly to that provider. Their privacy policies apply.
|
||||
|
||||
**If you use the built-in testing API**, the terms in [Section 2](#2-testing-api-and-demo-disclaimer--terms-of-use) apply. By using the extension with the default testing API, you agree to those terms.
|
||||
**If you use the testing API**, the terms in [Section 2](#2-testing-api-and-demo-disclaimer--terms-of-use) apply. By using the extension with the default testing API, you agree to those terms.
|
||||
|
||||
### Data Storage
|
||||
|
||||
- **Local storage only**: Your configuration (API endpoint, API key, model selection) is stored in your browser via `chrome.storage.local`
|
||||
- **Local storage only**: Your configuration (API endpoint, API key, model selection) is stored in your browser via `chrome.storage.local` (or equivalent browser storage APIs)
|
||||
- **No cloud sync**: Configuration is not synced to any external server
|
||||
- **No analytics**: The extension does not include any analytics or tracking code
|
||||
|
||||
@@ -76,7 +78,7 @@ are sent to the LLM API endpoint configured in **your settings**.
|
||||
|
||||
## Changes
|
||||
|
||||
We may update these terms as the project evolves.
|
||||
We may update these terms at our discretion.
|
||||
|
||||
## Contact
|
||||
|
||||
|
||||
Generated
+1516
-846
File diff suppressed because it is too large
Load Diff
+18
-15
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"version": "1.4.0",
|
||||
"version": "1.6.3",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/page-controller",
|
||||
@@ -9,6 +9,7 @@
|
||||
"packages/llms",
|
||||
"packages/core",
|
||||
"packages/page-agent",
|
||||
"packages/mcp",
|
||||
"packages/extension",
|
||||
"packages/website"
|
||||
],
|
||||
@@ -24,25 +25,27 @@
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"scripts": {
|
||||
"cleanup": "rm -rf packages/*/dist",
|
||||
"start": "npm run dev --workspace=@page-agent/website",
|
||||
"build:website": "npm run build:website --workspace=@page-agent/website",
|
||||
"build:libs": "npm run build --workspaces --if-present",
|
||||
"build": "npm run build:libs && npm run build: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:website": "npm run build:website --workspace=@page-agent/website",
|
||||
"build:ext": "npm run build:libs && npm run zip -w @page-agent/ext",
|
||||
"version": "node scripts/sync-version.js",
|
||||
"lint": "eslint .",
|
||||
"cleanup": "rm -rf packages/*/dist",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.4.2",
|
||||
"@commitlint/config-conventional": "^20.4.2",
|
||||
"@commitlint/cli": "^20.5.0",
|
||||
"@commitlint/config-conventional": "^20.5.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@microsoft/api-extractor": "^7.57.3",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@microsoft/api-extractor": "^7.57.7",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/node": "^25.3.0",
|
||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitejs/plugin-react-swc": "^4.3.0",
|
||||
"chalk": "^5.6.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"dotenv": "^17.3.1",
|
||||
@@ -52,15 +55,15 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"eslint-plugin-react-x": "^2.13.0",
|
||||
"globals": "^17.0.0",
|
||||
"globals": "^17.4.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.4",
|
||||
"lint-staged": "^16.4.0",
|
||||
"prettier": "^3.8.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"typescript-eslint": "^8.57.2",
|
||||
"unplugin-dts": "^1.0.0-beta.6",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-css-injected-by-js": "^3.5.2",
|
||||
"vite-plugin-css-injected-by-js": "^4.0.1",
|
||||
"vite-bundle-analyzer": "^1.3.6"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@page-agent/core",
|
||||
"private": false,
|
||||
"version": "1.4.0",
|
||||
"version": "1.6.3",
|
||||
"type": "module",
|
||||
"main": "./dist/esm/page-agent-core.js",
|
||||
"module": "./dist/esm/page-agent-core.js",
|
||||
@@ -44,8 +44,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"zod": "^4.3.5",
|
||||
"@page-agent/llms": "1.4.0",
|
||||
"@page-agent/page-controller": "1.4.0"
|
||||
"@page-agent/llms": "1.6.3",
|
||||
"@page-agent/page-controller": "1.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
/**
|
||||
* Copyright (C) 2025 Alibaba Group Holding Limited
|
||||
* Copyright (C) 2026 SimonLuvRamen
|
||||
* All rights reserved.
|
||||
*/
|
||||
import { InvokeError, LLM, type Tool } from '@page-agent/llms'
|
||||
import type { BrowserState, PageController } from '@page-agent/page-controller'
|
||||
import chalk from 'chalk'
|
||||
import * as z from 'zod'
|
||||
import * as z from 'zod/v4'
|
||||
|
||||
import { type PageAgentConfig, type SupportedLanguage } from './config'
|
||||
import { DEFAULT_MAX_STEPS } from './config/constants'
|
||||
import SYSTEM_PROMPT from './prompts/system_prompt.md?raw'
|
||||
import { tools } from './tools'
|
||||
import type {
|
||||
AgentActivity,
|
||||
AgentConfig,
|
||||
AgentReflection,
|
||||
AgentStatus,
|
||||
AgentStepEvent,
|
||||
@@ -21,13 +21,13 @@ import type {
|
||||
MacroToolInput,
|
||||
MacroToolResult,
|
||||
} from './types'
|
||||
import { assert, normalizeResponse, uid, waitFor } from './utils'
|
||||
import { assert, fetchLlmsTxt, normalizeResponse, uid, waitFor } from './utils'
|
||||
|
||||
export { type PageAgentConfig }
|
||||
export type { SupportedLanguage }
|
||||
export { tool, type PageAgentTool } from './tools'
|
||||
export type * from './types'
|
||||
|
||||
export type PageAgentCoreConfig = AgentConfig & { pageController: PageController }
|
||||
|
||||
/**
|
||||
* AI agent for browser automation.
|
||||
*
|
||||
@@ -60,7 +60,7 @@ export type * from './types'
|
||||
*/
|
||||
export class PageAgentCore extends EventTarget {
|
||||
readonly id = uid()
|
||||
readonly config: PageAgentConfig & { maxSteps: number }
|
||||
readonly config: PageAgentCoreConfig & { maxSteps: number }
|
||||
readonly tools: typeof tools
|
||||
/** PageController for DOM operations */
|
||||
readonly pageController: PageController
|
||||
@@ -94,10 +94,10 @@ export class PageAgentCore extends EventTarget {
|
||||
browserState: null as BrowserState | null,
|
||||
}
|
||||
|
||||
constructor(config: PageAgentConfig & { pageController: PageController }) {
|
||||
constructor(config: PageAgentCoreConfig) {
|
||||
super()
|
||||
|
||||
this.config = { ...config, maxSteps: config.maxSteps || DEFAULT_MAX_STEPS }
|
||||
this.config = { ...config, maxSteps: config.maxSteps ?? 40 }
|
||||
|
||||
this.#llm = new LLM(this.config)
|
||||
this.tools = new Map(tools)
|
||||
@@ -177,7 +177,7 @@ export class PageAgentCore extends EventTarget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a observation message to the history event stream.
|
||||
* Push an observation message to the history event stream.
|
||||
* This will be visible in <agent_history> and remain persistent in memory across steps.
|
||||
* @experimental @internal
|
||||
* @note history change will be emitted before next step starts
|
||||
@@ -222,6 +222,7 @@ export class PageAgentCore extends EventTarget {
|
||||
this.history = []
|
||||
this.#setStatus('running')
|
||||
this.#emitHistoryChange()
|
||||
this.#observations = []
|
||||
|
||||
// Reset internal states
|
||||
this.#states = { totalWaitTime: 0, lastURL: '', browserState: null }
|
||||
@@ -342,6 +343,8 @@ export class PageAgentCore extends EventTarget {
|
||||
await onAfterTask?.(this, result)
|
||||
return result
|
||||
}
|
||||
|
||||
await waitFor(this.config.stepDelay ?? 0.4)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,14 +463,13 @@ export class PageAgentCore extends EventTarget {
|
||||
* Get instructions from config
|
||||
*/
|
||||
async #getInstructions(): Promise<string> {
|
||||
const { instructions } = this.config
|
||||
if (!instructions) return ''
|
||||
const { instructions, experimentalLlmsTxt } = this.config
|
||||
|
||||
const systemInstructions = instructions.system?.trim()
|
||||
const systemInstructions = instructions?.system?.trim()
|
||||
let pageInstructions: string | undefined
|
||||
|
||||
const url = this.#states.browserState?.url || ''
|
||||
if (instructions.getPageInstructions && url) {
|
||||
if (instructions?.getPageInstructions && url) {
|
||||
try {
|
||||
pageInstructions = instructions.getPageInstructions(url)?.trim()
|
||||
} catch (error) {
|
||||
@@ -477,7 +479,10 @@ export class PageAgentCore extends EventTarget {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!systemInstructions && !pageInstructions) return ''
|
||||
|
||||
const llmsTxt = experimentalLlmsTxt && url ? await fetchLlmsTxt(url) : undefined
|
||||
|
||||
if (!systemInstructions && !pageInstructions && !llmsTxt) return ''
|
||||
|
||||
let result = '<instructions>\n'
|
||||
|
||||
@@ -489,6 +494,10 @@ export class PageAgentCore extends EventTarget {
|
||||
result += `<page_instructions>\n${pageInstructions}\n</page_instructions>\n`
|
||||
}
|
||||
|
||||
if (llmsTxt) {
|
||||
result += `<llms_txt>\n${llmsTxt}\n</llms_txt>\n`
|
||||
}
|
||||
|
||||
result += '</instructions>\n\n'
|
||||
|
||||
return result
|
||||
@@ -503,7 +512,8 @@ export class PageAgentCore extends EventTarget {
|
||||
// Accumulated wait time warning
|
||||
if (this.#states.totalWaitTime >= 3) {
|
||||
this.pushObservation(
|
||||
`You have waited ${this.#states.totalWaitTime} seconds accumulatively. DO NOT wait any longer unless you have a good reason.`
|
||||
`You have waited ${this.#states.totalWaitTime} seconds accumulatively. ` +
|
||||
`DO NOT wait any longer unless you have a good reason.`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -519,7 +529,8 @@ export class PageAgentCore extends EventTarget {
|
||||
const remaining = this.config.maxSteps - step
|
||||
if (remaining === 5) {
|
||||
this.pushObservation(
|
||||
`⚠️ Only ${remaining} steps remaining. Consider wrapping up or calling done with partial results.`
|
||||
`⚠️ Only ${remaining} steps remaining. ` +
|
||||
`Consider wrapping up or calling done with partial results.`
|
||||
)
|
||||
} else if (remaining === 2) {
|
||||
this.pushObservation(
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const DEFAULT_MAX_STEPS = 20
|
||||
@@ -1,152 +0,0 @@
|
||||
import type { LLMConfig } from '@page-agent/llms'
|
||||
import type { PageControllerConfig } from '@page-agent/page-controller'
|
||||
|
||||
import type { PageAgentCore } from '../PageAgentCore'
|
||||
import type { PageAgentTool } from '../tools'
|
||||
import type { ExecutionResult, HistoricalEvent } from '../types'
|
||||
|
||||
export type { LLMConfig }
|
||||
|
||||
/** Supported UI languages */
|
||||
export type SupportedLanguage = 'en-US' | 'zh-CN'
|
||||
|
||||
export interface AgentConfig {
|
||||
// theme?: 'light' | 'dark'
|
||||
language?: SupportedLanguage
|
||||
|
||||
/**
|
||||
* Maximum number of steps the agent can take per task.
|
||||
* @default 20
|
||||
*/
|
||||
maxSteps?: number
|
||||
|
||||
/**
|
||||
* Custom tools to extend PageAgent capabilities
|
||||
* @experimental
|
||||
* @note You can also override or remove internal tools by using the same name.
|
||||
* @see PageAgentTool
|
||||
*
|
||||
* @example
|
||||
* // override internal tool
|
||||
* import { tool } from 'page-agent'
|
||||
* const customTools = {
|
||||
* ask_user: tool({
|
||||
* description:
|
||||
* 'Ask the user or parent model a question and wait for their answer. Use this if you need more information or clarification.',
|
||||
* inputSchema: zod.object({
|
||||
* question: zod.string(),
|
||||
* }),
|
||||
* execute: async function (this: PageAgent, input) {
|
||||
* const answer = await do_some_thing(input.question)
|
||||
* return "✅ Received user answer: " + answer
|
||||
* },
|
||||
* })
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // remove internal tool
|
||||
* const customTools = {
|
||||
* ask_user: null // never ask user questions
|
||||
* }
|
||||
*/
|
||||
customTools?: Record<string, PageAgentTool | null>
|
||||
|
||||
/**
|
||||
* Instructions to guide the agent's behavior
|
||||
*/
|
||||
instructions?: {
|
||||
/**
|
||||
* Global system-level instructions, applied to all tasks
|
||||
*/
|
||||
system?: string
|
||||
|
||||
/**
|
||||
* Dynamic page-level instructions callback
|
||||
* Called before each step to get instructions for the current page
|
||||
* @param url - Current page URL (window.location.href)
|
||||
* @returns Instructions string, or undefined/null to skip
|
||||
*/
|
||||
getPageInstructions?: (url: string) => string | undefined | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hooks for task execution.
|
||||
* @experimental API may change in future versions.
|
||||
*
|
||||
* All hooks receive the agent instance as first parameter.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Called before each step execution.
|
||||
* @experimental
|
||||
* @param agent - The PageAgentCore instance
|
||||
* @param stepCount - Current step number (0-indexed)
|
||||
*/
|
||||
onBeforeStep?: (agent: PageAgentCore, stepCount: number) => Promise<void> | void
|
||||
|
||||
/**
|
||||
* Called after each step execution.
|
||||
* @experimental
|
||||
* @param agent - The PageAgentCore instance
|
||||
* @param history - Current history of events
|
||||
*/
|
||||
onAfterStep?: (agent: PageAgentCore, history: HistoricalEvent[]) => Promise<void> | void
|
||||
|
||||
/**
|
||||
* Called before task execution starts.
|
||||
* @experimental
|
||||
* @param agent - The PageAgentCore instance
|
||||
*/
|
||||
onBeforeTask?: (agent: PageAgentCore) => Promise<void> | void
|
||||
|
||||
/**
|
||||
* Called after task execution completes (success or failure).
|
||||
* @experimental
|
||||
* @param agent - The PageAgentCore instance
|
||||
* @param result - The execution result
|
||||
*/
|
||||
onAfterTask?: (agent: PageAgentCore, result: ExecutionResult) => Promise<void> | void
|
||||
|
||||
/**
|
||||
* Called when the agent is disposed.
|
||||
* @experimental
|
||||
* @note This hook can block the disposal process if it's async.
|
||||
* @param agent - The PageAgentCore instance
|
||||
* @param reason - Optional reason for disposal
|
||||
*/
|
||||
onDispose?: (agent: PageAgentCore, reason?: string) => void
|
||||
|
||||
// page behavior hooks
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Enable the experimental script execution tool that allows executing generated JavaScript code on the page.
|
||||
* @note Can cause unpredictable side effects.
|
||||
* @note May bypass some safe guards and data-masking mechanisms.
|
||||
*/
|
||||
experimentalScriptExecutionTool?: boolean
|
||||
|
||||
/**
|
||||
* Transform page content before sending to LLM.
|
||||
* Called after DOM extraction and simplification, before LLM invocation.
|
||||
* Use cases: inspect extraction results, modify page info, mask sensitive data.
|
||||
*
|
||||
* @param content - Simplified page content that will be sent to LLM
|
||||
* @returns Transformed content
|
||||
*
|
||||
* @example
|
||||
* // Mask phone numbers
|
||||
* transformPageContent: async (content) => {
|
||||
* return content.replace(/1[3-9]\d{9}/g, '***********')
|
||||
* }
|
||||
*/
|
||||
transformPageContent?: (content: string) => Promise<string> | string
|
||||
|
||||
/**
|
||||
* Completely override the default system prompt.
|
||||
* @experimental Use with caution - incorrect prompts may break agent behavior.
|
||||
*/
|
||||
customSystemPrompt?: string
|
||||
}
|
||||
|
||||
export type PageAgentConfig = LLMConfig & AgentConfig & PageControllerConfig
|
||||
@@ -69,7 +69,7 @@ Strictly follow these rules while using the browser and navigating the web:
|
||||
- By default, only elements in the visible viewport are listed. Use scrolling actions if you suspect relevant content is offscreen which you need to interact with. Scroll ONLY if there are more pixels below or above the page.
|
||||
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
|
||||
- All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed.
|
||||
- If a captcha appears, tell user you can not solve captcha. finished the task and ask user to solve it.
|
||||
- If a captcha appears, tell user you can not solve captcha. Finish the task and ask user to solve it.
|
||||
- If expected elements are missing, try scrolling, or navigating back.
|
||||
- If the page is not fully loaded, use the `wait` action.
|
||||
- Do not repeat one action for more than 3 times unless some conditions changed.
|
||||
@@ -87,11 +87,11 @@ Strictly follow these rules while using the browser and navigating the web:
|
||||
|
||||
<capability>
|
||||
- You can only handle single page app. Do not jump out of current page.
|
||||
- Do not click on link if it will open in a new page (etc. <a target="_blank">)
|
||||
- Do not click on link if it will open in a new page (e.g., <a target="_blank">)
|
||||
- It is ok to fail the task.
|
||||
- User can be wrong. If the request of user is not achievable, inappropriate or you do not have enough information or tools to achieve it. Tell user to make a better request.
|
||||
- Webpage can be broken. All webpages or apps have bugs. Some bug will make it hard for your job. It's encouraged to tell user the problem of current page. Your feedbacks (including failing) are valuable for user.
|
||||
- Trying to hard can be harmful. Repeating some action back and forth or pushing for a complex procedure with little knowledge can cause unwanted result and harmful side-effects. User would rather you to complete the task with a fail.
|
||||
- Trying too hard can be harmful. Repeating some action back and forth or pushing for a complex procedure with little knowledge can cause unwanted results and harmful side-effects. User would rather you complete the task with a fail.
|
||||
- If you do not have knowledge for the current webpage or task. You must require user to give specific instructions and detailed steps.
|
||||
</capability>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Internal tools for PageAgent.
|
||||
* @note Adapted from browser-use
|
||||
*/
|
||||
import * as z from 'zod'
|
||||
import * as z from 'zod/v4'
|
||||
|
||||
import type { PageAgentCore } from '../PageAgentCore'
|
||||
import { waitFor } from '../utils'
|
||||
|
||||
@@ -1,3 +1,165 @@
|
||||
import type { LLMConfig } from '@page-agent/llms'
|
||||
|
||||
// @note circular dependency but okay
|
||||
import type { PageAgentCore } from './PageAgentCore'
|
||||
import type { PageAgentTool } from './tools'
|
||||
|
||||
/** Supported UI languages */
|
||||
export type SupportedLanguage = 'en-US' | 'zh-CN'
|
||||
|
||||
export interface AgentConfig extends LLMConfig {
|
||||
language?: SupportedLanguage
|
||||
|
||||
/**
|
||||
* Maximum number of steps the agent can take per task.
|
||||
* @default 40
|
||||
*/
|
||||
maxSteps?: number
|
||||
|
||||
/**
|
||||
* Custom tools to extend PageAgent capabilities
|
||||
* @experimental
|
||||
* @note You can also override or remove internal tools by using the same name.
|
||||
* @see PageAgentTool
|
||||
*
|
||||
* @example
|
||||
* // override internal tool
|
||||
* import { z } from 'zod/v4'
|
||||
* import { tool } from 'page-agent'
|
||||
* const customTools = {
|
||||
* ask_user: tool({
|
||||
* description:
|
||||
* 'Ask the user or parent model a question and wait for their answer. Use this if you need more information or clarification.',
|
||||
* inputSchema: z.object({
|
||||
* question: z.string(),
|
||||
* }),
|
||||
* execute: async function (this: PageAgent, input) {
|
||||
* const answer = await do_some_thing(input.question)
|
||||
* return "✅ Received user answer: " + answer
|
||||
* },
|
||||
* })
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // remove internal tool
|
||||
* const customTools = {
|
||||
* ask_user: null // never ask user questions
|
||||
* }
|
||||
*/
|
||||
customTools?: Record<string, PageAgentTool | null>
|
||||
|
||||
/**
|
||||
* Instructions to guide the agent's behavior
|
||||
*/
|
||||
instructions?: {
|
||||
/**
|
||||
* Global system-level instructions, applied to all tasks
|
||||
*/
|
||||
system?: string
|
||||
|
||||
/**
|
||||
* Dynamic page-level instructions callback
|
||||
* Called before each step to get instructions for the current page
|
||||
* @param url - Current page URL (window.location.href)
|
||||
* @returns Instructions string, or undefined/null to skip
|
||||
*/
|
||||
getPageInstructions?: (url: string) => string | undefined | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hooks for task execution.
|
||||
* @experimental API may change in future versions.
|
||||
*
|
||||
* All hooks receive the agent instance as first parameter.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Called before each step execution.
|
||||
* @experimental
|
||||
* @param agent - The PageAgentCore instance
|
||||
* @param stepCount - Current step number (0-indexed)
|
||||
*/
|
||||
onBeforeStep?: (agent: PageAgentCore, stepCount: number) => Promise<void> | void
|
||||
|
||||
/**
|
||||
* Called after each step execution.
|
||||
* @experimental
|
||||
* @param agent - The PageAgentCore instance
|
||||
* @param history - Current history of events
|
||||
*/
|
||||
onAfterStep?: (agent: PageAgentCore, history: HistoricalEvent[]) => Promise<void> | void
|
||||
|
||||
/**
|
||||
* Called before task execution starts.
|
||||
* @experimental
|
||||
* @param agent - The PageAgentCore instance
|
||||
*/
|
||||
onBeforeTask?: (agent: PageAgentCore) => Promise<void> | void
|
||||
|
||||
/**
|
||||
* Called after task execution completes (success or failure).
|
||||
* @experimental
|
||||
* @param agent - The PageAgentCore instance
|
||||
* @param result - The execution result
|
||||
*/
|
||||
onAfterTask?: (agent: PageAgentCore, result: ExecutionResult) => Promise<void> | void
|
||||
|
||||
/**
|
||||
* Called when the agent is disposed.
|
||||
* @experimental
|
||||
* @note This hook can block the disposal process if it's async.
|
||||
* @param agent - The PageAgentCore instance
|
||||
* @param reason - Optional reason for disposal
|
||||
*/
|
||||
onDispose?: (agent: PageAgentCore, reason?: string) => void
|
||||
|
||||
// page behavior hooks
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Enable the experimental script execution tool that allows executing generated JavaScript code on the page.
|
||||
* @note Can cause unpredictable side effects.
|
||||
* @note May bypass some safe guards and data-masking mechanisms.
|
||||
*/
|
||||
experimentalScriptExecutionTool?: boolean
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Fetch /llms.txt from current site origin and include as context.
|
||||
* Only fetched once per origin per task.
|
||||
* @default false
|
||||
*/
|
||||
experimentalLlmsTxt?: boolean
|
||||
|
||||
/**
|
||||
* Transform page content before sending to LLM.
|
||||
* Called after DOM extraction and simplification, before LLM invocation.
|
||||
* Use cases: inspect extraction results, modify page info, mask sensitive data.
|
||||
*
|
||||
* @param content - Simplified page content that will be sent to LLM
|
||||
* @returns Transformed content
|
||||
*
|
||||
* @example
|
||||
* // Mask phone numbers
|
||||
* transformPageContent: async (content) => {
|
||||
* return content.replace(/1[3-9]\d{9}/g, '***********')
|
||||
* }
|
||||
*/
|
||||
transformPageContent?: (content: string) => Promise<string> | string
|
||||
|
||||
/**
|
||||
* Completely override the default system prompt.
|
||||
* @experimental Use with caution - incorrect prompts may break agent behavior.
|
||||
*/
|
||||
customSystemPrompt?: string
|
||||
|
||||
/**
|
||||
* Delay between steps in seconds.
|
||||
* @default 0.4
|
||||
*/
|
||||
stepDelay?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent reflection state - the reflection-before-action model
|
||||
*
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { InvokeError, InvokeErrorType } from '@page-agent/llms'
|
||||
import chalk from 'chalk'
|
||||
import * as z from 'zod'
|
||||
import * as z from 'zod/v4'
|
||||
|
||||
import type { PageAgentTool } from '../tools'
|
||||
|
||||
const log = console.log.bind(console, chalk.yellow('[autoFixer]'))
|
||||
|
||||
/**
|
||||
* Normalize LLM response and fix common format issues.
|
||||
*
|
||||
@@ -34,7 +36,7 @@ export function normalizeResponse(response: any, tools?: Map<string, PageAgentTo
|
||||
|
||||
// case: sometimes the model only returns the action level
|
||||
if (toolCall.function.name && toolCall.function.name !== 'AgentOutput') {
|
||||
console.log(chalk.yellow(`[normalizeResponse] #1: fixing tool_call`))
|
||||
log(`#1: fixing tool_call`)
|
||||
resolvedArguments = { action: safeJsonParse(resolvedArguments) }
|
||||
}
|
||||
} else {
|
||||
@@ -47,13 +49,13 @@ export function normalizeResponse(response: any, tools?: Map<string, PageAgentTo
|
||||
|
||||
// case: sometimes the content json includes upper level wrapper
|
||||
if (resolvedArguments?.name === 'AgentOutput') {
|
||||
console.log(chalk.yellow(`[normalizeResponse] #2: fixing tool_call`))
|
||||
log(`#2: fixing tool_call`)
|
||||
resolvedArguments = safeJsonParse(resolvedArguments.arguments)
|
||||
}
|
||||
|
||||
// case: sometimes even 2-levels of wrapping
|
||||
if (resolvedArguments?.type === 'function') {
|
||||
console.log(chalk.yellow(`[normalizeResponse] #3: fixing tool_call`))
|
||||
log(`#3: fixing tool_call`)
|
||||
resolvedArguments = safeJsonParse(resolvedArguments.function.arguments)
|
||||
}
|
||||
|
||||
@@ -66,7 +68,7 @@ export function normalizeResponse(response: any, tools?: Map<string, PageAgentTo
|
||||
!resolvedArguments?.next_goal &&
|
||||
!resolvedArguments?.thinking
|
||||
) {
|
||||
console.log(chalk.yellow(`[normalizeResponse] #4: fixing tool_call`))
|
||||
log(`#4: fixing tool_call`)
|
||||
resolvedArguments = { action: safeJsonParse(resolvedArguments) }
|
||||
}
|
||||
} else {
|
||||
@@ -90,7 +92,7 @@ export function normalizeResponse(response: any, tools?: Map<string, PageAgentTo
|
||||
|
||||
// fix incomplete formats
|
||||
if (!resolvedArguments.action) {
|
||||
console.log(chalk.yellow(`[normalizeResponse] #5: fixing tool_call`))
|
||||
log(`#5: fixing tool_call`)
|
||||
resolvedArguments.action = { name: 'wait', input: { seconds: 1 } }
|
||||
}
|
||||
|
||||
@@ -149,9 +151,7 @@ function validateAction(action: any, tools: Map<string, PageAgentTool>): any {
|
||||
(k) => !(schema.shape as Record<string, z.ZodType>)[k].safeParse(undefined).success
|
||||
)
|
||||
if (requiredKey) {
|
||||
console.log(
|
||||
chalk.yellow(`[normalizeResponse] coercing primitive action input for "${toolName}"`)
|
||||
)
|
||||
log(`coercing primitive action input for "${toolName}"`)
|
||||
value = { [requiredKey]: value }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import chalk from 'chalk'
|
||||
|
||||
export { normalizeResponse } from './autoFixer'
|
||||
export * from './autoFixer'
|
||||
|
||||
export async function waitFor(seconds: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
|
||||
@@ -31,7 +31,7 @@ export function randomID(existingIDs?: string[]): string {
|
||||
id = Math.random().toString(36).substring(2, 11)
|
||||
tryCount++
|
||||
if (tryCount > MAX_TRY) {
|
||||
throw new Error('randomID: too many try')
|
||||
throw new Error('randomID: too many tries')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,43 @@ export function uid() {
|
||||
return id
|
||||
}
|
||||
|
||||
const llmsTxtCache = new Map<string, string | null>()
|
||||
|
||||
/** Fetch /llms.txt for a URL's origin. Cached per origin, `null` = tried and not found. */
|
||||
export async function fetchLlmsTxt(url: string): Promise<string | null> {
|
||||
let origin: string
|
||||
try {
|
||||
origin = new URL(url).origin
|
||||
} catch {
|
||||
return null // Invalid URL
|
||||
}
|
||||
// about:blank, data:, file:
|
||||
if (origin === 'null') return null
|
||||
|
||||
if (llmsTxtCache.has(origin)) return llmsTxtCache.get(origin)!
|
||||
|
||||
const endpoint = `${origin}/llms.txt`
|
||||
let result: string | null = null
|
||||
try {
|
||||
console.log(chalk.gray(`[llms.txt] Fetching ${endpoint}`))
|
||||
const res = await fetch(endpoint, { signal: AbortSignal.timeout(3000) })
|
||||
if (res.ok) {
|
||||
result = await res.text()
|
||||
console.log(chalk.green(`[llms.txt] Found (${result.length} chars)`))
|
||||
if (result.length > 1000) {
|
||||
console.log(chalk.yellow(`[llms.txt] Truncating to 1000 chars`))
|
||||
result = truncate(result, 1000)
|
||||
}
|
||||
} else {
|
||||
console.debug(chalk.gray(`[llms.txt] ${res.status} for ${endpoint}`))
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug(chalk.gray(`[llms.txt] not found for ${endpoint}`), e)
|
||||
}
|
||||
llmsTxtCache.set(origin, result)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple assertion function that throws an error if the condition is falsy
|
||||
* @param condition - The condition to assert
|
||||
|
||||
@@ -30,6 +30,7 @@ export default defineConfig({
|
||||
external: [
|
||||
'chalk',
|
||||
'zod',
|
||||
'zod/v4',
|
||||
// all the internal packages
|
||||
/^@page-agent\//,
|
||||
],
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
AI-powered browser automation. Control web pages with natural language.
|
||||
|
||||
Page Agent Ext — AI-Powered Browser Automation
|
||||
|
||||
🌟 What is Page Agent Ext?
|
||||
|
||||
Page Agent Ext brings AI-powered automation to your browser. Built on the open-source Page Agent framework, it lets you control web pages across multiple tabs using natural language — no scripting required.
|
||||
|
||||
🌟 Key Features:
|
||||
|
||||
- Natural Language Control — Command your browser in plain language, no code needed
|
||||
- Cross-Tab Automation — Seamlessly operate across multiple tabs and pages
|
||||
- Smart HTML Cleaning — Intelligently extracts and simplifies page structure for accurate AI understanding
|
||||
- Bring Your Own LLM — Use OpenAI, Anthropic, or any compatible API with full data control
|
||||
- Privacy-First — Zero data collection; all data flows directly to your chosen LLM provider
|
||||
- Open Source — MIT licensed, built on the Page Agent framework with full transparency
|
||||
|
||||
🌟 How It Works & Privacy:
|
||||
|
||||
Page Agent Ext performs DOM analysis locally in your browser. When you initiate a task, sanitized page structure is sent to the LLM API you configure. Your data is never collected or stored by us.
|
||||
|
||||
- Your API Key — Configure your own LLM API (OpenAI, Anthropic, etc.). Data goes directly to your provider
|
||||
- Test API — A free test endpoint is available for evaluation; we recommend your own key for regular use
|
||||
|
||||
Terms of Use & Privacy: https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md
|
||||
|
||||
🌟 Open Source:
|
||||
|
||||
This project is MIT licensed. Review the code, verify privacy claims, or extend it for your needs:
|
||||
https://github.com/alibaba/page-agent
|
||||
@@ -1,30 +0,0 @@
|
||||
AI 驱动的浏览器自动化助手,用自然语言控制网页。
|
||||
|
||||
Page Agent Ext — AI 驱动的浏览器自动化助手
|
||||
|
||||
🌟 什么是 Page Agent Ext?
|
||||
|
||||
Page Agent Ext 为浏览器带来 AI 自动化能力。基于开源的 Page Agent 框架,你可以用自然语言跨标签页控制网页,无需编写任何脚本。
|
||||
|
||||
🌟 核心特性:
|
||||
|
||||
- 自然语言控制 — 用日常语言指挥浏览器,无需编程
|
||||
- 跨标签页操作 — 在多个标签页之间无缝切换和操控
|
||||
- HTML 智能清洗 — 智能提取和精简页面结构,让 AI 准确理解网页内容
|
||||
- 使用你自己的模型 — 支持 OpenAI、Anthropic 或任何兼容 API,数据完全自主可控
|
||||
- 隐私优先 — 零数据收集,所有数据直接发送到你配置的 LLM 服务商
|
||||
- 开源透明 — MIT 协议,基于 Page Agent 开源框架,代码完全公开
|
||||
|
||||
🌟 工作原理与隐私:
|
||||
|
||||
Page Agent Ext 在浏览器本地进行 DOM 分析。当你发起任务时,经清洗的页面结构会发送到你配置的 LLM API。我们不会收集或存储你的任何数据。
|
||||
|
||||
- 你的 API Key — 配置你自己的 LLM API(OpenAI、Anthropic 等),数据直接发送到你的服务商
|
||||
- 测试 API — 提供免费测试端点供体验,日常使用建议配置自己的 Key
|
||||
|
||||
使用条款与隐私:https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md
|
||||
|
||||
🌟 开源项目:
|
||||
|
||||
MIT 协议开源。查看源码、验证隐私承诺,或按需扩展:
|
||||
https://github.com/alibaba/page-agent
|
||||
@@ -115,12 +115,17 @@ import type {
|
||||
|
||||
export interface ExecuteConfig {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
apiKey?: string
|
||||
|
||||
// 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
|
||||
@@ -205,9 +210,10 @@ import type {
|
||||
|
||||
interface ExecuteConfig {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
apiKey?: string
|
||||
includeInitialTab?: boolean
|
||||
experimentalIncludeAllTabs?: boolean
|
||||
onStatusChange?: (status: AgentStatus) => void
|
||||
onActivity?: (activity: AgentActivity) => void
|
||||
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@page-agent/ext",
|
||||
"private": true,
|
||||
"version": "0.1.8",
|
||||
"version": "1.6.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wxt",
|
||||
@@ -16,34 +16,35 @@
|
||||
"@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",
|
||||
"@types/chrome": "^0.1.38",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"@wxt-dev/module-react": "^1.1.5",
|
||||
"@wxt-dev/module-react": "^1.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-react": "^0.575.0",
|
||||
"motion": "^12.34.3",
|
||||
"lucide-react": "^1.0.1",
|
||||
"motion": "^12.38.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"rough-notation": "^0.5.1",
|
||||
"simple-icons": "^16.9.0",
|
||||
"simple-icons": "^16.13.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"wxt": "^0.20.18"
|
||||
"wxt": "^0.20.20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@page-agent/core": "1.4.0",
|
||||
"@page-agent/llms": "1.4.0",
|
||||
"@page-agent/page-controller": "1.4.0",
|
||||
"@page-agent/ui": "1.4.0",
|
||||
"@page-agent/core": "1.6.3",
|
||||
"@page-agent/llms": "1.6.3",
|
||||
"@page-agent/page-controller": "1.6.3",
|
||||
"@page-agent/ui": "1.6.3",
|
||||
"ai-motion": "^0.4.8",
|
||||
"chalk": "^5.6.2",
|
||||
"zod": "^4.3.5"
|
||||
"chalk": "^5.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type PageAgentConfig, PageAgentCore } from '@page-agent/core'
|
||||
import { type AgentConfig, PageAgentCore } from '@page-agent/core'
|
||||
|
||||
import { RemotePageController } from './RemotePageController'
|
||||
import { TabsController } from './TabsController'
|
||||
@@ -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: Omit<PageAgentConfig, 'pageController'> & { 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
|
||||
|
||||
|
||||
@@ -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,8 +56,6 @@ export class RemotePageController {
|
||||
}
|
||||
|
||||
async getBrowserState(): Promise<BrowserState> {
|
||||
if (!this.currentTabId) throw new Error('tabsController not initialized.')
|
||||
|
||||
let browserState = {} as BrowserState
|
||||
debug('getBrowserState', this.currentTabId)
|
||||
|
||||
@@ -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) })
|
||||
@@ -52,7 +49,7 @@ export function handleTabControlMessage(
|
||||
.create({ url: payload.url, active: false })
|
||||
.then((newTab) => {
|
||||
debug('open_new_tab: success', newTab)
|
||||
sendResponse({ success: true, tabId: newTab.id, windowId: newTab.windowId })
|
||||
sendResponse({ success: true, tabId: newTab.id })
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||
@@ -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,102 +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 | null = null
|
||||
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 = ''
|
||||
private windowId: number | null = null
|
||||
|
||||
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 = null
|
||||
this.portRetries = 0
|
||||
|
||||
this.windowId = null
|
||||
this.tabs = []
|
||||
this.tabGroupId = null
|
||||
this.initialTabId = null
|
||||
this.windowId = 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()
|
||||
|
||||
// update tab status immediately
|
||||
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
|
||||
|
||||
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,11 +132,8 @@ export class TabsController extends EventTarget {
|
||||
}
|
||||
|
||||
const tabId = result.tabId as number
|
||||
const windowId = result.windowId as number
|
||||
|
||||
this.windowId = windowId
|
||||
|
||||
this.tabs.push({
|
||||
this.addTab({
|
||||
id: tabId,
|
||||
isInitial: false,
|
||||
})
|
||||
@@ -144,32 +141,7 @@ export class TabsController extends EventTarget {
|
||||
await this.switchToTab(tabId)
|
||||
|
||||
if (!this.tabGroupId) {
|
||||
const result = await sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'create_tab_group',
|
||||
payload: { tabIds: [tabId], windowId: this.windowId },
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Failed to create tab group: ${result.error}`)
|
||||
}
|
||||
|
||||
const groupId = result.groupId as number
|
||||
|
||||
this.tabGroupId = groupId
|
||||
|
||||
await sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'update_tab_group',
|
||||
payload: {
|
||||
groupId: this.tabGroupId,
|
||||
properties: {
|
||||
title: `PageAgent(${this.task})`,
|
||||
color: randomColor(),
|
||||
collapsed: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
await this.createTabGroup([tabId])
|
||||
} else {
|
||||
await sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
@@ -230,6 +202,38 @@ export class TabsController extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
private async createTabGroup(tabIds: number[]) {
|
||||
const result = await sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'create_tab_group',
|
||||
payload: { tabIds, windowId: this.windowId },
|
||||
})
|
||||
|
||||
if (!result?.success) {
|
||||
throw new Error(`Failed to create tab group: ${result?.error}`)
|
||||
}
|
||||
|
||||
this.tabGroupId = result.groupId as number
|
||||
|
||||
await sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'update_tab_group',
|
||||
payload: {
|
||||
groupId: this.tabGroupId,
|
||||
properties: {
|
||||
title: `PageAgent(${this.task})`,
|
||||
color: randomColor(),
|
||||
collapsed: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -286,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 = null
|
||||
if (this.disposed) return
|
||||
if (this.portRetries >= 7) {
|
||||
console.error(PREFIX, 'tab events port failed after 3 retries, giving up')
|
||||
return
|
||||
}
|
||||
debug('port disconnected, reconnecting...')
|
||||
this.portRetries++
|
||||
this.connectTabEvents()
|
||||
})
|
||||
}
|
||||
|
||||
dispose() {
|
||||
debug('dispose')
|
||||
this.disposed = true
|
||||
this.port?.disconnect()
|
||||
this.port = null
|
||||
}
|
||||
}
|
||||
|
||||
export interface TabsInitOptions {
|
||||
includeInitialTab?: boolean
|
||||
experimentalIncludeAllTabs?: boolean
|
||||
}
|
||||
|
||||
export type TabAction =
|
||||
@@ -300,6 +372,7 @@ export type TabAction =
|
||||
| 'add_tab_to_group'
|
||||
| 'close_tab'
|
||||
| 'get_tab_title'
|
||||
| 'get_window_tabs'
|
||||
|
||||
interface TabMeta {
|
||||
id: number
|
||||
@@ -309,16 +382,7 @@ interface TabMeta {
|
||||
status?: 'loading' | 'unloaded' | 'complete'
|
||||
}
|
||||
|
||||
const TAB_GROUP_COLORS = [
|
||||
'grey',
|
||||
'blue',
|
||||
'red',
|
||||
'yellow',
|
||||
'green',
|
||||
'pink',
|
||||
'purple',
|
||||
'cyan',
|
||||
] as const
|
||||
const TAB_GROUP_COLORS = ['blue', 'red', 'yellow', 'green', 'pink', 'purple', 'cyan'] as const
|
||||
|
||||
type TabGroupColor = (typeof TAB_GROUP_COLORS)[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 */
|
||||
|
||||
@@ -71,7 +71,7 @@ Strictly follow these rules while using the browser and navigating the web:
|
||||
- By default, only elements in the visible viewport are listed. Use scrolling actions if you suspect relevant content is offscreen which you need to interact with. Scroll ONLY if there are more pixels below or above the page.
|
||||
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
|
||||
- All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed.
|
||||
- If a captcha appears, tell user you can not solve captcha. finished the task and ask user to solve it.
|
||||
- If a captcha appears, tell user you can not solve captcha. Finish the task and ask user to solve it.
|
||||
- If expected elements are missing, try scrolling, or navigating back.
|
||||
- If the page is not fully loaded, use the `wait` action.
|
||||
- Do not repeat one action for more than 3 times unless some conditions changed.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* - switch_to_tab: Switch to an existing tab
|
||||
* - close_tab: Close a tab (optionally switch to another)
|
||||
*/
|
||||
import * as z from 'zod'
|
||||
import * as z from 'zod/v4'
|
||||
|
||||
import type { TabsController } from './TabsController'
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import type {
|
||||
AgentActivity,
|
||||
AgentStatus,
|
||||
ExecutionResult,
|
||||
HistoricalEvent,
|
||||
SupportedLanguage,
|
||||
} from '@page-agent/core'
|
||||
@@ -16,7 +17,15 @@ import { DEMO_CONFIG, migrateLegacyEndpoint } from './constants'
|
||||
/** Language preference: undefined means follow system */
|
||||
export type LanguagePreference = SupportedLanguage | undefined
|
||||
|
||||
export interface ExtConfig extends LLMConfig {
|
||||
export interface AdvancedConfig {
|
||||
maxSteps?: number
|
||||
systemInstruction?: string
|
||||
experimentalLlmsTxt?: boolean
|
||||
experimentalIncludeAllTabs?: boolean
|
||||
disableNamedToolChoice?: boolean
|
||||
}
|
||||
|
||||
export interface ExtConfig extends LLMConfig, AdvancedConfig {
|
||||
language?: LanguagePreference
|
||||
}
|
||||
|
||||
@@ -26,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>
|
||||
}
|
||||
@@ -40,9 +49,10 @@ export function useAgent(): UseAgentResult {
|
||||
const [config, setConfig] = useState<ExtConfig | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
chrome.storage.local.get(['llmConfig', 'language']).then((result) => {
|
||||
chrome.storage.local.get(['llmConfig', 'language', 'advancedConfig']).then((result) => {
|
||||
let llmConfig = (result.llmConfig as LLMConfig) ?? DEMO_CONFIG
|
||||
const language = (result.language as SupportedLanguage) || undefined
|
||||
const advancedConfig = (result.advancedConfig as AdvancedConfig) ?? {}
|
||||
|
||||
// Auto-migrate legacy testing endpoints
|
||||
const migrated = migrateLegacyEndpoint(llmConfig)
|
||||
@@ -53,14 +63,18 @@ export function useAgent(): UseAgentResult {
|
||||
chrome.storage.local.set({ llmConfig: DEMO_CONFIG })
|
||||
}
|
||||
|
||||
setConfig({ ...llmConfig, language })
|
||||
setConfig({ ...llmConfig, ...advancedConfig, language })
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return
|
||||
|
||||
const agent = new MultiPageAgent(config)
|
||||
const { systemInstruction, ...agentConfig } = config
|
||||
const agent = new MultiPageAgent({
|
||||
...agentConfig,
|
||||
instructions: systemInstruction ? { system: systemInstruction } : undefined,
|
||||
})
|
||||
agentRef.current = agent
|
||||
|
||||
const handleStatusChange = (e: Event) => {
|
||||
@@ -99,22 +113,41 @@ export function useAgent(): UseAgentResult {
|
||||
|
||||
setCurrentTask(task)
|
||||
setHistory([])
|
||||
await agent.execute(task)
|
||||
return agent.execute(task)
|
||||
}, [])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
agentRef.current?.stop()
|
||||
}, [])
|
||||
|
||||
const configure = useCallback(async ({ language, ...llmConfig }: ExtConfig) => {
|
||||
await chrome.storage.local.set({ llmConfig })
|
||||
if (language) {
|
||||
await chrome.storage.local.set({ language })
|
||||
} else {
|
||||
await chrome.storage.local.remove('language')
|
||||
}
|
||||
setConfig({ ...llmConfig, language })
|
||||
}, [])
|
||||
const configure = useCallback(
|
||||
async ({
|
||||
language,
|
||||
maxSteps,
|
||||
systemInstruction,
|
||||
experimentalLlmsTxt,
|
||||
experimentalIncludeAllTabs,
|
||||
disableNamedToolChoice,
|
||||
...llmConfig
|
||||
}: ExtConfig) => {
|
||||
await chrome.storage.local.set({ llmConfig })
|
||||
if (language) {
|
||||
await chrome.storage.local.set({ language })
|
||||
} else {
|
||||
await chrome.storage.local.remove('language')
|
||||
}
|
||||
const advancedConfig: AdvancedConfig = {
|
||||
maxSteps,
|
||||
systemInstruction,
|
||||
experimentalLlmsTxt,
|
||||
experimentalIncludeAllTabs,
|
||||
disableNamedToolChoice,
|
||||
}
|
||||
await chrome.storage.local.set({ advancedConfig })
|
||||
setConfig({ ...llmConfig, ...advancedConfig, language })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return {
|
||||
status,
|
||||
|
||||
+139
-38
@@ -1,11 +1,24 @@
|
||||
import { Copy, CornerUpLeft, Eye, EyeOff, HatGlasses, Home, Loader2, Scale } from 'lucide-react'
|
||||
import {
|
||||
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'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
|
||||
interface ConfigPanelProps {
|
||||
config: ExtConfig | null
|
||||
@@ -14,22 +27,38 @@ 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(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)
|
||||
|
||||
// Update local state when config prop changes
|
||||
useEffect(() => {
|
||||
setApiKey(config?.apiKey || DEMO_API_KEY)
|
||||
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)
|
||||
setExperimentalIncludeAllTabs(config?.experimentalIncludeAllTabs ?? false)
|
||||
setDisableNamedToolChoice(config?.disableNamedToolChoice ?? false)
|
||||
}, [config])
|
||||
|
||||
// Poll for user auth token every second until found
|
||||
@@ -67,7 +96,17 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave({ apiKey, baseURL, model, language })
|
||||
await onSave({
|
||||
apiKey,
|
||||
baseURL,
|
||||
model,
|
||||
language,
|
||||
maxSteps: maxSteps || undefined,
|
||||
systemInstruction: systemInstruction || undefined,
|
||||
experimentalLlmsTxt,
|
||||
experimentalIncludeAllTabs,
|
||||
disableNamedToolChoice,
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -87,28 +126,11 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Testing API notice */}
|
||||
{isTestingEndpoint(baseURL) && (
|
||||
<div className="p-2.5 rounded-md border border-amber-500/30 bg-amber-500/5 text-[10px] 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{' '}
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
Terms of Use & Privacy Policy
|
||||
</a>
|
||||
. No sensitive data. No guaranteed availability.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<p className="text-[10px] text-muted-foreground mb-1">
|
||||
Add this token to a website's localStorage to give it authorization to call this extension
|
||||
Give a website the ability to call this extension.
|
||||
</p>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
@@ -143,6 +165,16 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hub link */}
|
||||
<a
|
||||
href="/hub.html"
|
||||
target="_blank"
|
||||
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>
|
||||
<Input
|
||||
@@ -153,10 +185,26 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Testing API notice */}
|
||||
{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 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"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
Terms of Use & Privacy Policy
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">Model</label>
|
||||
<Input
|
||||
placeholder="gpt-5.2"
|
||||
placeholder="gpt-5.1"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="text-xs h-8"
|
||||
@@ -168,7 +216,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
placeholder="sk-..."
|
||||
// placeholder="sk-..."
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="text-xs h-8"
|
||||
@@ -185,7 +233,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)}
|
||||
@@ -197,6 +245,62 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Advanced Config */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen(!advancedOpen)}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground cursor-pointer mt-1 font-bold"
|
||||
>
|
||||
Advanced
|
||||
{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>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="40"
|
||||
min={1}
|
||||
max={200}
|
||||
value={maxSteps ?? ''}
|
||||
onChange={(e) => setMaxSteps(e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="text-xs h-8 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">System Instruction</label>
|
||||
<textarea
|
||||
placeholder="Additional instructions for the agent..."
|
||||
value={systemInstruction}
|
||||
onChange={(e) => setSystemInstruction(e.target.value)}
|
||||
rows={3}
|
||||
className="text-xs rounded-md border border-input bg-background px-3 py-2 resize-y min-h-[60px]"
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button variant="outline" onClick={onClose} className="flex-1 h-8 text-xs cursor-pointer">
|
||||
Cancel
|
||||
@@ -213,6 +317,10 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
{/* Footer */}
|
||||
<div className="mt-4 mb-4 pt-4 border-t border-border/50 flex gap-2 justify-between text-[10px] text-muted-foreground">
|
||||
<div className="flex flex-col justify-between">
|
||||
<span>
|
||||
Version <span className="font-mono">v{__VERSION__}</span>
|
||||
</span>
|
||||
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent"
|
||||
target="_blank"
|
||||
@@ -224,7 +332,9 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
</svg>
|
||||
<span>Source Code</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end">
|
||||
<a
|
||||
href="https://alibaba.github.io/page-agent/"
|
||||
target="_blank"
|
||||
@@ -242,18 +352,9 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<HatGlasses className="size-3" />
|
||||
<span>Privacy Policy</span>
|
||||
<span>Privacy</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end">
|
||||
<span>
|
||||
Extension <span className="font-mono">v{__EXT_VERSION__}</span>
|
||||
</span>
|
||||
<span>
|
||||
PageAgent <span className="font-mono">v{__CORE_VERSION__}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* attribute */}
|
||||
+16
-5
@@ -1,4 +1,4 @@
|
||||
import { AlertTriangle, RotateCcw } from 'lucide-react'
|
||||
import { AlertTriangle, Eraser, RotateCcw } from 'lucide-react'
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -27,6 +27,11 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
handleResetConfig = async () => {
|
||||
await chrome.storage.local.remove(['llmConfig', 'language', 'advancedConfig'])
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.hasError) {
|
||||
return this.props.children
|
||||
@@ -39,10 +44,16 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
<p className="text-sm text-muted-foreground mb-4 max-w-xs">
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={this.handleReload}>
|
||||
<RotateCcw className="size-3.5 mr-2" />
|
||||
Reload Panel
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={this.handleResetConfig}>
|
||||
<Eraser className="size-3.5 mr-2" />
|
||||
Reset Config
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={this.handleReload}>
|
||||
<RotateCcw className="size-3.5 mr-2" />
|
||||
Reload Panel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+32
-3
@@ -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,6 +45,27 @@ 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) */}
|
||||
+48
-14
@@ -1,8 +1,9 @@
|
||||
import { ArrowLeft, CheckCircle, Trash2, XCircle } from 'lucide-react'
|
||||
import { ArrowDownToLine, ArrowLeft, CheckCircle, 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)
|
||||
@@ -18,9 +19,11 @@ function timeAgo(ts: number): string {
|
||||
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)
|
||||
@@ -41,6 +44,16 @@ export function HistoryList({
|
||||
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 */}
|
||||
@@ -85,7 +98,6 @@ export function HistoryList({
|
||||
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 */}
|
||||
@@ -98,19 +110,41 @@ export function HistoryList({
|
||||
{/* 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 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>
|
||||
|
||||
{/* 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>
|
||||
+18
-13
@@ -43,20 +43,24 @@ export function MotionOverlay({ active }: { active: boolean }) {
|
||||
const motionRef = useRef<Motion | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const mode = document.documentElement.classList.contains('dark') ? 'dark' : 'light'
|
||||
const motion = new Motion({
|
||||
mode,
|
||||
borderWidth: 4,
|
||||
borderRadius: 14,
|
||||
glowWidth: mode === 'dark' ? 120 : 60,
|
||||
styles: { position: 'absolute', inset: '0' },
|
||||
})
|
||||
motionRef.current = motion
|
||||
containerRef.current!.appendChild(motion.element)
|
||||
motion.autoResize(containerRef.current!)
|
||||
try {
|
||||
const mode = document.documentElement.classList.contains('dark') ? 'dark' : 'light'
|
||||
const motion = new Motion({
|
||||
mode,
|
||||
borderWidth: 4,
|
||||
borderRadius: 14,
|
||||
glowWidth: mode === 'dark' ? 120 : 60,
|
||||
styles: { position: 'absolute', inset: '0' },
|
||||
})
|
||||
motionRef.current = motion
|
||||
containerRef.current!.appendChild(motion.element)
|
||||
motion.autoResize(containerRef.current!)
|
||||
} catch (e) {
|
||||
console.warn('[MotionOverlay] Motion unavailable:', e)
|
||||
}
|
||||
|
||||
return () => {
|
||||
motion.dispose()
|
||||
motionRef.current?.dispose()
|
||||
motionRef.current = null
|
||||
}
|
||||
}, [])
|
||||
@@ -80,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' }}
|
||||
/>
|
||||
)
|
||||
@@ -107,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 })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
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) => (
|
||||
// eslint-disable-next-line react-x/no-array-index-key
|
||||
<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 latest = useRef({ execute, stop, configure, config })
|
||||
useEffect(() => {
|
||||
latest.current = { execute, stop, configure, config }
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!wsPort) return
|
||||
|
||||
const hubWs = new HubWs(
|
||||
Number(wsPort),
|
||||
{
|
||||
onExecute: async (task, incomingConfig) => {
|
||||
const { execute, configure, config } = latest.current
|
||||
if (incomingConfig) {
|
||||
await configure({ ...config, ...incomingConfig } as ExtConfig)
|
||||
}
|
||||
const result = await execute(task)
|
||||
return { success: result.success, data: result.data }
|
||||
},
|
||||
onStop: () => latest.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,8 @@ export type Execute = (task: string, config: ExecuteConfig) => Promise<Execution
|
||||
|
||||
export interface ExecuteConfig {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
apiKey?: string
|
||||
|
||||
/**
|
||||
* Whether to include the initial tab (that holds this main world script) in the task.
|
||||
@@ -13,6 +13,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,7 +33,6 @@ 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()
|
||||
@@ -85,9 +87,10 @@ export default defineUnlistedScript(() => {
|
||||
task,
|
||||
config: {
|
||||
baseURL: config.baseURL,
|
||||
apiKey: config.apiKey,
|
||||
model: config.model,
|
||||
apiKey: config.apiKey,
|
||||
includeInitialTab: config.includeInitialTab,
|
||||
experimentalIncludeAllTabs: config.experimentalIncludeAllTabs,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -110,9 +113,9 @@ export default defineUnlistedScript(() => {
|
||||
)
|
||||
}
|
||||
|
||||
;(window as any).PAGE_AGENT_EXT_VERSION = __EXT_VERSION__
|
||||
;(window as any).PAGE_AGENT_EXT_VERSION = __VERSION__
|
||||
;(window as any).PAGE_AGENT_EXT = {
|
||||
version: __EXT_VERSION__,
|
||||
version: __VERSION__,
|
||||
execute,
|
||||
stop,
|
||||
}
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
+1
-2
@@ -1,2 +1 @@
|
||||
declare const __EXT_VERSION__: string
|
||||
declare const __CORE_VERSION__: string
|
||||
declare const __VERSION__: string
|
||||
|
||||
@@ -19,8 +19,7 @@ export default defineConfig({
|
||||
vite: () => ({
|
||||
plugins: [tailwindcss()],
|
||||
define: {
|
||||
__EXT_VERSION__: JSON.stringify(pkg.version),
|
||||
__CORE_VERSION__: JSON.stringify(pkg.dependencies['@page-agent/core']),
|
||||
__VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
optimizeDeps: {
|
||||
force: true,
|
||||
@@ -37,7 +36,11 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
}),
|
||||
zip: {
|
||||
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__',
|
||||
@@ -59,5 +62,8 @@ export default defineConfig({
|
||||
side_panel: {
|
||||
default_path: 'sidepanel/index.html',
|
||||
},
|
||||
externally_connectable: {
|
||||
matches: ['http://localhost/*'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@page-agent/llms",
|
||||
"version": "1.4.0",
|
||||
"version": "1.6.3",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-agent-llms.js",
|
||||
"module": "./dist/lib/page-agent-llms.js",
|
||||
@@ -37,7 +37,12 @@
|
||||
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"chalk": "^5.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* OpenAI Client implementation
|
||||
*/
|
||||
import * as z from 'zod'
|
||||
import * as z from 'zod/v4'
|
||||
|
||||
import { InvokeError, InvokeErrorType } from './errors'
|
||||
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
|
||||
@@ -29,16 +29,19 @@ 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)
|
||||
@@ -50,7 +53,7 @@ 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),
|
||||
signal: abortSignal,
|
||||
|
||||
@@ -8,19 +8,20 @@ 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,
|
||||
disableNamedToolChoice: config.disableNamedToolChoice ?? false,
|
||||
customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Core types for LLM integration
|
||||
*/
|
||||
import type * as z from 'zod'
|
||||
import type * as z from 'zod/v4'
|
||||
|
||||
/**
|
||||
* Message format - OpenAI standard (industry standard)
|
||||
@@ -89,12 +89,18 @@ export interface InvokeResult<TResult = unknown> {
|
||||
*/
|
||||
export interface LLMConfig {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
apiKey?: string
|
||||
|
||||
temperature?: number
|
||||
maxRetries?: number
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
* Utility functions for LLM integration
|
||||
*/
|
||||
import chalk from 'chalk'
|
||||
import * as z from 'zod'
|
||||
import * as z from 'zod/v4'
|
||||
|
||||
import type { Tool } from './types'
|
||||
|
||||
function debug(message: string) {
|
||||
console.debug(chalk.gray('[LLM]'), message)
|
||||
}
|
||||
const debug = console.debug.bind(console, chalk.gray('[LLM]'))
|
||||
|
||||
/**
|
||||
* Convert Zod schema to OpenAI tool format
|
||||
@@ -75,6 +73,11 @@ export function modelPatch(body: Record<string, any>) {
|
||||
} else if (modelName.startsWith('gpt-51')) {
|
||||
debug('Applying GPT-51 patch: disable reasoning')
|
||||
body.reasoning_effort = 'none'
|
||||
} else if (modelName.startsWith('gpt-54')) {
|
||||
debug(
|
||||
'Applying GPT-5.4 patch: skip reasoning_effort because chat/completions rejects it with function tools'
|
||||
)
|
||||
delete body.reasoning_effort
|
||||
} 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'
|
||||
@@ -90,6 +93,15 @@ export function modelPatch(body: Record<string, any>) {
|
||||
body.reasoning_effort = 'minimal'
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export default defineConfig({
|
||||
},
|
||||
outDir: resolve(__dirname, 'dist', 'lib'),
|
||||
rollupOptions: {
|
||||
external: ['chalk', 'zod'],
|
||||
external: ['chalk', 'zod', 'zod/v4'],
|
||||
},
|
||||
minify: false,
|
||||
sourcemap: true,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# @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 build:libs
|
||||
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.6.3",
|
||||
"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.27.1",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/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 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, () => {
|
||||
console.error(`[page-agent-mcp] HTTP + WS on http://localhost:${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
+98
@@ -0,0 +1,98 @@
|
||||
#!/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 { 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')
|
||||
|
||||
/** @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: '1.5.8' })
|
||||
|
||||
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,237 @@
|
||||
<!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,7 +1,7 @@
|
||||
{
|
||||
"name": "page-agent",
|
||||
"private": false,
|
||||
"version": "1.4.0",
|
||||
"version": "1.6.3",
|
||||
"type": "module",
|
||||
"main": "./dist/esm/page-agent.js",
|
||||
"module": "./dist/esm/page-agent.js",
|
||||
@@ -44,11 +44,16 @@
|
||||
"postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@page-agent/core": "1.4.0",
|
||||
"@page-agent/llms": "1.4.0",
|
||||
"@page-agent/page-controller": "1.4.0",
|
||||
"@page-agent/ui": "1.4.0",
|
||||
"chalk": "^5.6.2",
|
||||
"@page-agent/core": "1.6.3",
|
||||
"@page-agent/llms": "1.6.3",
|
||||
"@page-agent/page-controller": "1.6.3",
|
||||
"@page-agent/ui": "1.6.3",
|
||||
"chalk": "^5.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
* Copyright (C) 2025 Alibaba Group Holding Limited
|
||||
* All rights reserved.
|
||||
*/
|
||||
import { type PageAgentConfig, PageAgentCore, type PageAgentTool, tool } from '@page-agent/core'
|
||||
import { PageController } from '@page-agent/page-controller'
|
||||
import { Panel } from '@page-agent/ui'
|
||||
import { type AgentConfig, PageAgentCore } from '@page-agent/core'
|
||||
import { PageController, type PageControllerConfig } from '@page-agent/page-controller'
|
||||
import { Panel, type PanelConfig } from '@page-agent/ui'
|
||||
|
||||
export type { PageAgentConfig, PageAgentTool }
|
||||
export { tool }
|
||||
export * from '@page-agent/core'
|
||||
|
||||
export type PageAgentConfig = AgentConfig & PageControllerConfig & Omit<PanelConfig, 'language'>
|
||||
|
||||
export class PageAgent extends PageAgentCore {
|
||||
panel: Panel
|
||||
@@ -22,6 +23,7 @@ export class PageAgent extends PageAgentCore {
|
||||
|
||||
this.panel = new Panel(this, {
|
||||
language: config.language,
|
||||
promptForNextTask: config.promptForNextTask,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export default defineConfig({
|
||||
external: [
|
||||
'chalk',
|
||||
'zod',
|
||||
'zod/v4',
|
||||
// all the internal packages
|
||||
/^@page-agent\//,
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@page-agent/page-controller",
|
||||
"version": "1.4.0",
|
||||
"version": "1.6.3",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-controller.js",
|
||||
"module": "./dist/lib/page-controller.js",
|
||||
|
||||
@@ -14,17 +14,16 @@ import {
|
||||
scrollVertically,
|
||||
selectOptionElement,
|
||||
} from './actions'
|
||||
import { VIEWPORT_EXPANSION } from './constants'
|
||||
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
|
||||
*/
|
||||
export interface PageControllerConfig extends dom.DomConfig {
|
||||
viewportExpansion?: number
|
||||
/** Enable visual mask overlay during operations (default: false) */
|
||||
enableMask?: boolean
|
||||
}
|
||||
@@ -131,7 +130,7 @@ export class PageController extends EventTarget {
|
||||
const url = window.location.href
|
||||
const title = document.title
|
||||
const pi = getPageInfo()
|
||||
const viewportExpansion = this.config.viewportExpansion ?? VIEWPORT_EXPANSION
|
||||
const viewportExpansion = dom.resolveViewportExpansion(this.config.viewportExpansion)
|
||||
|
||||
await this.updateTree()
|
||||
|
||||
@@ -245,7 +244,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.`,
|
||||
@@ -425,3 +424,5 @@ export class PageController extends EventTarget {
|
||||
this.mask = null
|
||||
}
|
||||
}
|
||||
|
||||
export * from './actions'
|
||||
|
||||
@@ -3,27 +3,19 @@
|
||||
* 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 {
|
||||
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 +31,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`)
|
||||
}
|
||||
|
||||
@@ -54,20 +46,30 @@ function blurLastClickedElement() {
|
||||
lastClickedElement.dispatchEvent(
|
||||
new MouseEvent('mouseout', { bubbles: true, cancelable: true })
|
||||
)
|
||||
lastClickedElement.dispatchEvent(
|
||||
new MouseEvent('mouseleave', { bubbles: false, cancelable: true })
|
||||
)
|
||||
lastClickedElement = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a click on the element
|
||||
* @private Internal method, subject to change at any time.
|
||||
*/
|
||||
export async function clickElement(element: HTMLElement) {
|
||||
blurLastClickedElement()
|
||||
|
||||
lastClickedElement = element
|
||||
|
||||
await scrollIntoViewIfNeeded(element)
|
||||
// Scroll the iframe element itself into view if needed
|
||||
const frame = element.ownerDocument.defaultView?.frameElement
|
||||
if (frame) await scrollIntoViewIfNeeded(frame)
|
||||
|
||||
await movePointerToElement(element)
|
||||
window.dispatchEvent(new CustomEvent('PageAgent::ClickPointer'))
|
||||
|
||||
await waitFor(0.1)
|
||||
|
||||
// hover it
|
||||
@@ -86,65 +88,123 @@ export async function clickElement(element: HTMLElement) {
|
||||
// dispatch a click event
|
||||
// element.click()
|
||||
|
||||
await waitFor(0.1) // Wait to ensure click event processing completes
|
||||
await waitFor(0.2) // Wait to ensure click event processing completes
|
||||
}
|
||||
|
||||
// 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!
|
||||
|
||||
/**
|
||||
* create a synthetic keyboard event
|
||||
* with key keycode code
|
||||
* @private Internal method, subject to change at any time.
|
||||
*/
|
||||
export async function createSyntheticInputEvent(elem: HTMLElement, key: string) {
|
||||
elem.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key }))
|
||||
await waitFor(0.01)
|
||||
|
||||
if (elem instanceof HTMLInputElement || elem instanceof HTMLTextAreaElement) {
|
||||
elem.dispatchEvent(new Event('beforeinput', { bubbles: true }))
|
||||
await waitFor(0.01)
|
||||
elem.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await waitFor(0.01)
|
||||
}
|
||||
|
||||
elem.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: true, key }))
|
||||
}
|
||||
|
||||
export async function inputTextElement(element: HTMLElement, text: string) {
|
||||
if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) {
|
||||
throw new Error('Element is not an input or textarea')
|
||||
const isContentEditable = element.isContentEditable
|
||||
if (!isInputElement(element) && !isTextAreaElement(element) && !isContentEditable) {
|
||||
throw new Error('Element is not an input, textarea, or contenteditable')
|
||||
}
|
||||
|
||||
await clickElement(element)
|
||||
|
||||
if (element instanceof HTMLTextAreaElement) {
|
||||
nativeTextAreaValueSetter.call(element, text)
|
||||
if (isContentEditable) {
|
||||
// Contenteditable support (partial)
|
||||
// Not supported:
|
||||
// - 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: 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
|
||||
if (
|
||||
element.dispatchEvent(
|
||||
new InputEvent('beforeinput', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
inputType: 'deleteContent',
|
||||
})
|
||||
)
|
||||
) {
|
||||
element.innerText = ''
|
||||
element.dispatchEvent(
|
||||
new InputEvent('input', {
|
||||
bubbles: true,
|
||||
inputType: 'deleteContent',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Dispatch beforeinput + mutation + input for insertion (important for React apps)
|
||||
if (
|
||||
element.dispatchEvent(
|
||||
new InputEvent('beforeinput', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
inputType: 'insertText',
|
||||
data: text,
|
||||
})
|
||||
)
|
||||
) {
|
||||
element.innerText = text
|
||||
element.dispatchEvent(
|
||||
new InputEvent('input', {
|
||||
bubbles: true,
|
||||
inputType: 'insertText',
|
||||
data: text,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// 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()
|
||||
} else {
|
||||
nativeInputValueSetter.call(element, text)
|
||||
getNativeValueSetter(element as HTMLInputElement | HTMLTextAreaElement).call(element, text)
|
||||
}
|
||||
|
||||
const inputEvent = new Event('input', { bubbles: true })
|
||||
element.dispatchEvent(inputEvent)
|
||||
// Only dispatch shared input event for non-contenteditable (contenteditable has its own)
|
||||
if (!isContentEditable) {
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
await waitFor(0.1) // Wait to ensure input event processing completes
|
||||
await waitFor(0.1)
|
||||
|
||||
blurLastClickedElement()
|
||||
}
|
||||
|
||||
/**
|
||||
* @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')
|
||||
}
|
||||
|
||||
@@ -161,18 +221,28 @@ export async function selectOptionElement(selectElement: HTMLSelectElement, opti
|
||||
await waitFor(0.1) // Wait to ensure change event processing completes
|
||||
}
|
||||
|
||||
export async function scrollIntoViewIfNeeded(element: HTMLElement) {
|
||||
const el = element as any
|
||||
if (el.scrollIntoViewIfNeeded) {
|
||||
interface ScrollableElement extends Element {
|
||||
scrollIntoViewIfNeeded?: (centerIfNeeded?: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* @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()
|
||||
// await waitFor(0.5) // Animation playback
|
||||
} else {
|
||||
// @todo visibility check
|
||||
el.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'nearest' })
|
||||
element.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'nearest' })
|
||||
// await waitFor(0.5) // Animation playback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private Internal method, subject to change at any time.
|
||||
*/
|
||||
export async function scrollVertically(
|
||||
down: boolean,
|
||||
scroll_amount: number,
|
||||
@@ -181,11 +251,6 @@ export async function scrollVertically(
|
||||
// Element-specific scrolling if element is provided
|
||||
if (element) {
|
||||
const targetElement = element
|
||||
console.log(
|
||||
'[SCROLL DEBUG] Starting direct container scroll for element:',
|
||||
targetElement.tagName
|
||||
)
|
||||
|
||||
let currentElement = targetElement as HTMLElement | null
|
||||
let scrollSuccess = false
|
||||
let scrolledElement: HTMLElement | null = null
|
||||
@@ -198,19 +263,6 @@ export async function scrollVertically(
|
||||
const hasScrollableY = /(auto|scroll|overlay)/.test(computedStyle.overflowY)
|
||||
const canScrollVertically = currentElement.scrollHeight > currentElement.clientHeight
|
||||
|
||||
console.log(
|
||||
'[SCROLL DEBUG] Checking element:',
|
||||
currentElement.tagName,
|
||||
'hasScrollableY:',
|
||||
hasScrollableY,
|
||||
'canScrollVertically:',
|
||||
canScrollVertically,
|
||||
'scrollHeight:',
|
||||
currentElement.scrollHeight,
|
||||
'clientHeight:',
|
||||
currentElement.clientHeight
|
||||
)
|
||||
|
||||
if (hasScrollableY && canScrollVertically) {
|
||||
const beforeScroll = currentElement.scrollTop
|
||||
const maxScroll = currentElement.scrollHeight - currentElement.clientHeight
|
||||
@@ -228,27 +280,10 @@ export async function scrollVertically(
|
||||
const afterScroll = currentElement.scrollTop
|
||||
const actualScrollDelta = afterScroll - beforeScroll
|
||||
|
||||
console.log(
|
||||
'[SCROLL DEBUG] Scroll attempt:',
|
||||
currentElement.tagName,
|
||||
'before:',
|
||||
beforeScroll,
|
||||
'after:',
|
||||
afterScroll,
|
||||
'delta:',
|
||||
actualScrollDelta
|
||||
)
|
||||
|
||||
if (Math.abs(actualScrollDelta) > 0.5) {
|
||||
scrollSuccess = true
|
||||
scrolledElement = currentElement
|
||||
scrollDelta = actualScrollDelta
|
||||
console.log(
|
||||
'[SCROLL DEBUG] Successfully scrolled container:',
|
||||
currentElement.tagName,
|
||||
'delta:',
|
||||
actualScrollDelta
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -336,6 +371,9 @@ export async function scrollVertically(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private Internal method, subject to change at any time.
|
||||
*/
|
||||
export async function scrollHorizontally(
|
||||
right: boolean,
|
||||
scroll_amount: number,
|
||||
@@ -344,11 +382,6 @@ export async function scrollHorizontally(
|
||||
// Element-specific scrolling if element is provided
|
||||
if (element) {
|
||||
const targetElement = element
|
||||
console.log(
|
||||
'[SCROLL DEBUG] Starting direct container scroll for element:',
|
||||
targetElement.tagName
|
||||
)
|
||||
|
||||
let currentElement = targetElement as HTMLElement | null
|
||||
let scrollSuccess = false
|
||||
let scrolledElement: HTMLElement | null = null
|
||||
@@ -361,19 +394,6 @@ export async function scrollHorizontally(
|
||||
const hasScrollableX = /(auto|scroll|overlay)/.test(computedStyle.overflowX)
|
||||
const canScrollHorizontally = currentElement.scrollWidth > currentElement.clientWidth
|
||||
|
||||
console.log(
|
||||
'[SCROLL DEBUG] Checking element:',
|
||||
currentElement.tagName,
|
||||
'hasScrollableX:',
|
||||
hasScrollableX,
|
||||
'canScrollHorizontally:',
|
||||
canScrollHorizontally,
|
||||
'scrollWidth:',
|
||||
currentElement.scrollWidth,
|
||||
'clientWidth:',
|
||||
currentElement.clientWidth
|
||||
)
|
||||
|
||||
if (hasScrollableX && canScrollHorizontally) {
|
||||
const beforeScroll = currentElement.scrollLeft
|
||||
const maxScroll = currentElement.scrollWidth - currentElement.clientWidth
|
||||
@@ -391,27 +411,10 @@ export async function scrollHorizontally(
|
||||
const afterScroll = currentElement.scrollLeft
|
||||
const actualScrollDelta = afterScroll - beforeScroll
|
||||
|
||||
console.log(
|
||||
'[SCROLL DEBUG] Scroll attempt:',
|
||||
currentElement.tagName,
|
||||
'before:',
|
||||
beforeScroll,
|
||||
'after:',
|
||||
afterScroll,
|
||||
'delta:',
|
||||
actualScrollDelta
|
||||
)
|
||||
|
||||
if (Math.abs(actualScrollDelta) > 0.5) {
|
||||
scrollSuccess = true
|
||||
scrolledElement = currentElement
|
||||
scrollDelta = actualScrollDelta
|
||||
console.log(
|
||||
'[SCROLL DEBUG] Successfully scrolled container:',
|
||||
currentElement.tagName,
|
||||
'delta:',
|
||||
actualScrollDelta
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Copyright (C) 2025 Alibaba Group Holding Limited
|
||||
* All rights reserved.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Viewport expansion for DOM tree extraction.
|
||||
* -1 means full page (no viewport restriction)
|
||||
* 0 means viewport only
|
||||
* positive values expand the viewport by that many pixels
|
||||
*
|
||||
* @note Since isTopElement depends on elementFromPoint,
|
||||
* it returns null when out of viewport, this feature has no practical use, only differ between -1 and 0
|
||||
*/
|
||||
// export const VIEWPORT_EXPANSION = 100
|
||||
export const VIEWPORT_EXPANSION = -1
|
||||
@@ -17,6 +17,7 @@
|
||||
* @edit add `data-browser-use-ignore` attribute
|
||||
* @edit improve `sampleRect`, filter out rects with 0 area
|
||||
* @edit exclude aria-hidden elements
|
||||
* @edit make sure attributes exist for interactive candidates.
|
||||
*/
|
||||
|
||||
export default (
|
||||
@@ -1446,7 +1447,7 @@ export default (
|
||||
/**
|
||||
* @edit add `data-browser-use-ignore` attribute
|
||||
*/
|
||||
if (node.dataset?.browserUseIgnore === 'true') {
|
||||
if (node.dataset?.browserUseIgnore === 'true' || node.dataset?.pageAgentIgnore === 'true') {
|
||||
return null // Skip this node and its children
|
||||
}
|
||||
|
||||
@@ -1608,6 +1609,18 @@ export default (
|
||||
* @edit direct dom ref
|
||||
*/
|
||||
nodeData.ref = node
|
||||
|
||||
/**
|
||||
* @edit make sure attributes exist for interactive candidates.
|
||||
* @note if the element failed the isInteractiveCandidate, attributes would be empty.
|
||||
*/
|
||||
if (nodeData.isInteractive && Object.keys(nodeData.attributes).length === 0) {
|
||||
const attributeNames = node.getAttributeNames?.() || []
|
||||
for (const name of attributeNames) {
|
||||
const value = node.getAttribute(name)
|
||||
nodeData.attributes[name] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { VIEWPORT_EXPANSION } from '../constants'
|
||||
import domTree from './dom_tree/index.js'
|
||||
import {
|
||||
ElementDomNode,
|
||||
@@ -7,7 +6,23 @@ import {
|
||||
TextDomNode,
|
||||
} from './dom_tree/type'
|
||||
|
||||
/**
|
||||
* Viewport expansion for DOM tree extraction.
|
||||
* -1 means full page (no viewport restriction)
|
||||
* 0 means viewport only
|
||||
* positive values expand the viewport by that many pixels
|
||||
*
|
||||
* @note Since isTopElement depends on elementFromPoint,
|
||||
* it returns null when out of viewport, this feature has no practical use, only differ between -1 and 0
|
||||
*/
|
||||
const DEFAULT_VIEWPORT_EXPANSION = -1
|
||||
|
||||
export function resolveViewportExpansion(viewportExpansion?: number): number {
|
||||
return viewportExpansion ?? DEFAULT_VIEWPORT_EXPANSION
|
||||
}
|
||||
|
||||
export interface DomConfig {
|
||||
viewportExpansion?: number
|
||||
interactiveBlacklist?: (Element | (() => Element))[]
|
||||
interactiveWhitelist?: (Element | (() => Element))[]
|
||||
includeAttributes?: string[]
|
||||
@@ -21,6 +36,8 @@ export interface DomConfig {
|
||||
const newElementsCache = new WeakMap<HTMLElement, string>()
|
||||
|
||||
export function getFlatTree(config: DomConfig): FlatDomTree {
|
||||
const viewportExpansion = resolveViewportExpansion(config.viewportExpansion)
|
||||
|
||||
const interactiveBlacklist = [] as Element[]
|
||||
for (const item of config.interactiveBlacklist || []) {
|
||||
if (typeof item === 'function') {
|
||||
@@ -43,7 +60,7 @@ export function getFlatTree(config: DomConfig): FlatDomTree {
|
||||
doHighlightElements: true,
|
||||
debugMode: true,
|
||||
focusHighlightIndex: -1,
|
||||
viewportExpansion: VIEWPORT_EXPANSION,
|
||||
viewportExpansion,
|
||||
interactiveBlacklist,
|
||||
interactiveWhitelist,
|
||||
highlightOpacity: config.highlightOpacity ?? 0.0,
|
||||
@@ -74,6 +91,43 @@ export function getFlatTree(config: DomConfig): FlatDomTree {
|
||||
return elements
|
||||
}
|
||||
|
||||
const globRegexCache = new Map<string, RegExp>()
|
||||
|
||||
function globToRegex(pattern: string): RegExp {
|
||||
let regex = globRegexCache.get(pattern)
|
||||
if (!regex) {
|
||||
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
regex = new RegExp(`^${escaped.replace(/\*/g, '.*')}$`)
|
||||
globRegexCache.set(pattern, regex)
|
||||
}
|
||||
return regex
|
||||
}
|
||||
|
||||
function matchAttributes(
|
||||
attrs: Record<string, string>,
|
||||
patterns: string[]
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.includes('*')) {
|
||||
const regex = globToRegex(pattern)
|
||||
for (const key of Object.keys(attrs)) {
|
||||
if (regex.test(key) && attrs[key].trim()) {
|
||||
result[key] = attrs[key].trim()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const value = attrs[pattern]
|
||||
if (value && value.trim()) {
|
||||
result[pattern] = value.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* elementsToString 内部使用的类型
|
||||
*/
|
||||
@@ -140,10 +194,13 @@ export function flatTreeToString(flatTree: FlatDomTree, includeAttributes?: stri
|
||||
// for jump check
|
||||
'target',
|
||||
|
||||
// absolute 定位的下拉菜单
|
||||
// absolute position dropdown menu
|
||||
'aria-haspopup',
|
||||
'aria-controls',
|
||||
'aria-owns',
|
||||
|
||||
// content editable
|
||||
'contenteditable',
|
||||
]
|
||||
|
||||
const includeAttrs = [...(includeAttributes || []), ...DEFAULT_INCLUDE_ATTRIBUTES]
|
||||
@@ -245,23 +302,15 @@ export function flatTreeToString(flatTree: FlatDomTree, includeAttributes?: stri
|
||||
let attributesHtmlStr = ''
|
||||
|
||||
if (includeAttrs.length > 0 && node.attributes) {
|
||||
const attributesToInclude: Record<string, string> = {}
|
||||
|
||||
// Filter attributes
|
||||
for (const key of includeAttrs) {
|
||||
const value = node.attributes[key]
|
||||
if (value && value.trim() !== '') {
|
||||
attributesToInclude[key] = value.trim()
|
||||
}
|
||||
}
|
||||
const attributesToInclude = matchAttributes(node.attributes, includeAttrs)
|
||||
|
||||
// Remove duplicate values (for attributes longer than 5 chars)
|
||||
const orderedKeys = includeAttrs.filter((key) => key in attributesToInclude)
|
||||
if (orderedKeys.length > 1) {
|
||||
const keys = Object.keys(attributesToInclude)
|
||||
if (keys.length > 1) {
|
||||
const keysToRemove = new Set<string>()
|
||||
const seenValues: Record<string, string> = {}
|
||||
|
||||
for (const key of orderedKeys) {
|
||||
for (const key of keys) {
|
||||
const value = attributesToInclude[key]
|
||||
if (value.length > 5) {
|
||||
if (value in seenValues) {
|
||||
|
||||
@@ -7,3 +7,7 @@
|
||||
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wrapper.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -8,13 +8,7 @@ import cursorStyles from './cursor.module.css'
|
||||
export class SimulatorMask {
|
||||
shown: boolean = false
|
||||
wrapper = document.createElement('div')
|
||||
motion = new Motion({
|
||||
mode: isPageDark() ? 'dark' : 'light',
|
||||
styles: {
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
},
|
||||
})
|
||||
motion: Motion | null = null
|
||||
|
||||
#cursor = document.createElement('div')
|
||||
|
||||
@@ -28,9 +22,19 @@ export class SimulatorMask {
|
||||
this.wrapper.id = 'page-agent-runtime_simulator-mask'
|
||||
this.wrapper.className = styles.wrapper
|
||||
this.wrapper.setAttribute('data-browser-use-ignore', 'true')
|
||||
this.wrapper.setAttribute('data-page-agent-ignore', 'true')
|
||||
|
||||
this.wrapper.appendChild(this.motion.element)
|
||||
this.motion.autoResize(this.wrapper)
|
||||
try {
|
||||
const motion = new Motion({
|
||||
mode: isPageDark() ? 'dark' : 'light',
|
||||
styles: { position: 'absolute', inset: '0' },
|
||||
})
|
||||
this.motion = motion
|
||||
this.wrapper.appendChild(motion.element)
|
||||
motion.autoResize(this.wrapper)
|
||||
} catch (e) {
|
||||
console.warn('[SimulatorMask] Motion overlay unavailable:', e)
|
||||
}
|
||||
|
||||
// Capture all mouse, keyboard, and wheel events
|
||||
this.wrapper.addEventListener('click', (e) => {
|
||||
@@ -144,10 +148,10 @@ export class SimulatorMask {
|
||||
if (this.shown) return
|
||||
|
||||
this.shown = true
|
||||
this.motion.start()
|
||||
this.motion.fadeIn()
|
||||
this.motion?.start()
|
||||
this.motion?.fadeIn()
|
||||
|
||||
this.wrapper.style.display = 'block'
|
||||
this.wrapper.classList.add(styles.visible)
|
||||
|
||||
// Initialize cursor position
|
||||
this.#currentCursorX = window.innerWidth / 2
|
||||
@@ -162,18 +166,18 @@ export class SimulatorMask {
|
||||
if (!this.shown) return
|
||||
|
||||
this.shown = false
|
||||
this.motion.fadeOut()
|
||||
this.motion.pause()
|
||||
this.motion?.fadeOut()
|
||||
this.motion?.pause()
|
||||
|
||||
this.#cursor.classList.remove(cursorStyles.clicking)
|
||||
|
||||
setTimeout(() => {
|
||||
this.wrapper.style.display = 'none'
|
||||
this.wrapper.classList.remove(styles.visible)
|
||||
}, 800) // Match the animation duration
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.motion.dispose()
|
||||
this.motion?.dispose()
|
||||
this.wrapper.remove()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// ======= 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))
|
||||
}
|
||||
|
||||
// ======= dom utils =======
|
||||
|
||||
export async function movePointerToElement(element: HTMLElement) {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const offset = getIframeOffset(element)
|
||||
const x = rect.left + rect.width / 2 + offset.x
|
||||
const y = rect.top + rect.height / 2 + offset.y
|
||||
|
||||
window.dispatchEvent(new CustomEvent('PageAgent::MovePointerTo', { detail: { x, y } }))
|
||||
|
||||
await waitFor(0.3)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@page-agent/ui",
|
||||
"version": "1.4.0",
|
||||
"version": "1.6.3",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-agent-ui.js",
|
||||
"module": "./dist/lib/page-agent-ui.js",
|
||||
|
||||
@@ -15,7 +15,7 @@ const enUS = {
|
||||
close: 'Close',
|
||||
expand: 'Expand history',
|
||||
collapse: 'Collapse history',
|
||||
step: 'Step {{number}} · {{time}}{{duration}}',
|
||||
step: 'Step {{number}}',
|
||||
},
|
||||
tools: {
|
||||
clicking: 'Clicking element [{{index}}]...',
|
||||
@@ -63,7 +63,7 @@ const zhCN = {
|
||||
close: '关闭',
|
||||
expand: '展开历史',
|
||||
collapse: '收起历史',
|
||||
step: '步骤 {{number}} · {{time}}{{duration}}',
|
||||
step: '步骤 {{number}}',
|
||||
},
|
||||
tools: {
|
||||
clicking: '正在点击元素 [{{index}}]...',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { I18n, type SupportedLanguage } from '../i18n'
|
||||
import { truncate } from '../utils'
|
||||
import { createCard, createReflectionLines, formatTime } from './cards'
|
||||
import { createCard, createReflectionLines } from './cards'
|
||||
import type { AgentActivity, PanelAgentAdapter } from './types'
|
||||
|
||||
import styles from './Panel.module.css'
|
||||
@@ -187,7 +187,6 @@ export class Panel {
|
||||
tempCard.innerHTML = createCard({
|
||||
icon: '❓',
|
||||
content: `Question: ${question}`,
|
||||
meta: formatTime(this.#config.language ?? 'en-US'),
|
||||
type: 'question',
|
||||
})
|
||||
const cardElement = tempCard.firstElementChild as HTMLElement
|
||||
@@ -370,10 +369,12 @@ export class Panel {
|
||||
}
|
||||
|
||||
#createWrapper(): HTMLElement {
|
||||
const taskInputMaxLength = 1000
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.id = 'page-agent-runtime_agent-panel'
|
||||
wrapper.className = styles.wrapper
|
||||
wrapper.setAttribute('data-browser-use-ignore', 'true')
|
||||
wrapper.setAttribute('data-page-agent-ignore', 'true')
|
||||
|
||||
wrapper.innerHTML = `
|
||||
<div class="${styles.background}"></div>
|
||||
@@ -406,7 +407,7 @@ export class Panel {
|
||||
<input
|
||||
type="text"
|
||||
class="${styles.taskInput}"
|
||||
maxlength="200"
|
||||
maxlength="${taskInputMaxLength}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -591,15 +592,12 @@ export class Panel {
|
||||
/** Create cards for a history event */
|
||||
#createHistoryCards(event: PanelAgentAdapter['history'][number]): string[] {
|
||||
const cards: string[] = []
|
||||
const time = formatTime(this.#config.language ?? 'en-US')
|
||||
const meta =
|
||||
event.type === 'step' && event.stepIndex !== undefined
|
||||
? this.#i18n.t('ui.panel.step', {
|
||||
number: (event.stepIndex + 1).toString(),
|
||||
time,
|
||||
duration: '',
|
||||
})
|
||||
: time
|
||||
: undefined
|
||||
|
||||
if (event.type === 'step') {
|
||||
// Reflection card
|
||||
@@ -636,7 +634,7 @@ export class Panel {
|
||||
/** Create cards for an action */
|
||||
#createActionCards(
|
||||
action: { name: string; input: unknown; output: string },
|
||||
meta: string
|
||||
meta?: string
|
||||
): string[] {
|
||||
const cards: string[] = []
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ interface CardOptions {
|
||||
export function createCard({ icon, content, meta, type }: CardOptions): string {
|
||||
const typeClass = type ? styles[type] : ''
|
||||
const contentHtml = Array.isArray(content)
|
||||
? `<div class="${styles.reflectionLines}">${content.join('')}</div>`
|
||||
? `<div class="${styles.reflectionLines}">${content.map((line) => `<span>${escapeHtml(line)}</span>`).join('')}</div>`
|
||||
: `<span>${escapeHtml(content)}</span>`
|
||||
|
||||
return `
|
||||
@@ -32,16 +32,6 @@ export function createCard({ icon, content, meta, type }: CardOptions): string {
|
||||
`
|
||||
}
|
||||
|
||||
/** Format timestamp for cards */
|
||||
export function formatTime(locale: string = 'en-US'): string {
|
||||
return new Date().toLocaleTimeString(locale, {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/** Create reflection lines from reflection object */
|
||||
export function createReflectionLines(reflection: {
|
||||
evaluation_previous_goal?: string
|
||||
@@ -50,13 +40,13 @@ export function createReflectionLines(reflection: {
|
||||
}): string[] {
|
||||
const lines: string[] = []
|
||||
if (reflection.evaluation_previous_goal) {
|
||||
lines.push(`<div>🔍 ${escapeHtml(reflection.evaluation_previous_goal)}</div>`)
|
||||
lines.push(`🔍 ${reflection.evaluation_previous_goal}`)
|
||||
}
|
||||
if (reflection.memory) {
|
||||
lines.push(`<div>💾 ${escapeHtml(reflection.memory)}</div>`)
|
||||
lines.push(`💾 ${reflection.memory}`)
|
||||
}
|
||||
if (reflection.next_goal) {
|
||||
lines.push(`<div>🎯 ${escapeHtml(reflection.next_goal)}</div>`)
|
||||
lines.push(`🎯 ${reflection.next_goal}`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ Located in `src/components/ui/`:
|
||||
src/
|
||||
├── pages/
|
||||
│ ├── home/
|
||||
│ │ ├── HomeContent.tsx # Homepage sections
|
||||
│ │ ├── index.tsx # Homepage
|
||||
│ │ └── ...Section.tsx
|
||||
│ └── docs/
|
||||
│ ├── index.tsx # Docs route switch
|
||||
|
||||
@@ -45,13 +45,24 @@
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="root">
|
||||
<div id="sk">
|
||||
<p class="sk-text" id="sk-text">Loading...</p>
|
||||
<style>
|
||||
#sk{display:flex;align-items:center;justify-content:center;min-height:100vh;background:linear-gradient(135deg,#eff6ff,#f5f3ff)}
|
||||
@media(prefers-color-scheme:dark){#sk{background:linear-gradient(135deg,#111827,#1f2937)}}
|
||||
.sk-text{font:400 14px/1 system-ui,sans-serif;color:#94a3b8;animation:skf 2s ease-in-out infinite}
|
||||
@keyframes skf{0%,100%{opacity:.6}50%{opacity:.3}}
|
||||
</style>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
<script>
|
||||
// Dynamically update html lang attribute based on i18n detection
|
||||
const updateHtmlLang = () => {
|
||||
const lang = localStorage.getItem('i18nextLng') || navigator.language || 'zh-CN'
|
||||
document.documentElement.lang = lang
|
||||
const el = document.getElementById('sk-text')
|
||||
if (el) el.textContent = lang.startsWith('zh') ? '加载中...' : 'Loading...'
|
||||
}
|
||||
updateHtmlLang()
|
||||
window.addEventListener('storage', updateHtmlLang)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@page-agent/website",
|
||||
"private": true,
|
||||
"version": "1.4.0",
|
||||
"version": "1.6.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
@@ -19,13 +19,13 @@
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"motion": "^12.34.3",
|
||||
"lucide-react": "^1.0.1",
|
||||
"motion": "^12.38.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"rough-notation": "^0.5.1",
|
||||
"simple-icons": "^16.9.0",
|
||||
"simple-icons": "^16.13.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://alibaba.github.io/page-agent/sitemap.xml
|
||||
+14
-6
@@ -5,10 +5,9 @@
|
||||
*/
|
||||
import * as React from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { Badge } from './badge'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
@@ -35,6 +34,8 @@ export interface APIReferenceProps {
|
||||
description?: React.ReactNode
|
||||
/** Property definitions */
|
||||
properties: PropDefinition[]
|
||||
/** Display variant: 'properties' for fields, 'methods' for methods */
|
||||
variant?: 'properties' | 'methods'
|
||||
/** Additional CSS classes */
|
||||
className?: string
|
||||
}
|
||||
@@ -43,7 +44,14 @@ export interface APIReferenceProps {
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export function APIReference({ title, description, properties, className }: APIReferenceProps) {
|
||||
export function APIReference({
|
||||
title,
|
||||
description,
|
||||
properties,
|
||||
variant = 'properties',
|
||||
className,
|
||||
}: APIReferenceProps) {
|
||||
const isMethodsVariant = variant === 'methods'
|
||||
return (
|
||||
<div className={cn('my-6', className)}>
|
||||
{title && (
|
||||
@@ -58,10 +66,10 @@ export function APIReference({ title, description, properties, className }: APIR
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-800/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-300">
|
||||
Property
|
||||
{isMethodsVariant ? 'Method' : 'Property'}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-300">
|
||||
Type
|
||||
{isMethodsVariant ? 'Return Type' : 'Type'}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-300 hidden md:table-cell">
|
||||
Default
|
||||
@@ -120,7 +128,7 @@ function PropRow({ name, type, required, defaultValue, description, status }: Pr
|
||||
|
||||
{/* Type */}
|
||||
<td className="px-4 py-3 align-top">
|
||||
<code className="font-mono text-xs text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded whitespace-nowrap">
|
||||
<code className="font-mono text-xs text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded wrap-break-word">
|
||||
{type}
|
||||
</code>
|
||||
</td>
|
||||
@@ -42,7 +42,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative ${containerClasses} rounded-xl border shadow-2xl overflow-hidden ${className}`}
|
||||
className={`group relative ${containerClasses} rounded-xl border shadow-2xl my-4 overflow-hidden ${className}`}
|
||||
>
|
||||
{/* 编辑器顶部栏 */}
|
||||
{showHeader && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { siGithub } from 'simple-icons'
|
||||
import { siGithub, siX } from 'simple-icons'
|
||||
|
||||
import { useLanguage } from '@/i18n/context'
|
||||
|
||||
@@ -12,18 +12,47 @@ export default function Footer() {
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-6 py-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm">
|
||||
© 2026 page-agent. All rights reserved.
|
||||
</p>
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="text-gray-600 dark:text-gray-300 text-sm text-center md:text-left">
|
||||
<p>
|
||||
<a
|
||||
href="https://x.com/simonluvramen"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block bg-[linear-gradient(60deg,#39b6ff_0%,#bd45fb_33%,#ff5733_66%,#ffd600_100%)] bg-clip-text text-xs leading-none text-transparent font-mono transition-opacity duration-200 hover:opacity-85"
|
||||
>
|
||||
Simon.
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-xs mt-0.5">
|
||||
© 2026 page-agent. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-200 text-sm"
|
||||
className="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-200 text-sm mr-4"
|
||||
>
|
||||
{isZh ? '使用条款与隐私' : 'Terms & Privacy'}
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/simonluvramen"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-200 mr-4"
|
||||
aria-label="X (Twitter)"
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-4 h-4 fill-current"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={siX.path} />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent"
|
||||
target="_blank"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState } from 'react'
|
||||
import { siGithub } from 'simple-icons'
|
||||
import { Link } from 'wouter'
|
||||
|
||||
import { formatStars, useGitHubStars } from '@/hooks/useGitHubStars'
|
||||
import { useLanguage } from '@/i18n/context'
|
||||
|
||||
import LanguageSwitcher from './LanguageSwitcher'
|
||||
@@ -12,6 +13,7 @@ import { HyperText } from './ui/hyper-text'
|
||||
export default function Header() {
|
||||
const { isZh } = useLanguage()
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const stars = useGitHubStars()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -34,8 +36,11 @@ export default function Header() {
|
||||
className="w-10 h-10 rounded-xl group-hover:scale-110 transition-transform duration-200"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-base sm:text-xl font-bold text-gray-900 dark:text-white block leading-tight">
|
||||
<span className="text-base sm:text-xl font-bold text-gray-900 dark:text-white leading-tight flex items-baseline gap-1.5">
|
||||
page-agent
|
||||
<span className="hidden sm:inline text-[10px] font-mono font-normal text-gray-400 dark:text-gray-500 tabular-nums before:content-['v']">
|
||||
{import.meta.env.VERSION}
|
||||
</span>
|
||||
</span>
|
||||
<HyperText
|
||||
as="p"
|
||||
@@ -66,7 +71,7 @@ export default function Header() {
|
||||
href="https://github.com/alibaba/page-agent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 shrink-0"
|
||||
className="flex items-center gap-1 p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 shrink-0"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<svg
|
||||
@@ -77,6 +82,9 @@ export default function Header() {
|
||||
>
|
||||
<path d={siGithub.path} />
|
||||
</svg>
|
||||
{stars !== null && (
|
||||
<span className="text-sm tabular-nums">★ {formatStars(stars)}</span>
|
||||
)}
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
@@ -86,9 +94,6 @@ export default function Header() {
|
||||
role="navigation"
|
||||
aria-label={isZh ? '文档' : 'Docs'}
|
||||
>
|
||||
<span className="text-xs font-mono text-gray-400 dark:text-gray-500 tabular-nums before:content-['v']">
|
||||
{import.meta.env.VERSION}
|
||||
</span>
|
||||
<Link
|
||||
href="/docs/introduction/overview"
|
||||
className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
||||
@@ -112,6 +117,9 @@ export default function Header() {
|
||||
<path d={siGithub.path} />
|
||||
</svg>
|
||||
GitHub
|
||||
{stars !== null && (
|
||||
<span className="text-sm font-medium tabular-nums ">★ {formatStars(stars)}</span>
|
||||
)}
|
||||
</a>
|
||||
<ThemeSwitcher />
|
||||
<LanguageSwitcher />
|
||||
@@ -161,6 +169,11 @@ export default function Header() {
|
||||
<path d={siGithub.path} />
|
||||
</svg>
|
||||
GitHub
|
||||
{stars !== null && (
|
||||
<span className="text-xs tabular-nums text-gray-400 dark:text-gray-500">
|
||||
★ {formatStars(stars)}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
<div className="flex items-center gap-3 px-3 py-2">
|
||||
<ThemeSwitcher />
|
||||
|
||||
@@ -8,8 +8,8 @@ export default function LanguageSwitcher() {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const languages = [
|
||||
{ code: 'zh-CN' as const, label: '中文' },
|
||||
{ code: 'en-US' as const, label: 'English' },
|
||||
{ code: 'zh-CN' as const, label: '中文' },
|
||||
]
|
||||
|
||||
const currentLanguage = languages.find((lang) => lang.code === language) || languages[0]
|
||||
@@ -40,7 +40,7 @@ export default function LanguageSwitcher() {
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors text-sm font-medium border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300"
|
||||
className="flex cursor-pointer items-center gap-2 rounded-lg border border-gray-200 bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
aria-label={isZh ? '切换语言' : 'Switch language'}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="true"
|
||||
@@ -81,7 +81,7 @@ export default function LanguageSwitcher() {
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => handleLanguageChange(lang.code)}
|
||||
className={`w-full flex items-center gap-2 px-4 py-2 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
|
||||
className={`flex w-full cursor-pointer items-center gap-2 px-4 py-2 text-left text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
language === lang.code
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function ThemeSwitcher() {
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="relative inline-flex items-center h-8 w-16 rounded-full transition-colors duration-300 ease-in-out focus:outline-none"
|
||||
className="relative inline-flex h-8 w-16 cursor-pointer items-center rounded-full transition-colors duration-300 ease-in-out focus:outline-none"
|
||||
style={{
|
||||
backgroundColor: theme === 'dark' ? '#1e293b' : '#e0f2fe',
|
||||
}}
|
||||
@@ -62,10 +62,11 @@ export default function ThemeSwitcher() {
|
||||
>
|
||||
{/* 滑块 */}
|
||||
<span
|
||||
className="inline-block h-6 w-6 transform rounded-full transition-all duration-300 ease-in-out shadow-md"
|
||||
className={`inline-block h-6 w-6 rounded-full transition-all duration-300 ease-in-out shadow-md ${
|
||||
theme === 'dark' ? 'translate-x-9' : 'translate-x-1'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: theme === 'dark' ? '#475569' : '#fbbf24',
|
||||
transform: theme === 'dark' ? 'translateX(2.25rem)' : 'translateX(0.25rem)',
|
||||
}}
|
||||
>
|
||||
{/* 图标 */}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Demo build (auto-init with demo LLM, for quick testing)
|
||||
export const CDN_DEMO_URL =
|
||||
'https://cdn.jsdelivr.net/npm/page-agent@1.4.0/dist/iife/page-agent.demo.js'
|
||||
'https://cdn.jsdelivr.net/npm/page-agent@1.6.3/dist/iife/page-agent.demo.js'
|
||||
export const CDN_DEMO_CN_URL =
|
||||
'https://registry.npmmirror.com/page-agent/1.4.0/files/dist/iife/page-agent.demo.js'
|
||||
'https://registry.npmmirror.com/page-agent/1.6.3/files/dist/iife/page-agent.demo.js'
|
||||
|
||||
// Demo LLM for website testing (homepage quick trial uses flash)
|
||||
export const DEMO_MODEL = 'qwen3.5-flash'
|
||||
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 = ''
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const STATS_URL = 'https://page-agent.github.io/gh-stats/stats.json'
|
||||
|
||||
let cached: number | null = null
|
||||
|
||||
export function useGitHubStars() {
|
||||
const [stars, setStars] = useState(cached)
|
||||
|
||||
useEffect(() => {
|
||||
if (cached !== null) return
|
||||
fetch(STATS_URL)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
cached = data.stargazers_count ?? null
|
||||
setStars(cached)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
return stars
|
||||
}
|
||||
|
||||
export function formatStars(n: number): string {
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`
|
||||
return String(n)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
@config '../tailwind.config.js';
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const DEFAULT_TITLE = 'PageAgent - The GUI Agent Living in Your Webpage'
|
||||
|
||||
export function useDocumentTitle(title?: string) {
|
||||
useEffect(() => {
|
||||
document.title = title ? `${title} - PageAgent` : DEFAULT_TITLE
|
||||
}, [title])
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import FeaturesSection from './home/FeaturesSection'
|
||||
import HeroSection from './home/HeroSection'
|
||||
import OneMoreThingSection from './home/OneMoreThingSection'
|
||||
import ScenariosSection from './home/ScenariosSection'
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<HeroSection />
|
||||
<FeaturesSection />
|
||||
<ScenariosSection />
|
||||
<OneMoreThingSection />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Link, useLocation } from 'wouter'
|
||||
|
||||
import { SparklesText } from '@/components/ui/sparkles-text'
|
||||
import { useLanguage } from '@/i18n/context'
|
||||
import { useDocumentTitle } from '@/lib/useDocumentTitle'
|
||||
|
||||
interface DocsLayoutProps {
|
||||
children: ReactNode
|
||||
@@ -34,10 +35,6 @@ export default function DocsLayout({ children }: DocsLayoutProps) {
|
||||
title: isZh ? '故障排查' : 'Troubleshooting',
|
||||
path: '/introduction/troubleshooting',
|
||||
},
|
||||
{
|
||||
title: '🚧 ' + (isZh ? '最佳实践' : 'Best Practices'),
|
||||
path: '/integration/best-practices',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -48,9 +45,10 @@ export default function DocsLayout({ children }: DocsLayoutProps) {
|
||||
{ title: isZh ? '知识注入' : 'Instructions', path: '/features/custom-instructions' },
|
||||
{ title: isZh ? '数据脱敏' : 'Data Masking', path: '/features/data-masking' },
|
||||
{ title: isZh ? 'Chrome 扩展' : 'Chrome Extension', path: '/features/chrome-extension' },
|
||||
{ title: 'MCP Server (Beta)', path: '/features/mcp-server' },
|
||||
{
|
||||
title: isZh ? '接入第三方 Agent' : 'Third-party Agent',
|
||||
path: '/integration/third-party-agent',
|
||||
path: '/features/third-party-agent',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -59,15 +57,22 @@ export default function DocsLayout({ children }: DocsLayoutProps) {
|
||||
items: [
|
||||
{ title: 'PageAgent', path: '/advanced/page-agent' },
|
||||
{ title: 'PageAgentCore', path: '/advanced/page-agent-core' },
|
||||
{ title: 'PageController', path: '/advanced/page-controller' },
|
||||
{ title: isZh ? '自定义 UI' : 'Custom UI', path: '/advanced/custom-ui' },
|
||||
{
|
||||
title: '🚧 ' + (isZh ? '安全与权限' : 'Security & Permissions'),
|
||||
path: '/integration/security-permissions',
|
||||
path: '/advanced/security-permissions',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const activeTitle = navigationSections
|
||||
.flatMap((s) => s.items)
|
||||
.find((item) => item.path === location)?.title
|
||||
|
||||
useDocumentTitle(activeTitle)
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 py-8 overflow-x-auto">
|
||||
<div className="flex gap-8 min-w-225">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { APIDivider, APIReference } from '@/components/APIReference'
|
||||
import CodeEditor from '@/components/CodeEditor'
|
||||
import { Heading } from '@/components/Heading'
|
||||
import { APIDivider, APIReference } from '@/components/ui/api-reference'
|
||||
import { useLanguage } from '@/i18n/context'
|
||||
|
||||
export default function CustomUIDocs() {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Link } from 'wouter'
|
||||
|
||||
import { APIDivider, APIReference, TypeRef } from '@/components/APIReference'
|
||||
import CodeEditor from '@/components/CodeEditor'
|
||||
import { Heading } from '@/components/Heading'
|
||||
import { APIDivider, APIReference, TypeRef } from '@/components/ui/api-reference'
|
||||
import { useLanguage } from '@/i18n/context'
|
||||
|
||||
export default function PageAgentCoreDocs() {
|
||||
@@ -50,19 +52,13 @@ const agent = new PageAgentCore({
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: 'your-api-key',
|
||||
model: 'gpt-5.2',
|
||||
language: 'en-US',
|
||||
})
|
||||
|
||||
// Listen to events for UI display
|
||||
|
||||
agent.addEventListener('statuschange', () => {
|
||||
console.log('Status:', agent.status)
|
||||
})
|
||||
|
||||
agent.addEventListener('historychange', () => {
|
||||
console.log('History:', agent.history)
|
||||
})
|
||||
|
||||
agent.addEventListener('activity', (e) => {
|
||||
const activity = (e as CustomEvent).detail
|
||||
console.log('Activity:', activity.type)
|
||||
@@ -75,14 +71,54 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
|
||||
<APIDivider title={isZh ? '配置' : 'Configuration'} />
|
||||
|
||||
{/* LLM Configuration */}
|
||||
{/* Configuration */}
|
||||
<section className="mb-10">
|
||||
<Heading id="llmconfig">LLMConfig</Heading>
|
||||
<Heading id="configuration">PageAgentCoreConfig</Heading>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{isZh
|
||||
? '配置与大语言模型的连接参数。支持 OpenAI 兼容的 API。'
|
||||
: 'Configure connection parameters for the language model. Supports OpenAI-compatible APIs.'}
|
||||
? 'PageAgentCoreConfig = AgentConfig & { pageController: PageController }。AgentConfig 包含以下配置项:'
|
||||
: 'PageAgentCoreConfig = AgentConfig & { pageController: PageController }. AgentConfig contains the following options:'}
|
||||
</p>
|
||||
|
||||
{/* PageController */}
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-800 dark:text-gray-200">
|
||||
PageController
|
||||
</h3>
|
||||
<APIReference
|
||||
properties={[
|
||||
{
|
||||
name: 'pageController',
|
||||
type: 'PageController',
|
||||
required: true,
|
||||
description: isZh ? (
|
||||
<>
|
||||
<Link
|
||||
href="/advanced/page-controller"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
PageController
|
||||
</Link>{' '}
|
||||
实例,用于 DOM 操作和元素交互。
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/advanced/page-controller"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
PageController
|
||||
</Link>{' '}
|
||||
instance for DOM operations and element interaction.
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* LLM Config */}
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-800 dark:text-gray-200">
|
||||
{isZh ? 'LLM 配置' : 'LLM Config'}
|
||||
</h3>
|
||||
<APIReference
|
||||
properties={[
|
||||
{
|
||||
@@ -93,12 +129,6 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
? 'LLM API 的基础 URL(如 https://api.openai.com/v1)'
|
||||
: 'Base URL of the LLM API (e.g., https://api.openai.com/v1)',
|
||||
},
|
||||
{
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: isZh ? 'API 密钥' : 'API key for authentication',
|
||||
},
|
||||
{
|
||||
name: 'model',
|
||||
type: 'string',
|
||||
@@ -107,10 +137,15 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
? '模型名称(如 gpt-5.2, anthropic/claude-4.5-haiku)'
|
||||
: 'Model name (e.g., gpt-5.2, anthropic/claude-4.5-haiku)',
|
||||
},
|
||||
{
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'LLM AK',
|
||||
},
|
||||
{
|
||||
name: 'temperature',
|
||||
type: 'number',
|
||||
defaultValue: '0',
|
||||
description: isZh
|
||||
? '模型温度参数,控制输出随机性'
|
||||
: 'Model temperature, controls output randomness',
|
||||
@@ -121,6 +156,14 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
defaultValue: '3',
|
||||
description: isZh ? 'API 调用失败时的最大重试次数' : 'Maximum retries on API failure',
|
||||
},
|
||||
{
|
||||
name: 'disableNamedToolChoice',
|
||||
type: 'boolean',
|
||||
defaultValue: 'false',
|
||||
description: isZh
|
||||
? '禁用命名 tool_choice,始终使用 "required" 字符串。适用于不支持 tool_choice 对象格式的 LLM 服务。'
|
||||
: 'Disable named tool_choice, always use "required" string. For LLM services that don\'t support the object format of tool_choice.',
|
||||
},
|
||||
{
|
||||
name: 'customFetch',
|
||||
type: 'typeof fetch',
|
||||
@@ -130,16 +173,11 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Agent Configuration */}
|
||||
<section className="mb-10">
|
||||
<Heading id="agentconfig">AgentConfig</Heading>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{isZh
|
||||
? '配置 Agent 的行为、生命周期钩子和扩展能力。'
|
||||
: 'Configure agent behavior, lifecycle hooks, and extension capabilities.'}
|
||||
</p>
|
||||
{/* Agent Config */}
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-800 dark:text-gray-200">
|
||||
{isZh ? 'Agent 配置' : 'Agent Config'}
|
||||
</h3>
|
||||
<APIReference
|
||||
properties={[
|
||||
{
|
||||
@@ -151,7 +189,7 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
{
|
||||
name: 'maxSteps',
|
||||
type: 'number',
|
||||
defaultValue: '20',
|
||||
defaultValue: '40',
|
||||
description: isZh ? '每个任务的最大步骤数' : 'Maximum number of steps per task',
|
||||
},
|
||||
{
|
||||
@@ -166,8 +204,8 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
name: 'instructions',
|
||||
type: 'InstructionsConfig',
|
||||
description: isZh
|
||||
? '指导 Agent 行为的指令配置'
|
||||
: 'Instructions to guide agent behavior',
|
||||
? '指导 Agent 行为的指令配置,见下方类型定义'
|
||||
: 'Instructions to guide agent behavior, see type definition below',
|
||||
},
|
||||
{
|
||||
name: 'transformPageContent',
|
||||
@@ -177,7 +215,15 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
: 'Transform page content before sending to LLM, useful for data masking',
|
||||
},
|
||||
{
|
||||
name: 'experimentalScriptExecutionTool',
|
||||
name: 'customSystemPrompt',
|
||||
type: 'string',
|
||||
status: 'experimental',
|
||||
description: isZh
|
||||
? '完全覆盖默认系统提示词。谨慎使用。'
|
||||
: 'Completely override the default system prompt. Use with caution.',
|
||||
},
|
||||
{
|
||||
name: 'experimentalScript\nExecutionTool',
|
||||
type: 'boolean',
|
||||
defaultValue: 'false',
|
||||
status: 'experimental',
|
||||
@@ -185,134 +231,58 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
? '启用实验性 JavaScript 执行工具'
|
||||
: 'Enable experimental JavaScript execution tool',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3">InstructionsConfig</h3>
|
||||
<APIReference
|
||||
properties={[
|
||||
{
|
||||
name: 'system',
|
||||
type: 'string',
|
||||
name: 'experimentalLlmsTxt',
|
||||
type: 'boolean',
|
||||
defaultValue: 'false',
|
||||
status: 'experimental',
|
||||
description: isZh
|
||||
? '全局系统级指令,应用于所有任务'
|
||||
: 'Global system-level instructions, applied to all tasks',
|
||||
},
|
||||
{
|
||||
name: 'getPageInstructions',
|
||||
type: '(url: string) => string | undefined | null',
|
||||
description: isZh
|
||||
? '动态页面级指令回调,在每个步骤前调用'
|
||||
: 'Dynamic page-level instructions callback, called before each step',
|
||||
? '从当前站点根目录获取 /llms.txt 并作为上下文提供给 LLM'
|
||||
: 'Fetch /llms.txt from site origin and include as LLM context',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Lifecycle Hooks */}
|
||||
<section className="mb-10">
|
||||
<Heading id="lifecycle-hooks">{isZh ? '生命周期钩子' : 'Lifecycle Hooks'}</Heading>
|
||||
{/* Lifecycle Hooks */}
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-800 dark:text-gray-200">
|
||||
{isZh ? '生命周期钩子' : 'Lifecycle Hooks'}
|
||||
<span className="ml-2 text-xs font-normal text-amber-600 dark:text-amber-400">
|
||||
experimental
|
||||
</span>
|
||||
</h3>
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-4">
|
||||
<p className="text-amber-800 dark:text-amber-200 text-sm">
|
||||
<strong>⚠️ {isZh ? '警告' : 'Warning'}:</strong>{' '}
|
||||
{isZh
|
||||
? '这些接口高度实验性,可能在未来版本中发生变化。建议优先使用事件系统(Events)来监听 Agent 状态。'
|
||||
: 'These APIs are highly experimental and may change in future versions. Prefer using the Events system for monitoring agent state.'}
|
||||
? '这些接口高度实验性,可能在未来版本中发生变化。'
|
||||
: 'These APIs are highly experimental and may change in future versions. '}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{isZh
|
||||
? '所有生命周期钩子都接收 agent 实例作为第一个参数,便于在回调中访问 Agent 状态和方法。'
|
||||
: 'All lifecycle hooks receive the agent instance as first parameter, making it easy to access agent state and methods in callbacks.'}
|
||||
</p>
|
||||
<APIReference
|
||||
properties={[
|
||||
{
|
||||
name: 'onBeforeStep',
|
||||
type: '(agent: PageAgentCore, stepCount: number) => void | Promise<void>',
|
||||
type: '(agent, stepCount) => void | Promise<void>',
|
||||
description: isZh ? '每个步骤执行前调用' : 'Called before each step execution',
|
||||
status: 'experimental',
|
||||
},
|
||||
{
|
||||
name: 'onAfterStep',
|
||||
type: '(agent: PageAgentCore, history: HistoricalEvent[]) => void | Promise<void>',
|
||||
type: '(agent, history) => void | Promise<void>',
|
||||
description: isZh ? '每个步骤执行后调用' : 'Called after each step execution',
|
||||
status: 'experimental',
|
||||
},
|
||||
{
|
||||
name: 'onBeforeTask',
|
||||
type: '(agent: PageAgentCore) => void | Promise<void>',
|
||||
type: '(agent) => void | Promise<void>',
|
||||
description: isZh ? '任务开始前调用' : 'Called before task starts',
|
||||
status: 'experimental',
|
||||
},
|
||||
{
|
||||
name: 'onAfterTask',
|
||||
type: '(agent: PageAgentCore, result: ExecutionResult) => void | Promise<void>',
|
||||
type: '(agent, result) => void | Promise<void>',
|
||||
description: isZh ? '任务结束后调用' : 'Called after task ends',
|
||||
status: 'experimental',
|
||||
},
|
||||
{
|
||||
name: 'onDispose',
|
||||
type: '(agent: PageAgentCore, reason?: string) => void',
|
||||
type: '(agent, reason?) => void',
|
||||
description: isZh ? 'Agent 销毁时调用' : 'Called when agent is disposed',
|
||||
status: 'experimental',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* PageController Configuration */}
|
||||
<section className="mb-10">
|
||||
<Heading id="pagecontrollerconfig">PageControllerConfig</Heading>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{isZh
|
||||
? '配置 DOM 提取、元素交互和视觉反馈。'
|
||||
: 'Configure DOM extraction, element interaction, and visual feedback.'}
|
||||
</p>
|
||||
<APIReference
|
||||
properties={[
|
||||
{
|
||||
name: 'pageController',
|
||||
type: 'PageController',
|
||||
status: 'experimental',
|
||||
description: isZh
|
||||
? '自定义 PageController 实例。如不提供,将创建默认实例。'
|
||||
: 'Custom PageController instance. If not provided, a default one will be created.',
|
||||
},
|
||||
{
|
||||
name: 'enableMask',
|
||||
type: 'boolean',
|
||||
defaultValue: 'true',
|
||||
description: isZh
|
||||
? '启用视觉遮罩覆盖层,阻止用户在自动化期间操作'
|
||||
: 'Enable visual mask overlay that blocks user interaction during automation',
|
||||
},
|
||||
{
|
||||
name: 'viewportExpansion',
|
||||
type: 'number',
|
||||
defaultValue: '0',
|
||||
description: isZh
|
||||
? '视口扩展像素数,-1 表示提取整个页面'
|
||||
: 'Viewport expansion in pixels, -1 means extract entire page',
|
||||
},
|
||||
{
|
||||
name: 'interactiveBlacklist',
|
||||
type: '(Element | (() => Element))[]',
|
||||
description: isZh ? '要排除的交互元素列表' : 'Elements to exclude from interaction',
|
||||
},
|
||||
{
|
||||
name: 'interactiveWhitelist',
|
||||
type: '(Element | (() => Element))[]',
|
||||
description: isZh
|
||||
? '要强制包含的交互元素列表'
|
||||
: 'Elements to force include for interaction',
|
||||
},
|
||||
{
|
||||
name: 'includeAttributes',
|
||||
type: 'string[]',
|
||||
description: isZh
|
||||
? '在 DOM 提取中包含的额外属性'
|
||||
: 'Additional attributes to include in DOM extraction',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -369,16 +339,24 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
<section className="mb-10">
|
||||
<Heading id="methods">{isZh ? '方法' : 'Methods'}</Heading>
|
||||
<APIReference
|
||||
variant="methods"
|
||||
properties={[
|
||||
{
|
||||
name: 'execute(task: string)',
|
||||
name: 'execute(task)',
|
||||
type: 'Promise<ExecutionResult>',
|
||||
description: isZh
|
||||
? '执行任务并返回结果。包含 success、data 和 history 字段。'
|
||||
: 'Execute a task and return result. Contains success, data, and history fields.',
|
||||
},
|
||||
{
|
||||
name: 'dispose(reason?: string)',
|
||||
name: 'stop()',
|
||||
type: 'void',
|
||||
description: isZh
|
||||
? '停止当前任务。Agent 仍可复用。'
|
||||
: 'Stop the current task. Agent remains reusable.',
|
||||
},
|
||||
{
|
||||
name: 'dispose()',
|
||||
type: 'void',
|
||||
description: isZh
|
||||
? '销毁 Agent 并清理资源'
|
||||
@@ -443,11 +421,8 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
<CodeEditor
|
||||
language="typescript"
|
||||
code={`interface ExecutionResult {
|
||||
/** Whether the task completed successfully */
|
||||
success: boolean
|
||||
/** Result description from the agent */
|
||||
data: string
|
||||
/** Full execution history */
|
||||
history: HistoricalEvent[]
|
||||
}`}
|
||||
/>
|
||||
@@ -467,50 +442,21 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<APIDivider title={isZh ? '无头模式' : 'Headless Mode'} />
|
||||
|
||||
{/* Headless Usage */}
|
||||
{/* InstructionsConfig */}
|
||||
<section className="mb-10">
|
||||
<Heading id="headless-mode">{isZh ? '无头模式' : 'Headless Mode'}</Heading>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{isZh
|
||||
? '在非 DOM 环境中,你必须实现自定义的 PageController(例如远程操作页面或 Puppeteer)。'
|
||||
: 'In non-DOM environments, you must implement a custom PageController (e.g., remote page control or Puppeteer).'}
|
||||
</p>
|
||||
<Heading id="instructionsconfig">InstructionsConfig</Heading>
|
||||
<CodeEditor
|
||||
language="typescript"
|
||||
code={`import { PageAgentCore } from '@page-agent/core'
|
||||
import type { PageController } from '@page-agent/page-controller'
|
||||
code={`interface InstructionsConfig {
|
||||
/** Global system-level instructions, applied to all tasks */
|
||||
system?: string
|
||||
|
||||
class MyRemotePageController implements PageController {
|
||||
// Implement required methods for DOM extraction and interaction
|
||||
}
|
||||
|
||||
const agent = new PageAgentCore({
|
||||
pageController: new MyRemotePageController(),
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: 'your-api-key',
|
||||
model: 'gpt-5.2',
|
||||
language: 'en-US',
|
||||
})
|
||||
|
||||
// Listen to events for UI display
|
||||
|
||||
agent.addEventListener('statuschange', () => {
|
||||
console.log('Status:', agent.status)
|
||||
})
|
||||
|
||||
agent.addEventListener('historychange', () => {
|
||||
console.log('History:', agent.history)
|
||||
})
|
||||
|
||||
agent.addEventListener('activity', (e) => {
|
||||
const activity = (e as CustomEvent).detail
|
||||
console.log('Activity:', activity.type)
|
||||
})
|
||||
|
||||
// Execute task
|
||||
const result = await agent.execute('Fill in the form with test data')`}
|
||||
/**
|
||||
* Dynamic page-level instructions callback.
|
||||
* Called before each step to get instructions for the current page.
|
||||
*/
|
||||
getPageInstructions?: (url: string) => string | undefined
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Link } from 'wouter'
|
||||
|
||||
import CodeEditor from '@/components/CodeEditor'
|
||||
import { Heading } from '@/components/Heading'
|
||||
import { APIReference, TypeRef } from '@/components/ui/api-reference'
|
||||
import { useLanguage } from '@/i18n/context'
|
||||
|
||||
export default function PageAgentDocs() {
|
||||
@@ -14,8 +13,8 @@ export default function PageAgentDocs() {
|
||||
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
|
||||
{isZh
|
||||
? 'PageAgent 是带有内置 UI 面板的完整 Agent 类。它继承自 PageAgentCore,并自动创建交互面板。'
|
||||
: 'PageAgent is the complete Agent class with built-in UI panel. It extends PageAgentCore and automatically creates an interactive panel.'}
|
||||
? 'PageAgent 是带有内置 UI 面板的完整 Agent 类。它继承自 PageAgentCore,并自动创建交互面板和 PageController。'
|
||||
: 'PageAgent is the complete Agent class with built-in UI panel. It extends PageAgentCore and automatically creates an interactive panel and PageController.'}
|
||||
</p>
|
||||
|
||||
{/* When to use */}
|
||||
@@ -29,6 +28,11 @@ export default function PageAgentDocs() {
|
||||
: 'In most cases, you should use PageAgent. It provides a complete out-of-the-box experience:'}
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-600 dark:text-gray-400 space-y-2 mb-6">
|
||||
<li>
|
||||
{isZh
|
||||
? '自动创建 PageController,处理 DOM 提取和元素操作'
|
||||
: 'Automatically creates PageController for DOM extraction and element actions'}
|
||||
</li>
|
||||
<li>
|
||||
{isZh
|
||||
? '内置 UI 面板,显示任务进度、Agent 思考过程和操作结果'
|
||||
@@ -74,9 +78,8 @@ console.log(result.history) // Full execution history`}
|
||||
<CodeEditor
|
||||
language="typescript"
|
||||
code={`class PageAgent extends PageAgentCore {
|
||||
/** The UI panel instance */
|
||||
panel: Panel
|
||||
|
||||
pageController: PageController
|
||||
constructor(config: PageAgentConfig)
|
||||
}`}
|
||||
/>
|
||||
@@ -90,7 +93,21 @@ console.log(result.history) // Full execution history`}
|
||||
>
|
||||
PageAgentCore
|
||||
</Link>
|
||||
,所有核心方法和事件都可用。详细的 API 参考请查看 PageAgentCore 文档。
|
||||
,所有核心方法和事件都可用。配置项合并了{' '}
|
||||
<Link
|
||||
href="/advanced/page-agent-core#configuration"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
AgentConfig
|
||||
</Link>{' '}
|
||||
、 PanelConfig 和{' '}
|
||||
<Link
|
||||
href="/advanced/page-controller#configuration"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
PageControllerConfig
|
||||
</Link>
|
||||
。
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -101,71 +118,34 @@ console.log(result.history) // Full execution history`}
|
||||
>
|
||||
PageAgentCore
|
||||
</Link>
|
||||
. All core methods and events are available. See PageAgentCore docs for detailed API
|
||||
reference.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Configuration */}
|
||||
<section className="mb-10">
|
||||
<Heading id="configuration">{isZh ? '配置' : 'Configuration'}</Heading>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{isZh
|
||||
? 'PageAgent 使用与 PageAgentCore 相同的配置接口。'
|
||||
: 'PageAgent uses the same configuration interface as PageAgentCore.'}
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{isZh ? (
|
||||
<>
|
||||
完整配置请参考{' '}
|
||||
. All core methods and events are available. Config merges{' '}
|
||||
<Link
|
||||
href="/advanced/page-agent-core"
|
||||
href="/advanced/page-agent-core#configuration"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
PageAgentCore 配置文档
|
||||
</Link>
|
||||
。
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
See{' '}
|
||||
<Link
|
||||
href="/advanced/page-agent-core"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
PageAgentCore configuration docs
|
||||
AgentConfig
|
||||
</Link>{' '}
|
||||
for complete reference.
|
||||
, PanelConfig, and{' '}
|
||||
<Link
|
||||
href="/advanced/page-controller#configuration"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
PageControllerConfig
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Panel Property */}
|
||||
{/* Panel */}
|
||||
<section className="mb-10">
|
||||
<Heading id="panel-property">{isZh ? 'Panel 属性' : 'Panel Property'}</Heading>
|
||||
<Heading id="panel">{isZh ? 'UI 面板' : 'UI Panel'}</Heading>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{isZh
|
||||
? 'PageAgent 自动创建一个 Panel 实例。你可以通过 panel 属性访问它来控制 UI:'
|
||||
: 'PageAgent automatically creates a Panel instance. You can access it via the panel property to control the UI:'}
|
||||
? 'PageAgent 自动创建一个 Panel 实例。你可以通过 panel 属性控制 UI:'
|
||||
: 'PageAgent automatically creates a Panel instance. You can control the UI via the panel property:'}
|
||||
</p>
|
||||
|
||||
<APIReference
|
||||
properties={[
|
||||
{
|
||||
name: 'panel',
|
||||
type: 'Panel',
|
||||
required: true,
|
||||
description: isZh
|
||||
? '内置的 UI 面板实例,用于显示任务进度和接收用户输入。'
|
||||
: 'The built-in UI panel instance for displaying task progress and receiving user input.',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3">{isZh ? 'Panel 方法' : 'Panel Methods'}</h3>
|
||||
<CodeEditor
|
||||
language="typescript"
|
||||
code={`// Show/hide the panel
|
||||
@@ -212,16 +192,16 @@ agent.panel.dispose()`}
|
||||
</tr>
|
||||
<tr className="bg-white dark:bg-gray-900">
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||
{isZh ? 'Headless 模式' : 'Headless Mode'}
|
||||
{isZh ? '自动创建 PageController' : 'Auto-creates PageController'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-gray-400 dark:text-gray-600">-</td>
|
||||
<td className="px-4 py-3 text-center text-green-600 dark:text-green-400">✓</td>
|
||||
<td className="px-4 py-3 text-center text-gray-400 dark:text-gray-600">-</td>
|
||||
</tr>
|
||||
<tr className="bg-white dark:bg-gray-900">
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||
{isZh ? '自定义 PageController' : 'Custom PageController'}
|
||||
{isZh ? 'Headless 模式' : 'Headless Mode'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-green-600 dark:text-green-400">✓</td>
|
||||
<td className="px-4 py-3 text-center text-gray-400 dark:text-gray-600">-</td>
|
||||
<td className="px-4 py-3 text-center text-green-600 dark:text-green-400">✓</td>
|
||||
</tr>
|
||||
<tr className="bg-white dark:bg-gray-900">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user