Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75bc69c0c0 | |||
| 0d9b9e4b9a | |||
| 78a31ef5d1 | |||
| 98005e323a | |||
| f3c253c044 | |||
| 02f63ec04d | |||
| 583f757f0a | |||
| 53c9149d06 | |||
| 64a90e3b3a | |||
| abfa903c9c | |||
| 6dd0ceab73 | |||
| eede819ea8 | |||
| ce3047428a | |||
| 3456f45590 | |||
| 4f71f065df | |||
| cd2d33a9f6 | |||
| 80e96d0b9e | |||
| 2c3b27e337 | |||
| b6691b4732 | |||
| a557a85d28 | |||
| eeb5b6a5af | |||
| ad31e2b750 | |||
| e98d80b6a0 | |||
| 93b1e52376 | |||
| 66a85c0dd3 | |||
| 50ce56a4f6 | |||
| 5aeb40abac | |||
| 8c06cdc898 | |||
| dfde89aa70 | |||
| 49b3316a5f | |||
| c35f367b39 | |||
| 3459836a14 | |||
| b7b5b6db30 | |||
| 05d16313c7 | |||
| a3a96d85d5 | |||
| c89042f142 | |||
| 2e18bd862d | |||
| 0bc47a997d | |||
| bcc7dfea2d | |||
| ba242d3a1b | |||
| fe82b11285 | |||
| bdf79b7c10 | |||
| 24eefdef23 | |||
| 252f550c04 | |||
| 628c249d45 | |||
| 61d598142d | |||
| c0510b2235 | |||
| ca0005c825 | |||
| 17e92b6200 | |||
| 2e0cbef034 | |||
| 95b6785f05 | |||
| 2917941b13 | |||
| 31c496cc65 | |||
| cef39d5090 | |||
| 3063b2a06d | |||
| d66a390516 | |||
| aae6c93f11 | |||
| 2f5476b76c | |||
| 88ea2c6708 | |||
| 37760a97df | |||
| de76d97303 | |||
| 92196ac793 | |||
| 80dda37f8c | |||
| 5e7fe18463 | |||
| 8ad4d19232 | |||
| 2f92a9cb32 |
@@ -1,5 +1,7 @@
|
|||||||
# Contributing to PageAgent
|
# Contributing to PageAgent
|
||||||
|
|
||||||
|
> **[A note from the maintainer](https://github.com/alibaba/page-agent/issues/349)** — Please read before opening issues or PRs.
|
||||||
|
|
||||||
♥️ We welcome contributions from everyone.
|
♥️ We welcome contributions from everyone.
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ The GUI Agent Living in Your Webpage. Control web interfaces with natural langua
|
|||||||
- **📖 Text-based DOM manipulation**
|
- **📖 Text-based DOM manipulation**
|
||||||
- No screenshots. No multi-modal LLMs or special permissions needed.
|
- No screenshots. No multi-modal LLMs or special permissions needed.
|
||||||
- **🧠 Bring your own LLMs**
|
- **🧠 Bring your own LLMs**
|
||||||
- **🎨 Pretty UI with human-in-the-loop**
|
|
||||||
- **🐙 Optional [chrome extension](https://alibaba.github.io/page-agent/docs/features/chrome-extension) for multi-page tasks.**
|
- **🐙 Optional [chrome extension](https://alibaba.github.io/page-agent/docs/features/chrome-extension) for multi-page tasks.**
|
||||||
|
- And an [MCP Server (Beta)](https://alibaba.github.io/page-agent/docs/features/mcp-server) to control it from outside
|
||||||
|
|
||||||
## 💡 Use Cases
|
## 💡 Use Cases
|
||||||
|
|
||||||
@@ -49,8 +49,8 @@ Fastest way to try PageAgent with our free Demo LLM:
|
|||||||
|
|
||||||
| Mirrors | URL |
|
| Mirrors | URL |
|
||||||
| ------- | ---------------------------------------------------------------------------------- |
|
| ------- | ---------------------------------------------------------------------------------- |
|
||||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.5.8/dist/iife/page-agent.demo.js |
|
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.6.2/dist/iife/page-agent.demo.js |
|
||||||
| China | https://registry.npmmirror.com/page-agent/1.5.8/files/dist/iife/page-agent.demo.js |
|
| China | https://registry.npmmirror.com/page-agent/1.6.2/files/dist/iife/page-agent.demo.js |
|
||||||
|
|
||||||
### NPM Installation
|
### NPM Installation
|
||||||
|
|
||||||
@@ -77,9 +77,9 @@ For more programmatic usage, see [📖 Documentations](https://alibaba.github.io
|
|||||||
|
|
||||||
We welcome contributions from the community! Follow our instructions in [CONTRIBUTING.md](CONTRIBUTING.md) for setup and guidelines.
|
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.
|
Please read the [maintainer note](https://github.com/alibaba/page-agent/issues/349) and [Code of Conduct](docs/CODE_OF_CONDUCT.md) before opening issues or PRs.
|
||||||
|
|
||||||
Contributions generated entirely by bots or agents without substantial human involvement will not be accepted, and bot accounts may be blocked.
|
Contributions generated entirely by **bots or agents** without substantial human involvement will **not be accepted**.
|
||||||
|
|
||||||
## 👏 Acknowledgments
|
## 👏 Acknowledgments
|
||||||
|
|
||||||
|
|||||||
+25
-36
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.6.0] - 2026-03-21
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Beta MCP support** - New `@page-agent/mcp` package lets MCP clients such as Claude Desktop and Copilot control the browser through the Page Agent extension
|
||||||
|
- **Better iframe handling** - Same-origin iframe elements are handled more reliably during DOM extraction and actions
|
||||||
|
- **Extension history workflows** - Users can rerun past tasks, export history sessions as JSON, and approve MCP-triggered tasks before execution
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- **Unified versioning across packages** - The extension now follows the root workspace version. Changelog entries are no longer split into a separate extension version section
|
||||||
|
- **Configurable `stepDelay`** - Agent pacing between steps is now configurable via `stepDelay`
|
||||||
|
- **Optional API key** - `apiKey` can now be omitted for compatible deployments that do not require one
|
||||||
|
- **Optional named tool choice** - Tool invocation can disable named tool choice for providers that behave better without it
|
||||||
|
- **Better rich-text input support** - Improved `contenteditable` handling with better event dispatching and `execCommand` fallback for more editors
|
||||||
|
- **More flexible DOM extraction** - `includeAttributes` now supports wildcards, `contenteditable` is included by default, and heuristically interactive elements expose more useful attributes
|
||||||
|
- **MiniMax model support** - Added MiniMax compatibility, with the default recommendation updated to `MiniMax-M2.7`
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixed Safari issues when `requestIdleCallback` is unavailable
|
||||||
|
- Avoid throwing when `webgl2` initialization fails
|
||||||
|
- Improved OpenAI-compatible request patches for GPT-5.4 chat tools and MiniMax temperature/tool-call compatibility
|
||||||
|
- Fixed several UI polish issues in the extension and website, including cursor and layout regressions
|
||||||
|
|
||||||
## [1.5.1] - 2026-03-05
|
## [1.5.1] - 2026-03-05
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
@@ -153,42 +178,6 @@ PageAgent is now ready for production use. The API is stable and breaking change
|
|||||||
- **Ask User Tool** - Agent can ask users for clarification
|
- **Ask User Tool** - Agent can ask users for clarification
|
||||||
- **i18n Support** - English and Chinese localization
|
- **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
|
### Packages
|
||||||
|
|
||||||
| Package | Description |
|
| Package | Description |
|
||||||
|
|||||||
+11
-10
@@ -20,17 +20,16 @@
|
|||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- **🎯 轻松集成**
|
- **🎯 轻松集成**
|
||||||
- 无需 `浏览器插件` / `Python` / `无头浏览器`。
|
- 无需 `浏览器插件` / `Python` / `无头浏览器`,纯页面内 JavaScript
|
||||||
- 纯页面内 JavaScript,一切都在你的网页中完成。
|
|
||||||
- **📖 基于文本的 DOM 操作**
|
- **📖 基于文本的 DOM 操作**
|
||||||
- 无需截图,无需多模态模型或特殊权限。
|
- 无需截图,无需多模态模型或特殊权限
|
||||||
- **🧠 用你自己的 LLM**
|
- **🧠 自备 LLM**
|
||||||
- **🎨 精美 UI,支持人机协同**
|
- 🐙 可选的 [Chrome 扩展](https://alibaba.github.io/page-agent/docs/features/chrome-extension),支持跨页面任务
|
||||||
- **🐙 可选的 [Chrome 扩展](https://alibaba.github.io/page-agent/docs/features/chrome-extension),支持跨页面任务。**
|
- [MCP Server (Beta)](https://alibaba.github.io/page-agent/docs/features/mcp-server)
|
||||||
|
|
||||||
## 💡 应用场景
|
## 💡 应用场景
|
||||||
|
|
||||||
- **SaaS AI 副驾驶** — 几行代码为你的产品加上 AI 副驾驶,无需重写后端。
|
- **SaaS AI Copilot** — 几行代码为你的产品加上 AI 副驾驶,无需重写后端。
|
||||||
- **智能表单填写** — 把 20 次点击变成一句话。ERP、CRM、管理后台的最佳拍档。
|
- **智能表单填写** — 把 20 次点击变成一句话。ERP、CRM、管理后台的最佳拍档。
|
||||||
- **无障碍增强** — 用自然语言让任何网页无障碍。语音指令、屏幕阅读器,零门槛。
|
- **无障碍增强** — 用自然语言让任何网页无障碍。语音指令、屏幕阅读器,零门槛。
|
||||||
- **跨页面 Agent** — 通过可选的 [Chrome 扩展](https://alibaba.github.io/page-agent/docs/features/chrome-extension),让你自己的 Agent 跨标签页工作。
|
- **跨页面 Agent** — 通过可选的 [Chrome 扩展](https://alibaba.github.io/page-agent/docs/features/chrome-extension),让你自己的 Agent 跨标签页工作。
|
||||||
@@ -49,8 +48,8 @@
|
|||||||
|
|
||||||
| Mirrors | URL |
|
| Mirrors | URL |
|
||||||
| ------- | ---------------------------------------------------------------------------------- |
|
| ------- | ---------------------------------------------------------------------------------- |
|
||||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.5.8/dist/iife/page-agent.demo.js |
|
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.6.2/dist/iife/page-agent.demo.js |
|
||||||
| China | https://registry.npmmirror.com/page-agent/1.5.8/files/dist/iife/page-agent.demo.js |
|
| China | https://registry.npmmirror.com/page-agent/1.6.2/files/dist/iife/page-agent.demo.js |
|
||||||
|
|
||||||
### NPM 安装
|
### NPM 安装
|
||||||
|
|
||||||
@@ -75,7 +74,9 @@ await agent.execute('点击登录按钮')
|
|||||||
|
|
||||||
## 🤝 贡献
|
## 🤝 贡献
|
||||||
|
|
||||||
欢迎社区贡献!请参阅 [CONTRIBUTING.md](../CONTRIBUTING.md) 了解安装与贡献指南。请在贡献前阅读[行为准则](CODE_OF_CONDUCT.md)。
|
欢迎社区贡献!请参阅 [CONTRIBUTING.md](../CONTRIBUTING.md) 了解安装与贡献指南。
|
||||||
|
|
||||||
|
提交 issue 或 PR 之前,请先阅读[维护者说明](https://github.com/alibaba/page-agent/issues/349)和[行为准则](CODE_OF_CONDUCT.md)。
|
||||||
|
|
||||||
我们不接受未经实质性人类参与、完全由 Bot 或 Agent 自动生成的代码,机器人账号可能被禁止参与互动。
|
我们不接受未经实质性人类参与、完全由 Bot 或 Agent 自动生成的代码,机器人账号可能被禁止参与互动。
|
||||||
|
|
||||||
|
|||||||
Generated
+1403
-679
File diff suppressed because it is too large
Load Diff
+9
-8
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "root",
|
"name": "root",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.5.8",
|
"version": "1.6.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/page-controller",
|
"packages/page-controller",
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"packages/llms",
|
"packages/llms",
|
||||||
"packages/core",
|
"packages/core",
|
||||||
"packages/page-agent",
|
"packages/page-agent",
|
||||||
|
"packages/mcp",
|
||||||
"packages/extension",
|
"packages/extension",
|
||||||
"packages/website"
|
"packages/website"
|
||||||
],
|
],
|
||||||
@@ -37,14 +38,14 @@
|
|||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^20.4.3",
|
"@commitlint/cli": "^20.5.0",
|
||||||
"@commitlint/config-conventional": "^20.4.3",
|
"@commitlint/config-conventional": "^20.5.0",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@microsoft/api-extractor": "^7.57.7",
|
"@microsoft/api-extractor": "^7.57.7",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"@types/node": "^25.4.0",
|
"@types/node": "^25.5.0",
|
||||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
"@vitejs/plugin-react-swc": "^4.3.0",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
@@ -56,10 +57,10 @@
|
|||||||
"eslint-plugin-react-x": "^2.13.0",
|
"eslint-plugin-react-x": "^2.13.0",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.3.2",
|
"lint-staged": "^16.4.0",
|
||||||
"prettier": "^3.8.0",
|
"prettier": "^3.8.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.57.0",
|
"typescript-eslint": "^8.57.2",
|
||||||
"unplugin-dts": "^1.0.0-beta.6",
|
"unplugin-dts": "^1.0.0-beta.6",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-css-injected-by-js": "^4.0.1",
|
"vite-plugin-css-injected-by-js": "^4.0.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/core",
|
"name": "@page-agent/core",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "1.5.8",
|
"version": "1.6.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/esm/page-agent-core.js",
|
"main": "./dist/esm/page-agent-core.js",
|
||||||
"module": "./dist/esm/page-agent-core.js",
|
"module": "./dist/esm/page-agent-core.js",
|
||||||
@@ -44,8 +44,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"@page-agent/llms": "1.5.8",
|
"@page-agent/llms": "1.6.2",
|
||||||
"@page-agent/page-controller": "1.5.8"
|
"@page-agent/page-controller": "1.6.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
|
|||||||
@@ -115,8 +115,8 @@ import type {
|
|||||||
|
|
||||||
export interface ExecuteConfig {
|
export interface ExecuteConfig {
|
||||||
baseURL: string
|
baseURL: string
|
||||||
apiKey: string
|
|
||||||
model: string
|
model: string
|
||||||
|
apiKey?: string
|
||||||
|
|
||||||
// Include the initial tab where page JS starts. Default: true.
|
// Include the initial tab where page JS starts. Default: true.
|
||||||
includeInitialTab?: boolean
|
includeInitialTab?: boolean
|
||||||
@@ -205,8 +205,8 @@ import type {
|
|||||||
|
|
||||||
interface ExecuteConfig {
|
interface ExecuteConfig {
|
||||||
baseURL: string
|
baseURL: string
|
||||||
apiKey: string
|
|
||||||
model: string
|
model: string
|
||||||
|
apiKey?: string
|
||||||
includeInitialTab?: boolean
|
includeInitialTab?: boolean
|
||||||
onStatusChange?: (status: AgentStatus) => void
|
onStatusChange?: (status: AgentStatus) => void
|
||||||
onActivity?: (activity: AgentActivity) => void
|
onActivity?: (activity: AgentActivity) => void
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/ext",
|
"name": "@page-agent/ext",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.5.8",
|
"version": "1.6.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "wxt",
|
"dev": "wxt",
|
||||||
@@ -16,32 +16,31 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@types/chrome": "^0.1.38",
|
||||||
"@types/chrome": "^0.1.37",
|
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.1",
|
"@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",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^1.0.1",
|
||||||
"motion": "^12.35.2",
|
"motion": "^12.38.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"rough-notation": "^0.5.1",
|
"rough-notation": "^0.5.1",
|
||||||
"simple-icons": "^16.11.0",
|
"simple-icons": "^16.13.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"wxt": "^0.20.18"
|
"wxt": "^0.20.20"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@page-agent/core": "1.5.8",
|
"@page-agent/core": "1.6.2",
|
||||||
"@page-agent/llms": "1.5.8",
|
"@page-agent/llms": "1.6.2",
|
||||||
"@page-agent/page-controller": "1.5.8",
|
"@page-agent/page-controller": "1.6.2",
|
||||||
"@page-agent/ui": "1.5.8",
|
"@page-agent/ui": "1.6.2",
|
||||||
"ai-motion": "^0.4.8",
|
"ai-motion": "^0.4.8",
|
||||||
"chalk": "^5.6.2"
|
"chalk": "^5.6.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export class MultiPageAgent extends PageAgentCore {
|
|||||||
},
|
},
|
||||||
|
|
||||||
onBeforeStep: async (agent) => {
|
onBeforeStep: async (agent) => {
|
||||||
|
if (!tabsController.currentTabId) return
|
||||||
// make sure the current tab is loaded before the step starts
|
// make sure the current tab is loaded before the step starts
|
||||||
await tabsController.waitUntilTabLoaded(tabsController.currentTabId!)
|
await tabsController.waitUntilTabLoaded(tabsController.currentTabId!)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -58,8 +58,6 @@ export class RemotePageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getBrowserState(): Promise<BrowserState> {
|
async getBrowserState(): Promise<BrowserState> {
|
||||||
if (!this.currentTabId) throw new Error('tabsController not initialized.')
|
|
||||||
|
|
||||||
let browserState = {} as BrowserState
|
let browserState = {} as BrowserState
|
||||||
debug('getBrowserState', this.currentTabId)
|
debug('getBrowserState', this.currentTabId)
|
||||||
|
|
||||||
@@ -178,7 +176,7 @@ interface DomActionReturn {
|
|||||||
/**
|
/**
|
||||||
* Check if a URL can run content scripts.
|
* 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
|
if (!url) return false
|
||||||
|
|
||||||
const restrictedPatterns = [
|
const restrictedPatterns = [
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isContentScriptAllowed } from './RemotePageController'
|
||||||
|
|
||||||
const PREFIX = '[TabsController]'
|
const PREFIX = '[TabsController]'
|
||||||
|
|
||||||
function debug(...messages: any[]) {
|
function debug(...messages: any[]) {
|
||||||
@@ -49,23 +51,25 @@ export class TabsController extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (includeInitialTab) {
|
if (includeInitialTab) {
|
||||||
this.currentTabId = this.initialTabId
|
|
||||||
|
|
||||||
const info = await sendMessage({
|
const info = await sendMessage({
|
||||||
type: 'TAB_CONTROL',
|
type: 'TAB_CONTROL',
|
||||||
action: 'get_tab_info',
|
action: 'get_tab_info',
|
||||||
payload: { tabId: this.initialTabId },
|
payload: { tabId: this.initialTabId },
|
||||||
})
|
})
|
||||||
|
|
||||||
this.tabs.push({
|
if (isContentScriptAllowed(info.url)) {
|
||||||
id: result.tabId,
|
this.currentTabId = this.initialTabId
|
||||||
isInitial: true,
|
|
||||||
url: info.url,
|
|
||||||
title: info.title,
|
|
||||||
status: info.status,
|
|
||||||
})
|
|
||||||
|
|
||||||
await this.createTabGroup([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)
|
await this.updateCurrentTabId(this.currentTabId)
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import type { LLMConfig } from '@page-agent/llms'
|
|||||||
// Demo LLM for testing
|
// Demo LLM for testing
|
||||||
export const DEMO_MODEL = 'qwen3.5-plus'
|
export const DEMO_MODEL = 'qwen3.5-plus'
|
||||||
export const DEMO_BASE_URL = 'https://page-ag-testing-ohftxirgbn.cn-shanghai.fcapp.run'
|
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 = {
|
export const DEMO_CONFIG: LLMConfig = {
|
||||||
apiKey: DEMO_API_KEY,
|
|
||||||
baseURL: DEMO_BASE_URL,
|
baseURL: DEMO_BASE_URL,
|
||||||
model: DEMO_MODEL,
|
model: DEMO_MODEL,
|
||||||
|
// apiKey: DEMO_API_KEY,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy testing endpoints that should be auto-migrated to DEMO_BASE_URL */
|
/** Legacy testing endpoints that should be auto-migrated to DEMO_BASE_URL */
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import type {
|
import type {
|
||||||
AgentActivity,
|
AgentActivity,
|
||||||
AgentStatus,
|
AgentStatus,
|
||||||
|
ExecutionResult,
|
||||||
HistoricalEvent,
|
HistoricalEvent,
|
||||||
SupportedLanguage,
|
SupportedLanguage,
|
||||||
} from '@page-agent/core'
|
} from '@page-agent/core'
|
||||||
@@ -20,6 +21,7 @@ export interface AdvancedConfig {
|
|||||||
maxSteps?: number
|
maxSteps?: number
|
||||||
systemInstruction?: string
|
systemInstruction?: string
|
||||||
experimentalLlmsTxt?: boolean
|
experimentalLlmsTxt?: boolean
|
||||||
|
disableNamedToolChoice?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtConfig extends LLMConfig, AdvancedConfig {
|
export interface ExtConfig extends LLMConfig, AdvancedConfig {
|
||||||
@@ -32,7 +34,7 @@ export interface UseAgentResult {
|
|||||||
activity: AgentActivity | null
|
activity: AgentActivity | null
|
||||||
currentTask: string
|
currentTask: string
|
||||||
config: ExtConfig | null
|
config: ExtConfig | null
|
||||||
execute: (task: string) => Promise<void>
|
execute: (task: string) => Promise<ExecutionResult>
|
||||||
stop: () => void
|
stop: () => void
|
||||||
configure: (config: ExtConfig) => Promise<void>
|
configure: (config: ExtConfig) => Promise<void>
|
||||||
}
|
}
|
||||||
@@ -110,7 +112,7 @@ export function useAgent(): UseAgentResult {
|
|||||||
|
|
||||||
setCurrentTask(task)
|
setCurrentTask(task)
|
||||||
setHistory([])
|
setHistory([])
|
||||||
await agent.execute(task)
|
return agent.execute(task)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
@@ -123,6 +125,7 @@ export function useAgent(): UseAgentResult {
|
|||||||
maxSteps,
|
maxSteps,
|
||||||
systemInstruction,
|
systemInstruction,
|
||||||
experimentalLlmsTxt,
|
experimentalLlmsTxt,
|
||||||
|
disableNamedToolChoice,
|
||||||
...llmConfig
|
...llmConfig
|
||||||
}: ExtConfig) => {
|
}: ExtConfig) => {
|
||||||
await chrome.storage.local.set({ llmConfig })
|
await chrome.storage.local.set({ llmConfig })
|
||||||
@@ -131,7 +134,12 @@ export function useAgent(): UseAgentResult {
|
|||||||
} else {
|
} else {
|
||||||
await chrome.storage.local.remove('language')
|
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 })
|
await chrome.storage.local.set({ advancedConfig })
|
||||||
setConfig({ ...llmConfig, ...advancedConfig, language })
|
setConfig({ ...llmConfig, ...advancedConfig, language })
|
||||||
},
|
},
|
||||||
|
|||||||
+33
-15
@@ -1,18 +1,20 @@
|
|||||||
import {
|
import {
|
||||||
ChevronDown,
|
|
||||||
Copy,
|
Copy,
|
||||||
CornerUpLeft,
|
CornerUpLeft,
|
||||||
|
ExternalLink,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
FoldVertical,
|
||||||
HatGlasses,
|
HatGlasses,
|
||||||
Home,
|
Home,
|
||||||
Loader2,
|
Loader2,
|
||||||
Scale,
|
Scale,
|
||||||
|
UnfoldVertical,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { siGithub } from 'simple-icons'
|
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 type { ExtConfig, LanguagePreference } from '@/agent/useAgent'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -25,30 +27,34 @@ interface ConfigPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ConfigPanel({ config, onSave, onClose }: 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 [baseURL, setBaseURL] = useState(config?.baseURL || DEMO_BASE_URL)
|
||||||
const [model, setModel] = useState(config?.model || DEMO_MODEL)
|
const [model, setModel] = useState(config?.model || DEMO_MODEL)
|
||||||
|
const [apiKey, setApiKey] = useState(config?.apiKey)
|
||||||
const [language, setLanguage] = useState<LanguagePreference>(config?.language)
|
const [language, setLanguage] = useState<LanguagePreference>(config?.language)
|
||||||
const [maxSteps, setMaxSteps] = useState<number | undefined>(config?.maxSteps)
|
const [maxSteps, setMaxSteps] = useState(config?.maxSteps)
|
||||||
const [systemInstruction, setSystemInstruction] = useState(config?.systemInstruction ?? '')
|
const [systemInstruction, setSystemInstruction] = useState(config?.systemInstruction ?? '')
|
||||||
const [experimentalLlmsTxt, setExperimentalLlmsTxt] = useState(
|
const [experimentalLlmsTxt, setExperimentalLlmsTxt] = useState(
|
||||||
config?.experimentalLlmsTxt ?? false
|
config?.experimentalLlmsTxt ?? false
|
||||||
)
|
)
|
||||||
|
const [disableNamedToolChoice, setDisableNamedToolChoice] = useState(
|
||||||
|
config?.disableNamedToolChoice ?? false
|
||||||
|
)
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [userAuthToken, setUserAuthToken] = useState<string>('')
|
const [userAuthToken, setUserAuthToken] = useState('')
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [showToken, setShowToken] = useState(false)
|
const [showToken, setShowToken] = useState(false)
|
||||||
const [showApiKey, setShowApiKey] = useState(false)
|
const [showApiKey, setShowApiKey] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setApiKey(config?.apiKey || DEMO_API_KEY)
|
|
||||||
setBaseURL(config?.baseURL || DEMO_BASE_URL)
|
setBaseURL(config?.baseURL || DEMO_BASE_URL)
|
||||||
setModel(config?.model || DEMO_MODEL)
|
setModel(config?.model || DEMO_MODEL)
|
||||||
|
setApiKey(config?.apiKey)
|
||||||
setLanguage(config?.language)
|
setLanguage(config?.language)
|
||||||
setMaxSteps(config?.maxSteps)
|
setMaxSteps(config?.maxSteps)
|
||||||
setSystemInstruction(config?.systemInstruction ?? '')
|
setSystemInstruction(config?.systemInstruction ?? '')
|
||||||
setExperimentalLlmsTxt(config?.experimentalLlmsTxt ?? false)
|
setExperimentalLlmsTxt(config?.experimentalLlmsTxt ?? false)
|
||||||
|
setDisableNamedToolChoice(config?.disableNamedToolChoice ?? false)
|
||||||
}, [config])
|
}, [config])
|
||||||
|
|
||||||
// Poll for user auth token every second until found
|
// Poll for user auth token every second until found
|
||||||
@@ -94,6 +100,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
maxSteps: maxSteps || undefined,
|
maxSteps: maxSteps || undefined,
|
||||||
systemInstruction: systemInstruction || undefined,
|
systemInstruction: systemInstruction || undefined,
|
||||||
experimentalLlmsTxt,
|
experimentalLlmsTxt,
|
||||||
|
disableNamedToolChoice,
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
@@ -153,6 +160,16 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-xs text-muted-foreground">Base URL</label>
|
<label className="text-xs text-muted-foreground">Base URL</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -167,7 +184,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
{isTestingEndpoint(baseURL) && (
|
{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">
|
<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" />
|
<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
|
<a
|
||||||
href="https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md"
|
href="https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -176,14 +193,13 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
>
|
>
|
||||||
Terms of Use & Privacy Policy
|
Terms of Use & Privacy Policy
|
||||||
</a>
|
</a>
|
||||||
. No sensitive data. No guaranteed availability.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-xs text-muted-foreground">Model</label>
|
<label className="text-xs text-muted-foreground">Model</label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="gpt-5.2"
|
placeholder="gpt-5.1"
|
||||||
value={model}
|
value={model}
|
||||||
onChange={(e) => setModel(e.target.value)}
|
onChange={(e) => setModel(e.target.value)}
|
||||||
className="text-xs h-8"
|
className="text-xs h-8"
|
||||||
@@ -195,7 +211,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<Input
|
<Input
|
||||||
type={showApiKey ? 'text' : 'password'}
|
type={showApiKey ? 'text' : 'password'}
|
||||||
placeholder="sk-..."
|
// placeholder="sk-..."
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
className="text-xs h-8"
|
className="text-xs h-8"
|
||||||
@@ -212,7 +228,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-xs text-muted-foreground">Language</label>
|
<label className="text-xs text-muted-foreground">Response Language</label>
|
||||||
<select
|
<select
|
||||||
value={language ?? ''}
|
value={language ?? ''}
|
||||||
onChange={(e) => setLanguage((e.target.value || undefined) as LanguagePreference)}
|
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"
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground cursor-pointer mt-1 font-bold"
|
||||||
>
|
>
|
||||||
Advanced
|
Advanced
|
||||||
<ChevronDown
|
{advancedOpen ? <FoldVertical className="size-3" /> : <UnfoldVertical className="size-3" />}
|
||||||
className="size-3 transition-transform"
|
|
||||||
style={{ transform: advancedOpen ? 'rotate(0deg)' : 'rotate(90deg)' }}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{advancedOpen && (
|
{advancedOpen && (
|
||||||
@@ -263,6 +276,11 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<label className="flex items-center justify-between cursor-pointer">
|
||||||
<span className="text-xs text-muted-foreground">Experimental llms.txt support</span>
|
<span className="text-xs text-muted-foreground">Experimental llms.txt support</span>
|
||||||
<Switch checked={experimentalLlmsTxt} onCheckedChange={setExperimentalLlmsTxt} />
|
<Switch checked={experimentalLlmsTxt} onCheckedChange={setExperimentalLlmsTxt} />
|
||||||
+32
-3
@@ -1,12 +1,20 @@
|
|||||||
import { ArrowLeft } from 'lucide-react'
|
import { ArrowLeft, RotateCcw, Trash2 } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
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'
|
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)
|
const [session, setSession] = useState<SessionRecord | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,6 +45,27 @@ export function HistoryDetail({ sessionId, onBack }: { sessionId: string; onBack
|
|||||||
<div className="text-xs font-medium" title={session.task}>
|
<div className="text-xs font-medium" title={session.task}>
|
||||||
{session.task}
|
{session.task}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Events (read-only) */}
|
{/* 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 { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { type SessionRecord, clearSessions, deleteSession, listSessions } from '@/lib/db'
|
import { type SessionRecord, clearSessions, deleteSession, listSessions } from '@/lib/db'
|
||||||
|
import { downloadHistoryExport } from '@/lib/history-export'
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
function timeAgo(ts: number): string {
|
||||||
const seconds = Math.floor((Date.now() - ts) / 1000)
|
const seconds = Math.floor((Date.now() - ts) / 1000)
|
||||||
@@ -18,9 +19,11 @@ function timeAgo(ts: number): string {
|
|||||||
export function HistoryList({
|
export function HistoryList({
|
||||||
onSelect,
|
onSelect,
|
||||||
onBack,
|
onBack,
|
||||||
|
onRerun,
|
||||||
}: {
|
}: {
|
||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
|
onRerun: (task: string) => void
|
||||||
}) {
|
}) {
|
||||||
const [sessions, setSessions] = useState<SessionRecord[]>([])
|
const [sessions, setSessions] = useState<SessionRecord[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -41,6 +44,16 @@ export function HistoryList({
|
|||||||
setSessions((prev) => prev.filter((s) => s.id !== id))
|
setSessions((prev) => prev.filter((s) => s.id !== id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleExport = (e: React.MouseEvent, session: SessionRecord) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
downloadHistoryExport(session.task, session.createdAt, session.history)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRerun = (e: React.MouseEvent, task: string) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRerun(task)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen bg-background">
|
<div className="flex flex-col h-screen bg-background">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -85,7 +98,6 @@ export function HistoryList({
|
|||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => onSelect(session.id)}
|
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"
|
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 */}
|
{/* Status icon */}
|
||||||
@@ -98,19 +110,41 @@ export function HistoryList({
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-xs font-medium truncate">{session.task}</p>
|
<p className="text-xs font-medium truncate">{session.task}</p>
|
||||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
<div className="flex items-center mt-0.5">
|
||||||
{timeAgo(session.createdAt)} · {session.history.length} steps
|
<p className="text-[10px] text-muted-foreground">
|
||||||
</p>
|
{timeAgo(session.createdAt)} · {session.history.length} steps
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-0.5 ml-auto opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => handleRerun(e, session.task)}
|
||||||
|
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||||
|
title="Run task again"
|
||||||
|
aria-label={`Run history task again: ${session.task}`}
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => handleExport(e, session)}
|
||||||
|
className="p-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||||
|
title="Export history JSON"
|
||||||
|
aria-label={`Export history for ${session.task}`}
|
||||||
|
>
|
||||||
|
<ArrowDownToLine className="size-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => handleDelete(e, session.id)}
|
||||||
|
className="p-0.5 text-muted-foreground hover:text-destructive transition-colors cursor-pointer"
|
||||||
|
title="Delete history"
|
||||||
|
aria-label={`Delete history for ${session.task}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => handleDelete(e, session.id)}
|
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:text-destructive cursor-pointer shrink-0"
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
+1
-1
@@ -84,7 +84,7 @@ export function MotionOverlay({ active }: { active: boolean }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
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' }}
|
style={{ display: active ? undefined : 'none' }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -8,7 +8,7 @@ function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimi
|
|||||||
<SwitchPrimitive.Root
|
<SwitchPrimitive.Root
|
||||||
data-slot="switch"
|
data-slot="switch"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...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
|
// setup
|
||||||
|
|
||||||
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
|
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 {
|
export interface ExecuteConfig {
|
||||||
baseURL: string
|
baseURL: string
|
||||||
apiKey: string
|
|
||||||
model: string
|
model: string
|
||||||
|
apiKey?: string
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether to include the initial tab (that holds this main world script) in the task.
|
* Whether to include the initial tab (that holds this main world script) in the task.
|
||||||
@@ -30,7 +30,6 @@ export default defineUnlistedScript(() => {
|
|||||||
if (task.trim().length === 0) throw new Error('Task cannot be empty')
|
if (task.trim().length === 0) throw new Error('Task cannot be empty')
|
||||||
if (!config) throw new Error('Config is required')
|
if (!config) throw new Error('Config is required')
|
||||||
if (!config.baseURL) throw new Error('Config must have a baseURL')
|
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')
|
if (!config.model) throw new Error('Config must have a model')
|
||||||
|
|
||||||
const id = getId()
|
const id = getId()
|
||||||
@@ -85,8 +84,8 @@ export default defineUnlistedScript(() => {
|
|||||||
task,
|
task,
|
||||||
config: {
|
config: {
|
||||||
baseURL: config.baseURL,
|
baseURL: config.baseURL,
|
||||||
apiKey: config.apiKey,
|
|
||||||
model: config.model,
|
model: config.model,
|
||||||
|
apiKey: config.apiKey,
|
||||||
includeInitialTab: config.includeInitialTab,
|
includeInitialTab: config.includeInitialTab,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { History, Send, Settings, Square } from 'lucide-react'
|
import { History, Send, Settings, Square } from 'lucide-react'
|
||||||
import { useCallback, useEffect, useRef, useState } from '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 { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
InputGroup,
|
InputGroup,
|
||||||
@@ -11,11 +16,6 @@ import {
|
|||||||
import { saveSession } from '@/lib/db'
|
import { saveSession } from '@/lib/db'
|
||||||
|
|
||||||
import { useAgent } from '../../agent/useAgent'
|
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 =
|
type View =
|
||||||
| { name: 'chat' }
|
| { name: 'chat' }
|
||||||
@@ -56,19 +56,27 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [history, activity])
|
}, [history, activity])
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const runTask = useCallback(
|
||||||
(e?: React.SyntheticEvent) => {
|
(task: string) => {
|
||||||
e?.preventDefault()
|
const normalizedTask = task.trim()
|
||||||
if (!inputValue.trim() || status === 'running') return
|
if (!normalizedTask || status === 'running') return
|
||||||
|
|
||||||
const taskToExecute = inputValue.trim()
|
|
||||||
setInputValue('')
|
setInputValue('')
|
||||||
|
setView({ name: 'chat' })
|
||||||
|
|
||||||
execute(taskToExecute).catch((error) => {
|
execute(normalizedTask).catch((error) => {
|
||||||
console.error('[SidePanel] Failed to execute task:', 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(() => {
|
const handleStop = useCallback(() => {
|
||||||
@@ -103,12 +111,19 @@ export default function App() {
|
|||||||
<HistoryList
|
<HistoryList
|
||||||
onSelect={(id) => setView({ name: 'history-detail', sessionId: id })}
|
onSelect={(id) => setView({ name: 'history-detail', sessionId: id })}
|
||||||
onBack={() => setView({ name: 'chat' })}
|
onBack={() => setView({ name: 'chat' })}
|
||||||
|
onRerun={runTask}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (view.name === 'history-detail') {
|
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 ---
|
// --- Chat view ---
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
|
|
||||||
|
import { ErrorBoundary } from '@/components/ErrorBoundary'
|
||||||
|
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
|
||||||
|
|
||||||
import '@/assets/index.css'
|
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')
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ export default defineConfig({
|
|||||||
artifactTemplate: 'page-agent-ext-{{version}}-{{browser}}.zip',
|
artifactTemplate: 'page-agent-ext-{{version}}-{{browser}}.zip',
|
||||||
},
|
},
|
||||||
manifest: {
|
manifest: {
|
||||||
|
key: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqbzT0iTYeYlnCvDJIGDnGU8oarJgZILDzSfLi/ufuSxXEPDKuMyD892GhvrMCZNVHS11Sh6NYUOc/PcUOhtaR2urHtcNkrpSJNV10zUamY7fxBdVEkOucfyLu8INVy+teis62MoRWYPaUPkfZUjrLGW8MsZ9aFzARfu9GGDEp2EAYsWDN6w6vyz9LJ82pm542EWnVT4MjmDPgvYFCWGBtaU/dfHD+GAX6URJFapsCvryVURKJ+76c/GO9/I3EX1IBfbY6dec78bLCMvVxiTmiv36KyGPwX1OpakW8IiCpXWdbAxjm+plbYlp5t5zTyyoE3sOSFeXsBH0Kg27o8GcvQIDAQAB',
|
||||||
default_locale: 'en',
|
default_locale: 'en',
|
||||||
name: '__MSG_extName__',
|
name: '__MSG_extName__',
|
||||||
description: '__MSG_extDescription__',
|
description: '__MSG_extDescription__',
|
||||||
@@ -61,5 +62,8 @@ export default defineConfig({
|
|||||||
side_panel: {
|
side_panel: {
|
||||||
default_path: 'sidepanel/index.html',
|
default_path: 'sidepanel/index.html',
|
||||||
},
|
},
|
||||||
|
externally_connectable: {
|
||||||
|
matches: ['http://localhost/*'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/llms",
|
"name": "@page-agent/llms",
|
||||||
"version": "1.5.8",
|
"version": "1.6.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/lib/page-agent-llms.js",
|
"main": "./dist/lib/page-agent-llms.js",
|
||||||
"module": "./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))
|
const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t))
|
||||||
|
|
||||||
// Build request body
|
// 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> = {
|
const requestBody: Record<string, unknown> = {
|
||||||
model: this.config.model,
|
model: this.config.model,
|
||||||
temperature: this.config.temperature,
|
temperature: this.config.temperature,
|
||||||
messages,
|
messages,
|
||||||
tools: openaiTools,
|
tools: openaiTools,
|
||||||
parallel_tool_calls: false,
|
parallel_tool_calls: false,
|
||||||
// Require tool call: specific tool if provided, otherwise any tool
|
tool_choice: toolChoice,
|
||||||
tool_choice: options?.toolChoiceName
|
|
||||||
? { type: 'function', function: { name: options.toolChoiceName } }
|
|
||||||
: 'required',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
modelPatch(requestBody)
|
modelPatch(requestBody)
|
||||||
@@ -50,7 +53,7 @@ export class OpenAIClient implements LLMClient {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${this.config.apiKey}`,
|
...(this.config.apiKey && { Authorization: `Bearer ${this.config.apiKey}` }),
|
||||||
},
|
},
|
||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(requestBody),
|
||||||
signal: abortSignal,
|
signal: abortSignal,
|
||||||
|
|||||||
@@ -8,19 +8,20 @@ export type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool }
|
|||||||
|
|
||||||
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
|
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
|
||||||
// Runtime validation as defensive programming (types already guarantee these)
|
// Runtime validation as defensive programming (types already guarantee these)
|
||||||
if (!config.baseURL || !config.apiKey || !config.model) {
|
if (!config.baseURL || !config.model) {
|
||||||
throw new Error(
|
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'
|
'See: https://alibaba.github.io/page-agent/docs/features/models'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseURL: config.baseURL,
|
baseURL: config.baseURL,
|
||||||
apiKey: config.apiKey,
|
|
||||||
model: config.model,
|
model: config.model,
|
||||||
|
apiKey: config.apiKey || '',
|
||||||
temperature: config.temperature ?? DEFAULT_TEMPERATURE,
|
temperature: config.temperature ?? DEFAULT_TEMPERATURE,
|
||||||
maxRetries: config.maxRetries ?? LLM_MAX_RETRIES,
|
maxRetries: config.maxRetries ?? LLM_MAX_RETRIES,
|
||||||
|
disableNamedToolChoice: config.disableNamedToolChoice ?? false,
|
||||||
customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound
|
customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,12 +89,18 @@ export interface InvokeResult<TResult = unknown> {
|
|||||||
*/
|
*/
|
||||||
export interface LLMConfig {
|
export interface LLMConfig {
|
||||||
baseURL: string
|
baseURL: string
|
||||||
apiKey: string
|
|
||||||
model: string
|
model: string
|
||||||
|
apiKey?: string
|
||||||
|
|
||||||
temperature?: number
|
temperature?: number
|
||||||
maxRetries?: 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.
|
* Custom fetch function for LLM API requests.
|
||||||
* Use this to customize headers, credentials, proxy, etc.
|
* Use this to customize headers, credentials, proxy, etc.
|
||||||
|
|||||||
@@ -93,6 +93,15 @@ export function modelPatch(body: Record<string, any>) {
|
|||||||
body.reasoning_effort = 'minimal'
|
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
|
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.2",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"page-agent-mcp": "src/index.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src/"
|
||||||
|
],
|
||||||
|
"description": "MCP server for controlling the browser via Page Agent extension",
|
||||||
|
"keywords": [
|
||||||
|
"page-agent",
|
||||||
|
"mcp",
|
||||||
|
"browser-automation",
|
||||||
|
"chrome-extension"
|
||||||
|
],
|
||||||
|
"author": "Simon<gaomeng1900>",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/alibaba/page-agent.git",
|
||||||
|
"directory": "packages/mcp"
|
||||||
|
},
|
||||||
|
"homepage": "https://alibaba.github.io/page-agent/",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
|
"ws": "^8.20.0",
|
||||||
|
"zod": "^4.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import http from 'node:http'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { WebSocketServer } from 'ws'
|
||||||
|
|
||||||
|
const EXT_ID = 'akldabonmimlicnjlflnapfeklbfemhj'
|
||||||
|
const STORE_URL = `https://chromewebstore.google.com/detail/page-agent-ext/${EXT_ID}`
|
||||||
|
|
||||||
|
const launcherTemplate = readFileSync(
|
||||||
|
fileURLToPath(new URL('./launcher.html', import.meta.url)),
|
||||||
|
'utf-8'
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP + WebSocket bridge to the hub.html extension tab.
|
||||||
|
* - HTTP serves the launcher page (triggers extension to open hub)
|
||||||
|
* - WS carries execute/stop commands and result/error responses
|
||||||
|
*/
|
||||||
|
export class HubBridge {
|
||||||
|
/** @type {number} */
|
||||||
|
port
|
||||||
|
|
||||||
|
/** @type {http.Server} */
|
||||||
|
#httpServer
|
||||||
|
|
||||||
|
/** @type {WebSocketServer} */
|
||||||
|
#wss
|
||||||
|
|
||||||
|
/** @type {import('ws').WebSocket | null} */
|
||||||
|
#hub = null
|
||||||
|
|
||||||
|
/** @type {{ resolve: (r: {success: boolean, data: string}) => void, reject: (e: Error) => void } | null} */
|
||||||
|
#pendingTask = null
|
||||||
|
|
||||||
|
/** @param {number} port */
|
||||||
|
constructor(port) {
|
||||||
|
this.port = port
|
||||||
|
this.#httpServer = http.createServer((_req, res) => {
|
||||||
|
const html = launcherTemplate
|
||||||
|
.replaceAll('__EXT_ID__', EXT_ID)
|
||||||
|
.replaceAll('__STORE_URL__', STORE_URL)
|
||||||
|
.replaceAll('__WS_PORT__', String(port))
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
||||||
|
res.end(html)
|
||||||
|
})
|
||||||
|
this.#wss = new WebSocketServer({ server: this.#httpServer })
|
||||||
|
this.#wss.on('connection', (ws) => this.#onConnection(ws))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {Promise<void>} */
|
||||||
|
async start() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.#httpServer.on('error', (/** @type {NodeJS.ErrnoException} */ err) => {
|
||||||
|
if (err.code === 'EADDRINUSE') {
|
||||||
|
reject(
|
||||||
|
new Error(`Port ${this.port} is in use. Another Page Agent MCP server may be running.`)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.#httpServer.listen(this.port, () => {
|
||||||
|
console.error(`[page-agent-mcp] HTTP + WS on http://localhost:${this.port}`)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
get connected() {
|
||||||
|
return this.#hub?.readyState === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get busy() {
|
||||||
|
return this.#pendingTask !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} task
|
||||||
|
* @param {Record<string, unknown>} [config]
|
||||||
|
* @returns {Promise<{success: boolean, data: string}>}
|
||||||
|
*/
|
||||||
|
async executeTask(task, config) {
|
||||||
|
if (!this.connected) throw new Error('Hub is not connected. Is the extension running?')
|
||||||
|
if (this.#pendingTask) throw new Error('Agent is already running a task.')
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.#pendingTask = { resolve, reject }
|
||||||
|
this.#hub.send(JSON.stringify({ type: 'execute', task, config }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
stopTask() {
|
||||||
|
if (this.connected) {
|
||||||
|
this.#hub.send(JSON.stringify({ type: 'stop' }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Add version checking
|
||||||
|
|
||||||
|
/** @param {import('ws').WebSocket} ws */
|
||||||
|
#onConnection(ws) {
|
||||||
|
if (this.#hub && this.#hub.readyState === 1) {
|
||||||
|
ws.close(4000, 'Another hub is already connected')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#hub = ws
|
||||||
|
console.error('[page-agent-mcp] Hub connected')
|
||||||
|
|
||||||
|
ws.on('message', (/** @type {Buffer} */ rawData) => {
|
||||||
|
/** @type {{ type: string, success?: boolean, data?: string, message?: string }} */
|
||||||
|
let msg
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(rawData.toString('utf-8'))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'result') {
|
||||||
|
this.#pendingTask?.resolve({ success: msg.success ?? false, data: msg.data ?? '' })
|
||||||
|
this.#pendingTask = null
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
this.#pendingTask?.reject(new Error(msg.message ?? 'Unknown error from hub'))
|
||||||
|
this.#pendingTask = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
console.error('[page-agent-mcp] Hub disconnected')
|
||||||
|
if (this.#hub === ws) this.#hub = null
|
||||||
|
if (this.#pendingTask) {
|
||||||
|
this.#pendingTask.reject(new Error('Hub disconnected while task was running'))
|
||||||
|
this.#pendingTask = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+98
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||||
|
import { exec } from 'node:child_process'
|
||||||
|
import { platform } from 'node:os'
|
||||||
|
import * as z from 'zod/v4'
|
||||||
|
|
||||||
|
import { HubBridge } from './hub-bridge.js'
|
||||||
|
|
||||||
|
const env = process.env
|
||||||
|
const port = parseInt(env.PORT || '38401')
|
||||||
|
|
||||||
|
/** @type {Record<string, string>} */
|
||||||
|
const llmConfig = {}
|
||||||
|
if (env.LLM_BASE_URL) llmConfig.baseURL = env.LLM_BASE_URL
|
||||||
|
if (env.LLM_MODEL_NAME) llmConfig.model = env.LLM_MODEL_NAME
|
||||||
|
if (env.LLM_API_KEY) llmConfig.apiKey = env.LLM_API_KEY
|
||||||
|
|
||||||
|
// --- Hub bridge (HTTP + WebSocket) ---
|
||||||
|
|
||||||
|
const hub = new HubBridge(port)
|
||||||
|
await hub.start()
|
||||||
|
|
||||||
|
// Open launcher in default browser
|
||||||
|
const url = `http://localhost:${port}`
|
||||||
|
const cmd = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'start ""' : 'xdg-open'
|
||||||
|
exec(`${cmd} "${url}"`, (err) => {
|
||||||
|
if (err) console.error(`[page-agent-mcp] Could not open browser: ${err.message}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- MCP server (stdio) ---
|
||||||
|
|
||||||
|
const mcpServer = new McpServer({ name: 'page-agent', version: '1.5.8' })
|
||||||
|
|
||||||
|
mcpServer.registerTool(
|
||||||
|
'execute_task',
|
||||||
|
{
|
||||||
|
description: "Execute a task in user's browser.",
|
||||||
|
inputSchema: {
|
||||||
|
task: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
'Task description. Give specific instructions for the task. Steps preferable. And the information you want to get after the task is done.'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ task }) => {
|
||||||
|
try {
|
||||||
|
const config = Object.keys(llmConfig).length > 0 ? llmConfig : undefined
|
||||||
|
const result = await hub.executeTask(task, config)
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: result.success
|
||||||
|
? `Task completed.\n\n${result.data}`
|
||||||
|
: `Task failed.\n\n${result.data}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
||||||
|
isError: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
mcpServer.registerTool(
|
||||||
|
'get_status',
|
||||||
|
{
|
||||||
|
description: 'Check the current status of the Page Agent hub.',
|
||||||
|
},
|
||||||
|
async () => ({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify({ connected: hub.connected, busy: hub.busy }, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
mcpServer.registerTool(
|
||||||
|
'stop_task',
|
||||||
|
{
|
||||||
|
description: 'Stop the currently running browser automation task.',
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
hub.stopTask()
|
||||||
|
return { content: [{ type: 'text', text: 'Stop signal sent.' }] }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const transport = new StdioServerTransport()
|
||||||
|
await mcpServer.connect(transport)
|
||||||
|
console.error('[page-agent-mcp] MCP server ready (stdio)')
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" href="https://img.alicdn.com/imgextra/i1/O1CN01mRGret1QrKiu7CFJI_!!6000000002029-2-tps-64-64.png" />
|
||||||
|
<title>Page Agent MCP Launcher</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
|
background: #09090b;
|
||||||
|
color: #e5e5e5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 480px;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 18px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #52525b;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.sub {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #a1a1aa;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid #27272a;
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #60a5fa;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.install.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips {
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
text-align: left;
|
||||||
|
background: #18181b;
|
||||||
|
border: 1px solid #27272a;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
}
|
||||||
|
.tips li {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #a1a1aa;
|
||||||
|
line-height: 1.7;
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
.tips li + li {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding: 0.625rem 1.5rem;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.store-btn:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.store-btn img {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links {
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.links a {
|
||||||
|
color: #71717a;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.links a:hover {
|
||||||
|
color: #a1a1aa;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<img
|
||||||
|
class="logo"
|
||||||
|
src="https://img.alicdn.com/imgextra/i3/O1CN01JPT4Fj1FJTfmHfNxO_!!6000000000466-49-tps-512-512.webp"
|
||||||
|
alt="Page Agent"
|
||||||
|
/>
|
||||||
|
<div class="badge">Page Agent MCP Launcher</div>
|
||||||
|
|
||||||
|
<div id="connecting">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<h1 data-i18n="connecting_title">Connecting to Page Agent</h1>
|
||||||
|
<p class="sub" data-i18n="connecting_sub">Opening the hub in your browser…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="install" class="install">
|
||||||
|
<h1 data-i18n="install_title">Extension Required</h1>
|
||||||
|
<p class="sub" data-i18n="install_sub">
|
||||||
|
Page Agent requires the latest browser extension to work.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a class="store-btn" href="__STORE_URL__" target="_blank">
|
||||||
|
<img
|
||||||
|
src="https://img.alicdn.com/imgextra/i3/O1CN01JpW0Vo1sR3FpiZKFM_!!6000000005762-55-tps-192-192.svg"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<span data-i18n="install_btn">Install from Chrome Web Store</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<ul class="tips">
|
||||||
|
<li data-i18n="tip_outdated">
|
||||||
|
If the extension is outdated, please update it to the latest version.
|
||||||
|
</li>
|
||||||
|
<li data-i18n="tip_other_browser">
|
||||||
|
If the extension is not installed in this browser, open this page from the
|
||||||
|
browser that has it installed.
|
||||||
|
</li>
|
||||||
|
<li data-i18n="tip_refresh">Refresh this page after installing or updating.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="links">
|
||||||
|
<a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank" data-i18n="link_docs">Docs</a>
|
||||||
|
<a href="https://github.com/alibaba/page-agent/issues" target="_blank" data-i18n="link_issues">Report an Issue</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
{
|
||||||
|
const EXT_ID = '__EXT_ID__'
|
||||||
|
const wsPort = __WS_PORT__
|
||||||
|
|
||||||
|
const zh = {
|
||||||
|
connecting_title: '正在连接 Page Agent',
|
||||||
|
connecting_sub: '正在浏览器中打开 Hub…',
|
||||||
|
install_title: '需要安装浏览器插件',
|
||||||
|
install_sub: 'Page Agent 需要安装最新版浏览器插件才能运行。',
|
||||||
|
install_btn: '从 Chrome 应用商店安装',
|
||||||
|
tip_outdated: '如果插件版本过旧,请更新到最新版本。',
|
||||||
|
tip_other_browser:
|
||||||
|
'如果该浏览器中未安装插件,请从装有插件的浏览器打开此页面。',
|
||||||
|
tip_refresh: '安装或更新后,请刷新此页面。',
|
||||||
|
link_docs: '文档',
|
||||||
|
link_issues: '问题反馈',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^zh\b/i.test(navigator.language)) {
|
||||||
|
document.querySelectorAll('[data-i18n]').forEach((el) => {
|
||||||
|
const key = el.getAttribute('data-i18n')
|
||||||
|
if (zh[key]) el.textContent = zh[key]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const showInstall = () => {
|
||||||
|
document.getElementById('connecting').style.display = 'none'
|
||||||
|
document.getElementById('install').classList.add('show')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!globalThis.chrome?.runtime?.sendMessage) {
|
||||||
|
showInstall()
|
||||||
|
} else {
|
||||||
|
chrome.runtime.sendMessage(
|
||||||
|
EXT_ID,
|
||||||
|
{ type: 'OPEN_HUB', wsPort },
|
||||||
|
(response) => {
|
||||||
|
if (chrome.runtime.lastError || !response?.ok) showInstall()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showInstall()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "page-agent",
|
"name": "page-agent",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "1.5.8",
|
"version": "1.6.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/esm/page-agent.js",
|
"main": "./dist/esm/page-agent.js",
|
||||||
"module": "./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{}})\""
|
"postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@page-agent/core": "1.5.8",
|
"@page-agent/core": "1.6.2",
|
||||||
"@page-agent/llms": "1.5.8",
|
"@page-agent/llms": "1.6.2",
|
||||||
"@page-agent/page-controller": "1.5.8",
|
"@page-agent/page-controller": "1.6.2",
|
||||||
"@page-agent/ui": "1.5.8",
|
"@page-agent/ui": "1.6.2",
|
||||||
"chalk": "^5.6.2"
|
"chalk": "^5.6.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
import { type AgentConfig, PageAgentCore } from '@page-agent/core'
|
import { type AgentConfig, PageAgentCore } from '@page-agent/core'
|
||||||
import { PageController, type PageControllerConfig } from '@page-agent/page-controller'
|
import { PageController, type PageControllerConfig } from '@page-agent/page-controller'
|
||||||
import { Panel } from '@page-agent/ui'
|
import { Panel, type PanelConfig } from '@page-agent/ui'
|
||||||
|
|
||||||
export * from '@page-agent/core'
|
export * from '@page-agent/core'
|
||||||
|
|
||||||
export type PageAgentConfig = AgentConfig & PageControllerConfig
|
export type PageAgentConfig = AgentConfig & PageControllerConfig & Omit<PanelConfig, 'language'>
|
||||||
|
|
||||||
export class PageAgent extends PageAgentCore {
|
export class PageAgent extends PageAgentCore {
|
||||||
panel: Panel
|
panel: Panel
|
||||||
@@ -23,6 +23,7 @@ export class PageAgent extends PageAgentCore {
|
|||||||
|
|
||||||
this.panel = new Panel(this, {
|
this.panel = new Panel(this, {
|
||||||
language: config.language,
|
language: config.language,
|
||||||
|
promptForNextTask: config.promptForNextTask,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
|||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
// Load .env from repo root
|
// Load .env from repo root
|
||||||
dotenvConfig({ path: resolve(__dirname, '../../.env') })
|
dotenvConfig({ path: resolve(__dirname, '../../.env'), quiet: true })
|
||||||
|
|
||||||
// UMD Bundle for CDN
|
// UMD Bundle for CDN
|
||||||
// - alias all local packages so that they can be build in
|
// - alias all local packages so that they can be build in
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/page-controller",
|
"name": "@page-agent/page-controller",
|
||||||
"version": "1.5.8",
|
"version": "1.6.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/lib/page-controller.js",
|
"main": "./dist/lib/page-controller.js",
|
||||||
"module": "./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 type { FlatDomTree, InteractiveElementDomNode } from './dom/dom_tree/type'
|
||||||
import { getPageInfo } from './dom/getPageInfo'
|
import { getPageInfo } from './dom/getPageInfo'
|
||||||
import { patchReact } from './patches/react'
|
import { patchReact } from './patches/react'
|
||||||
|
import { isAnchorElement } from './utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for PageController
|
* Configuration for PageController
|
||||||
@@ -243,7 +244,7 @@ export class PageController extends EventTarget {
|
|||||||
await clickElement(element)
|
await clickElement(element)
|
||||||
|
|
||||||
// Handle links that open in new tabs
|
// Handle links that open in new tabs
|
||||||
if (element instanceof HTMLAnchorElement && element.target === '_blank') {
|
if (isAnchorElement(element) && element.target === '_blank') {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `✅ Clicked element (${elemText ?? index}). ⚠️ Link opened in a new tab.`,
|
message: `✅ Clicked element (${elemText ?? index}). ⚠️ Link opened in a new tab.`,
|
||||||
@@ -423,3 +424,5 @@ export class PageController extends EventTarget {
|
|||||||
this.mask = null
|
this.mask = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export * from './actions'
|
||||||
|
|||||||
@@ -3,27 +3,19 @@
|
|||||||
* All rights reserved.
|
* All rights reserved.
|
||||||
*/
|
*/
|
||||||
import type { InteractiveElementDomNode } from './dom/dom_tree/type'
|
import type { InteractiveElementDomNode } from './dom/dom_tree/type'
|
||||||
|
import {
|
||||||
// ======= general utils =======
|
getNativeValueSetter,
|
||||||
|
isHTMLElement,
|
||||||
async function waitFor(seconds: number): Promise<void> {
|
isInputElement,
|
||||||
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
|
isSelectElement,
|
||||||
}
|
isTextAreaElement,
|
||||||
|
movePointerToElement,
|
||||||
// ======= dom utils =======
|
waitFor,
|
||||||
|
} from './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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the HTMLElement by index from a selectorMap.
|
* Get the HTMLElement by index from a selectorMap.
|
||||||
|
* @private Internal method, subject to change at any time.
|
||||||
*/
|
*/
|
||||||
export function getElementByIndex(
|
export function getElementByIndex(
|
||||||
selectorMap: Map<number, InteractiveElementDomNode>,
|
selectorMap: Map<number, InteractiveElementDomNode>,
|
||||||
@@ -39,7 +31,7 @@ export function getElementByIndex(
|
|||||||
throw new Error(`Element at index ${index} does not have a reference`)
|
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`)
|
throw new Error(`Element at index ${index} is not an HTMLElement`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,20 +46,30 @@ function blurLastClickedElement() {
|
|||||||
lastClickedElement.dispatchEvent(
|
lastClickedElement.dispatchEvent(
|
||||||
new MouseEvent('mouseout', { bubbles: true, cancelable: true })
|
new MouseEvent('mouseout', { bubbles: true, cancelable: true })
|
||||||
)
|
)
|
||||||
|
lastClickedElement.dispatchEvent(
|
||||||
|
new MouseEvent('mouseleave', { bubbles: false, cancelable: true })
|
||||||
|
)
|
||||||
lastClickedElement = null
|
lastClickedElement = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simulate a click on the element
|
* Simulate a click on the element
|
||||||
|
* @private Internal method, subject to change at any time.
|
||||||
*/
|
*/
|
||||||
export async function clickElement(element: HTMLElement) {
|
export async function clickElement(element: HTMLElement) {
|
||||||
blurLastClickedElement()
|
blurLastClickedElement()
|
||||||
|
|
||||||
lastClickedElement = element
|
lastClickedElement = element
|
||||||
|
|
||||||
await scrollIntoViewIfNeeded(element)
|
await scrollIntoViewIfNeeded(element)
|
||||||
|
// Scroll the iframe element itself into view if needed
|
||||||
|
const frame = element.ownerDocument.defaultView?.frameElement
|
||||||
|
if (frame) await scrollIntoViewIfNeeded(frame)
|
||||||
|
|
||||||
await movePointerToElement(element)
|
await movePointerToElement(element)
|
||||||
window.dispatchEvent(new CustomEvent('PageAgent::ClickPointer'))
|
window.dispatchEvent(new CustomEvent('PageAgent::ClickPointer'))
|
||||||
|
|
||||||
await waitFor(0.1)
|
await waitFor(0.1)
|
||||||
|
|
||||||
// hover it
|
// hover it
|
||||||
@@ -89,25 +91,12 @@ export async function clickElement(element: HTMLElement) {
|
|||||||
await waitFor(0.2) // Wait to ensure click event processing completes
|
await waitFor(0.2) // Wait to ensure click event processing completes
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
/**
|
||||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
* @private Internal method, subject to change at any time.
|
||||||
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) {
|
export async function inputTextElement(element: HTMLElement, text: string) {
|
||||||
const isContentEditable = element.isContentEditable
|
const isContentEditable = element.isContentEditable
|
||||||
if (
|
if (!isInputElement(element) && !isTextAreaElement(element) && !isContentEditable) {
|
||||||
!(element instanceof HTMLInputElement) &&
|
|
||||||
!(element instanceof HTMLTextAreaElement) &&
|
|
||||||
!isContentEditable
|
|
||||||
) {
|
|
||||||
throw new Error('Element is not an input, textarea, or contenteditable')
|
throw new Error('Element is not an input, textarea, or contenteditable')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,9 +108,12 @@ export async function inputTextElement(element: HTMLElement, text: string) {
|
|||||||
// - Monaco/CodeMirror: Require direct JS instance access. No universal way to obtain.
|
// - Monaco/CodeMirror: Require direct JS instance access. No universal way to obtain.
|
||||||
// - Draft.js: Not responsive to synthetic/execCommand/Range/DataTransfer. Unmaintained.
|
// - 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
|
// Plan A: Dispatch synthetic events
|
||||||
// Works: LinkedIn, React contenteditable, Quill.
|
// Works: React contenteditable, Quill.
|
||||||
// Fails: Slate.js
|
// Fails: Slate.js, some contenteditable editors that ignore synthetic events.
|
||||||
// Sequence: beforeinput -> mutation -> input -> change -> blur
|
// Sequence: beforeinput -> mutation -> input -> change -> blur
|
||||||
|
|
||||||
// Dispatch beforeinput + mutation + input for clearing
|
// Dispatch beforeinput + mutation + input for clearing
|
||||||
@@ -164,22 +156,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)
|
// Dispatch change event (for good measure)
|
||||||
element.dispatchEvent(new Event('change', { bubbles: true }))
|
element.dispatchEvent(new Event('change', { bubbles: true }))
|
||||||
|
|
||||||
// Trigger blur for validation
|
// Trigger blur for validation
|
||||||
element.blur()
|
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 {
|
} 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)
|
// Only dispatch shared input event for non-contenteditable (contenteditable has its own)
|
||||||
@@ -194,9 +201,10 @@ export async function inputTextElement(element: HTMLElement, text: string) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @todo browser-use version is very complex and supports menu tags, need to follow up
|
* @todo browser-use version is very complex and supports menu tags, need to follow up
|
||||||
|
* @private Internal method, subject to change at any time.
|
||||||
*/
|
*/
|
||||||
export async function selectOptionElement(selectElement: HTMLSelectElement, optionText: string) {
|
export async function selectOptionElement(selectElement: HTMLSelectElement, optionText: string) {
|
||||||
if (!(selectElement instanceof HTMLSelectElement)) {
|
if (!isSelectElement(selectElement)) {
|
||||||
throw new Error('Element is not a select element')
|
throw new Error('Element is not a select element')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,11 +221,14 @@ export async function selectOptionElement(selectElement: HTMLSelectElement, opti
|
|||||||
await waitFor(0.1) // Wait to ensure change event processing completes
|
await waitFor(0.1) // Wait to ensure change event processing completes
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScrollableElement extends HTMLElement {
|
interface ScrollableElement extends Element {
|
||||||
scrollIntoViewIfNeeded?: (centerIfNeeded?: boolean) => void
|
scrollIntoViewIfNeeded?: (centerIfNeeded?: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function scrollIntoViewIfNeeded(element: HTMLElement) {
|
/**
|
||||||
|
* @private Internal method, subject to change at any time.
|
||||||
|
*/
|
||||||
|
export async function scrollIntoViewIfNeeded(element: Element) {
|
||||||
const el = element as ScrollableElement
|
const el = element as ScrollableElement
|
||||||
if (typeof el.scrollIntoViewIfNeeded === 'function') {
|
if (typeof el.scrollIntoViewIfNeeded === 'function') {
|
||||||
el.scrollIntoViewIfNeeded()
|
el.scrollIntoViewIfNeeded()
|
||||||
@@ -229,6 +240,9 @@ export async function scrollIntoViewIfNeeded(element: HTMLElement) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private Internal method, subject to change at any time.
|
||||||
|
*/
|
||||||
export async function scrollVertically(
|
export async function scrollVertically(
|
||||||
down: boolean,
|
down: boolean,
|
||||||
scroll_amount: number,
|
scroll_amount: number,
|
||||||
@@ -357,6 +371,9 @@ export async function scrollVertically(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private Internal method, subject to change at any time.
|
||||||
|
*/
|
||||||
export async function scrollHorizontally(
|
export async function scrollHorizontally(
|
||||||
right: boolean,
|
right: boolean,
|
||||||
scroll_amount: number,
|
scroll_amount: number,
|
||||||
|
|||||||
@@ -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",
|
"name": "@page-agent/ui",
|
||||||
"version": "1.5.8",
|
"version": "1.6.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/lib/page-agent-ui.js",
|
"main": "./dist/lib/page-agent-ui.js",
|
||||||
"module": "./dist/lib/page-agent-ui.js",
|
"module": "./dist/lib/page-agent-ui.js",
|
||||||
|
|||||||
@@ -369,6 +369,7 @@ export class Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#createWrapper(): HTMLElement {
|
#createWrapper(): HTMLElement {
|
||||||
|
const taskInputMaxLength = 1000
|
||||||
const wrapper = document.createElement('div')
|
const wrapper = document.createElement('div')
|
||||||
wrapper.id = 'page-agent-runtime_agent-panel'
|
wrapper.id = 'page-agent-runtime_agent-panel'
|
||||||
wrapper.className = styles.wrapper
|
wrapper.className = styles.wrapper
|
||||||
@@ -406,7 +407,7 @@ export class Panel {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="${styles.taskInput}"
|
class="${styles.taskInput}"
|
||||||
maxlength="200"
|
maxlength="${taskInputMaxLength}"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/website",
|
"name": "@page-agent/website",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.5.8",
|
"version": "1.6.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
@@ -19,13 +19,13 @@
|
|||||||
"@types/react-dom": "^19.2.1",
|
"@types/react-dom": "^19.2.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^1.0.1",
|
||||||
"motion": "^12.35.2",
|
"motion": "^12.38.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"rough-notation": "^0.5.1",
|
"rough-notation": "^0.5.1",
|
||||||
"simple-icons": "^16.11.0",
|
"simple-icons": "^16.13.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
|
|||||||
@@ -13,26 +13,27 @@ export default function Footer() {
|
|||||||
<div className="max-w-7xl mx-auto px-6 py-6">
|
<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">
|
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
|
||||||
<div className="text-gray-600 dark:text-gray-300 text-sm text-center md:text-left">
|
<div className="text-gray-600 dark:text-gray-300 text-sm text-center md:text-left">
|
||||||
<p>© 2026 page-agent. All rights reserved.</p>
|
|
||||||
<p>
|
<p>
|
||||||
Built with ♥️ by{' '}
|
|
||||||
<a
|
<a
|
||||||
href="https://x.com/simonluvramen"
|
href="https://x.com/simonluvramen"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="hover:text-gray-900 dark:hover:text-white transition-colors duration-200"
|
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
|
Simon.
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300 text-xs mt-0.5">
|
||||||
|
© 2026 page-agent. All rights reserved.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-6">
|
<div className="flex items-center">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md"
|
href="https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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'}
|
{isZh ? '使用条款与隐私' : 'Terms & Privacy'}
|
||||||
</a>
|
</a>
|
||||||
@@ -40,7 +41,7 @@ export default function Footer() {
|
|||||||
href="https://x.com/simonluvramen"
|
href="https://x.com/simonluvramen"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-200"
|
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)"
|
aria-label="X (Twitter)"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ export default function LanguageSwitcher() {
|
|||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const languages = [
|
const languages = [
|
||||||
{ code: 'zh-CN' as const, label: '中文' },
|
|
||||||
{ code: 'en-US' as const, label: 'English' },
|
{ code: 'en-US' as const, label: 'English' },
|
||||||
|
{ code: 'zh-CN' as const, label: '中文' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const currentLanguage = languages.find((lang) => lang.code === language) || languages[0]
|
const currentLanguage = languages.find((lang) => lang.code === language) || languages[0]
|
||||||
@@ -40,7 +40,7 @@ export default function LanguageSwitcher() {
|
|||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
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-label={isZh ? '切换语言' : 'Switch language'}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
@@ -81,7 +81,7 @@ export default function LanguageSwitcher() {
|
|||||||
<button
|
<button
|
||||||
key={lang.code}
|
key={lang.code}
|
||||||
onClick={() => handleLanguageChange(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
|
language === lang.code
|
||||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
||||||
: 'text-gray-700 dark:text-gray-300'
|
: 'text-gray-700 dark:text-gray-300'
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function ThemeSwitcher() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
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={{
|
style={{
|
||||||
backgroundColor: theme === 'dark' ? '#1e293b' : '#e0f2fe',
|
backgroundColor: theme === 'dark' ? '#1e293b' : '#e0f2fe',
|
||||||
}}
|
}}
|
||||||
@@ -62,10 +62,11 @@ export default function ThemeSwitcher() {
|
|||||||
>
|
>
|
||||||
{/* 滑块 */}
|
{/* 滑块 */}
|
||||||
<span
|
<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={{
|
style={{
|
||||||
backgroundColor: theme === 'dark' ? '#475569' : '#fbbf24',
|
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)
|
// Demo build (auto-init with demo LLM, for quick testing)
|
||||||
export const CDN_DEMO_URL =
|
export const CDN_DEMO_URL =
|
||||||
'https://cdn.jsdelivr.net/npm/page-agent@1.5.8/dist/iife/page-agent.demo.js'
|
'https://cdn.jsdelivr.net/npm/page-agent@1.6.2/dist/iife/page-agent.demo.js'
|
||||||
export const CDN_DEMO_CN_URL =
|
export const CDN_DEMO_CN_URL =
|
||||||
'https://registry.npmmirror.com/page-agent/1.5.8/files/dist/iife/page-agent.demo.js'
|
'https://registry.npmmirror.com/page-agent/1.6.2/files/dist/iife/page-agent.demo.js'
|
||||||
|
|
||||||
// Demo LLM for website testing (homepage quick trial uses flash)
|
// Demo LLM for website testing (homepage quick trial uses flash)
|
||||||
export const DEMO_MODEL = 'qwen3.5-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_BASE_URL = 'https://page-ag-testing-ohftxirgbn.cn-shanghai.fcapp.run'
|
||||||
export const DEMO_API_KEY = 'NA'
|
// export const DEMO_API_KEY = ''
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@import 'tailwindcss' important;
|
@config '../tailwind.config.js';
|
||||||
|
@import 'tailwindcss';
|
||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export default function DocsLayout({ children }: DocsLayoutProps) {
|
|||||||
{ title: isZh ? '知识注入' : 'Instructions', path: '/features/custom-instructions' },
|
{ title: isZh ? '知识注入' : 'Instructions', path: '/features/custom-instructions' },
|
||||||
{ title: isZh ? '数据脱敏' : 'Data Masking', path: '/features/data-masking' },
|
{ title: isZh ? '数据脱敏' : 'Data Masking', path: '/features/data-masking' },
|
||||||
{ title: isZh ? 'Chrome 扩展' : 'Chrome Extension', path: '/features/chrome-extension' },
|
{ title: isZh ? 'Chrome 扩展' : 'Chrome Extension', path: '/features/chrome-extension' },
|
||||||
|
{ title: 'MCP Server (Beta)', path: '/features/mcp-server' },
|
||||||
{
|
{
|
||||||
title: isZh ? '接入第三方 Agent' : 'Third-party Agent',
|
title: isZh ? '接入第三方 Agent' : 'Third-party Agent',
|
||||||
path: '/features/third-party-agent',
|
path: '/features/third-party-agent',
|
||||||
|
|||||||
@@ -129,12 +129,6 @@ const result = await agent.execute('Fill in the form with test data')`}
|
|||||||
? 'LLM API 的基础 URL(如 https://api.openai.com/v1)'
|
? 'LLM API 的基础 URL(如 https://api.openai.com/v1)'
|
||||||
: 'Base URL of the LLM API (e.g., 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',
|
name: 'model',
|
||||||
type: 'string',
|
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)'
|
? '模型名称(如 gpt-5.2, anthropic/claude-4.5-haiku)'
|
||||||
: 'Model name (e.g., 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',
|
name: 'temperature',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
@@ -156,6 +156,14 @@ const result = await agent.execute('Fill in the form with test data')`}
|
|||||||
defaultValue: '3',
|
defaultValue: '3',
|
||||||
description: isZh ? 'API 调用失败时的最大重试次数' : 'Maximum retries on API failure',
|
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',
|
name: 'customFetch',
|
||||||
type: 'typeof fetch',
|
type: 'typeof fetch',
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ console.log(result.history) // Full execution history`}
|
|||||||
>
|
>
|
||||||
AgentConfig
|
AgentConfig
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
和{' '}
|
、 PanelConfig 和{' '}
|
||||||
<Link
|
<Link
|
||||||
href="/advanced/page-controller#configuration"
|
href="/advanced/page-controller#configuration"
|
||||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
@@ -125,7 +125,7 @@ console.log(result.history) // Full execution history`}
|
|||||||
>
|
>
|
||||||
AgentConfig
|
AgentConfig
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
and{' '}
|
, PanelConfig, and{' '}
|
||||||
<Link
|
<Link
|
||||||
href="/advanced/page-controller#configuration"
|
href="/advanced/page-controller#configuration"
|
||||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
|||||||
@@ -187,39 +187,7 @@ localStorage.setItem('PageAgentExtUserAuthToken', '<your-token-from-extension>')
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
code={
|
code={`import type {
|
||||||
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 {
|
|
||||||
AgentActivity,
|
AgentActivity,
|
||||||
AgentStatus,
|
AgentStatus,
|
||||||
ExecutionResult,
|
ExecutionResult,
|
||||||
@@ -228,8 +196,8 @@ declare global {
|
|||||||
|
|
||||||
interface ExecuteConfig {
|
interface ExecuteConfig {
|
||||||
baseURL: string // LLM API endpoint
|
baseURL: string // LLM API endpoint
|
||||||
apiKey: string // API key
|
|
||||||
model: string // Model name
|
model: string // Model name
|
||||||
|
apiKey?: string // LLM AK
|
||||||
|
|
||||||
includeInitialTab?: boolean
|
includeInitialTab?: boolean
|
||||||
onStatusChange?: (status: AgentStatus) => void
|
onStatusChange?: (status: AgentStatus) => void
|
||||||
@@ -248,8 +216,7 @@ declare global {
|
|||||||
stop: () => void
|
stop: () => void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`}
|
||||||
}
|
|
||||||
language="typescript"
|
language="typescript"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import BetaNotice from '@/components/BetaNotice'
|
||||||
|
import CodeEditor from '@/components/CodeEditor'
|
||||||
|
import { Heading } from '@/components/Heading'
|
||||||
|
|
||||||
|
export default function McpServerPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-bold mb-6">MCP Server (Beta)</h1>
|
||||||
|
<BetaNotice />
|
||||||
|
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
|
||||||
|
Use the MCP server to let your local agent send natural-language browser tasks to Page Agent
|
||||||
|
Ext.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section className="mb-10">
|
||||||
|
<Heading id="quick-start" className="text-2xl font-bold mb-4">
|
||||||
|
How to use
|
||||||
|
</Heading>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||||
|
<p className="text-sm text-blue-900 dark:text-blue-200 leading-7">
|
||||||
|
1. Install Page Agent Ext in Chrome.
|
||||||
|
<br />
|
||||||
|
2. Add the MCP server to your local agent client.
|
||||||
|
<br />
|
||||||
|
3. Start the client and approve the Hub connection in the browser when prompted.
|
||||||
|
<br />
|
||||||
|
4. Ask your agent to do something in the browser. The client will call execute_task
|
||||||
|
for you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CodeEditor
|
||||||
|
code={`{
|
||||||
|
"mcpServers": {
|
||||||
|
"page-agent": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@page-agent/mcp"],
|
||||||
|
"env": {
|
||||||
|
"LLM_BASE_URL": "https://api.openai.com/v1",
|
||||||
|
"LLM_API_KEY": "sk-xxx",
|
||||||
|
"LLM_MODEL_NAME": "gpt-5.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
language="json"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-10">
|
||||||
|
<Heading id="the-hub" className="text-2xl font-bold mb-4">
|
||||||
|
The Hub
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">
|
||||||
|
The Hub is the control center for communication between Page Agent Ext and external
|
||||||
|
callers.
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">
|
||||||
|
When the MCP server starts, it opens a local launcher page. The launcher asks the
|
||||||
|
extension to open the Hub tab, and the Hub receives tasks from your local agent. MCP uses
|
||||||
|
this path, but the Hub itself is the extension's general external communication entry
|
||||||
|
point.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ const MODEL_GROUPS: Record<string, string[]> = {
|
|||||||
'claude-haiku-4.5',
|
'claude-haiku-4.5',
|
||||||
'claude-sonnet-3.5',
|
'claude-sonnet-3.5',
|
||||||
],
|
],
|
||||||
|
MiniMax: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'],
|
||||||
xAI: ['grok-4.1-fast', 'grok-4', 'grok-code-fast'],
|
xAI: ['grok-4.1-fast', 'grok-4', 'grok-code-fast'],
|
||||||
MoonshotAI: ['kimi-k2.5'],
|
MoonshotAI: ['kimi-k2.5'],
|
||||||
'Z.AI': ['glm-5', 'glm-4.7'],
|
'Z.AI': ['glm-5', 'glm-4.7'],
|
||||||
@@ -121,10 +122,16 @@ const pageAgent = new PageAgent({
|
|||||||
model: 'qwen3.5-plus'
|
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({
|
const pageAgent = new PageAgent({
|
||||||
baseURL: 'http://localhost:11434/v1',
|
baseURL: 'http://localhost:11434/v1',
|
||||||
apiKey: 'NA',
|
|
||||||
model: 'qwen3:14b'
|
model: 'qwen3:14b'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -272,7 +279,6 @@ LLM_MODEL_NAME="qwen3:14b"`}
|
|||||||
<CodeEditor
|
<CodeEditor
|
||||||
code={`const agent = new PageAgent({
|
code={`const agent = new PageAgent({
|
||||||
baseURL: '/api/llm-proxy',
|
baseURL: '/api/llm-proxy',
|
||||||
apiKey: 'NA',
|
|
||||||
model: 'gpt-5.1',
|
model: 'gpt-5.1',
|
||||||
customFetch: (url, init) =>
|
customFetch: (url, init) =>
|
||||||
fetch(url, { ...init, credentials: 'include' }),
|
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.'}
|
: 'Integrate pageAgent as a tool in your support assistant or Agent system, becoming the eyes and hands of your Agent.'}
|
||||||
</p>
|
</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">
|
<Heading id="integration-method" className="text-2xl font-bold mb-4">
|
||||||
{isZh ? '集成方式' : 'Integration Method'}
|
{isZh ? '集成方式' : 'Integration Method'}
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import ChromeExtension from './features/chrome-extension/page'
|
|||||||
import Instructions from './features/custom-instructions/page'
|
import Instructions from './features/custom-instructions/page'
|
||||||
import CustomTools from './features/custom-tools/page'
|
import CustomTools from './features/custom-tools/page'
|
||||||
import DataMasking from './features/data-masking/page'
|
import DataMasking from './features/data-masking/page'
|
||||||
|
import McpServerPage from './features/mcp-server/page'
|
||||||
import Models from './features/models/page'
|
import Models from './features/models/page'
|
||||||
import ThirdPartyAgent from './features/third-party-agent/page'
|
import ThirdPartyAgent from './features/third-party-agent/page'
|
||||||
import Limitations from './introduction/limitations/page'
|
import Limitations from './introduction/limitations/page'
|
||||||
@@ -80,6 +81,11 @@ export default function DocsRouter() {
|
|||||||
<ChromeExtension />
|
<ChromeExtension />
|
||||||
</DocsPage>
|
</DocsPage>
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/features/mcp-server">
|
||||||
|
<DocsPage>
|
||||||
|
<McpServerPage />
|
||||||
|
</DocsPage>
|
||||||
|
</Route>
|
||||||
<Route path="/features/third-party-agent">
|
<Route path="/features/third-party-agent">
|
||||||
<DocsPage>
|
<DocsPage>
|
||||||
<ThirdPartyAgent />
|
<ThirdPartyAgent />
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export default function LimitationsPage() {
|
|||||||
isZh ? '点击、文本输入、选择' : 'Click, text input, select',
|
isZh ? '点击、文本输入、选择' : 'Click, text input, select',
|
||||||
isZh ? '页面滚动(垂直 / 水平)' : 'Scroll (vertical / horizontal)',
|
isZh ? '页面滚动(垂直 / 水平)' : 'Scroll (vertical / horizontal)',
|
||||||
isZh ? '表单提交、焦点切换' : 'Form submit, focus',
|
isZh ? '表单提交、焦点切换' : 'Form submit, focus',
|
||||||
|
isZh ? '同源 iframe(仅单层)' : 'Same-origin iframe (single level only)',
|
||||||
isZh ? '执行 JavaScript(可选)' : 'Execute JavaScript (opt-in)',
|
isZh ? '执行 JavaScript(可选)' : 'Execute JavaScript (opt-in)',
|
||||||
].map((text) => (
|
].map((text) => (
|
||||||
<li key={text} className="flex items-center text-gray-700 dark:text-gray-300">
|
<li key={text} className="flex items-center text-gray-700 dark:text-gray-300">
|
||||||
@@ -115,6 +116,7 @@ export default function LimitationsPage() {
|
|||||||
isZh ? '悬停、拖拽、右键菜单' : 'Hover, drag & drop, right-click',
|
isZh ? '悬停、拖拽、右键菜单' : 'Hover, drag & drop, right-click',
|
||||||
isZh ? '键盘快捷键' : 'Keyboard shortcuts',
|
isZh ? '键盘快捷键' : 'Keyboard shortcuts',
|
||||||
isZh ? '坐标定位操作' : 'Position-based control',
|
isZh ? '坐标定位操作' : 'Position-based control',
|
||||||
|
isZh ? '嵌套 iframe、跨域 iframe' : 'Nested iframes, cross-origin iframes',
|
||||||
isZh ? '绘图操作' : 'Drawing',
|
isZh ? '绘图操作' : 'Drawing',
|
||||||
isZh
|
isZh
|
||||||
? 'Monaco、CodeMirror 等需要通过 JS 实例控制的编辑器'
|
? 'Monaco、CodeMirror 等需要通过 JS 实例控制的编辑器'
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ export default function TroubleshootingPage() {
|
|||||||
.getElementById(section.id)
|
.getElementById(section.id)
|
||||||
?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
?.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
|
activeId === section.id
|
||||||
? 'text-blue-600 dark:text-blue-400 font-medium'
|
? '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'
|
: '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 {
|
import {
|
||||||
CDN_DEMO_CN_URL,
|
CDN_DEMO_CN_URL,
|
||||||
CDN_DEMO_URL,
|
CDN_DEMO_URL,
|
||||||
DEMO_API_KEY,
|
// DEMO_API_KEY,
|
||||||
DEMO_BASE_URL,
|
DEMO_BASE_URL,
|
||||||
DEMO_MODEL,
|
DEMO_MODEL,
|
||||||
} from '../../constants'
|
} from '../../constants'
|
||||||
@@ -94,7 +94,7 @@ export default function HeroSection() {
|
|||||||
apiKey:
|
apiKey:
|
||||||
import.meta.env.DEV && import.meta.env.LLM_API_KEY
|
import.meta.env.DEV && import.meta.env.LLM_API_KEY
|
||||||
? import.meta.env.LLM_API_KEY
|
? import.meta.env.LLM_API_KEY
|
||||||
: DEMO_API_KEY,
|
: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,10 +103,7 @@ export default function HeroSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section className="relative px-6 pt-18 pb-14 lg:pb-20 lg:pt-24" aria-labelledby="hero-heading">
|
||||||
className="relative px-6 pt-24 py-20 pb-18 lg:py-22 lg:pt-28 overflow-hidden"
|
|
||||||
aria-labelledby="hero-heading"
|
|
||||||
>
|
|
||||||
<div className="max-w-7xl mx-auto text-center">
|
<div className="max-w-7xl mx-auto text-center">
|
||||||
{/* Background Pattern + Particles */}
|
{/* Background Pattern + Particles */}
|
||||||
<div className="absolute inset-0 opacity-30" aria-hidden="true">
|
<div className="absolute inset-0 opacity-30" aria-hidden="true">
|
||||||
@@ -122,7 +119,7 @@ export default function HeroSection() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<div className="inline-flex items-center px-4 py-2 mb-8 text-sm font-medium bg-white/90 dark:bg-gray-800/90 rounded-full shadow-lg border border-gray-200 dark:border-gray-700">
|
<div className="inline-flex items-center px-4 py-2 mb-4 text-sm font-medium bg-white/90 dark:bg-gray-800/90 rounded-full shadow-lg border border-gray-200 dark:border-gray-700">
|
||||||
<span
|
<span
|
||||||
className="w-2 h-2 bg-blue-500 rounded-full mr-2 animate-pulse"
|
className="w-2 h-2 bg-blue-500 rounded-full mr-2 animate-pulse"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -134,7 +131,7 @@ export default function HeroSection() {
|
|||||||
|
|
||||||
<h1
|
<h1
|
||||||
id="hero-heading"
|
id="hero-heading"
|
||||||
className="text-5xl lg:text-7xl font-bold mb-14 mt-8 bg-linear-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent pb-1"
|
className="text-5xl lg:text-7xl font-bold mb-10 mt-8 bg-linear-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent pb-1"
|
||||||
>
|
>
|
||||||
{isZh ? (
|
{isZh ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -58,6 +58,22 @@ export default function OneMoreThingSection() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-10 rounded-2xl border border-blue-200/70 dark:border-blue-800/70 bg-linear-to-r from-blue-50 to-white dark:from-blue-950/30 dark:to-gray-900 px-5 py-4 max-w-3xl mx-auto text-left sm:text-center">
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300 leading-7">
|
||||||
|
{isZh
|
||||||
|
? '从 Claude Desktop、Copilot 或其他本地 Agent 直接发起浏览器任务?'
|
||||||
|
: 'Using Claude Desktop, Copilot, or another local agent? Connect it to the extension with the MCP server.'}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Link
|
||||||
|
href="/docs/features/mcp-server"
|
||||||
|
className="font-medium text-blue-700 dark:text-blue-300 underline underline-offset-4"
|
||||||
|
>
|
||||||
|
{isZh ? '查看 MCP 文档' : 'Read the MCP docs'}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid sm:grid-cols-3 gap-5 text-left max-w-3xl mx-auto">
|
<div className="grid sm:grid-cols-3 gap-5 text-left max-w-3xl mx-auto">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
@@ -67,16 +83,16 @@ export default function OneMoreThingSection() {
|
|||||||
: 'Run tasks across multiple pages and tabs without being limited to a single page context',
|
: 'Run tasks across multiple pages and tabs without being limited to a single page context',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: isZh ? '页面内发起控制' : 'Control from Your Page',
|
title: isZh ? '从页面发起控制' : 'Control from a WebPage',
|
||||||
desc: isZh
|
desc: isZh
|
||||||
? '在页面 JS 中发起任务,驱动整个浏览器完成跨标签操作'
|
? '在页面 JS 中发起任务,驱动整个浏览器完成跨标签操作'
|
||||||
: 'Trigger tasks from page JS to drive the entire browser across tabs',
|
: 'Trigger tasks from in-page JS to drive the entire browser across tabs',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: isZh ? '外部发起任务' : 'External Triggers',
|
title: isZh ? '外部发起任务' : 'External Caller',
|
||||||
desc: isZh
|
desc: isZh
|
||||||
? '页面 JS、本地 Agent 或云端 Agent 均可通过扩展发起任务'
|
? '页面 JS、本地 Agent 或云端 Agent 均可通过扩展发起任务'
|
||||||
: 'Page JS, local agents, or cloud agents can trigger tasks through the extension',
|
: 'Local agents and cloud agents can control user browser through the extension',
|
||||||
},
|
},
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<MagicCard
|
<MagicCard
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
important: '#root',
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ const pageAgentPkg = JSON.parse(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Load .env from repo root
|
// 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
|
// All SPA routes that need index.html copies for direct access on static hosts
|
||||||
const SPA_ROUTES = [
|
const SPA_ROUTES = [
|
||||||
@@ -27,6 +27,7 @@ const SPA_ROUTES = [
|
|||||||
'docs/features/custom-instructions',
|
'docs/features/custom-instructions',
|
||||||
'docs/features/models',
|
'docs/features/models',
|
||||||
'docs/features/chrome-extension',
|
'docs/features/chrome-extension',
|
||||||
|
'docs/features/mcp-server',
|
||||||
'docs/features/third-party-agent',
|
'docs/features/third-party-agent',
|
||||||
'docs/advanced/page-agent',
|
'docs/advanced/page-agent',
|
||||||
'docs/advanced/page-agent-core',
|
'docs/advanced/page-agent-core',
|
||||||
|
|||||||
Reference in New Issue
Block a user