Compare commits
83 Commits
EXT_v0.1.17
...
v1.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| c4bdd8fc4b |
@@ -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,13 +5,13 @@
|
||||
<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://news.ycombinator.com/item?id=47264138" target="_blank">📢 Join HN Discussion</a>
|
||||
<a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank"><b>📖 Docs</b></a> | <a href="https://news.ycombinator.com/item?id=47264138" target="_blank"><b>📢 HN Discussion</b></a> | <a href="https://x.com/simonluvramen" target="_blank"><b>𝕏 Follow on X</b></a>
|
||||
|
||||
<video id="demo-video" src="https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2" controls crossorigin muted></video>
|
||||
|
||||
@@ -22,20 +22,18 @@ 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.**
|
||||
|
||||
## 💡 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 agent's reach across browser tabs with the optional [chrome extension](https://alibaba.github.io/page-agent/docs/features/chrome-extension).
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
@@ -47,12 +45,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.5.5/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.5.5/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.0/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.6.0/files/dist/iife/page-agent.demo.js |
|
||||
|
||||
### NPM Installation
|
||||
|
||||
@@ -77,10 +75,12 @@ 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! Follow our instructions in [CONTRIBUTING.md](CONTRIBUTING.md) for setup and guidelines.
|
||||
|
||||
Please read [Code of Conduct](docs/CODE_OF_CONDUCT.md) before contributing.
|
||||
|
||||
Contributions generated entirely by bots or agents without substantial human involvement will not be accepted, and bot accounts may be blocked.
|
||||
|
||||
## 👏 Acknowledgments
|
||||
|
||||
This project builds upon the excellent work of **[`browser-use`](https://github.com/browser-use/browser-use)**.
|
||||
@@ -90,12 +90,10 @@ 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.
|
||||
@@ -114,8 +112,8 @@ file and in the node_modules directory after installation.
|
||||
|
||||
<a href="https://www.star-history.com/?repos=alibaba%2Fpage-agent&type=date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&theme=dark&legend=top-left&v=2" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&legend=top-left&v=2" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&legend=top-left&v=2" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&theme=dark&legend=top-left&v=7" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&legend=top-left&v=7" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&legend=top-left&v=7" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
+50
@@ -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.
|
||||
@@ -153,42 +153,6 @@ PageAgent is now ready for production use. The API is stable and breaking change
|
||||
- **Ask User Tool** - Agent can ask users for clarification
|
||||
- **i18n Support** - English and Chinese localization
|
||||
|
||||
### Configuration
|
||||
|
||||
```typescript
|
||||
// Version 1.0.0
|
||||
interface PageAgentConfig {
|
||||
// LLM Configuration (required)
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
temperature?: number
|
||||
maxRetries?: number
|
||||
customFetch?: typeof fetch
|
||||
|
||||
// Agent Configuration
|
||||
language?: 'en-US' | 'zh-CN'
|
||||
maxSteps?: number // default: 20
|
||||
customTools?: Record<string, PageAgentTool> // experimental
|
||||
instructions?: InstructionsConfig
|
||||
transformPageContent?: (content: string) => string | Promise<string>
|
||||
experimentalScriptExecutionTool?: boolean // default: false
|
||||
|
||||
// Lifecycle Hooks (experimental)
|
||||
onBeforeTask?: (agent, result) => void
|
||||
onAfterTask?: (agent, result) => void
|
||||
onBeforeStep?: (agent, stepCount) => void
|
||||
onAfterStep?: (agent, history) => void
|
||||
onDispose?: (agent, reason?) => void
|
||||
|
||||
// Page Controller Configuration
|
||||
enableMask?: boolean // default: true
|
||||
viewportExpansion?: number
|
||||
interactiveBlacklist?: Element[]
|
||||
interactiveWhitelist?: Element[]
|
||||
}
|
||||
```
|
||||
|
||||
### Packages
|
||||
|
||||
| Package | Description |
|
||||
|
||||
+15
-19
@@ -5,13 +5,13 @@
|
||||
<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/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2" controls crossorigin muted></video>
|
||||
|
||||
@@ -22,20 +22,18 @@
|
||||
- **🎯 轻松集成**
|
||||
- 无需 `浏览器插件` / `Python` / `无头浏览器`。
|
||||
- 纯页面内 JavaScript,一切都在你的网页中完成。
|
||||
- The best tool for your agent to control web pages.
|
||||
- **📖 基于文本的 DOM 操作**
|
||||
- 无需截图,无需 OCR 或多模态模型。
|
||||
- 无需特殊权限。
|
||||
- 无需截图,无需多模态模型或特殊权限。
|
||||
- **🧠 用你自己的 LLM**
|
||||
- **🎨 精美 UI,支持人机协同**
|
||||
- **🐙 可选的 [Chrome 扩展](https://alibaba.github.io/page-agent/docs/features/chrome-extension),支持跨页面任务。**
|
||||
|
||||
## 💡 应用场景
|
||||
|
||||
- **SaaS AI 副驾驶** — 几行代码为你的产品加上 AI 副驾驶,不需要重写后端。
|
||||
- **SaaS AI 副驾驶** — 几行代码为你的产品加上 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),让你自己的 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.5.5/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.5.5/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.0/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.6.0/files/dist/iife/page-agent.demo.js |
|
||||
|
||||
### NPM 安装
|
||||
|
||||
@@ -77,9 +75,9 @@ await agent.execute('点击登录按钮')
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎社区贡献!请参阅 [CONTRIBUTING.md](../CONTRIBUTING.md) 了解环境配置和本地开发说明。
|
||||
欢迎社区贡献!请参阅 [CONTRIBUTING.md](../CONTRIBUTING.md) 了解安装与贡献指南。请在贡献前阅读[行为准则](CODE_OF_CONDUCT.md)。
|
||||
|
||||
请在贡献前阅读[行为准则](CODE_OF_CONDUCT.md)。
|
||||
我们不接受未经实质性人类参与、完全由 Bot 或 Agent 自动生成的代码,机器人账号可能被禁止参与互动。
|
||||
|
||||
## 👏 致谢
|
||||
|
||||
@@ -90,12 +88,10 @@ 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.
|
||||
@@ -114,8 +110,8 @@ file and in the node_modules directory after installation.
|
||||
|
||||
<a href="https://www.star-history.com/?repos=alibaba%2Fpage-agent&type=date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&theme=dark&legend=top-left&v=2" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&legend=top-left&v=2" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&legend=top-left&v=2" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&theme=dark&legend=top-left&v=7" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&legend=top-left&v=7" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=alibaba/page-agent&type=date&legend=top-left&v=7" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**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). "The software" refers to PageAgent.js (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.
|
||||
"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.
|
||||
|
||||
---
|
||||
|
||||
@@ -12,18 +12,18 @@ The software is a **client-side only** tool with a "Bring Your Own Key" (BYOK) a
|
||||
|
||||
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 under the [MIT License](https://github.com/alibaba/page-agent/blob/main/LICENSE) and can be audited at: 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 API, you agree to the following terms:
|
||||
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
|
||||
Generated
+1269
-545
File diff suppressed because it is too large
Load Diff
+8
-8
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"version": "1.5.5",
|
||||
"version": "1.6.0",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/page-controller",
|
||||
@@ -9,6 +9,7 @@
|
||||
"packages/llms",
|
||||
"packages/core",
|
||||
"packages/page-agent",
|
||||
"packages/mcp",
|
||||
"packages/extension",
|
||||
"packages/website"
|
||||
],
|
||||
@@ -32,20 +33,19 @@
|
||||
"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",
|
||||
"version:ext": "node scripts/ext-version.js",
|
||||
"lint": "eslint .",
|
||||
"cleanup": "rm -rf packages/*/dist",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.4.3",
|
||||
"@commitlint/config-conventional": "^20.4.3",
|
||||
"@commitlint/cli": "^20.5.0",
|
||||
"@commitlint/config-conventional": "^20.5.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@microsoft/api-extractor": "^7.57.7",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/node": "^25.4.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",
|
||||
@@ -57,10 +57,10 @@
|
||||
"eslint-plugin-react-x": "^2.13.0",
|
||||
"globals": "^17.4.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.3.2",
|
||||
"lint-staged": "^16.4.0",
|
||||
"prettier": "^3.8.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"typescript-eslint": "^8.57.1",
|
||||
"unplugin-dts": "^1.0.0-beta.6",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-css-injected-by-js": "^4.0.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@page-agent/core",
|
||||
"private": false,
|
||||
"version": "1.5.5",
|
||||
"version": "1.6.0",
|
||||
"type": "module",
|
||||
"main": "./dist/esm/page-agent-core.js",
|
||||
"module": "./dist/esm/page-agent-core.js",
|
||||
@@ -44,8 +44,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"@page-agent/llms": "1.5.5",
|
||||
"@page-agent/page-controller": "1.5.5"
|
||||
"@page-agent/llms": "1.6.0",
|
||||
"@page-agent/page-controller": "1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Copyright (C) 2025 Alibaba Group Holding Limited
|
||||
* Copyright (C) 2026 SimonLuvRamen
|
||||
* All rights reserved.
|
||||
*/
|
||||
import { InvokeError, LLM, type Tool } from '@page-agent/llms'
|
||||
@@ -176,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
|
||||
@@ -343,7 +344,7 @@ export class PageAgentCore extends EventTarget {
|
||||
return result
|
||||
}
|
||||
|
||||
await waitFor(0.4) // @TODO: configurable
|
||||
await waitFor(this.config.stepDelay ?? 0.4)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,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.`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -527,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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -152,6 +152,12 @@ export interface AgentConfig extends LLMConfig {
|
||||
* @experimental Use with caution - incorrect prompts may break agent behavior.
|
||||
*/
|
||||
customSystemPrompt?: string
|
||||
|
||||
/**
|
||||
* Delay between steps in seconds.
|
||||
* @default 0.4
|
||||
*/
|
||||
stepDelay?: number
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,15 @@ 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> {
|
||||
const origin = new URL(url).origin
|
||||
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`
|
||||
|
||||
@@ -115,8 +115,8 @@ 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
|
||||
@@ -205,8 +205,8 @@ import type {
|
||||
|
||||
interface ExecuteConfig {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
apiKey?: string
|
||||
includeInitialTab?: boolean
|
||||
onStatusChange?: (status: AgentStatus) => void
|
||||
onActivity?: (activity: AgentActivity) => void
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@page-agent/ext",
|
||||
"private": true,
|
||||
"version": "0.1.17",
|
||||
"version": "1.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wxt",
|
||||
@@ -16,32 +16,31 @@
|
||||
"@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/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"@wxt-dev/module-react": "^1.2.1",
|
||||
"@wxt-dev/module-react": "^1.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-react": "^0.577.0",
|
||||
"motion": "^12.35.2",
|
||||
"motion": "^12.37.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"rough-notation": "^0.5.1",
|
||||
"simple-icons": "^16.11.0",
|
||||
"simple-icons": "^16.12.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.19"
|
||||
},
|
||||
"dependencies": {
|
||||
"@page-agent/core": "1.5.5",
|
||||
"@page-agent/llms": "1.5.5",
|
||||
"@page-agent/page-controller": "1.5.5",
|
||||
"@page-agent/ui": "1.5.5",
|
||||
"@page-agent/core": "1.6.0",
|
||||
"@page-agent/llms": "1.6.0",
|
||||
"@page-agent/page-controller": "1.6.0",
|
||||
"@page-agent/ui": "1.6.0",
|
||||
"ai-motion": "^0.4.8",
|
||||
"chalk": "^5.6.2"
|
||||
},
|
||||
|
||||
@@ -75,6 +75,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!)
|
||||
},
|
||||
|
||||
@@ -58,8 +58,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 +176,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 = [
|
||||
|
||||
@@ -52,7 +52,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) })
|
||||
@@ -63,7 +63,7 @@ export function handleTabControlMessage(
|
||||
case 'create_tab_group': {
|
||||
debug('create_tab_group', payload)
|
||||
chrome.tabs
|
||||
.group({ tabIds: payload.tabIds, createProperties: { windowId: payload.windowId } })
|
||||
.group({ tabIds: payload.tabIds })
|
||||
.then((groupId) => {
|
||||
debug('create_tab_group: success', groupId)
|
||||
sendResponse({ success: true, groupId })
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isContentScriptAllowed } from './RemotePageController'
|
||||
|
||||
const PREFIX = '[TabsController]'
|
||||
|
||||
function debug(...messages: any[]) {
|
||||
@@ -27,7 +29,6 @@ export class TabsController extends EventTarget {
|
||||
private initialTabId: number | null = null
|
||||
private tabGroupId: number | null = null
|
||||
private task: string = ''
|
||||
private windowId: number | null = null
|
||||
|
||||
async init(task: string, includeInitialTab: boolean = true) {
|
||||
debug('init', task, includeInitialTab)
|
||||
@@ -37,7 +38,6 @@ export class TabsController extends EventTarget {
|
||||
this.currentTabId = null
|
||||
this.tabGroupId = null
|
||||
this.initialTabId = null
|
||||
this.windowId = null
|
||||
|
||||
const result = await sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
@@ -51,22 +51,25 @@ export class TabsController extends EventTarget {
|
||||
}
|
||||
|
||||
if (includeInitialTab) {
|
||||
this.currentTabId = this.initialTabId
|
||||
|
||||
// update tab status immediately
|
||||
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)) {
|
||||
this.currentTabId = this.initialTabId
|
||||
|
||||
this.tabs.push({
|
||||
id: result.tabId,
|
||||
isInitial: true,
|
||||
url: info.url,
|
||||
title: info.title,
|
||||
status: info.status,
|
||||
})
|
||||
|
||||
await this.createTabGroup([this.initialTabId])
|
||||
}
|
||||
}
|
||||
|
||||
await this.updateCurrentTabId(this.currentTabId)
|
||||
@@ -132,9 +135,6 @@ export class TabsController extends EventTarget {
|
||||
}
|
||||
|
||||
const tabId = result.tabId as number
|
||||
const windowId = result.windowId as number
|
||||
|
||||
this.windowId = windowId
|
||||
|
||||
this.tabs.push({
|
||||
id: tabId,
|
||||
@@ -144,32 +144,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 +205,33 @@ export class TabsController extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
private async createTabGroup(tabIds: number[]) {
|
||||
const result = await sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'create_tab_group',
|
||||
payload: { tabIds },
|
||||
})
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async updateCurrentTabId(tabId: number | null) {
|
||||
debug('updateCurrentTabId', tabId)
|
||||
|
||||
@@ -309,16 +311,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.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import type {
|
||||
AgentActivity,
|
||||
AgentStatus,
|
||||
ExecutionResult,
|
||||
HistoricalEvent,
|
||||
SupportedLanguage,
|
||||
} from '@page-agent/core'
|
||||
@@ -20,6 +21,7 @@ export interface AdvancedConfig {
|
||||
maxSteps?: number
|
||||
systemInstruction?: string
|
||||
experimentalLlmsTxt?: boolean
|
||||
disableNamedToolChoice?: boolean
|
||||
}
|
||||
|
||||
export interface ExtConfig extends LLMConfig, AdvancedConfig {
|
||||
@@ -32,7 +34,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>
|
||||
}
|
||||
@@ -110,7 +112,7 @@ export function useAgent(): UseAgentResult {
|
||||
|
||||
setCurrentTask(task)
|
||||
setHistory([])
|
||||
await agent.execute(task)
|
||||
return agent.execute(task)
|
||||
}, [])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
@@ -123,6 +125,7 @@ export function useAgent(): UseAgentResult {
|
||||
maxSteps,
|
||||
systemInstruction,
|
||||
experimentalLlmsTxt,
|
||||
disableNamedToolChoice,
|
||||
...llmConfig
|
||||
}: ExtConfig) => {
|
||||
await chrome.storage.local.set({ llmConfig })
|
||||
@@ -131,7 +134,12 @@ export function useAgent(): UseAgentResult {
|
||||
} else {
|
||||
await chrome.storage.local.remove('language')
|
||||
}
|
||||
const advancedConfig: AdvancedConfig = { maxSteps, systemInstruction, experimentalLlmsTxt }
|
||||
const advancedConfig: AdvancedConfig = {
|
||||
maxSteps,
|
||||
systemInstruction,
|
||||
experimentalLlmsTxt,
|
||||
disableNamedToolChoice,
|
||||
}
|
||||
await chrome.storage.local.set({ advancedConfig })
|
||||
setConfig({ ...llmConfig, ...advancedConfig, language })
|
||||
},
|
||||
|
||||
+38
-23
@@ -1,18 +1,20 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
Copy,
|
||||
CornerUpLeft,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FoldVertical,
|
||||
HatGlasses,
|
||||
Home,
|
||||
Loader2,
|
||||
Scale,
|
||||
UnfoldVertical,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { siGithub } from 'simple-icons'
|
||||
|
||||
import { DEMO_API_KEY, DEMO_BASE_URL, DEMO_MODEL, isTestingEndpoint } from '@/agent/constants'
|
||||
import { DEMO_BASE_URL, DEMO_MODEL, isTestingEndpoint } from '@/agent/constants'
|
||||
import type { ExtConfig, LanguagePreference } from '@/agent/useAgent'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -25,15 +27,18 @@ interface ConfigPanelProps {
|
||||
}
|
||||
|
||||
export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
const [apiKey, setApiKey] = useState(config?.apiKey || DEMO_API_KEY)
|
||||
const [baseURL, setBaseURL] = useState(config?.baseURL || DEMO_BASE_URL)
|
||||
const [model, setModel] = useState(config?.model || DEMO_MODEL)
|
||||
const [apiKey, setApiKey] = useState(config?.apiKey)
|
||||
const [language, setLanguage] = useState<LanguagePreference>(config?.language)
|
||||
const [maxSteps, setMaxSteps] = useState<number | undefined>(config?.maxSteps)
|
||||
const [systemInstruction, setSystemInstruction] = useState(config?.systemInstruction ?? '')
|
||||
const [experimentalLlmsTxt, setExperimentalLlmsTxt] = useState(
|
||||
config?.experimentalLlmsTxt ?? false
|
||||
)
|
||||
const [disableNamedToolChoice, setDisableNamedToolChoice] = useState(
|
||||
config?.disableNamedToolChoice ?? false
|
||||
)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [userAuthToken, setUserAuthToken] = useState<string>('')
|
||||
@@ -42,13 +47,14 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
const [showApiKey, setShowApiKey] = useState(false)
|
||||
|
||||
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)
|
||||
setDisableNamedToolChoice(config?.disableNamedToolChoice ?? false)
|
||||
}, [config])
|
||||
|
||||
// Poll for user auth token every second until found
|
||||
@@ -94,6 +100,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
maxSteps: maxSteps || undefined,
|
||||
systemInstruction: systemInstruction || undefined,
|
||||
experimentalLlmsTxt,
|
||||
disableNamedToolChoice,
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
@@ -153,6 +160,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
|
||||
@@ -167,7 +184,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
{isTestingEndpoint(baseURL) && (
|
||||
<div className="p-2.5 rounded-md border border-amber-500/30 bg-amber-500/5 text-[11px] text-muted-foreground leading-relaxed">
|
||||
<Scale className="size-3 inline-block mr-1 -mt-0.5 text-amber-600" />
|
||||
You are using the free testing API. By using this service you agree to the{' '}
|
||||
You are using our testing API. By using this you agree to the{' '}
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md"
|
||||
target="_blank"
|
||||
@@ -176,14 +193,13 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
>
|
||||
Terms of Use & Privacy Policy
|
||||
</a>
|
||||
. No sensitive data. No guaranteed availability.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">Model</label>
|
||||
<Input
|
||||
placeholder="gpt-5.2"
|
||||
placeholder="gpt-5.1"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="text-xs h-8"
|
||||
@@ -195,7 +211,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"
|
||||
@@ -212,7 +228,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">Language</label>
|
||||
<label className="text-xs text-muted-foreground">Response Language</label>
|
||||
<select
|
||||
value={language ?? ''}
|
||||
onChange={(e) => setLanguage((e.target.value || undefined) as LanguagePreference)}
|
||||
@@ -231,10 +247,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground cursor-pointer mt-1 font-bold"
|
||||
>
|
||||
Advanced
|
||||
<ChevronDown
|
||||
className="size-3 transition-transform"
|
||||
style={{ transform: advancedOpen ? 'rotate(0deg)' : 'rotate(90deg)' }}
|
||||
/>
|
||||
{advancedOpen ? <FoldVertical className="size-3" /> : <UnfoldVertical className="size-3" />}
|
||||
</button>
|
||||
|
||||
{advancedOpen && (
|
||||
@@ -263,6 +276,11 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-xs text-muted-foreground">Disable named tool_choice</span>
|
||||
<Switch checked={disableNamedToolChoice} onCheckedChange={setDisableNamedToolChoice} />
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-xs text-muted-foreground">Experimental llms.txt support</span>
|
||||
<Switch checked={experimentalLlmsTxt} onCheckedChange={setExperimentalLlmsTxt} />
|
||||
@@ -286,6 +304,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"
|
||||
@@ -297,7 +319,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"
|
||||
@@ -315,18 +339,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 */}
|
||||
+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>
|
||||
+1
-1
@@ -84,7 +84,7 @@ export function MotionOverlay({ active }: { active: boolean }) {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="pointer-events-none absolute inset-0 z-10 opacity-60"
|
||||
className="pointer-events-none absolute inset-0 z-10 opacity-60 overflow-hidden"
|
||||
style={{ display: active ? undefined : 'none' }}
|
||||
/>
|
||||
)
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
@@ -30,7 +30,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,8 +84,8 @@ export default defineUnlistedScript(() => {
|
||||
task,
|
||||
config: {
|
||||
baseURL: config.baseURL,
|
||||
apiKey: config.apiKey,
|
||||
model: config.model,
|
||||
apiKey: config.apiKey,
|
||||
includeInitialTab: config.includeInitialTab,
|
||||
},
|
||||
},
|
||||
@@ -110,9 +109,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,
|
||||
@@ -41,6 +40,7 @@ export default defineConfig({
|
||||
artifactTemplate: 'page-agent-ext-{{version}}-{{browser}}.zip',
|
||||
},
|
||||
manifest: {
|
||||
key: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqbzT0iTYeYlnCvDJIGDnGU8oarJgZILDzSfLi/ufuSxXEPDKuMyD892GhvrMCZNVHS11Sh6NYUOc/PcUOhtaR2urHtcNkrpSJNV10zUamY7fxBdVEkOucfyLu8INVy+teis62MoRWYPaUPkfZUjrLGW8MsZ9aFzARfu9GGDEp2EAYsWDN6w6vyz9LJ82pm542EWnVT4MjmDPgvYFCWGBtaU/dfHD+GAX6URJFapsCvryVURKJ+76c/GO9/I3EX1IBfbY6dec78bLCMvVxiTmiv36KyGPwX1OpakW8IiCpXWdbAxjm+plbYlp5t5zTyyoE3sOSFeXsBH0Kg27o8GcvQIDAQAB',
|
||||
default_locale: 'en',
|
||||
name: '__MSG_extName__',
|
||||
description: '__MSG_extDescription__',
|
||||
@@ -62,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.5.5",
|
||||
"version": "1.6.0",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-agent-llms.js",
|
||||
"module": "./dist/lib/page-agent-llms.js",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -93,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.0",
|
||||
"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.19.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
+95
@@ -0,0 +1,95 @@
|
||||
#!/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 browser automation task described in natural language. ' +
|
||||
'The Page Agent extension will control the browser to complete the task. ' +
|
||||
'Blocks until the task is complete.',
|
||||
inputSchema: { task: z.string().describe('Task description in natural language') },
|
||||
},
|
||||
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 successfully.\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 connection and agent.',
|
||||
},
|
||||
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.5.5",
|
||||
"version": "1.6.0",
|
||||
"type": "module",
|
||||
"main": "./dist/esm/page-agent.js",
|
||||
"module": "./dist/esm/page-agent.js",
|
||||
@@ -44,10 +44,10 @@
|
||||
"postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@page-agent/core": "1.5.5",
|
||||
"@page-agent/llms": "1.5.5",
|
||||
"@page-agent/page-controller": "1.5.5",
|
||||
"@page-agent/ui": "1.5.5",
|
||||
"@page-agent/core": "1.6.0",
|
||||
"@page-agent/llms": "1.6.0",
|
||||
"@page-agent/page-controller": "1.6.0",
|
||||
"@page-agent/ui": "1.6.0",
|
||||
"chalk": "^5.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -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.5.5",
|
||||
"version": "1.6.0",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-controller.js",
|
||||
"module": "./dist/lib/page-controller.js",
|
||||
|
||||
@@ -18,6 +18,7 @@ import * as dom from './dom'
|
||||
import type { FlatDomTree, InteractiveElementDomNode } from './dom/dom_tree/type'
|
||||
import { getPageInfo } from './dom/getPageInfo'
|
||||
import { patchReact } from './patches/react'
|
||||
import { isAnchorElement } from './utils'
|
||||
|
||||
/**
|
||||
* Configuration for PageController
|
||||
@@ -243,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.`,
|
||||
|
||||
@@ -3,24 +3,15 @@
|
||||
* 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.
|
||||
@@ -39,7 +30,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,6 +45,9 @@ function blurLastClickedElement() {
|
||||
lastClickedElement.dispatchEvent(
|
||||
new MouseEvent('mouseout', { bubbles: true, cancelable: true })
|
||||
)
|
||||
lastClickedElement.dispatchEvent(
|
||||
new MouseEvent('mouseleave', { bubbles: false, cancelable: true })
|
||||
)
|
||||
lastClickedElement = null
|
||||
}
|
||||
}
|
||||
@@ -65,9 +59,15 @@ 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
|
||||
@@ -89,25 +89,9 @@ export async function clickElement(element: HTMLElement) {
|
||||
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!
|
||||
|
||||
export async function inputTextElement(element: HTMLElement, text: string) {
|
||||
const isContentEditable = element.isContentEditable
|
||||
if (
|
||||
!(element instanceof HTMLInputElement) &&
|
||||
!(element instanceof HTMLTextAreaElement) &&
|
||||
!isContentEditable
|
||||
) {
|
||||
if (!isInputElement(element) && !isTextAreaElement(element) && !isContentEditable) {
|
||||
throw new Error('Element is not an input, textarea, or contenteditable')
|
||||
}
|
||||
|
||||
@@ -119,9 +103,12 @@ export async function inputTextElement(element: HTMLElement, text: string) {
|
||||
// - Monaco/CodeMirror: Require direct JS instance access. No universal way to obtain.
|
||||
// - Draft.js: Not responsive to synthetic/execCommand/Range/DataTransfer. Unmaintained.
|
||||
//
|
||||
// Strategy: Try Plan A (synthetic events) first, then verify and fall back
|
||||
// to Plan B (execCommand) if the text wasn't actually inserted.
|
||||
//
|
||||
// Plan A: Dispatch synthetic events
|
||||
// Works: LinkedIn, React contenteditable, Quill.
|
||||
// Fails: Slate.js
|
||||
// Works: React contenteditable, Quill.
|
||||
// Fails: Slate.js, some contenteditable editors that ignore synthetic events.
|
||||
// Sequence: beforeinput -> mutation -> input -> change -> blur
|
||||
|
||||
// Dispatch beforeinput + mutation + input for clearing
|
||||
@@ -164,22 +151,37 @@ export async function inputTextElement(element: HTMLElement, text: string) {
|
||||
)
|
||||
}
|
||||
|
||||
// Verify Plan A worked by checking if the text was actually inserted
|
||||
const planASucceeded = element.innerText.trim() === text.trim()
|
||||
|
||||
if (!planASucceeded) {
|
||||
// Plan B: execCommand fallback (deprecated but widely supported)
|
||||
// Works: Quill, Slate.js, react contenteditable components.
|
||||
// This approach integrates with the browser's undo stack and is handled
|
||||
// natively by most rich-text editors.
|
||||
element.focus()
|
||||
|
||||
// Select all existing content and delete it
|
||||
const doc = element.ownerDocument
|
||||
const selection = (doc.defaultView || window).getSelection()
|
||||
const range = doc.createRange()
|
||||
range.selectNodeContents(element)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
doc.execCommand('delete', false)
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
doc.execCommand('insertText', false, text)
|
||||
}
|
||||
|
||||
// Dispatch change event (for good measure)
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
|
||||
// Trigger blur for validation
|
||||
element.blur()
|
||||
|
||||
// Plan B: execCommand (deprecated but works better for some editors)
|
||||
// Works: LinkedIn, Quill, Slate.js, react contenteditable components
|
||||
//
|
||||
// document.execCommand('selectAll')
|
||||
// document.execCommand('delete')
|
||||
// document.execCommand('insertText', false, text)
|
||||
} else if (element instanceof HTMLTextAreaElement) {
|
||||
nativeTextAreaValueSetter.call(element, text)
|
||||
} else {
|
||||
nativeInputValueSetter.call(element, text)
|
||||
getNativeValueSetter(element as HTMLInputElement | HTMLTextAreaElement).call(element, text)
|
||||
}
|
||||
|
||||
// Only dispatch shared input event for non-contenteditable (contenteditable has its own)
|
||||
@@ -196,7 +198,7 @@ export async function inputTextElement(element: HTMLElement, text: string) {
|
||||
* @todo browser-use version is very complex and supports menu tags, need to follow up
|
||||
*/
|
||||
export async function selectOptionElement(selectElement: HTMLSelectElement, optionText: string) {
|
||||
if (!(selectElement instanceof HTMLSelectElement)) {
|
||||
if (!isSelectElement(selectElement)) {
|
||||
throw new Error('Element is not a select element')
|
||||
}
|
||||
|
||||
@@ -213,14 +215,18 @@ 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,3 +7,7 @@
|
||||
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wrapper.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ export class SimulatorMask {
|
||||
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
|
||||
@@ -172,7 +172,7 @@ export class SimulatorMask {
|
||||
this.#cursor.classList.remove(cursorStyles.clicking)
|
||||
|
||||
setTimeout(() => {
|
||||
this.wrapper.style.display = 'none'
|
||||
this.wrapper.classList.remove(styles.visible)
|
||||
}, 800) // Match the animation duration
|
||||
}
|
||||
|
||||
|
||||
@@ -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.5.5",
|
||||
"version": "1.6.0",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-agent-ui.js",
|
||||
"module": "./dist/lib/page-agent-ui.js",
|
||||
|
||||
@@ -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 `
|
||||
@@ -40,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
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@page-agent/website",
|
||||
"private": true,
|
||||
"version": "1.5.5",
|
||||
"version": "1.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
@@ -20,12 +20,12 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"motion": "^12.35.2",
|
||||
"motion": "^12.37.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"rough-notation": "^0.5.1",
|
||||
"simple-icons": "^16.11.0",
|
||||
"simple-icons": "^16.12.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
|
||||
@@ -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 />
|
||||
|
||||
@@ -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.5.5/dist/iife/page-agent.demo.js'
|
||||
'https://cdn.jsdelivr.net/npm/page-agent@1.6.0/dist/iife/page-agent.demo.js'
|
||||
export const CDN_DEMO_CN_URL =
|
||||
'https://registry.npmmirror.com/page-agent/1.5.5/files/dist/iife/page-agent.demo.js'
|
||||
'https://registry.npmmirror.com/page-agent/1.6.0/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])
|
||||
}
|
||||
@@ -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
|
||||
@@ -65,6 +66,12 @@ export default function DocsLayout({ children }: DocsLayoutProps) {
|
||||
},
|
||||
]
|
||||
|
||||
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">
|
||||
|
||||
@@ -129,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',
|
||||
@@ -143,6 +137,12 @@ 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',
|
||||
@@ -156,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',
|
||||
|
||||
@@ -187,39 +187,7 @@ localStorage.setItem('PageAgentExtUserAuthToken', '<your-token-from-extension>')
|
||||
</p>
|
||||
|
||||
<CodeEditor
|
||||
code={
|
||||
isZh
|
||||
? `import type {
|
||||
AgentActivity,
|
||||
AgentStatus,
|
||||
ExecutionResult,
|
||||
HistoricalEvent
|
||||
} from '@page-agent/core'
|
||||
|
||||
interface ExecuteConfig {
|
||||
baseURL: string // LLM API 端点
|
||||
apiKey: string // API 密钥
|
||||
model: string // 模型名称
|
||||
|
||||
includeInitialTab?: boolean
|
||||
onStatusChange?: (status: AgentStatus) => void
|
||||
onActivity?: (activity: AgentActivity) => void
|
||||
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
||||
}
|
||||
|
||||
type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
PAGE_AGENT_EXT_VERSION?: string
|
||||
PAGE_AGENT_EXT?: {
|
||||
version: string
|
||||
execute: Execute
|
||||
stop: () => void
|
||||
}
|
||||
}
|
||||
}`
|
||||
: `import type {
|
||||
code={`import type {
|
||||
AgentActivity,
|
||||
AgentStatus,
|
||||
ExecutionResult,
|
||||
@@ -228,8 +196,8 @@ declare global {
|
||||
|
||||
interface ExecuteConfig {
|
||||
baseURL: string // LLM API endpoint
|
||||
apiKey: string // API key
|
||||
model: string // Model name
|
||||
apiKey?: string // LLM AK
|
||||
|
||||
includeInitialTab?: boolean
|
||||
onStatusChange?: (status: AgentStatus) => void
|
||||
@@ -248,8 +216,7 @@ declare global {
|
||||
stop: () => void
|
||||
}
|
||||
}
|
||||
}`
|
||||
}
|
||||
}`}
|
||||
language="typescript"
|
||||
/>
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ const MODEL_GROUPS: Record<string, string[]> = {
|
||||
'claude-sonnet-3.5',
|
||||
],
|
||||
xAI: ['grok-4.1-fast', 'grok-4', 'grok-code-fast'],
|
||||
MiniMax: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'],
|
||||
MoonshotAI: ['kimi-k2.5'],
|
||||
'Z.AI': ['glm-5', 'glm-4.7'],
|
||||
}
|
||||
@@ -121,10 +122,16 @@ const pageAgent = new PageAgent({
|
||||
model: 'qwen3.5-plus'
|
||||
});
|
||||
|
||||
// Self-hosted models (e.g., Ollama)
|
||||
// MiniMax
|
||||
const pageAgent = new PageAgent({
|
||||
baseURL: 'https://api.minimax.io/v1',
|
||||
apiKey: 'your-minimax-api-key',
|
||||
model: 'MiniMax-M2.7'
|
||||
});
|
||||
|
||||
// Self-hosted models (e.g., Ollama) — no apiKey needed
|
||||
const pageAgent = new PageAgent({
|
||||
baseURL: 'http://localhost:11434/v1',
|
||||
apiKey: 'NA',
|
||||
model: 'qwen3:14b'
|
||||
});
|
||||
|
||||
@@ -272,7 +279,6 @@ LLM_MODEL_NAME="qwen3:14b"`}
|
||||
<CodeEditor
|
||||
code={`const agent = new PageAgent({
|
||||
baseURL: '/api/llm-proxy',
|
||||
apiKey: 'NA',
|
||||
model: 'gpt-5.1',
|
||||
customFetch: (url, init) =>
|
||||
fetch(url, { ...init, credentials: 'include' }),
|
||||
|
||||
@@ -16,17 +16,6 @@ export default function ThirdPartyAgentPage() {
|
||||
: 'Integrate pageAgent as a tool in your support assistant or Agent system, becoming the eyes and hands of your Agent.'}
|
||||
</p>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 mb-6">
|
||||
<h3 className="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-300">
|
||||
{isZh ? '💡 核心价值' : '💡 Core Value'}
|
||||
</h3>
|
||||
<p className="text-blue-800 dark:text-blue-200">
|
||||
{isZh
|
||||
? '让你的答疑机器人不再只是"嘴巴",而是拥有"眼睛"和"手"的完整智能体。'
|
||||
: 'Transform your support bot from just a "mouth" into a complete intelligent agent with "eyes" and "hands".'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Heading id="integration-method" className="text-2xl font-bold mb-4">
|
||||
{isZh ? '集成方式' : 'Integration Method'}
|
||||
</Heading>
|
||||
|
||||
@@ -116,6 +116,9 @@ export default function LimitationsPage() {
|
||||
isZh ? '键盘快捷键' : 'Keyboard shortcuts',
|
||||
isZh ? '坐标定位操作' : 'Position-based control',
|
||||
isZh ? '绘图操作' : 'Drawing',
|
||||
isZh
|
||||
? 'Monaco、CodeMirror 等需要通过 JS 实例控制的编辑器'
|
||||
: 'Monaco, CodeMirror and other editors that require JS instance access',
|
||||
].map((text) => (
|
||||
<li key={text} className="flex items-center text-gray-700 dark:text-gray-300">
|
||||
<span className="mr-2 text-red-600 dark:text-red-400">✗</span>
|
||||
@@ -128,8 +131,8 @@ export default function LimitationsPage() {
|
||||
</div>
|
||||
|
||||
{/* Understanding Limitations */}
|
||||
<Heading id="understanding" className="text-2xl font-bold mb-3 mt-6">
|
||||
{isZh ? '理解能力' : 'Understanding'}
|
||||
<Heading id="text-based-approach" className="text-2xl font-bold mb-3 mt-6">
|
||||
{isZh ? '基于文本的方案' : 'Text-Based Approach'}
|
||||
</Heading>
|
||||
|
||||
<p className="mb-2 font-medium">
|
||||
|
||||
@@ -410,7 +410,7 @@ export default function TroubleshootingPage() {
|
||||
.getElementById(section.id)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
className={`block text-sm py-1 text-left transition-colors ${
|
||||
className={`block cursor-pointer py-1 text-left text-sm transition-colors ${
|
||||
activeId === section.id
|
||||
? 'text-blue-600 dark:text-blue-400 font-medium'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Particles } from '../../components/ui/particles'
|
||||
import {
|
||||
CDN_DEMO_CN_URL,
|
||||
CDN_DEMO_URL,
|
||||
DEMO_API_KEY,
|
||||
// DEMO_API_KEY,
|
||||
DEMO_BASE_URL,
|
||||
DEMO_MODEL,
|
||||
} from '../../constants'
|
||||
@@ -94,7 +94,7 @@ export default function HeroSection() {
|
||||
apiKey:
|
||||
import.meta.env.DEV && import.meta.env.LLM_API_KEY
|
||||
? import.meta.env.LLM_API_KEY
|
||||
: DEMO_API_KEY,
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Suspense, lazy } from 'react'
|
||||
|
||||
import { useDocumentTitle } from '@/lib/useDocumentTitle'
|
||||
|
||||
import HeroSection from './HeroSection'
|
||||
|
||||
const FeaturesSection = lazy(() => import('./FeaturesSection'))
|
||||
@@ -7,6 +9,8 @@ const ScenariosSection = lazy(() => import('./ScenariosSection'))
|
||||
const OneMoreThingSection = lazy(() => import('./OneMoreThingSection'))
|
||||
|
||||
export default function HomePage() {
|
||||
useDocumentTitle()
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeroSection />
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
important: '#root',
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import { copyFileSync, mkdirSync, readFileSync } from 'node:fs'
|
||||
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import process from 'node:process'
|
||||
import { dirname, join, resolve } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
@@ -13,7 +13,7 @@ const pageAgentPkg = JSON.parse(
|
||||
)
|
||||
|
||||
// Load .env from repo root
|
||||
dotenvConfig({ path: resolve(__dirname, '../../.env') })
|
||||
dotenvConfig({ path: resolve(__dirname, '../../.env'), quiet: true })
|
||||
|
||||
// All SPA routes that need index.html copies for direct access on static hosts
|
||||
const SPA_ROUTES = [
|
||||
@@ -35,6 +35,8 @@ const SPA_ROUTES = [
|
||||
'docs/advanced/security-permissions',
|
||||
]
|
||||
|
||||
const SITE_URL = 'https://alibaba.github.io/page-agent'
|
||||
|
||||
function spaRoutes() {
|
||||
return {
|
||||
name: 'spa-routes',
|
||||
@@ -47,6 +49,19 @@ function spaRoutes() {
|
||||
copyFileSync(src, join(dir, 'index.html'))
|
||||
}
|
||||
console.log(` ✓ Copied index.html to ${SPA_ROUTES.length} SPA routes`)
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const urls = ['', ...SPA_ROUTES]
|
||||
.map(
|
||||
(route) =>
|
||||
` <url>\n <loc>${SITE_URL}/${route}</loc>\n <lastmod>${today}</lastmod>\n </url>`
|
||||
)
|
||||
.join('\n')
|
||||
writeFileSync(
|
||||
join(dist, 'sitemap.xml'),
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`
|
||||
)
|
||||
console.log(` ✓ Generated sitemap.xml with ${SPA_ROUTES.length + 1} URLs`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Bump extension version and show git tag commands
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/ext-version.js 0.1.16
|
||||
*/
|
||||
import chalk from 'chalk'
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
import { exit } from 'process'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const pkgPath = join(__dirname, '..', 'packages', 'extension', 'package.json')
|
||||
|
||||
const newVersion = process.argv[2]
|
||||
if (!newVersion) {
|
||||
console.log(chalk.yellow('⚠️ Usage: npm run ext:version <version>\n'))
|
||||
exit(1)
|
||||
}
|
||||
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
||||
const oldVersion = pkg.version
|
||||
|
||||
pkg.version = newVersion
|
||||
writeFileSync(pkgPath, JSON.stringify(pkg, null, ' ') + '\n')
|
||||
|
||||
console.log(
|
||||
chalk.green.bold('\n✓') +
|
||||
` ${chalk.bold('@page-agent/ext')}: ${chalk.dim(oldVersion)} → ${chalk.yellow(newVersion)}\n`
|
||||
)
|
||||
|
||||
const tagName = `EXT_v${newVersion}`
|
||||
console.log(chalk.cyan.bold('📋 Next steps:\n'))
|
||||
console.log(chalk.blueBright(`npm i`))
|
||||
console.log(
|
||||
chalk.blueBright(`git add . && git commit -m "chore(ext): bump version to ${newVersion}"`)
|
||||
)
|
||||
console.log(chalk.blueBright(`git tag -a ${tagName} -m "${tagName}"`))
|
||||
console.log(chalk.blueBright(`git push && git push origin ${tagName}\n`))
|
||||
+13
-14
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/sync-version.js # Sync current version from root
|
||||
* node scripts/sync-version.js 0.1.0 # Set and sync new version
|
||||
* node scripts/sync-version.js 0.1.0 # Set root version, then sync all packages
|
||||
*/
|
||||
import chalk from 'chalk'
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'
|
||||
@@ -15,19 +15,18 @@ import { fileURLToPath } from 'url'
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const rootDir = join(__dirname, '..')
|
||||
|
||||
// Parse arguments
|
||||
const versionArg = process.argv[2]
|
||||
|
||||
if (!versionArg) {
|
||||
console.log(chalk.yellow('⚠️ No version specified.\n'))
|
||||
exit(1)
|
||||
}
|
||||
|
||||
// Read root package.json
|
||||
const rootPkgPath = join(rootDir, 'package.json')
|
||||
const rootPkg = JSON.parse(readFileSync(rootPkgPath, 'utf-8'))
|
||||
const oldVersion = rootPkg.version
|
||||
const newVersion = versionArg
|
||||
const newVersion = versionArg ?? rootPkg.version
|
||||
|
||||
if (!newVersion) {
|
||||
console.log(chalk.yellow('⚠️ No version found in root package.json.\n'))
|
||||
exit(1)
|
||||
}
|
||||
|
||||
console.log(chalk.cyan.bold('\n📦 Syncing version\n'))
|
||||
|
||||
@@ -35,7 +34,10 @@ console.log(chalk.cyan.bold('\n📦 Syncing version\n'))
|
||||
if (versionArg) {
|
||||
rootPkg.version = newVersion
|
||||
writeFileSync(rootPkgPath, JSON.stringify(rootPkg, null, ' ') + '\n')
|
||||
console.log(chalk.green('✓') + ` ${chalk.bold('root')} → ${chalk.yellow(newVersion)}`)
|
||||
console.log(
|
||||
chalk.green('✓') +
|
||||
` ${chalk.bold('root')}: ${chalk.dim(oldVersion)} → ${chalk.yellow(newVersion)}`
|
||||
)
|
||||
} else {
|
||||
console.log(chalk.dim(' root:') + ` ${chalk.yellow(newVersion)} ${chalk.dim('(source)')}`)
|
||||
}
|
||||
@@ -78,11 +80,8 @@ for (const pkg of packages) {
|
||||
const pkgJson = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
||||
let pkgChanged = false
|
||||
|
||||
// Skip extension package version (it has its own versioning)
|
||||
const isExtension = pkg === 'extension'
|
||||
|
||||
// Update package version (skip extension)
|
||||
if (!isExtension && pkgJson.version !== newVersion) {
|
||||
// Update package version
|
||||
if (pkgJson.version !== newVersion) {
|
||||
pkgJson.version = newVersion
|
||||
pkgChanged = true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user