Compare commits
273 Commits
v0.0.3
...
v1.0.0-beta.0
| Author | SHA1 | Date | |
|---|---|---|---|
| f83a4174cc | |||
| 334512210d | |||
| 3df71788ff | |||
| 60450ea7be | |||
| 80c93bd668 | |||
| 6d53f4cb44 | |||
| b35489d6a5 | |||
| d78cbb6b6e | |||
| e9c5cece4f | |||
| 4b69a7ee00 | |||
| 25e4d07fc5 | |||
| 6a381dede1 | |||
| b3bfbb96bb | |||
| 3d10953324 | |||
| 8dd4c5d78e | |||
| 8bde218f28 | |||
| 0643cc4ff9 | |||
| 4c5dd23fcd | |||
| 3ce4d8e3fe | |||
| b217e6a2ca | |||
| c9f049a733 | |||
| 09c3084629 | |||
| 38c8020e4a | |||
| 5057f7472b | |||
| 6820eec676 | |||
| ef2777b521 | |||
| 8bba3a63f5 | |||
| 722eb10286 | |||
| 15ffc27059 | |||
| 5f162b1a1c | |||
| 083c002ce0 | |||
| 1ede1d9911 | |||
| 8abe3afed9 | |||
| af36f43104 | |||
| b5f260207c | |||
| 9075204c29 | |||
| ce3a80bc53 | |||
| 64c36f67bb | |||
| 78d4919289 | |||
| 1af2607cca | |||
| ab713f3d3a | |||
| 05cc4f36c8 | |||
| 54e9bdad7d | |||
| 0fc88c0080 | |||
| 02f97ef2fa | |||
| 0853ffbbc6 | |||
| 60ac6da09f | |||
| 11a9fb1181 | |||
| 3762d0f1e0 | |||
| ebbe92f7ca | |||
| 9f239b2de3 | |||
| 2410c27b55 | |||
| 6d6c82648c | |||
| f41119c1ad | |||
| 9e6e1b996c | |||
| 1ab23e181f | |||
| a1b485691f | |||
| 694e05b800 | |||
| b761795c1a | |||
| 8cc54983aa | |||
| 59a599285e | |||
| 2f8c2b395c | |||
| 28f836674c | |||
| 6f01632231 | |||
| 41123ac561 | |||
| c8bf0dd20f | |||
| ec2b744bab | |||
| eab9cf64e8 | |||
| 42130d6f1d | |||
| 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 | |||
| b85a82785f | |||
| 2314d28e60 | |||
| 58475c63da | |||
| 55f07d971a | |||
| cd84269427 | |||
| fedeb57f48 | |||
| 386167e99b | |||
| 190d264bda | |||
| 7af3b580d5 | |||
| 4af927f218 |
@@ -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,12 +35,27 @@ body:
|
||||
label: Browser
|
||||
placeholder: 'Chrome 120, Firefox 119, etc.'
|
||||
validations:
|
||||
required: true
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: version
|
||||
placeholder: '0.0.0'
|
||||
validations:
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,27 +6,38 @@ on:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
id-token: write # Required for OIDC
|
||||
id-token: write # Required for OIDC
|
||||
contents: read
|
||||
|
||||
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
|
||||
cache: 'npm'
|
||||
node-version: 24
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
# Ensure npm 11.5.1 or later is installed
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install dependencies
|
||||
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: |
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
if [[ "$VERSION" == *"-"* ]]; then
|
||||
# Prerelease version (e.g., 0.3.0-beta.1) -> extract tag name before the dot
|
||||
TAG=$(echo "$VERSION" | sed 's/.*-\([a-zA-Z]*\).*/\1/')
|
||||
npm publish --workspaces --access public --tag "$TAG"
|
||||
else
|
||||
npm publish --workspaces --access public
|
||||
fi
|
||||
|
||||
+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
+29
-16
@@ -1,18 +1,31 @@
|
||||
{
|
||||
"editor.fontLigatures": true,
|
||||
"cSpell.words": ["HITL", "opensource", "retryable"],
|
||||
"markdownlint.config": {
|
||||
// "comment": "Relaxed rules",
|
||||
"default": true,
|
||||
"whitespace": false,
|
||||
"line_length": false,
|
||||
"ul-indent": false,
|
||||
"no-inline-html": false,
|
||||
"no-bare-urls": false,
|
||||
"fenced-code-language": false,
|
||||
"first-line-h1": false,
|
||||
"block-spacing": false,
|
||||
"blanks-around-lists": false,
|
||||
"ol-prefix": false
|
||||
}
|
||||
"editor.fontLigatures": true,
|
||||
"cSpell.words": [
|
||||
"deepseek",
|
||||
"historychange",
|
||||
"HITL",
|
||||
"innerhtml",
|
||||
"llms",
|
||||
"onwarn",
|
||||
"opensource",
|
||||
"qwen",
|
||||
"retryable",
|
||||
"shadcn",
|
||||
"statuschange",
|
||||
"wouter"
|
||||
],
|
||||
"markdownlint.config": {
|
||||
// "comment": "Relaxed rules",
|
||||
"default": true,
|
||||
"whitespace": false,
|
||||
"line_length": false,
|
||||
"ul-indent": false,
|
||||
"no-inline-html": false,
|
||||
"no-bare-urls": false,
|
||||
"fenced-code-language": false,
|
||||
"first-line-h1": false,
|
||||
"block-spacing": false,
|
||||
"blanks-around-lists": false,
|
||||
"ol-prefix": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,161 +1,143 @@
|
||||
# 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
|
||||
- **Page Agent** (`packages/page-agent/`) - Main entry with built-in UI Panel, 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:
|
||||
|
||||
- **Core** (`packages/core/`) - PageAgentCore without UI (npm: `@page-agent/core`)
|
||||
- **LLMs** (`packages/llms/`) - LLM client with reflection-before-action mental model
|
||||
- **Page Controller** (`packages/page-controller/`) - DOM operations and visual feedback (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/
|
||||
├── core/ # npm: "@page-agent/core" ⭐ Core agent logic (headless)
|
||||
├── page-agent/ # npm: "page-agent" entry class (with UI + controller + demo 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**: Main entry with UI. Extends PageAgentCore and adds Panel. Imports from `@page-agent/core`, `@page-agent/ui`
|
||||
- **Core**: PageAgentCore without UI. Imports from `@page-agent/llms`, `@page-agent/page-controller`
|
||||
- **LLMs**: LLM client with MacroToolInput contract. No dependency on page-agent
|
||||
- **UI**: Panel and i18n. Decoupled from PageAgent via PanelAgentAdapter interface
|
||||
- **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
|
||||
### IIFE Builds (`packages/page-agent/dist/iife/`)
|
||||
|
||||
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
|
||||
Build with `npm run build:iife --workspace=page-agent`.
|
||||
|
||||
Uses wouter with `useHashLocation` for static hosting:
|
||||
## Key Files Reference
|
||||
|
||||
```tsx
|
||||
<Router hook={useHashLocation}> // Always hash-based routes
|
||||
```
|
||||
### Page Agent (`packages/page-agent/`)
|
||||
|
||||
### CDN Auto-Injection Pattern
|
||||
| File | Description |
|
||||
| ------------------------ | -------------------------------------------- |
|
||||
| `src/PageAgent.ts` | ⭐ Main class with UI, extends PageAgentCore |
|
||||
| `src/entry-iife.ts` | IIFE entry (exposes PageAgent class) |
|
||||
| `src/entry-iife-demo.ts` | IIFE demo entry (auto-init with demo API) |
|
||||
|
||||
Library auto-initializes when injected via script tag:
|
||||
### Core (`packages/core/`)
|
||||
|
||||
```html
|
||||
<script src="page-agent.js?model=gpt-4"></script>
|
||||
```
|
||||
| File | Description |
|
||||
| ---------------------- | --------------------------------------- |
|
||||
| `src/PageAgentCore.ts` | ⭐ Core agent class without UI |
|
||||
| `src/tools/` | Tool definitions calling PageController |
|
||||
| `src/config/` | Configuration types and constants |
|
||||
| `src/prompts/` | System prompt templates |
|
||||
|
||||
Query params configure `PageAgentConfig` automatically in `src/entry.ts`.
|
||||
### LLMs (`packages/llms/`)
|
||||
|
||||
## File Organization
|
||||
| File | Description |
|
||||
| --------------------- | ------------------------------------- |
|
||||
| `src/index.ts` | ⭐ LLM class with retry logic |
|
||||
| `src/types.ts` | MacroToolInput, AgentBrain, LLMConfig |
|
||||
| `src/OpenAIClient.ts` | OpenAI-compatible client |
|
||||
|
||||
### Core Library (`src/`)
|
||||
### Page Controller (`packages/page-controller/`)
|
||||
|
||||
- `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/core/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.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.0] - 2026-01-19
|
||||
|
||||
### 🎉 First Stable Release
|
||||
|
||||
PageAgent is now ready for production use. The API is stable and breaking changes will follow semantic versioning.
|
||||
|
||||
### Features
|
||||
|
||||
#### Core
|
||||
|
||||
- **PageAgent** - Main entry class with built-in UI Panel
|
||||
- **PageAgentCore** - Headless agent class for custom UI or programmatic use
|
||||
- **DOM Analysis** - Text-based DOM extraction with high-intensity dehydration
|
||||
- **LLM Support** - Works with OpenAI, Claude, DeepSeek, Qwen, and other OpenAI-compatible APIs
|
||||
- **Tool System** - Built-in tools for click, input, scroll, select, and more
|
||||
- **Custom Tools** - Extend agent capabilities with your own tools (experimental)
|
||||
- **Lifecycle Hooks** - Hook into agent execution (experimental)
|
||||
- **Instructions System** - System-level and page-level instructions to guide agent behavior
|
||||
- **Data Masking** - Transform page content before sending to LLM
|
||||
|
||||
#### Page Controller
|
||||
|
||||
- **Element Interactions** - Click, input text, select options, scroll
|
||||
- **Visual Mask** - Blocks user interaction during automation
|
||||
- **DOM Tree Extraction** - Efficient page structure extraction for LLM consumption
|
||||
|
||||
#### UI
|
||||
|
||||
- **Interactive Panel** - Real-time task progress and agent thinking display
|
||||
- **Ask User Tool** - Agent can ask users for clarification
|
||||
- **i18n Support** - English and Chinese localization
|
||||
|
||||
### Configuration
|
||||
|
||||
```typescript
|
||||
interface PageAgentConfig {
|
||||
// LLM Configuration (required)
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
temperature?: number
|
||||
maxRetries?: number
|
||||
customFetch?: typeof fetch
|
||||
|
||||
// Agent Configuration
|
||||
language?: 'en-US' | 'zh-CN'
|
||||
maxSteps?: number // default: 20
|
||||
customTools?: Record<string, PageAgentTool> // experimental
|
||||
instructions?: InstructionsConfig
|
||||
transformPageContent?: (content: string) => string | Promise<string>
|
||||
experimentalScriptExecutionTool?: boolean // default: false
|
||||
|
||||
// Lifecycle Hooks (experimental)
|
||||
onBeforeTask?: (agent, result) => void
|
||||
onAfterTask?: (agent, result) => void
|
||||
onBeforeStep?: (agent, stepCount) => void
|
||||
onAfterStep?: (agent, history) => void
|
||||
onDispose?: (agent, reason?) => void
|
||||
|
||||
// Page Controller Configuration
|
||||
enableMask?: boolean // default: true
|
||||
viewportExpansion?: number
|
||||
interactiveBlacklist?: Element[]
|
||||
interactiveWhitelist?: Element[]
|
||||
}
|
||||
```
|
||||
|
||||
### Packages
|
||||
|
||||
| Package | Description |
|
||||
| ----------------------------- | ---------------------------------- |
|
||||
| `page-agent` | Main entry with UI Panel |
|
||||
| `@page-agent/core` | Core agent logic without UI |
|
||||
| `@page-agent/llms` | LLM client with retry logic |
|
||||
| `@page-agent/page-controller` | DOM operations and visual feedback |
|
||||
| `@page-agent/ui` | Panel and i18n |
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- Single-page application only (cannot navigate across pages)
|
||||
- No visual recognition (relies on DOM structure)
|
||||
- Limited interaction support (no hover, drag-drop, canvas operations)
|
||||
- See [Limitations](https://alibaba.github.io/page-agent/#/docs/introduction/limitations) for details
|
||||
|
||||
### Acknowledgments
|
||||
|
||||
This project builds upon the excellent work of [browser-use](https://github.com/browser-use/browser-use). DOM processing components and prompts are adapted from browser-use (MIT License).
|
||||
+70
-66
@@ -7,25 +7,30 @@ 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**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start # Start demo and documentation site
|
||||
```
|
||||
```bash
|
||||
npm ci
|
||||
npm start # Start demo and documentation site
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
This project has **two separate parts**:
|
||||
This is a **monorepo** with npm workspaces containing **3 main packages**:
|
||||
|
||||
- **Core Library** (`src/`) - Pure JavaScript AI agent library
|
||||
- **Documentation Website** (`pages/`) - React web app for landing page and docs
|
||||
- **Page Agent** (`packages/page-agent/`) - Main entry with built-in UI Panel, published as `page-agent` on npm
|
||||
- **Core** (`packages/core/`) - Core agent logic without UI (npm: `@page-agent/core`)
|
||||
- **Website** (`packages/website/`) - React documentation and landing page. Also as demo and test page for the core lib. private package `@page-agent/website`
|
||||
|
||||
We use a simplified monorepo solution with `native npm-workspace + ts reference + vite alias`. No fancy tooling. Hoisting is required.
|
||||
|
||||
- When developing. Use alias so that we don't have to pre-build.
|
||||
- When bundling. Use external and disable ts `paths` alias to leave deps out.
|
||||
- When bundling `IIFE` and `Website`. Bundle everything including local packages.
|
||||
|
||||
## 🤝 How to Contribute
|
||||
|
||||
@@ -40,40 +45,39 @@ This project has **two separate parts**:
|
||||
|
||||
1. **Fork and Clone**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-username/page-agent.git
|
||||
cd page-agent
|
||||
```
|
||||
```bash
|
||||
git clone https://github.com/your-username/page-agent.git
|
||||
cd page-agent
|
||||
```
|
||||
|
||||
2. **Create Feature Branch**
|
||||
|
||||
```bash
|
||||
git checkout -b feat/your-feature-name
|
||||
```
|
||||
```bash
|
||||
git checkout -b feat/your-feature-name
|
||||
```
|
||||
|
||||
3. **Make Changes**
|
||||
- Follow existing code style and patterns
|
||||
- Add tests for new functionality
|
||||
- Update documentation as needed
|
||||
- Follow existing code style and patterns
|
||||
- Add tests for new functionality
|
||||
- Update documentation as needed
|
||||
|
||||
4. **Test Your Changes**
|
||||
|
||||
```bash
|
||||
# TODO
|
||||
```
|
||||
- Test in our demo website
|
||||
- Test it on other websites if applicable
|
||||
- `@TODO: test suite`
|
||||
|
||||
5. **Commit and Push**
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: add awesome feature"
|
||||
git push origin feat/your-feature-name
|
||||
```
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: add awesome feature"
|
||||
git push origin feat/your-feature-name
|
||||
```
|
||||
|
||||
6. **Create Pull Request**
|
||||
- Provide clear description of changes
|
||||
- Link related issues
|
||||
- Include screenshots for UI changes
|
||||
- Provide clear description of changes
|
||||
- Link related issues
|
||||
- Include screenshots for UI changes
|
||||
|
||||
## 📝 Code Style
|
||||
|
||||
@@ -90,52 +94,52 @@ This project has **two separate parts**:
|
||||
- It's **recommended** to heavily rely on AI (aka "vibe coding") when maintaining **demo pages and tests**.
|
||||
- BUT **NOT the core lib!!!** Be very careful if AI ever touched the core lib!!!
|
||||
- Review anything AI wrote before make a commit. You are the author of anything you commit. NOT AI.
|
||||
- Update the AI instructions when structure changed.
|
||||
- Cursor and Cline: `./.cursor/rules`
|
||||
- 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
|
||||
- Update the [AI instructions](AGENTS.md) when structure changed.
|
||||
|
||||
## 🔧 Development Workflows
|
||||
|
||||
### Test With Your Own LLM API
|
||||
|
||||
- Create a `.env` file in the repo root with your LLM API config
|
||||
|
||||
```env
|
||||
LLM_MODEL_NAME=gpt-5.2
|
||||
LLM_API_KEY=your-api-key
|
||||
LLM_BASE_URL=https://api.your-llm-provider.com/v1
|
||||
```
|
||||
|
||||
- Restart the dev server to load new env vars
|
||||
- If not provided, the demo will the free testing proxy by default
|
||||
|
||||
### Website Development
|
||||
|
||||
```bash
|
||||
npm start # React development server
|
||||
npm start
|
||||
```
|
||||
|
||||
### Core Lib Development and Testing
|
||||
### Testing on Other Websites
|
||||
|
||||
- Config your LLM API
|
||||
- Start a local script
|
||||
- Start and serve a local `iife` script
|
||||
|
||||
```bash
|
||||
npm run build:lib:watch # Auto-rebuild on changes
|
||||
```
|
||||
```bash
|
||||
npm run dev:iife # Serving IIFE with auto rebuild at http://localhost:5174/page-agent.js
|
||||
```
|
||||
|
||||
- Add a new bookmark enable it on other website
|
||||
- Add a new bookmark
|
||||
|
||||
```
|
||||
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
|
||||
javascript:(function(){var s=document.createElement('script');s.src=`http://localhost:5174/page-agent.js?t=${Math.random()}`;s.onload=()=>console.log(%27PageAgent ready!%27);document.head.appendChild(s);})();
|
||||
```
|
||||
|
||||
- Click the bookmark on any page to load Page-Agent
|
||||
|
||||
> Warning: AK in your local `.env` will be inlined in the iife script.
|
||||
|
||||
### Adding Documentation
|
||||
|
||||
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`
|
||||
Ask an AI to help you add documentation to the `website/` package. Follow the existing style.
|
||||
|
||||
> Our AGENTS.md file and guardrails are designed for this purpose. But please be careful and review anything AI generated.
|
||||
|
||||
## 🎯 Contribution Areas
|
||||
|
||||
@@ -150,7 +154,7 @@ We especially welcome contributions in:
|
||||
|
||||
## 🚫 What We Don't Accept
|
||||
|
||||
- Changes that break existing API compatibility
|
||||
- Changes that break existing API compatibility (Discuss first)
|
||||
- Heavy dependencies to core library
|
||||
- Contributions without proper testing
|
||||
- Code that doesn't follow project conventions
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Alibaba
|
||||
Copyright (c) 2026 Alibaba
|
||||
Copyright (c) 2026 Simon
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
+47
-47
@@ -1,16 +1,19 @@
|
||||
# PageAgent 🤖🪄
|
||||
|
||||

|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://img.alicdn.com/imgextra/i4/O1CN01qKig1P1FnhpFKNdi6_!!6000000000532-2-tps-1280-256.png">
|
||||
<img alt="Page Agent Banner" src="https://img.alicdn.com/imgextra/i1/O1CN01NCMKXj1Gn4tkFTsxf_!!6000000000666-2-tps-1280-256.png">
|
||||
</picture>
|
||||
|
||||
[](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)
|
||||
[](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/34d8444d-cbfb-44a3-a24e-fd5c167bb0bf" controls crossorigin muted></video>
|
||||
|
||||
---
|
||||
|
||||
@@ -24,21 +27,28 @@
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
👉 [**Roadmap**](./ROADMAP.md)
|
||||
👉 [**Roadmap**](https://github.com/alibaba/page-agent/issues/96)
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### CDN 集成
|
||||
### 快速体验 (With Testing LLM)
|
||||
|
||||
最快的体验方式:
|
||||
|
||||
```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"
|
||||
src="https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.demo.js"
|
||||
crossorigin="true"
|
||||
></script>
|
||||
```
|
||||
|
||||
> ⚠️ **仅用于技术评估。** Demo 模型有速率限制和使用限制,生产环境请使用 NPM 方式。
|
||||
|
||||
| 镜像 | URL |
|
||||
| ------ | ----------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/latest/files/dist/iife/page-agent.demo.js |
|
||||
|
||||
### NPM 安装
|
||||
|
||||
```bash
|
||||
@@ -48,63 +58,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,
|
||||
language: 'zh-CN',
|
||||
model: 'deepseek-chat',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
language: 'zh-CN',
|
||||
})
|
||||
|
||||
await agent.execute('点击登录按钮')
|
||||
```
|
||||
|
||||
适用于无法使用 NPM 的环境,我们也提供了 IIFE 构建的 CDN 方式。[@see CDN Usage](https://alibaba.github.io/page-agent/#/docs/integration/cdn-setup)
|
||||
|
||||
## 🏗️ 架构设计
|
||||
|
||||
PageAgent 采用清晰的模块化架构:
|
||||
PageAgent adopts a simplified monorepo structure:
|
||||
|
||||
```
|
||||
src/
|
||||
├── PageAgent.ts # Agent 主流程
|
||||
├── dom/ # DOM 理解
|
||||
├── tools/ # 代理交互工具
|
||||
├── ui/ # UI 组件和面板
|
||||
├── llms/ # LLM 集成层
|
||||
└── utils/ # 事件总线和工具
|
||||
packages/
|
||||
├── core/ # ** Core agent logic without UI(npm: @page-agent/core) **
|
||||
├── page-agent/ # Exported agent and demo(npm: page-agent)
|
||||
├── llms/ # LLM 客户端 (npm: @page-agent/llms)
|
||||
├── page-controller/ # DOM 操作 & 蒙层 & 模拟鼠标 (npm: @page-agent/page-controller)
|
||||
├── ui/ # 面板 & i18n (npm: @page-agent/ui)
|
||||
└── website/ # 文档站点
|
||||
```
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎社区贡献!以下是参与方式:
|
||||
|
||||
### 开发环境
|
||||
1. Fork 并 clone。`git clone https://github.com/alibaba/page-agent.git && cd page-agent`
|
||||
2. 安装依赖: `npm install`
|
||||
3. 启动开发: `npm start`
|
||||
|
||||
1. Fork 项目仓库
|
||||
2. Clone or fork: `git clone https://github.com/alibaba/page-agent.git && cd page-agent`
|
||||
3. 安装依赖: `npm install`
|
||||
4. 启动开发: `npm start`
|
||||
更多详情请参阅 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
|
||||
### 贡献指南
|
||||
|
||||
请在贡献前阅读我们的[行为准则](CODE_OF_CONDUCT.md)和[贡献指南](CONTRIBUTING.md)。
|
||||
请在贡献前阅读我们的[行为准则](CODE_OF_CONDUCT.md)。
|
||||
|
||||
## 👏 致谢
|
||||
|
||||
本项目基于以下优秀项目构建:
|
||||
本项目基于 **[`browser-use`](https://github.com/browser-use/browser-use)** 的优秀工作构建。
|
||||
|
||||
- **[browser-use](https://github.com/browser-use/browser-use)**
|
||||
|
||||
PageAgent 专为**客户端网页增强**设计,不是服务端自动化工具。
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT 许可证 - 详见 [LICENSE](LICENSE) 文件。
|
||||
`PageAgent` 专为**客户端网页增强**设计,不是服务端自动化工具。
|
||||
|
||||
```
|
||||
DOM processing components and prompt are derived from browser-use:
|
||||
@@ -123,6 +119,10 @@ Third-party dependencies and their licenses can be found in the package.json
|
||||
file and in the node_modules directory after installation.
|
||||
```
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
[MIT License](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
**⭐ 如果觉得 PageAgent 有用或有趣,请给项目点个星!**
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
# PageAgent 🤖🪄
|
||||
|
||||

|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://img.alicdn.com/imgextra/i4/O1CN01qKig1P1FnhpFKNdi6_!!6000000000532-2-tps-1280-256.png">
|
||||
<img alt="Page Agent Banner" src="https://img.alicdn.com/imgextra/i1/O1CN01NCMKXj1Gn4tkFTsxf_!!6000000000666-2-tps-1280-256.png">
|
||||
</picture>
|
||||
|
||||
[](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)
|
||||
[](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/34d8444d-cbfb-44a3-a24e-fd5c167bb0bf" controls crossorigin muted></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,21 +27,28 @@ 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 (With Testing LLM)
|
||||
|
||||
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"
|
||||
src="https://cdn.jsdelivr.net/npm/page-agent/dist/iife/page-agent.demo.js"
|
||||
crossorigin="true"
|
||||
></script>
|
||||
```
|
||||
|
||||
> ⚠️ **For technical evaluation only.** Demo model has rate limits and usage restrictions. Use NPM for production.
|
||||
|
||||
| Mirrors | URL |
|
||||
| ------- | ----------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/latest/files/dist/iife/page-agent.demo.js |
|
||||
|
||||
### NPM Installation
|
||||
|
||||
```bash
|
||||
@@ -48,18 +58,11 @@ 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,
|
||||
language: 'en-US',
|
||||
model: 'deepseek-chat',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
language: 'en-US',
|
||||
})
|
||||
|
||||
await agent.execute('Click the login button')
|
||||
@@ -67,44 +70,35 @@ await agent.execute('Click the login button')
|
||||
|
||||
## 🏗️ 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/
|
||||
├── core/ # ** Core agent logic without UI(npm: @page-agent/core) **
|
||||
├── page-agent/ # Exported agent and demo(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)
|
||||
└── website/ # Demo & Documentation site
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions from the community! Here's how to get started:
|
||||
|
||||
### Setup
|
||||
1. Fork and clone. `git clone https://github.com/alibaba/page-agent.git && cd page-agent`
|
||||
2. Install dependencies: `npm install`
|
||||
3. Start development: `npm start`
|
||||
|
||||
1. Fork the repository
|
||||
2. Clone your fork: `git clone https://github.com/alibaba/page-agent.git && cd page-agent`
|
||||
3. Install dependencies: `npm install`
|
||||
4. Start development: `npm start`
|
||||
More details in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
### Contributing Guidelines
|
||||
|
||||
Please read our [Code of Conduct](CODE_OF_CONDUCT.md) and [Contributing Guide](CONTRIBUTING.md) before contributing.
|
||||
Please read our [Code of Conduct](CODE_OF_CONDUCT.md) before contributing.
|
||||
|
||||
## 👏 Acknowledgments
|
||||
|
||||
This project builds upon the excellent work of:
|
||||
This project builds upon the excellent work of **[`browser-use`](https://github.com/browser-use/browser-use)**.
|
||||
|
||||
- **[browser-use](https://github.com/browser-use/browser-use)**
|
||||
|
||||
PageAgent is designed for **client-side web enhancement**, not server-side automation.
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
`PageAgent` is designed for **client-side web enhancement**, not server-side automation.
|
||||
|
||||
```
|
||||
DOM processing components and prompt are derived from browser-use:
|
||||
@@ -123,6 +117,10 @@ Third-party dependencies and their licenses can be found in the package.json
|
||||
file and in the node_modules directory after installation.
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
|
||||
[MIT License](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
**⭐ Star this repo if you find PageAgent helpful!**
|
||||
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
# 🗺️ PageAgent Roadmap
|
||||
|
||||
The development progress and future plans for PageAgent.
|
||||
|
||||
## 🚀 Current Works
|
||||
|
||||
- [x] **MVP** - Core functionality implemented
|
||||
- [x] **SPA interaction** - Single Page Application support
|
||||
- [x] **Reasoning and (short) memory**
|
||||
- [x] **Multi model provider integration and testing**
|
||||
- [x] **UI with HITL** - Human-in-the-loop user interface
|
||||
- [x] **Landing and doc pages**
|
||||
- [x] **Remove ai-sdk** - Only one function is being used
|
||||
- [x] **Robust LLM output**
|
||||
- [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**
|
||||
- [ ] **❗Hijack `page_open/page_change/page_unload` behavior**
|
||||
- [ ] **Custom knowledge base and instructions**
|
||||
- [ ] **Black/white-list safeguard**
|
||||
- [ ] **Data-masking**
|
||||
- [ ] **Optimize for popular UI frameworks**
|
||||
- [ ] **Testing suits**
|
||||
- [ ] **Support custom llm fetch**
|
||||
- [ ] **Refactor: Separate Agent and Page-Controller** - Agent should run w/o dom
|
||||
|
||||
♻️ 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
|
||||
@@ -1,25 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
import type { PageAgent } from './src/PageAgent'
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.md?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
||||
/**
|
||||
* for local dev and umd demo
|
||||
*/
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
pageAgent?: PageAgent
|
||||
PageAgent: typeof PageAgent
|
||||
|
||||
__PAGE_AGENT_IDS__: string[]
|
||||
}
|
||||
}
|
||||
+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',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
-32
@@ -1,32 +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 - 网页内的交互助手</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="PageAgent.js:一行 CDN 引入,为任何网站添加智能 UI Agent。现代网页 AI 自动化,极简集成,开发者专用。"
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="PageAgent, AI Agent, Web Automation, UI 自动化, 前端, CDN, JavaScript, React, Vite"
|
||||
/>
|
||||
<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" />
|
||||
<meta property="og:description" content="一行 CDN 引入,为任何网站添加智能 UI Agent。" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="zh_CN" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./pages/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2259
-1011
File diff suppressed because it is too large
Load Diff
+58
-65
@@ -1,89 +1,66 @@
|
||||
{
|
||||
"name": "page-agent",
|
||||
"private": false,
|
||||
"version": "0.0.3",
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"version": "1.0.0-beta.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/core",
|
||||
"packages/page-agent",
|
||||
"packages/website"
|
||||
],
|
||||
"description": "AI-powered UI agent for web applications",
|
||||
"author": "Simon<gaomeng1900>",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/alibaba/page-agent.git"
|
||||
},
|
||||
"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:iife": "npm run dev:iife --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",
|
||||
"lint-staged": "^16.2.4",
|
||||
"prettier": "^3.6.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"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"
|
||||
},
|
||||
"overrides": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,cjs,cts,mjs,mts}": [
|
||||
@@ -125,6 +102,22 @@
|
||||
".css$"
|
||||
],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true
|
||||
"importOrderSortSpecifiers": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.md",
|
||||
"options": {
|
||||
"useTabs": false,
|
||||
"tabWidth": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": "*.json",
|
||||
"options": {
|
||||
"useTabs": false,
|
||||
"tabWidth": 4
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@page-agent/core",
|
||||
"private": false,
|
||||
"version": "1.0.0-beta.0",
|
||||
"type": "module",
|
||||
"main": "./dist/esm/page-agent-core.js",
|
||||
"module": "./dist/esm/page-agent-core.js",
|
||||
"types": "./dist/esm/PageAgentCore.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/esm/PageAgentCore.d.ts",
|
||||
"import": "./dist/esm/page-agent-core.js",
|
||||
"default": "./dist/esm/page-agent-core.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"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",
|
||||
"dev:iife": "concurrently \"vite build --config vite.iife.config.js --watch\" \"npx serve dist/iife -p 5174\"",
|
||||
"prepublishOnly": "node -e \"const fs=require('fs');['README.md','LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
||||
"postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"zod": "^4.3.5",
|
||||
"@page-agent/llms": "1.0.0-beta.0",
|
||||
"@page-agent/page-controller": "1.0.0-beta.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
/**
|
||||
* Copyright (C) 2025 Alibaba Group Holding Limited
|
||||
* All rights reserved.
|
||||
*/
|
||||
import { LLM, type Tool } from '@page-agent/llms'
|
||||
import type { PageController } from '@page-agent/page-controller'
|
||||
import chalk from 'chalk'
|
||||
import zod from 'zod'
|
||||
|
||||
import { type PageAgentConfig } from './config'
|
||||
import { DEFAULT_MAX_STEPS } from './config/constants'
|
||||
import SYSTEM_PROMPT from './prompts/system_prompt.md?raw'
|
||||
import { tools } from './tools'
|
||||
import {
|
||||
AgentActivity,
|
||||
AgentReflection,
|
||||
AgentStatus,
|
||||
AgentStep,
|
||||
ExecutionResult,
|
||||
HistoricalEvent,
|
||||
MacroToolInput,
|
||||
MacroToolResult,
|
||||
} from './types'
|
||||
import { normalizeResponse, trimLines, uid, waitFor } from './utils'
|
||||
import { assert } from './utils/assert'
|
||||
|
||||
export { type PageAgentConfig }
|
||||
export { tool, type PageAgentTool } from './tools'
|
||||
|
||||
/**
|
||||
* AI agent for browser DOM automation.
|
||||
*
|
||||
* @remarks
|
||||
* ## Event System
|
||||
* - `statuschange` - Agent status transitions (idle → running → completed/error)
|
||||
* - `historychange` - History events updated (persistent, part of agent memory)
|
||||
* - `activity` - Real-time activity feedback (transient, for UI only)
|
||||
* - `dispose` - Agent cleanup triggered
|
||||
*
|
||||
* ## Information Streams
|
||||
* 1. **History Events** (`history` array)
|
||||
* - Persistent event stream that forms agent's memory
|
||||
* - Included in LLM context across steps
|
||||
* - Types: steps, observations, user takeovers, llm errors
|
||||
*
|
||||
* 2. **Activity Events** (via `activity` event)
|
||||
* - Transient UI feedback during task execution
|
||||
* - NOT included in LLM context
|
||||
* - Types: thinking, executing, executed, retrying, error
|
||||
*/
|
||||
export class PageAgentCore extends EventTarget {
|
||||
config: PageAgentConfig & { maxSteps: number }
|
||||
id = uid()
|
||||
tools: typeof tools
|
||||
disposed = false
|
||||
task = ''
|
||||
taskId = ''
|
||||
|
||||
/** Agent execution status */
|
||||
#status: AgentStatus = 'idle'
|
||||
|
||||
/**
|
||||
* Callback for when agent needs user input (ask_user tool)
|
||||
* If not set, ask_user tool will be disabled
|
||||
* @example onAskUser: (q) => window.prompt(q) || ''
|
||||
*/
|
||||
onAskUser?: (question: string) => Promise<string>
|
||||
|
||||
#llm: LLM
|
||||
#abortController = new AbortController()
|
||||
|
||||
/** 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 events */
|
||||
history: HistoricalEvent[] = []
|
||||
|
||||
constructor(config: PageAgentConfig & { pageController: PageController }) {
|
||||
super()
|
||||
|
||||
this.config = { ...config, maxSteps: config.maxSteps || DEFAULT_MAX_STEPS }
|
||||
|
||||
this.#llm = new LLM(this.config)
|
||||
this.tools = new Map(tools)
|
||||
this.pageController = config.pageController
|
||||
|
||||
// Listen to LLM retry events
|
||||
this.#llm.addEventListener('retry', (e) => {
|
||||
const { attempt, maxAttempts } = (e as CustomEvent).detail
|
||||
this.emitActivity({ type: 'retrying', attempt, maxAttempts })
|
||||
// Also push to history for panel rendering
|
||||
this.history.push({
|
||||
type: 'error',
|
||||
errorType: 'retry',
|
||||
message: `LLM retry attempt ${attempt} of ${maxAttempts}`,
|
||||
attempt,
|
||||
maxAttempts,
|
||||
})
|
||||
this.#emitHistoryChange()
|
||||
})
|
||||
this.#llm.addEventListener('error', (e) => {
|
||||
const { error } = (e as CustomEvent).detail
|
||||
const message = String(error)
|
||||
this.emitActivity({ type: 'error', message })
|
||||
// Also push to history for panel rendering
|
||||
this.history.push({
|
||||
type: 'error',
|
||||
errorType: 'error',
|
||||
message,
|
||||
})
|
||||
this.#emitHistoryChange()
|
||||
})
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
/** Get current agent status */
|
||||
get status(): AgentStatus {
|
||||
return this.#status
|
||||
}
|
||||
|
||||
/** Emit statuschange event */
|
||||
#emitStatusChange(): void {
|
||||
this.dispatchEvent(new Event('statuschange'))
|
||||
}
|
||||
|
||||
/** Emit historychange event */
|
||||
#emitHistoryChange(): void {
|
||||
this.dispatchEvent(new Event('historychange'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit activity event - for transient UI feedback
|
||||
* @param activity - Current agent activity
|
||||
*/
|
||||
emitActivity(activity: AgentActivity): void {
|
||||
this.dispatchEvent(new CustomEvent('activity', { detail: activity }))
|
||||
}
|
||||
|
||||
/** Update status and emit event */
|
||||
#setStatus(status: AgentStatus): void {
|
||||
if (this.#status !== status) {
|
||||
this.#status = status
|
||||
this.#emitStatusChange()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 })
|
||||
this.#emitHistoryChange()
|
||||
}
|
||||
|
||||
async execute(task: string): Promise<ExecutionResult> {
|
||||
if (!task) throw new Error('Task is required')
|
||||
this.task = task
|
||||
this.taskId = uid()
|
||||
|
||||
// Disable ask_user tool if onAskUser is not set
|
||||
if (!this.onAskUser) {
|
||||
this.tools.delete('ask_user')
|
||||
}
|
||||
|
||||
const onBeforeStep = this.config.onBeforeStep
|
||||
const onAfterStep = this.config.onAfterStep
|
||||
const onBeforeTask = this.config.onBeforeTask
|
||||
const onAfterTask = this.config.onAfterTask
|
||||
|
||||
await onBeforeTask?.(this)
|
||||
|
||||
// Show mask
|
||||
await this.pageController.showMask()
|
||||
|
||||
if (this.#abortController) {
|
||||
this.#abortController.abort()
|
||||
this.#abortController = new AbortController()
|
||||
}
|
||||
|
||||
this.history = []
|
||||
this.#setStatus('running')
|
||||
this.#emitHistoryChange()
|
||||
|
||||
// Reset states
|
||||
this.states = {
|
||||
totalWaitTime: 0,
|
||||
lastURL: '',
|
||||
}
|
||||
|
||||
try {
|
||||
let step = 0
|
||||
|
||||
while (true) {
|
||||
await this.#generateObservations(step)
|
||||
|
||||
await onBeforeStep?.(this, step)
|
||||
|
||||
console.group(`step: ${step}`)
|
||||
|
||||
// abort
|
||||
if (this.#abortController.signal.aborted) throw new Error('AbortError')
|
||||
|
||||
// Thinking
|
||||
console.log(chalk.blue('Thinking...'))
|
||||
this.emitActivity({ 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)
|
||||
this.#emitHistoryChange()
|
||||
|
||||
console.log(chalk.green('Step finished:'), actionName)
|
||||
console.groupEnd()
|
||||
|
||||
await onAfterStep?.(this, this.history)
|
||||
|
||||
step++
|
||||
if (step > this.config.maxSteps) {
|
||||
this.#onDone('Step count exceeded maximum limit', false)
|
||||
const result: ExecutionResult = {
|
||||
success: false,
|
||||
data: 'Step count exceeded maximum limit',
|
||||
history: this.history,
|
||||
}
|
||||
await onAfterTask?.(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?.(this, result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error('Task failed', error)
|
||||
const errorMessage = String(error)
|
||||
this.emitActivity({ type: 'error', message: errorMessage })
|
||||
this.#onDone(errorMessage, false)
|
||||
const result: ExecutionResult = {
|
||||
success: false,
|
||||
data: errorMessage,
|
||||
history: this.history,
|
||||
}
|
||||
await onAfterTask?.(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')
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Emit executing activity
|
||||
this.emitActivity({ type: 'executing', tool: toolName, input: 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)
|
||||
|
||||
// Emit executed activity
|
||||
this.emitActivity({
|
||||
type: 'executed',
|
||||
tool: toolName,
|
||||
input: toolInput,
|
||||
output: result,
|
||||
duration,
|
||||
})
|
||||
|
||||
// Reset wait time for non-wait tools
|
||||
if (toolName !== 'wait') {
|
||||
this.states.totalWaitTime = 0
|
||||
}
|
||||
|
||||
// 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
|
||||
await waitFor(500) // wait for page to stabilize
|
||||
}
|
||||
|
||||
// Warn about remaining steps
|
||||
const remaining = this.config.maxSteps - 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 ${this.config.maxSteps} 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`
|
||||
} else if (event.type === 'error') {
|
||||
// Error events are mainly for panel rendering, not included in LLM context
|
||||
// to avoid polluting the agent's reasoning with transient errors
|
||||
}
|
||||
}
|
||||
|
||||
prompt += '</agent_history>\n\n'
|
||||
|
||||
// <browser_state>
|
||||
|
||||
prompt += await this.#getBrowserState()
|
||||
|
||||
return trimLines(prompt)
|
||||
}
|
||||
|
||||
#onDone(text: string, success = true) {
|
||||
this.pageController.cleanUpHighlights()
|
||||
this.pageController.hideMask() // No await - fire and forget
|
||||
this.#setStatus(success ? 'completed' : 'error')
|
||||
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.history = []
|
||||
this.#abortController.abort(reason ?? 'PageAgent disposed')
|
||||
|
||||
// Emit dispose event for UI cleanup
|
||||
this.dispatchEvent(new Event('dispose'))
|
||||
|
||||
this.config.onDispose?.(this, reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const DEFAULT_MAX_STEPS = 20
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { LLMConfig } from '@page-agent/llms'
|
||||
import type { PageControllerConfig } from '@page-agent/page-controller'
|
||||
|
||||
import type { PageAgentCore } from '../PageAgentCore'
|
||||
import type { PageAgentTool } from '../tools'
|
||||
import type { ExecutionResult, HistoricalEvent } from '../types'
|
||||
|
||||
export type { LLMConfig }
|
||||
|
||||
/** Supported UI languages */
|
||||
export type SupportedLanguage = 'en-US' | 'zh-CN'
|
||||
|
||||
export interface AgentConfig {
|
||||
// theme?: 'light' | 'dark'
|
||||
language?: SupportedLanguage
|
||||
|
||||
/**
|
||||
* Maximum number of steps the agent can take per task.
|
||||
* @default 20
|
||||
*/
|
||||
maxSteps?: number
|
||||
|
||||
/**
|
||||
* Custom tools to extend PageAgent capabilities
|
||||
* @experimental
|
||||
* @note You can also override or remove internal tools by using the same name.
|
||||
* @see PageAgentTool
|
||||
*
|
||||
* @example
|
||||
* // override internal tool
|
||||
* import { tool } from 'page-agent'
|
||||
* const customTools = {
|
||||
* ask_user: tool({
|
||||
* description:
|
||||
* 'Ask the user or parent model a question and wait for their answer. Use this if you need more information or clarification.',
|
||||
* inputSchema: zod.object({
|
||||
* question: zod.string(),
|
||||
* }),
|
||||
* execute: async function (this: PageAgent, input) {
|
||||
* const answer = await do_some_thing(input.question)
|
||||
* return "✅ Received user answer: " + answer
|
||||
* },
|
||||
* })
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // remove internal tool
|
||||
* const customTools = {
|
||||
* ask_user: null // never ask user questions
|
||||
* }
|
||||
*/
|
||||
customTools?: Record<string, PageAgentTool | null>
|
||||
|
||||
/**
|
||||
* Instructions to guide the agent's behavior
|
||||
*/
|
||||
instructions?: {
|
||||
/**
|
||||
* Global system-level instructions, applied to all tasks
|
||||
*/
|
||||
system?: string
|
||||
|
||||
/**
|
||||
* Dynamic page-level instructions callback
|
||||
* Called before each step to get instructions for the current page
|
||||
* @param url - Current page URL (window.location.href)
|
||||
* @returns Instructions string, or undefined/null to skip
|
||||
*/
|
||||
getPageInstructions?: (url: string) => string | undefined | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hooks for task execution.
|
||||
* @experimental API may change in future versions.
|
||||
*
|
||||
* All hooks receive the agent instance as first parameter.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Called before each step execution.
|
||||
* @experimental
|
||||
* @param agent - The PageAgentCore instance
|
||||
* @param stepCount - Current step number (0-indexed)
|
||||
*/
|
||||
onBeforeStep?: (agent: PageAgentCore, stepCount: number) => Promise<void> | void
|
||||
|
||||
/**
|
||||
* Called after each step execution.
|
||||
* @experimental
|
||||
* @param agent - The PageAgentCore instance
|
||||
* @param history - Current history of events
|
||||
*/
|
||||
onAfterStep?: (agent: PageAgentCore, history: HistoricalEvent[]) => Promise<void> | void
|
||||
|
||||
/**
|
||||
* Called before task execution starts.
|
||||
* @experimental
|
||||
* @param agent - The PageAgentCore instance
|
||||
*/
|
||||
onBeforeTask?: (agent: PageAgentCore) => Promise<void> | void
|
||||
|
||||
/**
|
||||
* Called after task execution completes (success or failure).
|
||||
* @experimental
|
||||
* @param agent - The PageAgentCore instance
|
||||
* @param result - The execution result
|
||||
*/
|
||||
onAfterTask?: (agent: PageAgentCore, result: ExecutionResult) => Promise<void> | void
|
||||
|
||||
/**
|
||||
* Called when the agent is disposed.
|
||||
* @experimental
|
||||
* @note This hook can block the disposal process if it's async.
|
||||
* @param agent - The PageAgentCore instance
|
||||
* @param reason - Optional reason for disposal
|
||||
*/
|
||||
onDispose?: (agent: PageAgentCore, reason?: string) => void
|
||||
|
||||
// page behavior hooks
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Enable the experimental script execution tool that allows executing generated JavaScript code on the page.
|
||||
* @note Can cause unpredictable side effects.
|
||||
* @note May bypass some safe guards and data-masking mechanisms.
|
||||
*/
|
||||
experimentalScriptExecutionTool?: boolean
|
||||
|
||||
/**
|
||||
* Transform page content before sending to LLM.
|
||||
* Called after DOM extraction and simplification, before LLM invocation.
|
||||
* Use cases: inspect extraction results, modify page info, mask sensitive data.
|
||||
*
|
||||
* @param content - Simplified page content that will be sent to LLM
|
||||
* @returns Transformed content
|
||||
*
|
||||
* @example
|
||||
* // Mask phone numbers
|
||||
* transformPageContent: async (content) => {
|
||||
* return content.replace(/1[3-9]\d{9}/g, '***********')
|
||||
* }
|
||||
*/
|
||||
transformPageContent?: (content: string) => Promise<string> | string
|
||||
}
|
||||
|
||||
export type PageAgentConfig = LLMConfig & AgentConfig & PageControllerConfig
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.md?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
system_prompt.md
|
||||
@@ -92,7 +92,6 @@ Strictly follow these rules while using the browser and navigating the web:
|
||||
- User can be wrong. If the request of user is not achievable, inappropriate or you do not have enough information or tools to achieve it. Tell user to make a better request.
|
||||
- Webpage can be broken. All webpages or apps have bugs. Some bug will make it hard for your job. It's encouraged to tell user the problem of current page. Your feedbacks (including failing) are valuable for user.
|
||||
- Trying to hard can be harmful. Repeating some action back and forth or pushing for a complex procedure with little knowledge can cause unwanted result and harmful side-effects. User would rather you to complete the task with a fail.
|
||||
- If you are not clear about the request or steps. `ask_user` to clarify it.
|
||||
- If you do not have knowledge for the current webpage or task. You must require user to give specific instructions and detailed steps.
|
||||
</capability>
|
||||
|
||||
@@ -120,7 +119,7 @@ Exhibit the following reasoning patterns to successfully achieve the <user_reque
|
||||
- Analyze all relevant items in <agent_history> and <browser_state> to understand your state.
|
||||
- Explicitly judge success/failure/uncertainty of the last action. Never assume an action succeeded just because it appears to be executed in your last step in <agent_history>. If the expected change is missing, mark the last action as failed (or uncertain) and plan a recovery.
|
||||
- Analyze whether you are stuck, e.g. when you repeat the same actions multiple times without any progress. Then consider alternative approaches e.g. scrolling for more context or ask user for help.
|
||||
- `ask_user` for help if you have any difficulty. Users want to be kept in the loop.
|
||||
- Ask user for help if you have any difficulty. Keep user in the loop.
|
||||
- If you see information relevant to <user_request>, plan saving the information to memory.
|
||||
- Always reason about the <user_request>. Make sure to carefully analyze the specific steps and information required. E.g. specific filters, specific form fields, specific information to search. Make sure to always compare the current trajectory with the user request and think carefully if thats how the user requested it.
|
||||
</reasoning_rules>
|
||||
@@ -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 { PageAgentCore } from '../PageAgentCore'
|
||||
import { waitFor } from '../utils'
|
||||
|
||||
/**
|
||||
* Internal tool definition that has access to PageAgent `this` context
|
||||
@@ -29,7 +14,7 @@ export interface PageAgentTool<TParams = any> {
|
||||
// name: string
|
||||
description: string
|
||||
inputSchema: z.ZodType<TParams>
|
||||
execute: (this: PageAgent, args: TParams) => Promise<string>
|
||||
execute: (this: PageAgentCore, args: TParams) => Promise<string>
|
||||
}
|
||||
|
||||
export function tool<TParams>(options: PageAgentTool<TParams>): PageAgentTool<TParams> {
|
||||
@@ -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({
|
||||
@@ -63,7 +36,7 @@ tools.set(
|
||||
text: zod.string(),
|
||||
success: zod.boolean().default(true),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
execute: async function (this: PageAgentCore, input) {
|
||||
// @note main loop will handle this one
|
||||
// this.onDone(input.text, input.success)
|
||||
return Promise.resolve('Task completed')
|
||||
@@ -79,12 +52,21 @@ tools.set(
|
||||
inputSchema: zod.object({
|
||||
seconds: zod.number().min(1).max(10).default(1),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
const lastTimeUpdate = this.lastTimeUpdate
|
||||
execute: async function (this: PageAgentCore, input) {
|
||||
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.`
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -97,9 +79,12 @@ tools.set(
|
||||
inputSchema: zod.object({
|
||||
question: zod.string(),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
const answer = await this.panel.askUser(input.question)
|
||||
return `✅ Received user answer: ${answer}` + (await getSystemInfo())
|
||||
execute: async function (this: PageAgentCore, input) {
|
||||
if (!this.onAskUser) {
|
||||
throw new Error('ask_user tool requires onAskUser callback to be set')
|
||||
}
|
||||
const answer = await this.onAskUser(input.question)
|
||||
return `User answered: ${answer}`
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -111,17 +96,9 @@ tools.set(
|
||||
inputSchema: zod.object({
|
||||
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())
|
||||
execute: async function (this: PageAgentCore, input) {
|
||||
const result = await this.pageController.clickElement(input.index)
|
||||
return result.message
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -134,14 +111,9 @@ tools.set(
|
||||
index: zod.int().min(0),
|
||||
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())
|
||||
)
|
||||
execute: async function (this: PageAgentCore, input) {
|
||||
const result = await this.pageController.inputText(input.index, input.text)
|
||||
return result.message
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -155,14 +127,9 @@ tools.set(
|
||||
index: zod.int().min(0),
|
||||
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())
|
||||
)
|
||||
execute: async function (this: PageAgentCore, input) {
|
||||
const result = await this.pageController.selectOption(input.index, input.text)
|
||||
return result.message
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -181,14 +148,12 @@ tools.set(
|
||||
pixels: zod.number().int().min(0).optional(),
|
||||
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())
|
||||
execute: async function (this: PageAgentCore, input) {
|
||||
const result = await this.pageController.scroll({
|
||||
...input,
|
||||
numPages: input.num_pages,
|
||||
})
|
||||
return result.message
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -203,14 +168,24 @@ tools.set(
|
||||
pixels: zod.number().int().min(0),
|
||||
index: zod.number().int().min(0).optional(),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
const { right, pixels, index } = input
|
||||
execute: async function (this: PageAgentCore, 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: PageAgentCore, input) {
|
||||
const result = await this.pageController.executeJavascript(input.script)
|
||||
return result.message
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
}
|
||||
|
||||
/**
|
||||
* Error event (retry or error from LLM)
|
||||
*/
|
||||
export interface ErrorEvent {
|
||||
type: 'error'
|
||||
errorType: 'retry' | 'error'
|
||||
message: string
|
||||
attempt?: number
|
||||
maxAttempts?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all history events
|
||||
*/
|
||||
export type HistoricalEvent = AgentStep | ObservationEvent | UserTakeoverEvent | ErrorEvent
|
||||
|
||||
/**
|
||||
* Agent execution status
|
||||
*/
|
||||
export type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
|
||||
|
||||
/**
|
||||
* Agent activity - transient state for immediate UI feedback.
|
||||
*
|
||||
* Unlike historical events (which are persisted), activities are ephemeral
|
||||
* and represent "what the agent is doing right now". UI components should
|
||||
* listen to 'activity' events to show real-time feedback.
|
||||
*
|
||||
* Note: There is no 'idle' activity - absence of activity events means idle.
|
||||
*/
|
||||
export type AgentActivity =
|
||||
| { type: 'thinking' }
|
||||
| { type: 'executing'; tool: string; input: unknown }
|
||||
| { type: 'executed'; tool: string; input: unknown; output: string; duration: number }
|
||||
| { type: 'retrying'; attempt: number; maxAttempts: number }
|
||||
| { type: 'error'; message: string }
|
||||
|
||||
export interface ExecutionResult {
|
||||
success: boolean
|
||||
data: string
|
||||
history: HistoricalEvent[]
|
||||
}
|
||||
@@ -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,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 {
|
||||
@@ -62,12 +68,13 @@ export function randomID(existingIDs?: string[]): string {
|
||||
}
|
||||
|
||||
//
|
||||
const _global = globalThis as any
|
||||
|
||||
if (!window.__PAGE_AGENT_IDS__) {
|
||||
window.__PAGE_AGENT_IDS__ = []
|
||||
if (!_global.__PAGE_AGENT_IDS__) {
|
||||
_global.__PAGE_AGENT_IDS__ = []
|
||||
}
|
||||
|
||||
const ids = window.__PAGE_AGENT_IDS__
|
||||
const ids = _global.__PAGE_AGENT_IDS__
|
||||
|
||||
/**
|
||||
* Generate a random ID.
|
||||
@@ -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,22 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"],
|
||||
"references": [
|
||||
//
|
||||
{ "path": "../llms" },
|
||||
{ "path": "../page-controller" }
|
||||
]
|
||||
}
|
||||
@@ -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/PageAgentCore.ts'),
|
||||
name: 'PageAgentCore',
|
||||
fileName: 'page-agent-core',
|
||||
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,43 @@
|
||||
{
|
||||
"name": "@page-agent/llms",
|
||||
"version": "1.0.0-beta.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: (attempt: number) => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('retry', { detail: { attempt, maxAttempts: this.config.maxRetries } })
|
||||
)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
this.dispatchEvent(new CustomEvent('error', { detail: { error } }))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
settings: {
|
||||
maxRetries: number
|
||||
onRetry: (attempt: number) => void
|
||||
onError: (error: Error) => void
|
||||
}
|
||||
): Promise<T> {
|
||||
let attempt = 0
|
||||
let lastError: Error | null = null
|
||||
while (attempt <= settings.maxRetries) {
|
||||
if (attempt > 0) {
|
||||
settings.onRetry(attempt)
|
||||
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
|
||||
attempt++
|
||||
|
||||
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,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"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// @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,54 @@
|
||||
{
|
||||
"name": "page-agent",
|
||||
"private": false,
|
||||
"version": "1.0.0-beta.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 && npm run build:demo",
|
||||
"build:demo": "vite build --config vite.iife.config.js",
|
||||
"dev:demo": "concurrently \"vite build --config vite.iife.config.js --watch\" \"npx serve dist/iife -p 5174\"",
|
||||
"prepublishOnly": "node -e \"const fs=require('fs');['README.md','LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
||||
"postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"zod": "^4.3.5",
|
||||
"@page-agent/llms": "1.0.0-beta.0",
|
||||
"@page-agent/page-controller": "1.0.0-beta.0",
|
||||
"@page-agent/core": "1.0.0-beta.0",
|
||||
"@page-agent/ui": "1.0.0-beta.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright (C) 2025 Alibaba Group Holding Limited
|
||||
* All rights reserved.
|
||||
*/
|
||||
import { type PageAgentConfig, PageAgentCore, type PageAgentTool, tool } from '@page-agent/core'
|
||||
import { PageController } from '@page-agent/page-controller'
|
||||
import { Panel } from '@page-agent/ui'
|
||||
|
||||
export type { PageAgentConfig, PageAgentTool }
|
||||
export { tool }
|
||||
|
||||
export class PageAgent extends PageAgentCore {
|
||||
panel: Panel
|
||||
|
||||
constructor(config: PageAgentConfig) {
|
||||
const pageController = new PageController({
|
||||
...config,
|
||||
enableMask: config.enableMask ?? true,
|
||||
})
|
||||
|
||||
super({ ...config, pageController })
|
||||
|
||||
this.panel = new Panel(this, {
|
||||
language: config.language,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* IIFE demo entry - auto-initializes with built-in demo API for testing
|
||||
*/
|
||||
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
|
||||
|
||||
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
|
||||
setTimeout(() => {
|
||||
const currentScript = document.currentScript as HTMLScriptElement | null
|
||||
let config: PageAgentConfig
|
||||
|
||||
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'
|
||||
config = { model, baseURL, apiKey, language }
|
||||
} else {
|
||||
console.log('🚀 page-agent.js no current script detected, using default demo config')
|
||||
config = {
|
||||
model: import.meta.env.LLM_MODEL_NAME ? import.meta.env.LLM_MODEL_NAME : DEMO_MODEL,
|
||||
baseURL: import.meta.env.LLM_BASE_URL ? import.meta.env.LLM_BASE_URL : DEMO_BASE_URL,
|
||||
apiKey: import.meta.env.LLM_API_KEY ? import.meta.env.LLM_API_KEY : DEMO_API_KEY,
|
||||
}
|
||||
}
|
||||
|
||||
// Create agent
|
||||
window.pageAgent = new PageAgent(config)
|
||||
|
||||
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
|
||||
})
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
import type { PageAgent } from './PageAgent'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
pageAgent?: PageAgent
|
||||
PageAgent: typeof PageAgent
|
||||
}
|
||||
}
|
||||
@@ -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,26 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||
"noEmit": false,
|
||||
"allowImportingTsExtensions": false,
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist",
|
||||
"paths": {
|
||||
//
|
||||
"@page-agent/llms": ["../llms/src/index.ts"],
|
||||
"@page-agent/page-controller": ["../page-controller/src/PageController.ts"],
|
||||
"@page-agent/core": ["../core/src/PageAgentCore.ts"],
|
||||
"@page-agent/ui": ["../ui/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"],
|
||||
"references": [
|
||||
//
|
||||
{ "path": "../llms" },
|
||||
{ "path": "../page-controller" },
|
||||
{ "path": "../core" },
|
||||
{ "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,57 @@
|
||||
// @ts-check
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
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))
|
||||
|
||||
// Load .env from repo root
|
||||
dotenvConfig({ path: resolve(__dirname, '../../.env') })
|
||||
|
||||
// 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(({ mode }) => ({
|
||||
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/core': resolve(__dirname, '../core/src/PageAgentCore.ts'),
|
||||
'@page-agent/ui': resolve(__dirname, '../ui/src/index.ts'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/demo.ts'),
|
||||
name: 'PageAgent',
|
||||
fileName: () => `page-agent.demo.js`,
|
||||
formats: ['iife'],
|
||||
},
|
||||
outDir: resolve(__dirname, 'dist', 'iife'),
|
||||
cssCodeSplit: true,
|
||||
// minify: false,
|
||||
rollupOptions: {
|
||||
// output: {
|
||||
// // force use .js as extension
|
||||
// entryFileNames: 'page-agent.js',
|
||||
// },
|
||||
onwarn: function (message, handler) {
|
||||
if (message.code === 'EVAL') return
|
||||
handler(message)
|
||||
},
|
||||
},
|
||||
},
|
||||
define: {
|
||||
'import.meta.env.LLM_MODEL_NAME': JSON.stringify(process.env.LLM_MODEL_NAME),
|
||||
'import.meta.env.LLM_API_KEY': JSON.stringify(process.env.LLM_API_KEY),
|
||||
'import.meta.env.LLM_BASE_URL': JSON.stringify(process.env.LLM_BASE_URL),
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@page-agent/page-controller",
|
||||
"version": "1.0.0-beta.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,400 @@
|
||||
/**
|
||||
* 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 { patchReact } from './patches/react'
|
||||
|
||||
/**
|
||||
* 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: InstanceType<typeof import('./mask/SimulatorMask').SimulatorMask> | null = null
|
||||
private maskReady: Promise<void> | null = null
|
||||
|
||||
constructor(config: PageControllerConfig = {}) {
|
||||
super()
|
||||
|
||||
this.config = config
|
||||
|
||||
patchReact(this)
|
||||
|
||||
if (config.enableMask) {
|
||||
this.maskReady = this.initMask()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize mask asynchronously (dynamic import to avoid CSS loading in Node)
|
||||
*/
|
||||
private async initMask(): Promise<void> {
|
||||
const { SimulatorMask } = await import('./mask/SimulatorMask')
|
||||
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.
|
||||
*/
|
||||
async showMask(): Promise<void> {
|
||||
await this.maskReady
|
||||
this.mask?.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the visual mask overlay.
|
||||
* Only works if enableMask was set to true in config.
|
||||
*/
|
||||
async hideMask(): Promise<void> {
|
||||
await this.maskReady
|
||||
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) {
|
||||
@@ -297,12 +287,52 @@ export async function scrollVertically(
|
||||
(document.documentElement as HTMLElement)
|
||||
|
||||
if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
|
||||
// Page-level scroll
|
||||
const scrollBefore = window.scrollY
|
||||
const scrollMax = document.documentElement.scrollHeight - window.innerHeight
|
||||
|
||||
window.scrollBy(0, dy)
|
||||
return `✅ Scrolled page by ${dy}px.`
|
||||
|
||||
const scrollAfter = window.scrollY
|
||||
const scrolled = scrollAfter - scrollBefore
|
||||
|
||||
if (Math.abs(scrolled) < 1) {
|
||||
return dy > 0
|
||||
? `⚠️ Already at the bottom of the page, cannot scroll down further.`
|
||||
: `⚠️ Already at the top of the page, cannot scroll up further.`
|
||||
}
|
||||
|
||||
const reachedBottom = dy > 0 && scrollAfter >= scrollMax - 1
|
||||
const reachedTop = dy < 0 && scrollAfter <= 1
|
||||
|
||||
if (reachedBottom) return `✅ Scrolled page by ${scrolled}px. Reached the bottom of the page.`
|
||||
if (reachedTop) return `✅ Scrolled page by ${scrolled}px. Reached the top of the page.`
|
||||
return `✅ Scrolled page by ${scrolled}px.`
|
||||
} else {
|
||||
// Container scroll
|
||||
const scrollBefore = el!.scrollTop
|
||||
const scrollMax = el!.scrollHeight - el!.clientHeight
|
||||
|
||||
el!.scrollBy({ top: dy, behavior: 'smooth' })
|
||||
await waitFor(0.1) // Animation playback
|
||||
return `✅ Scrolled container (${el!.tagName}) by ${dy}px.`
|
||||
await waitFor(0.1)
|
||||
|
||||
const scrollAfter = el!.scrollTop
|
||||
const scrolled = scrollAfter - scrollBefore
|
||||
|
||||
if (Math.abs(scrolled) < 1) {
|
||||
return dy > 0
|
||||
? `⚠️ Already at the bottom of container (${el!.tagName}), cannot scroll down further.`
|
||||
: `⚠️ Already at the top of container (${el!.tagName}), cannot scroll up further.`
|
||||
}
|
||||
|
||||
const reachedBottom = dy > 0 && scrollAfter >= scrollMax - 1
|
||||
const reachedTop = dy < 0 && scrollAfter <= 1
|
||||
|
||||
if (reachedBottom)
|
||||
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the bottom.`
|
||||
if (reachedTop)
|
||||
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the top.`
|
||||
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px.`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,11 +450,52 @@ export async function scrollHorizontally(
|
||||
(document.documentElement as HTMLElement)
|
||||
|
||||
if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
|
||||
// Page-level scroll
|
||||
const scrollBefore = window.scrollX
|
||||
const scrollMax = document.documentElement.scrollWidth - window.innerWidth
|
||||
|
||||
window.scrollBy(dx, 0)
|
||||
return `✅ Scrolled page horizontally by ${dx}px`
|
||||
|
||||
const scrollAfter = window.scrollX
|
||||
const scrolled = scrollAfter - scrollBefore
|
||||
|
||||
if (Math.abs(scrolled) < 1) {
|
||||
return dx > 0
|
||||
? `⚠️ Already at the right edge of the page, cannot scroll right further.`
|
||||
: `⚠️ Already at the left edge of the page, cannot scroll left further.`
|
||||
}
|
||||
|
||||
const reachedRight = dx > 0 && scrollAfter >= scrollMax - 1
|
||||
const reachedLeft = dx < 0 && scrollAfter <= 1
|
||||
|
||||
if (reachedRight)
|
||||
return `✅ Scrolled page by ${scrolled}px. Reached the right edge of the page.`
|
||||
if (reachedLeft) return `✅ Scrolled page by ${scrolled}px. Reached the left edge of the page.`
|
||||
return `✅ Scrolled page horizontally by ${scrolled}px.`
|
||||
} else {
|
||||
// Container scroll
|
||||
const scrollBefore = el!.scrollLeft
|
||||
const scrollMax = el!.scrollWidth - el!.clientWidth
|
||||
|
||||
el!.scrollBy({ left: dx, behavior: 'smooth' })
|
||||
await waitFor(0.1) // Animation playback
|
||||
return `✅ Scrolled container (${el!.tagName}) horizontally by ${dx}px`
|
||||
await waitFor(0.1)
|
||||
|
||||
const scrollAfter = el!.scrollLeft
|
||||
const scrolled = scrollAfter - scrollBefore
|
||||
|
||||
if (Math.abs(scrolled) < 1) {
|
||||
return dx > 0
|
||||
? `⚠️ Already at the right edge of container (${el!.tagName}), cannot scroll right further.`
|
||||
: `⚠️ Already at the left edge of container (${el!.tagName}), cannot scroll left further.`
|
||||
}
|
||||
|
||||
const reachedRight = dx > 0 && scrollAfter >= scrollMax - 1
|
||||
const reachedLeft = dx < 0 && scrollAfter <= 1
|
||||
|
||||
if (reachedRight)
|
||||
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the right edge.`
|
||||
if (reachedLeft)
|
||||
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the left edge.`
|
||||
return `✅ Scrolled container (${el!.tagName}) horizontally by ${scrolled}px.`
|
||||
}
|
||||
}
|
||||
@@ -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,45 @@
|
||||
// @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'],
|
||||
onwarn: function (message, handler) {
|
||||
if (message.code === 'EVAL') return
|
||||
handler(message)
|
||||
},
|
||||
},
|
||||
minify: false,
|
||||
sourcemap: true,
|
||||
cssCodeSplit: true,
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@page-agent/ui",
|
||||
"version": "1.0.0-beta.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{}})\""
|
||||
}
|
||||
}
|
||||
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]
|
||||
}
|
||||
|
||||
// 类型安全的翻译方法
|
||||
@@ -4,17 +4,13 @@ const enUS = {
|
||||
panel: {
|
||||
ready: 'Ready',
|
||||
thinking: 'Thinking...',
|
||||
paused: 'Paused',
|
||||
taskInput: 'Enter new task, describe steps in detail, press Enter to submit',
|
||||
userAnswerPrompt: 'Please answer the question above, press Enter to submit',
|
||||
taskTerminated: 'Task terminated',
|
||||
taskCompleted: 'Task completed',
|
||||
continueExecution: 'Continue execution',
|
||||
userAnswer: 'User answer: {{input}}',
|
||||
question: 'Question: {{question}}',
|
||||
waitingPlaceholder: 'Waiting for task to start...',
|
||||
pause: 'Pause',
|
||||
continue: 'Continue',
|
||||
stop: 'Stop',
|
||||
expand: 'Expand history',
|
||||
collapse: 'Collapse history',
|
||||
@@ -26,6 +22,7 @@ const enUS = {
|
||||
selecting: 'Selecting option "{{text}}"...',
|
||||
scrolling: 'Scrolling page...',
|
||||
waiting: 'Waiting {{seconds}} seconds...',
|
||||
askingUser: 'Asking user...',
|
||||
done: 'Task done',
|
||||
clicked: '🖱️ Clicked element [{{index}}]',
|
||||
inputted: '⌨️ Inputted text "{{text}}"',
|
||||
@@ -54,17 +51,13 @@ const zhCN = {
|
||||
panel: {
|
||||
ready: '准备就绪',
|
||||
thinking: '正在思考...',
|
||||
paused: '暂停中,稍后',
|
||||
taskInput: '输入新任务,详细描述步骤,回车提交',
|
||||
userAnswerPrompt: '请回答上面问题,回车提交',
|
||||
taskTerminated: '任务已终止',
|
||||
taskCompleted: '任务结束',
|
||||
continueExecution: '继续执行',
|
||||
userAnswer: '用户回答: {{input}}',
|
||||
question: '询问: {{question}}',
|
||||
waitingPlaceholder: '等待任务开始...',
|
||||
pause: '暂停',
|
||||
continue: '继续',
|
||||
stop: '终止',
|
||||
expand: '展开历史',
|
||||
collapse: '收起历史',
|
||||
@@ -76,6 +69,7 @@ const zhCN = {
|
||||
selecting: '正在选择选项 "{{text}}"...',
|
||||
scrolling: '正在滚动页面...',
|
||||
waiting: '等待 {{seconds}} 秒...',
|
||||
askingUser: '正在询问用户...',
|
||||
done: '结束任务',
|
||||
clicked: '🖱️ 已点击元素 [{{index}}]',
|
||||
inputted: '⌨️ 已输入文本 "{{text}}"',
|
||||
@@ -0,0 +1,3 @@
|
||||
export { Panel, type PanelConfig } from './panel/Panel'
|
||||
export type { AgentActivity, PanelAgentAdapter } from './panel/types'
|
||||
export { I18n, type SupportedLanguage, type TranslationKey } from './i18n'
|
||||
@@ -2,4 +2,4 @@ This is the CSS implementation of ai-motion.
|
||||
|
||||
Easy to use but Terrible performance. Causing full screen glitching in some browsers.
|
||||
|
||||
Use it only in a small area.
|
||||
Use it only in a small area.
|
||||
@@ -33,8 +33,7 @@
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 480px) {
|
||||
width: calc(100vw - 40px);
|
||||
left: 20px;
|
||||
transform: none;
|
||||
--width: calc(100vw - 40px);
|
||||
}
|
||||
|
||||
.background {
|
||||
@@ -210,18 +209,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.pauseButton {
|
||||
font-weight: 600;
|
||||
&.paused {
|
||||
background: rgba(34, 197, 94, 0.2); /* 绿色背景表示可以继续 */
|
||||
color: rgb(34, 197, 94);
|
||||
|
||||
&:hover {
|
||||
background: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stopButton {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: rgb(255, 41, 41);
|
||||
@@ -365,6 +352,16 @@
|
||||
background: linear-gradient(135deg, rgba(255, 214, 0, 0.1), rgba(255, 214, 0, 0.05));
|
||||
}
|
||||
|
||||
&.observation {
|
||||
border-left-color: rgb(147, 51, 234);
|
||||
background: linear-gradient(135deg, rgba(147, 51, 234, 0.1), rgba(147, 51, 234, 0.05));
|
||||
}
|
||||
|
||||
&.question {
|
||||
border-left-color: rgb(255, 159, 67);
|
||||
background: linear-gradient(135deg, rgba(255, 159, 67, 0.15), rgba(255, 159, 67, 0.08));
|
||||
}
|
||||
|
||||
/* 突出显示 done 成功结果 */
|
||||
&.doneSuccess {
|
||||
background: linear-gradient(
|
||||
@@ -447,7 +444,7 @@
|
||||
|
||||
.historyContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
word-break: break-all;
|
||||
@@ -461,6 +458,12 @@
|
||||
line-height: 1;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.reflectionLines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.historyMeta {
|
||||
@@ -0,0 +1,649 @@
|
||||
import { I18n, type SupportedLanguage } from '../i18n'
|
||||
import { truncate } from '../utils'
|
||||
import { createCard, createReflectionLines, formatTime } from './cards'
|
||||
import type { AgentActivity, PanelAgentAdapter } from './types'
|
||||
|
||||
import styles from './Panel.module.css'
|
||||
|
||||
/**
|
||||
* Panel configuration
|
||||
*/
|
||||
export interface PanelConfig {
|
||||
language?: SupportedLanguage
|
||||
/**
|
||||
* Whether to prompt for next task after task completion
|
||||
* @default true
|
||||
*/
|
||||
promptForNextTask?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent control panel
|
||||
*
|
||||
* Architecture:
|
||||
* - History list: renders directly from agent.history (historical events)
|
||||
* - Header bar: shows activity events (transient state) and agent status
|
||||
*
|
||||
* This separation ensures data consistency - history is the single source of truth
|
||||
* for what has been done, while activity shows what is happening now.
|
||||
*/
|
||||
export class Panel {
|
||||
#wrapper: HTMLElement
|
||||
#indicator: HTMLElement
|
||||
#statusText: HTMLElement
|
||||
#historySection: HTMLElement
|
||||
#expandButton: HTMLElement
|
||||
#stopButton: HTMLElement
|
||||
#inputSection: HTMLElement
|
||||
#taskInput: HTMLInputElement
|
||||
|
||||
#agent: PanelAgentAdapter
|
||||
#config: PanelConfig
|
||||
#isExpanded = false
|
||||
#i18n: I18n
|
||||
#userAnswerResolver: ((input: string) => void) | null = null
|
||||
#isWaitingForUserAnswer: boolean = false
|
||||
#headerUpdateTimer: ReturnType<typeof setInterval> | null = null
|
||||
#pendingHeaderText: string | null = null
|
||||
#isAnimating = false
|
||||
|
||||
// Event handlers (bound for removal)
|
||||
#onStatusChange = () => this.#handleStatusChange()
|
||||
#onHistoryChange = () => this.#handleHistoryChange()
|
||||
#onActivity = (e: Event) => this.#handleActivity((e as CustomEvent<AgentActivity>).detail)
|
||||
#onAgentDispose = () => this.dispose()
|
||||
|
||||
get wrapper(): HTMLElement {
|
||||
return this.#wrapper
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Panel bound to an agent
|
||||
* @param agent - Agent instance that implements PanelAgentAdapter
|
||||
* @param config - Optional panel configuration
|
||||
*/
|
||||
constructor(agent: PanelAgentAdapter, config: PanelConfig = {}) {
|
||||
this.#agent = agent
|
||||
this.#config = config
|
||||
this.#i18n = new I18n(config.language ?? 'en-US')
|
||||
|
||||
// Set up askUser callback on agent
|
||||
this.#agent.onAskUser = (question) => this.#askUser(question)
|
||||
|
||||
// Create UI elements
|
||||
this.#wrapper = this.#createWrapper()
|
||||
this.#indicator = this.#wrapper.querySelector(`.${styles.indicator}`)!
|
||||
this.#statusText = this.#wrapper.querySelector(`.${styles.statusText}`)!
|
||||
this.#historySection = this.#wrapper.querySelector(`.${styles.historySection}`)!
|
||||
this.#expandButton = this.#wrapper.querySelector(`.${styles.expandButton}`)!
|
||||
this.#stopButton = this.#wrapper.querySelector(`.${styles.stopButton}`)!
|
||||
this.#inputSection = this.#wrapper.querySelector(`.${styles.inputSectionWrapper}`)!
|
||||
this.#taskInput = this.#wrapper.querySelector(`.${styles.taskInput}`)!
|
||||
|
||||
// Listen to agent events
|
||||
this.#agent.addEventListener('statuschange', this.#onStatusChange)
|
||||
this.#agent.addEventListener('historychange', this.#onHistoryChange)
|
||||
this.#agent.addEventListener('activity', this.#onActivity)
|
||||
this.#agent.addEventListener('dispose', this.#onAgentDispose)
|
||||
|
||||
this.#setupEventListeners()
|
||||
this.#startHeaderUpdateLoop()
|
||||
|
||||
this.#showInputArea()
|
||||
|
||||
this.hide() // Start hidden
|
||||
}
|
||||
|
||||
// ========== Agent event handlers ==========
|
||||
|
||||
/** Handle agent status change */
|
||||
#handleStatusChange(): void {
|
||||
const status = this.#agent.status
|
||||
|
||||
// Map agent status to UI indicator type
|
||||
const indicatorType =
|
||||
status === 'running' ? 'thinking' : status === 'idle' ? 'thinking' : status
|
||||
this.#updateStatusIndicator(indicatorType)
|
||||
|
||||
// Show/hide based on status
|
||||
if (status === 'running') {
|
||||
this.show()
|
||||
this.#hideInputArea() // Hide input while running
|
||||
}
|
||||
|
||||
// Handle completion
|
||||
if (status === 'completed' || status === 'error') {
|
||||
if (!this.#isExpanded) {
|
||||
this.#expand()
|
||||
}
|
||||
if (this.#shouldShowInputArea()) {
|
||||
this.#showInputArea()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle agent history change - re-render history list from agent.history */
|
||||
#handleHistoryChange(): void {
|
||||
this.#renderHistory()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle agent activity - transient state for immediate UI feedback
|
||||
* Activity events are NOT persisted in history, only used for header bar updates
|
||||
*/
|
||||
#handleActivity(activity: AgentActivity): void {
|
||||
switch (activity.type) {
|
||||
case 'thinking':
|
||||
this.#pendingHeaderText = this.#i18n.t('ui.panel.thinking')
|
||||
this.#updateStatusIndicator('thinking')
|
||||
break
|
||||
|
||||
case 'executing':
|
||||
this.#pendingHeaderText = this.#getToolExecutingText(activity.tool, activity.input)
|
||||
this.#updateStatusIndicator('executing')
|
||||
break
|
||||
|
||||
case 'executed':
|
||||
this.#pendingHeaderText = truncate(activity.output, 50)
|
||||
break
|
||||
|
||||
case 'retrying':
|
||||
this.#pendingHeaderText = `Retrying (${activity.attempt}/${activity.maxAttempts})`
|
||||
this.#updateStatusIndicator('retrying')
|
||||
break
|
||||
|
||||
case 'error':
|
||||
this.#pendingHeaderText = truncate(activity.message, 50)
|
||||
this.#updateStatusIndicator('error')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for user input (internal, called by agent via onAskUser)
|
||||
*/
|
||||
#askUser(question: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
// Set `waiting for user answer` state
|
||||
this.#isWaitingForUserAnswer = true
|
||||
this.#userAnswerResolver = resolve
|
||||
|
||||
// Expand history panel
|
||||
if (!this.#isExpanded) {
|
||||
this.#expand()
|
||||
}
|
||||
|
||||
// Add temporary question card so user can see the full question
|
||||
const tempCard = document.createElement('div')
|
||||
tempCard.innerHTML = createCard({
|
||||
icon: '❓',
|
||||
content: `Question: ${question}`,
|
||||
meta: formatTime(this.#config.language ?? 'en-US'),
|
||||
type: 'question',
|
||||
})
|
||||
const cardElement = tempCard.firstElementChild as HTMLElement
|
||||
cardElement.setAttribute('data-temp-card', 'true')
|
||||
this.#historySection.appendChild(cardElement)
|
||||
this.#scrollToBottom()
|
||||
|
||||
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.#statusText.textContent = this.#i18n.t('ui.panel.ready')
|
||||
this.#updateStatusIndicator('thinking')
|
||||
this.#renderHistory()
|
||||
this.#collapse()
|
||||
// Reset user input state
|
||||
this.#isWaitingForUserAnswer = false
|
||||
this.#userAnswerResolver = null
|
||||
// Show input area
|
||||
this.#showInputArea()
|
||||
}
|
||||
|
||||
expand(): void {
|
||||
this.#expand()
|
||||
}
|
||||
|
||||
collapse(): void {
|
||||
this.#collapse()
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose panel and clean up event listeners
|
||||
*/
|
||||
dispose(): void {
|
||||
// Remove agent event listeners
|
||||
this.#agent.removeEventListener('statuschange', this.#onStatusChange)
|
||||
this.#agent.removeEventListener('historychange', this.#onHistoryChange)
|
||||
this.#agent.removeEventListener('activity', this.#onActivity)
|
||||
this.#agent.removeEventListener('dispose', this.#onAgentDispose)
|
||||
|
||||
// Clean up UI
|
||||
this.#isWaitingForUserAnswer = false
|
||||
this.#stopHeaderUpdateLoop()
|
||||
this.wrapper.remove()
|
||||
}
|
||||
|
||||
// ========== Private methods ==========
|
||||
|
||||
#getToolExecutingText(toolName: string, args: unknown): string {
|
||||
const a = args as Record<string, string | number>
|
||||
switch (toolName) {
|
||||
case 'click_element_by_index':
|
||||
return this.#i18n.t('ui.tools.clicking', { index: a.index })
|
||||
case 'input_text':
|
||||
return this.#i18n.t('ui.tools.inputting', { index: a.index })
|
||||
case 'select_dropdown_option':
|
||||
return this.#i18n.t('ui.tools.selecting', { text: a.text })
|
||||
case 'scroll':
|
||||
return this.#i18n.t('ui.tools.scrolling')
|
||||
case 'wait':
|
||||
return this.#i18n.t('ui.tools.waiting', { seconds: a.seconds })
|
||||
case 'ask_user':
|
||||
return this.#i18n.t('ui.tools.askingUser')
|
||||
case 'done':
|
||||
return this.#i18n.t('ui.tools.done')
|
||||
default:
|
||||
return this.#i18n.t('ui.tools.executing', { toolName })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop Agent
|
||||
*/
|
||||
#stopAgent(): void {
|
||||
this.#agent.dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit task
|
||||
*/
|
||||
#submitTask() {
|
||||
const input = this.#taskInput.value.trim()
|
||||
if (!input) return
|
||||
|
||||
// Hide input area
|
||||
this.#hideInputArea()
|
||||
|
||||
if (this.#isWaitingForUserAnswer) {
|
||||
// Handle user input mode
|
||||
this.#handleUserAnswer(input)
|
||||
} else {
|
||||
// Execute task via agent
|
||||
this.#agent.execute(input)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle user answer
|
||||
*/
|
||||
#handleUserAnswer(input: string): void {
|
||||
// Remove temporary question cards (only direct children for safety)
|
||||
Array.from(this.#historySection.children).forEach((child) => {
|
||||
if (child.getAttribute('data-temp-card') === 'true') {
|
||||
child.remove()
|
||||
}
|
||||
})
|
||||
|
||||
// Reset state
|
||||
this.#isWaitingForUserAnswer = false
|
||||
|
||||
// Call resolver to return user input
|
||||
if (this.#userAnswerResolver) {
|
||||
this.#userAnswerResolver(input)
|
||||
this.#userAnswerResolver = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show input area
|
||||
*/
|
||||
#showInputArea(placeholder?: string): void {
|
||||
// Clear input field
|
||||
this.#taskInput.value = ''
|
||||
this.#taskInput.placeholder = placeholder || this.#i18n.t('ui.panel.taskInput')
|
||||
this.#inputSection.classList.remove(styles.hidden)
|
||||
// Focus on input field
|
||||
setTimeout(() => {
|
||||
this.#taskInput.focus()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide input area
|
||||
*/
|
||||
#hideInputArea(): void {
|
||||
this.#inputSection.classList.add(styles.hidden)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if input area should be shown
|
||||
*/
|
||||
#shouldShowInputArea(): boolean {
|
||||
// Always show input area if waiting for user input
|
||||
if (this.#isWaitingForUserAnswer) return true
|
||||
|
||||
const history = this.#agent.history
|
||||
if (history.length === 0) {
|
||||
return true // Initial state
|
||||
}
|
||||
|
||||
const status = this.#agent.status
|
||||
const isTaskEnded = status === 'completed' || status === 'error'
|
||||
|
||||
// Only show input area after task completion if configured to do so
|
||||
if (isTaskEnded) {
|
||||
return this.#config.promptForNextTask ?? true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
#createWrapper(): HTMLElement {
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.id = 'page-agent-runtime_agent-panel'
|
||||
wrapper.className = styles.wrapper
|
||||
wrapper.setAttribute('data-browser-use-ignore', 'true')
|
||||
|
||||
wrapper.innerHTML = `
|
||||
<div class="${styles.background}"></div>
|
||||
<div class="${styles.historySectionWrapper}">
|
||||
<div class="${styles.historySection}">
|
||||
<div class="${styles.historyItem}">
|
||||
<div class="${styles.historyContent}">
|
||||
<span class="${styles.statusIcon}">🧠</span>
|
||||
<span>${this.#i18n.t('ui.panel.waitingPlaceholder')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="${styles.header}">
|
||||
<div class="${styles.statusSection}">
|
||||
<div class="${styles.indicator} ${styles.thinking}"></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.#i18n.t('ui.panel.expand')}">
|
||||
▼
|
||||
</button>
|
||||
<button class="${styles.controlButton} ${styles.stopButton}" title="${this.#i18n.t('ui.panel.stop')}">
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="${styles.inputSectionWrapper} ${styles.hidden}">
|
||||
<div class="${styles.inputSection}">
|
||||
<input
|
||||
type="text"
|
||||
class="${styles.taskInput}"
|
||||
maxlength="200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
document.body.appendChild(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
#setupEventListeners(): void {
|
||||
// Click header area to expand/collapse
|
||||
const header = this.wrapper.querySelector(`.${styles.header}`)!
|
||||
header.addEventListener('click', (e) => {
|
||||
// Don't trigger expand/collapse if clicking on buttons
|
||||
if ((e.target as HTMLElement).closest(`.${styles.controlButton}`)) {
|
||||
return
|
||||
}
|
||||
this.#toggle()
|
||||
})
|
||||
|
||||
// Expand button
|
||||
this.#expandButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
this.#toggle()
|
||||
})
|
||||
|
||||
// Stop button
|
||||
this.#stopButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
this.#stopAgent()
|
||||
})
|
||||
|
||||
// Submit on Enter key in input field
|
||||
this.#taskInput.addEventListener('keydown', (e) => {
|
||||
if (e.isComposing) return // Ignore IME composition keys
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
this.#submitTask()
|
||||
}
|
||||
})
|
||||
|
||||
// Prevent input area click event bubbling
|
||||
this.#inputSection.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
})
|
||||
}
|
||||
|
||||
#toggle(): void {
|
||||
if (this.#isExpanded) {
|
||||
this.#collapse()
|
||||
} else {
|
||||
this.#expand()
|
||||
}
|
||||
}
|
||||
|
||||
#expand(): void {
|
||||
this.#isExpanded = true
|
||||
this.wrapper.classList.add(styles.expanded)
|
||||
this.#expandButton.textContent = '▲'
|
||||
}
|
||||
|
||||
#collapse(): void {
|
||||
this.#isExpanded = false
|
||||
this.wrapper.classList.remove(styles.expanded)
|
||||
this.#expandButton.textContent = '▼'
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic header update loop
|
||||
*/
|
||||
#startHeaderUpdateLoop(): void {
|
||||
// Check every 450ms (same as total animation duration)
|
||||
this.#headerUpdateTimer = setInterval(() => {
|
||||
this.#checkAndUpdateHeader()
|
||||
}, 450)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic header update loop
|
||||
*/
|
||||
#stopHeaderUpdateLoop(): void {
|
||||
if (this.#headerUpdateTimer) {
|
||||
clearInterval(this.#headerUpdateTimer)
|
||||
this.#headerUpdateTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if header needs update and trigger animation if not currently animating
|
||||
*/
|
||||
#checkAndUpdateHeader(): void {
|
||||
// If no pending text or currently animating, skip
|
||||
if (!this.#pendingHeaderText || this.#isAnimating) {
|
||||
return
|
||||
}
|
||||
|
||||
// If text is already displayed, clear pending and skip
|
||||
if (this.#statusText.textContent === this.#pendingHeaderText) {
|
||||
this.#pendingHeaderText = null
|
||||
return
|
||||
}
|
||||
|
||||
// Start animation
|
||||
const textToShow = this.#pendingHeaderText
|
||||
this.#pendingHeaderText = null
|
||||
this.#animateTextChange(textToShow)
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate text change with fade out/in effect
|
||||
*/
|
||||
#animateTextChange(newText: string): void {
|
||||
this.#isAnimating = true
|
||||
|
||||
// Fade out current text
|
||||
this.#statusText.classList.add(styles.fadeOut)
|
||||
|
||||
setTimeout(() => {
|
||||
// Update text content
|
||||
this.#statusText.textContent = newText
|
||||
|
||||
// Fade in new text
|
||||
this.#statusText.classList.remove(styles.fadeOut)
|
||||
this.#statusText.classList.add(styles.fadeIn)
|
||||
|
||||
setTimeout(() => {
|
||||
this.#statusText.classList.remove(styles.fadeIn)
|
||||
this.#isAnimating = false
|
||||
}, 300)
|
||||
}, 150) // Half the duration of fade out animation
|
||||
}
|
||||
|
||||
#updateStatusIndicator(
|
||||
type: 'thinking' | 'executing' | 'executed' | 'retrying' | 'completed' | 'error'
|
||||
): void {
|
||||
// Clear all status classes
|
||||
this.#indicator.className = styles.indicator
|
||||
|
||||
// Add corresponding status class
|
||||
this.#indicator.classList.add(styles[type])
|
||||
}
|
||||
|
||||
#scrollToBottom(): void {
|
||||
// Execute in next event loop to ensure DOM update completion
|
||||
setTimeout(() => {
|
||||
this.#historySection.scrollTop = this.#historySection.scrollHeight
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render history directly from agent.history
|
||||
*
|
||||
* Renders:
|
||||
* 1. Task (first item, from agent.task)
|
||||
* 2. Reflection cards (evaluation, memory, next_goal)
|
||||
* 3. Tool execution with output
|
||||
* 4. Observations
|
||||
*/
|
||||
#renderHistory(): void {
|
||||
const items: string[] = []
|
||||
|
||||
// 1. Task card (always first)
|
||||
const task = this.#agent.task
|
||||
if (task) {
|
||||
items.push(this.#createTaskCard(task))
|
||||
}
|
||||
|
||||
// 2. Render each history event
|
||||
const history = this.#agent.history
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
const event = history[i]
|
||||
items.push(...this.#createHistoryCards(event, i + 1))
|
||||
}
|
||||
|
||||
this.#historySection.innerHTML = items.join('')
|
||||
this.#scrollToBottom()
|
||||
}
|
||||
|
||||
#createTaskCard(task: string): string {
|
||||
return createCard({ icon: '🎯', content: task, type: 'input' })
|
||||
}
|
||||
|
||||
/** Create cards for a history event */
|
||||
#createHistoryCards(event: PanelAgentAdapter['history'][number], stepNumber: number): string[] {
|
||||
const cards: string[] = []
|
||||
const time = formatTime(this.#config.language ?? 'en-US')
|
||||
const meta = this.#i18n.t('ui.panel.step', {
|
||||
number: stepNumber.toString(),
|
||||
time,
|
||||
duration: '',
|
||||
})
|
||||
|
||||
if (event.type === 'step') {
|
||||
// Reflection card
|
||||
if (event.reflection) {
|
||||
const lines = createReflectionLines(event.reflection)
|
||||
if (lines.length > 0) {
|
||||
cards.push(createCard({ icon: '🧠', content: lines, meta }))
|
||||
}
|
||||
}
|
||||
|
||||
// Action card
|
||||
const action = event.action
|
||||
if (action) {
|
||||
cards.push(...this.#createActionCards(action, meta))
|
||||
}
|
||||
} else if (event.type === 'observation') {
|
||||
cards.push(
|
||||
createCard({ icon: '👁️', content: event.content || '', meta, type: 'observation' })
|
||||
)
|
||||
} else if (event.type === 'user_takeover') {
|
||||
cards.push(createCard({ icon: '👤', content: 'User takeover', meta, type: 'input' }))
|
||||
}
|
||||
|
||||
return cards
|
||||
}
|
||||
|
||||
/** Create cards for an action */
|
||||
#createActionCards(
|
||||
action: { name: string; input: unknown; output: string },
|
||||
meta: string
|
||||
): string[] {
|
||||
const cards: string[] = []
|
||||
|
||||
if (action.name === 'done') {
|
||||
const input = action.input as { text?: string }
|
||||
const text = input.text || action.output || ''
|
||||
if (text) {
|
||||
cards.push(createCard({ icon: '🤖', content: text, meta, type: 'output' }))
|
||||
}
|
||||
} else if (action.name === 'ask_user') {
|
||||
const input = action.input as { question?: string }
|
||||
const answer = action.output.replace(/^User answered:\s*/i, '')
|
||||
cards.push(
|
||||
createCard({
|
||||
icon: '❓',
|
||||
content: `Question: ${input.question || ''}`,
|
||||
meta,
|
||||
type: 'question',
|
||||
})
|
||||
)
|
||||
cards.push(createCard({ icon: '💬', content: `Answer: ${answer}`, meta, type: 'input' }))
|
||||
} else {
|
||||
const toolText = this.#getToolExecutingText(action.name, action.input)
|
||||
cards.push(createCard({ icon: '🔨', content: toolText, meta }))
|
||||
if (action.output?.length > 0) {
|
||||
cards.push(createCard({ icon: '🔨', content: action.output, meta, type: 'output' }))
|
||||
}
|
||||
}
|
||||
|
||||
return cards
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Card HTML generation utilities for Panel
|
||||
*/
|
||||
import { escapeHtml } from '../utils'
|
||||
|
||||
import styles from './Panel.module.css'
|
||||
|
||||
type CardType = 'default' | 'input' | 'output' | 'question' | 'observation'
|
||||
|
||||
interface CardOptions {
|
||||
icon: string
|
||||
content: string | string[]
|
||||
meta?: string
|
||||
type?: CardType
|
||||
}
|
||||
|
||||
/** Create a single history card */
|
||||
export function createCard({ icon, content, meta, type }: CardOptions): string {
|
||||
const typeClass = type ? styles[type] : ''
|
||||
const contentHtml = Array.isArray(content)
|
||||
? `<div class="${styles.reflectionLines}">${content.join('')}</div>`
|
||||
: `<span>${escapeHtml(content)}</span>`
|
||||
|
||||
return `
|
||||
<div class="${styles.historyItem} ${typeClass}">
|
||||
<div class="${styles.historyContent}">
|
||||
<span class="${styles.statusIcon}">${icon}</span>
|
||||
${contentHtml}
|
||||
</div>
|
||||
${meta ? `<div class="${styles.historyMeta}">${meta}</div>` : ''}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
/** Format timestamp for cards */
|
||||
export function formatTime(locale: string = 'en-US'): string {
|
||||
return new Date().toLocaleTimeString(locale, {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/** Create reflection lines from reflection object */
|
||||
export function createReflectionLines(reflection: {
|
||||
evaluation_previous_goal?: string
|
||||
memory?: string
|
||||
next_goal?: string
|
||||
}): string[] {
|
||||
const lines: string[] = []
|
||||
if (reflection.evaluation_previous_goal) {
|
||||
lines.push(`<div>🔍 ${escapeHtml(reflection.evaluation_previous_goal)}</div>`)
|
||||
}
|
||||
if (reflection.memory) {
|
||||
lines.push(`<div>💾 ${escapeHtml(reflection.memory)}</div>`)
|
||||
}
|
||||
if (reflection.next_goal) {
|
||||
lines.push(`<div>🎯 ${escapeHtml(reflection.next_goal)}</div>`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Agent activity - transient state for immediate UI feedback.
|
||||
*
|
||||
* Unlike historical events (which are persisted), activities are ephemeral
|
||||
* and represent "what the agent is doing right now". UI components should
|
||||
* listen to 'activity' events to show real-time feedback.
|
||||
*
|
||||
* Note: There is no 'idle' activity - absence of activity events means idle.
|
||||
*
|
||||
* Events dispatched: CustomEvent<AgentActivity>
|
||||
*/
|
||||
export type AgentActivity =
|
||||
| { type: 'thinking' }
|
||||
| { type: 'executing'; tool: string; input: unknown }
|
||||
| { type: 'executed'; tool: string; input: unknown; output: string; duration: number }
|
||||
| { type: 'retrying'; attempt: number; maxAttempts: number }
|
||||
| { type: 'error'; message: string }
|
||||
|
||||
/**
|
||||
* Minimal interface that Panel expects from an agent.
|
||||
* Panel does not depend on PageAgent directly - it only requires this interface.
|
||||
* This enables decoupling and allows any agent implementation to work with Panel.
|
||||
*
|
||||
* Events:
|
||||
* - 'statuschange': Agent status changed (idle/running/completed/error)
|
||||
* - 'historychange': Historical events updated (persisted)
|
||||
* - 'activity': Transient activity for immediate UI feedback (thinking/executing/etc)
|
||||
* - 'dispose': Agent is being disposed
|
||||
*/
|
||||
export interface PanelAgentAdapter extends EventTarget {
|
||||
/** Current agent status */
|
||||
readonly status: 'idle' | 'running' | 'completed' | 'error'
|
||||
|
||||
/** History of agent events */
|
||||
readonly history: readonly {
|
||||
type: 'step' | 'observation' | 'user_takeover' | 'error'
|
||||
/** For 'step' type */
|
||||
reflection?: {
|
||||
evaluation_previous_goal?: string
|
||||
memory?: string
|
||||
next_goal?: string
|
||||
}
|
||||
/** For 'step' type */
|
||||
action?: {
|
||||
name: string
|
||||
input: unknown
|
||||
output: string
|
||||
}
|
||||
/** For 'observation' type */
|
||||
content?: string
|
||||
}[]
|
||||
|
||||
/** Current task being executed */
|
||||
readonly task: string
|
||||
|
||||
/**
|
||||
* Callback for when agent needs user input.
|
||||
* Panel will set this to handle user questions via its UI.
|
||||
*/
|
||||
onAskUser?: (question: string) => Promise<string>
|
||||
|
||||
/** Execute a task */
|
||||
execute(task: string): Promise<unknown>
|
||||
|
||||
/** Dispose the agent */
|
||||
dispose(): void
|
||||
}
|
||||
@@ -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,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/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,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": "1.0.0-beta.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"
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function BetaNotice() {
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
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">
|
||||
<h3 className="text-sm font-medium text-orange-800 dark:text-orange-200 mb-1">
|
||||
Beta 阶段
|
||||
{t('beta_notice.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-orange-700 dark:text-orange-300">
|
||||
当前功能未完成,接口可能随时变更。正式版本发布前请勿用于生产环境。
|
||||
</p>
|
||||
<p className="text-sm text-orange-700 dark:text-orange-300">{t('beta_notice.content')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { siGithub } from 'simple-icons'
|
||||
|
||||
export default function Footer() {
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
return (
|
||||
<footer
|
||||
className="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700"
|
||||
role="contentinfo"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-6 py-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm">{t('footer.copyright')}</p>
|
||||
<div className="flex items-center space-x-6">
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-200"
|
||||
aria-label={t('footer.github_label')}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user