Compare commits
194 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0ce6e7d14 | |||
| 8f5f98f889 | |||
| ee4719cdcf | |||
| b4c82b7833 | |||
| aefc3cfb89 | |||
| 7f2b8c2766 | |||
| 62e24a3050 | |||
| 4f6249a252 | |||
| 62519416bb | |||
| a1e70e663d | |||
| 338e317be1 | |||
| 9a66d5205f | |||
| 7d45bf6598 | |||
| 78ee72eea4 | |||
| 3b71c535b1 | |||
| 6615f7b300 | |||
| 38caae5e23 | |||
| 1751ba325d | |||
| 066b045822 | |||
| 3b21c42cdb | |||
| 8b1e91d665 | |||
| aa5909264a | |||
| b6232d4e21 | |||
| 0d3cef5578 | |||
| 1e737f3a71 | |||
| 2f2bdb4697 | |||
| 3fa7c8b85a | |||
| 7efce91a91 | |||
| a11d370f2b | |||
| d7bfa42f85 | |||
| 551ba49280 | |||
| adab24bb22 | |||
| 64aea7e84c | |||
| 67f6bd619e | |||
| 35d1fd1166 | |||
| d5b8019fb1 | |||
| 526cb4dbb3 | |||
| 6dc56c57c6 | |||
| a3263fdd3a | |||
| 14974c0257 | |||
| e70ae40096 | |||
| 3e04a131f1 | |||
| b487a7900b | |||
| 78b49c8b7a | |||
| 949e511dcf | |||
| d9faca5d01 | |||
| 398c6d0869 | |||
| 54d478df1e | |||
| 43c80f5418 | |||
| b186ffcaa6 | |||
| 778bbda4b8 | |||
| f5900bd096 | |||
| 09772d79bd | |||
| bfa6a5347e | |||
| da5cf44012 | |||
| 1666c08e58 | |||
| 0c0c76fdd8 | |||
| 6b0b1218ac | |||
| d70f652285 | |||
| c3c01b63fb | |||
| 463d010c24 | |||
| 4e52f17b9f | |||
| 5237edfcca | |||
| 79dcf9d9e4 | |||
| 09544205ed | |||
| 2c74c3170a | |||
| 9cee8f4174 | |||
| d05b43cf50 | |||
| 3fe6ae6862 | |||
| 66be1e56e3 | |||
| 0fd4eaf9af | |||
| a0712baa91 | |||
| 49af3a9c58 | |||
| 20ad3d04d2 | |||
| c7e0538836 | |||
| 59fcfaa503 | |||
| 17befe4bab | |||
| 7911bac052 | |||
| 87a8da5783 | |||
| 0aec35aee3 | |||
| 35fe51427c | |||
| 9e0b5bec8b | |||
| 70d33a9756 | |||
| 1c99d21c19 | |||
| e3c612c338 | |||
| 87596469b6 | |||
| 566b2d7022 | |||
| f521bd9beb | |||
| 35db15460e | |||
| a34d86ba75 | |||
| 3144e8d44d | |||
| 32be34bcb1 | |||
| a774bd94dc | |||
| 7063b8199d | |||
| 64f1a8c443 | |||
| 635416f964 | |||
| 7c2d000e29 | |||
| b36a0c0261 | |||
| 339b066d2d | |||
| c8efffa80a | |||
| 59235263ad | |||
| bc1e8fea3c | |||
| 85a4096b2d | |||
| 0ef84bac13 | |||
| a7834c77e1 | |||
| 030e5cfc54 | |||
| aa694e3b85 | |||
| 2fee2d5ea3 | |||
| 3082843810 | |||
| 34098e2f66 | |||
| 8461006c91 | |||
| 2e76a35188 | |||
| abc96b415f | |||
| 77ce3b0b49 | |||
| 3742f75d14 | |||
| c2e9e6a571 | |||
| 54eb50f7be | |||
| 9351948794 | |||
| 68d1a10dd0 | |||
| b9fe8146b6 | |||
| 2a727226bc | |||
| 0167377b2f | |||
| f21914284c | |||
| 035091045a | |||
| 1cd18b3ba9 | |||
| 8ba04bf243 | |||
| 67bf38c8d5 | |||
| 8885bde0ec | |||
| b33e361cee | |||
| d1c8ca8197 | |||
| 2b8b6ef86a | |||
| 0b46fd1d7a | |||
| e2819714f1 | |||
| 3e6c431a0b | |||
| d0836fd788 | |||
| 32c17627e9 | |||
| 13a6d2c164 | |||
| d756a2ef1c | |||
| b9b812cc25 | |||
| 70caf6856b | |||
| 34dad35698 | |||
| 34e3a1a75c | |||
| cac86f2fbf | |||
| d1290c8070 | |||
| 49d6163a70 | |||
| 20f33ac478 | |||
| 683602bb6b | |||
| ad19a26a57 | |||
| 51f81fc44c | |||
| 71905a6ca0 | |||
| 9ada66bb43 | |||
| 2d66915570 | |||
| 25098f8772 | |||
| 8a67b96fdb | |||
| 682a08e0dc | |||
| b53d4dd8a5 | |||
| a4e48d32a4 | |||
| cdd1602f79 | |||
| adec9d8197 | |||
| 1b9970da14 | |||
| aa8f5ae235 | |||
| 60a6b5e5b6 | |||
| 7ae8fe753a | |||
| a1dfe879c1 | |||
| 6e7619760f | |||
| efc85da91c | |||
| 8e11fcb918 | |||
| 4a9bd9aed0 | |||
| 0f697c21bd | |||
| e0d6d0d51b | |||
| d92ba7d882 | |||
| 3e8e584065 | |||
| 9bdd565feb | |||
| b0a31e777b | |||
| 78a54d0d3d | |||
| 7f04f3564a | |||
| 22a25b17f5 | |||
| 8a63c1b716 | |||
| ab710bcaf5 | |||
| df449166d5 | |||
| da822c5fe1 | |||
| 78412323a7 | |||
| ad06cd1869 | |||
| 86682c3a1e | |||
| fee3bbdf85 | |||
| 4d3c878809 | |||
| e704398c43 | |||
| 56d20904b9 | |||
| bdddcb860e | |||
| e6e8ebf4e3 | |||
| 9b1131ce13 | |||
| 462d76dbb6 | |||
| 3791d1e157 | |||
| 2e3355f52c |
@@ -6,32 +6,11 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please follow our community communication guidelines when filing issues.
|
||||
- Be polite and respectful.
|
||||
- Prefer constructive, actionable feedback over venting.
|
||||
- Include clear reproduction steps and environment details.
|
||||
- Consider contributing a fix if possible — PRs are welcome.
|
||||
Thanks for your interest in improving the project! Before submitting, please read our guidelines.
|
||||
感谢您对改进项目的兴趣!提交前请阅读我们的指南。
|
||||
|
||||
提交 Issue 时请遵守社区沟通规范:
|
||||
- 保持礼貌与尊重。
|
||||
- 提供建设性、可行动的反馈,避免情绪化表达。
|
||||
- 请包含清晰的复现步骤与环境信息。
|
||||
- 如果可以,欢迎直接提交修复 PR。
|
||||
|
||||
See: CODE_OF_CONDUCT.md / CODE_OF_CONDUCT_zh.md, CONTRIBUTING.md
|
||||
|
||||
- type: checkboxes
|
||||
id: community
|
||||
attributes:
|
||||
label: Community Communication / 社区沟通
|
||||
description: Confirm you will communicate respectfully and constructively / 确认将以礼貌、建设性的方式沟通
|
||||
options:
|
||||
- label: I will be polite and respectful. / 我会保持礼貌与尊重。
|
||||
required: true
|
||||
- label: I will share constructive, actionable details. / 我会提供建设性、可行动的细节。
|
||||
required: true
|
||||
- label: I have read the Code of Conduct. / 我已阅读行为准则。
|
||||
required: true
|
||||
- [Code of Conduct](https://github.com/alibaba/page-agent/blob/main/CODE_OF_CONDUCT.md)
|
||||
- [Contributing Guide](https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md)
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
@@ -48,7 +27,7 @@ body:
|
||||
render: typescript
|
||||
placeholder: Minimal reproduction code
|
||||
validations:
|
||||
required: true
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: browser
|
||||
@@ -56,7 +35,7 @@ body:
|
||||
label: Browser
|
||||
placeholder: 'Chrome 120, Firefox 119, etc.'
|
||||
validations:
|
||||
required: true
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
@@ -64,4 +43,19 @@ body:
|
||||
label: version
|
||||
placeholder: '0.0.0'
|
||||
validations:
|
||||
required: true
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: community
|
||||
attributes:
|
||||
label: Community Communication / 社区沟通
|
||||
description: Confirm you will communicate respectfully and constructively / 确认将以礼貌、建设性的方式沟通
|
||||
options:
|
||||
- label: I will be polite and respectful. / 我会保持礼貌与尊重。
|
||||
required: true
|
||||
- label: I will share constructive, actionable suggestions. / 我会提供建设性、可行动的建议。
|
||||
required: true
|
||||
- label: I have read the Code of Conduct. / 我已阅读行为准则。
|
||||
required: true
|
||||
validations:
|
||||
required: true
|
||||
@@ -12,6 +12,3 @@ contact_links:
|
||||
- name: Code of Conduct / 行为准则
|
||||
url: https://github.com/alibaba/page-agent/blob/main/CODE_OF_CONDUCT.md
|
||||
about: Community expectations and standards. 社区行为期望与标准。
|
||||
- name: 行为准则(中文)
|
||||
url: https://github.com/alibaba/page-agent/blob/main/CODE_OF_CONDUCT_zh.md
|
||||
about: 中文版社区行为准则。
|
||||
|
||||
@@ -6,19 +6,30 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please follow our community communication guidelines when proposing features.
|
||||
- Be polite and respectful.
|
||||
- Explain the problem first, then propose solutions.
|
||||
- Prefer constructive, actionable suggestions.
|
||||
- Contributions are encouraged — consider opening a PR.
|
||||
Thanks for your interest in improving the project! Before submitting, please read our guidelines.
|
||||
感谢您对改进项目的兴趣!提交前请阅读我们的指南。
|
||||
|
||||
提交功能建议时请遵守社区沟通规范:
|
||||
- 保持礼貌与尊重。
|
||||
- 先阐述问题,再提出解决方案。
|
||||
- 提供建设性、可行动的建议。
|
||||
- 鼓励代码贡献,欢迎直接提交 PR。
|
||||
- [Code of Conduct](https://github.com/alibaba/page-agent/blob/main/CODE_OF_CONDUCT.md)
|
||||
- [Contributing Guide](https://github.com/alibaba/page-agent/blob/main/CONTRIBUTING.md)
|
||||
|
||||
See: CODE_OF_CONDUCT.md / CODE_OF_CONDUCT_zh.md, CONTRIBUTING.md
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Feature Description / 功能描述
|
||||
description: Describe the problem, solution, and any API changes. / 描述问题、解决方案以及 API 变更。
|
||||
placeholder: |
|
||||
**Problem**:
|
||||
What problem does this solve?
|
||||
|
||||
**Solution**:
|
||||
How should this work?
|
||||
|
||||
**Proposed API**:
|
||||
```typescript
|
||||
// code here
|
||||
```
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: community
|
||||
@@ -32,27 +43,5 @@ body:
|
||||
required: true
|
||||
- label: I have read the CODE_OF_CONDUCT.md and CONTRIBUTING.md. / 我已阅读行为准则。
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
placeholder: What problem does this solve?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Solution
|
||||
placeholder: How should this work?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: api
|
||||
attributes:
|
||||
label: Proposed API
|
||||
render: typescript
|
||||
validations:
|
||||
required: false
|
||||
required: true
|
||||
@@ -5,9 +5,12 @@ Brief description of changes.
|
||||
## Type
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Breaking change
|
||||
- [ ] Feature / Improvement
|
||||
- [ ] Refactor
|
||||
- [ ] Documentation
|
||||
- [ ] Website
|
||||
- [ ] Demo / Testing
|
||||
- [ ] Breaking change
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -19,10 +22,4 @@ Closes #(issue)
|
||||
|
||||
## Requirements / 要求
|
||||
|
||||
- [ ] I will be polite and respectful. / 我会保持礼貌与尊重。
|
||||
- [ ] My comments and replies are constructive and actionable. / 我的评论与回复具有建设性。
|
||||
- [ ] I have read and follow the [Code of Conduct](CODE_OF_CONDUCT.md) and [Contributing Guide](CONTRIBUTING.md) . / 我已阅读并遵守行为准则。
|
||||
|
||||
## Contributing / 贡献
|
||||
|
||||
Constructive suggestions and code contributions are encouraged. If this PR originated from a discussion or issue, please link it above. 欢迎建设性意见与代码贡献;如源自讨论或 Issue,请在上方关联链接。
|
||||
|
||||
@@ -13,10 +13,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20, 22]
|
||||
node-version: [20, 24, 25]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v6
|
||||
@@ -24,6 +24,13 @@ jobs:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
|
||||
# test on default version of npm
|
||||
# - 9.6~10.8 on node@20
|
||||
# - 11.3~11.6 on node@24
|
||||
|
||||
- name: Node and NPM version
|
||||
run: node --version && npm --version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
|
||||
@@ -13,19 +13,19 @@ jobs:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build demo
|
||||
run: npm run build
|
||||
run: npm run build:website
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: './dist'
|
||||
path: './packages/website/dist'
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
|
||||
@@ -13,12 +13,12 @@ jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
# Ensure npm 11.5.1 or later is installed
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build:lib
|
||||
run: npm run build:libs
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish
|
||||
- name: Publish all public packages
|
||||
run: npm publish --workspaces --access public
|
||||
+7
-1
@@ -23,7 +23,13 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.qoder
|
||||
|
||||
|
||||
#
|
||||
.env
|
||||
.env
|
||||
|
||||
# Copied files for npm publish (generated by prepublishOnly)
|
||||
packages/page-agent/README.md
|
||||
packages/page-agent/README-zh.md
|
||||
packages/page-agent/LICENSE
|
||||
+1
-1
@@ -1 +1 @@
|
||||
npx lint-staged
|
||||
npx lint-staged --allow-empty
|
||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
||||
"recommendations": ["dbaeumer.vscode-eslint", "prettier.prettier-vscode"]
|
||||
}
|
||||
|
||||
Vendored
+11
-1
@@ -1,6 +1,16 @@
|
||||
{
|
||||
"editor.fontLigatures": true,
|
||||
"cSpell.words": ["HITL", "opensource", "retryable"],
|
||||
"cSpell.words": [
|
||||
"deepseek",
|
||||
"HITL",
|
||||
"innerhtml",
|
||||
"llms",
|
||||
"opensource",
|
||||
"qwen",
|
||||
"retryable",
|
||||
"shadcn",
|
||||
"wouter"
|
||||
],
|
||||
"markdownlint.config": {
|
||||
// "comment": "Relaxed rules",
|
||||
"default": true,
|
||||
|
||||
@@ -1,161 +1,131 @@
|
||||
# Instructions for coding assistants
|
||||
# Instructions for Coding Assistants
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a dual-architecture project with **two separate parts**:
|
||||
This is a **monorepo** with npm workspaces:
|
||||
|
||||
1. **Core Library** (`src/`) - Pure JavaScript/TypeScript AI agent library for browser DOM automation
|
||||
2. **Demo&docs Website** (`pages/`) - React documentation and landing page
|
||||
- **Core Library** (`packages/page-agent/`) - AI agent for browser DOM automation, published as `page-agent` on npm
|
||||
- **Website** (`packages/website/`) - React docs and landing page. **When working on website, follow `packages/website/AGENTS.md`**
|
||||
|
||||
Internal packages:
|
||||
|
||||
- **CDN** (`packages/cdn/`) - IIFE builds for script tag usage (npm: `@page-agent/cdn`)
|
||||
- **LLMs** (`packages/llms/`) - LLM client with reflection-before-action mental model
|
||||
- **Page Controller** (`packages/page-controller/`) - DOM operations and visual feedback (SimulatorMask), independent of LLM
|
||||
- **UI** (`packages/ui/`) - Panel and i18n. Decoupled from PageAgent
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Core Commands
|
||||
|
||||
```bash
|
||||
npm start # Start React website development server
|
||||
npm run build # Build both library AND website
|
||||
npm run build:lib # Build pure JS library only (src/ → dist/lib/)
|
||||
npm run build:lib:watch # Library development with auto-rebuild
|
||||
npm start # Start website dev server
|
||||
npm run build # Build all packages
|
||||
npm run build:libs # Build all libraries
|
||||
npm run lint # ESLint with TypeScript strict rules
|
||||
```
|
||||
|
||||
## Architecture & Critical Patterns
|
||||
## Architecture
|
||||
|
||||
### Dual Build System
|
||||
### Monorepo Structure
|
||||
|
||||
- **Website build**: `vite.config.js` → React SPA with hash routing → `dist/`
|
||||
- **Library build**: `vite.lib.config.js` → UMD/ES modules → `dist/lib/`
|
||||
- **Entry points**: `src/entry.ts` (library), `pages/main.tsx` (website)
|
||||
Simple monorepo solution: TypeScript references + Vite aliases. Update tsconfig and vite config when adding/removing packages.
|
||||
|
||||
### Module Boundaries (Critical)
|
||||
```
|
||||
packages/
|
||||
├── page-agent/ # npm: "page-agent" ⭐ MAIN
|
||||
├── cdn/ # npm: "@page-agent/cdn" (IIFE builds)
|
||||
├── website/ # @page-agent/website (private)
|
||||
├── llms/ # @page-agent/llms
|
||||
├── page-controller/ # @page-agent/page-controller
|
||||
└── ui/ # @page-agent/ui
|
||||
```
|
||||
|
||||
- **Core library** (`src/`): NEVER import from `pages/` - must remain pure JavaScript
|
||||
- **Website** (`pages/`): CAN import from `src/` via `@/` alias for demos
|
||||
- **Import aliases**: `@/` → `src/`, `@pages/` → `pages/`
|
||||
`workspaces` in `package.json` must be in topological order.
|
||||
|
||||
### Module Boundaries
|
||||
|
||||
- **Page Agent**: Core lib. Imports from `@page-agent/llms`, `@page-agent/page-controller`, `@page-agent/ui`
|
||||
- **LLMs**: LLM client with MacroToolInput contract. No dependency on page-agent
|
||||
- **UI**: Panel and i18n. No dependency on page-agent
|
||||
- **Page Controller**: DOM operations with optional visual feedback (SimulatorMask). No LLM dependency. Enable mask via `enableMask: true` config
|
||||
|
||||
### PageController ↔ PageAgent Communication
|
||||
|
||||
All communication is async and isolated:
|
||||
|
||||
```typescript
|
||||
// PageAgent delegates DOM operations to PageController
|
||||
await this.pageController.updateTree()
|
||||
await this.pageController.clickElement(index)
|
||||
await this.pageController.inputText(index, text)
|
||||
await this.pageController.scroll({ down: true, numPages: 1 })
|
||||
|
||||
// PageController exposes state via async methods
|
||||
const simplifiedHTML = await this.pageController.getSimplifiedHTML()
|
||||
const pageInfo = await this.pageController.getPageInfo()
|
||||
```
|
||||
|
||||
### DOM Pipeline
|
||||
|
||||
1. **DOM Extraction**: Convert live DOM to `FlatDomTree` via `src/dom/dom_tree/`
|
||||
2. **Dehydration**: DOM tree → simplified text for LLM processing
|
||||
3. **LLM Processing**: AI model returns action plans
|
||||
4. **Indexed Operations**: Map LLM responses back to specific DOM elements
|
||||
1. **DOM Extraction**: Live DOM → `FlatDomTree` via `page-controller/src/dom/dom_tree/`
|
||||
2. **Dehydration**: DOM tree → simplified text for LLM
|
||||
3. **LLM Processing**: AI returns action plans (page-agent)
|
||||
4. **Indexed Operations**: PageAgent calls PageController by element index
|
||||
|
||||
### Event Bus Communication
|
||||
### CDN Builds (`packages/cdn/`)
|
||||
|
||||
Use `src/utils/bus.ts` for decoupled PageAgent ↔ UI communication:
|
||||
Two IIFE builds for script tag usage:
|
||||
|
||||
```typescript
|
||||
// Emit from PageAgent
|
||||
getEventBus().emit('panel:show')
|
||||
getEventBus().emit('panel:update', { status: 'thinking' })
|
||||
| Build | File | Description |
|
||||
| ----- | -------------------- | -------------------------------- |
|
||||
| Demo | `page-agent.demo.js` | Auto-init with built-in test API |
|
||||
| Full | `page-agent.js` | Exposes `PageAgent` class only |
|
||||
|
||||
// Listen in UI components
|
||||
getEventBus().on('panel:show', () => panel.show())
|
||||
```
|
||||
Demo build supports query params (e.g., `?model=gpt-4&lang=en-US`).
|
||||
|
||||
### Hash Routing Requirement
|
||||
## Key Files Reference
|
||||
|
||||
Uses wouter with `useHashLocation` for static hosting:
|
||||
### Page Agent (`packages/page-agent/`)
|
||||
|
||||
```tsx
|
||||
<Router hook={useHashLocation}> // Always hash-based routes
|
||||
```
|
||||
| File | Description |
|
||||
| ------------------ | --------------------------------------- |
|
||||
| `src/PageAgent.ts` | ⭐ Main AI agent class |
|
||||
| `src/umd.ts` | CDN/UMD entry with auto-init |
|
||||
| `src/tools/` | Tool definitions calling PageController |
|
||||
|
||||
### CDN Auto-Injection Pattern
|
||||
### LLMs (`packages/llms/`)
|
||||
|
||||
Library auto-initializes when injected via script tag:
|
||||
| File | Description |
|
||||
| ---------------------------- | ------------------------------------- |
|
||||
| `src/index.ts` | ⭐ LLM class with retry logic |
|
||||
| `src/types.ts` | MacroToolInput, AgentBrain, LLMConfig |
|
||||
| `src/OpenAILenientClient.ts` | OpenAI-compatible client |
|
||||
|
||||
```html
|
||||
<script src="page-agent.js?model=gpt-4"></script>
|
||||
```
|
||||
### Page Controller (`packages/page-controller/`)
|
||||
|
||||
Query params configure `PageAgentConfig` automatically in `src/entry.ts`.
|
||||
|
||||
## File Organization
|
||||
|
||||
### Core Library (`src/`)
|
||||
|
||||
- `entry.ts` - CDN/UMD entry point with auto-initialization
|
||||
- `PageAgent.ts` - **Main AI agent class** orchestrating DOM operations
|
||||
- `tools/` - Agent tool implementations for web actions
|
||||
- `ui/` - UI components (Panel, SimulatorMask) with CSS modules
|
||||
- `utils/bus.ts` - **Type-safe event bus** for decoupled communication
|
||||
- `patches/` - Framework-specific optimizations (React, Antd compatibility)
|
||||
- `llms/` - LLM integration and communication layer
|
||||
- `dom/` - HTML serialization and page analysis utilities
|
||||
- `config/` - Configuration constants and settings
|
||||
|
||||
### Website (`pages/`)
|
||||
|
||||
- `main.tsx` - Site entry with hash routing setup
|
||||
- `router.tsx` - **Manual route definitions** (requires explicit registration)
|
||||
- `components/DocsLayout.tsx` - Navigation structure (hardcoded nav items)
|
||||
- `docs/[section]/[topic]/page.tsx` - Documentation pages
|
||||
- `test-pages/` - Library integration test pages
|
||||
| File | Description |
|
||||
| --------------------------- | ---------------------------------------------------------- |
|
||||
| `src/PageController.ts` | ⭐ Main controller class with optional mask support |
|
||||
| `src/SimulatorMask.ts` | Visual overlay blocking user interaction during automation |
|
||||
| `src/actions.ts` | Element interactions (click, input, scroll) |
|
||||
| `src/dom/dom_tree/index.js` | Core DOM extraction engine |
|
||||
|
||||
## Adding New Features
|
||||
|
||||
### New Documentation Page
|
||||
|
||||
1. Create `pages/docs/<section>/<slug>/page.tsx`
|
||||
2. Add route to `pages/router.tsx` with `<Header /> + <DocsLayout>` wrapper
|
||||
3. Add navigation item to `DocsLayout.tsx`
|
||||
|
||||
### New Agent Tool
|
||||
|
||||
1. Implement under `src/tools/`
|
||||
2. Export via `src/tools/index.ts`
|
||||
3. Wire into `PageAgent.ts` if needed
|
||||
1. Implement in `packages/page-agent/src/tools/index.ts`
|
||||
2. If tool needs DOM ops, add method to PageController first
|
||||
3. Tool calls `this.pageController.methodName()` for DOM interactions
|
||||
|
||||
### New UI Component
|
||||
### New PageController Action
|
||||
|
||||
1. Create in `src/ui/` with colocated CSS modules
|
||||
2. Use event bus for PageAgent communication
|
||||
3. Test via `pages/test-pages/`
|
||||
1. Add implementation in `packages/page-controller/src/actions.ts`
|
||||
2. Expose via async method in `PageController.ts`
|
||||
3. Export from `packages/page-controller/src/index.ts`
|
||||
|
||||
## Code Standards
|
||||
|
||||
### TypeScript
|
||||
|
||||
- Explicit typing for exported/public APIs
|
||||
- ESLint relaxes some unsafe rules for rapid iteration
|
||||
|
||||
### CSS & Styling
|
||||
|
||||
- **Prefer Tailwind CSS over custom CSS**
|
||||
- Custom CSS variables for theme gradients in `src/index.css`
|
||||
- Dark mode support via `dark:` classes
|
||||
- CSS modules for component-specific styles
|
||||
|
||||
### Import Organization
|
||||
|
||||
- External libraries first
|
||||
- Internal modules (`@/`, `@pages/`)
|
||||
- Relative imports last
|
||||
- Blank lines between groups
|
||||
|
||||
## Critical Files to Understand
|
||||
|
||||
- `pages/router.tsx` - Central routing definition (manual registration required)
|
||||
- `pages/components/DocsLayout.tsx` - Navigation structure
|
||||
- `src/PageAgent.ts` - Core AI agent class with DOM manipulation
|
||||
- `src/dom/dom_tree/index.js` - DOM extraction engine
|
||||
- `src/utils/bus.ts` - Type-safe event bus system
|
||||
- `src/entry.ts` - Library entry point for CDN usage
|
||||
- `vite.config.js` / `vite.lib.config.js` - Dual build configuration
|
||||
|
||||
## Debugging Common Issues
|
||||
|
||||
### Blank Documentation Pages
|
||||
|
||||
1. Verify route exists in `pages/router.tsx`
|
||||
2. Check component import path
|
||||
3. Verify CSS isn't hiding content (check dark mode classes)
|
||||
4. Test with minimal component first
|
||||
|
||||
### Library Integration Issues
|
||||
|
||||
1. Check `dist/lib/page-agent.umd.js` builds correctly
|
||||
2. Test CDN injection with query params
|
||||
3. Verify event bus communications are properly typed
|
||||
4. Use `pages/test-pages/` for isolated testing
|
||||
- Every change you make should not only implement the desired functionality but also improve the quality of the codebase
|
||||
- All code and comments must be in English.
|
||||
|
||||
+20
-27
@@ -7,11 +7,9 @@ Thank you for your interest in contributing to Page-Agent! We welcome contributi
|
||||
### Development Setup
|
||||
|
||||
1. **Prerequisites**
|
||||
- Node.js 20+
|
||||
- npm 10+
|
||||
- typescript as required in package.json
|
||||
- vscode or other editors that support ts/eslint/prettier
|
||||
- make sure `eslint`, `auto-format` and `commit-lint` all work
|
||||
- `node.js >= 20` with `npm >= 10`
|
||||
- An editor that supports `ts/eslint/prettier`
|
||||
- Make sure `eslint`, `prettier` and `commitlint` work well
|
||||
|
||||
2. **Setup**
|
||||
|
||||
@@ -22,10 +20,16 @@ Thank you for your interest in contributing to Page-Agent! We welcome contributi
|
||||
|
||||
### Project Structure
|
||||
|
||||
This project has **two separate parts**:
|
||||
This is a **monorepo** with npm workspaces containing **two main packages**:
|
||||
|
||||
- **Core Library** (`src/`) - Pure JavaScript AI agent library
|
||||
- **Documentation Website** (`pages/`) - React web app for landing page and docs
|
||||
1. **Core Library** (`packages/page-agent/`) - Pure JavaScript/TypeScript AI agent library for browser DOM automation, published as `page-agent` on npm
|
||||
2. **Website** (`packages/website/`) - React documentation and landing page. Also as demo and test page for the core lib. private package `@page-agent/website`
|
||||
|
||||
We use a simplified monorepo solution with `native npm-workspace + ts reference + vite alias`. No fancy tooling. Hoisting is required.
|
||||
|
||||
- When developing. Use alias so that we don't have to pre-build.
|
||||
- When bundling. Use external and disable ts `paths` alias to leave deps out.
|
||||
- When bundling `UMD` and `Website`. Bundle everything including local packages.
|
||||
|
||||
## 🤝 How to Contribute
|
||||
|
||||
@@ -95,19 +99,6 @@ This project has **two separate parts**:
|
||||
- Github Copilot: `./.github/copilot-instructions.md`
|
||||
- Claude Code: `./CLAUDE.md`
|
||||
|
||||
### Core Library (`src/`)
|
||||
|
||||
- **No React dependencies** - Pure JavaScript/TypeScript only
|
||||
- Use event bus for component communication
|
||||
- Follow browser-use patterns for DOM operations
|
||||
|
||||
### Doc&demo Site (`pages/`)
|
||||
|
||||
- Use React 19 + TypeScript
|
||||
- Follow hash routing patterns (`useHashLocation`)
|
||||
- Use Tailwind CSS for styling
|
||||
- Add new docs pages to both router and sidebar
|
||||
|
||||
## 🔧 Development Workflows
|
||||
|
||||
### Website Development
|
||||
@@ -118,24 +109,26 @@ npm start # React development server
|
||||
|
||||
### Core Lib Development and Testing
|
||||
|
||||
> @TODO this part is outdated. Update this.
|
||||
|
||||
- Config your LLM API
|
||||
- Start a local script
|
||||
- Start and serve a local umd script
|
||||
|
||||
```bash
|
||||
npm run build:lib:watch # Auto-rebuild on changes
|
||||
npm run dev:umd # Serving UMD with auto rebuild at http://localhost:5173/page-agent.umd.cjs
|
||||
```
|
||||
|
||||
- Add a new bookmark enable it on other website
|
||||
|
||||
```
|
||||
javascript:(function(){var s=document.createElement('script');s.src=`http://localhost:5173/dist/lib/page-agent.umd.cjs?t=${Math.random()}`;s.onload=()=>console.log(%27PageAgent ready!%27);document.head.appendChild(s);})();
|
||||
javascript:(function(){var s=document.createElement('script');s.src=`http://localhost:5173/page-agent.umd.cjs?t=${Math.random()}`;s.onload=()=>console.log(%27PageAgent ready!%27);document.head.appendChild(s);})();
|
||||
```
|
||||
|
||||
### Adding Documentation
|
||||
|
||||
1. Create `pages/docs/section/page-name/page.tsx`
|
||||
2. Add route to `pages/router.tsx`
|
||||
3. Add navigation link to `pages/components/DocsLayout.tsx`
|
||||
1. Create `website/src/docs/section/page-name/page.tsx`
|
||||
2. Add route to `website/src/router.tsx`
|
||||
3. Add navigation link to `website/src/components/DocsLayout.tsx`
|
||||
|
||||
## 🎯 Contribution Areas
|
||||
|
||||
|
||||
+49
-31
@@ -4,13 +4,13 @@
|
||||
|
||||
[](https://badge.fury.io/js/page-agent) [](https://opensource.org/licenses/MIT) [](http://www.typescriptlang.org/) [](https://www.npmjs.com/package/page-agent) [](https://bundlephobia.com/package/page-agent) [](https://github.com/alibaba/page-agent)
|
||||
|
||||
**让你的网页支持 AI 自动化。**
|
||||
|
||||
运行在页面内的 UI agent. 使用自然语言操作 Web 应用。
|
||||
纯 JS 实现的 GUI agent。使用自然语言操作你的 Web 应用。无须后端、客户端、浏览器插件。
|
||||
|
||||
🌐 [English](./README.md) | **中文**
|
||||
|
||||
👉 [🚀 **Demo**](https://alibaba.github.io/page-agent/) | [📖 **Documentation**](https://alibaba.github.io/page-agent/#/docs/introduction/overview)
|
||||
👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/#/docs/introduction/overview" target="_blank"><b>📖 Documentation</b></a>
|
||||
|
||||
<video id="demo-video" src="https://github.com/user-attachments/assets/141bbb01-8022-4d1f-919d-9efc9a1dc1cf" width="640" crossorigin muted autoplay loop></video>
|
||||
|
||||
---
|
||||
|
||||
@@ -24,22 +24,26 @@
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
👉 [**Roadmap**](./ROADMAP.md)
|
||||
👉 [**Roadmap**](https://github.com/alibaba/page-agent/issues/96)
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### CDN 集成
|
||||
### 快速体验 (Demo CDN)
|
||||
|
||||
最快的体验方式:
|
||||
|
||||
```html
|
||||
<!-- 临时 CDN URL. 未来会变更 -->
|
||||
<script
|
||||
src="https://hwcxiuzfylggtcktqgij.supabase.co/storage/v1/object/public/demo-public/v0.0.2/page-agent.js"
|
||||
crossorigin="true"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<script src="https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.demo.js" crossorigin="true"></script>
|
||||
```
|
||||
|
||||
### NPM 安装
|
||||
> ⚠️ **仅用于技术评估。** Demo 模型有速率限制和使用限制,生产环境请使用 NPM 方式。
|
||||
|
||||
| 位置 | URL |
|
||||
| ------ | --------------------------------------------------------------------------------------- |
|
||||
| 全球 | https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.demo.js |
|
||||
| 中国 | https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.demo.js |
|
||||
|
||||
### NPM 安装(推荐)
|
||||
|
||||
```bash
|
||||
npm install page-agent
|
||||
@@ -48,35 +52,49 @@ npm install page-agent
|
||||
```javascript
|
||||
import { PageAgent } from 'page-agent'
|
||||
|
||||
// 测试接口
|
||||
// @note: 限流,限制 prompt 内容,限制来源,随时变更,请替换成你自己的
|
||||
// @note: 使用 DeepSeek-chat(3.2) 官方版本,使用协议和隐私策略见 DeepSeek 网站
|
||||
const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
const DEMO_BASE_URL = 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
|
||||
const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
|
||||
const agent = new PageAgent({
|
||||
modelName: DEMO_MODEL,
|
||||
baseURL: DEMO_BASE_URL,
|
||||
apiKey: DEMO_API_KEY,
|
||||
model: 'deepseek-chat',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
language: 'zh-CN',
|
||||
})
|
||||
|
||||
await agent.execute('点击登录按钮')
|
||||
```
|
||||
|
||||
### CDN 引入
|
||||
|
||||
适用于无法使用 NPM 的环境,用法与 NPM 一致:
|
||||
|
||||
```html
|
||||
<script src="https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.js" crossorigin="true"></script>
|
||||
<script>
|
||||
const agent = new PageAgent({
|
||||
model: 'deepseek-chat',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
})
|
||||
await agent.execute('点击登录按钮')
|
||||
</script>
|
||||
```
|
||||
|
||||
| 位置 | URL |
|
||||
| ------ | ----------------------------------------------------------------------------------- |
|
||||
| 全球 | https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.js |
|
||||
| 中国 | https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.js |
|
||||
|
||||
## 🏗️ 架构设计
|
||||
|
||||
PageAgent 采用清晰的模块化架构:
|
||||
PageAgent adopts a simplified monorepo structure:
|
||||
|
||||
```
|
||||
src/
|
||||
├── PageAgent.ts # Agent 主流程
|
||||
├── dom/ # DOM 理解
|
||||
├── tools/ # 代理交互工具
|
||||
├── ui/ # UI 组件和面板
|
||||
├── llms/ # LLM 集成层
|
||||
└── utils/ # 事件总线和工具
|
||||
packages/
|
||||
├── page-agent/ # AI agent (npm: page-agent)
|
||||
├── llms/ # LLM 客户端 (npm: @page-agent/llms)
|
||||
├── page-controller/ # DOM 操作 & 蒙层 & 模拟鼠标 (npm: @page-agent/page-controller)
|
||||
├── ui/ # 面板 & i18n (npm: @page-agent/ui)
|
||||
├── cdn/ # CDN IIFE builds (npm: @page-agent/cdn)
|
||||
└── website/ # 文档站点
|
||||
```
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
# PageAgent 🤖🪄
|
||||
|
||||

|
||||

|
||||
|
||||
[](https://badge.fury.io/js/page-agent) [](https://opensource.org/licenses/MIT) [](http://www.typescriptlang.org/) [](https://www.npmjs.com/package/page-agent) [](https://bundlephobia.com/package/page-agent) [](https://github.com/alibaba/page-agent)
|
||||
|
||||
**Transform your webpage into an AI-powered application with a single script tag.**
|
||||
|
||||
An in-page UI agent in javascript. Control web interfaces with natural language.
|
||||
The GUI Agent Living in Your Webpage. Control web interfaces with natural language.
|
||||
|
||||
🌐 **English** | [中文](./README-zh.md)
|
||||
|
||||
👉 [🚀 **Demo**](https://alibaba.github.io/page-agent/) | [📖 **Documentation**](https://alibaba.github.io/page-agent/#/docs/introduction/overview)
|
||||
👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/#/docs/introduction/overview" target="_blank"><b>📖 Documentation</b></a>
|
||||
|
||||
<video id="demo-video" src="https://github.com/user-attachments/assets/de8d1964-8bde-494f-a52f-2975469557a5" width="640" crossorigin muted autoplay loop></video>
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🎯 Easy Integration**
|
||||
- **🎯 Easy Integration** - Transform your webpage into an agent with a single script tag.
|
||||
- **🔐 Client-Side Processing**
|
||||
- **🧠 DOM Extraction**
|
||||
- **💬 Natural Language Interface**
|
||||
@@ -24,22 +24,26 @@ An in-page UI agent in javascript. Control web interfaces with natural language.
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
👉 [**Roadmap**](./ROADMAP.md)
|
||||
👉 [**Roadmap**](https://github.com/alibaba/page-agent/issues/96)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### CDN Integration
|
||||
### Quick Try (Demo CDN)
|
||||
|
||||
Fastest way to try PageAgent:
|
||||
|
||||
```html
|
||||
<!-- temporary CDN URL. May change in the future -->
|
||||
<script
|
||||
src="https://hwcxiuzfylggtcktqgij.supabase.co/storage/v1/object/public/demo-public/v0.0.2/page-agent.js"
|
||||
crossorigin="true"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.demo.js" crossorigin="true"></script>
|
||||
```
|
||||
|
||||
### NPM Installation
|
||||
> ⚠️ **For technical evaluation only.** Demo model has rate limits and usage restrictions. Use NPM for production.
|
||||
|
||||
| Location | URL |
|
||||
| -------- | --------------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.demo.js |
|
||||
|
||||
### NPM Installation (Recommended)
|
||||
|
||||
```bash
|
||||
npm install page-agent
|
||||
@@ -48,35 +52,49 @@ npm install page-agent
|
||||
```javascript
|
||||
import { PageAgent } from 'page-agent'
|
||||
|
||||
// test server
|
||||
// @note: rate limit. prompt limit. Origin limit. May change anytime. Use your own llm!
|
||||
// @note Using official DeepSeek-chat(3.2). Go to DeepSeek website for privacy policy.
|
||||
const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
const DEMO_BASE_URL = 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
|
||||
const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
|
||||
const agent = new PageAgent({
|
||||
modelName: DEMO_MODEL,
|
||||
baseURL: DEMO_BASE_URL,
|
||||
apiKey: DEMO_API_KEY,
|
||||
model: 'deepseek-chat',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
language: 'en-US',
|
||||
})
|
||||
|
||||
await agent.execute('Click the login button')
|
||||
```
|
||||
|
||||
### CDN Build
|
||||
|
||||
For environments where NPM is not available. Usage is identical to NPM:
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.js" crossorigin="true"></script>
|
||||
<script>
|
||||
const agent = new PageAgent({
|
||||
model: 'deepseek-chat',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
})
|
||||
await agent.execute('Click the login button')
|
||||
</script>
|
||||
```
|
||||
|
||||
| Location | URL |
|
||||
| -------- | ----------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.js |
|
||||
| China | https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.js |
|
||||
|
||||
## 🏗️ Structure
|
||||
|
||||
PageAgent follows a clean, modular architecture:
|
||||
PageAgent adopts a simplified monorepo structure:
|
||||
|
||||
```
|
||||
src/
|
||||
├── PageAgent.ts # Agent main loop
|
||||
├── dom/ # DOM processing
|
||||
├── tools/ # Agent tools
|
||||
├── ui/ # UI components & panels
|
||||
├── llms/ # LLM integration layer
|
||||
└── utils/ # Event bus & utilities
|
||||
packages/
|
||||
├── page-agent/ # AI agent (npm: page-agent)
|
||||
├── llms/ # LLM client (npm: @page-agent/llms)
|
||||
├── page-controller/ # DOM operations & Visual Mask (npm: @page-agent/page-controller)
|
||||
├── ui/ # Panel & i18n (npm: @page-agent/ui)
|
||||
├── cdn/ # CDN IIFE builds (npm: @page-agent/cdn)
|
||||
└── website/ # Demo & Documentation site
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
# 🗺️ PageAgent Roadmap
|
||||
|
||||
The development progress and future plans for PageAgent.
|
||||
|
||||
## 🚀 Current Works
|
||||
|
||||
- [x] **MVP**
|
||||
- Core functionality implemented.
|
||||
- [x] **SPA interaction**
|
||||
- [x] **Reasoning and (short) memory**
|
||||
- [x] **Multi model provider integration and testing**
|
||||
- [x] **UI with HITL**
|
||||
- Human-in-the-loop user interface. Agent can ask user questions.
|
||||
- [x] **Landing and doc pages**
|
||||
- [x] **Remove `ai-sdk`**
|
||||
- Only one function of AI-ADK is being used.
|
||||
- Our agent memory and thinking mechanism does not suite ai-sdk.
|
||||
- [x] **Robust LLM output**
|
||||
- Auto-fix incomplete output format of DeepSeek and QWen.
|
||||
- [x] **Working homepage with live LLM API**
|
||||
- [x] **~~free~~ CDN**
|
||||
- [x] **Free evaluation plan**
|
||||
- [x] **Custom actions and HITL**
|
||||
- [ ] **Hooks and Events**
|
||||
- [x] **lifecycle hooks**
|
||||
- [ ] **lifecycle events**
|
||||
- [ ] **❗Pause and intervene**
|
||||
- Current pause does not allow user intervention.
|
||||
- [ ] **❗Hijack `page_open/page_change/page_unload` behavior**
|
||||
- [ ] **Custom knowledge base and instructions**
|
||||
- [ ] **Black/white-list safeguard**
|
||||
- [ ] **Data-masking**
|
||||
- [ ] **Optimize for popular UI frameworks**
|
||||
- [x] **i18n of the website**
|
||||
- [x] Chinese version
|
||||
- [x] English version
|
||||
- [ ] **Testing suits**
|
||||
- [ ] **Support custom llm fetch**
|
||||
- [ ] **Refactor: Separate `Agent` and `Page-Controller`**
|
||||
- Agent should be able to run w/o dom.
|
||||
- Actions should be able to be injected through iframe.
|
||||
|
||||
♻️ Following browser-use's update and contribute back.
|
||||
|
||||
## 📋 Pending Features
|
||||
|
||||
- [ ] **Chrome-ext wrapper**
|
||||
- [ ] **Same-origin multi-page-app rally**
|
||||
- [ ] **Local MCP proxy**
|
||||
|
||||
## 🤔 To Be Decided
|
||||
|
||||
- [ ] **Cross-origin multi-page?**
|
||||
- Tricky
|
||||
- Need some kind of "memory rally"
|
||||
+8
-3
@@ -8,7 +8,7 @@ import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist', 'test-pages']),
|
||||
globalIgnores(['**/dist', '**/node_modules', 'packages/website/src/components/ui']),
|
||||
{
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
@@ -37,8 +37,10 @@ export default defineConfig([
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
// project: ['./tsconfig.json'],
|
||||
// project: ['./packages/*/tsconfig.json'],
|
||||
// tsconfigRootDir: import.meta.dirname,
|
||||
projectService: true,
|
||||
},
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
@@ -67,6 +69,9 @@ export default defineConfig([
|
||||
'react-dom/no-missing-button-type': 'off',
|
||||
'react-x/no-nested-component-definitions': 'off',
|
||||
'@typescript-eslint/prefer-optional-chain': 'off',
|
||||
|
||||
// 'require-await': 'off',
|
||||
'@typescript-eslint/require-await': 'off',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="https://img.alicdn.com/imgextra/i3/O1CN01uo9VaC20mamWNM2Zn_!!6000000006892-55-tps-64-64.svg"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PageAgent - AI-powered UI Agent in Your Webpage</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="PageAgent.js: One-line CDN import, add intelligent UI Agent to any website. Modern web AI automation with minimal integration."
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="PageAgent, AI Agent, Web Automation, UI Automation, Frontend, CDN, JavaScript, React, Vite, LLM"
|
||||
/>
|
||||
<meta name="theme-color" content="#58c0fc" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="author" content="PageAgent.js Team" />
|
||||
<meta property="og:title" content="PageAgent.js - AI-powered UI Agent" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="One-line CDN import, add intelligent UI Agent to any website."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:locale:alternate" content="zh_CN" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./pages/main.tsx"></script>
|
||||
<script>
|
||||
// Dynamically update html lang attribute based on i18n detection
|
||||
const updateHtmlLang = () => {
|
||||
const lang = localStorage.getItem('i18nextLng') || navigator.language || 'zh-CN'
|
||||
document.documentElement.lang = lang
|
||||
}
|
||||
updateHtmlLang()
|
||||
window.addEventListener('storage', updateHtmlLang)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2159
-1012
File diff suppressed because it is too large
Load Diff
+37
-67
@@ -1,35 +1,17 @@
|
||||
{
|
||||
"name": "page-agent",
|
||||
"private": false,
|
||||
"version": "0.0.4",
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-agent.js",
|
||||
"module": "./dist/lib/page-agent.js",
|
||||
"types": "./dist/lib/PageAgent.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/lib/PageAgent.d.ts",
|
||||
"import": "./dist/lib/page-agent.js",
|
||||
"default": "./dist/lib/page-agent.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/lib/",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
"NOTICE"
|
||||
],
|
||||
"description": "AI-powered UI agent for web applications - add intelligent automation to any webpage with a single script tag",
|
||||
"keywords": [
|
||||
"ai",
|
||||
"automation",
|
||||
"ui-agent",
|
||||
"browser-automation",
|
||||
"web-agent",
|
||||
"llm",
|
||||
"dom-interaction",
|
||||
"intelligent-ui"
|
||||
"workspaces": [
|
||||
"packages/page-controller",
|
||||
"packages/ui",
|
||||
"packages/llms",
|
||||
"packages/page-agent",
|
||||
"packages/cdn",
|
||||
"packages/website"
|
||||
],
|
||||
"description": "AI-powered UI agent for web applications",
|
||||
"author": "Simon<gaomeng1900>",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@@ -38,56 +20,44 @@
|
||||
},
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=20.0.0",
|
||||
"npm": ">=10.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"start": "vite",
|
||||
"build": "tsc -b && vite build && npm run build:lib && npm run build:umd",
|
||||
"build:lib": "MODE=lib vite build",
|
||||
"build:lib:watch": "MODE=lib vite build --watch",
|
||||
"build:umd": "MODE=umd vite build",
|
||||
"cleanup": "rm -rf packages/*/dist",
|
||||
"start": "npm run dev --workspace=@page-agent/website",
|
||||
"build:website": "npm run build:website --workspace=@page-agent/website",
|
||||
"build:libs": "npm run build --workspaces --if-present",
|
||||
"build": "npm run build:libs && npm run build:website",
|
||||
"dev:umd": "npm run dev:umd --workspace=page-agent",
|
||||
"version": "node scripts/sync-version.js",
|
||||
"lint": "eslint .",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"ai-motion": "^0.4.7",
|
||||
"chalk": "^5.6.2",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.1.0",
|
||||
"@commitlint/config-conventional": "^20.0.0",
|
||||
"@eslint/js": "^9.37.0",
|
||||
"@microsoft/api-extractor": "^7.53.1",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||
"@commitlint/cli": "^20.3.1",
|
||||
"@commitlint/config-conventional": "^20.3.1",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@microsoft/api-extractor": "^7.55.2",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/node": "^25.0.7",
|
||||
"concurrently": "^9.2.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-dom": "^2.0.6",
|
||||
"eslint-plugin-react-hooks": "^7.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.23",
|
||||
"eslint-plugin-react-x": "^2.0.6",
|
||||
"globals": "^16.4.0",
|
||||
"eslint-plugin-react-dom": "^2.5.7",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"eslint-plugin-react-x": "^2.5.7",
|
||||
"globals": "^17.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"i18next": "^25.6.0",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"lint-staged": "^16.2.4",
|
||||
"prettier": "^3.6.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-i18next": "^16.1.4",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"prettier": "^3.7.4",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.0",
|
||||
"typescript-eslint": "^8.53.0",
|
||||
"unplugin-dts": "^1.0.0-beta.6",
|
||||
"vite": "^7.1.9",
|
||||
"vite-plugin-css-injected-by-js": "^3.5.2",
|
||||
"wouter": "^3.7.1"
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-css-injected-by-js": "^3.5.2"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,cjs,cts,mjs,mts}": [
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@page-agent/cdn",
|
||||
"private": false,
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"description": "CDN builds for page-agent - IIFE bundles for script tag usage",
|
||||
"author": "Simon<gaomeng1900>",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/alibaba/page-agent.git"
|
||||
},
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"scripts": {
|
||||
"build": "vite build && vite build --mode demo"
|
||||
},
|
||||
"dependencies": {
|
||||
"page-agent": "0.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Demo CDN build for page-agent
|
||||
* Auto-initializes with built-in demo API for testing
|
||||
*
|
||||
* Usage:
|
||||
* <script src="https://unpkg.com/@page-agent/cdn/dist/page-agent.demo.js"></script>
|
||||
*/
|
||||
import { PageAgent } from 'page-agent'
|
||||
|
||||
// Clean up existing instances to prevent multiple injections from bookmarklet
|
||||
if (window.pageAgent) {
|
||||
window.pageAgent.dispose()
|
||||
}
|
||||
|
||||
// Mount to global window object
|
||||
window.PageAgent = PageAgent
|
||||
|
||||
// Export for IIFE
|
||||
export { PageAgent }
|
||||
|
||||
console.log('🚀 page-agent.js loaded!')
|
||||
|
||||
const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
const DEMO_BASE_URL = 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
|
||||
const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
|
||||
// Demo warning
|
||||
console.log(
|
||||
'%c⚠️ DEMO MODE %c\n' +
|
||||
'This build uses a shared testing LLM with rate limits.\n' +
|
||||
'For production, use page-agent.js with your own API key:\n' +
|
||||
'https://alibaba.github.io/page-agent/#/docs/features/models',
|
||||
'background: #f59e0b; color: #000; font-size: 14px; font-weight: bold; padding: 4px 8px; border-radius: 4px;',
|
||||
'color: #f59e0b; font-size: 12px;'
|
||||
)
|
||||
|
||||
// in case document.x is not ready yet
|
||||
setTimeout(() => {
|
||||
const currentScript = document.currentScript
|
||||
|
||||
if (currentScript) {
|
||||
console.log('🚀 page-agent.js detected current script:', currentScript.src)
|
||||
const url = new URL(currentScript.src)
|
||||
const model = url.searchParams.get('model') || DEMO_MODEL
|
||||
const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL
|
||||
const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY
|
||||
const language = url.searchParams.get('lang') || 'zh-CN'
|
||||
const config = { model, baseURL, apiKey, language }
|
||||
window.pageAgent = new PageAgent(config)
|
||||
} else {
|
||||
console.log('🚀 page-agent.js no current script detected, using default demo config')
|
||||
const config = {
|
||||
model: DEMO_MODEL,
|
||||
baseURL: DEMO_BASE_URL,
|
||||
apiKey: DEMO_API_KEY,
|
||||
}
|
||||
window.pageAgent = new PageAgent(config)
|
||||
}
|
||||
|
||||
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
|
||||
|
||||
window.pageAgent.panel.show()
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Full CDN build for page-agent
|
||||
* Exposes PageAgent class for manual instantiation and configuration
|
||||
*
|
||||
* Usage:
|
||||
* <script src="https://unpkg.com/@page-agent/cdn/dist/page-agent.js"></script>
|
||||
* <script>
|
||||
* const agent = new PageAgent({ model: 'gpt-4o', apiKey: 'your-key' })
|
||||
* agent.panel.show()
|
||||
* </script>
|
||||
*/
|
||||
import { PageAgent } from 'page-agent'
|
||||
|
||||
export { PageAgent }
|
||||
@@ -0,0 +1,42 @@
|
||||
// @ts-nocheck
|
||||
import { dirname, resolve } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig } from 'vite'
|
||||
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
/**
|
||||
* CDN IIFE builds for page-agent
|
||||
*
|
||||
* Build targets (via --mode):
|
||||
* - demo: page-agent.demo.js with auto-init and built-in API
|
||||
* - full (default): page-agent.js exposes PageAgent class only
|
||||
*/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const isDemo = mode === 'demo'
|
||||
const entry = isDemo ? resolve(__dirname, 'src/demo.js') : resolve(__dirname, 'src/full.js')
|
||||
const fileName = isDemo ? 'page-agent.demo' : 'page-agent'
|
||||
|
||||
return {
|
||||
plugins: [cssInjectedByJsPlugin({ relativeCSSInjection: true })],
|
||||
publicDir: false,
|
||||
esbuild: {
|
||||
keepNames: true,
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry,
|
||||
name: 'PageAgent',
|
||||
fileName: () => `${fileName}.js`,
|
||||
formats: ['iife'],
|
||||
},
|
||||
outDir: resolve(__dirname, 'dist'),
|
||||
emptyOutDir: !isDemo, // only empty on first build (full)
|
||||
minify: false,
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
# @page-agent/llms
|
||||
|
||||
LLM client with a **reflection-before-action** mental model for page-agent.
|
||||
|
||||
## Why This Package Exists
|
||||
|
||||
The LLM module and the agent logic are inherently coupled. This package exists not to decouple them, but to **define the interface contract** between the LLM and the agent.
|
||||
|
||||
The core abstraction is the `MacroToolInput` — a structured output format that **forces the model to reflect before acting**.
|
||||
|
||||
## The Reflection-Before-Action Model
|
||||
|
||||
Every tool call must first output its reasoning state before the actual action:
|
||||
|
||||
```typescript
|
||||
interface MacroToolInput {
|
||||
// Reflection (mandatory before any action)
|
||||
evaluation_previous_goal?: string // How well did the previous action work?
|
||||
memory?: string // Key information to remember
|
||||
next_goal?: string // What to accomplish next
|
||||
|
||||
// Action (the actual operation)
|
||||
action: Record<string, any>
|
||||
}
|
||||
```
|
||||
|
||||
This design ensures that:
|
||||
|
||||
1. **The model evaluates its previous action** before deciding the next step
|
||||
2. **Working memory is explicitly maintained** across conversation turns
|
||||
3. **Goals are clearly stated**, making the agent's reasoning transparent and debuggable
|
||||
|
||||
## Key Components
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `LLM` | Main LLM client class with retry logic |
|
||||
| `MacroToolInput` | The reflection-before-action input schema |
|
||||
| `AgentBrain` | Agent's thinking state (eval, memory, goal) |
|
||||
| `LLMConfig` | Configuration for LLM connection |
|
||||
| `parseLLMConfig` | Parse and apply defaults to config |
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@page-agent/llms",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-agent-llms.js",
|
||||
"module": "./dist/lib/page-agent-llms.js",
|
||||
"types": "./dist/lib/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/lib/index.d.ts",
|
||||
"import": "./dist/lib/page-agent-llms.js",
|
||||
"default": "./dist/lib/page-agent-llms.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"description": "LLM client with reflection-before-action mental model for page-agent",
|
||||
"keywords": [
|
||||
"page-agent",
|
||||
"llm",
|
||||
"openai",
|
||||
"tool-calling",
|
||||
"agent"
|
||||
],
|
||||
"author": "Simon<gaomeng1900>",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/alibaba/page-agent.git",
|
||||
"directory": "packages/llms"
|
||||
},
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
||||
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* OpenAI Client implementation
|
||||
*/
|
||||
import { InvokeError, InvokeErrorType } from './errors'
|
||||
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
|
||||
import { modelPatch, zodToOpenAITool } from './utils'
|
||||
|
||||
/**
|
||||
* Client for OpenAI compatible APIs
|
||||
*/
|
||||
export class OpenAIClient implements LLMClient {
|
||||
config: Required<LLMConfig>
|
||||
private fetch: typeof globalThis.fetch
|
||||
|
||||
constructor(config: Required<LLMConfig>) {
|
||||
this.config = config
|
||||
this.fetch = config.customFetch
|
||||
}
|
||||
|
||||
async invoke(
|
||||
messages: Message[],
|
||||
tools: Record<string, Tool>,
|
||||
abortSignal?: AbortSignal,
|
||||
options?: InvokeOptions
|
||||
): Promise<InvokeResult> {
|
||||
// 1. Convert tools to OpenAI format
|
||||
const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t))
|
||||
|
||||
// Build request body
|
||||
const requestBody: Record<string, unknown> = {
|
||||
model: this.config.model,
|
||||
temperature: this.config.temperature,
|
||||
messages,
|
||||
tools: openaiTools,
|
||||
parallel_tool_calls: false,
|
||||
// Require tool call: specific tool if provided, otherwise any tool
|
||||
tool_choice: options?.toolChoiceName
|
||||
? { type: 'function', function: { name: options.toolChoiceName } }
|
||||
: 'required',
|
||||
}
|
||||
|
||||
// 2. Call API
|
||||
let response: Response
|
||||
try {
|
||||
response = await this.fetch(`${this.config.baseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(modelPatch(requestBody)),
|
||||
signal: abortSignal,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
console.error(error)
|
||||
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, 'Network request failed', error)
|
||||
}
|
||||
|
||||
// 3. Handle HTTP errors
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch()
|
||||
const errorMessage =
|
||||
(errorData as { error?: { message?: string } }).error?.message || response.statusText
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.AUTH_ERROR,
|
||||
`Authentication failed: ${errorMessage}`,
|
||||
errorData
|
||||
)
|
||||
}
|
||||
if (response.status === 429) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.RATE_LIMIT,
|
||||
`Rate limit exceeded: ${errorMessage}`,
|
||||
errorData
|
||||
)
|
||||
}
|
||||
if (response.status >= 500) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.SERVER_ERROR,
|
||||
`Server error: ${errorMessage}`,
|
||||
errorData
|
||||
)
|
||||
}
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.UNKNOWN,
|
||||
`HTTP ${response.status}: ${errorMessage}`,
|
||||
errorData
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Parse and validate response
|
||||
const data = await response.json()
|
||||
|
||||
const choice = data.choices?.[0]
|
||||
if (!choice) {
|
||||
throw new InvokeError(InvokeErrorType.UNKNOWN, 'No choices in response', data)
|
||||
}
|
||||
|
||||
// Check finish_reason
|
||||
switch (choice.finish_reason) {
|
||||
case 'tool_calls':
|
||||
case 'function_call': // gemini
|
||||
case 'stop': // some models use this even with tool calls
|
||||
break
|
||||
case 'length':
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.CONTEXT_LENGTH,
|
||||
'Response truncated: max tokens reached'
|
||||
)
|
||||
case 'content_filter':
|
||||
throw new InvokeError(InvokeErrorType.CONTENT_FILTER, 'Content filtered by safety system')
|
||||
default:
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.UNKNOWN,
|
||||
`Unexpected finish_reason: ${choice.finish_reason}`
|
||||
)
|
||||
}
|
||||
|
||||
// Apply normalizeResponse if provided (for fixing format issues automatically)
|
||||
const normalizedData = options?.normalizeResponse ? options.normalizeResponse(data) : data
|
||||
const normalizedChoice = (normalizedData as any).choices?.[0]
|
||||
|
||||
// Get tool name from response
|
||||
const toolCallName = normalizedChoice?.message?.tool_calls?.[0]?.function?.name
|
||||
if (!toolCallName) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.NO_TOOL_CALL,
|
||||
'No tool call found in response',
|
||||
normalizedData
|
||||
)
|
||||
}
|
||||
|
||||
const tool = tools[toolCallName]
|
||||
if (!tool) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.UNKNOWN,
|
||||
`Tool "${toolCallName}" not found in tools`,
|
||||
normalizedData
|
||||
)
|
||||
}
|
||||
|
||||
// Extract and parse tool arguments
|
||||
const argString = normalizedChoice.message?.tool_calls?.[0]?.function?.arguments
|
||||
if (!argString) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
'No tool call arguments found',
|
||||
normalizedData
|
||||
)
|
||||
}
|
||||
|
||||
let parsedArgs: unknown
|
||||
try {
|
||||
parsedArgs = JSON.parse(argString)
|
||||
} catch (error) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
'Failed to parse tool arguments as JSON',
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
// Validate with schema
|
||||
const validation = tool.inputSchema.safeParse(parsedArgs)
|
||||
if (!validation.success) {
|
||||
console.error(validation.error)
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
'Tool arguments validation failed',
|
||||
validation.error
|
||||
)
|
||||
}
|
||||
const toolInput = validation.data
|
||||
|
||||
// 5. Execute tool
|
||||
let toolResult: unknown
|
||||
try {
|
||||
toolResult = await tool.execute(toolInput)
|
||||
} catch (e) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.TOOL_EXECUTION_ERROR,
|
||||
`Tool execution failed: ${(e as Error).message}`,
|
||||
e
|
||||
)
|
||||
}
|
||||
|
||||
// Return result
|
||||
return {
|
||||
toolCall: {
|
||||
name: toolCallName,
|
||||
args: toolInput,
|
||||
},
|
||||
toolResult,
|
||||
usage: {
|
||||
promptTokens: data.usage?.prompt_tokens ?? 0,
|
||||
completionTokens: data.usage?.completion_tokens ?? 0,
|
||||
totalTokens: data.usage?.total_tokens ?? 0,
|
||||
cachedTokens: data.usage?.prompt_tokens_details?.cached_tokens,
|
||||
reasoningTokens: data.usage?.completion_tokens_details?.reasoning_tokens,
|
||||
},
|
||||
rawResponse: data,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Internal constants
|
||||
export const LLM_MAX_RETRIES = 2
|
||||
export const DEFAULT_TEMPERATURE = 0.7 // higher randomness helps auto-recovery
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,108 @@
|
||||
import { OpenAIClient } from './OpenAIClient'
|
||||
import { DEFAULT_TEMPERATURE, LLM_MAX_RETRIES } from './constants'
|
||||
import { InvokeError } from './errors'
|
||||
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
|
||||
|
||||
export type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool }
|
||||
|
||||
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
|
||||
// Runtime validation as defensive programming (types already guarantee these)
|
||||
if (!config.baseURL || !config.apiKey || !config.model) {
|
||||
throw new Error(
|
||||
'[PageAgent] LLM configuration required. Please provide: baseURL, apiKey, model. ' +
|
||||
'See: https://alibaba.github.io/page-agent/#/docs/features/models'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
baseURL: config.baseURL,
|
||||
apiKey: config.apiKey,
|
||||
model: config.model,
|
||||
temperature: config.temperature ?? DEFAULT_TEMPERATURE,
|
||||
maxRetries: config.maxRetries ?? LLM_MAX_RETRIES,
|
||||
customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound
|
||||
}
|
||||
}
|
||||
|
||||
export class LLM extends EventTarget {
|
||||
config: Required<LLMConfig>
|
||||
client: LLMClient
|
||||
|
||||
constructor(config: LLMConfig) {
|
||||
super()
|
||||
this.config = parseLLMConfig(config)
|
||||
|
||||
// Default to OpenAI client
|
||||
this.client = new OpenAIClient(this.config)
|
||||
}
|
||||
|
||||
/**
|
||||
* - call llm api *once*
|
||||
* - invoke tool call *once*
|
||||
* - return the result of the tool
|
||||
*/
|
||||
async invoke(
|
||||
messages: Message[],
|
||||
tools: Record<string, Tool>,
|
||||
abortSignal: AbortSignal,
|
||||
options?: InvokeOptions
|
||||
): Promise<InvokeResult> {
|
||||
return await withRetry(
|
||||
async () => {
|
||||
const result = await this.client.invoke(messages, tools, abortSignal, options)
|
||||
|
||||
return result
|
||||
},
|
||||
// retry settings
|
||||
{
|
||||
maxRetries: this.config.maxRetries,
|
||||
onRetry: (current: number) => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('retry', { detail: { current, max: this.config.maxRetries } })
|
||||
)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
this.dispatchEvent(new CustomEvent('error', { detail: { error } }))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
settings: {
|
||||
maxRetries: number
|
||||
onRetry: (retries: number) => void
|
||||
onError: (error: Error) => void
|
||||
}
|
||||
): Promise<T> {
|
||||
let retries = 0
|
||||
let lastError: Error | null = null
|
||||
while (retries <= settings.maxRetries) {
|
||||
if (retries > 0) {
|
||||
settings.onRetry(retries)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error: unknown) {
|
||||
console.error(error)
|
||||
settings.onError(error as Error)
|
||||
|
||||
// do not retry if aborted by user
|
||||
if ((error as { name?: string })?.name === 'AbortError') throw error
|
||||
|
||||
// do not retry if error is not retryable (InvokeError)
|
||||
if (error instanceof InvokeError && !error.retryable) throw error
|
||||
|
||||
lastError = error as Error
|
||||
retries++
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError!
|
||||
}
|
||||
@@ -32,6 +32,24 @@ export interface Tool<TParams = any, TResult = any> {
|
||||
execute: (args: TParams) => Promise<TResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke options for LLM call
|
||||
*/
|
||||
export interface InvokeOptions {
|
||||
/**
|
||||
* Force LLM to call a specific tool by name.
|
||||
* If provided: tool_choice = { type: 'function', function: { name: toolChoiceName } }
|
||||
* If not provided: tool_choice = 'required' (must call some tool, but model chooses which)
|
||||
*/
|
||||
toolChoiceName?: string
|
||||
/**
|
||||
* Response normalization function.
|
||||
* Called before parsing the response.
|
||||
* Used to fix various response format errors from the model.
|
||||
*/
|
||||
normalizeResponse?: (response: any) => any
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM Client interface
|
||||
* Note: Does not use generics because each tool in the tools array has different types
|
||||
@@ -40,7 +58,8 @@ export interface LLMClient {
|
||||
invoke(
|
||||
messages: Message[],
|
||||
tools: Record<string, Tool>,
|
||||
abortSignal?: AbortSignal
|
||||
abortSignal?: AbortSignal,
|
||||
options?: InvokeOptions
|
||||
): Promise<InvokeResult>
|
||||
}
|
||||
|
||||
@@ -65,13 +84,20 @@ export interface InvokeResult<TResult = unknown> {
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI Client config
|
||||
* LLM configuration
|
||||
*/
|
||||
export interface OpenAIClientConfig {
|
||||
model: string
|
||||
apiKey: string
|
||||
export interface LLMConfig {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
maxRetries?: number
|
||||
|
||||
/**
|
||||
* Custom fetch function for LLM API requests.
|
||||
* Use this to customize headers, credentials, proxy, etc.
|
||||
* The response should follow OpenAI API format.
|
||||
*/
|
||||
customFetch?: typeof globalThis.fetch
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Utility functions for LLM integration
|
||||
*/
|
||||
import chalk from 'chalk'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { Tool } from './types'
|
||||
|
||||
function debug(message: string) {
|
||||
console.debug(chalk.gray('[LLM]'), message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Zod schema to OpenAI tool format
|
||||
* Uses Zod 4 native z.toJSONSchema()
|
||||
*/
|
||||
export function zodToOpenAITool(name: string, tool: Tool) {
|
||||
return {
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name,
|
||||
description: tool.description,
|
||||
parameters: z.toJSONSchema(tool.inputSchema, { target: 'openapi-3.0' }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch model specific parameters
|
||||
*/
|
||||
export function modelPatch(body: Record<string, any>) {
|
||||
const model: string = body.model || ''
|
||||
if (!model) return body
|
||||
|
||||
const modelName = normalizeModelName(model)
|
||||
|
||||
if (modelName.startsWith('qwen')) {
|
||||
debug('Applying Qwen patch: use higher temperature for auto fixing')
|
||||
body.temperature = Math.max(body.temperature || 0, 1.0)
|
||||
}
|
||||
|
||||
if (modelName.startsWith('claude')) {
|
||||
debug('Applying Claude patch: disable thinking')
|
||||
body.thinking = { type: 'disabled' }
|
||||
|
||||
// Convert tool_choice to Claude format
|
||||
if (body.tool_choice === 'required') {
|
||||
// 'required' -> { type: 'any' } (must call some tool)
|
||||
debug('Applying Claude patch: convert tool_choice "required" to { type: "any" }')
|
||||
body.tool_choice = { type: 'any' }
|
||||
} else if (body.tool_choice?.function?.name) {
|
||||
// { type: 'function', function: { name: '...' } } -> { type: 'tool', name: '...' }
|
||||
debug('Applying Claude patch: convert tool_choice format')
|
||||
body.tool_choice = { type: 'tool', name: body.tool_choice.function.name }
|
||||
}
|
||||
}
|
||||
|
||||
if (modelName.startsWith('grok')) {
|
||||
debug('Applying Grok patch: removing tool_choice')
|
||||
delete body.tool_choice
|
||||
debug('Applying Grok patch: disable reasoning and thinking')
|
||||
body.thinking = { type: 'disabled', effort: 'minimal' }
|
||||
body.reasoning = { enabled: false, effort: 'low' }
|
||||
}
|
||||
|
||||
if (modelName.startsWith('gpt')) {
|
||||
debug('Applying GPT patch: set verbosity to low')
|
||||
body.verbosity = 'low'
|
||||
|
||||
if (modelName.startsWith('gpt-52')) {
|
||||
debug('Applying GPT-52 patch: disable reasoning')
|
||||
body.reasoning_effort = 'none'
|
||||
} else if (modelName.startsWith('gpt-51')) {
|
||||
debug('Applying GPT-51 patch: disable reasoning')
|
||||
body.reasoning_effort = 'none'
|
||||
} else if (modelName.startsWith('gpt-5')) {
|
||||
debug('Applying GPT-5 patch: set reasoning effort to low')
|
||||
body.reasoning_effort = 'low'
|
||||
}
|
||||
}
|
||||
|
||||
if (modelName.startsWith('gemini')) {
|
||||
debug('Applying Gemini patch: set reasoning effort to minimal')
|
||||
body.reasoning_effort = 'minimal'
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
/**
|
||||
* check if a given model ID fits a specific model name
|
||||
*
|
||||
* @note
|
||||
* Different model providers may use different model IDs for the same model.
|
||||
* For example, openai's `gpt-5.2` may called:
|
||||
*
|
||||
* - `gpt-5.2-version`
|
||||
* - `gpt-5_2-date`
|
||||
* - `GPT-52-version-date`
|
||||
* - `openai/gpt-5.2-chat`
|
||||
*
|
||||
* They should be treated as the same model.
|
||||
* Normalize them to `gpt-52`
|
||||
*/
|
||||
function normalizeModelName(modelName: string): string {
|
||||
let normalizedName = modelName.toLowerCase()
|
||||
|
||||
// remove prefix before '/'
|
||||
if (normalizedName.includes('/')) {
|
||||
normalizedName = normalizedName.split('/')[1]
|
||||
}
|
||||
|
||||
// remove '_'
|
||||
normalizedName = normalizedName.replace(/_/g, '')
|
||||
|
||||
// remove '.'
|
||||
normalizedName = normalizedName.replace(/\./g, '')
|
||||
|
||||
return normalizedName
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// @workaround DTS bug
|
||||
// dts do not work with monorepo path mapping
|
||||
// disable path mapping for it
|
||||
"paths": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||
"noEmit": false,
|
||||
"allowImportingTsExtensions": false,
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// @ts-check
|
||||
import chalk from 'chalk'
|
||||
import { dirname, resolve } from 'path'
|
||||
import dts from 'unplugin-dts/vite'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
console.log(chalk.cyan(`📦 Building @page-agent/llms`))
|
||||
|
||||
export default defineConfig({
|
||||
clearScreen: false,
|
||||
plugins: [dts({ tsconfigPath: './tsconfig.dts.json', bundleTypes: true })],
|
||||
publicDir: false,
|
||||
esbuild: {
|
||||
keepNames: true,
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/index.ts'),
|
||||
name: 'PageAgentLLMs',
|
||||
fileName: 'page-agent-llms',
|
||||
formats: ['es'],
|
||||
},
|
||||
outDir: resolve(__dirname, 'dist', 'lib'),
|
||||
rollupOptions: {
|
||||
external: ['chalk', 'zod'],
|
||||
},
|
||||
minify: false,
|
||||
sourcemap: true,
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "page-agent",
|
||||
"private": false,
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"main": "./dist/esm/page-agent.js",
|
||||
"module": "./dist/esm/page-agent.js",
|
||||
"types": "./dist/esm/PageAgent.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/esm/PageAgent.d.ts",
|
||||
"import": "./dist/esm/page-agent.js",
|
||||
"default": "./dist/esm/page-agent.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"description": "GUI agent for web applications - add intelligent automation to any webpage with a single script",
|
||||
"keywords": [
|
||||
"ai",
|
||||
"automation",
|
||||
"ui-agent",
|
||||
"GUI-agent",
|
||||
"browser-automation",
|
||||
"web-agent",
|
||||
"llm",
|
||||
"dom-interaction",
|
||||
"web-automation",
|
||||
"GUI-simulation"
|
||||
],
|
||||
"author": "Simon<gaomeng1900>",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/alibaba/page-agent.git"
|
||||
},
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"scripts": {
|
||||
"build": "vite build && vite build --config vite.umd.config.js",
|
||||
"serve": "npx serve dist/umd -p 5173",
|
||||
"dev:umd": "concurrently \"vite build --config vite.umd.config.js --watch\" \"npm run serve\"",
|
||||
"prepublishOnly": "node -e \"const fs=require('fs');['README.md','LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
||||
"postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"zod": "^4.3.5",
|
||||
"@page-agent/llms": "0.2.0",
|
||||
"@page-agent/page-controller": "0.2.0",
|
||||
"@page-agent/ui": "0.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
/**
|
||||
* Copyright (C) 2025 Alibaba Group Holding Limited
|
||||
* All rights reserved.
|
||||
*/
|
||||
import { LLM, type Tool } from '@page-agent/llms'
|
||||
import { PageController } from '@page-agent/page-controller'
|
||||
import { Panel } from '@page-agent/ui'
|
||||
import chalk from 'chalk'
|
||||
import zod from 'zod'
|
||||
|
||||
import type { PageAgentConfig } from './config'
|
||||
import { MAX_STEPS } from './config/constants'
|
||||
import SYSTEM_PROMPT from './prompts/system_prompt.md?raw'
|
||||
import { tools } from './tools'
|
||||
import { normalizeResponse, trimLines, uid, waitUntil } from './utils'
|
||||
import { assert } from './utils/assert'
|
||||
|
||||
/**
|
||||
* Agent reflection state - the reflection-before-action model
|
||||
*
|
||||
* Every tool call must first reflect on:
|
||||
* - evaluation_previous_goal: How well did the previous action achieve its goal?
|
||||
* - memory: Key information to remember for future steps
|
||||
* - next_goal: What should be accomplished in the next action?
|
||||
*/
|
||||
export interface AgentReflection {
|
||||
evaluation_previous_goal: string
|
||||
memory: string
|
||||
next_goal: string
|
||||
}
|
||||
|
||||
/**
|
||||
* MacroTool input structure
|
||||
*
|
||||
* This is the core abstraction that enforces the "reflection-before-action" mental model.
|
||||
* Before executing any action, the LLM must output its reasoning state.
|
||||
*/
|
||||
export interface MacroToolInput extends Partial<AgentReflection> {
|
||||
action: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* MacroTool output structure
|
||||
*/
|
||||
export interface MacroToolResult {
|
||||
input: MacroToolInput
|
||||
output: string
|
||||
}
|
||||
|
||||
export type { PageAgentConfig }
|
||||
export { tool, type PageAgentTool } from './tools'
|
||||
|
||||
/**
|
||||
* A single agent step with reflection and action
|
||||
*/
|
||||
export interface AgentStep {
|
||||
type: 'step'
|
||||
reflection: Partial<AgentReflection>
|
||||
action: {
|
||||
name: string
|
||||
input: any
|
||||
output: string
|
||||
}
|
||||
usage: {
|
||||
promptTokens: number
|
||||
completionTokens: number
|
||||
totalTokens: number
|
||||
cachedTokens?: number
|
||||
reasoningTokens?: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent observation event (stays in memory)
|
||||
*/
|
||||
export interface ObservationEvent {
|
||||
type: 'observation'
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* User takeover event
|
||||
*/
|
||||
export interface UserTakeoverEvent {
|
||||
type: 'user_takeover'
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all history events
|
||||
*/
|
||||
export type HistoryEvent = AgentStep | ObservationEvent | UserTakeoverEvent
|
||||
|
||||
/** @deprecated Use AgentStep instead */
|
||||
export type AgentHistory = AgentStep
|
||||
|
||||
export interface ExecutionResult {
|
||||
success: boolean
|
||||
data: string
|
||||
history: HistoryEvent[]
|
||||
}
|
||||
|
||||
export class PageAgent extends EventTarget {
|
||||
config: PageAgentConfig
|
||||
id = uid()
|
||||
panel: Panel
|
||||
tools: typeof tools
|
||||
paused = false
|
||||
disposed = false
|
||||
task = ''
|
||||
taskId = ''
|
||||
|
||||
#llm: LLM
|
||||
#abortController = new AbortController()
|
||||
#llmRetryListener: ((e: Event) => void) | null = null
|
||||
#llmErrorListener: ((e: Event) => void) | null = null
|
||||
#beforeUnloadListener: ((e: Event) => void) | null = null
|
||||
|
||||
/** PageController for DOM operations */
|
||||
pageController: PageController
|
||||
|
||||
/** Runtime states for tracking across steps */
|
||||
states = {
|
||||
/** Accumulated wait time in seconds, used by wait tool */
|
||||
totalWaitTime: 0,
|
||||
/** Last known URL for detecting navigation */
|
||||
lastURL: '',
|
||||
}
|
||||
|
||||
/** History event stream */
|
||||
history: HistoryEvent[] = []
|
||||
|
||||
constructor(config: PageAgentConfig) {
|
||||
super()
|
||||
|
||||
this.config = config
|
||||
this.#llm = new LLM(this.config)
|
||||
this.panel = new Panel({
|
||||
language: this.config.language,
|
||||
onExecuteTask: (task) => this.execute(task),
|
||||
onStop: () => this.dispose(),
|
||||
onPauseToggle: () => {
|
||||
this.paused = !this.paused
|
||||
return this.paused
|
||||
},
|
||||
getPaused: () => this.paused,
|
||||
})
|
||||
this.tools = new Map(tools)
|
||||
|
||||
// Initialize PageController with config (mask enabled by default)
|
||||
this.pageController = new PageController({
|
||||
...this.config,
|
||||
enableMask: this.config.enableMask ?? true,
|
||||
})
|
||||
|
||||
// Listen to LLM events
|
||||
this.#llmRetryListener = (e) => {
|
||||
const { current, max } = (e as CustomEvent).detail
|
||||
this.panel.update({ type: 'retry', current, max })
|
||||
}
|
||||
this.#llmErrorListener = (e) => {
|
||||
const { error } = (e as CustomEvent).detail
|
||||
this.panel.update({ type: 'error', message: `step failed: ${error.message}` })
|
||||
}
|
||||
this.#llm.addEventListener('retry', this.#llmRetryListener)
|
||||
this.#llm.addEventListener('error', this.#llmErrorListener)
|
||||
|
||||
if (this.config.customTools) {
|
||||
for (const [name, tool] of Object.entries(this.config.customTools)) {
|
||||
if (tool === null) {
|
||||
this.tools.delete(name)
|
||||
continue
|
||||
}
|
||||
this.tools.set(name, tool)
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.config.experimentalScriptExecutionTool) {
|
||||
this.tools.delete('execute_javascript')
|
||||
}
|
||||
|
||||
this.#beforeUnloadListener = (e) => {
|
||||
if (!this.disposed) this.dispose('PAGE_UNLOADING')
|
||||
}
|
||||
window.addEventListener('beforeunload', this.#beforeUnloadListener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a persistent observation to the history event stream.
|
||||
* This will be visible in <agent_history> and remain in memory across steps.
|
||||
*/
|
||||
pushObservation(content: string): void {
|
||||
this.history.push({ type: 'observation', content })
|
||||
}
|
||||
|
||||
async execute(task: string): Promise<ExecutionResult> {
|
||||
if (!task) throw new Error('Task is required')
|
||||
this.task = task
|
||||
this.taskId = uid()
|
||||
|
||||
const onBeforeStep = this.config.onBeforeStep || (() => void 0)
|
||||
const onAfterStep = this.config.onAfterStep || (() => void 0)
|
||||
const onBeforeTask = this.config.onBeforeTask || (() => void 0)
|
||||
const onAfterTask = this.config.onAfterTask || (() => void 0)
|
||||
|
||||
await onBeforeTask.call(this)
|
||||
|
||||
// Show mask and panel
|
||||
this.pageController.showMask()
|
||||
|
||||
this.panel.show()
|
||||
this.panel.reset()
|
||||
|
||||
this.panel.update({ type: 'input', task: this.task })
|
||||
|
||||
if (this.#abortController) {
|
||||
this.#abortController.abort()
|
||||
this.#abortController = new AbortController()
|
||||
}
|
||||
|
||||
this.history = []
|
||||
|
||||
// Reset states
|
||||
this.states = {
|
||||
totalWaitTime: 0,
|
||||
lastURL: '',
|
||||
}
|
||||
|
||||
try {
|
||||
let step = 0
|
||||
|
||||
while (true) {
|
||||
await this.#generateObservations(step)
|
||||
|
||||
await onBeforeStep.call(this, step)
|
||||
|
||||
console.group(`step: ${step}`)
|
||||
|
||||
// abort
|
||||
if (this.#abortController.signal.aborted) throw new Error('AbortError')
|
||||
// pause
|
||||
await waitUntil(() => !this.paused)
|
||||
|
||||
// Update status to thinking
|
||||
console.log(chalk.blue('Thinking...'))
|
||||
this.panel.update({ type: 'thinking' })
|
||||
|
||||
const result = await this.#llm.invoke(
|
||||
[
|
||||
{
|
||||
role: 'system',
|
||||
content: this.#getSystemPrompt(),
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: await this.#assembleUserPrompt(),
|
||||
},
|
||||
],
|
||||
{ AgentOutput: this.#packMacroTool() },
|
||||
this.#abortController.signal,
|
||||
{
|
||||
toolChoiceName: 'AgentOutput',
|
||||
normalizeResponse,
|
||||
}
|
||||
)
|
||||
|
||||
const macroResult = result.toolResult as MacroToolResult
|
||||
const input = macroResult.input
|
||||
const output = macroResult.output
|
||||
const reflection: Partial<AgentReflection> = {
|
||||
evaluation_previous_goal: input.evaluation_previous_goal,
|
||||
memory: input.memory,
|
||||
next_goal: input.next_goal,
|
||||
}
|
||||
const actionName = Object.keys(input.action)[0]
|
||||
const action: AgentStep['action'] = {
|
||||
name: actionName,
|
||||
input: input.action[actionName],
|
||||
output: output,
|
||||
}
|
||||
|
||||
this.history.push({
|
||||
type: 'step',
|
||||
reflection,
|
||||
action,
|
||||
usage: result.usage,
|
||||
} as AgentStep)
|
||||
|
||||
console.log(chalk.green('Step finished:'), actionName)
|
||||
console.groupEnd()
|
||||
|
||||
await onAfterStep.call(this, step, this.history)
|
||||
|
||||
step++
|
||||
if (step > MAX_STEPS) {
|
||||
this.#onDone('Step count exceeded maximum limit', false)
|
||||
const result: ExecutionResult = {
|
||||
success: false,
|
||||
data: 'Step count exceeded maximum limit',
|
||||
history: this.history,
|
||||
}
|
||||
await onAfterTask.call(this, result)
|
||||
return result
|
||||
}
|
||||
if (actionName === 'done') {
|
||||
const success = action.input?.success ?? false
|
||||
const text = action.input?.text || 'no text provided'
|
||||
console.log(chalk.green.bold('Task completed'), success, text)
|
||||
this.#onDone(text, success)
|
||||
const result: ExecutionResult = {
|
||||
success,
|
||||
data: text,
|
||||
history: this.history,
|
||||
}
|
||||
await onAfterTask.call(this, result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error('Task failed', error)
|
||||
this.#onDone(String(error), false)
|
||||
const result: ExecutionResult = {
|
||||
success: false,
|
||||
data: String(error),
|
||||
history: this.history,
|
||||
}
|
||||
await onAfterTask.call(this, result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all tools into a single MacroTool with the following input:
|
||||
* - thinking: string
|
||||
* - evaluation_previous_goal: string
|
||||
* - memory: string
|
||||
* - next_goal: string
|
||||
* - action: { toolName: toolInput }
|
||||
* where action must be selected from tools defined in this.tools
|
||||
*/
|
||||
#packMacroTool(): Tool<MacroToolInput, MacroToolResult> {
|
||||
const tools = this.tools
|
||||
|
||||
const actionSchemas = Array.from(tools.entries()).map(([toolName, tool]) => {
|
||||
return zod.object({ [toolName]: tool.inputSchema }).describe(tool.description)
|
||||
})
|
||||
|
||||
const actionSchema = zod.union(
|
||||
actionSchemas as unknown as [zod.ZodType, zod.ZodType, ...zod.ZodType[]]
|
||||
)
|
||||
|
||||
const macroToolSchema = zod.object({
|
||||
// thinking: zod.string().optional(),
|
||||
evaluation_previous_goal: zod.string().optional(),
|
||||
memory: zod.string().optional(),
|
||||
next_goal: zod.string().optional(),
|
||||
action: actionSchema,
|
||||
})
|
||||
|
||||
return {
|
||||
description: 'You MUST call this tool every step. Outputs your reflections and next action.',
|
||||
inputSchema: macroToolSchema as zod.ZodType<MacroToolInput>,
|
||||
execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
|
||||
// abort
|
||||
if (this.#abortController.signal.aborted) throw new Error('AbortError')
|
||||
// pause
|
||||
await waitUntil(() => !this.paused)
|
||||
|
||||
console.log(chalk.blue.bold('MacroTool execute'), input)
|
||||
const action = input.action
|
||||
|
||||
const toolName = Object.keys(action)[0]
|
||||
const toolInput = action[toolName]
|
||||
|
||||
// Build reflection text, only include non-empty fields
|
||||
const reflectionLines: string[] = []
|
||||
if (input.evaluation_previous_goal)
|
||||
reflectionLines.push(`✅: ${input.evaluation_previous_goal}`)
|
||||
if (input.memory) reflectionLines.push(`💾: ${input.memory}`)
|
||||
if (input.next_goal) reflectionLines.push(`🎯: ${input.next_goal}`)
|
||||
|
||||
const reflectionText = reflectionLines.length > 0 ? reflectionLines.join('\n') : ''
|
||||
|
||||
if (reflectionText) {
|
||||
console.log(reflectionText)
|
||||
this.panel.update({ type: 'thinking', text: reflectionText })
|
||||
}
|
||||
|
||||
// Find the corresponding tool
|
||||
const tool = tools.get(toolName)
|
||||
assert(tool, `Tool ${toolName} not found. (@note should have been caught before this!!!)`)
|
||||
|
||||
console.log(chalk.blue.bold(`Executing tool: ${toolName}`), toolInput)
|
||||
this.panel.update({ type: 'toolExecuting', toolName, args: toolInput })
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
// Execute tool, bind `this` to PageAgent
|
||||
const result = await tool.execute.bind(this)(toolInput)
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result)
|
||||
|
||||
// Reset wait time for non-wait tools
|
||||
if (toolName !== 'wait') {
|
||||
this.states.totalWaitTime = 0
|
||||
}
|
||||
|
||||
// Briefly display execution result
|
||||
this.panel.update({
|
||||
type: 'toolCompleted',
|
||||
toolName,
|
||||
args: toolInput,
|
||||
result,
|
||||
duration,
|
||||
})
|
||||
|
||||
// Wait a moment to let user see the result
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Return structured result
|
||||
return {
|
||||
input,
|
||||
output: result,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system prompt, dynamically replace language settings based on configured language
|
||||
*/
|
||||
#getSystemPrompt(): string {
|
||||
let systemPrompt = SYSTEM_PROMPT
|
||||
|
||||
const targetLanguage = this.config.language === 'zh-CN' ? '中文' : 'English'
|
||||
systemPrompt = systemPrompt.replace(
|
||||
/Default working language: \*\*.*?\*\*/,
|
||||
`Default working language: **${targetLanguage}**`
|
||||
)
|
||||
|
||||
return systemPrompt
|
||||
}
|
||||
|
||||
/**
|
||||
* Get instructions from config and format as XML block
|
||||
*/
|
||||
async #getInstructions(): Promise<string> {
|
||||
const { instructions } = this.config
|
||||
if (!instructions) return ''
|
||||
|
||||
const systemInstructions = instructions.system?.trim()
|
||||
const url = await this.pageController.getCurrentUrl()
|
||||
let pageInstructions: string | undefined
|
||||
|
||||
if (instructions.getPageInstructions) {
|
||||
try {
|
||||
pageInstructions = instructions.getPageInstructions(url)?.trim()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('[PageAgent] Failed to execute getPageInstructions callback:'),
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!systemInstructions && !pageInstructions) return ''
|
||||
|
||||
let result = '<instructions>\n'
|
||||
|
||||
if (systemInstructions) {
|
||||
result += `<system_instructions>\n${systemInstructions}\n</system_instructions>\n`
|
||||
}
|
||||
|
||||
if (pageInstructions) {
|
||||
result += `<page_instructions>\n${pageInstructions}\n</page_instructions>\n`
|
||||
}
|
||||
|
||||
result += '</instructions>\n\n'
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate observations before each step
|
||||
* - URL change detection
|
||||
* - Too many steps warning
|
||||
* @todo loop detection
|
||||
* @todo console error
|
||||
*/
|
||||
async #generateObservations(stepCount: number): Promise<void> {
|
||||
// Detect URL change
|
||||
const currentURL = await this.pageController.getCurrentUrl()
|
||||
if (currentURL !== this.states.lastURL) {
|
||||
this.pushObservation(`Page navigated to → ${currentURL}`)
|
||||
this.states.lastURL = currentURL
|
||||
}
|
||||
|
||||
// Warn about remaining steps
|
||||
const remaining = MAX_STEPS - stepCount
|
||||
if (remaining === 5) {
|
||||
this.pushObservation(
|
||||
`⚠️ Only ${remaining} steps remaining. Consider wrapping up or calling done with partial results.`
|
||||
)
|
||||
} else if (remaining === 2) {
|
||||
this.pushObservation(
|
||||
`⚠️ Critical: Only ${remaining} steps left! You must finish the task or call done immediately.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async #assembleUserPrompt(): Promise<string> {
|
||||
let prompt = ''
|
||||
|
||||
// <instructions> (optional)
|
||||
prompt += await this.#getInstructions()
|
||||
|
||||
// <agent_state>
|
||||
// - <user_request>
|
||||
// - <step_info>
|
||||
// <agent_state>
|
||||
|
||||
const stepCount = this.history.filter((e) => e.type === 'step').length
|
||||
|
||||
prompt += `<agent_state>
|
||||
<user_request>
|
||||
${this.task}
|
||||
</user_request>
|
||||
<step_info>
|
||||
Step ${stepCount + 1} of ${MAX_STEPS} max possible steps
|
||||
Current date and time: ${new Date().toISOString()}
|
||||
</step_info>
|
||||
</agent_state>
|
||||
`
|
||||
|
||||
// <agent_history>
|
||||
// - <step_N> for steps
|
||||
// - <sys> for observations and system messages
|
||||
|
||||
prompt += '\n<agent_history>\n'
|
||||
|
||||
let stepIndex = 0
|
||||
for (const event of this.history) {
|
||||
if (event.type === 'step') {
|
||||
stepIndex++
|
||||
prompt += `<step_${stepIndex}>
|
||||
Evaluation of Previous Step: ${event.reflection.evaluation_previous_goal}
|
||||
Memory: ${event.reflection.memory}
|
||||
Next Goal: ${event.reflection.next_goal}
|
||||
Action Results: ${event.action.output}
|
||||
</step_${stepIndex}>
|
||||
`
|
||||
} else if (event.type === 'observation') {
|
||||
prompt += `<sys>${event.content}</sys>\n`
|
||||
} else if (event.type === 'user_takeover') {
|
||||
prompt += `<sys>User took over control and made changes to the page.</sys>\n`
|
||||
}
|
||||
}
|
||||
|
||||
prompt += '</agent_history>\n\n'
|
||||
|
||||
// <browser_state>
|
||||
|
||||
prompt += await this.#getBrowserState()
|
||||
|
||||
return trimLines(prompt)
|
||||
}
|
||||
|
||||
#onDone(text: string, success = true) {
|
||||
this.pageController.cleanUpHighlights()
|
||||
|
||||
// Update panel status
|
||||
if (success) {
|
||||
this.panel.update({ type: 'output', text })
|
||||
} else {
|
||||
this.panel.update({ type: 'error', message: text })
|
||||
}
|
||||
|
||||
// Task completed
|
||||
this.panel.update({ type: 'completed' })
|
||||
|
||||
this.pageController.hideMask()
|
||||
|
||||
this.#abortController.abort()
|
||||
}
|
||||
|
||||
async #getBrowserState(): Promise<string> {
|
||||
const state = await this.pageController.getBrowserState()
|
||||
|
||||
let content = state.content
|
||||
if (this.config.transformPageContent) {
|
||||
content = await this.config.transformPageContent(content)
|
||||
}
|
||||
|
||||
return trimLines(`<browser_state>
|
||||
Current Page: [${state.title}](${state.url})
|
||||
|
||||
${state.header}
|
||||
${content}
|
||||
${state.footer}
|
||||
|
||||
</browser_state>
|
||||
`)
|
||||
}
|
||||
|
||||
dispose(reason?: string) {
|
||||
console.log('Disposing PageAgent...')
|
||||
this.disposed = true
|
||||
this.pageController.dispose()
|
||||
this.panel.dispose()
|
||||
this.history = []
|
||||
this.#abortController.abort(reason ?? 'PageAgent disposed')
|
||||
|
||||
// Clean up LLM event listeners
|
||||
if (this.#llmRetryListener) {
|
||||
this.#llm.removeEventListener('retry', this.#llmRetryListener)
|
||||
this.#llmRetryListener = null
|
||||
}
|
||||
if (this.#llmErrorListener) {
|
||||
this.#llm.removeEventListener('error', this.#llmErrorListener)
|
||||
this.#llmErrorListener = null
|
||||
}
|
||||
|
||||
// Clean up window event listeners
|
||||
if (this.#beforeUnloadListener) {
|
||||
window.removeEventListener('beforeunload', this.#beforeUnloadListener)
|
||||
this.#beforeUnloadListener = null
|
||||
}
|
||||
|
||||
this.config.onDispose?.call(this, reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Agent-specific constants (LLM constants moved to @page-agent/llms)
|
||||
export const MAX_STEPS = 20
|
||||
@@ -1,25 +1,11 @@
|
||||
import type { AgentHistory, ExecutionResult, PageAgent } from '@/PageAgent'
|
||||
import type { DomConfig } from '@/dom'
|
||||
import type { SupportedLanguage } from '@/i18n'
|
||||
import type { PageAgentTool } from '@/tools'
|
||||
import type { LLMConfig } from '@page-agent/llms'
|
||||
import type { PageControllerConfig } from '@page-agent/page-controller'
|
||||
import type { SupportedLanguage } from '@page-agent/ui'
|
||||
|
||||
import {
|
||||
DEFAULT_API_KEY,
|
||||
DEFAULT_BASE_URL,
|
||||
DEFAULT_MAX_TOKENS,
|
||||
DEFAULT_MODEL_NAME,
|
||||
DEFAULT_TEMPERATURE,
|
||||
LLM_MAX_RETRIES,
|
||||
} from './constants'
|
||||
import type { ExecutionResult, HistoryEvent, PageAgent } from '../PageAgent'
|
||||
import type { PageAgentTool } from '../tools'
|
||||
|
||||
export interface LLMConfig {
|
||||
baseURL?: string
|
||||
apiKey?: string
|
||||
model?: string
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
maxRetries?: number
|
||||
}
|
||||
export type { LLMConfig }
|
||||
|
||||
export interface AgentConfig {
|
||||
// theme?: 'light' | 'dark'
|
||||
@@ -56,26 +42,71 @@ export interface AgentConfig {
|
||||
*/
|
||||
customTools?: Record<string, PageAgentTool | null>
|
||||
|
||||
/**
|
||||
* Instructions to guide the agent's behavior
|
||||
*/
|
||||
instructions?: {
|
||||
/**
|
||||
* Global system-level instructions, applied to all tasks
|
||||
*/
|
||||
system?: string
|
||||
|
||||
/**
|
||||
* Dynamic page-level instructions callback
|
||||
* Called before each step to get instructions for the current page
|
||||
* @param url - Current page URL (window.location.href)
|
||||
* @returns Instructions string, or undefined/null to skip
|
||||
*/
|
||||
getPageInstructions?: (url: string) => string | undefined | null
|
||||
}
|
||||
|
||||
// lifecycle hooks
|
||||
// @todo: use event instead of hooks
|
||||
// @todo: remove `this` binding, pass agent as explicit parameter instead
|
||||
|
||||
onBeforeStep?: (this: PageAgent, stepCnt: number) => Promise<void> | void
|
||||
onAfterStep?: (this: PageAgent, stepCnt: number, history: AgentHistory[]) => Promise<void> | void
|
||||
onAfterStep?: (this: PageAgent, stepCnt: number, history: HistoryEvent[]) => Promise<void> | void
|
||||
onBeforeTask?: (this: PageAgent) => Promise<void> | void
|
||||
onAfterTask?: (this: PageAgent, result: ExecutionResult) => Promise<void> | void
|
||||
|
||||
/**
|
||||
* @note this hook can block the disposal process
|
||||
* @note when dispose caused by page unload, reason will be 'PAGE_UNLOADING'. this method CANNOT block unloading. async operations may be cut.
|
||||
* @todo remove `this` binding, pass agent as explicit parameter instead
|
||||
*/
|
||||
onDispose?: (this: PageAgent, reason?: string) => void
|
||||
|
||||
// page behavior hooks
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Enable the experimental script execution tool that allows executing generated JavaScript code on the page.
|
||||
* @note Can cause unpredictable side effects.
|
||||
* @note May bypass some safe guards and data-masking mechanisms.
|
||||
*/
|
||||
experimentalScriptExecutionTool?: boolean
|
||||
|
||||
/**
|
||||
* Transform page content before sending to LLM.
|
||||
* Called after DOM extraction and simplification, before LLM invocation.
|
||||
* Use cases: inspect extraction results, modify page info, mask sensitive data.
|
||||
*
|
||||
* @param content - Simplified page content that will be sent to LLM
|
||||
* @returns Transformed content
|
||||
*
|
||||
* @example
|
||||
* // Mask phone numbers
|
||||
* transformPageContent: async (content) => {
|
||||
* return content.replace(/1[3-9]\d{9}/g, '***********')
|
||||
* }
|
||||
*/
|
||||
transformPageContent?: (content: string) => Promise<string> | string
|
||||
|
||||
/**
|
||||
* TODO: @unimplemented
|
||||
* hook when action causes a new page to be opened
|
||||
* @note PageAgent will try to detect new pages and decide if it's caused by an action. But not very reliable.
|
||||
* @todo remove `this` binding, pass agent as explicit parameter instead
|
||||
*/
|
||||
onNewPageOpen?: (this: PageAgent, url: string) => Promise<void> | void
|
||||
|
||||
@@ -87,15 +118,4 @@ export interface AgentConfig {
|
||||
experimentalPreventNewPage?: boolean
|
||||
}
|
||||
|
||||
export type PageAgentConfig = LLMConfig & AgentConfig & DomConfig
|
||||
|
||||
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
|
||||
return {
|
||||
baseURL: config.baseURL ?? DEFAULT_BASE_URL,
|
||||
apiKey: config.apiKey ?? DEFAULT_API_KEY,
|
||||
model: config.model ?? DEFAULT_MODEL_NAME,
|
||||
temperature: config.temperature ?? DEFAULT_TEMPERATURE,
|
||||
maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
|
||||
maxRetries: config.maxRetries ?? LLM_MAX_RETRIES,
|
||||
}
|
||||
}
|
||||
export type PageAgentConfig = LLMConfig & AgentConfig & PageControllerConfig
|
||||
+1
-6
@@ -1,5 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
import type { PageAgent } from './src/PageAgent'
|
||||
import type { PageAgent } from './PageAgent'
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
@@ -11,15 +11,10 @@ declare module '*.md?raw' {
|
||||
export default content
|
||||
}
|
||||
|
||||
/**
|
||||
* for local dev and umd demo
|
||||
*/
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
pageAgent?: PageAgent
|
||||
PageAgent: typeof PageAgent
|
||||
|
||||
__PAGE_AGENT_IDS__: string[]
|
||||
}
|
||||
}
|
||||
@@ -4,23 +4,8 @@
|
||||
*/
|
||||
import zod, { type z } from 'zod'
|
||||
|
||||
import type { PageAgent } from '@/PageAgent'
|
||||
|
||||
import {
|
||||
clickElement,
|
||||
getElementByIndex,
|
||||
getSystemInfo,
|
||||
inputTextElement,
|
||||
scrollHorizontally,
|
||||
scrollVertically,
|
||||
selectOptionElement,
|
||||
waitFor,
|
||||
} from './actions'
|
||||
// debug
|
||||
import * as utils from './actions'
|
||||
|
||||
// @ts-expect-error debug only
|
||||
window.utils = utils
|
||||
import type { PageAgent } from '../PageAgent'
|
||||
import { waitFor } from '../utils'
|
||||
|
||||
/**
|
||||
* Internal tool definition that has access to PageAgent `this` context
|
||||
@@ -42,18 +27,6 @@ export function tool<TParams>(options: PageAgentTool<TParams>): PageAgentTool<TP
|
||||
*/
|
||||
export const tools = new Map<string, PageAgentTool>()
|
||||
|
||||
// tools.set(
|
||||
// 'get_current_html',
|
||||
// tool({
|
||||
// description: 'Get the current (updated) simplified HTML of the page',
|
||||
// inputSchema: zod.object({}),
|
||||
// execute: function (this: PageAgent) {
|
||||
// this.updateTree()
|
||||
// return this.simplifiedHTML
|
||||
// },
|
||||
// })
|
||||
// )
|
||||
|
||||
tools.set(
|
||||
'done',
|
||||
tool({
|
||||
@@ -80,11 +53,20 @@ tools.set(
|
||||
seconds: zod.number().min(1).max(10).default(1),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
const lastTimeUpdate = this.lastTimeUpdate
|
||||
const lastTimeUpdate = await this.pageController.getLastUpdateTime()
|
||||
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000)
|
||||
console.log(`actualWaitTime: ${actualWaitTime} seconds`)
|
||||
await waitFor(actualWaitTime)
|
||||
return `✅ Waited for ${input.seconds} seconds.` + (await getSystemInfo())
|
||||
|
||||
this.states.totalWaitTime += input.seconds
|
||||
|
||||
if (this.states.totalWaitTime >= 3) {
|
||||
this.pushObservation(
|
||||
`You have waited ${this.states.totalWaitTime} seconds accumulatively. Do NOT wait any longer unless you have a good reason.`
|
||||
)
|
||||
}
|
||||
|
||||
return `✅ Waited for ${input.seconds} seconds.`
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -99,7 +81,7 @@ tools.set(
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
const answer = await this.panel.askUser(input.question)
|
||||
return `✅ Received user answer: ${answer}` + (await getSystemInfo())
|
||||
return `✅ Received user answer: ${answer}`
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -112,16 +94,8 @@ tools.set(
|
||||
index: zod.int().min(0),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
const element = getElementByIndex(this, input.index)
|
||||
const elemText = this.elementTextMap.get(input.index)
|
||||
await clickElement(element)
|
||||
|
||||
// @workaround: Handle links that open in new tabs
|
||||
if (element instanceof HTMLAnchorElement && element.target === '_blank') {
|
||||
return `⚠️ Clicked link that opens in a new tab (${elemText ? elemText : input.index}). You are not capable of reading new tabs.`
|
||||
}
|
||||
|
||||
return `✅ Clicked element (${elemText ? elemText : input.index}).` + (await getSystemInfo())
|
||||
const result = await this.pageController.clickElement(input.index)
|
||||
return result.message
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -135,13 +109,8 @@ tools.set(
|
||||
text: zod.string(),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
const element = getElementByIndex(this, input.index)
|
||||
const elemText = this.elementTextMap.get(input.index)
|
||||
await inputTextElement(element, input.text)
|
||||
return (
|
||||
`✅ Input text (${input.text}) into element (${elemText ? elemText : input.index}).` +
|
||||
(await getSystemInfo())
|
||||
)
|
||||
const result = await this.pageController.inputText(input.index, input.text)
|
||||
return result.message
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -156,13 +125,8 @@ tools.set(
|
||||
text: zod.string(),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
const element = getElementByIndex(this, input.index)
|
||||
const elemText = this.elementTextMap.get(input.index)
|
||||
await selectOptionElement(element as HTMLSelectElement, input.text)
|
||||
return (
|
||||
`✅ Selected option (${input.text}) in element (${elemText ? elemText : input.index}).` +
|
||||
(await getSystemInfo())
|
||||
)
|
||||
const result = await this.pageController.selectOption(input.index, input.text)
|
||||
return result.message
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -182,13 +146,11 @@ tools.set(
|
||||
index: zod.number().int().min(0).optional(),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
const { down, num_pages, index, pixels } = input
|
||||
|
||||
const scroll_amount = pixels ? pixels : num_pages * (down ? 1 : -1) * window.innerHeight
|
||||
|
||||
const element = index !== undefined ? getElementByIndex(this, index) : null
|
||||
|
||||
return (await scrollVertically(down, scroll_amount, element)) + (await getSystemInfo())
|
||||
const result = await this.pageController.scroll({
|
||||
...input,
|
||||
numPages: input.num_pages,
|
||||
})
|
||||
return result.message
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -204,13 +166,23 @@ tools.set(
|
||||
index: zod.number().int().min(0).optional(),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
const { right, pixels, index } = input
|
||||
const result = await this.pageController.scrollHorizontally(input)
|
||||
return result.message
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const scroll_amount = pixels * (right ? 1 : -1)
|
||||
|
||||
const element = index !== undefined ? getElementByIndex(this, index) : null
|
||||
|
||||
return (await scrollHorizontally(right, scroll_amount, element)) + (await getSystemInfo())
|
||||
tools.set(
|
||||
'execute_javascript',
|
||||
tool({
|
||||
description:
|
||||
'Execute JavaScript code on the current page. Supports async/await syntax. Use with caution!',
|
||||
inputSchema: zod.object({
|
||||
script: zod.string(),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
const result = await this.pageController.executeJavascript(input.script)
|
||||
return result.message
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Auto-run entry for page-agent.js. Insert this script into your page to get page-agent functionality.
|
||||
*/
|
||||
import { PageAgent, type PageAgentConfig } from './PageAgent'
|
||||
|
||||
// Clean up existing instances to prevent multiple injections from bookmarklet
|
||||
if (window.pageAgent) {
|
||||
window.pageAgent.dispose()
|
||||
}
|
||||
|
||||
// Mount to global window object
|
||||
window.PageAgent = PageAgent
|
||||
|
||||
// Export for ES module usage
|
||||
// export { PageAgent }
|
||||
|
||||
console.log('🚀 page-agent.js loaded!')
|
||||
|
||||
const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
const DEMO_BASE_URL = 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
|
||||
const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
|
||||
// in case document.x is not ready yet
|
||||
// @todo give a switch to disable auto-init
|
||||
setTimeout(() => {
|
||||
const currentScript = document.currentScript as HTMLScriptElement | null
|
||||
if (currentScript) {
|
||||
console.log('🚀 page-agent.js detected current script:', currentScript.src)
|
||||
const url = new URL(currentScript.src)
|
||||
const model = url.searchParams.get('model') || DEMO_MODEL
|
||||
const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL
|
||||
const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY
|
||||
const language = (url.searchParams.get('lang') as 'zh-CN' | 'en-US') || 'zh-CN'
|
||||
const config: PageAgentConfig = { model, baseURL, apiKey, language }
|
||||
window.pageAgent = new PageAgent(config)
|
||||
} else {
|
||||
console.log('🚀 page-agent.js no current script detected, using default demo config')
|
||||
const config: PageAgentConfig = {
|
||||
model: DEMO_MODEL,
|
||||
baseURL: DEMO_BASE_URL,
|
||||
apiKey: DEMO_API_KEY,
|
||||
}
|
||||
window.pageAgent = new PageAgent(config)
|
||||
}
|
||||
|
||||
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
|
||||
|
||||
window.pageAgent.panel.show() // Show panel
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
import chalk from 'chalk'
|
||||
|
||||
/**
|
||||
* Normalize LLM response and fix common format issues.
|
||||
*
|
||||
* Handles:
|
||||
* - No tool_calls but JSON in message.content (fallback)
|
||||
* - Model returns action name as tool call instead of AgentOutput
|
||||
* - Arguments wrapped as double JSON string
|
||||
* - Nested function call format
|
||||
* - Missing action field (fallback to wait)
|
||||
* - etc.
|
||||
*/
|
||||
export function normalizeResponse(response: any): any {
|
||||
let resolvedArguments = null as any
|
||||
|
||||
const choice = (response as { choices?: Choice[] }).choices?.[0]
|
||||
if (!choice) throw new Error('No choices in response')
|
||||
|
||||
const message = choice.message
|
||||
if (!message) throw new Error('No message in choice')
|
||||
|
||||
const toolCall = message.tool_calls?.[0]
|
||||
|
||||
// fix level and location of arguments
|
||||
|
||||
if (toolCall?.function?.arguments) {
|
||||
resolvedArguments = safeJsonParse(toolCall.function.arguments)
|
||||
|
||||
// case: sometimes the model only returns the action level
|
||||
if (toolCall.function.name && toolCall.function.name !== 'AgentOutput') {
|
||||
console.log(chalk.yellow(`[normalizeResponse] #1: fixing tool_call`))
|
||||
resolvedArguments = { action: safeJsonParse(resolvedArguments) }
|
||||
}
|
||||
} else {
|
||||
// case: sometimes the model returns json in content instead of tool_calls
|
||||
if (message.content) {
|
||||
const content = message.content.trim()
|
||||
const jsonInContent = retrieveJsonFromString(content)
|
||||
if (jsonInContent) {
|
||||
resolvedArguments = safeJsonParse(jsonInContent)
|
||||
|
||||
// case: sometimes the content json includes upper level wrapper
|
||||
if (resolvedArguments?.name === 'AgentOutput') {
|
||||
console.log(chalk.yellow(`[normalizeResponse] #2: fixing tool_call`))
|
||||
resolvedArguments = safeJsonParse(resolvedArguments.arguments)
|
||||
}
|
||||
|
||||
// case: sometimes even 2-levels of wrapping
|
||||
if (resolvedArguments?.type === 'function') {
|
||||
console.log(chalk.yellow(`[normalizeResponse] #3: fixing tool_call`))
|
||||
resolvedArguments = safeJsonParse(resolvedArguments.function.arguments)
|
||||
}
|
||||
|
||||
// case: and sometimes action level only
|
||||
// todo: needs better detection logic
|
||||
if (
|
||||
!resolvedArguments?.action &&
|
||||
!resolvedArguments?.evaluation_previous_goal &&
|
||||
!resolvedArguments?.memory &&
|
||||
!resolvedArguments?.next_goal &&
|
||||
!resolvedArguments?.thinking
|
||||
) {
|
||||
console.log(chalk.yellow(`[normalizeResponse] #4: fixing tool_call`))
|
||||
resolvedArguments = { action: safeJsonParse(resolvedArguments) }
|
||||
}
|
||||
} else {
|
||||
throw new Error('No tool_call and the message content does not contain valid JSON')
|
||||
}
|
||||
} else {
|
||||
throw new Error('No tool_call nor message content is present')
|
||||
}
|
||||
}
|
||||
|
||||
// fix double stringified arguments
|
||||
resolvedArguments = safeJsonParse(resolvedArguments)
|
||||
if (resolvedArguments.action) {
|
||||
resolvedArguments.action = safeJsonParse(resolvedArguments.action)
|
||||
}
|
||||
|
||||
// fix incomplete formats
|
||||
if (!resolvedArguments.action) {
|
||||
console.log(chalk.yellow(`[normalizeResponse] #5: fixing tool_call`))
|
||||
resolvedArguments.action = { name: 'wait', input: { seconds: 1 } }
|
||||
}
|
||||
|
||||
// pack back to standard format
|
||||
return {
|
||||
...response,
|
||||
choices: [
|
||||
{
|
||||
...choice,
|
||||
message: {
|
||||
...message,
|
||||
tool_calls: [
|
||||
{
|
||||
...(toolCall || {}),
|
||||
function: {
|
||||
...(toolCall?.function || {}),
|
||||
name: 'AgentOutput',
|
||||
arguments: JSON.stringify(resolvedArguments),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse JSON, return original input if not json.
|
||||
*/
|
||||
function safeJsonParse(input: any): any {
|
||||
if (typeof input === 'string') {
|
||||
try {
|
||||
return JSON.parse(input.trim())
|
||||
} catch {
|
||||
return input
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and parse JSON from a string.
|
||||
* - Treat content between the first `{` and the last `}` as JSON.
|
||||
* - Try to parse that content as JSON and return the parsed value (object/array/primitive) if successful, otherwise return null.
|
||||
*/
|
||||
function retrieveJsonFromString(str: string): any {
|
||||
try {
|
||||
const json = /({[\s\S]*})/.exec(str) ?? []
|
||||
if (json.length === 0) {
|
||||
return null
|
||||
}
|
||||
return JSON.parse(json[0]!)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface Choice {
|
||||
message?: {
|
||||
role?: 'assistant'
|
||||
content?: string
|
||||
tool_calls?: {
|
||||
id?: string
|
||||
type?: 'function'
|
||||
function?: {
|
||||
name?: string
|
||||
arguments?: string
|
||||
}
|
||||
}[]
|
||||
}
|
||||
index?: 0
|
||||
finish_reason?: 'tool_calls'
|
||||
}
|
||||
@@ -1,30 +1,9 @@
|
||||
/**
|
||||
* Type-safe event bus for decoupling PageAgent and Panel
|
||||
*/
|
||||
import type { Step } from '@/ui/UIState'
|
||||
|
||||
/**
|
||||
* Event mapping definitions
|
||||
* @note Event bus callbacks must be repeatable without errors
|
||||
*/
|
||||
export interface PageAgentEventMap {
|
||||
// Panel control events
|
||||
// call panel.show()
|
||||
'panel:show': { params: undefined }
|
||||
// call panel.hide()
|
||||
'panel:hide': { params: undefined }
|
||||
// call panel.reset()
|
||||
'panel:reset': { params: undefined }
|
||||
// call panel.update()
|
||||
'panel:update': { params: Omit<Step, 'id' | 'stepNumber' | 'timestamp'> }
|
||||
// call panel.expand()
|
||||
'panel:expand': { params: undefined }
|
||||
// call panel.collapse()
|
||||
'panel:collapse': { params: undefined }
|
||||
|
||||
// PageAgent status events
|
||||
// 'agent:beforeUpdate': { params: undefined }
|
||||
// 'agent:afterUpdate': { params: undefined }
|
||||
// 'agent:execute': { params: { task: string } }
|
||||
// 'agent:done': { params: { text: string; success: boolean } }
|
||||
// 'agent:paused': { params: undefined }
|
||||
@@ -33,8 +12,7 @@ export interface PageAgentEventMap {
|
||||
// 'agent:error': { params: { error: string | Error } }
|
||||
|
||||
// Task status change events
|
||||
// 'task:start': { params: { task: string } }
|
||||
// 'task:step': { params: Omit<AgentStep, 'id' | 'stepNumber' | 'timestamp'> }
|
||||
'task:start': { params: { task: string } }
|
||||
// 'task:complete': { params: { text: string; success: boolean } }
|
||||
// 'task:error': { params: { error: string | Error } }
|
||||
|
||||
@@ -69,10 +47,7 @@ class EventBus extends EventTarget {
|
||||
/**
|
||||
* Listen to built-in events
|
||||
*/
|
||||
on<T extends keyof PageAgentEventMap>(
|
||||
event: T,
|
||||
handler: EventHandler<T & keyof PageAgentEventMap>
|
||||
): void {
|
||||
on<T extends keyof PageAgentEventMap>(event: T, handler: EventHandler<T>): void {
|
||||
const wrappedHandler = (e: Event) => {
|
||||
const customEvent = e as CustomEvent
|
||||
const params = customEvent.detail?.[0]
|
||||
@@ -84,10 +59,7 @@ class EventBus extends EventTarget {
|
||||
/**
|
||||
* Listen to built-in events (one-time)
|
||||
*/
|
||||
once<T extends keyof PageAgentEventMap>(
|
||||
event: T,
|
||||
handler: EventHandler<T & keyof PageAgentEventMap>
|
||||
): void {
|
||||
once<T extends keyof PageAgentEventMap>(event: T, handler: EventHandler<T>): void {
|
||||
const wrappedHandler = (e: Event) => {
|
||||
const customEvent = e as CustomEvent
|
||||
const params = customEvent.detail?.[0]
|
||||
@@ -1,3 +1,5 @@
|
||||
export { normalizeResponse } from './autoFixer'
|
||||
|
||||
/**
|
||||
* Wait until condition becomes true
|
||||
* @returns Returns when condition becomes true, throws otherwise
|
||||
@@ -20,6 +22,10 @@ export async function waitUntil(check: () => boolean, timeout = 60 * 60_1000): P
|
||||
})
|
||||
}
|
||||
|
||||
export async function waitFor(seconds: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
export function truncate(text: string, maxLength: number): string {
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// @workaround DTS bug
|
||||
// dts do not work with monorepo path mapping
|
||||
// disable path mapping for it
|
||||
"paths": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||
"noEmit": false,
|
||||
"allowImportingTsExtensions": false,
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist",
|
||||
"paths": {
|
||||
//
|
||||
"@page-agent/llms": ["../llms/src/index.ts"],
|
||||
"@page-agent/page-controller": ["../page-controller/src/PageController.ts"],
|
||||
"@page-agent/ui": ["../ui/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"],
|
||||
"references": [
|
||||
//
|
||||
{ "path": "../llms" },
|
||||
{ "path": "../page-controller" },
|
||||
{ "path": "../ui" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// @ts-check
|
||||
import { dirname, resolve } from 'path'
|
||||
import dts from 'unplugin-dts/vite'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig } from 'vite'
|
||||
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// ES Module for NPM Package
|
||||
export default defineConfig({
|
||||
clearScreen: false,
|
||||
plugins: [
|
||||
dts({ tsconfigPath: './tsconfig.dts.json', bundleTypes: true }),
|
||||
cssInjectedByJsPlugin({ relativeCSSInjection: true }),
|
||||
],
|
||||
publicDir: false,
|
||||
esbuild: {
|
||||
keepNames: true,
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/PageAgent.ts'),
|
||||
name: 'PageAgent',
|
||||
fileName: 'page-agent',
|
||||
formats: ['es'],
|
||||
},
|
||||
outDir: resolve(__dirname, 'dist', 'esm'),
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'chalk',
|
||||
'zod',
|
||||
// all the internal packages
|
||||
/^@page-agent\//,
|
||||
],
|
||||
},
|
||||
minify: false,
|
||||
sourcemap: true,
|
||||
cssCodeSplit: true,
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
// @ts-check
|
||||
import { dirname, resolve } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig } from 'vite'
|
||||
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// UMD Bundle for CDN
|
||||
// - alias all local packages so that they can be build in
|
||||
// - no external
|
||||
// - no d.ts. dts does not work with monorepo aliasing
|
||||
export default defineConfig({
|
||||
plugins: [cssInjectedByJsPlugin({ relativeCSSInjection: true })],
|
||||
publicDir: false,
|
||||
esbuild: {
|
||||
keepNames: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@page-agent/page-controller': resolve(__dirname, '../page-controller/src/PageController.ts'),
|
||||
'@page-agent/llms': resolve(__dirname, '../llms/src/index.ts'),
|
||||
'@page-agent/ui': resolve(__dirname, '../ui/src/index.ts'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/umd.ts'),
|
||||
name: 'PageAgent',
|
||||
fileName: 'page-agent',
|
||||
formats: ['umd'],
|
||||
},
|
||||
outDir: resolve(__dirname, 'dist', 'umd'),
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// force use .js as extension
|
||||
entryFileNames: 'page-agent.js',
|
||||
},
|
||||
},
|
||||
cssCodeSplit: true,
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@page-agent/page-controller",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-controller.js",
|
||||
"module": "./dist/lib/page-controller.js",
|
||||
"types": "./dist/lib/PageController.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/lib/PageController.d.ts",
|
||||
"import": "./dist/lib/page-controller.js",
|
||||
"default": "./dist/lib/page-controller.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"description": "Page controller for page-agent - DOM operations and element interactions",
|
||||
"keywords": [
|
||||
"page-agent",
|
||||
"dom",
|
||||
"browser-automation",
|
||||
"web-automation"
|
||||
],
|
||||
"author": "Simon<gaomeng1900>",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/alibaba/page-agent.git",
|
||||
"directory": "packages/page-controller"
|
||||
},
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
||||
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||
},
|
||||
"dependencies": {
|
||||
"ai-motion": "^0.4.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* Copyright (C) 2025 Alibaba Group Holding Limited
|
||||
* All rights reserved.
|
||||
*
|
||||
* PageController - Manages DOM operations and element interactions.
|
||||
* Designed to be independent of LLM and can be tested in unit tests.
|
||||
* All public methods are async for potential remote calling support.
|
||||
*/
|
||||
import {
|
||||
clickElement,
|
||||
getElementByIndex,
|
||||
inputTextElement,
|
||||
scrollHorizontally,
|
||||
scrollVertically,
|
||||
selectOptionElement,
|
||||
} from './actions'
|
||||
import { VIEWPORT_EXPANSION } from './constants'
|
||||
import * as dom from './dom'
|
||||
import type { FlatDomTree, InteractiveElementDomNode } from './dom/dom_tree/type'
|
||||
import { getPageInfo } from './dom/getPageInfo'
|
||||
import { SimulatorMask } from './mask/SimulatorMask'
|
||||
import { patchReact } from './patches/react'
|
||||
|
||||
export { SimulatorMask }
|
||||
|
||||
/**
|
||||
* Configuration for PageController
|
||||
*/
|
||||
export interface PageControllerConfig extends dom.DomConfig {
|
||||
viewportExpansion?: number
|
||||
/** Enable visual mask overlay during operations (default: false) */
|
||||
enableMask?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured browser state for LLM consumption
|
||||
*/
|
||||
export interface BrowserState {
|
||||
url: string
|
||||
title: string
|
||||
/** Page info + scroll position hint (e.g. "Page info: 1920x1080px...\n[Start of page]") */
|
||||
header: string
|
||||
/** Simplified HTML of interactive elements */
|
||||
content: string
|
||||
/** Page footer hint (e.g. "... 300 pixels below ..." or "[End of page]") */
|
||||
footer: string
|
||||
}
|
||||
|
||||
interface ActionResult {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* PageController manages DOM state and element interactions.
|
||||
* It provides async methods for all DOM operations, keeping state isolated.
|
||||
*
|
||||
* @lifecycle
|
||||
* - beforeUpdate: Emitted before the DOM tree is updated.
|
||||
* - afterUpdate: Emitted after the DOM tree is updated.
|
||||
*/
|
||||
export class PageController extends EventTarget {
|
||||
private config: PageControllerConfig
|
||||
|
||||
/** Corresponds to eval_page in browser-use */
|
||||
private flatTree: FlatDomTree | null = null
|
||||
|
||||
/**
|
||||
* All highlighted index-mapped interactive elements
|
||||
* Corresponds to DOMState.selector_map in browser-use
|
||||
*/
|
||||
private selectorMap = new Map<number, InteractiveElementDomNode>()
|
||||
|
||||
/** Index -> element text description mapping */
|
||||
private elementTextMap = new Map<number, string>()
|
||||
|
||||
/**
|
||||
* Simplified HTML for LLM consumption.
|
||||
* Corresponds to clickable_elements_to_string in browser-use
|
||||
*/
|
||||
private simplifiedHTML = '<EMPTY>'
|
||||
|
||||
/** last time the tree was updated */
|
||||
private lastTimeUpdate = 0
|
||||
|
||||
/** Visual mask overlay for blocking user interaction during automation */
|
||||
private mask: SimulatorMask | null = null
|
||||
|
||||
constructor(config: PageControllerConfig = {}) {
|
||||
super()
|
||||
|
||||
this.config = config
|
||||
|
||||
patchReact(this)
|
||||
|
||||
if (config.enableMask) {
|
||||
this.mask = new SimulatorMask()
|
||||
}
|
||||
}
|
||||
|
||||
// ======= State Queries =======
|
||||
|
||||
/**
|
||||
* Get current page URL
|
||||
*/
|
||||
async getCurrentUrl(): Promise<string> {
|
||||
return window.location.href
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last tree update timestamp
|
||||
*/
|
||||
async getLastUpdateTime(): Promise<number> {
|
||||
return this.lastTimeUpdate
|
||||
}
|
||||
|
||||
/**
|
||||
* Get structured browser state for LLM consumption.
|
||||
* Automatically calls updateTree() to refresh the DOM state.
|
||||
*/
|
||||
async getBrowserState(): Promise<BrowserState> {
|
||||
const url = window.location.href
|
||||
const title = document.title
|
||||
const pi = getPageInfo()
|
||||
const viewportExpansion = this.config.viewportExpansion ?? VIEWPORT_EXPANSION
|
||||
|
||||
await this.updateTree()
|
||||
|
||||
const content = this.simplifiedHTML
|
||||
|
||||
// Build header: page info + scroll position hint
|
||||
const pageInfoLine = `Page info: ${pi.viewport_width}x${pi.viewport_height}px viewport, ${pi.page_width}x${pi.page_height}px total page size, ${pi.pages_above.toFixed(1)} pages above, ${pi.pages_below.toFixed(1)} pages below, ${pi.total_pages.toFixed(1)} total pages, at ${(pi.current_page_position * 100).toFixed(0)}% of page`
|
||||
|
||||
const elementsLabel =
|
||||
viewportExpansion === -1
|
||||
? 'Interactive elements from top layer of the current page (full page):'
|
||||
: 'Interactive elements from top layer of the current page inside the viewport:'
|
||||
|
||||
const hasContentAbove = pi.pixels_above > 4
|
||||
const scrollHintAbove =
|
||||
hasContentAbove && viewportExpansion !== -1
|
||||
? `... ${pi.pixels_above} pixels above (${pi.pages_above.toFixed(1)} pages) - scroll to see more ...`
|
||||
: '[Start of page]'
|
||||
|
||||
const header = `${pageInfoLine}\n\n${elementsLabel}\n\n${scrollHintAbove}`
|
||||
|
||||
// Build footer: scroll position hint
|
||||
const hasContentBelow = pi.pixels_below > 4
|
||||
const footer =
|
||||
hasContentBelow && viewportExpansion !== -1
|
||||
? `... ${pi.pixels_below} pixels below (${pi.pages_below.toFixed(1)} pages) - scroll to see more ...`
|
||||
: '[End of page]'
|
||||
|
||||
return { url, title, header, content, footer }
|
||||
}
|
||||
|
||||
// ======= DOM Tree Operations =======
|
||||
|
||||
/**
|
||||
* Update DOM tree, returns simplified HTML for LLM.
|
||||
* This is the main method to refresh the page state.
|
||||
* Automatically bypasses mask during DOM extraction if enabled.
|
||||
*/
|
||||
async updateTree(): Promise<string> {
|
||||
this.dispatchEvent(new Event('beforeUpdate'))
|
||||
|
||||
this.lastTimeUpdate = Date.now()
|
||||
|
||||
// Temporarily bypass mask to allow DOM extraction
|
||||
if (this.mask) {
|
||||
this.mask.wrapper.style.pointerEvents = 'none'
|
||||
}
|
||||
|
||||
dom.cleanUpHighlights()
|
||||
|
||||
const blacklist = [
|
||||
...(this.config.interactiveBlacklist || []),
|
||||
...document.querySelectorAll('[data-page-agent-not-interactive]').values(),
|
||||
]
|
||||
|
||||
this.flatTree = dom.getFlatTree({
|
||||
...this.config,
|
||||
interactiveBlacklist: blacklist,
|
||||
})
|
||||
|
||||
this.simplifiedHTML = dom.flatTreeToString(this.flatTree, this.config.include_attributes)
|
||||
|
||||
this.selectorMap.clear()
|
||||
this.selectorMap = dom.getSelectorMap(this.flatTree)
|
||||
|
||||
this.elementTextMap.clear()
|
||||
this.elementTextMap = dom.getElementTextMap(this.simplifiedHTML)
|
||||
|
||||
// Restore mask blocking
|
||||
if (this.mask) {
|
||||
this.mask.wrapper.style.pointerEvents = 'auto'
|
||||
}
|
||||
|
||||
this.dispatchEvent(new Event('afterUpdate'))
|
||||
|
||||
return this.simplifiedHTML
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all element highlights
|
||||
*/
|
||||
async cleanUpHighlights(): Promise<void> {
|
||||
dom.cleanUpHighlights()
|
||||
}
|
||||
|
||||
// ======= Element Actions =======
|
||||
|
||||
/**
|
||||
* Click element by index
|
||||
*/
|
||||
async clickElement(index: number): Promise<ActionResult> {
|
||||
try {
|
||||
const element = getElementByIndex(this.selectorMap, index)
|
||||
const elemText = this.elementTextMap.get(index)
|
||||
await clickElement(element)
|
||||
|
||||
// Handle links that open in new tabs
|
||||
if (element instanceof HTMLAnchorElement && element.target === '_blank') {
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Clicked element (${elemText ?? index}). ⚠️ Link opens in a new tab. You are not capable of reading new tabs.`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Clicked element (${elemText ?? index}).`,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Failed to click element: ${error}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Input text into element by index
|
||||
*/
|
||||
async inputText(index: number, text: string): Promise<ActionResult> {
|
||||
try {
|
||||
const element = getElementByIndex(this.selectorMap, index)
|
||||
const elemText = this.elementTextMap.get(index)
|
||||
await inputTextElement(element, text)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Input text (${text}) into element (${elemText ?? index}).`,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Failed to input text: ${error}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select dropdown option by index and option text
|
||||
*/
|
||||
async selectOption(index: number, optionText: string): Promise<ActionResult> {
|
||||
try {
|
||||
const element = getElementByIndex(this.selectorMap, index)
|
||||
const elemText = this.elementTextMap.get(index)
|
||||
await selectOptionElement(element as HTMLSelectElement, optionText)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Selected option (${optionText}) in element (${elemText ?? index}).`,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Failed to select option: ${error}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll vertically
|
||||
*/
|
||||
async scroll(options: {
|
||||
down: boolean
|
||||
numPages: number
|
||||
pixels?: number
|
||||
index?: number
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
const { down, numPages, pixels, index } = options
|
||||
|
||||
const scrollAmount = pixels ?? numPages * (down ? 1 : -1) * window.innerHeight
|
||||
|
||||
const element = index !== undefined ? getElementByIndex(this.selectorMap, index) : null
|
||||
|
||||
const message = await scrollVertically(down, scrollAmount, element)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Failed to scroll: ${error}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll horizontally
|
||||
*/
|
||||
async scrollHorizontally(options: {
|
||||
right: boolean
|
||||
pixels: number
|
||||
index?: number
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
const { right, pixels, index } = options
|
||||
|
||||
const scrollAmount = pixels * (right ? 1 : -1)
|
||||
|
||||
const element = index !== undefined ? getElementByIndex(this.selectorMap, index) : null
|
||||
|
||||
const message = await scrollHorizontally(right, scrollAmount, element)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Failed to scroll horizontally: ${error}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute arbitrary JavaScript on the page
|
||||
*/
|
||||
async executeJavascript(script: string): Promise<ActionResult> {
|
||||
try {
|
||||
// Wrap script in async function to support await
|
||||
const asyncFunction = eval(`(async () => { ${script} })`)
|
||||
const result = await asyncFunction()
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Executed JavaScript. Result: ${result}`,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Error executing JavaScript: ${error}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ======= Mask Operations =======
|
||||
|
||||
/**
|
||||
* Show the visual mask overlay.
|
||||
* Only works if enableMask was set to true in config.
|
||||
*/
|
||||
showMask(): void {
|
||||
this.mask?.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the visual mask overlay.
|
||||
* Only works if enableMask was set to true in config.
|
||||
*/
|
||||
hideMask(): void {
|
||||
this.mask?.hide()
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose and clean up resources
|
||||
*/
|
||||
dispose(): void {
|
||||
dom.cleanUpHighlights()
|
||||
this.flatTree = null
|
||||
this.selectorMap.clear()
|
||||
this.elementTextMap.clear()
|
||||
this.simplifiedHTML = '<EMPTY>'
|
||||
this.mask?.dispose()
|
||||
this.mask = null
|
||||
}
|
||||
}
|
||||
@@ -2,26 +2,14 @@
|
||||
* Copyright (C) 2025 Alibaba Group Holding Limited
|
||||
* All rights reserved.
|
||||
*/
|
||||
import type { PageAgent } from '../PageAgent'
|
||||
import type { InteractiveElementDomNode } from './dom/dom_tree/type'
|
||||
|
||||
// ======= general utils =======
|
||||
|
||||
export async function waitFor(seconds: number): Promise<void> {
|
||||
async function waitFor(seconds: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
|
||||
}
|
||||
|
||||
let currentUrl = window.location.href
|
||||
export async function getSystemInfo() {
|
||||
// If current URL is already up to date, no need to add message
|
||||
if (currentUrl === window.location.href) return ''
|
||||
|
||||
await waitFor(0.3) // Wait a bit longer for page to load
|
||||
|
||||
currentUrl = window.location.href
|
||||
|
||||
return `\n<sys> Current URL changed to: ${currentUrl} </sys>`
|
||||
}
|
||||
|
||||
// ======= dom utils =======
|
||||
|
||||
export async function movePointerToElement(element: HTMLElement) {
|
||||
@@ -35,10 +23,13 @@ export async function movePointerToElement(element: HTMLElement) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the HTMLElement by index from the selectorMap in PageAgent.
|
||||
* Get the HTMLElement by index from a selectorMap.
|
||||
*/
|
||||
export function getElementByIndex(pageAgent: PageAgent, index: number): HTMLElement {
|
||||
const interactiveNode = pageAgent.selectorMap.get(index)
|
||||
export function getElementByIndex(
|
||||
selectorMap: Map<number, InteractiveElementDomNode>,
|
||||
index: number
|
||||
): HTMLElement {
|
||||
const interactiveNode = selectorMap.get(index)
|
||||
if (!interactiveNode) {
|
||||
throw new Error(`No interactive element found at index ${index}`)
|
||||
}
|
||||
@@ -170,7 +161,6 @@ export async function selectOptionElement(selectElement: HTMLSelectElement, opti
|
||||
await waitFor(0.1) // Wait to ensure change event processing completes
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
export async function scrollIntoViewIfNeeded(element: HTMLElement) {
|
||||
const el = element as any
|
||||
if (el.scrollIntoViewIfNeeded) {
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright (C) 2025 Alibaba Group Holding Limited
|
||||
* All rights reserved.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Viewport expansion for DOM tree extraction.
|
||||
* -1 means full page (no viewport restriction)
|
||||
* 0 means viewport only
|
||||
* positive values expand the viewport by that many pixels
|
||||
*
|
||||
* @note Since isTopElement depends on elementFromPoint,
|
||||
* it returns null when out of viewport, this feature has no practical use, only differ between -1 and 0
|
||||
*/
|
||||
// export const VIEWPORT_EXPANSION = 100
|
||||
export const VIEWPORT_EXPANSION = -1
|
||||
@@ -16,6 +16,7 @@
|
||||
* @edit scrollable element detection
|
||||
* @edit add `data-browser-use-ignore` attribute
|
||||
* @edit improve `sampleRect`, filter out rects with 0 area
|
||||
* @edit exclude aria-hidden elements
|
||||
*/
|
||||
|
||||
export default (
|
||||
@@ -1446,7 +1447,14 @@ export default (
|
||||
* @edit add `data-browser-use-ignore` attribute
|
||||
*/
|
||||
if (node.dataset?.browserUseIgnore === 'true') {
|
||||
return true // Skip this node and its children
|
||||
return null // Skip this node and its children
|
||||
}
|
||||
|
||||
/**
|
||||
* @edit exclude aria-hidden elements
|
||||
*/
|
||||
if (node.getAttribute && node.getAttribute('aria-hidden') === 'true') {
|
||||
return null // Skip this node and its children
|
||||
}
|
||||
|
||||
// Special handling for root node (body)
|
||||
@@ -1,11 +1,11 @@
|
||||
import { VIEWPORT_EXPANSION } from '@/config/constants'
|
||||
import domTree from '@/dom/dom_tree/index'
|
||||
import { VIEWPORT_EXPANSION } from '../constants'
|
||||
import domTree from './dom_tree/index.js'
|
||||
import {
|
||||
ElementDomNode,
|
||||
FlatDomTree,
|
||||
InteractiveElementDomNode,
|
||||
TextDomNode,
|
||||
} from '@/dom/dom_tree/type'
|
||||
} from './dom_tree/type'
|
||||
|
||||
export interface DomConfig {
|
||||
interactiveBlacklist?: (Element | (() => Element))[]
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Motion } from 'ai-motion'
|
||||
|
||||
import { isPageDark } from '@/utils/checkDarkMode'
|
||||
import { isPageDark } from './checkDarkMode'
|
||||
|
||||
import styles from './SimulatorMask.module.css'
|
||||
import cursorStyles from './cursor.module.css'
|
||||
@@ -3,14 +3,14 @@
|
||||
* @returns {boolean} - True if a common dark mode class is found.
|
||||
*/
|
||||
function hasDarkModeClass() {
|
||||
const DFEAULT_DARK_MODE_CLASSES = ['dark', 'dark-mode', 'theme-dark', 'night', 'night-mode']
|
||||
const DEFAULT_DARK_MODE_CLASSES = ['dark', 'dark-mode', 'theme-dark', 'night', 'night-mode']
|
||||
|
||||
const htmlElement = document.documentElement
|
||||
const bodyElement = document.body
|
||||
const bodyElement = document.body || document.documentElement // can be null in some cases
|
||||
|
||||
// Check class names on <html> and <body>
|
||||
for (const className of DFEAULT_DARK_MODE_CLASSES) {
|
||||
if (htmlElement.classList.contains(className) || bodyElement.classList.contains(className)) {
|
||||
for (const className of DEFAULT_DARK_MODE_CLASSES) {
|
||||
if (htmlElement.classList.contains(className) || bodyElement?.classList.contains(className)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ function isColorDark(colorString: string, threshold = 128) {
|
||||
function isBackgroundDark() {
|
||||
// We check both <html> and <body> because some pages set the color on <html>
|
||||
const htmlStyle = window.getComputedStyle(document.documentElement)
|
||||
const bodyStyle = window.getComputedStyle(document.body)
|
||||
const bodyStyle = window.getComputedStyle(document.body || document.documentElement)
|
||||
|
||||
// Get background colors
|
||||
const htmlBgColor = htmlStyle.backgroundColor
|
||||
@@ -93,18 +93,23 @@ function isBackgroundDark() {
|
||||
* @returns {boolean} - True if the page is likely dark.
|
||||
*/
|
||||
export function isPageDark() {
|
||||
// Strategy 1: Check for common dark mode classes
|
||||
if (hasDarkModeClass()) {
|
||||
return true
|
||||
try {
|
||||
// Strategy 1: Check for common dark mode classes
|
||||
if (hasDarkModeClass()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Strategy 2: Analyze the computed background color
|
||||
if (isBackgroundDark()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// @TODO add more checks here, e.g., analyzing text color,
|
||||
// or checking the background of major layout elements like <main> or #app.
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.warn('Error determining if page is dark:', error)
|
||||
return false
|
||||
}
|
||||
|
||||
// Strategy 2: Analyze the computed background color
|
||||
if (isBackgroundDark()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// @TODO add more checks here, e.g., analyzing text color,
|
||||
// or checking the background of major layout elements like <main> or #app.
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PageAgent } from '@/PageAgent'
|
||||
import type { PageController } from '../PageController'
|
||||
|
||||
const clearFunctions = [] as (() => void)[]
|
||||
|
||||
@@ -11,9 +11,9 @@ function fixAntdSelect() {
|
||||
// for (const select of selects) {}
|
||||
}
|
||||
|
||||
export function patchAntd(pageAgent: PageAgent) {
|
||||
pageAgent.addEventListener('beforeUpdate', fixAntdSelect)
|
||||
pageAgent.addEventListener('afterUpdate', () => {
|
||||
export function patchAntd(pageController: PageController) {
|
||||
pageController.addEventListener('beforeUpdate', fixAntdSelect)
|
||||
pageController.addEventListener('afterUpdate', () => {
|
||||
for (const fn of clearFunctions) fn()
|
||||
clearFunctions.length = 0
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PageAgent } from '@/PageAgent'
|
||||
import type { PageController } from '../PageController'
|
||||
|
||||
// Find common React root elements and add data-page-agent-not-interactive attribute
|
||||
export function patchReact(pageAgent: PageAgent) {
|
||||
export function patchReact(pageController: PageController) {
|
||||
const reactRootElements = document.querySelectorAll(
|
||||
'[data-reactroot], [data-reactid], [data-react-checksum], #root, #app, [id^="root-"], [id^="app-"], #adex-wrapper, #adex-root'
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// @workaround DTS bug
|
||||
// dts do not work with monorepo path mapping
|
||||
// disable path mapping for it
|
||||
"paths": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||
"noEmit": false,
|
||||
"allowImportingTsExtensions": false,
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.js"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// @ts-check
|
||||
import chalk from 'chalk'
|
||||
import { dirname, resolve } from 'path'
|
||||
import dts from 'unplugin-dts/vite'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig } from 'vite'
|
||||
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
console.log(chalk.cyan(`📦 Building @page-agent/page-controller`))
|
||||
|
||||
export default defineConfig({
|
||||
clearScreen: false,
|
||||
plugins: [
|
||||
dts({ tsconfigPath: './tsconfig.dts.json', bundleTypes: true }),
|
||||
cssInjectedByJsPlugin({ relativeCSSInjection: true }),
|
||||
],
|
||||
publicDir: false,
|
||||
esbuild: {
|
||||
keepNames: true,
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/PageController.ts'),
|
||||
name: 'PageController',
|
||||
fileName: 'page-controller',
|
||||
formats: ['es'],
|
||||
},
|
||||
outDir: resolve(__dirname, 'dist', 'lib'),
|
||||
rollupOptions: {
|
||||
external: ['@page-agent/*', 'ai-motion'],
|
||||
},
|
||||
minify: false,
|
||||
sourcemap: true,
|
||||
cssCodeSplit: true,
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@page-agent/ui",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-agent-ui.js",
|
||||
"module": "./dist/lib/page-agent-ui.js",
|
||||
"types": "./dist/lib/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/lib/index.d.ts",
|
||||
"import": "./dist/lib/page-agent-ui.js",
|
||||
"default": "./dist/lib/page-agent-ui.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"description": "UI components for page-agent - Panel and i18n",
|
||||
"keywords": [
|
||||
"page-agent",
|
||||
"ui",
|
||||
"panel",
|
||||
"i18n"
|
||||
],
|
||||
"author": "Simon<gaomeng1900>",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/alibaba/page-agent.git",
|
||||
"directory": "packages/ui"
|
||||
},
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
||||
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,35 @@
|
||||
import type { PageAgent } from '@/PageAgent'
|
||||
import type { I18n } from '@/i18n'
|
||||
import { truncate } from '@/utils'
|
||||
import type { EventBus } from '@/utils/bus'
|
||||
|
||||
import { type Step, UIState } from './UIState'
|
||||
import { I18n, type SupportedLanguage } from './i18n'
|
||||
import { escapeHtml, truncate } from './utils'
|
||||
|
||||
import styles from './Panel.module.css'
|
||||
|
||||
/**
|
||||
* Panel configuration
|
||||
*/
|
||||
export interface PanelConfig {
|
||||
language?: SupportedLanguage
|
||||
onExecuteTask: (task: string) => void
|
||||
onStop: () => void
|
||||
onPauseToggle: () => boolean // returns new paused state
|
||||
getPaused: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic update types - Panel handles i18n internally
|
||||
*/
|
||||
export type PanelUpdate =
|
||||
| { type: 'thinking'; text?: string } // text is optional, defaults to i18n thinking text
|
||||
| { type: 'input'; task: string }
|
||||
| { type: 'question'; question: string }
|
||||
| { type: 'userAnswer'; input: string }
|
||||
| { type: 'retry'; current: number; max: number }
|
||||
| { type: 'error'; message: string }
|
||||
| { type: 'output'; text: string }
|
||||
| { type: 'completed' }
|
||||
| { type: 'toolExecuting'; toolName: string; args: any }
|
||||
| { type: 'toolCompleted'; toolName: string; args: any; result?: string; duration?: number }
|
||||
|
||||
/**
|
||||
* Agent control panel
|
||||
*/
|
||||
@@ -20,11 +43,11 @@ export class Panel {
|
||||
#stopButton: HTMLElement
|
||||
#inputSection: HTMLElement
|
||||
#taskInput: HTMLInputElement
|
||||
#bus: EventBus
|
||||
|
||||
#state = new UIState()
|
||||
#isExpanded = false
|
||||
#pageAgent: PageAgent
|
||||
#config: PanelConfig
|
||||
#i18n: I18n
|
||||
#userAnswerResolver: ((input: string) => void) | null = null
|
||||
#isWaitingForUserAnswer: boolean = false
|
||||
#headerUpdateTimer: ReturnType<typeof setInterval> | null = null
|
||||
@@ -35,9 +58,9 @@ export class Panel {
|
||||
return this.#wrapper
|
||||
}
|
||||
|
||||
constructor(pageAgent: PageAgent) {
|
||||
this.#pageAgent = pageAgent
|
||||
this.#bus = pageAgent.bus
|
||||
constructor(config: PanelConfig) {
|
||||
this.#config = config
|
||||
this.#i18n = new I18n(config.language ?? 'en-US')
|
||||
this.#wrapper = this.#createWrapper()
|
||||
this.#indicator = this.#wrapper.querySelector(`.${styles.indicator}`)!
|
||||
this.#statusText = this.#wrapper.querySelector(`.${styles.statusText}`)!
|
||||
@@ -50,16 +73,8 @@ export class Panel {
|
||||
|
||||
this.#setupEventListeners()
|
||||
this.#startHeaderUpdateLoop()
|
||||
// this.#expand() // debug
|
||||
|
||||
this.#showInputArea()
|
||||
|
||||
this.#bus.on('panel:show', () => this.#show())
|
||||
this.#bus.on('panel:hide', () => this.#hide())
|
||||
this.#bus.on('panel:reset', () => this.#reset())
|
||||
this.#bus.on('panel:update', (stepData) => this.#update(stepData))
|
||||
this.#bus.on('panel:expand', () => this.#expand())
|
||||
this.#bus.on('panel:collapse', () => this.#collapse())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,18 +87,67 @@ export class Panel {
|
||||
this.#userAnswerResolver = resolve
|
||||
|
||||
// Update state to `running`
|
||||
this.#update({
|
||||
this.#updateInternal({
|
||||
type: 'output',
|
||||
displayText: this.#pageAgent.i18n.t('ui.panel.question', { question }),
|
||||
displayText: this.#i18n.t('ui.panel.question', { question }),
|
||||
}) // Expand history panel
|
||||
if (!this.#isExpanded) {
|
||||
this.#expand()
|
||||
}
|
||||
|
||||
this.#showInputArea(this.#pageAgent.i18n.t('ui.panel.userAnswerPrompt'))
|
||||
this.#showInputArea(this.#i18n.t('ui.panel.userAnswerPrompt'))
|
||||
})
|
||||
}
|
||||
|
||||
// ========== Public control methods ==========
|
||||
|
||||
show(): void {
|
||||
this.wrapper.style.display = 'block'
|
||||
void this.wrapper.offsetHeight
|
||||
this.wrapper.style.opacity = '1'
|
||||
this.wrapper.style.transform = 'translateX(-50%) translateY(0)'
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.wrapper.style.opacity = '0'
|
||||
this.wrapper.style.transform = 'translateX(-50%) translateY(20px)'
|
||||
this.wrapper.style.display = 'none'
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.#state.reset()
|
||||
this.#statusText.textContent = this.#i18n.t('ui.panel.ready')
|
||||
this.#updateStatusIndicator('thinking')
|
||||
this.#updateHistory()
|
||||
this.#collapse()
|
||||
// Reset pause state via callback
|
||||
if (this.#config.getPaused()) {
|
||||
this.#config.onPauseToggle()
|
||||
}
|
||||
this.#updatePauseButton()
|
||||
// Reset user input state
|
||||
this.#isWaitingForUserAnswer = false
|
||||
this.#userAnswerResolver = null
|
||||
// Show input area
|
||||
this.#showInputArea()
|
||||
}
|
||||
|
||||
expand(): void {
|
||||
this.#expand()
|
||||
}
|
||||
|
||||
collapse(): void {
|
||||
this.#collapse()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update panel with semantic data - i18n handled internally
|
||||
*/
|
||||
update(data: PanelUpdate): void {
|
||||
const stepData = this.#toStepData(data)
|
||||
this.#updateInternal(stepData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose panel
|
||||
*/
|
||||
@@ -93,10 +157,102 @@ export class Panel {
|
||||
this.wrapper.remove()
|
||||
}
|
||||
|
||||
// ========== Private methods ==========
|
||||
|
||||
/**
|
||||
* Update status
|
||||
* Convert semantic update to step data with i18n
|
||||
*/
|
||||
#update(stepData: Omit<Step, 'id' | 'stepNumber' | 'timestamp'>): void {
|
||||
#toStepData(data: PanelUpdate): Omit<Step, 'id' | 'stepNumber' | 'timestamp'> {
|
||||
switch (data.type) {
|
||||
case 'thinking':
|
||||
return { type: 'thinking', displayText: data.text ?? this.#i18n.t('ui.panel.thinking') }
|
||||
case 'input':
|
||||
return { type: 'input', displayText: data.task }
|
||||
case 'question':
|
||||
return {
|
||||
type: 'output',
|
||||
displayText: this.#i18n.t('ui.panel.question', { question: data.question }),
|
||||
}
|
||||
case 'userAnswer':
|
||||
return {
|
||||
type: 'input',
|
||||
displayText: this.#i18n.t('ui.panel.userAnswer', { input: data.input }),
|
||||
}
|
||||
case 'retry':
|
||||
return { type: 'retry', displayText: `retry-ing (${data.current} / ${data.max})` }
|
||||
case 'error':
|
||||
return { type: 'error', displayText: data.message }
|
||||
case 'output':
|
||||
return { type: 'output', displayText: data.text }
|
||||
case 'completed':
|
||||
return { type: 'completed', displayText: this.#i18n.t('ui.panel.taskCompleted') }
|
||||
case 'toolExecuting':
|
||||
return {
|
||||
type: 'tool_executing',
|
||||
toolName: data.toolName,
|
||||
toolArgs: data.args,
|
||||
displayText: this.#getToolExecutingText(data.toolName, data.args),
|
||||
}
|
||||
case 'toolCompleted': {
|
||||
const displayText = this.#getToolCompletedText(data.toolName, data.args)
|
||||
if (!displayText) return { type: 'tool_executing', displayText: '' } // will be filtered
|
||||
return {
|
||||
type: 'tool_executing',
|
||||
toolName: data.toolName,
|
||||
toolArgs: data.args,
|
||||
toolResult: data.result,
|
||||
displayText,
|
||||
duration: data.duration,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#getToolExecutingText(toolName: string, args: any): string {
|
||||
switch (toolName) {
|
||||
case 'click_element_by_index':
|
||||
return this.#i18n.t('ui.tools.clicking', { index: args.index })
|
||||
case 'input_text':
|
||||
return this.#i18n.t('ui.tools.inputting', { index: args.index })
|
||||
case 'select_dropdown_option':
|
||||
return this.#i18n.t('ui.tools.selecting', { text: args.text })
|
||||
case 'scroll':
|
||||
return this.#i18n.t('ui.tools.scrolling')
|
||||
case 'wait':
|
||||
return this.#i18n.t('ui.tools.waiting', { seconds: args.seconds })
|
||||
case 'done':
|
||||
return this.#i18n.t('ui.tools.done')
|
||||
default:
|
||||
return this.#i18n.t('ui.tools.executing', { toolName })
|
||||
}
|
||||
}
|
||||
|
||||
#getToolCompletedText(toolName: string, args: any): string | null {
|
||||
switch (toolName) {
|
||||
case 'click_element_by_index':
|
||||
return this.#i18n.t('ui.tools.clicked', { index: args.index })
|
||||
case 'input_text':
|
||||
return this.#i18n.t('ui.tools.inputted', { text: args.text })
|
||||
case 'select_dropdown_option':
|
||||
return this.#i18n.t('ui.tools.selected', { text: args.text })
|
||||
case 'scroll':
|
||||
return this.#i18n.t('ui.tools.scrolled')
|
||||
case 'wait':
|
||||
return this.#i18n.t('ui.tools.waited')
|
||||
case 'done':
|
||||
return null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status (internal)
|
||||
*/
|
||||
#updateInternal(stepData: Omit<Step, 'id' | 'stepNumber' | 'timestamp'>): void {
|
||||
// Skip empty displayText (filtered toolCompleted for 'done')
|
||||
if (!stepData.displayText) return
|
||||
|
||||
const step = this.#state.addStep(stepData)
|
||||
|
||||
// Queue header text update (will be processed by periodic check)
|
||||
@@ -121,59 +277,20 @@ export class Panel {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show panel
|
||||
*/
|
||||
#show(): void {
|
||||
this.wrapper.style.display = 'block'
|
||||
// Force reflow to trigger animation
|
||||
void this.wrapper.offsetHeight
|
||||
this.wrapper.style.opacity = '1'
|
||||
this.wrapper.style.transform = 'translateX(-50%) translateY(0)'
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide panel
|
||||
*/
|
||||
#hide(): void {
|
||||
this.wrapper.style.opacity = '0'
|
||||
this.wrapper.style.transform = 'translateX(-50%) translateY(20px)'
|
||||
this.wrapper.style.display = 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state
|
||||
*/
|
||||
#reset(): void {
|
||||
this.#state.reset()
|
||||
this.#statusText.textContent = this.#pageAgent.i18n.t('ui.panel.ready')
|
||||
this.#updateStatusIndicator('thinking')
|
||||
this.#updateHistory()
|
||||
this.#collapse()
|
||||
// Reset pause state
|
||||
this.#pageAgent.paused = false
|
||||
this.#updatePauseButton()
|
||||
// Reset user input state
|
||||
this.#isWaitingForUserAnswer = false
|
||||
this.#userAnswerResolver = null
|
||||
// Show input area
|
||||
this.#showInputArea()
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle pause state
|
||||
*/
|
||||
#togglePause(): void {
|
||||
this.#pageAgent.paused = !this.#pageAgent.paused
|
||||
const paused = this.#config.onPauseToggle()
|
||||
this.#updatePauseButton()
|
||||
|
||||
// Update status display
|
||||
if (this.#pageAgent.paused) {
|
||||
this.#statusText.textContent = this.#pageAgent.i18n.t('ui.panel.paused')
|
||||
this.#updateStatusIndicator('thinking') // Use existing thinking state
|
||||
if (paused) {
|
||||
this.#statusText.textContent = this.#i18n.t('ui.panel.paused')
|
||||
this.#updateStatusIndicator('thinking')
|
||||
} else {
|
||||
this.#statusText.textContent = this.#pageAgent.i18n.t('ui.panel.continueExecution')
|
||||
this.#updateStatusIndicator('tool_executing') // Restore to execution state
|
||||
this.#statusText.textContent = this.#i18n.t('ui.panel.continueExecution')
|
||||
this.#updateStatusIndicator('tool_executing')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,13 +298,14 @@ export class Panel {
|
||||
* Update pause button state
|
||||
*/
|
||||
#updatePauseButton(): void {
|
||||
if (this.#pageAgent.paused) {
|
||||
const paused = this.#config.getPaused()
|
||||
if (paused) {
|
||||
this.#pauseButton.textContent = '▶'
|
||||
this.#pauseButton.title = this.#pageAgent.i18n.t('ui.panel.continue')
|
||||
this.#pauseButton.title = this.#i18n.t('ui.panel.continue')
|
||||
this.#pauseButton.classList.add(styles.paused)
|
||||
} else {
|
||||
this.#pauseButton.textContent = '⏸︎'
|
||||
this.#pauseButton.title = this.#pageAgent.i18n.t('ui.panel.pause')
|
||||
this.#pauseButton.title = this.#i18n.t('ui.panel.pause')
|
||||
this.#pauseButton.classList.remove(styles.paused)
|
||||
}
|
||||
}
|
||||
@@ -197,12 +315,12 @@ export class Panel {
|
||||
*/
|
||||
#stopAgent(): void {
|
||||
// Update status display
|
||||
this.#update({
|
||||
this.#updateInternal({
|
||||
type: 'error',
|
||||
displayText: this.#pageAgent.i18n.t('ui.panel.taskTerminated'),
|
||||
displayText: this.#i18n.t('ui.panel.taskTerminated'),
|
||||
})
|
||||
|
||||
this.#pageAgent.dispose()
|
||||
this.#config.onStop()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,7 +337,7 @@ export class Panel {
|
||||
// Handle user input mode
|
||||
this.#handleUserAnswer(input)
|
||||
} else {
|
||||
this.#pageAgent.execute(input)
|
||||
this.#config.onExecuteTask(input)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,9 +346,9 @@ export class Panel {
|
||||
*/
|
||||
#handleUserAnswer(input: string): void {
|
||||
// Add user input to history
|
||||
this.#update({
|
||||
this.#updateInternal({
|
||||
type: 'input',
|
||||
displayText: this.#pageAgent.i18n.t('ui.panel.userAnswer', { input }),
|
||||
displayText: this.#i18n.t('ui.panel.userAnswer', { input }),
|
||||
})
|
||||
|
||||
// Reset state
|
||||
@@ -249,7 +367,7 @@ export class Panel {
|
||||
#showInputArea(placeholder?: string): void {
|
||||
// Clear input field
|
||||
this.#taskInput.value = ''
|
||||
this.#taskInput.placeholder = placeholder || this.#pageAgent.i18n.t('ui.panel.taskInput')
|
||||
this.#taskInput.placeholder = placeholder || this.#i18n.t('ui.panel.taskInput')
|
||||
this.#inputSection.classList.remove(styles.hidden)
|
||||
// Focus on input field
|
||||
setTimeout(() => {
|
||||
@@ -295,23 +413,23 @@ export class Panel {
|
||||
stepNumber: 0,
|
||||
timestamp: new Date(),
|
||||
type: 'thinking',
|
||||
displayText: this.#pageAgent.i18n.t('ui.panel.waitingPlaceholder'),
|
||||
displayText: this.#i18n.t('ui.panel.waitingPlaceholder'),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div class="${styles.header}">
|
||||
<div class="${styles.statusSection}">
|
||||
<div class="${styles.indicator} ${styles.thinking}"></div>
|
||||
<div class="${styles.statusText}">${this.#pageAgent.i18n.t('ui.panel.ready')}</div>
|
||||
<div class="${styles.statusText}">${this.#i18n.t('ui.panel.ready')}</div>
|
||||
</div>
|
||||
<div class="${styles.controls}">
|
||||
<button class="${styles.controlButton} ${styles.expandButton}" title="${this.#pageAgent.i18n.t('ui.panel.expand')}">
|
||||
<button class="${styles.controlButton} ${styles.expandButton}" title="${this.#i18n.t('ui.panel.expand')}">
|
||||
▼
|
||||
</button>
|
||||
<button class="${styles.controlButton} ${styles.pauseButton}" title="${this.#pageAgent.i18n.t('ui.panel.pause')}">
|
||||
<button class="${styles.controlButton} ${styles.pauseButton}" title="${this.#i18n.t('ui.panel.pause')}">
|
||||
⏸︎
|
||||
</button>
|
||||
<button class="${styles.controlButton} ${styles.stopButton}" title="${this.#pageAgent.i18n.t('ui.panel.stop')}">
|
||||
<button class="${styles.controlButton} ${styles.stopButton}" title="${this.#i18n.t('ui.panel.stop')}">
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
@@ -472,10 +590,7 @@ export class Panel {
|
||||
|
||||
#updateHistory(): void {
|
||||
const steps = this.#state.getAllSteps()
|
||||
|
||||
this.#historySection.innerHTML = steps.map((step) => this.#createHistoryItem(step)).join('')
|
||||
|
||||
// Scroll to bottom to show latest records
|
||||
this.#scrollToBottom()
|
||||
}
|
||||
|
||||
@@ -502,8 +617,8 @@ export class Panel {
|
||||
// Check if this is a result from done tool
|
||||
if (step.toolName === 'done') {
|
||||
// Judge success or failure based on result
|
||||
const failureKeyword = this.#pageAgent.i18n.t('ui.tools.resultFailure')
|
||||
const errorKeyword = this.#pageAgent.i18n.t('ui.tools.resultError')
|
||||
const failureKeyword = this.#i18n.t('ui.tools.resultFailure')
|
||||
const errorKeyword = this.#i18n.t('ui.tools.resultError')
|
||||
const isSuccess =
|
||||
!step.toolResult ||
|
||||
(!step.toolResult.includes(failureKeyword) && !step.toolResult.includes(errorKeyword))
|
||||
@@ -532,7 +647,7 @@ export class Panel {
|
||||
}
|
||||
|
||||
const durationText = step.duration ? ` · ${step.duration}ms` : ''
|
||||
const stepLabel = this.#pageAgent.i18n.t('ui.panel.step', {
|
||||
const stepLabel = this.#i18n.t('ui.panel.step', {
|
||||
number: step.stepNumber.toString(),
|
||||
time,
|
||||
duration: durationText || '', // Explicitly pass empty string to replace template
|
||||
@@ -542,7 +657,7 @@ export class Panel {
|
||||
<div class="${styles.historyItem} ${typeClass}">
|
||||
<div class="${styles.historyContent}">
|
||||
<span class="${styles.statusIcon}">${statusIcon}</span>
|
||||
<span>${step.displayText}</span>
|
||||
<span>${escapeHtml(step.displayText)}</span>
|
||||
</div>
|
||||
<div class="${styles.historyMeta}">
|
||||
${stepLabel}
|
||||
@@ -551,47 +666,3 @@ export class Panel {
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display text for tool execution
|
||||
*/
|
||||
export function getToolExecutingText(toolName: string, args: any, i18n: I18n): string {
|
||||
switch (toolName) {
|
||||
case 'click_element_by_index':
|
||||
return i18n.t('ui.tools.clicking', { index: args.index })
|
||||
case 'input_text':
|
||||
return i18n.t('ui.tools.inputting', { index: args.index })
|
||||
case 'select_dropdown_option':
|
||||
return i18n.t('ui.tools.selecting', { text: args.text })
|
||||
case 'scroll':
|
||||
return i18n.t('ui.tools.scrolling')
|
||||
case 'wait':
|
||||
return i18n.t('ui.tools.waiting', { seconds: args.seconds })
|
||||
case 'done':
|
||||
return i18n.t('ui.tools.done')
|
||||
default:
|
||||
return i18n.t('ui.tools.executing', { toolName })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display text for tool completion
|
||||
*/
|
||||
export function getToolCompletedText(toolName: string, args: any, i18n: I18n): string | null {
|
||||
switch (toolName) {
|
||||
case 'click_element_by_index':
|
||||
return i18n.t('ui.tools.clicked', { index: args.index })
|
||||
case 'input_text':
|
||||
return i18n.t('ui.tools.inputted', { text: args.text })
|
||||
case 'select_dropdown_option':
|
||||
return i18n.t('ui.tools.selected', { text: args.text })
|
||||
case 'scroll':
|
||||
return i18n.t('ui.tools.scrolled')
|
||||
case 'wait':
|
||||
return i18n.t('ui.tools.waited')
|
||||
case 'done':
|
||||
return null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export class I18n {
|
||||
|
||||
constructor(language: SupportedLanguage = 'en-US') {
|
||||
this.language = language in locales ? language : 'en-US'
|
||||
this.translations = locales[language]
|
||||
this.translations = locales[this.language]
|
||||
}
|
||||
|
||||
// 类型安全的翻译方法
|
||||
@@ -0,0 +1,3 @@
|
||||
export { Panel, type PanelConfig, type PanelUpdate } from './Panel'
|
||||
export { UIState, type Step, type AgentStatus } from './UIState'
|
||||
export { I18n, type SupportedLanguage, type TranslationKey } from './i18n'
|
||||
@@ -0,0 +1,18 @@
|
||||
export function truncate(text: string, maxLength: number): string {
|
||||
if (text.length > maxLength) {
|
||||
return text.substring(0, maxLength) + '...'
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters to prevent XSS and rendering issues
|
||||
*/
|
||||
export function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// @workaround DTS bug
|
||||
// dts do not work with monorepo path mapping
|
||||
// disable path mapping for it
|
||||
"paths": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||
"noEmit": false,
|
||||
"allowImportingTsExtensions": false,
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.js"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// @ts-check
|
||||
import chalk from 'chalk'
|
||||
import { dirname, resolve } from 'path'
|
||||
import dts from 'unplugin-dts/vite'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig } from 'vite'
|
||||
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
console.log(chalk.cyan(`📦 Building @page-agent/ui`))
|
||||
|
||||
export default defineConfig({
|
||||
clearScreen: false,
|
||||
plugins: [
|
||||
dts({ tsconfigPath: './tsconfig.dts.json', bundleTypes: true }),
|
||||
cssInjectedByJsPlugin({ relativeCSSInjection: true }),
|
||||
],
|
||||
publicDir: false,
|
||||
esbuild: {
|
||||
keepNames: true,
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/index.ts'),
|
||||
name: 'PageAgentUI',
|
||||
fileName: 'page-agent-ui',
|
||||
formats: ['es'],
|
||||
},
|
||||
outDir: resolve(__dirname, 'dist', 'lib'),
|
||||
rollupOptions: {
|
||||
external: [],
|
||||
},
|
||||
minify: false,
|
||||
sourcemap: true,
|
||||
cssCodeSplit: true,
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
# Website Package - Instructions for Coding Assistants
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **React** with TypeScript
|
||||
- **Vite** for dev server and build
|
||||
- **Tailwind CSS** for styling
|
||||
- **shadcn/ui** (new-york style) for UI components
|
||||
- **Magic UI** for animations and effects
|
||||
- **wouter** with hash routing for static hosting
|
||||
- **lucide-react** for icons
|
||||
|
||||
## Component Guidelines
|
||||
|
||||
### Use shadcn/ui Components First
|
||||
|
||||
**ALWAYS prefer shadcn/ui components over custom implementations.**
|
||||
|
||||
Before creating any UI component, check if shadcn already provides it:
|
||||
|
||||
```bash
|
||||
# IMPORTANT: Run from packages/website/, NOT from repo root
|
||||
cd packages/website
|
||||
|
||||
# Add a new shadcn component
|
||||
npx shadcn@latest add <component-name>
|
||||
|
||||
# Add a Magic UI component
|
||||
npx shadcn@latest add "@magicui/<component-name>"
|
||||
```
|
||||
|
||||
Available shadcn components: https://ui.shadcn.com/docs/components
|
||||
Available Magic UI components: https://magicui.design/docs/components
|
||||
|
||||
### Current UI Components
|
||||
|
||||
Located in `src/components/ui/`:
|
||||
|
||||
**From shadcn/ui:**
|
||||
|
||||
- `alert`, `badge`, `button`, `separator`, `sonner`, `switch`, `tooltip`
|
||||
|
||||
**From Magic UI:**
|
||||
|
||||
- `animated-gradient-text`, `animated-shiny-text`, `aurora-text`
|
||||
- `hyper-text`, `magic-card`, `neon-gradient-card`, `particles`
|
||||
- `sparkles-text`, `text-animate`, `typing-animation`
|
||||
|
||||
**Custom:**
|
||||
|
||||
- `highlighter`, `kbd`, `spinner`
|
||||
|
||||
### Styling Rules
|
||||
|
||||
1. **Prefer Tailwind classes** over custom CSS
|
||||
2. Use CSS modules only for complex component-specific styles
|
||||
3. Support dark mode via `dark:` classes
|
||||
4. Use CSS variables from `src/index.css` for theme colors
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── pages/
|
||||
│ ├── Home.tsx # Homepage
|
||||
│ └── docs/
|
||||
│ ├── Layout.tsx # Documentation sidebar
|
||||
│ └── [section]/[topic]/page.tsx
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui + Magic UI components
|
||||
│ ├── Header.tsx # Site header
|
||||
│ └── Footer.tsx # Site footer
|
||||
├── i18n/ # Internationalization
|
||||
├── router.tsx # Central routing
|
||||
└── main.tsx # App entry
|
||||
```
|
||||
|
||||
## Adding New Pages
|
||||
|
||||
### Documentation Page
|
||||
|
||||
1. Create `src/pages/docs/<section>/<slug>/page.tsx`
|
||||
2. Add route to `src/router.tsx` with `<Header /> + <DocsLayout>` wrapper
|
||||
3. Add navigation item to `pages/docs/Layout.tsx`
|
||||
|
||||
## Routing
|
||||
|
||||
Uses hash-based routing for static hosting:
|
||||
|
||||
```tsx
|
||||
import { Router } from 'wouter'
|
||||
import { useHashLocation } from 'wouter/use-hash-location'
|
||||
|
||||
;<Router hook={useHashLocation}>{/* routes */}</Router>
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------- | ----------------------- |
|
||||
| `components.json` | shadcn/ui configuration |
|
||||
| `vite.config.js` | Vite build settings |
|
||||
| `tsconfig.json` | TypeScript config |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm start # Dev server (from root)
|
||||
npm run build:website # Build website (from root)
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
# Landing Page & Docs
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {
|
||||
"@magicui": "https://magicui.design/r/{name}.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="https://img.alicdn.com/imgextra/i2/O1CN012eGDRI1X6nnMt9clU_!!6000000002875-49-tps-64-64.webp"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PageAgent - The GUI Agent Living in Your Webpage</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="PageAgent.js: Intelligent GUI Agent for any website. Modern web AI automation with minimal integration."
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="PageAgent, AI Agent, GUI Agent, Web Automation, GUI Automation, Frontend, CDN, JavaScript, React, Vite, LLM"
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://img.alicdn.com/imgextra/i3/O1CN01JPT4Fj1FJTfmHfNxO_!!6000000000466-49-tps-512-512.webp"
|
||||
/>
|
||||
<meta property="og:url" content="https://alibaba.github.io/page-agent" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="theme-color" content="#58c0fc" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="author" content="PageAgent.js Team" />
|
||||
<meta property="og:title" content="PageAgent.js - AI-powered GUI Agent" />
|
||||
<meta property="og:description" content="The GUI Agent living in your website." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:locale:alternate" content="zh_CN" />
|
||||
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-HCGRJTN3HM"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || []
|
||||
function gtag() {
|
||||
dataLayer.push(arguments)
|
||||
}
|
||||
gtag('js', new Date())
|
||||
|
||||
gtag('config', 'G-HCGRJTN3HM')
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
<script>
|
||||
// Dynamically update html lang attribute based on i18n detection
|
||||
const updateHtmlLang = () => {
|
||||
const lang = localStorage.getItem('i18nextLng') || navigator.language || 'zh-CN'
|
||||
document.documentElement.lang = lang
|
||||
}
|
||||
updateHtmlLang()
|
||||
window.addEventListener('storage', updateHtmlLang)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@page-agent/website",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build:website": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||
"i18next": "^25.7.4",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-i18next": "^16.5.2",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"wouter": "^3.9.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.26.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"rough-notation": "^0.5.1",
|
||||
"simple-icons": "^16.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export default function BetaNotice() {
|
||||
return (
|
||||
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4 mb-8">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="shrink-0">
|
||||
<span className="text-xl">🚧</span>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
@@ -28,7 +28,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
|
||||
|
||||
// 使用 Tailwind 的 dark: 前缀实现自动主题切换
|
||||
const containerClasses =
|
||||
'bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 border-gray-300 dark:border-gray-700'
|
||||
'bg-linear-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 border-gray-300 dark:border-gray-700'
|
||||
const headerClasses = 'bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-700'
|
||||
const headerTextClasses = 'text-gray-700 dark:text-gray-300'
|
||||
const languageTextClasses = 'text-gray-600 dark:text-gray-400'
|
||||
@@ -72,7 +72,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
|
||||
<div className="flex">
|
||||
{/* 行号 */}
|
||||
{showLineNumbers && (
|
||||
<div className={`flex-shrink-0 px-4 py-4 ${lineNumbersClasses} border-r select-none`}>
|
||||
<div className={`shrink-0 px-4 py-4 ${lineNumbersClasses} border-r select-none`}>
|
||||
<div className="text-xs font-mono leading-6">
|
||||
{lines.map((line, lineIdx) => {
|
||||
const lineNum = lineIdx + 1
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { siGithub } from 'simple-icons'
|
||||
|
||||
export default function Footer() {
|
||||
const { t } = useTranslation('common')
|
||||
@@ -19,8 +20,13 @@ export default function Footer() {
|
||||
className="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-200"
|
||||
aria-label={t('footer.github_label')}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-5 h-5 fill-current"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={siGithub.path} />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
@@ -0,0 +1,174 @@
|
||||
import { BookOpen, Menu, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { siGithub } from 'simple-icons'
|
||||
import { Link } from 'wouter'
|
||||
|
||||
import LanguageSwitcher from './LanguageSwitcher'
|
||||
import ThemeSwitcher from './ThemeSwitcher'
|
||||
import { HyperText } from './ui/hyper-text'
|
||||
|
||||
export default function Header() {
|
||||
const { t } = useTranslation('common')
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className="relative z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-gray-200 dark:border-gray-700"
|
||||
role="banner"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 sm:gap-3 group shrink-0"
|
||||
aria-label={t('header.logo_alt')}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<img
|
||||
src="https://img.alicdn.com/imgextra/i2/O1CN01HB8ylu1uozANEMZw2_!!6000000006085-49-tps-128-128.webp"
|
||||
alt="PageAgent Logo"
|
||||
className="w-10 h-10 rounded-xl group-hover:scale-110 transition-transform duration-200"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-base sm:text-xl font-bold text-gray-900 dark:text-white block leading-tight">
|
||||
page-agent
|
||||
</span>
|
||||
<HyperText
|
||||
as="p"
|
||||
className="hidden sm:block text-xs text-gray-600 dark:text-gray-300 py-0 font-normal overflow-visible"
|
||||
duration={600}
|
||||
animateOnHover={true}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{t('header.slogan')}
|
||||
</HyperText>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Mobile Icon Navigation (横向滚动) */}
|
||||
<nav
|
||||
className="md:hidden flex items-center gap-1 overflow-x-auto scrollbar-hide flex-1"
|
||||
role="navigation"
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
<Link
|
||||
href="/docs/introduction/overview"
|
||||
className="p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 shrink-0"
|
||||
aria-label={t('header.nav_docs')}
|
||||
>
|
||||
<BookOpen className="w-5 h-5" />
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 shrink-0"
|
||||
aria-label={t('header.nav_source')}
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-5 h-5 fill-current"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={siGithub.path} />
|
||||
</svg>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav
|
||||
className="hidden md:flex items-center space-x-6"
|
||||
role="navigation"
|
||||
aria-label={t('header.nav_docs')}
|
||||
>
|
||||
<span className="text-xs font-mono text-gray-400 dark:text-gray-500 tabular-nums before:content-['v']">
|
||||
{import.meta.env.VERSION}
|
||||
</span>
|
||||
<Link
|
||||
href="/docs/introduction/overview"
|
||||
className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
||||
>
|
||||
<BookOpen className="w-4 h-4" />
|
||||
{t('header.nav_docs')}
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
||||
aria-label={t('header.nav_source')}
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-4 h-4 fill-current"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={siGithub.path} />
|
||||
</svg>
|
||||
{t('header.nav_source')}
|
||||
</a>
|
||||
<ThemeSwitcher />
|
||||
<LanguageSwitcher />
|
||||
</nav>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
type="button"
|
||||
className="md:hidden p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-200 shrink-0"
|
||||
aria-label={t('header.mobile_menu')}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
{mobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
{mobileMenuOpen && (
|
||||
<nav
|
||||
id="mobile-menu"
|
||||
className="md:hidden pt-4 pb-2 space-y-3 border-t border-gray-200 dark:border-gray-700 mt-4"
|
||||
role="navigation"
|
||||
>
|
||||
<Link
|
||||
href="/docs/introduction/overview"
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<BookOpen className="w-5 h-5" />
|
||||
{t('header.nav_docs')}
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
||||
aria-label={t('header.nav_source')}
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-5 h-5 fill-current"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={siGithub.path} />
|
||||
</svg>
|
||||
{t('header.nav_source')}
|
||||
</a>
|
||||
<div className="flex items-center gap-3 px-3 py-2">
|
||||
<ThemeSwitcher />
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
.syntax {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
font-family: monospace;
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
:global(.dark) .syntax {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* JavaScript/TypeScript 关键字 */
|
||||
.keyword {
|
||||
color: #d73a49;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.dark) .keyword {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* TypeScript 特定关键字 (interface, type, enum, etc.) */
|
||||
.tsKeyword {
|
||||
color: #af00db;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.dark) .tsKeyword {
|
||||
color: #c792ea;
|
||||
}
|
||||
|
||||
/* TypeScript 内置类型 */
|
||||
.type {
|
||||
color: #267f99;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:global(.dark) .type {
|
||||
color: #4ec9b0;
|
||||
}
|
||||
|
||||
/* 字符串 */
|
||||
.string {
|
||||
color: #1d6eca;
|
||||
}
|
||||
|
||||
:global(.dark) .string {
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
/* 数字 */
|
||||
.number {
|
||||
color: #00c583;
|
||||
}
|
||||
|
||||
:global(.dark) .number {
|
||||
color: #66bb6a;
|
||||
}
|
||||
|
||||
/* 布尔值和字面量 (true, false, null, undefined) */
|
||||
.literal {
|
||||
color: #0000ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:global(.dark) .literal {
|
||||
color: #569cd6;
|
||||
}
|
||||
|
||||
/* 注释 */
|
||||
.comment {
|
||||
color: #6a737d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
:global(.dark) .comment {
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
/* 装饰器 (@decorator) */
|
||||
.decorator {
|
||||
color: #e0aa00;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:global(.dark) .decorator {
|
||||
color: #dcdcaa;
|
||||
}
|
||||
|
||||
/* 箭头函数 (=>) */
|
||||
.arrow {
|
||||
color: #d73a49;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
:global(.dark) .arrow {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* 标识符(变量名、函数名等) */
|
||||
.identifier {
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
:global(.dark) .identifier {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* 属性访问 (.property) */
|
||||
.property {
|
||||
color: #0550ae;
|
||||
}
|
||||
|
||||
:global(.dark) .property {
|
||||
color: #9cdcfe;
|
||||
}
|
||||
|
||||
/* 运算符 */
|
||||
.operator {
|
||||
color: #5a5a5a;
|
||||
}
|
||||
|
||||
:global(.dark) .operator {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* js 语法高亮组件,适合在文章中演示代码片段
|
||||
*/
|
||||
import React from 'react'
|
||||
|
||||
import styles from './HighlightSyntax.module.css'
|
||||
|
||||
interface HighlightSyntaxProps {
|
||||
code: string
|
||||
}
|
||||
|
||||
// JavaScript/TypeScript 关键字
|
||||
const keywords =
|
||||
'async|await|function|const|let|var|if|else|for|while|return|try|catch|finally|class|extends|from|import|export|default|undefined|throw|break|continue|switch|case|do|with|yield|delete|typeof|void|static|get|set|super|debugger'
|
||||
|
||||
// TypeScript 特定关键字
|
||||
const tsKeywords =
|
||||
'interface|type|enum|namespace|module|declare|abstract|implements|public|private|protected|readonly|as|satisfies|infer|keyof|is'
|
||||
|
||||
// 布尔值和空值
|
||||
const literals = 'true|false|null|undefined|NaN|Infinity'
|
||||
|
||||
// TypeScript 内置类型
|
||||
const tsTypes =
|
||||
'string|number|boolean|any|unknown|never|void|object|symbol|bigint|Array|Promise|Record|Partial|Required|Readonly|Pick|Omit|Exclude|Extract|NonNullable|ReturnType|Parameters|ConstructorParameters|InstanceType|ThisType|Uppercase|Lowercase|Capitalize|Uncapitalize'
|
||||
|
||||
// 辅助函数:转义 HTML 特殊字符
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
// 语法高亮函数,先提取 token 再转义和高亮
|
||||
function highlightSyntax(code: string): string {
|
||||
// 构建正则模式,包含更多 token 类型(在原始文本上匹配)
|
||||
const pattern = new RegExp(
|
||||
'(' +
|
||||
// 1. 字符串(双引号、单引号、模板字符串)
|
||||
'"([^"\\\\]|\\\\.)*"|' +
|
||||
"'([^'\\\\]|\\\\.)*'|" +
|
||||
'`([^`\\\\]|\\\\.)*`|' +
|
||||
// 2. 注释(单行和多行)
|
||||
'//[^\\n]*|' +
|
||||
'/\\*[\\s\\S]*?\\*/|' +
|
||||
// 3. 装饰器
|
||||
'@[a-zA-Z_$][\\w$]*|' +
|
||||
// 4. 数字(包括小数、十六进制、科学计数法)
|
||||
'\\b0[xX][0-9a-fA-F]+\\b|' +
|
||||
'\\b\\d+\\.?\\d*(?:[eE][+-]?\\d+)?\\b|' +
|
||||
// 5. TypeScript/JavaScript 关键字
|
||||
'\\b(?:' +
|
||||
keywords +
|
||||
'|' +
|
||||
tsKeywords +
|
||||
'|' +
|
||||
literals +
|
||||
')\\b|' +
|
||||
// 6. TypeScript 内置类型
|
||||
'\\b(?:' +
|
||||
tsTypes +
|
||||
')\\b|' +
|
||||
// 7. 箭头函数
|
||||
'=>|' +
|
||||
// 8. 函数调用(函数名后跟括号)
|
||||
'\\b[a-zA-Z_$][\\w$]*(?=\\()|' +
|
||||
// 9. 属性访问
|
||||
'\\.[a-zA-Z_$][\\w$]*|' +
|
||||
// 10. 运算符和特殊符号
|
||||
'[+\\-*/%&|^!~<>=?:]+|' +
|
||||
'[{}\\[\\]();,.]' +
|
||||
')',
|
||||
'g'
|
||||
)
|
||||
|
||||
const tokens: string[] = []
|
||||
let lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = pattern.exec(code)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
const gap = code.slice(lastIndex, match.index)
|
||||
// 将间隙按空白符分割,保留空白符
|
||||
tokens.push(...gap.split(/(\s+)/))
|
||||
}
|
||||
tokens.push(match[0])
|
||||
lastIndex = pattern.lastIndex
|
||||
}
|
||||
if (lastIndex < code.length) {
|
||||
tokens.push(...code.slice(lastIndex).split(/(\s+)/))
|
||||
}
|
||||
|
||||
const highlighted = tokens
|
||||
.map((token) => {
|
||||
// 空白符直接返回
|
||||
if (/^\s+$/.test(token)) {
|
||||
return token
|
||||
}
|
||||
|
||||
// 1. 注释(单行和多行)
|
||||
if (/^\/\/.*$/.test(token) || /^\/\*[\s\S]*?\*\/$/.test(token)) {
|
||||
return `<span class="${styles.comment}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 2. 字符串
|
||||
if (
|
||||
/^"([^"\\]|\\.)*"$/.test(token) ||
|
||||
/^'([^'\\]|\\.)*'$/.test(token) ||
|
||||
/^`([^`\\]|\\.)*`$/.test(token)
|
||||
) {
|
||||
return `<span class="${styles.string}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 3. 数字
|
||||
if (/^(0[xX][0-9a-fA-F]+|\d+\.?\d*(?:[eE][+-]?\d+)?)$/.test(token)) {
|
||||
return `<span class="${styles.number}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 4. 布尔值和特殊字面量
|
||||
if (new RegExp(`^(?:${literals})$`).test(token)) {
|
||||
return `<span class="${styles.literal}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 5. JavaScript/TypeScript 关键字
|
||||
if (new RegExp(`^(?:${keywords})$`).test(token)) {
|
||||
return `<span class="${styles.keyword}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 6. TypeScript 特定关键字
|
||||
if (new RegExp(`^(?:${tsKeywords})$`).test(token)) {
|
||||
return `<span class="${styles.tsKeyword}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 7. TypeScript 内置类型
|
||||
if (new RegExp(`^(?:${tsTypes})$`).test(token)) {
|
||||
return `<span class="${styles.type}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 8. 装饰器
|
||||
if (/^@[a-zA-Z_$][\w$]*$/.test(token)) {
|
||||
return `<span class="${styles.decorator}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 9. 箭头函数
|
||||
if (token === '=>') {
|
||||
return `<span class="${styles.arrow}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 10. 函数调用和标识符
|
||||
if (/^[a-zA-Z_$][\w$]*$/.test(token)) {
|
||||
return `<span class="${styles.identifier}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 11. 属性访问
|
||||
if (/^\.[a-zA-Z_$][\w$]*$/.test(token)) {
|
||||
return `<span class="${styles.property}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 12. 运算符
|
||||
if (/^[+\-*/%&|^!~<>=?:]+$/.test(token)) {
|
||||
return `<span class="${styles.operator}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 13. 其他符号,需要转义
|
||||
return escapeHtml(token)
|
||||
})
|
||||
.join('')
|
||||
|
||||
return highlighted
|
||||
}
|
||||
|
||||
const HighlightSyntaxClient: React.FC<HighlightSyntaxProps> = ({ code }) => {
|
||||
const htmlContent = highlightSyntax(code)
|
||||
|
||||
// eslint-disable-next-line react-dom/no-dangerously-set-innerhtml
|
||||
return <code className={styles.syntax} dangerouslySetInnerHTML={{ __html: htmlContent }} />
|
||||
}
|
||||
|
||||
export default HighlightSyntaxClient
|
||||
+1
-1
@@ -56,7 +56,7 @@ export default function ThemeSwitcher() {
|
||||
style={{
|
||||
backgroundColor: theme === 'dark' ? '#1e293b' : '#e0f2fe',
|
||||
}}
|
||||
aria-label={theme === 'light' ? '切换到深色模式' : '切换到浅色模式'}
|
||||
aria-label={theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'}
|
||||
role="switch"
|
||||
aria-checked={theme === 'dark'}
|
||||
>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user