Compare commits
212 Commits
v0.2.2
...
EXT_v0.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bd4a47d35 | |||
| 71ca554108 | |||
| 648a0c1bda | |||
| 1a0422f99d | |||
| 2f72c677a0 | |||
| 18019d390a | |||
| bb3605b3df | |||
| 2b3bb8ab9e | |||
| 7a09bc559d | |||
| a834d37f28 | |||
| 0c3be817c9 | |||
| b8999fdb7c | |||
| 9ba95e96b6 | |||
| 4459026297 | |||
| 98d3016395 | |||
| 15ea488719 | |||
| 5b2a11c0b2 | |||
| c5c6340eb6 | |||
| 74aea50875 | |||
| f86bb03745 | |||
| f6b4447ef7 | |||
| d8ec4cf1ab | |||
| da440f63c3 | |||
| 622fbff790 | |||
| 68c944c3a4 | |||
| 6cd3e3726c | |||
| d11a2593b3 | |||
| 5e936c35d7 | |||
| 97cdb1e1cd | |||
| 7470c1987b | |||
| b767f10a85 | |||
| 8a03391c95 | |||
| 5cfaa292d3 | |||
| 4e87940127 | |||
| 5d77990187 | |||
| b0e07d5180 | |||
| cf4eeb9481 | |||
| d67ca95925 | |||
| f67ec5a2c2 | |||
| 0f3ae38cb3 | |||
| d6323f1488 | |||
| ef2d115742 | |||
| ff75aebc20 | |||
| 04d51c566d | |||
| 142231a16c | |||
| d712ea7041 | |||
| 9d37a452e4 | |||
| 94ae80ff6a | |||
| d7b17facb6 | |||
| 501411b254 | |||
| f6394a04b1 | |||
| c80de93d3b | |||
| 0cad8a1159 | |||
| 77a771f0b6 | |||
| dd593f77e9 | |||
| 55ffbb8f08 | |||
| e425a3ed9f | |||
| 87c3fe36d2 | |||
| d64e144ab4 | |||
| 5c3e3d1066 | |||
| a243f158d7 | |||
| 96a51d3674 | |||
| 06f2f8be6c | |||
| bb68d218cc | |||
| 6ff2a032d6 | |||
| fdc3cf4e6d | |||
| 8efa8e18c1 | |||
| cdecf3cc3d | |||
| 89d6835a99 | |||
| 3bf616dde1 | |||
| f18b756390 | |||
| 969dd31db5 | |||
| c4bcad04bd | |||
| 4f170b674e | |||
| 5826ff47cd | |||
| 0bac51b67b | |||
| 8a7b9b39ce | |||
| 3cfc830d14 | |||
| 6141c0f669 | |||
| fa5ab9d567 | |||
| 2aa9c3b978 | |||
| f4eec86e10 | |||
| 60b401d86c | |||
| dad837e173 | |||
| efe1f33144 | |||
| 7a560341aa | |||
| 2024a417c6 | |||
| 9738545b2c | |||
| 299a5b2075 | |||
| 1f3e51eeb4 | |||
| e96dac28fb | |||
| 785a2d194e | |||
| 391e70dca4 | |||
| 8c43fe1409 | |||
| d861f7b7f2 | |||
| 3ffcd6482d | |||
| 53d3aaa67f | |||
| 5c4777fe7a | |||
| 4c7de54365 | |||
| 0cd04db40f | |||
| 2d1b2bbb85 | |||
| 23f1818503 | |||
| c1ae196bdc | |||
| c06742b8ed | |||
| 0df512add7 | |||
| 5fdb49fecb | |||
| af133470e1 | |||
| 4153db866f | |||
| fe1893a382 | |||
| b2260e9484 | |||
| 8c1c1ed9fe | |||
| fb2ffa1de3 | |||
| 6cbb4e99c4 | |||
| 34ce56b446 | |||
| 3fea74faa9 | |||
| 570c79623b | |||
| f0cb69ec7f | |||
| 23ec4602c2 | |||
| 7dcfad42f5 | |||
| 189e9f3d39 | |||
| 908ffae77c | |||
| 536e5076da | |||
| 81807e17ca | |||
| 9531fcff5a | |||
| 52454305fb | |||
| a18ebfd6c1 | |||
| 5402cdecc2 | |||
| 969c00b2d1 | |||
| 266eec1474 | |||
| 6944bedf85 | |||
| 5210a6edff | |||
| 897b919044 | |||
| 3a3bb0e58c | |||
| bc7ccf47ff | |||
| c781eba986 | |||
| efe131660a | |||
| 4200c78327 | |||
| f500445965 | |||
| a47dfef659 | |||
| 22516dec74 | |||
| 32a9973e49 | |||
| 1f8953c450 | |||
| d6a57f3e20 | |||
| ba7e79251f | |||
| b4d17aa907 | |||
| 1a0c533cb4 | |||
| 37332cbb9d | |||
| be85d63ef8 | |||
| db3b24a5ea | |||
| e0027f99c4 | |||
| b234868bc1 | |||
| 10ae34a677 | |||
| 2fb01b7315 | |||
| 5c80f2b002 | |||
| 28c128645e | |||
| 250cb47e44 | |||
| 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 |
@@ -58,4 +58,4 @@ body:
|
||||
- label: I have read the Code of Conduct. / 我已阅读行为准则。
|
||||
required: true
|
||||
validations:
|
||||
required: true
|
||||
required: true
|
||||
|
||||
@@ -44,4 +44,4 @@ body:
|
||||
- label: I have read the CODE_OF_CONDUCT.md and CONTRIBUTING.md. / 我已阅读行为准则。
|
||||
required: true
|
||||
validations:
|
||||
required: true
|
||||
required: true
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
id-token: write # Required for OIDC
|
||||
id-token: write # Required for OIDC
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
# Ensure npm 11.5.1 or later is installed
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -32,4 +32,12 @@ jobs:
|
||||
run: npm run build:libs
|
||||
|
||||
- name: Publish all public packages
|
||||
run: npm publish --workspaces --access public
|
||||
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
|
||||
|
||||
+14
-3
@@ -25,11 +25,22 @@ dist-ssr
|
||||
*.sw?
|
||||
.qoder
|
||||
|
||||
|
||||
#
|
||||
# env files
|
||||
.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
|
||||
packages/page-agent/LICENSE
|
||||
|
||||
# extension
|
||||
.output
|
||||
.wxt
|
||||
|
||||
# AI
|
||||
.agent
|
||||
.claude
|
||||
.cursor
|
||||
.gemini
|
||||
CLAUDE.md
|
||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint", "prettier.prettier-vscode"]
|
||||
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
||||
}
|
||||
|
||||
Vendored
+35
-27
@@ -1,29 +1,37 @@
|
||||
{
|
||||
"editor.fontLigatures": true,
|
||||
"cSpell.words": [
|
||||
"deepseek",
|
||||
"HITL",
|
||||
"innerhtml",
|
||||
"llms",
|
||||
"onwarn",
|
||||
"opensource",
|
||||
"qwen",
|
||||
"retryable",
|
||||
"shadcn",
|
||||
"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
|
||||
}
|
||||
"cSpell.words": [
|
||||
"deepseek",
|
||||
"historychange",
|
||||
"HITL",
|
||||
"innerhtml",
|
||||
"languagedetector",
|
||||
"llms",
|
||||
"magicui",
|
||||
"npmmirror",
|
||||
"onwarn",
|
||||
"opensource",
|
||||
"qwen",
|
||||
"retryable",
|
||||
"shadcn",
|
||||
"sidepanel",
|
||||
"statuschange",
|
||||
"wouter"
|
||||
],
|
||||
"files.exclude": {
|
||||
"packages/*/node_modules": true,
|
||||
},
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
|
||||
This is a **monorepo** with npm workspaces:
|
||||
|
||||
- **Core Library** (`packages/page-agent/`) - AI agent for browser DOM automation, published as `page-agent` on npm
|
||||
- **Page Agent** (`packages/page-agent/`) - Main entry with built-in UI Panel, published as `page-agent` on npm
|
||||
- **Extension** (`packages/extension/`) - Browser extension (WXT + React) 🚧 WIP
|
||||
- **Website** (`packages/website/`) - React docs and landing page. **When working on website, follow `packages/website/AGENTS.md`**
|
||||
|
||||
Internal packages:
|
||||
|
||||
- **CDN** (`packages/cdn/`) - IIFE builds for script tag usage (npm: `@page-agent/cdn`)
|
||||
- **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
|
||||
@@ -31,10 +32,11 @@ Simple monorepo solution: TypeScript references + Vite aliases. Update tsconfig
|
||||
|
||||
```
|
||||
packages/
|
||||
├── page-agent/ # npm: "page-agent" ⭐ MAIN
|
||||
├── cdn/ # npm: "@page-agent/cdn" (IIFE builds)
|
||||
├── 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
|
||||
├── extension/ # 🚧 WIP: Browser extension (WXT + React)
|
||||
├── page-controller/ # @page-agent/page-controller
|
||||
└── ui/ # @page-agent/ui
|
||||
```
|
||||
@@ -43,9 +45,10 @@ packages/
|
||||
|
||||
### Module Boundaries
|
||||
|
||||
- **Page Agent**: Core lib. Imports from `@page-agent/llms`, `@page-agent/page-controller`, `@page-agent/ui`
|
||||
- **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. 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
|
||||
@@ -71,34 +74,31 @@ const pageInfo = await this.pageController.getPageInfo()
|
||||
3. **LLM Processing**: AI returns action plans (page-agent)
|
||||
4. **Indexed Operations**: PageAgent calls PageController by element index
|
||||
|
||||
### CDN Builds (`packages/cdn/`)
|
||||
|
||||
Two IIFE builds for script tag usage:
|
||||
|
||||
| Build | File | Description |
|
||||
| ----- | -------------------- | -------------------------------- |
|
||||
| Demo | `page-agent.demo.js` | Auto-init with built-in test API |
|
||||
| Full | `page-agent.js` | Exposes `PageAgent` class only |
|
||||
|
||||
Demo build supports query params (e.g., `?model=gpt-4&lang=en-US`).
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
### Page Agent (`packages/page-agent/`)
|
||||
|
||||
| File | Description |
|
||||
| ------------------ | --------------------------------------- |
|
||||
| `src/PageAgent.ts` | ⭐ Main AI agent class |
|
||||
| `src/umd.ts` | CDN/UMD entry with auto-init |
|
||||
| `src/tools/` | Tool definitions calling PageController |
|
||||
| File | Description |
|
||||
| ------------------ | -------------------------------------------- |
|
||||
| `src/PageAgent.ts` | ⭐ Main class with UI, extends PageAgentCore |
|
||||
| `src/demo.ts` | IIFE demo entry (auto-init with demo API) |
|
||||
|
||||
### Core (`packages/core/`)
|
||||
|
||||
| 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 |
|
||||
|
||||
### LLMs (`packages/llms/`)
|
||||
|
||||
| File | Description |
|
||||
| ---------------------------- | ------------------------------------- |
|
||||
| `src/index.ts` | ⭐ LLM class with retry logic |
|
||||
| `src/types.ts` | MacroToolInput, AgentBrain, LLMConfig |
|
||||
| `src/OpenAILenientClient.ts` | OpenAI-compatible client |
|
||||
| File | Description |
|
||||
| --------------------- | ------------------------------------- |
|
||||
| `src/index.ts` | ⭐ LLM class with retry logic |
|
||||
| `src/types.ts` | MacroToolInput, AgentBrain, LLMConfig |
|
||||
| `src/OpenAIClient.ts` | OpenAI-compatible client |
|
||||
|
||||
### Page Controller (`packages/page-controller/`)
|
||||
|
||||
@@ -113,7 +113,7 @@ Demo build supports query params (e.g., `?model=gpt-4&lang=en-US`).
|
||||
|
||||
### New Agent Tool
|
||||
|
||||
1. Implement in `packages/page-agent/src/tools/index.ts`
|
||||
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
|
||||
|
||||
@@ -129,3 +129,5 @@ Demo build supports query params (e.g., `?model=gpt-4&lang=en-US`).
|
||||
- ESLint relaxes some unsafe rules for rapid iteration
|
||||
- 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.
|
||||
- Do not try to hide errors or risks. They are valuable feedbacks for developers and users. Make them visible and actionable.
|
||||
- Traceability and predictability is more important than success rate.
|
||||
|
||||
@@ -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).
|
||||
+2
-2
@@ -69,10 +69,10 @@ members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][https://www.contributor-covenant.org], version 1.4,
|
||||
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 1.4,
|
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
> Chinese Version
|
||||
> 《阿里巴巴开源行为准则》
|
||||
|
||||
+72
-43
@@ -7,23 +7,24 @@ Thank you for your interest in contributing to Page-Agent! We welcome contributi
|
||||
### Development Setup
|
||||
|
||||
1. **Prerequisites**
|
||||
- `node.js >= 20` with `npm >= 10`
|
||||
- An editor that supports `ts/eslint/prettier`
|
||||
- Make sure `eslint`, `prettier` and `commitlint` work well
|
||||
- `node.js >= 20` with `npm >= 10`
|
||||
- An editor that supports `ts/eslint/prettier`
|
||||
- Make sure `eslint`, `prettier` and `commitlint` work well
|
||||
|
||||
2. **Setup**
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm start # Start demo and documentation site
|
||||
```
|
||||
```bash
|
||||
npm ci
|
||||
npm start # Start demo and documentation site
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
This is a **monorepo** with npm workspaces containing **two main packages**:
|
||||
This is a **monorepo** with npm workspaces containing **3 main packages**:
|
||||
|
||||
1. **Core Library** (`packages/page-agent/`) - Pure JavaScript/TypeScript AI agent library for browser DOM automation, published as `page-agent` on npm
|
||||
2. **Website** (`packages/website/`) - React documentation and landing page. Also as demo and test page for the core lib. private package `@page-agent/website`
|
||||
- **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.
|
||||
|
||||
@@ -44,39 +45,39 @@ We use a simplified monorepo solution with `native npm-workspace + ts reference
|
||||
|
||||
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**
|
||||
- Test in our demo website
|
||||
- Test it on other websites if applicable
|
||||
- `@TODO: test suite`
|
||||
- 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
|
||||
|
||||
@@ -91,21 +92,49 @@ We use a simplified monorepo solution with `native npm-workspace + ts reference
|
||||
### Vibe coding with AI
|
||||
|
||||
- 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!!!
|
||||
- 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](AGENTS.md) when structure changed.
|
||||
|
||||
If your lame AI assistant does not support [AGENTS.md](https://agents.md/). Add an alias for it:
|
||||
|
||||
- claude-code (`CLAUDE.md`)
|
||||
|
||||
```markdown
|
||||
@AGENTS.md
|
||||
```
|
||||
|
||||
- antigravity (`.agent/rules/alias.md`)
|
||||
|
||||
```markdown
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
@../../AGENTS.md
|
||||
```
|
||||
|
||||
## 🔧 Development Workflows
|
||||
|
||||
### Test With Your Own LLM API
|
||||
|
||||
- Create a `.env` file in the repo root with your LLM API config
|
||||
|
||||
```env
|
||||
LLM_MODEL_NAME=gpt-5.2
|
||||
LLM_API_KEY=your-api-key
|
||||
LLM_BASE_URL=https://api.your-llm-provider.com/v1
|
||||
```
|
||||
```env
|
||||
LLM_MODEL_NAME=gpt-5.2
|
||||
LLM_API_KEY=your-api-key
|
||||
LLM_BASE_URL=https://api.your-llm-provider.com/v1
|
||||
```
|
||||
|
||||
- **Ollama example** (tested on 0.15 + qwen3:14b, RTX3090 24GB):
|
||||
|
||||
```env
|
||||
LLM_BASE_URL="http://localhost:11434/v1"
|
||||
LLM_API_KEY="NA"
|
||||
LLM_MODEL_NAME="qwen3:14b"
|
||||
```
|
||||
|
||||
> ⚠️ Add `*` to `OLLAMA_ORIGINS` (403). Models < 10B unlikely strong enough. Requires tool_call support. Set context length > 15k (default 4k will NOT work): `$env:OLLAMA_CONTEXT_LENGTH=64000; ollama serve`
|
||||
|
||||
- Restart the dev server to load new env vars
|
||||
- If not provided, the demo will the free testing proxy by default
|
||||
@@ -120,15 +149,15 @@ npm start
|
||||
|
||||
- Start and serve a local `iife` script
|
||||
|
||||
```bash
|
||||
npm run dev:iife # Serving IIFE with auto rebuild at http://localhost:5174/page-agent.js
|
||||
```
|
||||
```bash
|
||||
npm run dev:demo # Serving IIFE with auto rebuild at http://localhost:5174/page-agent.demo.js
|
||||
```
|
||||
|
||||
- Add a new bookmark
|
||||
|
||||
```javascript
|
||||
javascript:(function(){var s=document.createElement('script');s.src=`http://localhost:5174/page-agent.js?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.demo.js?t=${Math.random()}`;s.onload=()=>console.log(%27PageAgent ready!%27);document.head.appendChild(s);})();
|
||||
```
|
||||
|
||||
- Click the bookmark on any page to load Page-Agent
|
||||
|
||||
|
||||
+39
-59
@@ -1,8 +1,11 @@
|
||||
# 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)
|
||||
|
||||
纯 JS 实现的 GUI agent。使用自然语言操作你的 Web 应用。无须后端、客户端、浏览器插件。
|
||||
|
||||
@@ -10,40 +13,47 @@
|
||||
|
||||
👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/#/docs/introduction/overview" target="_blank"><b>📖 Documentation</b></a>
|
||||
|
||||
<video id="demo-video" src="https://github.com/user-attachments/assets/141bbb01-8022-4d1f-919d-9efc9a1dc1cf" width="640" crossorigin muted autoplay loop></video>
|
||||
<video id="demo-video" src="https://github.com/user-attachments/assets/34d8444d-cbfb-44a3-a24e-fd5c167bb0bf" controls crossorigin muted></video>
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🎯 轻松集成**
|
||||
- 无需 Python,无需无头浏览器,无需浏览器插件。纯页面内脚本。
|
||||
- **🔐 端侧运行**
|
||||
- **🧠 HTML 脱水**
|
||||
- **💬 自然语言接口**
|
||||
- **🎨 HITL 交互界面**
|
||||
|
||||
## 🗺️ Roadmap
|
||||
以及 😉
|
||||
|
||||
👉 [**Roadmap**](https://github.com/alibaba/page-agent/issues/96)
|
||||
- **🧪 实验性的 Chrome 扩展,支持跨页面控制** - `packages/extension`
|
||||
|
||||
👉 [**🗺️ Roadmap**](https://github.com/alibaba/page-agent/issues/96)
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 快速体验 (Demo CDN)
|
||||
### 一行代码集成
|
||||
|
||||
最快的体验方式:
|
||||
通过我们免费的 Demo LLM 快速体验 PageAgent:
|
||||
|
||||
```html
|
||||
<script src="https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.demo.js" crossorigin="true"></script>
|
||||
<script
|
||||
src="https://registry.npmmirror.com/page-agent/1.1.0/files/dist/iife/page-agent.demo.js"
|
||||
crossorigin="true"
|
||||
></script>
|
||||
```
|
||||
|
||||
> ⚠️ **仅用于技术评估。** Demo 模型有速率限制和使用限制,生产环境请使用 NPM 方式。
|
||||
> - **⚠️ 仅用于技术评估。** Demo LLM 有速率和使用限制,可能随时变更。
|
||||
> - **🌷 建议使用自己的 LLM API。**
|
||||
|
||||
| 位置 | URL |
|
||||
| ------ | --------------------------------------------------------------------------------------- |
|
||||
| 全球 | https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.demo.js |
|
||||
| 中国 | https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.demo.js |
|
||||
| Mirrors | URL |
|
||||
| ------- | ---------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.1.0/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.1.0/files/dist/iife/page-agent.demo.js |
|
||||
|
||||
### NPM 安装(推荐)
|
||||
### NPM 安装
|
||||
|
||||
```bash
|
||||
npm install page-agent
|
||||
@@ -53,35 +63,16 @@ npm install page-agent
|
||||
import { PageAgent } from 'page-agent'
|
||||
|
||||
const agent = new PageAgent({
|
||||
model: 'deepseek-chat',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
language: 'zh-CN',
|
||||
model: 'deepseek-chat',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
language: 'zh-CN',
|
||||
})
|
||||
|
||||
await agent.execute('点击登录按钮')
|
||||
```
|
||||
|
||||
### CDN 引入
|
||||
|
||||
适用于无法使用 NPM 的环境,用法与 NPM 一致:
|
||||
|
||||
```html
|
||||
<script src="https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.js" crossorigin="true"></script>
|
||||
<script>
|
||||
const agent = new PageAgent({
|
||||
model: 'deepseek-chat',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
})
|
||||
await agent.execute('点击登录按钮')
|
||||
</script>
|
||||
```
|
||||
|
||||
| 位置 | URL |
|
||||
| ------ | ----------------------------------------------------------------------------------- |
|
||||
| 全球 | https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.js |
|
||||
| 中国 | https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.js |
|
||||
适用于无法使用 NPM 的环境,我们也提供了 IIFE 构建的 CDN 方式。[@see CDN Usage](https://alibaba.github.io/page-agent/#/docs/integration/cdn-setup)
|
||||
|
||||
## 🏗️ 架构设计
|
||||
|
||||
@@ -89,40 +80,25 @@ PageAgent adopts a simplified monorepo structure:
|
||||
|
||||
```
|
||||
packages/
|
||||
├── page-agent/ # AI agent (npm: page-agent)
|
||||
├── 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)
|
||||
├── cdn/ # CDN IIFE builds (npm: @page-agent/cdn)
|
||||
└── website/ # 文档站点
|
||||
```
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎社区贡献!以下是参与方式:
|
||||
欢迎社区贡献!请参阅 [CONTRIBUTING.md](CONTRIBUTING.md) 了解环境配置和本地开发说明。
|
||||
|
||||
### 开发环境
|
||||
|
||||
1. Fork 项目仓库
|
||||
2. Clone or fork: `git clone https://github.com/alibaba/page-agent.git && cd page-agent`
|
||||
3. 安装依赖: `npm install`
|
||||
4. 启动开发: `npm start`
|
||||
|
||||
### 贡献指南
|
||||
|
||||
请在贡献前阅读我们的[行为准则](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:
|
||||
@@ -141,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.
|
||||
```
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
[MIT License](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
**⭐ 如果觉得 PageAgent 有用或有趣,请给项目点个星!**
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# 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)
|
||||
|
||||
The GUI Agent Living in Your Webpage. Control web interfaces with natural language.
|
||||
|
||||
@@ -10,40 +13,47 @@ The GUI Agent Living in Your Webpage. Control web interfaces with natural langua
|
||||
|
||||
👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/#/docs/introduction/overview" target="_blank"><b>📖 Documentation</b></a>
|
||||
|
||||
<video id="demo-video" src="https://github.com/user-attachments/assets/de8d1964-8bde-494f-a52f-2975469557a5" width="640" crossorigin muted autoplay loop></video>
|
||||
<video id="demo-video" src="https://github.com/user-attachments/assets/34d8444d-cbfb-44a3-a24e-fd5c167bb0bf" controls crossorigin muted></video>
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🎯 Easy Integration** - Transform your webpage into an agent with a single script tag.
|
||||
- **🎯 Easy Integration**
|
||||
- No python. No headless browser. No browser extension. Just in-page scripts.
|
||||
- **🔐 Client-Side Processing**
|
||||
- **🧠 DOM Extraction**
|
||||
- **💬 Natural Language Interface**
|
||||
- **🎨 UI with Human in the loop**
|
||||
|
||||
## 🗺️ Roadmap
|
||||
And 😉
|
||||
|
||||
👉 [**Roadmap**](https://github.com/alibaba/page-agent/issues/96)
|
||||
- **🧪 `cross-page` control with an experimental chrome extension** - `packages/extension`
|
||||
|
||||
👉 [**🗺️ Roadmap**](https://github.com/alibaba/page-agent/issues/96)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Quick Try (Demo CDN)
|
||||
### One-line integration
|
||||
|
||||
Fastest way to try PageAgent:
|
||||
Fastest way to try PageAgent with our free Demo LLM:
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.demo.js" crossorigin="true"></script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/page-agent@1.1.0/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.
|
||||
> - **⚠️ For technical evaluation only.** Demo LLM has rate limits and usage restrictions. May change without notice.
|
||||
> - **🌷 Bring your own LLM API.**
|
||||
|
||||
| Location | URL |
|
||||
| -------- | --------------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.demo.js |
|
||||
| Mirrors | URL |
|
||||
| ------- | ---------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.1.0/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.1.0/files/dist/iife/page-agent.demo.js |
|
||||
|
||||
### NPM Installation (Recommended)
|
||||
### NPM Installation
|
||||
|
||||
```bash
|
||||
npm install page-agent
|
||||
@@ -53,76 +63,40 @@ npm install page-agent
|
||||
import { PageAgent } from 'page-agent'
|
||||
|
||||
const agent = new PageAgent({
|
||||
model: 'deepseek-chat',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: 'YOUR_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')
|
||||
```
|
||||
|
||||
### CDN Build
|
||||
|
||||
For environments where NPM is not available. Usage is identical to NPM:
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.js" crossorigin="true"></script>
|
||||
<script>
|
||||
const agent = new PageAgent({
|
||||
model: 'deepseek-chat',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
})
|
||||
await agent.execute('Click the login button')
|
||||
</script>
|
||||
```
|
||||
|
||||
| Location | URL |
|
||||
| -------- | ----------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.js |
|
||||
| China | https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.js |
|
||||
|
||||
## 🏗️ Structure
|
||||
|
||||
PageAgent adopts a simplified monorepo structure:
|
||||
|
||||
```
|
||||
packages/
|
||||
├── page-agent/ # AI agent (npm: page-agent)
|
||||
├── 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)
|
||||
├── cdn/ # CDN IIFE builds (npm: @page-agent/cdn)
|
||||
└── website/ # Demo & Documentation site
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions from the community! Here's how to get started:
|
||||
We welcome contributions from the community! Follow our instructions in [CONTRIBUTING.md](CONTRIBUTING.md) for environment setup and local development.
|
||||
|
||||
### Setup
|
||||
|
||||
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`
|
||||
|
||||
### Contributing Guidelines
|
||||
|
||||
Please read our [Code of Conduct](CODE_OF_CONDUCT.md) and [Contributing Guide](CONTRIBUTING.md) before contributing.
|
||||
Please read [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:
|
||||
@@ -141,6 +115,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!**
|
||||
|
||||
+9
-1
@@ -8,7 +8,13 @@ import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['**/dist', '**/node_modules', 'packages/website/src/components/ui']),
|
||||
globalIgnores([
|
||||
'**/dist',
|
||||
'**/node_modules',
|
||||
'packages/*/src/components/ui',
|
||||
'**/.wxt',
|
||||
'**/.output',
|
||||
]),
|
||||
{
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
@@ -69,6 +75,8 @@ export default defineConfig([
|
||||
'react-dom/no-missing-button-type': 'off',
|
||||
'react-x/no-nested-component-definitions': 'off',
|
||||
'@typescript-eslint/prefer-optional-chain': 'off',
|
||||
'@typescript-eslint/use-unknown-in-catch-callback-variable': 'off',
|
||||
'@typescript-eslint/no-unnecessary-type-parameters': 'off',
|
||||
|
||||
// 'require-await': 'off',
|
||||
'@typescript-eslint/require-await': 'off',
|
||||
|
||||
Generated
+11165
-8208
File diff suppressed because it is too large
Load Diff
+123
-105
@@ -1,107 +1,125 @@
|
||||
{
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"version": "0.2.2",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/page-controller",
|
||||
"packages/ui",
|
||||
"packages/llms",
|
||||
"packages/page-agent",
|
||||
"packages/cdn",
|
||||
"packages/website"
|
||||
],
|
||||
"description": "AI-powered UI agent for web applications",
|
||||
"author": "Simon<gaomeng1900>",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/alibaba/page-agent.git"
|
||||
},
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"npm": ">=10.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"cleanup": "rm -rf packages/*/dist",
|
||||
"start": "npm run dev --workspace=@page-agent/website",
|
||||
"build:website": "npm run build:website --workspace=@page-agent/website",
|
||||
"build:libs": "npm run build --workspaces --if-present",
|
||||
"build": "npm run build:libs && npm run build:website",
|
||||
"dev:iife": "npm run dev:iife --workspace=page-agent",
|
||||
"version": "node scripts/sync-version.js",
|
||||
"lint": "eslint .",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"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.7.4",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.53.0",
|
||||
"unplugin-dts": "^1.0.0-beta.6",
|
||||
"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}": [
|
||||
"npx prettier --write --ignore-unknown",
|
||||
"npx eslint --quiet"
|
||||
],
|
||||
"*.{jsx,tsx}": [
|
||||
"npx prettier --write --ignore-unknown",
|
||||
"npx eslint --quiet"
|
||||
],
|
||||
"*.css": [
|
||||
"npx prettier --write --ignore-unknown"
|
||||
]
|
||||
},
|
||||
"commitlint": {
|
||||
"extends": [
|
||||
"@commitlint/config-conventional"
|
||||
],
|
||||
"rules": {
|
||||
"subject-case": [
|
||||
0,
|
||||
"never"
|
||||
]
|
||||
}
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"semi": false,
|
||||
"useTabs": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "es5",
|
||||
"plugins": [
|
||||
"@trivago/prettier-plugin-sort-imports"
|
||||
],
|
||||
"importOrder": [
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^(@/).*(?<!css)$",
|
||||
"^[./].*(?<!css)$",
|
||||
".css$"
|
||||
],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true
|
||||
}
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/page-controller",
|
||||
"packages/ui",
|
||||
"packages/llms",
|
||||
"packages/core",
|
||||
"packages/page-agent",
|
||||
"packages/extension",
|
||||
"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",
|
||||
"npm": ">=10.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"cleanup": "rm -rf packages/*/dist",
|
||||
"start": "npm run dev --workspace=@page-agent/website",
|
||||
"build:website": "npm run build:website --workspace=@page-agent/website",
|
||||
"build:libs": "npm run build --workspaces --if-present",
|
||||
"build": "npm run build:libs && npm run build:website",
|
||||
"dev:demo": "npm run dev:demo --workspace=page-agent",
|
||||
"version": "node scripts/sync-version.js",
|
||||
"lint": "eslint .",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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.9",
|
||||
"chalk": "^5.6.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-dom": "^2.7.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.0",
|
||||
"eslint-plugin-react-x": "^2.7.2",
|
||||
"globals": "^17.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.4",
|
||||
"prettier": "^3.8.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"unplugin-dts": "^1.0.0-beta.6",
|
||||
"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}": [
|
||||
"npx prettier --write --ignore-unknown",
|
||||
"npx eslint --quiet"
|
||||
],
|
||||
"*.{jsx,tsx}": [
|
||||
"npx prettier --write --ignore-unknown",
|
||||
"npx eslint --quiet"
|
||||
],
|
||||
"*.css": [
|
||||
"npx prettier --write --ignore-unknown"
|
||||
]
|
||||
},
|
||||
"commitlint": {
|
||||
"extends": [
|
||||
"@commitlint/config-conventional"
|
||||
],
|
||||
"rules": {
|
||||
"subject-case": [
|
||||
0,
|
||||
"never"
|
||||
]
|
||||
}
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"semi": false,
|
||||
"useTabs": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "es5",
|
||||
"plugins": [
|
||||
"@trivago/prettier-plugin-sort-imports"
|
||||
],
|
||||
"importOrder": [
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^(@/).*(?<!css)$",
|
||||
"^[./].*(?<!css)$",
|
||||
".css$"
|
||||
],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.md",
|
||||
"options": {
|
||||
"useTabs": false,
|
||||
"tabWidth": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": "*.json",
|
||||
"options": {
|
||||
"useTabs": false,
|
||||
"tabWidth": 4
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"name": "@page-agent/cdn",
|
||||
"private": false,
|
||||
"version": "0.2.2",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"description": "CDN builds for page-agent - IIFE bundles for script tag usage",
|
||||
"author": "Simon<gaomeng1900>",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/alibaba/page-agent.git"
|
||||
},
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"scripts": {
|
||||
"build": "vite build && vite build --mode demo"
|
||||
},
|
||||
"dependencies": {
|
||||
"page-agent": "0.2.2"
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Demo CDN build for page-agent
|
||||
* Auto-initializes with built-in demo API for testing
|
||||
*
|
||||
* Usage:
|
||||
* <script src="https://unpkg.com/@page-agent/cdn/dist/page-agent.demo.js"></script>
|
||||
*/
|
||||
import { PageAgent } from 'page-agent'
|
||||
|
||||
// Clean up existing instances to prevent multiple injections from bookmarklet
|
||||
if (window.pageAgent) {
|
||||
window.pageAgent.dispose()
|
||||
}
|
||||
|
||||
// Mount to global window object
|
||||
window.PageAgent = PageAgent
|
||||
|
||||
// Export for IIFE
|
||||
export { PageAgent }
|
||||
|
||||
console.log('🚀 page-agent.js loaded!')
|
||||
|
||||
const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
const DEMO_BASE_URL = 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
|
||||
const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
|
||||
// Demo warning
|
||||
console.log(
|
||||
'%c⚠️ DEMO MODE %c\n' +
|
||||
'This build uses a shared testing LLM with rate limits.\n' +
|
||||
'For production, use page-agent.js with your own API key:\n' +
|
||||
'https://alibaba.github.io/page-agent/#/docs/features/models',
|
||||
'background: #f59e0b; color: #000; font-size: 14px; font-weight: bold; padding: 4px 8px; border-radius: 4px;',
|
||||
'color: #f59e0b; font-size: 12px;'
|
||||
)
|
||||
|
||||
// in case document.x is not ready yet
|
||||
setTimeout(() => {
|
||||
const currentScript = document.currentScript
|
||||
|
||||
if (currentScript) {
|
||||
console.log('🚀 page-agent.js detected current script:', currentScript.src)
|
||||
const url = new URL(currentScript.src)
|
||||
const model = url.searchParams.get('model') || DEMO_MODEL
|
||||
const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL
|
||||
const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY
|
||||
const language = url.searchParams.get('lang') || 'zh-CN'
|
||||
const config = { model, baseURL, apiKey, language }
|
||||
window.pageAgent = new PageAgent(config)
|
||||
} else {
|
||||
console.log('🚀 page-agent.js no current script detected, using default demo config')
|
||||
const config = {
|
||||
model: DEMO_MODEL,
|
||||
baseURL: DEMO_BASE_URL,
|
||||
apiKey: DEMO_API_KEY,
|
||||
}
|
||||
window.pageAgent = new PageAgent(config)
|
||||
}
|
||||
|
||||
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
|
||||
|
||||
window.pageAgent.panel.show()
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Full CDN build for page-agent
|
||||
* Exposes PageAgent class for manual instantiation and configuration
|
||||
*
|
||||
* Usage:
|
||||
* <script src="https://unpkg.com/@page-agent/cdn/dist/page-agent.js"></script>
|
||||
* <script>
|
||||
* const agent = new PageAgent({ model: 'gpt-4o', apiKey: 'your-key' })
|
||||
* agent.panel.show()
|
||||
* </script>
|
||||
*/
|
||||
import { PageAgent } from 'page-agent'
|
||||
|
||||
export { PageAgent }
|
||||
@@ -1,49 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import { dirname, resolve } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig } from 'vite'
|
||||
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
/**
|
||||
* CDN IIFE builds for page-agent
|
||||
*
|
||||
* Build targets (via --mode):
|
||||
* - demo: page-agent.demo.js with auto-init and built-in API
|
||||
* - full (default): page-agent.js exposes PageAgent class only
|
||||
*/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const isDemo = mode === 'demo'
|
||||
const entry = isDemo ? resolve(__dirname, 'src/demo.js') : resolve(__dirname, 'src/full.js')
|
||||
const fileName = isDemo ? 'page-agent.demo' : 'page-agent'
|
||||
|
||||
return {
|
||||
plugins: [cssInjectedByJsPlugin({ relativeCSSInjection: true })],
|
||||
publicDir: false,
|
||||
esbuild: {
|
||||
keepNames: true,
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry,
|
||||
name: 'PageAgent',
|
||||
fileName: () => `${fileName}.js`,
|
||||
formats: ['iife'],
|
||||
},
|
||||
outDir: resolve(__dirname, 'dist'),
|
||||
emptyOutDir: !isDemo, // only empty on first build (full)
|
||||
minify: false,
|
||||
cssCodeSplit: true,
|
||||
rollupOptions: {
|
||||
onwarn: function (message, handler) {
|
||||
if (message.code === 'EVAL') return
|
||||
handler(message)
|
||||
},
|
||||
},
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@page-agent/core",
|
||||
"private": false,
|
||||
"version": "1.1.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.1.0",
|
||||
"@page-agent/page-controller": "1.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
/**
|
||||
* Copyright (C) 2025 Alibaba Group Holding Limited
|
||||
* All rights reserved.
|
||||
*/
|
||||
import { InvokeError, 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,
|
||||
AgentStepEvent,
|
||||
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'
|
||||
export type * from './types'
|
||||
|
||||
/**
|
||||
* 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
|
||||
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: 'retry',
|
||||
message: `LLM retry attempt ${attempt} of ${maxAttempts}`,
|
||||
attempt,
|
||||
maxAttempts,
|
||||
})
|
||||
this.#emitHistoryChange()
|
||||
})
|
||||
this.#llm.addEventListener('error', (e) => {
|
||||
const error = (e as CustomEvent).detail.error as Error | InvokeError
|
||||
if ((error as any)?.rawError?.name === 'AbortError') return
|
||||
const message = String(error)
|
||||
this.#emitActivity({ type: 'error', message })
|
||||
// Also push to history for panel rendering
|
||||
this.history.push({
|
||||
type: 'error',
|
||||
message,
|
||||
rawResponse: (error as InvokeError).rawResponse,
|
||||
})
|
||||
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' })
|
||||
|
||||
// invoke LLM
|
||||
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: this.#getSystemPrompt() },
|
||||
{ role: 'user' as const, content: await this.#assembleUserPrompt() },
|
||||
]
|
||||
|
||||
const tools = { AgentOutput: this.#packMacroTool() }
|
||||
|
||||
const result = await this.#llm.invoke(messages, tools, this.#abortController.signal, {
|
||||
toolChoiceName: 'AgentOutput',
|
||||
normalizeResponse,
|
||||
})
|
||||
|
||||
// assemble history event
|
||||
|
||||
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: AgentStepEvent['action'] = {
|
||||
name: actionName,
|
||||
input: input.action[actionName],
|
||||
output: output,
|
||||
}
|
||||
|
||||
this.history.push({
|
||||
type: 'step',
|
||||
stepIndex: step,
|
||||
reflection,
|
||||
action,
|
||||
usage: result.usage,
|
||||
rawResponse: result.rawResponse,
|
||||
rawRequest: result.rawRequest,
|
||||
} as AgentStepEvent)
|
||||
this.#emitHistoryChange()
|
||||
|
||||
//
|
||||
|
||||
console.log(chalk.green('Step finished:'), actionName)
|
||||
console.groupEnd()
|
||||
|
||||
await onAfterStep?.(this, this.history)
|
||||
|
||||
step++
|
||||
if (step > this.config.maxSteps) {
|
||||
this.#onDone(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(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(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 {
|
||||
if (this.config.customSystemPrompt) {
|
||||
return this.config.customSystemPrompt
|
||||
}
|
||||
|
||||
const targetLanguage = this.config.language === 'zh-CN' ? '中文' : 'English'
|
||||
const systemPrompt = SYSTEM_PROMPT.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(0.5) // 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().toLocaleString()}
|
||||
</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(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>
|
||||
${state.header}
|
||||
${content}
|
||||
${state.footer}
|
||||
|
||||
</browser_state>
|
||||
`)
|
||||
}
|
||||
|
||||
dispose() {
|
||||
console.log('Disposing PageAgent...')
|
||||
this.pageController.dispose()
|
||||
// this.history = []
|
||||
this.#abortController.abort()
|
||||
|
||||
// Emit dispose event for UI cleanup
|
||||
this.dispatchEvent(new Event('dispose'))
|
||||
|
||||
this.config.onDispose?.(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const DEFAULT_MAX_STEPS = 20
|
||||
@@ -1,21 +1,30 @@
|
||||
import type { LLMConfig } from '@page-agent/llms'
|
||||
import type { PageControllerConfig } from '@page-agent/page-controller'
|
||||
import type { SupportedLanguage } from '@page-agent/ui'
|
||||
|
||||
import type { ExecutionResult, HistoryEvent, PageAgent } from '../PageAgent'
|
||||
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 [tools](../tools/index.ts)
|
||||
* @see PageAgentTool
|
||||
*
|
||||
* @example
|
||||
* // override internal tool
|
||||
@@ -60,21 +69,52 @@ export interface AgentConfig {
|
||||
getPageInstructions?: (url: string) => string | undefined | null
|
||||
}
|
||||
|
||||
// lifecycle hooks
|
||||
// @todo: use event instead of hooks
|
||||
// @todo: remove `this` binding, pass agent as explicit parameter instead
|
||||
|
||||
onBeforeStep?: (this: PageAgent, stepCnt: number) => Promise<void> | void
|
||||
onAfterStep?: (this: PageAgent, stepCnt: number, history: HistoryEvent[]) => Promise<void> | void
|
||||
onBeforeTask?: (this: PageAgent) => Promise<void> | void
|
||||
onAfterTask?: (this: PageAgent, result: ExecutionResult) => Promise<void> | void
|
||||
/**
|
||||
* Lifecycle hooks for task execution.
|
||||
* @experimental API may change in future versions.
|
||||
*
|
||||
* All hooks receive the agent instance as first parameter.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @note this hook can block the disposal process
|
||||
* @note when dispose caused by page unload, reason will be 'PAGE_UNLOADING'. this method CANNOT block unloading. async operations may be cut.
|
||||
* @todo remove `this` binding, pass agent as explicit parameter instead
|
||||
* Called before each step execution.
|
||||
* @experimental
|
||||
* @param agent - The PageAgentCore instance
|
||||
* @param stepCount - Current step number (0-indexed)
|
||||
*/
|
||||
onDispose?: (this: PageAgent, reason?: string) => void
|
||||
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
|
||||
|
||||
@@ -103,19 +143,10 @@ export interface AgentConfig {
|
||||
transformPageContent?: (content: string) => Promise<string> | string
|
||||
|
||||
/**
|
||||
* TODO: @unimplemented
|
||||
* hook when action causes a new page to be opened
|
||||
* @note PageAgent will try to detect new pages and decide if it's caused by an action. But not very reliable.
|
||||
* @todo remove `this` binding, pass agent as explicit parameter instead
|
||||
* Completely override the default system prompt.
|
||||
* @experimental Use with caution - incorrect prompts may break agent behavior.
|
||||
*/
|
||||
onNewPageOpen?: (this: PageAgent, url: string) => Promise<void> | void
|
||||
|
||||
/**
|
||||
* TODO: @unimplemented
|
||||
* try to navigate to a new page instead of opening a new tab/window.
|
||||
* @note will unload the current page when a action tries to open a new page. so that things keep in the same tab/window.
|
||||
*/
|
||||
experimentalPreventNewPage?: boolean
|
||||
customSystemPrompt?: 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
|
||||
+3
-4
@@ -10,7 +10,7 @@ You excel at following tasks:
|
||||
</intro>
|
||||
|
||||
<language_settings>
|
||||
- Default working language: **中文**
|
||||
- Default working language: **English**
|
||||
- Use the language that user is using. Return in user's language.
|
||||
</language_settings>
|
||||
|
||||
@@ -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>
|
||||
@@ -151,6 +150,6 @@ You must ALWAYS respond with a valid JSON in this exact format:
|
||||
"evaluation_previous_goal": "Concise one-sentence analysis of your last action. Clearly state success, failure, or uncertain.",
|
||||
"memory": "1-3 concise sentences of specific memory of this step and overall progress. You should put here everything that will help you track progress in future steps. Like counting pages visited, items found, etc.",
|
||||
"next_goal": "State the next immediate goal and action to achieve it, in one clear sentence."
|
||||
"action":{"one_action_name": {// action-specific parameter}}
|
||||
"action":{"action": {// action-specific parameter}}
|
||||
}
|
||||
</output>
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import zod, { type z } from 'zod'
|
||||
|
||||
import type { PageAgent } from '../PageAgent'
|
||||
import type { PageAgentCore } from '../PageAgentCore'
|
||||
import { waitFor } from '../utils'
|
||||
|
||||
/**
|
||||
@@ -14,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> {
|
||||
@@ -36,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')
|
||||
@@ -52,7 +52,7 @@ tools.set(
|
||||
inputSchema: zod.object({
|
||||
seconds: zod.number().min(1).max(10).default(1),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
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`)
|
||||
@@ -79,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}`
|
||||
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}`
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -93,7 +96,7 @@ tools.set(
|
||||
inputSchema: zod.object({
|
||||
index: zod.int().min(0),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
execute: async function (this: PageAgentCore, input) {
|
||||
const result = await this.pageController.clickElement(input.index)
|
||||
return result.message
|
||||
},
|
||||
@@ -108,7 +111,7 @@ tools.set(
|
||||
index: zod.int().min(0),
|
||||
text: zod.string(),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
execute: async function (this: PageAgentCore, input) {
|
||||
const result = await this.pageController.inputText(input.index, input.text)
|
||||
return result.message
|
||||
},
|
||||
@@ -124,7 +127,7 @@ tools.set(
|
||||
index: zod.int().min(0),
|
||||
text: zod.string(),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
execute: async function (this: PageAgentCore, input) {
|
||||
const result = await this.pageController.selectOption(input.index, input.text)
|
||||
return result.message
|
||||
},
|
||||
@@ -145,7 +148,7 @@ tools.set(
|
||||
pixels: zod.number().int().min(0).optional(),
|
||||
index: zod.number().int().min(0).optional(),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
execute: async function (this: PageAgentCore, input) {
|
||||
const result = await this.pageController.scroll({
|
||||
...input,
|
||||
numPages: input.num_pages,
|
||||
@@ -165,7 +168,7 @@ tools.set(
|
||||
pixels: zod.number().int().min(0),
|
||||
index: zod.number().int().min(0).optional(),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
execute: async function (this: PageAgentCore, input) {
|
||||
const result = await this.pageController.scrollHorizontally(input)
|
||||
return result.message
|
||||
},
|
||||
@@ -180,7 +183,7 @@ tools.set(
|
||||
inputSchema: zod.object({
|
||||
script: zod.string(),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
execute: async function (this: PageAgentCore, input) {
|
||||
const result = await this.pageController.executeJavascript(input.script)
|
||||
return result.message
|
||||
},
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 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 AgentStepEvent {
|
||||
type: 'step'
|
||||
stepIndex: number
|
||||
reflection: Partial<AgentReflection>
|
||||
action: {
|
||||
name: string
|
||||
input: any
|
||||
output: string
|
||||
}
|
||||
usage: {
|
||||
promptTokens: number
|
||||
completionTokens: number
|
||||
totalTokens: number
|
||||
cachedTokens?: number
|
||||
reasoningTokens?: number
|
||||
}
|
||||
/** Raw LLM response for debugging */
|
||||
rawResponse?: unknown
|
||||
/** Raw LLM request for debugging */
|
||||
rawRequest?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent observation event (stays in memory)
|
||||
*/
|
||||
export interface ObservationEvent {
|
||||
type: 'observation'
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* User takeover event
|
||||
*/
|
||||
export interface UserTakeoverEvent {
|
||||
type: 'user_takeover'
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry event - LLM call is being retried
|
||||
*/
|
||||
export interface RetryEvent {
|
||||
type: 'retry'
|
||||
message: string
|
||||
attempt: number
|
||||
maxAttempts: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Error event - fatal error from LLM or execution
|
||||
*/
|
||||
export interface AgentErrorEvent {
|
||||
type: 'error'
|
||||
message: string
|
||||
rawResponse?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all history events
|
||||
*/
|
||||
export type HistoricalEvent =
|
||||
| AgentStepEvent
|
||||
| ObservationEvent
|
||||
| UserTakeoverEvent
|
||||
| RetryEvent
|
||||
| AgentErrorEvent
|
||||
|
||||
/**
|
||||
* 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[]
|
||||
}
|
||||
@@ -68,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,2 @@
|
||||
.wxt
|
||||
src/components/ui
|
||||
@@ -0,0 +1,57 @@
|
||||
# Privacy Policy for Page Agent Extension
|
||||
|
||||
**Last updated:** January 2026
|
||||
|
||||
## Overview
|
||||
|
||||
Page Agent Extension is a browser automation tool that uses AI to help you interact with web pages. This policy explains what data is processed and under what circumstances.
|
||||
|
||||
## Data Processing
|
||||
|
||||
### Local Processing
|
||||
|
||||
The extension performs DOM analysis and automation actions **locally in your browser**. Your browsing history, passwords, and form data are not accessed or collected by the extension itself.
|
||||
|
||||
### Data Transmission
|
||||
|
||||
Data is transmitted to external servers **only when you initiate an automation task**. When this occurs:
|
||||
|
||||
- Your task instructions (natural language commands)
|
||||
- Sanitized page structure (simplified DOM, excluding sensitive form values)
|
||||
|
||||
are sent to the LLM API endpoint configured in **your settings**.
|
||||
|
||||
**If you configure a third-party LLM provider** (e.g., OpenAI, Anthropic, or others), data is sent directly to that provider. Their privacy policies apply.
|
||||
|
||||
**If you use our testing endpoint**, your requests are proxied to [DeepSeek](https://deepseek.com) for AI processing. Regarding this test endpoint:
|
||||
|
||||
- This endpoint is provided for evaluation purposes only and is not recommended for production or daily use
|
||||
- We do **not** store your task content, page content, or visited URLs
|
||||
- Minimal logging (timestamps, request metadata, IP addresses) may occur for abuse prevention and service stability
|
||||
- DeepSeek's [Privacy Policy](https://cdn.deepseek.com/policies/en-US/deepseek-privacy-policy.html) applies to their processing of your requests
|
||||
|
||||
## Data Storage
|
||||
|
||||
- **Local storage only**: Your configuration (API endpoint, API key, model selection) is stored in your browser via `chrome.storage.local`
|
||||
- **No cloud sync**: Configuration is not synced to any external server
|
||||
- **No analytics**: The extension does not include any analytics or tracking code
|
||||
|
||||
## Your Control
|
||||
|
||||
- The extension is open source and can be audited by anyone
|
||||
- You choose which LLM provider to use
|
||||
- You may configure your own API endpoint at any time
|
||||
- You can clear all stored data by removing the extension
|
||||
|
||||
## Changes to This Policy
|
||||
|
||||
We may update this policy as the extension evolves. Significant changes will be noted in the extension's release notes.
|
||||
|
||||
## Contact
|
||||
|
||||
For questions about this privacy policy:
|
||||
https://github.com/alibaba/page-agent/issues
|
||||
|
||||
---
|
||||
|
||||
Source code: https://github.com/alibaba/page-agent
|
||||
@@ -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,291 @@
|
||||
# Page Agent Extension API
|
||||
|
||||
This document describes how to integrate the Page Agent browser extension into your web application.
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Install the browser extension
|
||||
|
||||
Install the Page Agent extension from the Chrome Web Store.
|
||||
|
||||
### 2. Install type definitions (recommended)
|
||||
|
||||
```bash
|
||||
npm install @page-agent/core --save-dev
|
||||
```
|
||||
|
||||
### 3. Set up authentication
|
||||
|
||||
The extension only injects APIs when it detects a valid token in `localStorage`.
|
||||
|
||||
1. Open the extension's side panel to get your authorization token
|
||||
2. Set the token in your page:
|
||||
|
||||
```typescript
|
||||
localStorage.setItem('PageAgentExtUserAuthToken', 'your-token')
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
AgentActivity,
|
||||
AgentStatus,
|
||||
ExecutionResult,
|
||||
HistoricalEvent,
|
||||
} from '@page-agent/core'
|
||||
|
||||
// Wait for extension injection (up to 1 second)
|
||||
async function waitForExtension(timeout = 1000): Promise<boolean> {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeout) {
|
||||
if (window.PAGE_AGENT_EXT) return true
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Usage
|
||||
if (await waitForExtension()) {
|
||||
const result = await window.PAGE_AGENT_EXT!.execute('Click the login button', {
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: 'your-api-key',
|
||||
model: 'gpt-5.2',
|
||||
onStatusChange: (status) => console.log('Status:', status),
|
||||
onActivity: (activity) => console.log('Activity:', activity),
|
||||
})
|
||||
console.log('Result:', result)
|
||||
}
|
||||
```
|
||||
|
||||
## Global API
|
||||
|
||||
The extension injects the following APIs into the `window` object:
|
||||
|
||||
### `window.PAGE_AGENT_EXT_VERSION`
|
||||
|
||||
Extension version string (e.g., `"1.0.0"`). This is exposed separately to allow version checking before accessing the main API object.
|
||||
|
||||
### `window.PAGE_AGENT_EXT`
|
||||
|
||||
Main API namespace object containing:
|
||||
|
||||
#### `PAGE_AGENT_EXT.execute(task, config)`
|
||||
|
||||
Execute an agent task.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| `task` | `string` | Yes | Task description |
|
||||
| `config` | `ExecuteConfig` | Yes | Execution configuration (LLM settings, options, and event callbacks) |
|
||||
|
||||
**Returns:** `Promise<ExecutionResult>`
|
||||
|
||||
#### `PAGE_AGENT_EXT.dispose()`
|
||||
|
||||
Stop and destroy the current running agent.
|
||||
|
||||
## Types
|
||||
|
||||
Install `@page-agent/core` for full type definitions:
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
AgentActivity,
|
||||
AgentStatus,
|
||||
ExecutionResult,
|
||||
HistoricalEvent,
|
||||
} from '@page-agent/core'
|
||||
|
||||
export interface ExecuteConfig {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
|
||||
/**
|
||||
* Whether to include the initial tab (that holds this main world script) in the task.
|
||||
* @default true
|
||||
*/
|
||||
includeInitialTab?: boolean
|
||||
|
||||
onStatusChange?: (status: AgentStatus) => void
|
||||
onActivity?: (activity: AgentActivity) => void
|
||||
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
||||
onDispose?: () => void
|
||||
}
|
||||
|
||||
export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
|
||||
```
|
||||
|
||||
### AgentStatus
|
||||
|
||||
```typescript
|
||||
type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
|
||||
```
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| `idle` | Agent is idle, ready to execute |
|
||||
| `running` | Agent is executing a task |
|
||||
| `completed` | Task completed successfully |
|
||||
| `error` | Task failed with an error |
|
||||
|
||||
### AgentActivity
|
||||
|
||||
```typescript
|
||||
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 }
|
||||
```
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `thinking` | Agent is analyzing the page and planning |
|
||||
| `executing` | Agent is executing a tool action |
|
||||
| `executed` | Tool execution completed |
|
||||
| `retrying` | Retrying after a failure |
|
||||
| `error` | An error occurred |
|
||||
|
||||
### HistoricalEvent
|
||||
|
||||
```typescript
|
||||
type HistoricalEvent =
|
||||
| { type: 'step'; stepIndex: number; reflection: AgentReflection; action: Action }
|
||||
| { type: 'observation'; content: string }
|
||||
| { type: 'user_takeover' }
|
||||
| { type: 'retry'; message: string; attempt: number; maxAttempts: number }
|
||||
| { type: 'error'; message: string; rawResponse?: unknown }
|
||||
```
|
||||
|
||||
### ExecutionResult
|
||||
|
||||
```typescript
|
||||
interface ExecutionResult {
|
||||
success: boolean
|
||||
data: string
|
||||
history: HistoricalEvent[]
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Execution
|
||||
|
||||
```typescript
|
||||
const result = await window.PAGE_AGENT_EXT!.execute(
|
||||
'Fill in the email field with test@example.com and click Submit',
|
||||
{
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: process.env.OPENAI_API_KEY!,
|
||||
model: 'gpt-5.2',
|
||||
}
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
console.log('Task completed:', result.data)
|
||||
} else {
|
||||
console.error('Task failed')
|
||||
}
|
||||
```
|
||||
|
||||
### Exclude Initial Tab
|
||||
|
||||
By default, the agent includes the initial tab (where the script runs) in the task. Set `includeInitialTab: false` to exclude it:
|
||||
|
||||
```typescript
|
||||
const result = await window.PAGE_AGENT_EXT!.execute(
|
||||
'Open a new tab and search for page-agent on GitHub',
|
||||
{
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: process.env.OPENAI_API_KEY!,
|
||||
model: 'gpt-5.2',
|
||||
includeInitialTab: false, // Agent will open new tabs only
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### With Event Callbacks
|
||||
|
||||
```typescript
|
||||
await window.PAGE_AGENT_EXT!.execute('Navigate to the settings page', {
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: process.env.OPENAI_API_KEY!,
|
||||
model: 'gpt-5.2',
|
||||
onStatusChange: (status) => {
|
||||
updateUI({ agentStatus: status })
|
||||
},
|
||||
onActivity: (activity) => {
|
||||
switch (activity.type) {
|
||||
case 'thinking':
|
||||
showSpinner('Agent is thinking...')
|
||||
break
|
||||
case 'executing':
|
||||
showSpinner(`Executing: ${activity.tool}`)
|
||||
break
|
||||
case 'executed':
|
||||
log(`${activity.tool} completed in ${activity.duration}ms`)
|
||||
break
|
||||
case 'error':
|
||||
showError(activity.message)
|
||||
break
|
||||
}
|
||||
},
|
||||
onHistoryUpdate: (history) => {
|
||||
renderHistory(history)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Stop Execution
|
||||
|
||||
```typescript
|
||||
// Start a task
|
||||
window.PAGE_AGENT_EXT!.execute('Scroll through all pages', {
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: process.env.OPENAI_API_KEY!,
|
||||
model: 'gpt-5.2',
|
||||
})
|
||||
|
||||
// Later, stop it
|
||||
window.PAGE_AGENT_EXT!.dispose()
|
||||
```
|
||||
|
||||
## Window Type Declaration
|
||||
|
||||
If not using `@page-agent/core`, add this to your project:
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
AgentActivity,
|
||||
AgentStatus,
|
||||
ExecutionResult,
|
||||
HistoricalEvent,
|
||||
} from '@page-agent/core'
|
||||
|
||||
interface ExecuteConfig {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
includeInitialTab?: boolean
|
||||
onStatusChange?: (status: AgentStatus) => void
|
||||
onActivity?: (activity: AgentActivity) => void
|
||||
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
||||
onDispose?: () => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
PAGE_AGENT_EXT_VERSION?: string
|
||||
PAGE_AGENT_EXT?: {
|
||||
version: string
|
||||
execute: (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
|
||||
dispose: () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,291 @@
|
||||
# Page Agent 浏览器插件 API
|
||||
|
||||
本文档介绍如何在网页应用中接入 Page Agent 浏览器插件。
|
||||
|
||||
## 安装
|
||||
|
||||
### 1. 安装浏览器插件
|
||||
|
||||
从 Chrome 应用商店安装 Page Agent 插件。
|
||||
|
||||
### 2. 安装类型定义(推荐)
|
||||
|
||||
```bash
|
||||
npm install @page-agent/core --save-dev
|
||||
```
|
||||
|
||||
### 3. 配置认证
|
||||
|
||||
插件在页面加载后检测 `localStorage` 中的 token,匹配时才会注入 API。
|
||||
|
||||
1. 打开插件的侧边栏面板,获取授权 token
|
||||
2. 在页面中设置 token:
|
||||
|
||||
```typescript
|
||||
localStorage.setItem('PageAgentExtUserAuthToken', 'your-token')
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
AgentActivity,
|
||||
AgentStatus,
|
||||
ExecutionResult,
|
||||
HistoricalEvent,
|
||||
} from '@page-agent/core'
|
||||
|
||||
// 等待插件注入(最多 1 秒)
|
||||
async function waitForExtension(timeout = 1000): Promise<boolean> {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeout) {
|
||||
if (window.PAGE_AGENT_EXT) return true
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 使用
|
||||
if (await waitForExtension()) {
|
||||
const result = await window.PAGE_AGENT_EXT!.execute('点击登录按钮', {
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: 'your-api-key',
|
||||
model: 'gpt-5.2',
|
||||
onStatusChange: (status) => console.log('状态:', status),
|
||||
onActivity: (activity) => console.log('活动:', activity),
|
||||
})
|
||||
console.log('结果:', result)
|
||||
}
|
||||
```
|
||||
|
||||
## 全局 API
|
||||
|
||||
插件在 `window` 对象上注入以下 API:
|
||||
|
||||
### `window.PAGE_AGENT_EXT_VERSION`
|
||||
|
||||
插件版本号字符串(例如 `"1.0.0"`)。单独暴露版本号,方便在访问主 API 对象前进行版本检查。
|
||||
|
||||
### `window.PAGE_AGENT_EXT`
|
||||
|
||||
主 API 命名空间对象,包含:
|
||||
|
||||
#### `PAGE_AGENT_EXT.execute(task, config)`
|
||||
|
||||
执行 Agent 任务。
|
||||
|
||||
**参数:**
|
||||
|
||||
| 名称 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `task` | `string` | 是 | 任务描述 |
|
||||
| `config` | `ExecuteConfig` | 是 | 执行配置(LLM 设置、选项和事件回调) |
|
||||
|
||||
**返回:** `Promise<ExecutionResult>`
|
||||
|
||||
#### `PAGE_AGENT_EXT.dispose()`
|
||||
|
||||
停止并销毁当前运行的 Agent。
|
||||
|
||||
## 类型定义
|
||||
|
||||
安装 `@page-agent/core` 获取完整类型:
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
AgentActivity,
|
||||
AgentStatus,
|
||||
ExecutionResult,
|
||||
HistoricalEvent,
|
||||
} from '@page-agent/core'
|
||||
|
||||
export interface ExecuteConfig {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
|
||||
/**
|
||||
* 是否将初始标签页(运行此脚本的页面)包含在任务中。
|
||||
* @default true
|
||||
*/
|
||||
includeInitialTab?: boolean
|
||||
|
||||
onStatusChange?: (status: AgentStatus) => void
|
||||
onActivity?: (activity: AgentActivity) => void
|
||||
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
||||
onDispose?: () => void
|
||||
}
|
||||
|
||||
export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
|
||||
```
|
||||
|
||||
### AgentStatus
|
||||
|
||||
```typescript
|
||||
type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
|
||||
```
|
||||
|
||||
| 状态 | 说明 |
|
||||
|------|------|
|
||||
| `idle` | 空闲,准备执行 |
|
||||
| `running` | 正在执行任务 |
|
||||
| `completed` | 任务成功完成 |
|
||||
| `error` | 任务执行失败 |
|
||||
|
||||
### AgentActivity
|
||||
|
||||
```typescript
|
||||
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 }
|
||||
```
|
||||
|
||||
| 类型 | 说明 |
|
||||
|------|------|
|
||||
| `thinking` | Agent 正在分析页面并规划 |
|
||||
| `executing` | 正在执行工具操作 |
|
||||
| `executed` | 工具执行完成 |
|
||||
| `retrying` | 失败后重试 |
|
||||
| `error` | 发生错误 |
|
||||
|
||||
### HistoricalEvent
|
||||
|
||||
```typescript
|
||||
type HistoricalEvent =
|
||||
| { type: 'step'; stepIndex: number; reflection: AgentReflection; action: Action }
|
||||
| { type: 'observation'; content: string }
|
||||
| { type: 'user_takeover' }
|
||||
| { type: 'retry'; message: string; attempt: number; maxAttempts: number }
|
||||
| { type: 'error'; message: string; rawResponse?: unknown }
|
||||
```
|
||||
|
||||
### ExecutionResult
|
||||
|
||||
```typescript
|
||||
interface ExecutionResult {
|
||||
success: boolean
|
||||
data: string
|
||||
history: HistoricalEvent[]
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 基础执行
|
||||
|
||||
```typescript
|
||||
const result = await window.PAGE_AGENT_EXT!.execute(
|
||||
'在邮箱输入框填入 test@example.com 然后点击提交',
|
||||
{
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: process.env.OPENAI_API_KEY!,
|
||||
model: 'gpt-5.2',
|
||||
}
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
console.log('任务完成:', result.data)
|
||||
} else {
|
||||
console.error('任务失败')
|
||||
}
|
||||
```
|
||||
|
||||
### 排除初始标签页
|
||||
|
||||
默认情况下,Agent 会将初始标签页(运行脚本的页面)包含在任务中。设置 `includeInitialTab: false` 可以排除它:
|
||||
|
||||
```typescript
|
||||
const result = await window.PAGE_AGENT_EXT!.execute(
|
||||
'打开新标签页并在 GitHub 上搜索 page-agent',
|
||||
{
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: process.env.OPENAI_API_KEY!,
|
||||
model: 'gpt-5.2',
|
||||
includeInitialTab: false, // Agent 只会打开新标签页
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### 使用事件回调
|
||||
|
||||
```typescript
|
||||
await window.PAGE_AGENT_EXT!.execute('导航到设置页面', {
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: process.env.OPENAI_API_KEY!,
|
||||
model: 'gpt-5.2',
|
||||
onStatusChange: (status) => {
|
||||
updateUI({ agentStatus: status })
|
||||
},
|
||||
onActivity: (activity) => {
|
||||
switch (activity.type) {
|
||||
case 'thinking':
|
||||
showSpinner('Agent 正在思考...')
|
||||
break
|
||||
case 'executing':
|
||||
showSpinner(`正在执行: ${activity.tool}`)
|
||||
break
|
||||
case 'executed':
|
||||
log(`${activity.tool} 完成,耗时 ${activity.duration}ms`)
|
||||
break
|
||||
case 'error':
|
||||
showError(activity.message)
|
||||
break
|
||||
}
|
||||
},
|
||||
onHistoryUpdate: (history) => {
|
||||
renderHistory(history)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### 停止执行
|
||||
|
||||
```typescript
|
||||
// 启动任务
|
||||
window.PAGE_AGENT_EXT!.execute('滚动浏览所有页面', {
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: process.env.OPENAI_API_KEY!,
|
||||
model: 'gpt-5.2',
|
||||
})
|
||||
|
||||
// 稍后停止
|
||||
window.PAGE_AGENT_EXT!.dispose()
|
||||
```
|
||||
|
||||
## Window 类型声明
|
||||
|
||||
如果不使用 `@page-agent/core`,可以添加以下声明:
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
AgentActivity,
|
||||
AgentStatus,
|
||||
ExecutionResult,
|
||||
HistoricalEvent,
|
||||
} from '@page-agent/core'
|
||||
|
||||
interface ExecuteConfig {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
includeInitialTab?: boolean
|
||||
onStatusChange?: (status: AgentStatus) => void
|
||||
onActivity?: (activity: AgentActivity) => void
|
||||
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
||||
onDispose?: () => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
PAGE_AGENT_EXT_VERSION?: string
|
||||
PAGE_AGENT_EXT?: {
|
||||
version: string
|
||||
execute: (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
|
||||
dispose: () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@page-agent/ext",
|
||||
"private": true,
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wxt",
|
||||
"build:ext": "wxt build",
|
||||
"zip": "wxt zip",
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/chrome": "^0.1.34",
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||
"@wxt-dev/module-react": "^1.1.5",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"wxt": "^0.20.13"
|
||||
},
|
||||
"dependencies": {
|
||||
"@page-agent/core": "1.1.0",
|
||||
"@page-agent/llms": "1.1.0",
|
||||
"@page-agent/page-controller": "1.1.0",
|
||||
"@page-agent/ui": "1.1.0",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@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.563.0",
|
||||
"motion": "^12.30.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"rough-notation": "^0.5.1",
|
||||
"simple-icons": "^16.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
@@ -0,0 +1 @@
|
||||
system_prompt.md
|
||||
@@ -0,0 +1,92 @@
|
||||
import { type PageAgentConfig, PageAgentCore } from '@page-agent/core'
|
||||
|
||||
import { RemotePageController } from './RemotePageController'
|
||||
import { TabsController } from './TabsController'
|
||||
import SYSTEM_PROMPT from './system_prompt.md?raw'
|
||||
import { createTabTools } from './tabTools'
|
||||
|
||||
/** Detect user language from browser settings */
|
||||
function detectLanguage(): 'en-US' | 'zh-CN' {
|
||||
const lang = navigator.language || navigator.languages?.[0] || 'en-US'
|
||||
return lang.startsWith('zh') ? 'zh-CN' : 'en-US'
|
||||
}
|
||||
|
||||
/**
|
||||
* MultiPageAgent
|
||||
* - use with extension
|
||||
* - can be used from a side panel or a content script
|
||||
*/
|
||||
export class MultiPageAgent extends PageAgentCore {
|
||||
constructor(config: Omit<PageAgentConfig, 'pageController'> & { includeInitialTab?: boolean }) {
|
||||
// multi page controller
|
||||
const tabsController = new TabsController()
|
||||
const pageController = new RemotePageController(tabsController)
|
||||
const customTools = createTabTools(tabsController)
|
||||
|
||||
// system prompt - auto-detect language if not specified
|
||||
const language = config.language ?? detectLanguage()
|
||||
const targetLanguage = language === 'zh-CN' ? '中文' : 'English'
|
||||
const systemPrompt = SYSTEM_PROMPT.replace(
|
||||
/Default working language: \*\*.*?\*\*/,
|
||||
`Default working language: **${targetLanguage}**`
|
||||
)
|
||||
|
||||
// include initial tab for controlling
|
||||
const includeInitialTab = config.includeInitialTab ?? true
|
||||
|
||||
/**
|
||||
* When the agent is in side-panel and user closed the side-panel.
|
||||
* There is no chance for isAgentRunning to be set false.
|
||||
* (unload event doesn't work well in side panel.)
|
||||
* (I'm trying not to use long-lived connection because the lifecycle of a sw is hard to predict.)
|
||||
* This heartbeat mechanism acts as a backup.
|
||||
*/
|
||||
let heartBeatInterval: null | number = null
|
||||
|
||||
super({
|
||||
...config,
|
||||
pageController: pageController as any,
|
||||
customTools: customTools,
|
||||
customSystemPrompt: systemPrompt,
|
||||
|
||||
onBeforeTask: async (agent) => {
|
||||
await tabsController.init(agent.task, includeInitialTab)
|
||||
|
||||
heartBeatInterval = window.setInterval(() => {
|
||||
chrome.storage.local.set({
|
||||
agentHeartbeat: Date.now(),
|
||||
})
|
||||
}, 1_000)
|
||||
|
||||
await chrome.storage.local.set({
|
||||
isAgentRunning: true,
|
||||
})
|
||||
},
|
||||
|
||||
onAfterTask: async () => {
|
||||
if (heartBeatInterval) {
|
||||
window.clearInterval(heartBeatInterval)
|
||||
heartBeatInterval = null
|
||||
}
|
||||
|
||||
await chrome.storage.local.set({
|
||||
isAgentRunning: false,
|
||||
})
|
||||
},
|
||||
|
||||
onDispose: () => {
|
||||
if (heartBeatInterval) {
|
||||
window.clearInterval(heartBeatInterval)
|
||||
heartBeatInterval = null
|
||||
}
|
||||
|
||||
chrome.storage.local.set({
|
||||
isAgentRunning: false,
|
||||
})
|
||||
|
||||
// no need to dispose tabsController and pageController
|
||||
// as they do not keep references
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* background logics for RemotePageController
|
||||
* - redirect messages from RemotePageController(Agent, extension pages) to ContentScript
|
||||
*/
|
||||
|
||||
export function handlePageControlMessage(
|
||||
message: { type: 'PAGE_CONTROL'; action: string; payload: any; targetTabId: number },
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response: unknown) => void
|
||||
): true | undefined {
|
||||
const { action, payload, targetTabId } = message
|
||||
|
||||
if (action === 'get_my_tab_id') {
|
||||
sendResponse({ tabId: sender.tab?.id || null })
|
||||
return
|
||||
}
|
||||
|
||||
chrome.tabs
|
||||
.sendMessage(targetTabId, {
|
||||
type: 'PAGE_CONTROL',
|
||||
action,
|
||||
payload,
|
||||
})
|
||||
.then((result) => {
|
||||
sendResponse(result)
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
})
|
||||
|
||||
return true // async response
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* content script for RemotePageController
|
||||
*/
|
||||
import { PageController } from '@page-agent/page-controller'
|
||||
|
||||
export function initPageController() {
|
||||
let pageController: PageController | null = null
|
||||
let intervalID: number | null = null
|
||||
|
||||
const myTabIdPromise = chrome.runtime
|
||||
.sendMessage({ type: 'PAGE_CONTROL', action: 'get_my_tab_id' })
|
||||
.then((response) => {
|
||||
return (response as { tabId: number | null }).tabId
|
||||
})
|
||||
|
||||
function getPC(): PageController {
|
||||
if (!pageController) {
|
||||
pageController = new PageController({ enableMask: false })
|
||||
}
|
||||
return pageController
|
||||
}
|
||||
|
||||
intervalID = window.setInterval(async () => {
|
||||
const agentHeartbeat = (await chrome.storage.local.get('agentHeartbeat')).agentHeartbeat
|
||||
const now = Date.now()
|
||||
const agentInTouch = typeof agentHeartbeat === 'number' && now - agentHeartbeat < 2_000
|
||||
|
||||
const isAgentRunning = (await chrome.storage.local.get('isAgentRunning')).isAgentRunning
|
||||
const currentTabId = (await chrome.storage.local.get('currentTabId')).currentTabId
|
||||
|
||||
const shouldShowMask = isAgentRunning && agentInTouch && currentTabId === (await myTabIdPromise)
|
||||
|
||||
if (shouldShowMask) {
|
||||
const pc = getPC()
|
||||
pc.initMask()
|
||||
await pc.showMask()
|
||||
} else {
|
||||
// await getPC().hideMask()
|
||||
if (pageController) {
|
||||
pageController.hideMask()
|
||||
pageController.cleanUpHighlights()
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAgentRunning && agentInTouch) {
|
||||
if (pageController) {
|
||||
pageController.dispose()
|
||||
pageController = null
|
||||
}
|
||||
}
|
||||
}, 500)
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse): true | undefined => {
|
||||
if (message.type !== 'PAGE_CONTROL') {
|
||||
// sendResponse({
|
||||
// success: false,
|
||||
// error: `[RemotePageController.ContentScript]: Invalid message type: ${message.type}`,
|
||||
// })
|
||||
return
|
||||
}
|
||||
|
||||
const { action, payload } = message
|
||||
const methodName = getMethodName(action)
|
||||
|
||||
const pc = getPC() as any
|
||||
|
||||
switch (action) {
|
||||
case 'get_last_update_time':
|
||||
case 'get_browser_state':
|
||||
case 'update_tree':
|
||||
case 'clean_up_highlights':
|
||||
case 'click_element':
|
||||
case 'input_text':
|
||||
case 'select_option':
|
||||
case 'scroll':
|
||||
case 'scroll_horizontally':
|
||||
case 'execute_javascript':
|
||||
pc[methodName](...(payload || []))
|
||||
.then((result: any) => sendResponse(result))
|
||||
.catch((error: any) =>
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
)
|
||||
break
|
||||
|
||||
default:
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: `Unknown PAGE_CONTROL action: ${action}`,
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function getMethodName(action: string): string {
|
||||
switch (action) {
|
||||
case 'get_last_update_time':
|
||||
return 'getLastUpdateTime' as const
|
||||
case 'get_browser_state':
|
||||
return 'getBrowserState' as const
|
||||
case 'update_tree':
|
||||
return 'updateTree' as const
|
||||
case 'clean_up_highlights':
|
||||
return 'cleanUpHighlights' as const
|
||||
|
||||
// DOM actions
|
||||
|
||||
case 'click_element':
|
||||
return 'clickElement' as const
|
||||
case 'input_text':
|
||||
return 'inputText' as const
|
||||
case 'select_option':
|
||||
return 'selectOption' as const
|
||||
case 'scroll':
|
||||
return 'scroll' as const
|
||||
case 'scroll_horizontally':
|
||||
return 'scrollHorizontally' as const
|
||||
case 'execute_javascript':
|
||||
return 'executeJavascript' as const
|
||||
|
||||
default:
|
||||
return action
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { BrowserState } from '@page-agent/page-controller'
|
||||
|
||||
import type { TabsController } from './TabsController'
|
||||
|
||||
/**
|
||||
* Agent side page controller.
|
||||
* - live in the agent env (extension page or content script)
|
||||
* - communicates with remote PageController via sw
|
||||
*/
|
||||
export class RemotePageController {
|
||||
private tabsController: TabsController
|
||||
|
||||
constructor(tabsController: TabsController) {
|
||||
this.tabsController = tabsController
|
||||
}
|
||||
|
||||
get currentTabId(): number | null {
|
||||
return this.tabsController.currentTabId
|
||||
}
|
||||
|
||||
async getCurrentUrl(): Promise<string> {
|
||||
if (!this.currentTabId) return ''
|
||||
const { url } = await this.tabsController.getTabInfo(this.currentTabId)
|
||||
return url || ''
|
||||
}
|
||||
|
||||
async getCurrentTitle(): Promise<string> {
|
||||
if (!this.currentTabId) return ''
|
||||
const { title } = await this.tabsController.getTabInfo(this.currentTabId)
|
||||
return title || ''
|
||||
}
|
||||
|
||||
get currentTabTitle(): Promise<string> {
|
||||
return this.getCurrentTitle()
|
||||
}
|
||||
|
||||
async getLastUpdateTime(): Promise<number> {
|
||||
if (!this.currentTabId) throw new Error('tabsController not initialized.')
|
||||
|
||||
return await chrome.runtime.sendMessage({
|
||||
type: 'PAGE_CONTROL',
|
||||
action: 'get_last_update_time',
|
||||
targetTabId: this.currentTabId,
|
||||
})
|
||||
}
|
||||
|
||||
// getBrowserState
|
||||
async getBrowserState(): Promise<BrowserState> {
|
||||
let browserState = {} as BrowserState
|
||||
|
||||
if (!this.currentTabId || !isContentScriptAllowed(await this.currentTabUrl)) {
|
||||
browserState = {
|
||||
url: await this.currentTabUrl,
|
||||
title: await this.currentTabTitle,
|
||||
header: '',
|
||||
content: '(empty page)',
|
||||
footer: '',
|
||||
}
|
||||
} else {
|
||||
browserState = await chrome.runtime.sendMessage({
|
||||
type: 'PAGE_CONTROL',
|
||||
action: 'get_browser_state',
|
||||
targetTabId: this.currentTabId,
|
||||
})
|
||||
}
|
||||
|
||||
const sum = await this.tabsController.summarizeTabs()
|
||||
browserState.header = sum + '\n\n' + (browserState.header || '')
|
||||
|
||||
return browserState
|
||||
}
|
||||
|
||||
// updateTree
|
||||
async updateTree(): Promise<void> {
|
||||
if (!this.currentTabId || !isContentScriptAllowed(await this.currentTabUrl)) {
|
||||
return
|
||||
}
|
||||
|
||||
await chrome.runtime.sendMessage({
|
||||
type: 'PAGE_CONTROL',
|
||||
action: 'update_tree',
|
||||
targetTabId: this.currentTabId,
|
||||
})
|
||||
}
|
||||
|
||||
// cleanUpHighlights
|
||||
async cleanUpHighlights(): Promise<void> {
|
||||
if (!this.currentTabId || !isContentScriptAllowed(await this.currentTabUrl)) {
|
||||
return
|
||||
}
|
||||
|
||||
await chrome.runtime.sendMessage({
|
||||
type: 'PAGE_CONTROL',
|
||||
action: 'clean_up_highlights',
|
||||
targetTabId: this.currentTabId,
|
||||
})
|
||||
}
|
||||
|
||||
// clickElement
|
||||
async clickElement(...args: any[]): Promise<DomActionReturn> {
|
||||
return this.remoteCallDomAction('click_element', args)
|
||||
}
|
||||
|
||||
// inputText
|
||||
async inputText(...args: any[]): Promise<DomActionReturn> {
|
||||
return this.remoteCallDomAction('input_text', args)
|
||||
}
|
||||
|
||||
// selectOption
|
||||
async selectOption(...args: any[]): Promise<DomActionReturn> {
|
||||
return this.remoteCallDomAction('select_option', args)
|
||||
}
|
||||
|
||||
// scroll
|
||||
async scroll(...args: any[]): Promise<DomActionReturn> {
|
||||
return this.remoteCallDomAction('scroll', args)
|
||||
}
|
||||
|
||||
// scrollHorizontally
|
||||
async scrollHorizontally(...args: any[]): Promise<DomActionReturn> {
|
||||
return this.remoteCallDomAction('scroll_horizontally', args)
|
||||
}
|
||||
|
||||
// executeJavascript
|
||||
async executeJavascript(...args: any[]): Promise<DomActionReturn> {
|
||||
return this.remoteCallDomAction('execute_javascript', args)
|
||||
}
|
||||
|
||||
/** @note Managed by content script via storage polling. */
|
||||
async showMask(): Promise<void> {}
|
||||
/** @note Managed by content script via storage polling. */
|
||||
async hideMask(): Promise<void> {}
|
||||
/** @note Managed by content script via storage polling. */
|
||||
dispose(): void {}
|
||||
|
||||
private async preCheck() {
|
||||
if (!this.currentTabId) {
|
||||
return 'RemotePageController not initialized.'
|
||||
}
|
||||
|
||||
if (!isContentScriptAllowed(await this.currentTabUrl)) {
|
||||
return 'Operation not allowed on this page. Use open_new_tab to navigate to a web page first.'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private async remoteCallDomAction(action: string, payload: any[]): Promise<DomActionReturn> {
|
||||
const preCheckError = await this.preCheck()
|
||||
if (preCheckError) {
|
||||
return { success: false, message: preCheckError }
|
||||
}
|
||||
|
||||
return await chrome.runtime.sendMessage({
|
||||
type: 'PAGE_CONTROL',
|
||||
action: action,
|
||||
targetTabId: this.currentTabId!,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
private get currentTabUrl(): Promise<string> {
|
||||
return this.getCurrentUrl()
|
||||
}
|
||||
}
|
||||
|
||||
interface DomActionReturn {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL can run content scripts.
|
||||
*/
|
||||
function isContentScriptAllowed(url: string | undefined): boolean {
|
||||
if (!url) return false
|
||||
|
||||
const restrictedPatterns = [
|
||||
/^chrome:\/\//,
|
||||
/^chrome-extension:\/\//,
|
||||
/^about:/,
|
||||
/^edge:\/\//,
|
||||
/^brave:\/\//,
|
||||
/^opera:\/\//,
|
||||
/^vivaldi:\/\//,
|
||||
/^file:\/\//,
|
||||
/^view-source:/,
|
||||
/^devtools:\/\//,
|
||||
]
|
||||
|
||||
return !restrictedPatterns.some((pattern) => pattern.test(url))
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* background logics for TabsController
|
||||
*/
|
||||
import type { TabAction } from './TabsController'
|
||||
|
||||
export function handleTabControlMessage(
|
||||
message: { type: 'TAB_CONTROL'; action: TabAction; payload: any },
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response: unknown) => void
|
||||
): true | undefined {
|
||||
const { action, payload } = message
|
||||
|
||||
switch (action as TabAction) {
|
||||
case 'get_active_tab': {
|
||||
chrome.tabs
|
||||
.query({ active: true, currentWindow: true })
|
||||
.then((tabs) => {
|
||||
const tabId = tabs.length > 0 ? tabs[0].id || null : null
|
||||
sendResponse({ success: true, tabId })
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return true // async response
|
||||
}
|
||||
|
||||
case 'get_tab_info': {
|
||||
chrome.tabs
|
||||
.get(payload.tabId)
|
||||
.then((tab) => {
|
||||
const result = { title: tab.title || '', url: tab.url || '' }
|
||||
sendResponse(result)
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return true // async response
|
||||
}
|
||||
|
||||
case 'open_new_tab': {
|
||||
chrome.tabs
|
||||
.create({ url: payload.url, active: false })
|
||||
.then((newTab) => {
|
||||
// @todo: wait for tab to be fully loaded
|
||||
sendResponse({ success: true, tabId: newTab.id, windowId: newTab.windowId })
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return true // async response
|
||||
}
|
||||
|
||||
case 'create_tab_group': {
|
||||
chrome.tabs
|
||||
.group({ tabIds: payload.tabIds, createProperties: { windowId: payload.windowId } })
|
||||
.then((groupId) => {
|
||||
console.log('Created tab group', groupId)
|
||||
sendResponse({ success: true, groupId })
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to create tab group', error)
|
||||
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return true // async response
|
||||
}
|
||||
|
||||
case 'update_tab_group': {
|
||||
chrome.tabGroups
|
||||
.update(payload.groupId, payload.properties)
|
||||
.then(() => {
|
||||
sendResponse({ success: true })
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return true // async response
|
||||
}
|
||||
|
||||
case 'add_tab_to_group': {
|
||||
chrome.tabs
|
||||
.group({ tabIds: payload.tabId, groupId: payload.groupId })
|
||||
.then(() => {
|
||||
sendResponse({ success: true })
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return true // async response
|
||||
}
|
||||
|
||||
case 'close_tab': {
|
||||
chrome.tabs
|
||||
.remove(payload.tabId)
|
||||
.then(() => {
|
||||
sendResponse({ success: true })
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return true // async response
|
||||
}
|
||||
|
||||
default:
|
||||
sendResponse({ error: `Unknown action: ${action}` })
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Controller for managing browser tabs.
|
||||
* - live in the agent env (extension page or content script)
|
||||
* - no chrome apis. call sw for tab operations
|
||||
*/
|
||||
export class TabsController extends EventTarget {
|
||||
currentTabId: number | null = null
|
||||
|
||||
private tabs: TabMeta[] = []
|
||||
private initialTabId: number | null = null
|
||||
private tabGroupId: number | null = null
|
||||
private task: string = ''
|
||||
private windowId: number | null = null
|
||||
|
||||
async init(task: string, includeInitialTab: boolean = true) {
|
||||
this.task = task
|
||||
this.tabs = []
|
||||
this.currentTabId = null
|
||||
this.tabGroupId = null
|
||||
this.initialTabId = null
|
||||
this.windowId = null
|
||||
|
||||
const result = await chrome.runtime.sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'get_active_tab',
|
||||
})
|
||||
|
||||
this.initialTabId = result.tabId
|
||||
|
||||
if (!this.initialTabId) {
|
||||
throw new Error('Failed to get initial tab ID')
|
||||
}
|
||||
|
||||
if (includeInitialTab) {
|
||||
this.currentTabId = this.initialTabId
|
||||
this.tabs.push({
|
||||
id: result.tabId,
|
||||
isInitial: true,
|
||||
})
|
||||
}
|
||||
|
||||
await this.updateCurrentTabId(this.currentTabId)
|
||||
|
||||
const tabChangeHandler = (message: any): void => {
|
||||
if (message.type !== 'TAB_CHANGE') {
|
||||
// throw new Error(`[TabsController]: Invalid message type: ${message.type}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (message.action === 'created') {
|
||||
const tab = message.payload.tab as chrome.tabs.Tab
|
||||
if (tab.groupId === this.tabGroupId && tab.id != null) {
|
||||
// Tab created in our controlled group
|
||||
if (!this.tabs.find((t) => t.id === tab.id)) {
|
||||
this.tabs.push({ id: tab.id, isInitial: false })
|
||||
}
|
||||
this.switchToTab(tab.id)
|
||||
}
|
||||
} else if (message.action === 'removed') {
|
||||
const { tabId } = message.payload as { tabId: number }
|
||||
const targetTab = this.tabs.find((t) => t.id === tabId)
|
||||
if (targetTab) {
|
||||
this.tabs = this.tabs.filter((t) => t.id !== tabId)
|
||||
if (this.currentTabId === tabId) {
|
||||
const newCurrentTab = this.tabs[this.tabs.length - 1] || null
|
||||
if (newCurrentTab) {
|
||||
this.switchToTab(newCurrentTab.id)
|
||||
} else {
|
||||
this.updateCurrentTabId(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener(tabChangeHandler)
|
||||
|
||||
this.addEventListener('dispose', () => {
|
||||
chrome.runtime.onMessage.removeListener(tabChangeHandler)
|
||||
})
|
||||
}
|
||||
|
||||
async openNewTab(url: string): Promise<{ success: boolean; tabId: number; message: string }> {
|
||||
const result = await chrome.runtime.sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'open_new_tab',
|
||||
payload: { url },
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Failed to open new tab: ${result.error}`)
|
||||
}
|
||||
|
||||
const tabId = result.tabId as number
|
||||
const windowId = result.windowId as number
|
||||
|
||||
this.windowId = windowId
|
||||
|
||||
this.tabs.push({
|
||||
id: tabId,
|
||||
isInitial: false,
|
||||
})
|
||||
|
||||
await this.switchToTab(tabId)
|
||||
|
||||
if (!this.tabGroupId) {
|
||||
const result = await chrome.runtime.sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'create_tab_group',
|
||||
payload: { tabIds: [tabId], windowId: this.windowId },
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Failed to create tab group: ${result.error}`)
|
||||
}
|
||||
|
||||
const groupId = result.groupId as number
|
||||
|
||||
this.tabGroupId = groupId
|
||||
|
||||
await chrome.runtime.sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'update_tab_group',
|
||||
payload: {
|
||||
groupId: this.tabGroupId,
|
||||
properties: {
|
||||
title: `PageAgent(${this.task})`,
|
||||
color: randomColor(),
|
||||
collapsed: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await chrome.runtime.sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'add_tab_to_group',
|
||||
payload: { tabId: result.tabId, groupId: this.tabGroupId },
|
||||
})
|
||||
}
|
||||
|
||||
// wait for the new tab to be fully loaded
|
||||
// @todo
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
tabId,
|
||||
message: `Opened new tab ID ${tabId} with URL ${url}`,
|
||||
}
|
||||
}
|
||||
|
||||
async switchToTab(tabId: number): Promise<{ success: boolean; message: string }> {
|
||||
const targetTab = this.tabs.find((t) => t.id === tabId)
|
||||
if (!targetTab) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Tab ID ${tabId} not found in tab list.`,
|
||||
}
|
||||
}
|
||||
|
||||
await this.updateCurrentTabId(tabId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Switched to tab ID ${tabId}.`,
|
||||
}
|
||||
}
|
||||
|
||||
async closeTab(tabId: number): Promise<{ success: boolean; message: string }> {
|
||||
const targetTab = this.tabs.find((t) => t.id === tabId)
|
||||
if (!targetTab) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Tab ID ${tabId} not found in tab list.`,
|
||||
}
|
||||
}
|
||||
if (targetTab.isInitial) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Cannot close the initial tab ID ${tabId}.`,
|
||||
}
|
||||
}
|
||||
|
||||
const result = await chrome.runtime.sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'close_tab',
|
||||
payload: { tabId },
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
this.tabs = this.tabs.filter((t) => t.id !== tabId)
|
||||
if (this.currentTabId === tabId) {
|
||||
const newCurrentTab = this.tabs[this.tabs.length - 1] || null
|
||||
if (newCurrentTab) {
|
||||
await this.switchToTab(newCurrentTab.id)
|
||||
} else {
|
||||
await this.updateCurrentTabId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Closed tab ID ${tabId}.`,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to close tab ID ${tabId}: ${result.error}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateCurrentTabId(tabId: number | null) {
|
||||
this.currentTabId = tabId
|
||||
await chrome.storage.local.set({ currentTabId: tabId })
|
||||
}
|
||||
|
||||
async getTabInfo(tabId: number): Promise<{ title: string; url: string }> {
|
||||
const result = await chrome.runtime.sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'get_tab_info',
|
||||
payload: { tabId },
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
async summarizeTabs(): Promise<string> {
|
||||
const summaries = [`| Tab ID | URL | Title | Current |`, `|-----|-----|-----|-----|`]
|
||||
for (const tab of this.tabs) {
|
||||
const { title, url } = await this.getTabInfo(tab.id)
|
||||
summaries.push(
|
||||
`| ${tab.id} | ${url} | ${title} | ${this.currentTabId === tab.id ? '✅' : ''} |`
|
||||
)
|
||||
}
|
||||
if (!this.tabs.length) {
|
||||
summaries.push('\nNo tabs available. Open a tab if needed.')
|
||||
}
|
||||
|
||||
return summaries.join('\n')
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.dispatchEvent(new Event('dispose'))
|
||||
}
|
||||
}
|
||||
|
||||
export type TabAction =
|
||||
| 'get_active_tab'
|
||||
| 'get_tab_info'
|
||||
| 'open_new_tab'
|
||||
| 'create_tab_group'
|
||||
| 'update_tab_group'
|
||||
| 'add_tab_to_group'
|
||||
| 'close_tab'
|
||||
| 'get_tab_title'
|
||||
|
||||
interface TabMeta {
|
||||
id: number
|
||||
isInitial: boolean
|
||||
}
|
||||
|
||||
const TAB_GROUP_COLORS = [
|
||||
'grey',
|
||||
'blue',
|
||||
'red',
|
||||
'yellow',
|
||||
'green',
|
||||
'pink',
|
||||
'purple',
|
||||
'cyan',
|
||||
] as const
|
||||
|
||||
type TabGroupColor = (typeof TAB_GROUP_COLORS)[number]
|
||||
|
||||
function randomColor(): TabGroupColor {
|
||||
return TAB_GROUP_COLORS[Math.floor(Math.random() * TAB_GROUP_COLORS.length)]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { LLMConfig } from '@page-agent/llms'
|
||||
|
||||
// Demo LLM for testing
|
||||
export const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
export const DEMO_BASE_URL =
|
||||
'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
|
||||
export const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
|
||||
export const DEMO_CONFIG: LLMConfig = {
|
||||
apiKey: DEMO_API_KEY,
|
||||
baseURL: DEMO_BASE_URL,
|
||||
model: DEMO_MODEL,
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
You are an AI agent designed to operate in an iterative loop to automate browser tasks. Your ultimate goal is accomplishing the task provided in <user_request>.
|
||||
|
||||
<intro>
|
||||
You excel at following tasks:
|
||||
1. Navigating complex websites and extracting precise information
|
||||
2. Automating form submissions and interactive web actions
|
||||
3. Gathering and saving information
|
||||
4. Operate effectively in an agent loop
|
||||
5. Efficiently performing diverse web tasks
|
||||
</intro>
|
||||
|
||||
<language_settings>
|
||||
- Default working language: **English**
|
||||
- Use the language that user is using. Return in user's language.
|
||||
</language_settings>
|
||||
|
||||
<input>
|
||||
At every step, your input will consist of:
|
||||
1. <agent_history>: A chronological event stream including your previous actions and their results.
|
||||
2. <agent_state>: Current <user_request> and <step_info>.
|
||||
3. <browser_state>: Tabs, Current Tab, Current URL, interactive elements indexed for actions, and visible page content.
|
||||
</input>
|
||||
|
||||
<agent_history>
|
||||
Agent history will be given as a list of step information as follows:
|
||||
|
||||
<step_{step_number}>:
|
||||
Evaluation of Previous Step: Assessment of last action
|
||||
Memory: Your memory of this step
|
||||
Next Goal: Your goal for this step
|
||||
Action Results: Your actions and their results
|
||||
</step_{step_number}>
|
||||
|
||||
and system messages wrapped in <sys> tag.
|
||||
</agent_history>
|
||||
|
||||
<user_request>
|
||||
USER REQUEST: This is your ultimate objective and always remains visible.
|
||||
- This has the highest priority. Make the user happy.
|
||||
- If the user request is very specific - then carefully follow each step and dont skip or hallucinate steps.
|
||||
- If the task is open ended you can plan yourself how to get it done.
|
||||
</user_request>
|
||||
|
||||
<browser_state>
|
||||
1. Browser State will be given as:
|
||||
|
||||
Open Tabs: Open tabs with their ids.
|
||||
Current Tab: The tab you are currently viewing.
|
||||
Current URL: URL of the page you are currently viewing.
|
||||
Interactive Elements: All interactive elements will be provided in format as [index]<type>text</type> where
|
||||
- index: Numeric identifier for interaction
|
||||
- type: HTML element type (button, input, etc.)
|
||||
- text: Element description
|
||||
|
||||
Examples:
|
||||
[33]<div>User form</div>
|
||||
\t*[35]<button aria-label='Submit form'>Submit</button>
|
||||
|
||||
Note that:
|
||||
- Only elements with numeric indexes in [] are interactive
|
||||
- (stacked) indentation (with \t) is important and means that the element is a (html) child of the element above (with a lower index)
|
||||
- Elements tagged with `*[` are the new clickable elements that appeared on the website since the last step - if url has not changed.
|
||||
- Pure text elements without [] are not interactive.
|
||||
</browser_state>
|
||||
|
||||
<browser_rules>
|
||||
Strictly follow these rules while using the browser and navigating the web:
|
||||
- Only interact with elements that have a numeric [index] assigned.
|
||||
- Only use indexes that are explicitly provided.
|
||||
- If the page changes after, for example, an input text action, analyze if you need to interact with new elements, e.g. selecting the right option from the list.
|
||||
- By default, only elements in the visible viewport are listed. Use scrolling actions if you suspect relevant content is offscreen which you need to interact with. Scroll ONLY if there are more pixels below or above the page.
|
||||
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
|
||||
- All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed.
|
||||
- If a captcha appears, tell user you can not solve captcha. finished the task and ask user to solve it.
|
||||
- If expected elements are missing, try scrolling, or navigating back.
|
||||
- If the page is not fully loaded, use the `wait` action.
|
||||
- Do not repeat one action for more than 3 times unless some conditions changed.
|
||||
- If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field.
|
||||
- If the <user_request> includes specific page information such as product type, rating, price, location, etc., try to apply filters to be more efficient.
|
||||
- The <user_request> is the ultimate goal. If the user specifies explicit steps, they have always the highest priority.
|
||||
- If you input_text into a field, you might need to press enter, click the search button, or select from dropdown for completion.
|
||||
- Don't login into a page if you don't have to. Don't login if you don't have the credentials.
|
||||
- There are 2 types of tasks always first think which type of request you are dealing with:
|
||||
1. Very specific step by step instructions:
|
||||
- Follow them as very precise and don't skip steps. Try to complete everything as requested.
|
||||
2. Open ended tasks. Plan yourself, be creative in achieving them.
|
||||
- If you get stuck e.g. with logins or captcha in open-ended tasks you can re-evaluate the task and try alternative ways, e.g. sometimes accidentally login pops up, even though there some part of the page is accessible or you get some information via web search.
|
||||
</browser_rules>
|
||||
|
||||
<task_completion_rules>
|
||||
You must call the `done` action in one of three cases:
|
||||
- When you have fully completed the USER REQUEST.
|
||||
- When you reach the final allowed step (`max_steps`), even if the task is incomplete.
|
||||
- When you feel stuck or unable to solve user request. Or user request is not clear or contains inappropriate content.
|
||||
- When it is ABSOLUTELY IMPOSSIBLE to continue.
|
||||
|
||||
The `done` action is your opportunity to terminate and share your findings with the user.
|
||||
- Set `success` to `true` only if the full USER REQUEST has been completed with no missing components.
|
||||
- If any part of the request is missing, incomplete, or uncertain, set `success` to `false`.
|
||||
- You can use the `text` field of the `done` action to communicate your findings and to provide a coherent reply to the user and fulfill the USER REQUEST.
|
||||
- You are ONLY ALLOWED to call `done` as a single action. Don't call it together with other actions.
|
||||
- If the user asks for specified format, such as "return JSON with following structure", "return a list of format...", MAKE sure to use the right format in your answer.
|
||||
- If the user asks for a structured output, your `done` action's schema may be modified. Take this schema into account when solving the task!
|
||||
</task_completion_rules>
|
||||
|
||||
<reasoning_rules>
|
||||
Exhibit the following reasoning patterns to successfully achieve the <user_request>:
|
||||
|
||||
- Reason about <agent_history> to track progress and context toward <user_request>.
|
||||
- Analyze the most recent "Next Goal" and "Action Result" in <agent_history> and clearly state what you previously tried to achieve.
|
||||
- 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. 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>
|
||||
|
||||
<examples>
|
||||
Here are examples of good output patterns. Use them as reference but never copy them directly.
|
||||
|
||||
<evaluation_examples>
|
||||
- Positive Examples:
|
||||
"evaluation_previous_goal": "Successfully navigated to the product page and found the target information. Verdict: Success"
|
||||
"evaluation_previous_goal": "Clicked the login button and user authentication form appeared. Verdict: Success"
|
||||
</evaluation_examples>
|
||||
|
||||
<memory_examples>
|
||||
"memory": "Found many pending reports that need to be analyzed in the main page. Successfully processed the first 2 reports on quarterly sales data and moving on to inventory analysis and customer feedback reports."
|
||||
</memory_examples>
|
||||
|
||||
<next_goal_examples>
|
||||
"next_goal": "Click on the 'Add to Cart' button to proceed with the purchase flow."
|
||||
"next_goal": "Extract details from the first item on the page."
|
||||
</next_goal_examples>
|
||||
</examples>
|
||||
|
||||
<output>
|
||||
You must ALWAYS respond with a valid JSON in this exact format:
|
||||
|
||||
{
|
||||
"evaluation_previous_goal": "Concise one-sentence analysis of your last action. Clearly state success, failure, or uncertain.",
|
||||
"memory": "1-3 concise sentences of specific memory of this step and overall progress. You should put here everything that will help you track progress in future steps. Like counting pages visited, items found, etc.",
|
||||
"next_goal": "State the next immediate goal and action to achieve it, in one clear sentence."
|
||||
"action":{"action_name": {// action-specific parameter}}
|
||||
}
|
||||
</output>
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Tab control tools for browser extension
|
||||
*
|
||||
* These tools allow the agent to manage multiple browser tabs:
|
||||
* - open_new_tab: Open a new tab and set it as current
|
||||
* - switch_to_tab: Switch to an existing tab
|
||||
* - close_tab: Close a tab (optionally switch to another)
|
||||
*/
|
||||
import zod from 'zod'
|
||||
|
||||
import type { TabsController } from './TabsController'
|
||||
|
||||
/** Tool definition compatible with PageAgentCore customTools */
|
||||
interface TabTool {
|
||||
description: string
|
||||
inputSchema: zod.ZodType
|
||||
execute: (input: unknown) => Promise<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tab control tools bound to a TabsManager instance.
|
||||
* These tools are injected into PageAgentCore via customTools config.
|
||||
*/
|
||||
export function createTabTools(tabsController: TabsController): Record<string, TabTool> {
|
||||
return {
|
||||
open_new_tab: {
|
||||
description:
|
||||
'Open a new browser tab with the specified URL. The new tab becomes the current tab for all subsequent page operations.',
|
||||
inputSchema: zod.object({
|
||||
url: zod.string().describe('The URL to open in the new tab'),
|
||||
}),
|
||||
execute: async (input: unknown) => {
|
||||
const { url } = input as { url: string }
|
||||
const result = await tabsController.openNewTab(url)
|
||||
return result.message
|
||||
},
|
||||
},
|
||||
|
||||
switch_to_tab: {
|
||||
description:
|
||||
'Switch to an existing tab by its ID. After switching, all page operations will target the new current tab. You can only switch to tabs in the tab list shown in browser state.',
|
||||
inputSchema: zod.object({
|
||||
tab_id: zod.number().int().describe('The tab ID to switch to'),
|
||||
}),
|
||||
execute: async (input: unknown) => {
|
||||
const { tab_id } = input as { tab_id: number }
|
||||
return (await tabsController.switchToTab(tab_id)).message
|
||||
},
|
||||
},
|
||||
|
||||
close_tab: {
|
||||
description:
|
||||
'Close a tab by its ID. Cannot close the initial tab. Optionally specify which tab to switch to after closing.',
|
||||
inputSchema: zod.object({
|
||||
tab_id: zod.number().int().describe('The tab ID to close'),
|
||||
}),
|
||||
execute: async (input: unknown) => {
|
||||
const { tab_id } = input as { tab_id: number }
|
||||
return (await tabsController.closeTab(tab_id)).message
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* React hook for using AgentController
|
||||
*/
|
||||
import type { AgentActivity, AgentStatus, HistoricalEvent } from '@page-agent/core'
|
||||
import type { LLMConfig } from '@page-agent/llms'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { MultiPageAgent } from './MultiPageAgent'
|
||||
import { DEMO_CONFIG } from './constants'
|
||||
|
||||
export interface UseAgentResult {
|
||||
status: AgentStatus
|
||||
history: HistoricalEvent[]
|
||||
activity: AgentActivity | null
|
||||
currentTask: string
|
||||
config: LLMConfig | null
|
||||
execute: (task: string) => Promise<void>
|
||||
stop: () => void
|
||||
configure: (config: LLMConfig) => Promise<void>
|
||||
}
|
||||
|
||||
export function useAgent(): UseAgentResult {
|
||||
const agentRef = useRef<MultiPageAgent | null>(null)
|
||||
const [status, setStatus] = useState<AgentStatus>('idle')
|
||||
const [history, setHistory] = useState<HistoricalEvent[]>([])
|
||||
const [activity, setActivity] = useState<AgentActivity | null>(null)
|
||||
const [currentTask, setCurrentTask] = useState('')
|
||||
const [config, setConfig] = useState<LLMConfig | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
chrome.storage.local.get('llmConfig').then((result) => {
|
||||
if (result.llmConfig) {
|
||||
setConfig(result.llmConfig as LLMConfig)
|
||||
} else {
|
||||
chrome.storage.local.set({ llmConfig: DEMO_CONFIG })
|
||||
setConfig(DEMO_CONFIG)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return
|
||||
|
||||
const agent = new MultiPageAgent(config)
|
||||
agentRef.current = agent
|
||||
|
||||
const handleStatusChange = (e: Event) => {
|
||||
const newStatus = agent.status as AgentStatus
|
||||
setStatus(newStatus)
|
||||
if (newStatus === 'idle' || newStatus === 'completed' || newStatus === 'error') {
|
||||
setActivity(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleHistoryChange = (e: Event) => {
|
||||
setHistory([...agent.history])
|
||||
}
|
||||
|
||||
const handleActivity = (e: Event) => {
|
||||
const newActivity = (e as CustomEvent).detail as AgentActivity
|
||||
setActivity(newActivity)
|
||||
}
|
||||
|
||||
agent.addEventListener('statuschange', handleStatusChange)
|
||||
agent.addEventListener('historychange', handleHistoryChange)
|
||||
agent.addEventListener('activity', handleActivity)
|
||||
|
||||
return () => {
|
||||
agent.removeEventListener('statuschange', handleStatusChange)
|
||||
agent.removeEventListener('historychange', handleHistoryChange)
|
||||
agent.removeEventListener('activity', handleActivity)
|
||||
agent.dispose()
|
||||
}
|
||||
}, [config])
|
||||
|
||||
const execute = useCallback(async (task: string) => {
|
||||
const agent = agentRef.current
|
||||
console.log('Fire task:', task, agent)
|
||||
if (!agent) throw new Error('Agent not initialized')
|
||||
|
||||
setCurrentTask(task)
|
||||
setHistory([])
|
||||
await agent.execute(task)
|
||||
}, [])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
agentRef.current?.dispose()
|
||||
}, [])
|
||||
|
||||
const configure = useCallback(async (newConfig: LLMConfig) => {
|
||||
await chrome.storage.local.set({ llmConfig: newConfig })
|
||||
setConfig(newConfig)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
status,
|
||||
history,
|
||||
activity,
|
||||
currentTask,
|
||||
config,
|
||||
execute,
|
||||
stop,
|
||||
configure,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.19 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.396 0.141 25.723);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.439 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
@@ -0,0 +1,60 @@
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn('leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
|
||||
@@ -0,0 +1,232 @@
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
'flex flex-col gap-6',
|
||||
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = 'legend',
|
||||
...props
|
||||
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
'mb-3 font-medium',
|
||||
'data-[variant=legend]:text-base',
|
||||
'data-[variant=label]:text-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva('group/field flex w-full gap-3 data-[invalid=true]:text-destructive', {
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'],
|
||||
horizontal: [
|
||||
'flex-row items-center',
|
||||
'[&>[data-slot=field-label]]:flex-auto',
|
||||
'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
|
||||
],
|
||||
responsive: [
|
||||
'flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto',
|
||||
'@md/field-group:[&>[data-slot=field-label]]:flex-auto',
|
||||
'@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: 'vertical',
|
||||
},
|
||||
})
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn('group/field-content flex flex-1 flex-col gap-1.5 leading-snug', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
|
||||
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
|
||||
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
|
||||
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
|
||||
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [...new Map(errors.map((error) => [error?.message, error])).values()]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map((error, index) => error?.message && <li key={index}>{error.message}</li>)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn('text-destructive text-sm font-normal', className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function HoverCard({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardTrigger({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
@@ -0,0 +1,156 @@
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
|
||||
'h-9 min-w-0 has-[>textarea]:h-auto',
|
||||
|
||||
// Variants based on alignment.
|
||||
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
|
||||
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
|
||||
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
|
||||
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
|
||||
|
||||
// Focus state.
|
||||
'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',
|
||||
|
||||
// Error state.
|
||||
'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
|
||||
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
'inline-start': 'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
|
||||
'inline-end': 'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]',
|
||||
'block-start':
|
||||
'order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5',
|
||||
'block-end':
|
||||
'order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: 'inline-start',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = 'inline-start',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('button')) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector('input')?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva('text-sm shadow-none flex gap-2 items-center', {
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||
sm: 'h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5',
|
||||
'icon-xs': 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
|
||||
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'xs',
|
||||
},
|
||||
})
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = 'button',
|
||||
variant = 'ghost',
|
||||
size = 'xs',
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, 'size'> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn('group/item-group flex flex-col', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
className={cn('my-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
'group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
outline: 'border-border',
|
||||
muted: 'bg-muted/50',
|
||||
},
|
||||
size: {
|
||||
default: 'p-4 gap-4 ',
|
||||
sm: 'py-3 px-4 gap-2.5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'div'
|
||||
return (
|
||||
<Comp
|
||||
data-slot="item"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(itemVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||
image: 'size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn('flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn('flex w-fit items-center gap-2 text-sm leading-snug font-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
|
||||
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div data-slot="item-actions" className={cn('flex items-center gap-2', className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn('flex basis-full items-center justify-between gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn('flex basis-full items-center justify-between gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { Toaster as Sonner, type ToasterProps } from 'sonner'
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = 'system' } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps['theme']}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
'--border-radius': 'var(--radius)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Loader2Icon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<Loader2Icon
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn('size-4 animate-spin', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,48 @@
|
||||
import { handlePageControlMessage } from '@/agent/RemotePageController.background'
|
||||
import { handleTabControlMessage } from '@/agent/TabsController.background'
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse): true | undefined => {
|
||||
if (message.type === 'TAB_CONTROL') {
|
||||
return handleTabControlMessage(message, sender, sendResponse)
|
||||
} else if (message.type === 'PAGE_CONTROL') {
|
||||
return handlePageControlMessage(message, sender, sendResponse)
|
||||
} else {
|
||||
sendResponse({ error: 'Unknown message type' })
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Extension Setup
|
||||
// ============================================================================
|
||||
|
||||
export default defineBackground(() => {
|
||||
console.log('[Background] Service Worker started')
|
||||
|
||||
// generate user auth token
|
||||
|
||||
chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => {
|
||||
if (result.PageAgentExtUserAuthToken) return
|
||||
|
||||
const userAuthToken = crypto.randomUUID()
|
||||
chrome.storage.local.set({ PageAgentExtUserAuthToken: userAuthToken })
|
||||
})
|
||||
|
||||
// setup
|
||||
|
||||
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
|
||||
|
||||
// Tab change events
|
||||
|
||||
chrome.tabs.onCreated.addListener((tab) => {
|
||||
chrome.runtime.sendMessage({ type: 'TAB_CHANGE', action: 'created', payload: { tab } })
|
||||
})
|
||||
|
||||
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'TAB_CHANGE',
|
||||
action: 'removed',
|
||||
payload: { tabId, removeInfo },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { initPageController } from '@/agent/RemotePageController.content'
|
||||
|
||||
// import { DEMO_CONFIG } from '@/agent/constants'
|
||||
|
||||
const DEBUG_PREFIX = '[Content]'
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
runAt: 'document_idle',
|
||||
|
||||
main() {
|
||||
console.debug(`${DEBUG_PREFIX} Loaded on ${window.location.href}`)
|
||||
initPageController()
|
||||
|
||||
// if auth token matches, expose agent to page
|
||||
chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => {
|
||||
// extension side token.
|
||||
// @note this is isolated world. it is safe to assume user script cannot access it
|
||||
const extToken = result.PageAgentExtUserAuthToken
|
||||
if (!extToken) return
|
||||
|
||||
// page side token
|
||||
const pageToken = localStorage.getItem('PageAgentExtUserAuthToken')
|
||||
if (!pageToken) return
|
||||
|
||||
if (pageToken !== extToken) return
|
||||
|
||||
console.log('[PageAgentExt]: Auth tokens match. Exposing agent to page.')
|
||||
|
||||
// add isolated world script
|
||||
exposeAgentToPage().then(
|
||||
// add main-world script
|
||||
() => injectScript('/main-world.js')
|
||||
)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
async function exposeAgentToPage() {
|
||||
const { MultiPageAgent } = await import('@/agent/MultiPageAgent')
|
||||
console.log('[PageAgentExt]: MultiPageAgent loaded')
|
||||
|
||||
/**
|
||||
* singleton MultiPageAgent to handle requests from the page
|
||||
*/
|
||||
let multiPageAgent: InstanceType<typeof MultiPageAgent> | null = null
|
||||
|
||||
window.addEventListener('message', async (e) => {
|
||||
const data = e.data
|
||||
if (typeof data !== 'object' || data === null) return
|
||||
if (data.channel !== 'PAGE_AGENT_EXT_REQUEST') return
|
||||
|
||||
const { action, payload, id } = data
|
||||
|
||||
switch (action) {
|
||||
case 'execute': {
|
||||
// singleton check
|
||||
if (multiPageAgent && multiPageAgent.status === 'running') {
|
||||
window.postMessage(
|
||||
{
|
||||
channel: 'PAGE_AGENT_EXT_RESPONSE',
|
||||
id,
|
||||
action: 'execute_result',
|
||||
error: 'Agent is already running a task. Please wait until it finishes.',
|
||||
},
|
||||
'*'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { task, config } = payload
|
||||
|
||||
// create when used
|
||||
|
||||
multiPageAgent = new MultiPageAgent(config)
|
||||
|
||||
// events
|
||||
|
||||
multiPageAgent.addEventListener('statuschange', (event) => {
|
||||
if (!multiPageAgent) return
|
||||
window.postMessage(
|
||||
{
|
||||
channel: 'PAGE_AGENT_EXT_RESPONSE',
|
||||
id,
|
||||
action: 'status_change_event',
|
||||
payload: multiPageAgent.status,
|
||||
},
|
||||
'*'
|
||||
)
|
||||
})
|
||||
|
||||
multiPageAgent.addEventListener('activity', (event) => {
|
||||
if (!multiPageAgent) return
|
||||
window.postMessage(
|
||||
{
|
||||
channel: 'PAGE_AGENT_EXT_RESPONSE',
|
||||
id,
|
||||
action: 'activity_event',
|
||||
payload: (event as CustomEvent).detail,
|
||||
},
|
||||
'*'
|
||||
)
|
||||
})
|
||||
|
||||
multiPageAgent.addEventListener('historychange', (event) => {
|
||||
if (!multiPageAgent) return
|
||||
window.postMessage(
|
||||
{
|
||||
channel: 'PAGE_AGENT_EXT_RESPONSE',
|
||||
id,
|
||||
action: 'history_change_event',
|
||||
payload: multiPageAgent.history,
|
||||
},
|
||||
'*'
|
||||
)
|
||||
})
|
||||
|
||||
multiPageAgent.addEventListener('dispose', () => {
|
||||
window.postMessage(
|
||||
{
|
||||
channel: 'PAGE_AGENT_EXT_RESPONSE',
|
||||
id,
|
||||
action: 'dispose_event',
|
||||
},
|
||||
'*'
|
||||
)
|
||||
})
|
||||
|
||||
// result
|
||||
|
||||
const result = await multiPageAgent.execute(task)
|
||||
|
||||
window.postMessage(
|
||||
{
|
||||
channel: 'PAGE_AGENT_EXT_RESPONSE',
|
||||
id,
|
||||
action: 'execute_result',
|
||||
payload: result,
|
||||
},
|
||||
'*'
|
||||
)
|
||||
} catch (error) {
|
||||
window.postMessage(
|
||||
{
|
||||
channel: 'PAGE_AGENT_EXT_RESPONSE',
|
||||
id,
|
||||
action: 'execute_result',
|
||||
error: (error as Error).message,
|
||||
},
|
||||
'*'
|
||||
)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'dispose': {
|
||||
// @note stop ongoing processes but can still be re-used later
|
||||
multiPageAgent?.dispose()
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
console.warn(`${DEBUG_PREFIX} Unknown action from page:`, action)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core'
|
||||
|
||||
export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
|
||||
|
||||
export interface ExecuteConfig {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
|
||||
/**
|
||||
* Whether to include the initial tab (that holds this main world script) in the task.
|
||||
* @default true
|
||||
*/
|
||||
includeInitialTab?: boolean
|
||||
|
||||
onStatusChange?: (status: AgentStatus) => void
|
||||
onActivity?: (activity: AgentActivity) => void
|
||||
onHistoryUpdate?: (history: HistoricalEvent[]) => void
|
||||
onDispose?: () => void
|
||||
}
|
||||
|
||||
export default defineUnlistedScript(() => {
|
||||
let _lastId = 0
|
||||
function getId() {
|
||||
_lastId += 1
|
||||
return _lastId
|
||||
}
|
||||
|
||||
const execute: Execute = async (task, config) => {
|
||||
if (typeof task !== 'string') throw new Error('Task must be a string')
|
||||
if (task.trim().length === 0) throw new Error('Task cannot be empty')
|
||||
if (!config) throw new Error('Config is required')
|
||||
if (!config.baseURL) throw new Error('Config must have a baseURL')
|
||||
if (!config.apiKey) throw new Error('Config must have an apiKey')
|
||||
if (!config.model) throw new Error('Config must have a model')
|
||||
|
||||
const id = getId()
|
||||
|
||||
const promise = new Promise<ExecutionResult>((resolve, reject) => {
|
||||
function handleMessage(e: MessageEvent) {
|
||||
const data = e.data
|
||||
if (typeof data !== 'object' || data === null) return
|
||||
if (data.channel !== 'PAGE_AGENT_EXT_RESPONSE') return
|
||||
if (data.id !== id) return
|
||||
|
||||
// events
|
||||
|
||||
if (data.action === 'status_change_event' && config.onStatusChange) {
|
||||
config.onStatusChange(data.payload)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.action === 'activity_event' && config.onActivity) {
|
||||
config.onActivity(data.payload)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.action === 'history_change_event' && config.onHistoryUpdate) {
|
||||
config.onHistoryUpdate(data.payload)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.action === 'dispose_event' && config.onDispose) {
|
||||
config.onDispose()
|
||||
window.removeEventListener('message', handleMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.action !== 'execute_result') return
|
||||
|
||||
// execute_result
|
||||
|
||||
window.removeEventListener('message', handleMessage)
|
||||
|
||||
if (data.error) {
|
||||
reject(new Error(data.error))
|
||||
} else {
|
||||
resolve(data.payload)
|
||||
}
|
||||
}
|
||||
|
||||
// @note will be removed on dispose or result
|
||||
window.addEventListener('message', handleMessage)
|
||||
})
|
||||
|
||||
window.postMessage(
|
||||
{
|
||||
channel: 'PAGE_AGENT_EXT_REQUEST',
|
||||
id,
|
||||
action: 'execute',
|
||||
payload: {
|
||||
task,
|
||||
config: {
|
||||
baseURL: config.baseURL,
|
||||
apiKey: config.apiKey,
|
||||
model: config.model,
|
||||
includeInitialTab: config.includeInitialTab,
|
||||
},
|
||||
},
|
||||
},
|
||||
'*'
|
||||
)
|
||||
|
||||
return promise
|
||||
}
|
||||
|
||||
const dispose = () => {
|
||||
const id = getId()
|
||||
|
||||
window.postMessage(
|
||||
{
|
||||
channel: 'PAGE_AGENT_EXT_REQUEST',
|
||||
id,
|
||||
action: 'dispose',
|
||||
},
|
||||
'*'
|
||||
)
|
||||
}
|
||||
|
||||
;(window as any).PAGE_AGENT_EXT_VERSION = __EXT_VERSION__
|
||||
;(window as any).PAGE_AGENT_EXT = {
|
||||
version: __EXT_VERSION__,
|
||||
execute,
|
||||
dispose,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Send, Settings, Square } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupTextarea,
|
||||
} from '@/components/ui/input-group'
|
||||
|
||||
import { useAgent } from '../../agent/useAgent'
|
||||
import { ConfigPanel } from './components/ConfigPanel'
|
||||
import { ActivityCard, EventCard } from './components/cards'
|
||||
import { EmptyState, Logo, StatusDot } from './components/misc'
|
||||
|
||||
export default function App() {
|
||||
const [showConfig, setShowConfig] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const historyRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const { status, history, activity, currentTask, config, execute, stop, configure } = useAgent()
|
||||
|
||||
// Auto-scroll to bottom on new events
|
||||
useEffect(() => {
|
||||
if (historyRef.current) {
|
||||
historyRef.current.scrollTop = historyRef.current.scrollHeight
|
||||
}
|
||||
}, [history, activity])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e?: React.SyntheticEvent) => {
|
||||
e?.preventDefault()
|
||||
if (!inputValue.trim() || status === 'running') return
|
||||
|
||||
const taskToExecute = inputValue.trim()
|
||||
setInputValue('')
|
||||
|
||||
execute(taskToExecute).catch((error) => {
|
||||
console.error('[SidePanel] Failed to execute task:', error)
|
||||
})
|
||||
},
|
||||
[inputValue, status, execute]
|
||||
)
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
console.log('[SidePanel] Stopping task...')
|
||||
stop()
|
||||
}, [stop])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
if (showConfig) {
|
||||
return (
|
||||
<ConfigPanel
|
||||
config={config}
|
||||
onSave={async (newConfig) => {
|
||||
await configure(newConfig)
|
||||
setShowConfig(false)
|
||||
}}
|
||||
onClose={() => setShowConfig(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const isRunning = status === 'running'
|
||||
const showEmptyState = !currentTask && history.length === 0 && !isRunning
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between border-b px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Logo className="size-5" />
|
||||
<span className="text-sm font-medium">Page Agent Ext</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusDot status={status} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setShowConfig(true)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Settings className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 overflow-hidden flex flex-col">
|
||||
{/* Current task */}
|
||||
{currentTask && (
|
||||
<div className="border-b px-3 py-2 bg-muted/30">
|
||||
<div className="text-[10px] text-muted-foreground uppercase tracking-wide">Task</div>
|
||||
<div className="text-xs font-medium truncate" title={currentTask}>
|
||||
{currentTask}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
<div ref={historyRef} className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{showEmptyState && <EmptyState />}
|
||||
|
||||
{history.map((event, index) => (
|
||||
// eslint-disable-next-line react-x/no-array-index-key
|
||||
<EventCard key={index} event={event} />
|
||||
))}
|
||||
|
||||
{/* Activity indicator at bottom */}
|
||||
{activity && <ActivityCard activity={activity} />}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Input */}
|
||||
<footer className="border-t p-3">
|
||||
<InputGroup className="relative rounded-lg">
|
||||
<InputGroupTextarea
|
||||
ref={textareaRef}
|
||||
placeholder="Describe your task... (Enter to send)"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isRunning}
|
||||
className="text-xs pr-12 min-h-10"
|
||||
/>
|
||||
<InputGroupAddon align="inline-end" className="absolute bottom-0 right-0">
|
||||
{isRunning ? (
|
||||
<InputGroupButton
|
||||
size="icon-sm"
|
||||
variant="destructive"
|
||||
onClick={handleStop}
|
||||
className="size-7"
|
||||
>
|
||||
<Square className="size-3" />
|
||||
</InputGroupButton>
|
||||
) : (
|
||||
<InputGroupButton
|
||||
size="icon-sm"
|
||||
variant="default"
|
||||
onClick={() => handleSubmit()}
|
||||
disabled={!inputValue.trim()}
|
||||
className="size-7 cursor-pointer"
|
||||
>
|
||||
<Send className="size-3" />
|
||||
</InputGroupButton>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { LLMConfig } from '@page-agent/llms'
|
||||
import { Copy, CornerUpLeft, Eye, EyeOff, HatGlasses, Home, Loader2, Scale } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { siGithub } from 'simple-icons'
|
||||
|
||||
import { DEMO_API_KEY, DEMO_BASE_URL, DEMO_MODEL } from '@/agent/constants'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
interface ConfigPanelProps {
|
||||
config: LLMConfig | null
|
||||
onSave: (config: LLMConfig) => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
const [apiKey, setApiKey] = useState(config?.apiKey || DEMO_API_KEY)
|
||||
const [baseURL, setBaseURL] = useState(config?.baseURL || DEMO_BASE_URL)
|
||||
const [model, setModel] = useState(config?.model || DEMO_MODEL)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [userAuthToken, setUserAuthToken] = useState<string>('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [showToken, setShowToken] = useState(false)
|
||||
const [showApiKey, setShowApiKey] = useState(false)
|
||||
|
||||
// Update local state when config prop changes
|
||||
useEffect(() => {
|
||||
setApiKey(config?.apiKey || DEMO_API_KEY)
|
||||
setBaseURL(config?.baseURL || DEMO_BASE_URL)
|
||||
setModel(config?.model || DEMO_MODEL)
|
||||
}, [config])
|
||||
|
||||
// Poll for user auth token every second until found
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout | null = null
|
||||
|
||||
const fetchToken = async () => {
|
||||
const result = await chrome.storage.local.get('PageAgentExtUserAuthToken')
|
||||
const token = result.PageAgentExtUserAuthToken
|
||||
if (typeof token === 'string' && token) {
|
||||
setUserAuthToken(token)
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
interval = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchToken()
|
||||
interval = setInterval(fetchToken, 1000)
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCopyToken = async () => {
|
||||
if (userAuthToken) {
|
||||
await navigator.clipboard.writeText(userAuthToken)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave({ apiKey, baseURL, model })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 relative">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold">Settings</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-3 cursor-pointer"
|
||||
>
|
||||
<CornerUpLeft className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* User Auth Token Section */}
|
||||
<div className="flex flex-col gap-1.5 p-3 bg-muted/50 rounded-md border">
|
||||
<label className="text-xs font-medium text-muted-foreground">User Auth Token</label>
|
||||
<p className="text-[10px] text-muted-foreground mb-1">
|
||||
Add this token to a website's localStorage to give it authorization to call this extension
|
||||
</p>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
readOnly
|
||||
value={
|
||||
userAuthToken
|
||||
? showToken
|
||||
? userAuthToken
|
||||
: `${userAuthToken.slice(0, 4)}${'•'.repeat(userAuthToken.length - 8)}${userAuthToken.slice(-4)}`
|
||||
: 'Loading...'
|
||||
}
|
||||
className="text-xs h-8 font-mono bg-background"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 cursor-pointer"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
disabled={!userAuthToken}
|
||||
>
|
||||
{showToken ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 cursor-pointer"
|
||||
onClick={handleCopyToken}
|
||||
disabled={!userAuthToken}
|
||||
>
|
||||
{copied ? <span className="">✓</span> : <Copy className="size-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">Base URL</label>
|
||||
<Input
|
||||
placeholder="https://api.openai.com/v1"
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
className="text-xs h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">Model</label>
|
||||
<Input
|
||||
placeholder="gpt-5.2"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="text-xs h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">API Key</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
placeholder="sk-..."
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="text-xs h-8"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 cursor-pointer"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
>
|
||||
{showApiKey ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button variant="outline" onClick={onClose} className="flex-1 h-8 text-xs cursor-pointer">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex-1 h-8 text-xs cursor-pointer"
|
||||
>
|
||||
{saving ? <Loader2 className="size-3 animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-4 mb-4 pt-4 border-t border-border/50 flex gap-2 justify-between text-[10px] text-muted-foreground">
|
||||
<div className="flex flex-col justify-between">
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<svg role="img" viewBox="0 0 24 24" className="size-3 fill-current">
|
||||
<path d={siGithub.path} />
|
||||
</svg>
|
||||
<span>Source Code</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://alibaba.github.io/page-agent/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<Home className="size-3" />
|
||||
<span>Home Page</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent/blob/main/packages/extension/PRIVACY.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<HatGlasses className="size-3" />
|
||||
<span>Privacy Policy</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end">
|
||||
<span>
|
||||
Extension <span className="font-mono">v{__EXT_VERSION__}</span>
|
||||
</span>
|
||||
<span>
|
||||
PageAgent <span className="font-mono">v{__CORE_VERSION__}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* attribute */}
|
||||
<div className="text-[10px] text-muted-foreground bg-background fixed bottom-0 w-full flex justify-around">
|
||||
<span className="leading-loose">
|
||||
Built with ♥️ by{' '}
|
||||
<a
|
||||
href="https://github.com/gaomeng1900"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
@Simon
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { AlertTriangle, RotateCcw } from 'lucide-react'
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false, error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('[ErrorBoundary]', error, errorInfo.componentStack)
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.hasError) {
|
||||
return this.props.children
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-screen bg-background p-6 text-center">
|
||||
<AlertTriangle className="size-12 text-destructive mb-4" />
|
||||
<h2 className="text-lg font-semibold mb-2">Something went wrong</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4 max-w-xs">
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={this.handleReload}>
|
||||
<RotateCcw className="size-3.5 mr-2" />
|
||||
Reload Panel
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import type {
|
||||
AgentActivity,
|
||||
AgentErrorEvent,
|
||||
AgentStepEvent,
|
||||
HistoricalEvent,
|
||||
ObservationEvent,
|
||||
RetryEvent,
|
||||
} from '@page-agent/core'
|
||||
import {
|
||||
CheckCircle,
|
||||
Eye,
|
||||
Globe,
|
||||
Keyboard,
|
||||
Mouse,
|
||||
MoveVertical,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { Fragment, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Result card for done action
|
||||
function ResultCard({
|
||||
success,
|
||||
text,
|
||||
children,
|
||||
}: {
|
||||
success: boolean
|
||||
text: string
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-3',
|
||||
success ? 'border-green-500/30 bg-green-500/10' : 'border-destructive/30 bg-destructive/10'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{success ? (
|
||||
<CheckCircle className="size-3.5 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="size-3.5 text-destructive" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-medium',
|
||||
success ? 'text-green-600 dark:text-green-400' : 'text-destructive'
|
||||
)}
|
||||
>
|
||||
Result: {success ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-[11px] text-muted-foreground pl-5 whitespace-pre-wrap">{text}</p>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Single reflection item with truncation
|
||||
function ReflectionItem({ icon, value }: { icon: string; value: string }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<span className="text-xs flex justify-center">{icon}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[11px] text-muted-foreground cursor-pointer hover:text-muted-foreground/70',
|
||||
!expanded && 'line-clamp-1'
|
||||
)}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
// Reflection section in step card
|
||||
function ReflectionSection({
|
||||
reflection,
|
||||
}: {
|
||||
reflection: {
|
||||
evaluation_previous_goal?: string
|
||||
memory?: string
|
||||
next_goal?: string
|
||||
}
|
||||
}) {
|
||||
const items = [
|
||||
{ icon: '☑️', label: 'eval', value: reflection.evaluation_previous_goal },
|
||||
{ icon: '🧠', label: 'memory', value: reflection.memory },
|
||||
{ icon: '🎯', label: 'goal', value: reflection.next_goal },
|
||||
].filter((item) => item.value)
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
{/* <div className="text-[11px] font-semibold text-foreground uppercase tracking-wide mb-2">
|
||||
Reflection
|
||||
</div> */}
|
||||
<div className="grid grid-cols-[14px_1fr] gap-x-2 gap-y-2">
|
||||
{items.map((item) => (
|
||||
<ReflectionItem key={item.label} icon={item.icon} value={item.value!} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Get icon for action type
|
||||
function ActionIcon({ name, className }: { name: string; className?: string }) {
|
||||
const icons: Record<string, React.ReactNode> = {
|
||||
click_element_by_index: <Mouse className={className} />,
|
||||
input: <Keyboard className={className} />,
|
||||
scroll: <MoveVertical className={className} />,
|
||||
go_to_url: <Globe className={className} />,
|
||||
}
|
||||
return icons[name] || <Zap className={className} />
|
||||
}
|
||||
|
||||
// Copy button with "Copied!" feedback
|
||||
function CopyButton({ text, label }: { text: string; label: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}}
|
||||
className="text-[9px] text-muted-foreground hover:text-foreground transition-colors border px-1 rounded shrink-0 cursor-pointer backdrop-blur-xs"
|
||||
>
|
||||
{copied ? 'Copied!' : label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Extract message content by role from raw request
|
||||
function extractPrompt(rawRequest: unknown, role: 'system' | 'user'): string | null {
|
||||
const messages = (rawRequest as { messages?: { role: string; content?: unknown }[] })?.messages
|
||||
if (!messages) return null
|
||||
const msg =
|
||||
role === 'system'
|
||||
? messages.find((m) => m.role === role)
|
||||
: messages.findLast((m) => m.role === role)
|
||||
if (!msg?.content) return null
|
||||
return typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content, null, 2)
|
||||
}
|
||||
|
||||
// Raw request/response section (collapsible tabs, for debugging)
|
||||
function RawSection({ rawRequest, rawResponse }: { rawRequest?: unknown; rawResponse?: unknown }) {
|
||||
const [activeTab, setActiveTab] = useState<'request' | 'response' | null>(null)
|
||||
|
||||
if (!rawRequest && !rawResponse) return null
|
||||
|
||||
const handleTabClick = (tab: 'request' | 'response') => {
|
||||
setActiveTab(activeTab === tab ? null : tab)
|
||||
}
|
||||
|
||||
const content =
|
||||
activeTab === 'request' ? rawRequest : activeTab === 'response' ? rawResponse : null
|
||||
|
||||
const systemPrompt = activeTab === 'request' ? extractPrompt(rawRequest, 'system') : null
|
||||
const userPrompt = activeTab === 'request' ? extractPrompt(rawRequest, 'user') : null
|
||||
|
||||
return (
|
||||
<div className="mt-2 border-t border-dashed pt-2">
|
||||
<div className="flex items-center gap-3 -my-1">
|
||||
{rawRequest != null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabClick('request')}
|
||||
className={cn(
|
||||
'text-[10px] mt-0.5 transition-colors border-b cursor-pointer',
|
||||
activeTab === 'request'
|
||||
? 'text-foreground border-foreground'
|
||||
: 'text-muted-foreground border-transparent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Raw Request
|
||||
</button>
|
||||
)}
|
||||
{rawResponse != null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabClick('response')}
|
||||
className={cn(
|
||||
'text-[10px] mt-0.5 transition-colors border-b cursor-pointer',
|
||||
activeTab === 'response'
|
||||
? 'text-foreground border-foreground'
|
||||
: 'text-muted-foreground border-transparent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Raw Response
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{content != null && (
|
||||
<div className="relative mt-1.5">
|
||||
<div className="absolute top-1 right-1 flex gap-1">
|
||||
{systemPrompt && <CopyButton text={systemPrompt} label="Copy System" />}
|
||||
{userPrompt && <CopyButton text={userPrompt} label="Copy User" />}
|
||||
<CopyButton text={JSON.stringify(content, null, 4)} label="Copy" />
|
||||
</div>
|
||||
<pre className="p-2 pt-5 text-[10px] text-foreground/70 bg-muted rounded overflow-x-auto max-h-60 overflow-y-auto">
|
||||
{JSON.stringify(content, null, 4)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepCard({ event }: { event: AgentStepEvent }) {
|
||||
return (
|
||||
<div className="rounded-lg border-l-2 border-l-blue-500/50 border bg-muted/40 p-2.5">
|
||||
<div className="text-[11px] font-semibold text-foreground tracking-wide mb-2">
|
||||
Step #{event.stepIndex! + 1}
|
||||
</div>
|
||||
|
||||
{/* Reflection */}
|
||||
{event.reflection && <ReflectionSection reflection={event.reflection} />}
|
||||
|
||||
{/* Action */}
|
||||
{event.action && (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-foreground tracking-wide mb-1">
|
||||
Actions
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<ActionIcon
|
||||
name={event.action.name}
|
||||
className="size-3.5 text-blue-500 shrink-0 mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-foreground/80 mb-0.5 wrap-anywhere break-all line-clamp-1 hover:line-clamp-none">
|
||||
<span className="font-medium text-foreground/70">{event.action.name}</span>
|
||||
{event.action.name !== 'done' && (
|
||||
<span className="text-muted-foreground/70 ml-1.5">
|
||||
{JSON.stringify(event.action.input)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground/70 grid grid-cols-[auto_1fr] gap-1.5">
|
||||
<span className="">└</span>
|
||||
<span className="wrap-anywhere break-all line-clamp-1 hover:line-clamp-3">
|
||||
{event.action.output}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw Response */}
|
||||
<RawSection rawRequest={event.rawRequest} rawResponse={event.rawResponse} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ObservationCard({ event }: { event: ObservationEvent }) {
|
||||
return (
|
||||
<div className="rounded-lg border-l-2 border-l-green-500/50 border bg-muted/40 p-2.5">
|
||||
{/* <div className="text-[11px] font-semibold text-foreground uppercase tracking-wide mb-2">
|
||||
Observation
|
||||
</div> */}
|
||||
<div className="flex items-start gap-2">
|
||||
<Eye className="size-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||
<span className="text-[11px] text-muted-foreground">{event.content}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RetryCard({ event }: { event: RetryEvent }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-2.5">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<RefreshCw className="size-3 text-amber-500 shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-amber-600 dark:text-amber-400">
|
||||
{event.message} ({event.attempt}/{event.maxAttempts})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorCard({ event }: { event: AgentErrorEvent }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-2.5">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<XCircle className="size-3 text-destructive shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-destructive">{event.message}</span>
|
||||
</div>
|
||||
<RawSection rawResponse={event.rawResponse} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// History event card component
|
||||
export function EventCard({ event }: { event: HistoricalEvent }) {
|
||||
// Done action - show as result card
|
||||
if (event.type === 'step' && event.action?.name === 'done') {
|
||||
const input = event.action.input as { text?: string; success?: boolean }
|
||||
return (
|
||||
<>
|
||||
<StepCard event={event as AgentStepEvent} />
|
||||
<ResultCard
|
||||
success={input?.success ?? true}
|
||||
text={input?.text || event.action.output || ''}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (event.type === 'step') {
|
||||
return <StepCard event={event as AgentStepEvent} />
|
||||
}
|
||||
|
||||
if (event.type === 'observation') {
|
||||
return <ObservationCard event={event as ObservationEvent} />
|
||||
}
|
||||
|
||||
if (event.type === 'retry') {
|
||||
return <RetryCard event={event as RetryEvent} />
|
||||
}
|
||||
|
||||
if (event.type === 'error') {
|
||||
return <ErrorCard event={event as AgentErrorEvent} />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Activity card with animation
|
||||
export function ActivityCard({ activity }: { activity: AgentActivity }) {
|
||||
const getActivityInfo = () => {
|
||||
switch (activity.type) {
|
||||
case 'thinking':
|
||||
return { text: 'Thinking...', color: 'text-blue-500' }
|
||||
case 'executing':
|
||||
return { text: `Executing ${activity.tool}...`, color: 'text-amber-500' }
|
||||
case 'executed':
|
||||
return { text: `Done: ${activity.tool}`, color: 'text-green-500' }
|
||||
case 'retrying':
|
||||
return {
|
||||
text: `Retrying (${activity.attempt}/${activity.maxAttempts})...`,
|
||||
color: 'text-amber-500',
|
||||
}
|
||||
case 'error':
|
||||
return { text: activity.message, color: 'text-destructive' }
|
||||
}
|
||||
}
|
||||
|
||||
const info = getActivityInfo()
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-muted/40 p-2.5 animate-pulse">
|
||||
<div className="relative">
|
||||
<Sparkles className={cn('size-3.5', info.color)} />
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -top-0.5 -right-0.5 size-1.5 rounded-full animate-ping',
|
||||
activity.type === 'thinking'
|
||||
? 'bg-blue-500'
|
||||
: activity.type === 'executing'
|
||||
? 'bg-amber-500'
|
||||
: activity.type === 'retrying'
|
||||
? 'bg-amber-500'
|
||||
: activity.type === 'error'
|
||||
? 'bg-destructive'
|
||||
: 'bg-green-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span className={cn('text-xs', info.color)}>{info.text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { AgentStatus } from '@page-agent/core'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Status dot indicator
|
||||
export function StatusDot({ status }: { status: AgentStatus }) {
|
||||
const colorClass = {
|
||||
idle: 'bg-muted-foreground',
|
||||
running: 'bg-blue-500',
|
||||
completed: 'bg-green-500',
|
||||
error: 'bg-destructive',
|
||||
}[status]
|
||||
|
||||
const label = {
|
||||
idle: 'Ready',
|
||||
running: 'Running',
|
||||
completed: 'Done',
|
||||
error: 'Error',
|
||||
}[status]
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn('size-2 rounded-full', colorClass, status === 'running' && 'animate-pulse')}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Logo({ className }: { className?: string }) {
|
||||
return <img src="/assets/page-agent-256.webp" alt="Page Agent" className={cn('', className)} />
|
||||
}
|
||||
|
||||
// Empty state with logo
|
||||
export function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-6">
|
||||
<Logo className="size-20 opacity-80" />
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-foreground">Page Agent Ext</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">Enter a task to automate this page</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/assets/page-agent-64.png" />
|
||||
<title>Page Agent</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
|
||||
import App from './App'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
|
||||
import '@/assets/index.css'
|
||||
|
||||
// Sync dark mode with system preference
|
||||
const syncDarkMode = () => {
|
||||
document.documentElement.classList.toggle(
|
||||
'dark',
|
||||
matchMedia('(prefers-color-scheme: dark)').matches
|
||||
)
|
||||
}
|
||||
syncDarkMode()
|
||||
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncDarkMode)
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
// Asset type declarations
|
||||
declare module '*.webp' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
declare module '*.jpg' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
declare module '*.jpeg' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
declare const __EXT_VERSION__: string
|
||||
declare const __CORE_VERSION__: string
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
declare module '*.md?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "./.wxt/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||
"useDefineForClassFields": true,
|
||||
"noEmit": false,
|
||||
"allowImportingTsExtensions": false,
|
||||
"strictNullChecks": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
// Self root
|
||||
"@/*": ["src/*"],
|
||||
|
||||
"@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"]
|
||||
}
|
||||
},
|
||||
"references": [
|
||||
//
|
||||
{ "path": "../llms" },
|
||||
{ "path": "../page-controller" },
|
||||
{ "path": "../core" },
|
||||
{ "path": "../ui" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { mkdirSync, readFileSync } from 'node:fs'
|
||||
import { defineConfig } from 'wxt'
|
||||
|
||||
const chromeProfile = '.wxt/chrome-data'
|
||||
mkdirSync(chromeProfile, { recursive: true })
|
||||
|
||||
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
||||
|
||||
// See https://wxt.dev/api/config.html
|
||||
export default defineConfig({
|
||||
srcDir: 'src',
|
||||
modules: ['@wxt-dev/module-react'],
|
||||
webExt: {
|
||||
chromiumProfile: chromeProfile,
|
||||
keepProfileChanges: true,
|
||||
chromiumArgs: ['--hide-crash-restore-bubble'],
|
||||
},
|
||||
vite: () => ({
|
||||
plugins: [tailwindcss()],
|
||||
define: {
|
||||
__EXT_VERSION__: JSON.stringify(pkg.version),
|
||||
__CORE_VERSION__: JSON.stringify(pkg.dependencies['@page-agent/core']),
|
||||
},
|
||||
optimizeDeps: {
|
||||
force: true,
|
||||
},
|
||||
build: {
|
||||
minify: false,
|
||||
chunkSizeWarningLimit: 2000,
|
||||
cssCodeSplit: true,
|
||||
rollupOptions: {
|
||||
onwarn: function (message, handler) {
|
||||
if (message.code === 'EVAL') return
|
||||
handler(message)
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
manifest: {
|
||||
name: 'Page Agent Ext',
|
||||
description:
|
||||
'AI-powered browser automation assistant. Control web pages with natural language.',
|
||||
homepage_url: 'https://alibaba.github.io/page-agent/',
|
||||
permissions: ['tabs', 'tabGroups', 'sidePanel', 'storage'],
|
||||
host_permissions: ['<all_urls>'],
|
||||
icons: {
|
||||
64: 'assets/page-agent-64.png',
|
||||
},
|
||||
action: {
|
||||
default_title: 'Open Page Agent',
|
||||
},
|
||||
web_accessible_resources: [
|
||||
{
|
||||
resources: ['main-world.js'],
|
||||
matches: ['*://*/*'],
|
||||
},
|
||||
],
|
||||
side_panel: {
|
||||
default_path: 'sidepanel/index.html',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
# @page-agent/llms
|
||||
|
||||
LLM client with a **reflection-before-action** mental model for page-agent.
|
||||
|
||||
## Why This Package Exists
|
||||
|
||||
The LLM module and the agent logic are inherently coupled. This package exists not to decouple them, but to **define the interface contract** between the LLM and the agent.
|
||||
|
||||
The core abstraction is the `MacroToolInput` — a structured output format that **forces the model to reflect before acting**.
|
||||
|
||||
## The Reflection-Before-Action Model
|
||||
|
||||
Every tool call must first output its reasoning state before the actual action:
|
||||
|
||||
```typescript
|
||||
interface MacroToolInput {
|
||||
// Reflection (mandatory before any action)
|
||||
evaluation_previous_goal?: string // How well did the previous action work?
|
||||
memory?: string // Key information to remember
|
||||
next_goal?: string // What to accomplish next
|
||||
|
||||
// Action (the actual operation)
|
||||
action: Record<string, any>
|
||||
}
|
||||
```
|
||||
|
||||
This design ensures that:
|
||||
|
||||
1. **The model evaluates its previous action** before deciding the next step
|
||||
2. **Working memory is explicitly maintained** across conversation turns
|
||||
3. **Goals are clearly stated**, making the agent's reasoning transparent and debuggable
|
||||
|
||||
## Key Components
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `LLM` | Main LLM client class with retry logic |
|
||||
| `MacroToolInput` | The reflection-before-action input schema |
|
||||
| `AgentBrain` | Agent's thinking state (eval, memory, goal) |
|
||||
| `LLMConfig` | Configuration for LLM connection |
|
||||
| `parseLLMConfig` | Parse and apply defaults to config |
|
||||
+41
-41
@@ -1,43 +1,43 @@
|
||||
{
|
||||
"name": "@page-agent/llms",
|
||||
"version": "0.2.2",
|
||||
"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"
|
||||
}
|
||||
"name": "@page-agent/llms",
|
||||
"version": "1.1.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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ export class OpenAIClient implements LLMClient {
|
||||
: 'required',
|
||||
}
|
||||
|
||||
modelPatch(requestBody)
|
||||
|
||||
// 2. Call API
|
||||
let response: Response
|
||||
try {
|
||||
@@ -48,7 +50,7 @@ export class OpenAIClient implements LLMClient {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(modelPatch(requestBody)),
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: abortSignal,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
@@ -107,14 +109,23 @@ export class OpenAIClient implements LLMClient {
|
||||
case 'length':
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.CONTEXT_LENGTH,
|
||||
'Response truncated: max tokens reached'
|
||||
'Response truncated: max tokens reached',
|
||||
undefined,
|
||||
data
|
||||
)
|
||||
case 'content_filter':
|
||||
throw new InvokeError(InvokeErrorType.CONTENT_FILTER, 'Content filtered by safety system')
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.CONTENT_FILTER,
|
||||
'Content filtered by safety system',
|
||||
undefined,
|
||||
data
|
||||
)
|
||||
default:
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.UNKNOWN,
|
||||
`Unexpected finish_reason: ${choice.finish_reason}`
|
||||
`Unexpected finish_reason: ${choice.finish_reason}`,
|
||||
undefined,
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
@@ -128,7 +139,8 @@ export class OpenAIClient implements LLMClient {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.NO_TOOL_CALL,
|
||||
'No tool call found in response',
|
||||
normalizedData
|
||||
undefined,
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
@@ -137,7 +149,8 @@ export class OpenAIClient implements LLMClient {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.UNKNOWN,
|
||||
`Tool "${toolCallName}" not found in tools`,
|
||||
normalizedData
|
||||
undefined,
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
@@ -147,7 +160,8 @@ export class OpenAIClient implements LLMClient {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
'No tool call arguments found',
|
||||
normalizedData
|
||||
undefined,
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
@@ -158,7 +172,8 @@ export class OpenAIClient implements LLMClient {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
'Failed to parse tool arguments as JSON',
|
||||
error
|
||||
error,
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
@@ -169,7 +184,8 @@ export class OpenAIClient implements LLMClient {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
'Tool arguments validation failed',
|
||||
validation.error
|
||||
validation.error,
|
||||
data
|
||||
)
|
||||
}
|
||||
const toolInput = validation.data
|
||||
@@ -182,7 +198,8 @@ export class OpenAIClient implements LLMClient {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.TOOL_EXECUTION_ERROR,
|
||||
`Tool execution failed: ${(e as Error).message}`,
|
||||
e
|
||||
e,
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
@@ -201,6 +218,7 @@ export class OpenAIClient implements LLMClient {
|
||||
reasoningTokens: data.usage?.completion_tokens_details?.reasoning_tokens,
|
||||
},
|
||||
rawResponse: data,
|
||||
rawRequest: requestBody,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,14 +25,18 @@ export class InvokeError extends Error {
|
||||
type: InvokeErrorType
|
||||
retryable: boolean
|
||||
statusCode?: number
|
||||
/* raw error (provided if this error is caused by another error) */
|
||||
rawError?: unknown
|
||||
/* raw response from the API (provided if this error is caused by an API calling) */
|
||||
rawResponse?: unknown
|
||||
|
||||
constructor(type: InvokeErrorType, message: string, rawError?: unknown) {
|
||||
constructor(type: InvokeErrorType, message: string, rawError?: unknown, rawResponse?: unknown) {
|
||||
super(message)
|
||||
this.name = 'InvokeError'
|
||||
this.type = type
|
||||
this.retryable = this.isRetryable(type)
|
||||
this.rawError = rawError
|
||||
this.rawResponse = rawResponse
|
||||
}
|
||||
|
||||
private isRetryable(type: InvokeErrorType): boolean {
|
||||
|
||||
+12
-12
@@ -3,7 +3,7 @@ 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 type { InvokeError, InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool }
|
||||
|
||||
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
|
||||
// Runtime validation as defensive programming (types already guarantee these)
|
||||
@@ -56,9 +56,9 @@ export class LLM extends EventTarget {
|
||||
// retry settings
|
||||
{
|
||||
maxRetries: this.config.maxRetries,
|
||||
onRetry: (current: number) => {
|
||||
onRetry: (attempt: number) => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('retry', { detail: { current, max: this.config.maxRetries } })
|
||||
new CustomEvent('retry', { detail: { attempt, maxAttempts: this.config.maxRetries } })
|
||||
)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
@@ -73,32 +73,32 @@ async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
settings: {
|
||||
maxRetries: number
|
||||
onRetry: (retries: number) => void
|
||||
onRetry: (attempt: number) => void
|
||||
onError: (error: Error) => void
|
||||
}
|
||||
): Promise<T> {
|
||||
let retries = 0
|
||||
let attempt = 0
|
||||
let lastError: Error | null = null
|
||||
while (retries <= settings.maxRetries) {
|
||||
if (retries > 0) {
|
||||
settings.onRetry(retries)
|
||||
while (attempt <= settings.maxRetries) {
|
||||
if (attempt > 0) {
|
||||
settings.onRetry(attempt)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error: unknown) {
|
||||
// do not retry if aborted by user
|
||||
if ((error as any)?.rawError?.name === 'AbortError') throw error
|
||||
|
||||
console.error(error)
|
||||
settings.onError(error as Error)
|
||||
|
||||
// do not retry if aborted by user
|
||||
if ((error as { name?: string })?.name === 'AbortError') throw error
|
||||
|
||||
// do not retry if error is not retryable (InvokeError)
|
||||
if (error instanceof InvokeError && !error.retryable) throw error
|
||||
|
||||
lastError = error as Error
|
||||
retries++
|
||||
attempt++
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ export interface InvokeResult<TResult = unknown> {
|
||||
reasoningTokens?: number // OpenAI o1 series reasoning tokens
|
||||
}
|
||||
rawResponse?: unknown // Raw response for debugging
|
||||
rawRequest?: unknown // Raw request for debugging
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,6 +27,7 @@ export function zodToOpenAITool(name: string, tool: Tool) {
|
||||
|
||||
/**
|
||||
* Patch model specific parameters
|
||||
* @note in-place modification
|
||||
*/
|
||||
export function modelPatch(body: Record<string, any>) {
|
||||
const model: string = body.model || ''
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// @workaround DTS bug
|
||||
// dts do not work with monorepo path mapping
|
||||
// disable path mapping for it
|
||||
"paths": {}
|
||||
}
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// @workaround DTS bug
|
||||
// dts do not work with monorepo path mapping
|
||||
// disable path mapping for it
|
||||
"paths": {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-11
@@ -1,13 +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"]
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||
"noEmit": false,
|
||||
"allowImportingTsExtensions": false,
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,52 +1,54 @@
|
||||
{
|
||||
"name": "page-agent",
|
||||
"private": false,
|
||||
"version": "0.2.2",
|
||||
"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",
|
||||
"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": "0.2.2",
|
||||
"@page-agent/page-controller": "0.2.2",
|
||||
"@page-agent/ui": "0.2.2"
|
||||
}
|
||||
"name": "page-agent",
|
||||
"private": false,
|
||||
"version": "1.1.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.1.0",
|
||||
"@page-agent/page-controller": "1.1.0",
|
||||
"@page-agent/core": "1.1.0",
|
||||
"@page-agent/ui": "1.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,620 +2,26 @@
|
||||
* Copyright (C) 2025 Alibaba Group Holding Limited
|
||||
* All rights reserved.
|
||||
*/
|
||||
import { LLM, type Tool } from '@page-agent/llms'
|
||||
import { type PageAgentConfig, PageAgentCore, type PageAgentTool, tool } from '@page-agent/core'
|
||||
import { PageController } from '@page-agent/page-controller'
|
||||
import { Panel } from '@page-agent/ui'
|
||||
import chalk from 'chalk'
|
||||
import zod from 'zod'
|
||||
|
||||
import type { PageAgentConfig } from './config'
|
||||
import { MAX_STEPS } from './config/constants'
|
||||
import SYSTEM_PROMPT from './prompts/system_prompt.md?raw'
|
||||
import { tools } from './tools'
|
||||
import { normalizeResponse, trimLines, uid } from './utils'
|
||||
import { assert } from './utils/assert'
|
||||
export type { PageAgentConfig, PageAgentTool }
|
||||
export { tool }
|
||||
|
||||
/**
|
||||
* Agent reflection state - the reflection-before-action model
|
||||
*
|
||||
* Every tool call must first reflect on:
|
||||
* - evaluation_previous_goal: How well did the previous action achieve its goal?
|
||||
* - memory: Key information to remember for future steps
|
||||
* - next_goal: What should be accomplished in the next action?
|
||||
*/
|
||||
export interface AgentReflection {
|
||||
evaluation_previous_goal: string
|
||||
memory: string
|
||||
next_goal: string
|
||||
}
|
||||
|
||||
/**
|
||||
* MacroTool input structure
|
||||
*
|
||||
* This is the core abstraction that enforces the "reflection-before-action" mental model.
|
||||
* Before executing any action, the LLM must output its reasoning state.
|
||||
*/
|
||||
export interface MacroToolInput extends Partial<AgentReflection> {
|
||||
action: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* MacroTool output structure
|
||||
*/
|
||||
export interface MacroToolResult {
|
||||
input: MacroToolInput
|
||||
output: string
|
||||
}
|
||||
|
||||
export type { PageAgentConfig }
|
||||
export { tool, type PageAgentTool } from './tools'
|
||||
|
||||
/**
|
||||
* A single agent step with reflection and action
|
||||
*/
|
||||
export interface AgentStep {
|
||||
type: 'step'
|
||||
reflection: Partial<AgentReflection>
|
||||
action: {
|
||||
name: string
|
||||
input: any
|
||||
output: string
|
||||
}
|
||||
usage: {
|
||||
promptTokens: number
|
||||
completionTokens: number
|
||||
totalTokens: number
|
||||
cachedTokens?: number
|
||||
reasoningTokens?: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent observation event (stays in memory)
|
||||
*/
|
||||
export interface ObservationEvent {
|
||||
type: 'observation'
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* User takeover event
|
||||
*/
|
||||
export interface UserTakeoverEvent {
|
||||
type: 'user_takeover'
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all history events
|
||||
*/
|
||||
export type HistoryEvent = AgentStep | ObservationEvent | UserTakeoverEvent
|
||||
|
||||
/** @deprecated Use AgentStep instead */
|
||||
export type AgentHistory = AgentStep
|
||||
|
||||
export interface ExecutionResult {
|
||||
success: boolean
|
||||
data: string
|
||||
history: HistoryEvent[]
|
||||
}
|
||||
|
||||
export class PageAgent extends EventTarget {
|
||||
config: PageAgentConfig
|
||||
id = uid()
|
||||
export class PageAgent extends PageAgentCore {
|
||||
panel: Panel
|
||||
tools: typeof tools
|
||||
disposed = false
|
||||
task = ''
|
||||
taskId = ''
|
||||
|
||||
#llm: LLM
|
||||
#abortController = new AbortController()
|
||||
#llmRetryListener: ((e: Event) => void) | null = null
|
||||
#llmErrorListener: ((e: Event) => void) | null = null
|
||||
#beforeUnloadListener: ((e: Event) => void) | null = null
|
||||
|
||||
/** PageController for DOM operations */
|
||||
pageController: PageController
|
||||
|
||||
/** Runtime states for tracking across steps */
|
||||
states = {
|
||||
/** Accumulated wait time in seconds, used by wait tool */
|
||||
totalWaitTime: 0,
|
||||
/** Last known URL for detecting navigation */
|
||||
lastURL: '',
|
||||
}
|
||||
|
||||
/** History event stream */
|
||||
history: HistoryEvent[] = []
|
||||
|
||||
constructor(config: PageAgentConfig) {
|
||||
super()
|
||||
|
||||
this.config = config
|
||||
this.#llm = new LLM(this.config)
|
||||
this.panel = new Panel({
|
||||
language: this.config.language,
|
||||
onExecuteTask: (task) => this.execute(task),
|
||||
onStop: () => this.dispose(),
|
||||
})
|
||||
this.tools = new Map(tools)
|
||||
|
||||
// Initialize PageController with config (mask enabled by default)
|
||||
this.pageController = new PageController({
|
||||
...this.config,
|
||||
enableMask: this.config.enableMask ?? true,
|
||||
const pageController = new PageController({
|
||||
...config,
|
||||
enableMask: config.enableMask ?? true,
|
||||
})
|
||||
|
||||
// Listen to LLM events
|
||||
this.#llmRetryListener = (e) => {
|
||||
const { current, max } = (e as CustomEvent).detail
|
||||
this.panel.update({ type: 'retry', current, max })
|
||||
}
|
||||
this.#llmErrorListener = (e) => {
|
||||
const { error } = (e as CustomEvent).detail
|
||||
this.panel.update({ type: 'error', message: `step failed: ${error.message}` })
|
||||
}
|
||||
this.#llm.addEventListener('retry', this.#llmRetryListener)
|
||||
this.#llm.addEventListener('error', this.#llmErrorListener)
|
||||
super({ ...config, pageController })
|
||||
|
||||
if (this.config.customTools) {
|
||||
for (const [name, tool] of Object.entries(this.config.customTools)) {
|
||||
if (tool === null) {
|
||||
this.tools.delete(name)
|
||||
continue
|
||||
}
|
||||
this.tools.set(name, tool)
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.config.experimentalScriptExecutionTool) {
|
||||
this.tools.delete('execute_javascript')
|
||||
}
|
||||
|
||||
this.#beforeUnloadListener = (e) => {
|
||||
if (!this.disposed) this.dispose('PAGE_UNLOADING')
|
||||
}
|
||||
window.addEventListener('beforeunload', this.#beforeUnloadListener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a persistent observation to the history event stream.
|
||||
* This will be visible in <agent_history> and remain in memory across steps.
|
||||
*/
|
||||
pushObservation(content: string): void {
|
||||
this.history.push({ type: 'observation', content })
|
||||
this.panel.update({ type: 'observation', content })
|
||||
}
|
||||
|
||||
async execute(task: string): Promise<ExecutionResult> {
|
||||
if (!task) throw new Error('Task is required')
|
||||
this.task = task
|
||||
this.taskId = uid()
|
||||
|
||||
const onBeforeStep = this.config.onBeforeStep || (() => void 0)
|
||||
const onAfterStep = this.config.onAfterStep || (() => void 0)
|
||||
const onBeforeTask = this.config.onBeforeTask || (() => void 0)
|
||||
const onAfterTask = this.config.onAfterTask || (() => void 0)
|
||||
|
||||
await onBeforeTask.call(this)
|
||||
|
||||
// Show mask and panel
|
||||
this.pageController.showMask()
|
||||
|
||||
this.panel.show()
|
||||
this.panel.reset()
|
||||
|
||||
this.panel.update({ type: 'input', task: this.task })
|
||||
|
||||
if (this.#abortController) {
|
||||
this.#abortController.abort()
|
||||
this.#abortController = new AbortController()
|
||||
}
|
||||
|
||||
this.history = []
|
||||
|
||||
// Reset states
|
||||
this.states = {
|
||||
totalWaitTime: 0,
|
||||
lastURL: '',
|
||||
}
|
||||
|
||||
try {
|
||||
let step = 0
|
||||
|
||||
while (true) {
|
||||
await this.#generateObservations(step)
|
||||
|
||||
await onBeforeStep.call(this, step)
|
||||
|
||||
console.group(`step: ${step}`)
|
||||
|
||||
// abort
|
||||
if (this.#abortController.signal.aborted) throw new Error('AbortError')
|
||||
|
||||
// Update status to thinking
|
||||
console.log(chalk.blue('Thinking...'))
|
||||
this.panel.update({ type: 'thinking' })
|
||||
|
||||
const result = await this.#llm.invoke(
|
||||
[
|
||||
{
|
||||
role: 'system',
|
||||
content: this.#getSystemPrompt(),
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: await this.#assembleUserPrompt(),
|
||||
},
|
||||
],
|
||||
{ AgentOutput: this.#packMacroTool() },
|
||||
this.#abortController.signal,
|
||||
{
|
||||
toolChoiceName: 'AgentOutput',
|
||||
normalizeResponse,
|
||||
}
|
||||
)
|
||||
|
||||
const macroResult = result.toolResult as MacroToolResult
|
||||
const input = macroResult.input
|
||||
const output = macroResult.output
|
||||
const reflection: Partial<AgentReflection> = {
|
||||
evaluation_previous_goal: input.evaluation_previous_goal,
|
||||
memory: input.memory,
|
||||
next_goal: input.next_goal,
|
||||
}
|
||||
const actionName = Object.keys(input.action)[0]
|
||||
const action: AgentStep['action'] = {
|
||||
name: actionName,
|
||||
input: input.action[actionName],
|
||||
output: output,
|
||||
}
|
||||
|
||||
this.history.push({
|
||||
type: 'step',
|
||||
reflection,
|
||||
action,
|
||||
usage: result.usage,
|
||||
} as AgentStep)
|
||||
|
||||
console.log(chalk.green('Step finished:'), actionName)
|
||||
console.groupEnd()
|
||||
|
||||
await onAfterStep.call(this, step, this.history)
|
||||
|
||||
step++
|
||||
if (step > MAX_STEPS) {
|
||||
this.#onDone('Step count exceeded maximum limit', false)
|
||||
const result: ExecutionResult = {
|
||||
success: false,
|
||||
data: 'Step count exceeded maximum limit',
|
||||
history: this.history,
|
||||
}
|
||||
await onAfterTask.call(this, result)
|
||||
return result
|
||||
}
|
||||
if (actionName === 'done') {
|
||||
const success = action.input?.success ?? false
|
||||
const text = action.input?.text || 'no text provided'
|
||||
console.log(chalk.green.bold('Task completed'), success, text)
|
||||
this.#onDone(text, success)
|
||||
const result: ExecutionResult = {
|
||||
success,
|
||||
data: text,
|
||||
history: this.history,
|
||||
}
|
||||
await onAfterTask.call(this, result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error('Task failed', error)
|
||||
this.#onDone(String(error), false)
|
||||
const result: ExecutionResult = {
|
||||
success: false,
|
||||
data: String(error),
|
||||
history: this.history,
|
||||
}
|
||||
await onAfterTask.call(this, result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all tools into a single MacroTool with the following input:
|
||||
* - thinking: string
|
||||
* - evaluation_previous_goal: string
|
||||
* - memory: string
|
||||
* - next_goal: string
|
||||
* - action: { toolName: toolInput }
|
||||
* where action must be selected from tools defined in this.tools
|
||||
*/
|
||||
#packMacroTool(): Tool<MacroToolInput, MacroToolResult> {
|
||||
const tools = this.tools
|
||||
|
||||
const actionSchemas = Array.from(tools.entries()).map(([toolName, tool]) => {
|
||||
return zod.object({ [toolName]: tool.inputSchema }).describe(tool.description)
|
||||
this.panel = new Panel(this, {
|
||||
language: config.language,
|
||||
})
|
||||
|
||||
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)
|
||||
this.panel.update({ type: 'thinking', text: reflectionText })
|
||||
}
|
||||
|
||||
// Find the corresponding tool
|
||||
const tool = tools.get(toolName)
|
||||
assert(tool, `Tool ${toolName} not found. (@note should have been caught before this!!!)`)
|
||||
|
||||
console.log(chalk.blue.bold(`Executing tool: ${toolName}`), toolInput)
|
||||
this.panel.update({ type: 'toolExecuting', toolName, args: toolInput })
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
// Execute tool, bind `this` to PageAgent
|
||||
const result = await tool.execute.bind(this)(toolInput)
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result)
|
||||
|
||||
// Reset wait time for non-wait tools
|
||||
if (toolName !== 'wait') {
|
||||
this.states.totalWaitTime = 0
|
||||
}
|
||||
|
||||
// Briefly display execution result
|
||||
this.panel.update({
|
||||
type: 'toolCompleted',
|
||||
toolName,
|
||||
args: toolInput,
|
||||
result,
|
||||
duration,
|
||||
})
|
||||
|
||||
// Wait a moment to let user see the result
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Return structured result
|
||||
return {
|
||||
input,
|
||||
output: result,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system prompt, dynamically replace language settings based on configured language
|
||||
*/
|
||||
#getSystemPrompt(): string {
|
||||
let systemPrompt = SYSTEM_PROMPT
|
||||
|
||||
const targetLanguage = this.config.language === 'zh-CN' ? '中文' : 'English'
|
||||
systemPrompt = systemPrompt.replace(
|
||||
/Default working language: \*\*.*?\*\*/,
|
||||
`Default working language: **${targetLanguage}**`
|
||||
)
|
||||
|
||||
return systemPrompt
|
||||
}
|
||||
|
||||
/**
|
||||
* Get instructions from config and format as XML block
|
||||
*/
|
||||
async #getInstructions(): Promise<string> {
|
||||
const { instructions } = this.config
|
||||
if (!instructions) return ''
|
||||
|
||||
const systemInstructions = instructions.system?.trim()
|
||||
const url = await this.pageController.getCurrentUrl()
|
||||
let pageInstructions: string | undefined
|
||||
|
||||
if (instructions.getPageInstructions) {
|
||||
try {
|
||||
pageInstructions = instructions.getPageInstructions(url)?.trim()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('[PageAgent] Failed to execute getPageInstructions callback:'),
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!systemInstructions && !pageInstructions) return ''
|
||||
|
||||
let result = '<instructions>\n'
|
||||
|
||||
if (systemInstructions) {
|
||||
result += `<system_instructions>\n${systemInstructions}\n</system_instructions>\n`
|
||||
}
|
||||
|
||||
if (pageInstructions) {
|
||||
result += `<page_instructions>\n${pageInstructions}\n</page_instructions>\n`
|
||||
}
|
||||
|
||||
result += '</instructions>\n\n'
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate observations before each step
|
||||
* - URL change detection
|
||||
* - Too many steps warning
|
||||
* @todo loop detection
|
||||
* @todo console error
|
||||
*/
|
||||
async #generateObservations(stepCount: number): Promise<void> {
|
||||
// Detect URL change
|
||||
const currentURL = await this.pageController.getCurrentUrl()
|
||||
if (currentURL !== this.states.lastURL) {
|
||||
this.pushObservation(`Page navigated to → ${currentURL}`)
|
||||
this.states.lastURL = currentURL
|
||||
}
|
||||
|
||||
// Warn about remaining steps
|
||||
const remaining = MAX_STEPS - stepCount
|
||||
if (remaining === 5) {
|
||||
this.pushObservation(
|
||||
`⚠️ Only ${remaining} steps remaining. Consider wrapping up or calling done with partial results.`
|
||||
)
|
||||
} else if (remaining === 2) {
|
||||
this.pushObservation(
|
||||
`⚠️ Critical: Only ${remaining} steps left! You must finish the task or call done immediately.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async #assembleUserPrompt(): Promise<string> {
|
||||
let prompt = ''
|
||||
|
||||
// <instructions> (optional)
|
||||
prompt += await this.#getInstructions()
|
||||
|
||||
// <agent_state>
|
||||
// - <user_request>
|
||||
// - <step_info>
|
||||
// <agent_state>
|
||||
|
||||
const stepCount = this.history.filter((e) => e.type === 'step').length
|
||||
|
||||
prompt += `<agent_state>
|
||||
<user_request>
|
||||
${this.task}
|
||||
</user_request>
|
||||
<step_info>
|
||||
Step ${stepCount + 1} of ${MAX_STEPS} max possible steps
|
||||
Current date and time: ${new Date().toISOString()}
|
||||
</step_info>
|
||||
</agent_state>
|
||||
`
|
||||
|
||||
// <agent_history>
|
||||
// - <step_N> for steps
|
||||
// - <sys> for observations and system messages
|
||||
|
||||
prompt += '\n<agent_history>\n'
|
||||
|
||||
let stepIndex = 0
|
||||
for (const event of this.history) {
|
||||
if (event.type === 'step') {
|
||||
stepIndex++
|
||||
prompt += `<step_${stepIndex}>
|
||||
Evaluation of Previous Step: ${event.reflection.evaluation_previous_goal}
|
||||
Memory: ${event.reflection.memory}
|
||||
Next Goal: ${event.reflection.next_goal}
|
||||
Action Results: ${event.action.output}
|
||||
</step_${stepIndex}>
|
||||
`
|
||||
} else if (event.type === 'observation') {
|
||||
prompt += `<sys>${event.content}</sys>\n`
|
||||
} else if (event.type === 'user_takeover') {
|
||||
prompt += `<sys>User took over control and made changes to the page.</sys>\n`
|
||||
}
|
||||
}
|
||||
|
||||
prompt += '</agent_history>\n\n'
|
||||
|
||||
// <browser_state>
|
||||
|
||||
prompt += await this.#getBrowserState()
|
||||
|
||||
return trimLines(prompt)
|
||||
}
|
||||
|
||||
#onDone(text: string, success = true) {
|
||||
this.pageController.cleanUpHighlights()
|
||||
|
||||
// Update panel status
|
||||
if (success) {
|
||||
this.panel.update({ type: 'output', text })
|
||||
} else {
|
||||
this.panel.update({ type: 'error', message: text })
|
||||
}
|
||||
|
||||
// Task completed
|
||||
this.panel.update({ type: 'completed' })
|
||||
|
||||
this.pageController.hideMask()
|
||||
|
||||
this.#abortController.abort()
|
||||
}
|
||||
|
||||
async #getBrowserState(): Promise<string> {
|
||||
const state = await this.pageController.getBrowserState()
|
||||
|
||||
let content = state.content
|
||||
if (this.config.transformPageContent) {
|
||||
content = await this.config.transformPageContent(content)
|
||||
}
|
||||
|
||||
return trimLines(`<browser_state>
|
||||
Current Page: [${state.title}](${state.url})
|
||||
|
||||
${state.header}
|
||||
${content}
|
||||
${state.footer}
|
||||
|
||||
</browser_state>
|
||||
`)
|
||||
}
|
||||
|
||||
dispose(reason?: string) {
|
||||
console.log('Disposing PageAgent...')
|
||||
this.disposed = true
|
||||
this.pageController.dispose()
|
||||
this.panel.dispose()
|
||||
this.history = []
|
||||
this.#abortController.abort(reason ?? 'PageAgent disposed')
|
||||
|
||||
// Clean up LLM event listeners
|
||||
if (this.#llmRetryListener) {
|
||||
this.#llm.removeEventListener('retry', this.#llmRetryListener)
|
||||
this.#llmRetryListener = null
|
||||
}
|
||||
if (this.#llmErrorListener) {
|
||||
this.#llm.removeEventListener('error', this.#llmErrorListener)
|
||||
this.#llmErrorListener = null
|
||||
}
|
||||
|
||||
// Clean up window event listeners
|
||||
if (this.#beforeUnloadListener) {
|
||||
window.removeEventListener('beforeunload', this.#beforeUnloadListener)
|
||||
this.#beforeUnloadListener = null
|
||||
}
|
||||
|
||||
this.config.onDispose?.call(this, reason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Agent-specific constants (LLM constants moved to @page-agent/llms)
|
||||
export const MAX_STEPS = 20
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Auto-run entry for page-agent.js. Insert this script into your page to get page-agent functionality.
|
||||
* IIFE demo entry - auto-initializes with built-in demo API for testing
|
||||
*/
|
||||
import { PageAgent, type PageAgentConfig } from './PageAgent'
|
||||
|
||||
@@ -11,9 +11,6 @@ if (window.pageAgent) {
|
||||
// Mount to global window object
|
||||
window.PageAgent = PageAgent
|
||||
|
||||
// Export for ES module usage
|
||||
// export { PageAgent }
|
||||
|
||||
console.log('🚀 page-agent.js loaded!')
|
||||
|
||||
const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
@@ -21,9 +18,10 @@ const DEMO_BASE_URL = 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm
|
||||
const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
|
||||
// in case document.x is not ready yet
|
||||
// @todo give a switch to disable auto-init
|
||||
setTimeout(() => {
|
||||
const currentScript = document.currentScript as HTMLScriptElement | null
|
||||
let config: PageAgentConfig
|
||||
|
||||
if (currentScript) {
|
||||
console.log('🚀 page-agent.js detected current script:', currentScript.src)
|
||||
const url = new URL(currentScript.src)
|
||||
@@ -31,23 +29,19 @@ setTimeout(() => {
|
||||
const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL
|
||||
const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY
|
||||
const language = (url.searchParams.get('lang') as 'zh-CN' | 'en-US') || 'zh-CN'
|
||||
const config: PageAgentConfig = { model, baseURL, apiKey, language }
|
||||
window.pageAgent = new PageAgent(config)
|
||||
config = { model, baseURL, apiKey, language }
|
||||
} else {
|
||||
console.log('🚀 page-agent.js no current script detected, using default demo config')
|
||||
const config: PageAgentConfig = {
|
||||
// model: DEMO_MODEL,
|
||||
// baseURL: DEMO_BASE_URL,
|
||||
// apiKey: DEMO_API_KEY,
|
||||
|
||||
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,
|
||||
}
|
||||
window.pageAgent = new PageAgent(config)
|
||||
}
|
||||
|
||||
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
|
||||
// Create agent
|
||||
window.pageAgent = new PageAgent(config)
|
||||
window.pageAgent.panel.show()
|
||||
|
||||
window.pageAgent.panel.show() // Show panel
|
||||
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
|
||||
})
|
||||
Vendored
-11
@@ -1,20 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
import type { PageAgent } from './PageAgent'
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.md?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
pageAgent?: PageAgent
|
||||
PageAgent: typeof PageAgent
|
||||
__PAGE_AGENT_IDS__: string[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* Event mapping definitions
|
||||
* @note Event bus callbacks must be repeatable without errors
|
||||
*/
|
||||
export interface PageAgentEventMap {
|
||||
// PageAgent status events
|
||||
// 'agent:execute': { params: { task: string } }
|
||||
// 'agent:done': { params: { text: string; success: boolean } }
|
||||
// 'agent:disposed': { params: undefined }
|
||||
// 'agent:error': { params: { error: string | Error } }
|
||||
|
||||
// Task status change events
|
||||
'task:start': { params: { task: string } }
|
||||
// 'task:complete': { params: { text: string; success: boolean } }
|
||||
// 'task:error': { params: { error: string | Error } }
|
||||
|
||||
// Index signature for dynamic event names
|
||||
// [key: string]: { params: any }
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handler type definitions
|
||||
*/
|
||||
export type EventHandler<T extends keyof PageAgentEventMap> =
|
||||
PageAgentEventMap[T]['params'] extends undefined
|
||||
? () => void
|
||||
: (params: PageAgentEventMap[T]['params']) => void
|
||||
|
||||
/**
|
||||
* Async event handler type definitions
|
||||
*/
|
||||
export type AsyncEventHandler<T extends keyof PageAgentEventMap> =
|
||||
PageAgentEventMap[T]['params'] extends undefined
|
||||
? () => Promise<void>
|
||||
: (params: PageAgentEventMap[T]['params']) => Promise<void>
|
||||
|
||||
/**
|
||||
* Type-safe event bus
|
||||
* @note Mainly used to decouple logic and UI
|
||||
* @note All modules of a PageAgent instance share the same EventBus instance for communication
|
||||
* @note Use with caution if delivery guarantee is needed for logic communication
|
||||
* @note `on` `once` `emit` methods handle built-in events with type protection, use `addEventListener` for other events
|
||||
*/
|
||||
class EventBus extends EventTarget {
|
||||
/**
|
||||
* Listen to built-in events
|
||||
*/
|
||||
on<T extends keyof PageAgentEventMap>(event: T, handler: EventHandler<T>): void {
|
||||
const wrappedHandler = (e: Event) => {
|
||||
const customEvent = e as CustomEvent
|
||||
const params = customEvent.detail?.[0]
|
||||
return handler(params)
|
||||
}
|
||||
this.addEventListener(event, wrappedHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to built-in events (one-time)
|
||||
*/
|
||||
once<T extends keyof PageAgentEventMap>(event: T, handler: EventHandler<T>): void {
|
||||
const wrappedHandler = (e: Event) => {
|
||||
const customEvent = e as CustomEvent
|
||||
const params = customEvent.detail?.[0]
|
||||
return handler(params)
|
||||
}
|
||||
this.addEventListener(event, wrappedHandler, { once: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit built-in events
|
||||
*/
|
||||
emit<T extends keyof PageAgentEventMap>(
|
||||
event: T,
|
||||
...args: PageAgentEventMap[T]['params'] extends undefined
|
||||
? []
|
||||
: [PageAgentEventMap[T]['params']]
|
||||
): void {
|
||||
const customEvent = new CustomEvent(event, { detail: args })
|
||||
this.dispatchEvent(customEvent)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const buses = new Map<string, EventBus>()
|
||||
|
||||
/**
|
||||
* Get the event bus for a given channel
|
||||
*/
|
||||
export function getEventBus(channel: string) {
|
||||
if (buses.has(channel)) {
|
||||
return buses.get(channel)!
|
||||
}
|
||||
const bus = new EventBus()
|
||||
buses.set(channel, bus)
|
||||
return bus
|
||||
}
|
||||
|
||||
export type { EventBus }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user