Compare commits
193 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 299a5b2075 | |||
| 785a2d194e | |||
| 391e70dca4 | |||
| 3ffcd6482d | |||
| 0cd04db40f | |||
| 23f1818503 | |||
| 5fdb49fecb | |||
| fe1893a382 | |||
| 8c1c1ed9fe | |||
| 6cbb4e99c4 | |||
| 34ce56b446 | |||
| 23ec4602c2 | |||
| 536e5076da | |||
| 81807e17ca | |||
| 6944bedf85 | |||
| 897b919044 | |||
| 3a3bb0e58c | |||
| bc7ccf47ff | |||
| c781eba986 | |||
| efe131660a | |||
| 22516dec74 | |||
| 1f8953c450 | |||
| d6a57f3e20 | |||
| 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 | |||
| a1b485691f | |||
| 694e05b800 | |||
| b761795c1a | |||
| 8cc54983aa | |||
| 59a599285e | |||
| 2f8c2b395c | |||
| 28f836674c | |||
| 6f01632231 | |||
| 41123ac561 | |||
| c8bf0dd20f | |||
| ec2b744bab | |||
| eab9cf64e8 | |||
| 42130d6f1d | |||
| c0ce6e7d14 | |||
| 8f5f98f889 | |||
| ee4719cdcf | |||
| b4c82b7833 | |||
| aefc3cfb89 | |||
| 7f2b8c2766 | |||
| 62e24a3050 | |||
| 4f6249a252 | |||
| 62519416bb | |||
| a1e70e663d | |||
| 338e317be1 | |||
| 9a66d5205f | |||
| 7d45bf6598 | |||
| 78ee72eea4 | |||
| 3b71c535b1 | |||
| 6615f7b300 | |||
| 38caae5e23 | |||
| 1751ba325d | |||
| 066b045822 | |||
| 3b21c42cdb | |||
| 8b1e91d665 | |||
| aa5909264a | |||
| b6232d4e21 | |||
| 0d3cef5578 | |||
| 1e737f3a71 | |||
| 2f2bdb4697 | |||
| 3fa7c8b85a | |||
| 7efce91a91 | |||
| a11d370f2b | |||
| d7bfa42f85 | |||
| 551ba49280 | |||
| adab24bb22 | |||
| 64aea7e84c | |||
| 67f6bd619e | |||
| 35d1fd1166 | |||
| d5b8019fb1 | |||
| 526cb4dbb3 | |||
| 6dc56c57c6 | |||
| a3263fdd3a | |||
| 14974c0257 | |||
| e70ae40096 | |||
| 3e04a131f1 | |||
| b487a7900b | |||
| 78b49c8b7a | |||
| 949e511dcf | |||
| d9faca5d01 | |||
| 398c6d0869 | |||
| 54d478df1e | |||
| 43c80f5418 | |||
| b186ffcaa6 | |||
| 778bbda4b8 | |||
| f5900bd096 | |||
| 09772d79bd | |||
| bfa6a5347e | |||
| da5cf44012 | |||
| 1666c08e58 | |||
| 0c0c76fdd8 | |||
| 6b0b1218ac | |||
| d70f652285 | |||
| c3c01b63fb | |||
| 463d010c24 | |||
| 4e52f17b9f | |||
| 5237edfcca | |||
| 79dcf9d9e4 | |||
| 09544205ed | |||
| 2c74c3170a | |||
| 9cee8f4174 | |||
| d05b43cf50 | |||
| 3fe6ae6862 | |||
| 66be1e56e3 | |||
| 0fd4eaf9af | |||
| a0712baa91 | |||
| 49af3a9c58 | |||
| 20ad3d04d2 | |||
| c7e0538836 | |||
| 59fcfaa503 | |||
| 17befe4bab | |||
| 7911bac052 | |||
| 87a8da5783 | |||
| 0aec35aee3 | |||
| 35fe51427c | |||
| 9e0b5bec8b | |||
| 70d33a9756 | |||
| 1c99d21c19 | |||
| e3c612c338 | |||
| 87596469b6 | |||
| 566b2d7022 | |||
| f521bd9beb | |||
| 35db15460e | |||
| a34d86ba75 | |||
| 3144e8d44d | |||
| 32be34bcb1 | |||
| a774bd94dc | |||
| 7063b8199d |
@@ -58,4 +58,4 @@ body:
|
|||||||
- label: I have read the Code of Conduct. / 我已阅读行为准则。
|
- label: I have read the Code of Conduct. / 我已阅读行为准则。
|
||||||
required: true
|
required: true
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -44,4 +44,4 @@ body:
|
|||||||
- label: I have read the CODE_OF_CONDUCT.md and CONTRIBUTING.md. / 我已阅读行为准则。
|
- label: I have read the CODE_OF_CONDUCT.md and CONTRIBUTING.md. / 我已阅读行为准则。
|
||||||
required: true
|
required: true
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write # Required for OIDC
|
id-token: write # Required for OIDC
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
# Ensure npm 11.5.1 or later is installed
|
# Ensure npm 11.5.1 or later is installed
|
||||||
- name: Update npm
|
- name: Update npm
|
||||||
run: npm install -g npm@latest
|
run: npm install -g npm@latest
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
@@ -32,4 +32,12 @@ jobs:
|
|||||||
run: npm run build:libs
|
run: npm run build:libs
|
||||||
|
|
||||||
- name: Publish all public packages
|
- 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
|
||||||
|
|||||||
+7
-3
@@ -25,11 +25,15 @@ dist-ssr
|
|||||||
*.sw?
|
*.sw?
|
||||||
.qoder
|
.qoder
|
||||||
|
|
||||||
|
# env files
|
||||||
#
|
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
# Copied files for npm publish (generated by prepublishOnly)
|
# Copied files for npm publish (generated by prepublishOnly)
|
||||||
packages/page-agent/README.md
|
packages/page-agent/README.md
|
||||||
packages/page-agent/README-zh.md
|
packages/page-agent/README-zh.md
|
||||||
packages/page-agent/LICENSE
|
packages/page-agent/LICENSE
|
||||||
|
|
||||||
|
# extension
|
||||||
|
.output
|
||||||
|
.wxt
|
||||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"recommendations": ["dbaeumer.vscode-eslint", "prettier.prettier-vscode"]
|
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+30
-23
@@ -1,25 +1,32 @@
|
|||||||
{
|
{
|
||||||
"editor.fontLigatures": true,
|
"editor.fontLigatures": true,
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"HITL",
|
"deepseek",
|
||||||
"innerhtml",
|
"historychange",
|
||||||
"llms",
|
"HITL",
|
||||||
"opensource",
|
"innerhtml",
|
||||||
"retryable",
|
"llms",
|
||||||
"wouter"
|
"npmmirror",
|
||||||
],
|
"onwarn",
|
||||||
"markdownlint.config": {
|
"opensource",
|
||||||
// "comment": "Relaxed rules",
|
"qwen",
|
||||||
"default": true,
|
"retryable",
|
||||||
"whitespace": false,
|
"shadcn",
|
||||||
"line_length": false,
|
"statuschange",
|
||||||
"ul-indent": false,
|
"wouter"
|
||||||
"no-inline-html": false,
|
],
|
||||||
"no-bare-urls": false,
|
"markdownlint.config": {
|
||||||
"fenced-code-language": false,
|
// "comment": "Relaxed rules",
|
||||||
"first-line-h1": false,
|
"default": true,
|
||||||
"block-spacing": false,
|
"whitespace": false,
|
||||||
"blanks-around-lists": false,
|
"line_length": false,
|
||||||
"ol-prefix": false
|
"ul-indent": false,
|
||||||
}
|
"no-inline-html": false,
|
||||||
|
"no-bare-urls": false,
|
||||||
|
"fenced-code-language": false,
|
||||||
|
"first-line-h1": false,
|
||||||
|
"block-spacing": false,
|
||||||
|
"blanks-around-lists": false,
|
||||||
|
"ol-prefix": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +1,62 @@
|
|||||||
# Instructions for coding assistants
|
# Instructions for Coding Assistants
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
This is a **monorepo** with npm workspaces containing **two main packages**:
|
This is a **monorepo** with npm workspaces:
|
||||||
|
|
||||||
1. **Core Library** (`packages/page-agent/`) - Pure JavaScript/TypeScript AI agent library 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
|
||||||
2. **Website** (`packages/website/`) - React documentation and landing page. Also as demo and test page for the core lib. private package `@page-agent/website`
|
- **Website** (`packages/website/`) - React docs and landing page. **When working on website, follow `packages/website/AGENTS.md`**
|
||||||
|
|
||||||
And other internal packages:
|
Internal packages:
|
||||||
|
|
||||||
- **Page Controller** (`packages/page-controller/`) - DOM operations and element interactions. Independent of LLM.
|
- **Core** (`packages/core/`) - PageAgentCore without UI (npm: `@page-agent/core`)
|
||||||
- **UI** (`packages/ui/`) - Panel, SimulatorMask, and i18n. Decoupled from PageAgent.
|
- **LLMs** (`packages/llms/`) - LLM client with reflection-before-action mental model
|
||||||
|
- **Page Controller** (`packages/page-controller/`) - DOM operations and visual feedback (SimulatorMask), independent of LLM
|
||||||
|
- **UI** (`packages/ui/`) - Panel and i18n. Decoupled from PageAgent
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
### Core Commands
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm start # Start website dev server
|
npm start # Start website dev server
|
||||||
npm run build # Build all packages
|
npm run build # Build all packages
|
||||||
npm run build:libs # Build all libraries
|
npm run build:libs # Build all libraries
|
||||||
npm run build:website # Build the website
|
|
||||||
npm run lint # ESLint with TypeScript strict rules
|
npm run lint # ESLint with TypeScript strict rules
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture & Critical Patterns
|
## Architecture
|
||||||
|
|
||||||
### Monorepo Structure
|
### Monorepo Structure
|
||||||
|
|
||||||
We adopt a very simple monorepo solution: ts reference + vite alias.
|
Simple monorepo solution: TypeScript references + Vite aliases. Update tsconfig and vite config when adding/removing packages.
|
||||||
|
|
||||||
You must update tsconfig and vite config if you add/remove/rename a package.
|
```
|
||||||
|
|
||||||
```bash
|
|
||||||
packages/
|
packages/
|
||||||
├── page-agent/ # npm: "page-agent" ⭐ MAIN
|
├── core/ # npm: "@page-agent/core" ⭐ Core agent logic (headless)
|
||||||
│ ├── src/
|
├── page-agent/ # npm: "page-agent" entry class (with UI + controller + demo builds)
|
||||||
│ │ ├── PageAgent.ts # Main AI agent class
|
├── website/ # @page-agent/website (private)
|
||||||
│ │ ├── tools/ # LLM tool definitions
|
├── llms/ # @page-agent/llms
|
||||||
│ │ └── llms/ # LLM integration
|
├── page-controller/ # @page-agent/page-controller
|
||||||
│ ├── vite.config.js # Library build (ES + UMD)
|
└── ui/ # @page-agent/ui
|
||||||
│ └── package.json
|
|
||||||
├── website/ # npm: "@page-agent/website" (private) ⭐ MAIN
|
|
||||||
│ ├── src/ # Website source
|
|
||||||
│ └── index.html # Entry of vite webpage
|
|
||||||
│
|
|
||||||
│ # ...internal packages below...
|
|
||||||
│
|
|
||||||
├── page-controller/ # npm: "@page-agent/page-controller"
|
|
||||||
│ └── src/ # DOM operations
|
|
||||||
│ ├── PageController.ts
|
|
||||||
│ ├── actions.ts
|
|
||||||
│ └── dom/
|
|
||||||
└── ui/ # npm: "@page-agent/ui"
|
|
||||||
└── src/ # Panel and Mask Effects
|
|
||||||
├── Panel.ts
|
|
||||||
├── SimulatorMask.ts
|
|
||||||
└── i18n/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`workspaces` must be written in topological order to guarantee build order.
|
`workspaces` in `package.json` must be in topological order.
|
||||||
|
|
||||||
```json
|
### Module Boundaries
|
||||||
"workspaces": [
|
|
||||||
// internal deps (topological order)
|
|
||||||
"packages/page-controller",
|
|
||||||
"packages/ui",
|
|
||||||
"packages/page-agent",
|
|
||||||
"packages/website"
|
|
||||||
],
|
|
||||||
```
|
|
||||||
|
|
||||||
### Module Boundaries (Critical)
|
- **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`
|
||||||
- **Website** (`packages/website/`): CAN import from `page-agent` for demos. Alias `@/` → `website/src/`
|
- **LLMs**: LLM client with MacroToolInput contract. No dependency on page-agent
|
||||||
- **Page Agent** (`packages/page-agent/`): The core lib. Imports from `@page-agent/page-controller` and `@page-agent/ui`.
|
- **UI**: Panel and i18n. Decoupled from PageAgent via PanelAgentAdapter interface
|
||||||
- **UI** (`packages/ui/`): Panel, Mask, i18n. No dependency on page-agent.
|
- **Page Controller**: DOM operations with optional visual feedback (SimulatorMask). No LLM dependency. Enable mask via `enableMask: true` config
|
||||||
- **Page Controller** (`packages/page-controller/`): Pure DOM operations. No LLM or UI dependency.
|
|
||||||
|
|
||||||
### PageController ↔ PageAgent Communication
|
### PageController ↔ PageAgent Communication
|
||||||
|
|
||||||
All communication between PageAgent and PageController is async and isolated:
|
All communication is async and isolated:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// PageAgent delegates DOM operations to PageController
|
// PageAgent delegates DOM operations to PageController
|
||||||
await this.pageController.updateTree() // Refresh DOM state
|
await this.pageController.updateTree()
|
||||||
await this.pageController.clickElement(index) // Click by index
|
await this.pageController.clickElement(index)
|
||||||
await this.pageController.inputText(index, text)
|
await this.pageController.inputText(index, text)
|
||||||
await this.pageController.scroll({ down: true, numPages: 1 })
|
await this.pageController.scroll({ down: true, numPages: 1 })
|
||||||
|
|
||||||
@@ -94,119 +65,65 @@ const simplifiedHTML = await this.pageController.getSimplifiedHTML()
|
|||||||
const pageInfo = await this.pageController.getPageInfo()
|
const pageInfo = await this.pageController.getPageInfo()
|
||||||
```
|
```
|
||||||
|
|
||||||
DOM element references and internal state (selectorMap, elementTextMap) are encapsulated in PageController.
|
|
||||||
|
|
||||||
### DOM Pipeline
|
### DOM Pipeline
|
||||||
|
|
||||||
1. **DOM Extraction**: Convert live DOM to `FlatDomTree` via `page-controller/src/dom/dom_tree/`
|
1. **DOM Extraction**: Live DOM → `FlatDomTree` via `page-controller/src/dom/dom_tree/`
|
||||||
2. **Dehydration**: DOM tree → simplified text for LLM processing
|
2. **Dehydration**: DOM tree → simplified text for LLM
|
||||||
3. **LLM Processing**: AI model returns action plans (in page-agent)
|
3. **LLM Processing**: AI returns action plans (page-agent)
|
||||||
4. **Indexed Operations**: PageAgent calls PageController methods by element index
|
4. **Indexed Operations**: PageAgent calls PageController by element index
|
||||||
|
|
||||||
### Hash Routing Requirement
|
|
||||||
|
|
||||||
Uses wouter with `useHashLocation` for static hosting:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<Router hook={useHashLocation}> // Always hash-based routes
|
|
||||||
```
|
|
||||||
|
|
||||||
### CDN Auto-Injection Pattern
|
|
||||||
|
|
||||||
Library auto-initializes when injected via script tag:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<script src="page-agent.js?model=gpt-4"></script>
|
|
||||||
```
|
|
||||||
|
|
||||||
Query params configure `PageAgentConfig` automatically in `src/entry.ts`.
|
|
||||||
|
|
||||||
## Key Files Reference
|
## Key Files Reference
|
||||||
|
|
||||||
### Page Agent (`packages/page-agent/`)
|
### Page Agent (`packages/page-agent/`)
|
||||||
|
|
||||||
| File | Description |
|
| File | Description |
|
||||||
|------|-------------|
|
| ------------------ | -------------------------------------------- |
|
||||||
| `src/PageAgent.ts` | ⭐ Main AI agent class orchestrating tools and LLM |
|
| `src/PageAgent.ts` | ⭐ Main class with UI, extends PageAgentCore |
|
||||||
| `src/umd.ts` | CDN/UMD entry point with auto-initialization |
|
| `src/demo.ts` | IIFE demo entry (auto-init with demo API) |
|
||||||
| `src/tools/` | Tool definitions that call PageController methods |
|
|
||||||
| `src/llms/` | LLM integration and communication layer |
|
### Core (`packages/core/`)
|
||||||
| `vite.config.js` | Library build configuration (ES + UMD) |
|
|
||||||
|
| 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/OpenAIClient.ts` | OpenAI-compatible client |
|
||||||
|
|
||||||
### Page Controller (`packages/page-controller/`)
|
### Page Controller (`packages/page-controller/`)
|
||||||
|
|
||||||
| File | Description |
|
| File | Description |
|
||||||
|------|-------------|
|
| --------------------------- | ---------------------------------------------------------- |
|
||||||
| `src/PageController.ts` | ⭐ Main controller class managing DOM state and actions |
|
| `src/PageController.ts` | ⭐ Main controller class with optional mask support |
|
||||||
| `src/actions.ts` | Element interaction implementations (click, input, scroll) |
|
| `src/SimulatorMask.ts` | Visual overlay blocking user interaction during automation |
|
||||||
| `src/dom/dom_tree/index.js` | Core DOM extraction engine (ported from browser-use) |
|
| `src/actions.ts` | Element interactions (click, input, scroll) |
|
||||||
| `src/dom/getPageInfo.ts` | Page scroll/size information |
|
| `src/dom/dom_tree/index.js` | Core DOM extraction engine |
|
||||||
| `src/patches/` | Framework-specific optimizations (React, Antd) |
|
|
||||||
| `src/types.ts` | TypeScript interfaces for controller |
|
|
||||||
|
|
||||||
### Website (`packages/website/`)
|
|
||||||
|
|
||||||
| File | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `src/router.tsx` | ⭐ Central routing (manual registration required) |
|
|
||||||
| `src/components/DocsLayout.tsx` | Navigation structure (hardcoded nav items) |
|
|
||||||
| `src/main.tsx` | Site entry with hash routing setup |
|
|
||||||
| `src/docs/[section]/[topic]/page.tsx` | Documentation pages |
|
|
||||||
| `src/test-pages/` | Library integration test pages |
|
|
||||||
| `vite.config.js` | Website build configuration |
|
|
||||||
|
|
||||||
## Adding New Features
|
## Adding New Features
|
||||||
|
|
||||||
### New Documentation Page
|
|
||||||
|
|
||||||
1. Create `packages/website/src/docs/<section>/<slug>/page.tsx`
|
|
||||||
2. Add route to `packages/website/src/router.tsx` with `<Header /> + <DocsLayout>` wrapper
|
|
||||||
3. Add navigation item to `DocsLayout.tsx`
|
|
||||||
|
|
||||||
### New Agent Tool
|
### New Agent Tool
|
||||||
|
|
||||||
1. Implement tool in `packages/page-agent/src/tools/index.ts`
|
1. Implement in `packages/core/src/tools/index.ts`
|
||||||
2. If tool needs DOM operations, add method to PageController first
|
2. If tool needs DOM ops, add method to PageController first
|
||||||
3. Tool calls `this.pageController.methodName()` for DOM interactions
|
3. Tool calls `this.pageController.methodName()` for DOM interactions
|
||||||
|
|
||||||
### New PageController Action
|
### New PageController Action
|
||||||
|
|
||||||
1. Add action implementation in `packages/page-controller/src/actions.ts`
|
1. Add implementation in `packages/page-controller/src/actions.ts`
|
||||||
2. Expose via async method in `PageController.ts`
|
2. Expose via async method in `PageController.ts`
|
||||||
3. Export from `packages/page-controller/src/index.ts`
|
3. Export from `packages/page-controller/src/index.ts`
|
||||||
|
|
||||||
## Code Standards
|
## Code Standards
|
||||||
|
|
||||||
### TypeScript
|
|
||||||
|
|
||||||
- Explicit typing for exported/public APIs
|
- Explicit typing for exported/public APIs
|
||||||
- ESLint relaxes some unsafe rules for rapid iteration
|
- 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
|
||||||
### CSS & Styling
|
- All code and comments must be in English.
|
||||||
|
|
||||||
- **Prefer Tailwind CSS over custom CSS**
|
|
||||||
- Custom CSS variables for theme gradients in `src/index.css`
|
|
||||||
- Dark mode support via `dark:` classes
|
|
||||||
- CSS modules for component-specific styles
|
|
||||||
|
|
||||||
### Import Organization
|
|
||||||
|
|
||||||
- External libraries first
|
|
||||||
- Internal modules (`@/`, `@pages/`)
|
|
||||||
- Relative imports last
|
|
||||||
- Blank lines between groups
|
|
||||||
|
|
||||||
## Debugging Common Issues
|
|
||||||
|
|
||||||
### Blank Documentation Pages
|
|
||||||
|
|
||||||
1. Verify route exists in `packages/website/src/router.tsx`
|
|
||||||
2. Check component import path
|
|
||||||
3. Verify CSS isn't hiding content (check dark mode classes)
|
|
||||||
4. Test with minimal component first
|
|
||||||
|
|
||||||
### Library Integration Issues
|
|
||||||
|
|
||||||
1. Check `packages/page-agent/dist/lib/page-agent.umd.js` builds correctly
|
|
||||||
2. Test CDN injection with query params
|
|
||||||
4. Use `packages/website/src/test-pages/` for isolated testing
|
|
||||||
|
|||||||
@@ -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
|
## 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
|
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||||
|
|
||||||
----
|
---
|
||||||
|
|
||||||
> Chinese Version
|
> Chinese Version
|
||||||
> 《阿里巴巴开源行为准则》
|
> 《阿里巴巴开源行为准则》
|
||||||
|
|||||||
+65
-54
@@ -7,29 +7,30 @@ Thank you for your interest in contributing to Page-Agent! We welcome contributi
|
|||||||
### Development Setup
|
### Development Setup
|
||||||
|
|
||||||
1. **Prerequisites**
|
1. **Prerequisites**
|
||||||
- `node.js >= 20` with `npm >= 10`
|
- `node.js >= 20` with `npm >= 10`
|
||||||
- An editor that supports `ts/eslint/prettier`
|
- An editor that supports `ts/eslint/prettier`
|
||||||
- Make sure `eslint`, `prettier` and `commitlint` work well
|
- Make sure `eslint`, `prettier` and `commitlint` work well
|
||||||
|
|
||||||
2. **Setup**
|
2. **Setup**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm ci
|
||||||
npm start # Start demo and documentation site
|
npm start # Start demo and documentation site
|
||||||
```
|
```
|
||||||
|
|
||||||
### Project Structure
|
### 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
|
- **Page Agent** (`packages/page-agent/`) - Main entry with built-in UI Panel, 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`
|
- **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.
|
We use a simplified monorepo solution with `native npm-workspace + ts reference + vite alias`. No fancy tooling. Hoisting is required.
|
||||||
|
|
||||||
- When developing. Use alias so that we don't have to pre-build.
|
- When developing. Use alias so that we don't have to pre-build.
|
||||||
- When bundling. Use external and disable ts `paths` alias to leave deps out.
|
- When bundling. Use external and disable ts `paths` alias to leave deps out.
|
||||||
- When bundling `UMD` and `Website`. Bundle everything including local packages.
|
- When bundling `IIFE` and `Website`. Bundle everything including local packages.
|
||||||
|
|
||||||
## 🤝 How to Contribute
|
## 🤝 How to Contribute
|
||||||
|
|
||||||
@@ -44,40 +45,39 @@ We use a simplified monorepo solution with `native npm-workspace + ts reference
|
|||||||
|
|
||||||
1. **Fork and Clone**
|
1. **Fork and Clone**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/your-username/page-agent.git
|
git clone https://github.com/your-username/page-agent.git
|
||||||
cd page-agent
|
cd page-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Create Feature Branch**
|
2. **Create Feature Branch**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout -b feat/your-feature-name
|
git checkout -b feat/your-feature-name
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Make Changes**
|
3. **Make Changes**
|
||||||
- Follow existing code style and patterns
|
- Follow existing code style and patterns
|
||||||
- Add tests for new functionality
|
- Add tests for new functionality
|
||||||
- Update documentation as needed
|
- Update documentation as needed
|
||||||
|
|
||||||
4. **Test Your Changes**
|
4. **Test Your Changes**
|
||||||
|
- Test in our demo website
|
||||||
```bash
|
- Test it on other websites if applicable
|
||||||
# TODO
|
- `@TODO: test suite`
|
||||||
```
|
|
||||||
|
|
||||||
5. **Commit and Push**
|
5. **Commit and Push**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add .
|
git add .
|
||||||
git commit -m "feat: add awesome feature"
|
git commit -m "feat: add awesome feature"
|
||||||
git push origin feat/your-feature-name
|
git push origin feat/your-feature-name
|
||||||
```
|
```
|
||||||
|
|
||||||
6. **Create Pull Request**
|
6. **Create Pull Request**
|
||||||
- Provide clear description of changes
|
- Provide clear description of changes
|
||||||
- Link related issues
|
- Link related issues
|
||||||
- Include screenshots for UI changes
|
- Include screenshots for UI changes
|
||||||
|
|
||||||
## 📝 Code Style
|
## 📝 Code Style
|
||||||
|
|
||||||
@@ -94,41 +94,52 @@ We use a simplified monorepo solution with `native npm-workspace + ts reference
|
|||||||
- It's **recommended** to heavily rely on AI (aka "vibe coding") when maintaining **demo pages and tests**.
|
- 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!!!
|
- BUT **NOT the core lib!!!** Be very careful if AI ever touched the core lib!!!
|
||||||
- Review anything AI wrote before make a commit. You are the author of anything you commit. NOT AI.
|
- Review anything AI wrote before make a commit. You are the author of anything you commit. NOT AI.
|
||||||
- Update the AI instructions when structure changed.
|
- Update the [AI instructions](AGENTS.md) when structure changed.
|
||||||
- Cursor and Cline: `./.cursor/rules`
|
|
||||||
- Github Copilot: `./.github/copilot-instructions.md`
|
|
||||||
- Claude Code: `./CLAUDE.md`
|
|
||||||
|
|
||||||
## 🔧 Development Workflows
|
## 🔧 Development Workflows
|
||||||
|
|
||||||
|
### Test With Your Own LLM API
|
||||||
|
|
||||||
|
- Create a `.env` file in the repo root with your LLM API config
|
||||||
|
|
||||||
|
```env
|
||||||
|
LLM_MODEL_NAME=gpt-5.2
|
||||||
|
LLM_API_KEY=your-api-key
|
||||||
|
LLM_BASE_URL=https://api.your-llm-provider.com/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
- Restart the dev server to load new env vars
|
||||||
|
- If not provided, the demo will the free testing proxy by default
|
||||||
|
|
||||||
### Website Development
|
### Website Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm start # React development server
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
### Core Lib Development and Testing
|
### Testing on Other Websites
|
||||||
|
|
||||||
> @TODO this part is outdated. Update this.
|
- Start and serve a local `iife` script
|
||||||
|
|
||||||
- Config your LLM API
|
```bash
|
||||||
- Start and serve a local umd script
|
npm run dev:demo # Serving IIFE with auto rebuild at http://localhost:5174/page-agent.demo.js
|
||||||
|
```
|
||||||
|
|
||||||
```bash
|
- Add a new bookmark
|
||||||
npm run dev:umd # Serving UMD with auto rebuild at http://localhost:5173/page-agent.umd.cjs
|
|
||||||
```
|
|
||||||
|
|
||||||
- Add a new bookmark enable it on other website
|
```javascript
|
||||||
|
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
|
||||||
javascript:(function(){var s=document.createElement('script');s.src=`http://localhost:5173/page-agent.umd.cjs?t=${Math.random()}`;s.onload=()=>console.log(%27PageAgent ready!%27);document.head.appendChild(s);})();
|
|
||||||
```
|
> Warning: AK in your local `.env` will be inlined in the iife script.
|
||||||
|
|
||||||
### Adding Documentation
|
### Adding Documentation
|
||||||
|
|
||||||
1. Create `website/src/docs/section/page-name/page.tsx`
|
Ask an AI to help you add documentation to the `website/` package. Follow the existing style.
|
||||||
2. Add route to `website/src/router.tsx`
|
|
||||||
3. Add navigation link to `website/src/components/DocsLayout.tsx`
|
> Our AGENTS.md file and guardrails are designed for this purpose. But please be careful and review anything AI generated.
|
||||||
|
|
||||||
## 🎯 Contribution Areas
|
## 🎯 Contribution Areas
|
||||||
|
|
||||||
@@ -143,7 +154,7 @@ We especially welcome contributions in:
|
|||||||
|
|
||||||
## 🚫 What We Don't Accept
|
## 🚫 What We Don't Accept
|
||||||
|
|
||||||
- Changes that break existing API compatibility
|
- Changes that break existing API compatibility (Discuss first)
|
||||||
- Heavy dependencies to core library
|
- Heavy dependencies to core library
|
||||||
- Contributions without proper testing
|
- Contributions without proper testing
|
||||||
- Code that doesn't follow project conventions
|
- Code that doesn't follow project conventions
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 Alibaba
|
Copyright (c) 2026 Alibaba
|
||||||
|
Copyright (c) 2026 Simon
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
+45
-46
@@ -1,45 +1,58 @@
|
|||||||
# PageAgent 🤖🪄
|
# 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 应用。无须后端、客户端、浏览器插件。
|
纯 JS 实现的 GUI agent。使用自然语言操作你的 Web 应用。无须后端、客户端、浏览器插件。
|
||||||
|
|
||||||
🌐 [English](./README.md) | **中文**
|
🌐 [English](./README.md) | **中文**
|
||||||
|
|
||||||
👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/#/docs/introduction/overview" target="_blank"><b>📖 Documentation</b></a>
|
👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/#/docs/introduction/overview" target="_blank"><b>📖 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
|
## ✨ Features
|
||||||
|
|
||||||
- **🎯 轻松集成**
|
- **🎯 轻松集成**
|
||||||
|
- 无需 Python,无需无头浏览器,无需浏览器插件。纯页面内脚本。
|
||||||
- **🔐 端侧运行**
|
- **🔐 端侧运行**
|
||||||
- **🧠 HTML 脱水**
|
- **🧠 HTML 脱水**
|
||||||
- **💬 自然语言接口**
|
- **💬 自然语言接口**
|
||||||
- **🎨 HITL 交互界面**
|
- **🎨 HITL 交互界面**
|
||||||
|
|
||||||
## 🗺️ Roadmap
|
以及 😉
|
||||||
|
|
||||||
👉 [**Roadmap**](./ROADMAP.md)
|
- **🧪 实验性的 Chrome 扩展,支持跨页面控制** - `-b feat/ext`
|
||||||
|
|
||||||
|
👉 [**🗺️ Roadmap**](https://github.com/alibaba/page-agent/issues/96)
|
||||||
|
|
||||||
## 🚀 快速开始
|
## 🚀 快速开始
|
||||||
|
|
||||||
### CDN 集成
|
### 一行代码集成
|
||||||
|
|
||||||
|
通过我们免费的 Demo LLM 快速体验 PageAgent:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
// CDN - https://cdn.jsdelivr.net/npm/page-agent@latest/dist/umd/page-agent.js
|
<script
|
||||||
// Mirror(CN) - https://registry.npmmirror.com/page-agent/latest/files/dist/umd/page-agent.js
|
src="https://registry.npmmirror.com/page-agent/1.0.0/files/dist/iife/page-agent.demo.js"
|
||||||
<script
|
crossorigin="true"
|
||||||
src="https://registry.npmmirror.com/page-agent/latest/files/dist/umd/page-agent.js"
|
|
||||||
crossorigin="true"
|
|
||||||
type="text/javascript"
|
|
||||||
></script>
|
></script>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> - **⚠️ 仅用于技术评估。** Demo LLM 有速率和使用限制,可能随时变更。
|
||||||
|
> - **🌷 建议使用自己的 LLM API。**
|
||||||
|
|
||||||
|
| 镜像 | URL |
|
||||||
|
| ------ | ----------------------------------------------------------------------------------------- |
|
||||||
|
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.0.0/dist/iife/page-agent.demo.js |
|
||||||
|
| China | https://registry.npmmirror.com/page-agent/1.0.0/files/dist/iife/page-agent.demo.js |
|
||||||
|
|
||||||
### NPM 安装
|
### NPM 安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -49,61 +62,43 @@ npm install page-agent
|
|||||||
```javascript
|
```javascript
|
||||||
import { PageAgent } from 'page-agent'
|
import { PageAgent } from 'page-agent'
|
||||||
|
|
||||||
// 测试接口
|
|
||||||
// @note: 限流,限制 prompt 内容,限制来源,随时变更,请替换成你自己的
|
|
||||||
// @note: 使用 DeepSeek-chat(3.2) 官方版本,使用协议和隐私策略见 DeepSeek 网站
|
|
||||||
const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
|
||||||
const DEMO_BASE_URL = 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
|
|
||||||
const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
|
||||||
|
|
||||||
const agent = new PageAgent({
|
const agent = new PageAgent({
|
||||||
modelName: DEMO_MODEL,
|
model: 'deepseek-chat',
|
||||||
baseURL: DEMO_BASE_URL,
|
baseURL: 'https://api.deepseek.com',
|
||||||
apiKey: DEMO_API_KEY,
|
apiKey: 'YOUR_API_KEY',
|
||||||
language: 'zh-CN',
|
language: 'zh-CN',
|
||||||
})
|
})
|
||||||
|
|
||||||
await agent.execute('点击登录按钮')
|
await agent.execute('点击登录按钮')
|
||||||
```
|
```
|
||||||
|
|
||||||
|
适用于无法使用 NPM 的环境,我们也提供了 IIFE 构建的 CDN 方式。[@see CDN Usage](https://alibaba.github.io/page-agent/#/docs/integration/cdn-setup)
|
||||||
|
|
||||||
## 🏗️ 架构设计
|
## 🏗️ 架构设计
|
||||||
|
|
||||||
PageAgent adopts a simplified monorepo structure:
|
PageAgent adopts a simplified monorepo structure:
|
||||||
|
|
||||||
```
|
```
|
||||||
packages/
|
packages/
|
||||||
├── page-agent/ # AI agent (npm: page-agent)
|
├── core/ # ** Core agent logic without UI(npm: @page-agent/core) **
|
||||||
├── page-controller/ # DOM 操作 (npm: @page-agent/page-controller)
|
├── page-agent/ # Exported agent and demo(npm: page-agent)
|
||||||
├── ui/ # 面板 & 蒙层 & 模拟鼠标 (npm: @page-agent/ui)
|
├── llms/ # LLM 客户端 (npm: @page-agent/llms)
|
||||||
|
├── page-controller/ # DOM 操作 & 蒙层 & 模拟鼠标 (npm: @page-agent/page-controller)
|
||||||
|
├── ui/ # 面板 & i18n (npm: @page-agent/ui)
|
||||||
└── website/ # 文档站点
|
└── website/ # 文档站点
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🤝 贡献
|
## 🤝 贡献
|
||||||
|
|
||||||
欢迎社区贡献!以下是参与方式:
|
欢迎社区贡献!请参阅 [CONTRIBUTING.md](CONTRIBUTING.md) 了解环境配置和本地开发说明。
|
||||||
|
|
||||||
### 开发环境
|
请在贡献前阅读[行为准则](CODE_OF_CONDUCT.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)。
|
|
||||||
|
|
||||||
## 👏 致谢
|
## 👏 致谢
|
||||||
|
|
||||||
本项目基于以下优秀项目构建:
|
本项目基于 **[`browser-use`](https://github.com/browser-use/browser-use)** 的优秀工作构建。
|
||||||
|
|
||||||
- **[browser-use](https://github.com/browser-use/browser-use)**
|
`PageAgent` 专为**客户端网页增强**设计,不是服务端自动化工具。
|
||||||
|
|
||||||
PageAgent 专为**客户端网页增强**设计,不是服务端自动化工具。
|
|
||||||
|
|
||||||
## 📄 许可证
|
|
||||||
|
|
||||||
MIT 许可证 - 详见 [LICENSE](LICENSE) 文件。
|
|
||||||
|
|
||||||
```
|
```
|
||||||
DOM processing components and prompt are derived from browser-use:
|
DOM processing components and prompt are derived from browser-use:
|
||||||
@@ -122,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.
|
file and in the node_modules directory after installation.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
[MIT License](LICENSE)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**⭐ 如果觉得 PageAgent 有用或有趣,请给项目点个星!**
|
**⭐ 如果觉得 PageAgent 有用或有趣,请给项目点个星!**
|
||||||
|
|||||||
@@ -1,45 +1,58 @@
|
|||||||
# PageAgent 🤖🪄
|
# 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.
|
The GUI Agent Living in Your Webpage. Control web interfaces with natural language.
|
||||||
|
|
||||||
🌐 **English** | [中文](./README-zh.md)
|
🌐 **English** | [中文](./README-zh.md)
|
||||||
|
|
||||||
👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/#/docs/introduction/overview" target="_blank"><b>📖 Documentation</b></a>
|
👉 <a href="https://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
|
## ✨ 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**
|
- **🔐 Client-Side Processing**
|
||||||
- **🧠 DOM Extraction**
|
- **🧠 DOM Extraction**
|
||||||
- **💬 Natural Language Interface**
|
- **💬 Natural Language Interface**
|
||||||
- **🎨 UI with Human in the loop**
|
- **🎨 UI with Human in the loop**
|
||||||
|
|
||||||
## 🗺️ Roadmap
|
And 😉
|
||||||
|
|
||||||
👉 [**Roadmap**](./ROADMAP.md)
|
- **🧪 `cross-page` control with an experimental chrome extension** - `-b feat/ext`
|
||||||
|
|
||||||
|
👉 [**🗺️ Roadmap**](https://github.com/alibaba/page-agent/issues/96)
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### CDN Integration
|
### One-line integration
|
||||||
|
|
||||||
|
Fastest way to try PageAgent with our free Demo LLM:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
// CDN - https://cdn.jsdelivr.net/npm/page-agent@latest/dist/umd/page-agent.js
|
<script
|
||||||
// Mirror(CN) - https://registry.npmmirror.com/page-agent/latest/files/dist/umd/page-agent.js
|
src="https://cdn.jsdelivr.net/npm/page-agent@1.0.0/dist/iife/page-agent.demo.js"
|
||||||
<script
|
crossorigin="true"
|
||||||
src="https://cdn.jsdelivr.net/npm/page-agent@latest/dist/umd/page-agent.js"
|
|
||||||
crossorigin="true"
|
|
||||||
type="text/javascript"
|
|
||||||
></script>
|
></script>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> - **⚠️ For technical evaluation only.** Demo LLM has rate limits and usage restrictions. May change without notice.
|
||||||
|
> - **🌷 Bring your own LLM API.**
|
||||||
|
|
||||||
|
| Mirrors | URL |
|
||||||
|
| ------- | ----------------------------------------------------------------------------------------- |
|
||||||
|
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.0.0/dist/iife/page-agent.demo.js |
|
||||||
|
| China | https://registry.npmmirror.com/page-agent/1.0.0/files/dist/iife/page-agent.demo.js |
|
||||||
|
|
||||||
### NPM Installation
|
### NPM Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -49,18 +62,11 @@ npm install page-agent
|
|||||||
```javascript
|
```javascript
|
||||||
import { PageAgent } from 'page-agent'
|
import { PageAgent } from 'page-agent'
|
||||||
|
|
||||||
// test server
|
|
||||||
// @note: rate limit. prompt limit. Origin limit. May change anytime. Use your own llm!
|
|
||||||
// @note Using official DeepSeek-chat(3.2). Go to DeepSeek website for privacy policy.
|
|
||||||
const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
|
||||||
const DEMO_BASE_URL = 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
|
|
||||||
const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
|
||||||
|
|
||||||
const agent = new PageAgent({
|
const agent = new PageAgent({
|
||||||
modelName: DEMO_MODEL,
|
model: 'deepseek-chat',
|
||||||
baseURL: DEMO_BASE_URL,
|
baseURL: 'https://api.deepseek.com',
|
||||||
apiKey: DEMO_API_KEY,
|
apiKey: 'YOUR_API_KEY',
|
||||||
language: 'en-US',
|
language: 'en-US',
|
||||||
})
|
})
|
||||||
|
|
||||||
await agent.execute('Click the login button')
|
await agent.execute('Click the login button')
|
||||||
@@ -72,38 +78,25 @@ PageAgent adopts a simplified monorepo structure:
|
|||||||
|
|
||||||
```
|
```
|
||||||
packages/
|
packages/
|
||||||
├── page-agent/ # AI agent (npm: page-agent)
|
├── core/ # ** Core agent logic without UI(npm: @page-agent/core) **
|
||||||
├── page-controller/ # DOM operations (npm: @page-agent/page-controller)
|
├── page-agent/ # Exported agent and demo(npm: page-agent)
|
||||||
├── ui/ # Panel & Mask & Mouse Animation (npm: @page-agent/ui)
|
├── llms/ # LLM client (npm: @page-agent/llms)
|
||||||
|
├── page-controller/ # DOM operations & Visual Mask (npm: @page-agent/page-controller)
|
||||||
|
├── ui/ # Panel & i18n (npm: @page-agent/ui)
|
||||||
└── website/ # Demo & Documentation site
|
└── website/ # Demo & Documentation site
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 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
|
Please read [Code of Conduct](CODE_OF_CONDUCT.md) before contributing.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## 👏 Acknowledgments
|
## 👏 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.
|
||||||
|
|
||||||
PageAgent is designed for **client-side web enhancement**, not server-side automation.
|
|
||||||
|
|
||||||
## 📄 License
|
|
||||||
|
|
||||||
MIT License - see the [LICENSE](LICENSE) file for details.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
DOM processing components and prompt are derived from browser-use:
|
DOM processing components and prompt are derived from browser-use:
|
||||||
@@ -122,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.
|
file and in the node_modules directory after installation.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
[MIT License](LICENSE)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**⭐ Star this repo if you find PageAgent helpful!**
|
**⭐ Star this repo if you find PageAgent helpful!**
|
||||||
|
|||||||
-61
@@ -1,61 +0,0 @@
|
|||||||
# 🗺️ PageAgent Roadmap
|
|
||||||
|
|
||||||
The development progress and future plans for PageAgent.
|
|
||||||
|
|
||||||
## 🚀 Current Works
|
|
||||||
|
|
||||||
- [x] **MVP**
|
|
||||||
- Core functionality implemented.
|
|
||||||
- [x] **SPA interaction**
|
|
||||||
- [x] **Reasoning and (short) memory**
|
|
||||||
- [x] **Multi model provider integration and testing**
|
|
||||||
- [x] **UI with HITL**
|
|
||||||
- Human-in-the-loop user interface. Agent can ask user questions.
|
|
||||||
- [x] **Landing and doc pages**
|
|
||||||
- [x] **Remove `ai-sdk`**
|
|
||||||
- Only one function of AI-ADK is being used.
|
|
||||||
- Our agent memory and thinking mechanism does not suite ai-sdk.
|
|
||||||
- [x] **Robust LLM output**
|
|
||||||
- Auto-fix incomplete output format of DeepSeek and QWen.
|
|
||||||
- [x] **Working homepage with live LLM API**
|
|
||||||
- [x] **~~free~~ CDN**
|
|
||||||
- [x] **Free evaluation plan**
|
|
||||||
- [x] **Custom actions and HITL**
|
|
||||||
- [ ] **Hooks and Events**
|
|
||||||
- [x] **lifecycle hooks**
|
|
||||||
- [ ] **lifecycle events**
|
|
||||||
- [ ] **User takeover**
|
|
||||||
- [#64](https://github.com/alibaba/page-agent/issues/64)
|
|
||||||
- [ ] **❗Hijack `page_open/page_change/page_unload` behavior**
|
|
||||||
- [ ] **Custom knowledge base and instructions**
|
|
||||||
- [#45](https://github.com/alibaba/page-agent/issues/45)
|
|
||||||
- [ ] **Black/white-list safeguard**
|
|
||||||
- [ ] **Data-masking**
|
|
||||||
- [#44](https://github.com/alibaba/page-agent/issues/44)
|
|
||||||
- [ ] **Improve Memory**
|
|
||||||
- [#66](https://github.com/alibaba/page-agent/issues/66)
|
|
||||||
- [#65](https://github.com/alibaba/page-agent/issues/65)
|
|
||||||
- [ ] **Optimize for popular UI frameworks**
|
|
||||||
- [x] **i18n of the website**
|
|
||||||
- [x] Chinese version
|
|
||||||
- [x] English version
|
|
||||||
- [x] **Refactor: Separate `Agent` and `PageController`**
|
|
||||||
- [ ] **Chrome-ext wrapper for multi-page tasks**
|
|
||||||
|
|
||||||
♻️ Following browser-use's update and contribute back.
|
|
||||||
|
|
||||||
## 📋 Pending Features
|
|
||||||
|
|
||||||
- [ ] **Tools for more complex tasks**
|
|
||||||
- todo list
|
|
||||||
- file sys
|
|
||||||
- [ ] **Support custom llm fetch**
|
|
||||||
- [ ] **Testing suits**
|
|
||||||
- [ ] **Same-origin multi-page-app rally**
|
|
||||||
- [ ] **Local MCP proxy**
|
|
||||||
|
|
||||||
## 🤔 To Be Decided
|
|
||||||
|
|
||||||
- [ ] **Cross-origin multi-page?**
|
|
||||||
- Tricky
|
|
||||||
- Need some kind of "memory rally"
|
|
||||||
+1
-1
@@ -8,7 +8,7 @@ import globals from 'globals'
|
|||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['**/dist', '**/test-pages', '**/node_modules']),
|
globalIgnores(['**/dist', '**/node_modules', 'packages/website/src/components/ui']),
|
||||||
{
|
{
|
||||||
plugins: {
|
plugins: {
|
||||||
'react-hooks': reactHooks,
|
'react-hooks': reactHooks,
|
||||||
|
|||||||
Generated
+8207
-7240
File diff suppressed because it is too large
Load Diff
+121
-99
@@ -1,101 +1,123 @@
|
|||||||
{
|
{
|
||||||
"name": "root",
|
"name": "root",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.14",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/page-controller",
|
"packages/page-controller",
|
||||||
"packages/ui",
|
"packages/ui",
|
||||||
"packages/llms",
|
"packages/llms",
|
||||||
"packages/page-agent",
|
"packages/core",
|
||||||
"packages/website"
|
"packages/page-agent",
|
||||||
],
|
"packages/website"
|
||||||
"description": "AI-powered UI agent for web applications",
|
],
|
||||||
"author": "Simon<gaomeng1900>",
|
"description": "AI-powered UI agent for web applications",
|
||||||
"license": "MIT",
|
"author": "Simon<gaomeng1900>",
|
||||||
"repository": {
|
"license": "MIT",
|
||||||
"type": "git",
|
"repository": {
|
||||||
"url": "https://github.com/alibaba/page-agent.git"
|
"type": "git",
|
||||||
},
|
"url": "https://github.com/alibaba/page-agent.git"
|
||||||
"homepage": "https://alibaba.github.io/page-agent/",
|
},
|
||||||
"engines": {
|
"homepage": "https://alibaba.github.io/page-agent/",
|
||||||
"node": ">=20.0.0",
|
"engines": {
|
||||||
"npm": ">=10.0.0"
|
"node": ">=20.0.0",
|
||||||
},
|
"npm": ">=10.0.0"
|
||||||
"scripts": {
|
},
|
||||||
"start": "npm run dev --workspace=@page-agent/website",
|
"scripts": {
|
||||||
"build:website": "npm run build:website --workspace=@page-agent/website",
|
"cleanup": "rm -rf packages/*/dist",
|
||||||
"build:libs": "npm run build --workspaces --if-present",
|
"start": "npm run dev --workspace=@page-agent/website",
|
||||||
"build": "npm run build:libs && npm run build:website",
|
"build:website": "npm run build:website --workspace=@page-agent/website",
|
||||||
"dev:umd": "npm run dev:umd --workspace=page-agent",
|
"build:libs": "npm run build --workspaces --if-present",
|
||||||
"version": "node scripts/sync-version.js",
|
"build": "npm run build:libs && npm run build:website",
|
||||||
"lint": "eslint .",
|
"dev:demo": "npm run dev:demo --workspace=page-agent",
|
||||||
"prepare": "husky"
|
"version": "node scripts/sync-version.js",
|
||||||
},
|
"lint": "eslint .",
|
||||||
"devDependencies": {
|
"prepare": "husky"
|
||||||
"@commitlint/cli": "^20.2.0",
|
},
|
||||||
"@commitlint/config-conventional": "^20.2.0",
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.2",
|
"@commitlint/cli": "^20.3.1",
|
||||||
"@microsoft/api-extractor": "^7.55.2",
|
"@commitlint/config-conventional": "^20.3.1",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.0",
|
"@eslint/js": "^9.39.2",
|
||||||
"concurrently": "^9.2.1",
|
"@microsoft/api-extractor": "^7.55.2",
|
||||||
"dotenv": "^17.2.3",
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"eslint": "^9.39.2",
|
"@types/node": "^25.0.9",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"concurrently": "^9.2.1",
|
||||||
"eslint-plugin-react-dom": "^2.3.13",
|
"dotenv": "^17.2.3",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint": "^9.39.2",
|
||||||
"eslint-plugin-react-refresh": "^0.4.25",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-react-x": "^2.3.13",
|
"eslint-plugin-react-dom": "^2.7.2",
|
||||||
"globals": "^16.4.0",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"husky": "^9.1.7",
|
"eslint-plugin-react-refresh": "^0.4.26",
|
||||||
"lint-staged": "^16.2.4",
|
"eslint-plugin-react-x": "^2.7.2",
|
||||||
"prettier": "^3.7.4",
|
"globals": "^17.0.0",
|
||||||
"typescript": "^5.9.3",
|
"husky": "^9.1.7",
|
||||||
"typescript-eslint": "^8.50.0",
|
"lint-staged": "^16.2.4",
|
||||||
"unplugin-dts": "^1.0.0-beta.6",
|
"prettier": "^3.8.0",
|
||||||
"vite": "^7.3.0",
|
"typescript": "^5.9.3",
|
||||||
"vite-plugin-css-injected-by-js": "^3.5.2"
|
"typescript-eslint": "^8.53.1",
|
||||||
},
|
"unplugin-dts": "^1.0.0-beta.6",
|
||||||
"lint-staged": {
|
"vite": "^7.3.1",
|
||||||
"*.{js,ts,cjs,cts,mjs,mts}": [
|
"vite-plugin-css-injected-by-js": "^3.5.2"
|
||||||
"npx prettier --write --ignore-unknown",
|
},
|
||||||
"npx eslint --quiet"
|
"overrides": {
|
||||||
],
|
"typescript": "^5.9.3"
|
||||||
"*.{jsx,tsx}": [
|
},
|
||||||
"npx prettier --write --ignore-unknown",
|
"lint-staged": {
|
||||||
"npx eslint --quiet"
|
"*.{js,ts,cjs,cts,mjs,mts}": [
|
||||||
],
|
"npx prettier --write --ignore-unknown",
|
||||||
"*.css": [
|
"npx eslint --quiet"
|
||||||
"npx prettier --write --ignore-unknown"
|
],
|
||||||
]
|
"*.{jsx,tsx}": [
|
||||||
},
|
"npx prettier --write --ignore-unknown",
|
||||||
"commitlint": {
|
"npx eslint --quiet"
|
||||||
"extends": [
|
],
|
||||||
"@commitlint/config-conventional"
|
"*.css": [
|
||||||
],
|
"npx prettier --write --ignore-unknown"
|
||||||
"rules": {
|
]
|
||||||
"subject-case": [
|
},
|
||||||
0,
|
"commitlint": {
|
||||||
"never"
|
"extends": [
|
||||||
]
|
"@commitlint/config-conventional"
|
||||||
}
|
],
|
||||||
},
|
"rules": {
|
||||||
"prettier": {
|
"subject-case": [
|
||||||
"singleQuote": true,
|
0,
|
||||||
"semi": false,
|
"never"
|
||||||
"useTabs": true,
|
]
|
||||||
"printWidth": 100,
|
}
|
||||||
"trailingComma": "es5",
|
},
|
||||||
"plugins": [
|
"prettier": {
|
||||||
"@trivago/prettier-plugin-sort-imports"
|
"singleQuote": true,
|
||||||
],
|
"semi": false,
|
||||||
"importOrder": [
|
"useTabs": true,
|
||||||
"<THIRD_PARTY_MODULES>",
|
"printWidth": 100,
|
||||||
"^(@/).*(?<!css)$",
|
"trailingComma": "es5",
|
||||||
"^[./].*(?<!css)$",
|
"plugins": [
|
||||||
".css$"
|
"@trivago/prettier-plugin-sort-imports"
|
||||||
],
|
],
|
||||||
"importOrderSeparation": true,
|
"importOrder": [
|
||||||
"importOrderSortSpecifiers": true
|
"<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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "@page-agent/core",
|
||||||
|
"private": false,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/esm/page-agent-core.js",
|
||||||
|
"module": "./dist/esm/page-agent-core.js",
|
||||||
|
"types": "./dist/esm/PageAgentCore.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/esm/PageAgentCore.d.ts",
|
||||||
|
"import": "./dist/esm/page-agent-core.js",
|
||||||
|
"default": "./dist/esm/page-agent-core.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/"
|
||||||
|
],
|
||||||
|
"description": "GUI agent for web applications - add intelligent automation to any webpage with a single script",
|
||||||
|
"keywords": [
|
||||||
|
"ai",
|
||||||
|
"automation",
|
||||||
|
"ui-agent",
|
||||||
|
"GUI-agent",
|
||||||
|
"browser-automation",
|
||||||
|
"web-agent",
|
||||||
|
"llm",
|
||||||
|
"dom-interaction",
|
||||||
|
"web-automation",
|
||||||
|
"GUI-simulation"
|
||||||
|
],
|
||||||
|
"author": "Simon<gaomeng1900>",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/alibaba/page-agent.git"
|
||||||
|
},
|
||||||
|
"homepage": "https://alibaba.github.io/page-agent/",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"dev:iife": "concurrently \"vite build --config vite.iife.config.js --watch\" \"npx serve dist/iife -p 5174\"",
|
||||||
|
"prepublishOnly": "node -e \"const fs=require('fs');['README.md','LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
||||||
|
"postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"chalk": "^5.6.2",
|
||||||
|
"zod": "^4.3.5",
|
||||||
|
"@page-agent/llms": "1.0.0",
|
||||||
|
"@page-agent/page-controller": "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
disposed = false
|
||||||
|
task = ''
|
||||||
|
taskId = ''
|
||||||
|
|
||||||
|
/** Agent execution status */
|
||||||
|
#status: AgentStatus = 'idle'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for when agent needs user input (ask_user tool)
|
||||||
|
* If not set, ask_user tool will be disabled
|
||||||
|
* @example onAskUser: (q) => window.prompt(q) || ''
|
||||||
|
*/
|
||||||
|
onAskUser?: (question: string) => Promise<string>
|
||||||
|
|
||||||
|
#llm: LLM
|
||||||
|
#abortController = new AbortController()
|
||||||
|
|
||||||
|
/** PageController for DOM operations */
|
||||||
|
pageController: PageController
|
||||||
|
|
||||||
|
/** Runtime states for tracking across steps */
|
||||||
|
states = {
|
||||||
|
/** Accumulated wait time in seconds, used by wait tool */
|
||||||
|
totalWaitTime: 0,
|
||||||
|
/** Last known URL for detecting navigation */
|
||||||
|
lastURL: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** History events */
|
||||||
|
history: HistoricalEvent[] = []
|
||||||
|
|
||||||
|
constructor(config: PageAgentConfig & { pageController: PageController }) {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.config = { ...config, maxSteps: config.maxSteps || DEFAULT_MAX_STEPS }
|
||||||
|
|
||||||
|
this.#llm = new LLM(this.config)
|
||||||
|
this.tools = new Map(tools)
|
||||||
|
this.pageController = config.pageController
|
||||||
|
|
||||||
|
// Listen to LLM retry events
|
||||||
|
this.#llm.addEventListener('retry', (e) => {
|
||||||
|
const { attempt, maxAttempts } = (e as CustomEvent).detail
|
||||||
|
this.emitActivity({ type: 'retrying', attempt, maxAttempts })
|
||||||
|
// Also push to history for panel rendering
|
||||||
|
this.history.push({
|
||||||
|
type: '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
|
||||||
|
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' })
|
||||||
|
|
||||||
|
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: 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,
|
||||||
|
} 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('Step count exceeded maximum limit', false)
|
||||||
|
const result: ExecutionResult = {
|
||||||
|
success: false,
|
||||||
|
data: 'Step count exceeded maximum limit',
|
||||||
|
history: this.history,
|
||||||
|
}
|
||||||
|
await onAfterTask?.(this, result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if (actionName === 'done') {
|
||||||
|
const success = action.input?.success ?? false
|
||||||
|
const text = action.input?.text || 'no text provided'
|
||||||
|
console.log(chalk.green.bold('Task completed'), success, text)
|
||||||
|
this.#onDone(text, success)
|
||||||
|
const result: ExecutionResult = {
|
||||||
|
success,
|
||||||
|
data: text,
|
||||||
|
history: this.history,
|
||||||
|
}
|
||||||
|
await onAfterTask?.(this, result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('Task failed', error)
|
||||||
|
const errorMessage = String(error)
|
||||||
|
this.emitActivity({ type: 'error', message: errorMessage })
|
||||||
|
this.#onDone(errorMessage, false)
|
||||||
|
const result: ExecutionResult = {
|
||||||
|
success: false,
|
||||||
|
data: errorMessage,
|
||||||
|
history: this.history,
|
||||||
|
}
|
||||||
|
await onAfterTask?.(this, result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge all tools into a single MacroTool with the following input:
|
||||||
|
* - thinking: string
|
||||||
|
* - evaluation_previous_goal: string
|
||||||
|
* - memory: string
|
||||||
|
* - next_goal: string
|
||||||
|
* - action: { toolName: toolInput }
|
||||||
|
* where action must be selected from tools defined in this.tools
|
||||||
|
*/
|
||||||
|
#packMacroTool(): Tool<MacroToolInput, MacroToolResult> {
|
||||||
|
const tools = this.tools
|
||||||
|
|
||||||
|
const actionSchemas = Array.from(tools.entries()).map(([toolName, tool]) => {
|
||||||
|
return zod.object({ [toolName]: tool.inputSchema }).describe(tool.description)
|
||||||
|
})
|
||||||
|
|
||||||
|
const actionSchema = zod.union(
|
||||||
|
actionSchemas as unknown as [zod.ZodType, zod.ZodType, ...zod.ZodType[]]
|
||||||
|
)
|
||||||
|
|
||||||
|
const macroToolSchema = zod.object({
|
||||||
|
// thinking: zod.string().optional(),
|
||||||
|
evaluation_previous_goal: zod.string().optional(),
|
||||||
|
memory: zod.string().optional(),
|
||||||
|
next_goal: zod.string().optional(),
|
||||||
|
action: actionSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: 'You MUST call this tool every step. Outputs your reflections and next action.',
|
||||||
|
inputSchema: macroToolSchema as zod.ZodType<MacroToolInput>,
|
||||||
|
execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
|
||||||
|
// abort
|
||||||
|
if (this.#abortController.signal.aborted) throw new Error('AbortError')
|
||||||
|
|
||||||
|
console.log(chalk.blue.bold('MacroTool execute'), input)
|
||||||
|
const action = input.action
|
||||||
|
|
||||||
|
const toolName = Object.keys(action)[0]
|
||||||
|
const toolInput = action[toolName]
|
||||||
|
|
||||||
|
// Build reflection text, only include non-empty fields
|
||||||
|
const reflectionLines: string[] = []
|
||||||
|
if (input.evaluation_previous_goal)
|
||||||
|
reflectionLines.push(`✅: ${input.evaluation_previous_goal}`)
|
||||||
|
if (input.memory) reflectionLines.push(`💾: ${input.memory}`)
|
||||||
|
if (input.next_goal) reflectionLines.push(`🎯: ${input.next_goal}`)
|
||||||
|
|
||||||
|
const reflectionText = reflectionLines.length > 0 ? reflectionLines.join('\n') : ''
|
||||||
|
|
||||||
|
if (reflectionText) {
|
||||||
|
console.log(reflectionText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the corresponding tool
|
||||||
|
const tool = tools.get(toolName)
|
||||||
|
assert(tool, `Tool ${toolName} not found. (@note should have been caught before this!!!)`)
|
||||||
|
|
||||||
|
console.log(chalk.blue.bold(`Executing tool: ${toolName}`), toolInput)
|
||||||
|
|
||||||
|
// Emit executing activity
|
||||||
|
this.emitActivity({ type: 'executing', tool: toolName, input: toolInput })
|
||||||
|
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
// Execute tool, bind `this` to PageAgent
|
||||||
|
const result = await tool.execute.bind(this)(toolInput)
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime
|
||||||
|
console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result)
|
||||||
|
|
||||||
|
// Emit executed activity
|
||||||
|
this.emitActivity({
|
||||||
|
type: 'executed',
|
||||||
|
tool: toolName,
|
||||||
|
input: toolInput,
|
||||||
|
output: result,
|
||||||
|
duration,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset wait time for non-wait tools
|
||||||
|
if (toolName !== 'wait') {
|
||||||
|
this.states.totalWaitTime = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return structured result
|
||||||
|
return {
|
||||||
|
input,
|
||||||
|
output: result,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get system prompt, dynamically replace language settings based on configured language
|
||||||
|
*/
|
||||||
|
#getSystemPrompt(): string {
|
||||||
|
let systemPrompt = SYSTEM_PROMPT
|
||||||
|
|
||||||
|
const targetLanguage = this.config.language === 'zh-CN' ? '中文' : 'English'
|
||||||
|
systemPrompt = systemPrompt.replace(
|
||||||
|
/Default working language: \*\*.*?\*\*/,
|
||||||
|
`Default working language: **${targetLanguage}**`
|
||||||
|
)
|
||||||
|
|
||||||
|
return systemPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get instructions from config and format as XML block
|
||||||
|
*/
|
||||||
|
async #getInstructions(): Promise<string> {
|
||||||
|
const { instructions } = this.config
|
||||||
|
if (!instructions) return ''
|
||||||
|
|
||||||
|
const systemInstructions = instructions.system?.trim()
|
||||||
|
const url = await this.pageController.getCurrentUrl()
|
||||||
|
let pageInstructions: string | undefined
|
||||||
|
|
||||||
|
if (instructions.getPageInstructions) {
|
||||||
|
try {
|
||||||
|
pageInstructions = instructions.getPageInstructions(url)?.trim()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
chalk.red('[PageAgent] Failed to execute getPageInstructions callback:'),
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!systemInstructions && !pageInstructions) return ''
|
||||||
|
|
||||||
|
let result = '<instructions>\n'
|
||||||
|
|
||||||
|
if (systemInstructions) {
|
||||||
|
result += `<system_instructions>\n${systemInstructions}\n</system_instructions>\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageInstructions) {
|
||||||
|
result += `<page_instructions>\n${pageInstructions}\n</page_instructions>\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
result += '</instructions>\n\n'
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate observations before each step
|
||||||
|
* - URL change detection
|
||||||
|
* - Too many steps warning
|
||||||
|
* @todo loop detection
|
||||||
|
* @todo console error
|
||||||
|
*/
|
||||||
|
async #generateObservations(stepCount: number): Promise<void> {
|
||||||
|
// Detect URL change
|
||||||
|
const currentURL = await this.pageController.getCurrentUrl()
|
||||||
|
if (currentURL !== this.states.lastURL) {
|
||||||
|
this.pushObservation(`Page navigated to → ${currentURL}`)
|
||||||
|
this.states.lastURL = currentURL
|
||||||
|
await waitFor(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().toISOString()}
|
||||||
|
</step_info>
|
||||||
|
</agent_state>
|
||||||
|
`
|
||||||
|
|
||||||
|
// <agent_history>
|
||||||
|
// - <step_N> for steps
|
||||||
|
// - <sys> for observations and system messages
|
||||||
|
|
||||||
|
prompt += '\n<agent_history>\n'
|
||||||
|
|
||||||
|
let stepIndex = 0
|
||||||
|
for (const event of this.history) {
|
||||||
|
if (event.type === 'step') {
|
||||||
|
stepIndex++
|
||||||
|
prompt += `<step_${stepIndex}>
|
||||||
|
Evaluation of Previous Step: ${event.reflection.evaluation_previous_goal}
|
||||||
|
Memory: ${event.reflection.memory}
|
||||||
|
Next Goal: ${event.reflection.next_goal}
|
||||||
|
Action Results: ${event.action.output}
|
||||||
|
</step_${stepIndex}>
|
||||||
|
`
|
||||||
|
} else if (event.type === 'observation') {
|
||||||
|
prompt += `<sys>${event.content}</sys>\n`
|
||||||
|
} else if (event.type === 'user_takeover') {
|
||||||
|
prompt += `<sys>User took over control and made changes to the page.</sys>\n`
|
||||||
|
} else if (event.type === 'error') {
|
||||||
|
// Error events are mainly for panel rendering, not included in LLM context
|
||||||
|
// to avoid polluting the agent's reasoning with transient errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt += '</agent_history>\n\n'
|
||||||
|
|
||||||
|
// <browser_state>
|
||||||
|
|
||||||
|
prompt += await this.#getBrowserState()
|
||||||
|
|
||||||
|
return trimLines(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
#onDone(text: string, success = true) {
|
||||||
|
this.pageController.cleanUpHighlights()
|
||||||
|
this.pageController.hideMask() // No await - fire and forget
|
||||||
|
this.#setStatus(success ? 'completed' : 'error')
|
||||||
|
this.#abortController.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
async #getBrowserState(): Promise<string> {
|
||||||
|
const state = await this.pageController.getBrowserState()
|
||||||
|
|
||||||
|
let content = state.content
|
||||||
|
if (this.config.transformPageContent) {
|
||||||
|
content = await this.config.transformPageContent(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimLines(`<browser_state>
|
||||||
|
Current Page: [${state.title}](${state.url})
|
||||||
|
|
||||||
|
${state.header}
|
||||||
|
${content}
|
||||||
|
${state.footer}
|
||||||
|
|
||||||
|
</browser_state>
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(reason?: string) {
|
||||||
|
console.log('Disposing PageAgent...')
|
||||||
|
this.disposed = true
|
||||||
|
this.pageController.dispose()
|
||||||
|
this.history = []
|
||||||
|
this.#abortController.abort(reason ?? 'PageAgent disposed')
|
||||||
|
|
||||||
|
// Emit dispose event for UI cleanup
|
||||||
|
this.dispatchEvent(new Event('dispose'))
|
||||||
|
|
||||||
|
this.config.onDispose?.(this, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const DEFAULT_MAX_STEPS = 20
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import type { LLMConfig } from '@page-agent/llms'
|
||||||
|
import type { PageControllerConfig } from '@page-agent/page-controller'
|
||||||
|
|
||||||
|
import type { PageAgentCore } from '../PageAgentCore'
|
||||||
|
import type { PageAgentTool } from '../tools'
|
||||||
|
import type { ExecutionResult, HistoricalEvent } from '../types'
|
||||||
|
|
||||||
|
export type { LLMConfig }
|
||||||
|
|
||||||
|
/** Supported UI languages */
|
||||||
|
export type SupportedLanguage = 'en-US' | 'zh-CN'
|
||||||
|
|
||||||
|
export interface AgentConfig {
|
||||||
|
// theme?: 'light' | 'dark'
|
||||||
|
language?: SupportedLanguage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of steps the agent can take per task.
|
||||||
|
* @default 20
|
||||||
|
*/
|
||||||
|
maxSteps?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom tools to extend PageAgent capabilities
|
||||||
|
* @experimental
|
||||||
|
* @note You can also override or remove internal tools by using the same name.
|
||||||
|
* @see PageAgentTool
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // override internal tool
|
||||||
|
* import { tool } from 'page-agent'
|
||||||
|
* const customTools = {
|
||||||
|
* ask_user: tool({
|
||||||
|
* description:
|
||||||
|
* 'Ask the user or parent model a question and wait for their answer. Use this if you need more information or clarification.',
|
||||||
|
* inputSchema: zod.object({
|
||||||
|
* question: zod.string(),
|
||||||
|
* }),
|
||||||
|
* execute: async function (this: PageAgent, input) {
|
||||||
|
* const answer = await do_some_thing(input.question)
|
||||||
|
* return "✅ Received user answer: " + answer
|
||||||
|
* },
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // remove internal tool
|
||||||
|
* const customTools = {
|
||||||
|
* ask_user: null // never ask user questions
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
customTools?: Record<string, PageAgentTool | null>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instructions to guide the agent's behavior
|
||||||
|
*/
|
||||||
|
instructions?: {
|
||||||
|
/**
|
||||||
|
* Global system-level instructions, applied to all tasks
|
||||||
|
*/
|
||||||
|
system?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic page-level instructions callback
|
||||||
|
* Called before each step to get instructions for the current page
|
||||||
|
* @param url - Current page URL (window.location.href)
|
||||||
|
* @returns Instructions string, or undefined/null to skip
|
||||||
|
*/
|
||||||
|
getPageInstructions?: (url: string) => string | undefined | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle hooks for task execution.
|
||||||
|
* @experimental API may change in future versions.
|
||||||
|
*
|
||||||
|
* All hooks receive the agent instance as first parameter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called before each step execution.
|
||||||
|
* @experimental
|
||||||
|
* @param agent - The PageAgentCore instance
|
||||||
|
* @param stepCount - Current step number (0-indexed)
|
||||||
|
*/
|
||||||
|
onBeforeStep?: (agent: PageAgentCore, stepCount: number) => Promise<void> | void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called after each step execution.
|
||||||
|
* @experimental
|
||||||
|
* @param agent - The PageAgentCore instance
|
||||||
|
* @param history - Current history of events
|
||||||
|
*/
|
||||||
|
onAfterStep?: (agent: PageAgentCore, history: HistoricalEvent[]) => Promise<void> | void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called before task execution starts.
|
||||||
|
* @experimental
|
||||||
|
* @param agent - The PageAgentCore instance
|
||||||
|
*/
|
||||||
|
onBeforeTask?: (agent: PageAgentCore) => Promise<void> | void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called after task execution completes (success or failure).
|
||||||
|
* @experimental
|
||||||
|
* @param agent - The PageAgentCore instance
|
||||||
|
* @param result - The execution result
|
||||||
|
*/
|
||||||
|
onAfterTask?: (agent: PageAgentCore, result: ExecutionResult) => Promise<void> | void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the agent is disposed.
|
||||||
|
* @experimental
|
||||||
|
* @note This hook can block the disposal process if it's async.
|
||||||
|
* @param agent - The PageAgentCore instance
|
||||||
|
* @param reason - Optional reason for disposal
|
||||||
|
*/
|
||||||
|
onDispose?: (agent: PageAgentCore, reason?: string) => void
|
||||||
|
|
||||||
|
// page behavior hooks
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @experimental
|
||||||
|
* Enable the experimental script execution tool that allows executing generated JavaScript code on the page.
|
||||||
|
* @note Can cause unpredictable side effects.
|
||||||
|
* @note May bypass some safe guards and data-masking mechanisms.
|
||||||
|
*/
|
||||||
|
experimentalScriptExecutionTool?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform page content before sending to LLM.
|
||||||
|
* Called after DOM extraction and simplification, before LLM invocation.
|
||||||
|
* Use cases: inspect extraction results, modify page info, mask sensitive data.
|
||||||
|
*
|
||||||
|
* @param content - Simplified page content that will be sent to LLM
|
||||||
|
* @returns Transformed content
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Mask phone numbers
|
||||||
|
* transformPageContent: async (content) => {
|
||||||
|
* return content.replace(/1[3-9]\d{9}/g, '***********')
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
transformPageContent?: (content: string) => Promise<string> | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PageAgentConfig = LLMConfig & AgentConfig & PageControllerConfig
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.md?raw' {
|
||||||
|
const content: string
|
||||||
|
export default content
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
system_prompt.md
|
||||||
+1
-2
@@ -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.
|
- 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.
|
- Webpage can be broken. All webpages or apps have bugs. Some bug will make it hard for your job. It's encouraged to tell user the problem of current page. Your feedbacks (including failing) are valuable for user.
|
||||||
- Trying to hard can be harmful. Repeating some action back and forth or pushing for a complex procedure with little knowledge can cause unwanted result and harmful side-effects. User would rather you to complete the task with a fail.
|
- Trying 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.
|
- If you do not have knowledge for the current webpage or task. You must require user to give specific instructions and detailed steps.
|
||||||
</capability>
|
</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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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>
|
</reasoning_rules>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
import zod, { type z } from 'zod'
|
import zod, { type z } from 'zod'
|
||||||
|
|
||||||
import type { PageAgent } from '../PageAgent'
|
import type { PageAgentCore } from '../PageAgentCore'
|
||||||
import { waitFor } from '../utils'
|
import { waitFor } from '../utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,7 +14,7 @@ export interface PageAgentTool<TParams = any> {
|
|||||||
// name: string
|
// name: string
|
||||||
description: string
|
description: string
|
||||||
inputSchema: z.ZodType<TParams>
|
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> {
|
export function tool<TParams>(options: PageAgentTool<TParams>): PageAgentTool<TParams> {
|
||||||
@@ -36,7 +36,7 @@ tools.set(
|
|||||||
text: zod.string(),
|
text: zod.string(),
|
||||||
success: zod.boolean().default(true),
|
success: zod.boolean().default(true),
|
||||||
}),
|
}),
|
||||||
execute: async function (this: PageAgent, input) {
|
execute: async function (this: PageAgentCore, input) {
|
||||||
// @note main loop will handle this one
|
// @note main loop will handle this one
|
||||||
// this.onDone(input.text, input.success)
|
// this.onDone(input.text, input.success)
|
||||||
return Promise.resolve('Task completed')
|
return Promise.resolve('Task completed')
|
||||||
@@ -52,11 +52,20 @@ tools.set(
|
|||||||
inputSchema: zod.object({
|
inputSchema: zod.object({
|
||||||
seconds: zod.number().min(1).max(10).default(1),
|
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 lastTimeUpdate = await this.pageController.getLastUpdateTime()
|
||||||
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000)
|
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000)
|
||||||
console.log(`actualWaitTime: ${actualWaitTime} seconds`)
|
console.log(`actualWaitTime: ${actualWaitTime} seconds`)
|
||||||
await waitFor(actualWaitTime)
|
await waitFor(actualWaitTime)
|
||||||
|
|
||||||
|
this.states.totalWaitTime += input.seconds
|
||||||
|
|
||||||
|
if (this.states.totalWaitTime >= 3) {
|
||||||
|
this.pushObservation(
|
||||||
|
`You have waited ${this.states.totalWaitTime} seconds accumulatively. Do NOT wait any longer unless you have a good reason.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return `✅ Waited for ${input.seconds} seconds.`
|
return `✅ Waited for ${input.seconds} seconds.`
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -70,9 +79,12 @@ tools.set(
|
|||||||
inputSchema: zod.object({
|
inputSchema: zod.object({
|
||||||
question: zod.string(),
|
question: zod.string(),
|
||||||
}),
|
}),
|
||||||
execute: async function (this: PageAgent, input) {
|
execute: async function (this: PageAgentCore, input) {
|
||||||
const answer = await this.panel.askUser(input.question)
|
if (!this.onAskUser) {
|
||||||
return `✅ Received user answer: ${answer}`
|
throw new Error('ask_user tool requires onAskUser callback to be set')
|
||||||
|
}
|
||||||
|
const answer = await this.onAskUser(input.question)
|
||||||
|
return `User answered: ${answer}`
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -84,7 +96,7 @@ tools.set(
|
|||||||
inputSchema: zod.object({
|
inputSchema: zod.object({
|
||||||
index: zod.int().min(0),
|
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)
|
const result = await this.pageController.clickElement(input.index)
|
||||||
return result.message
|
return result.message
|
||||||
},
|
},
|
||||||
@@ -99,7 +111,7 @@ tools.set(
|
|||||||
index: zod.int().min(0),
|
index: zod.int().min(0),
|
||||||
text: zod.string(),
|
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)
|
const result = await this.pageController.inputText(input.index, input.text)
|
||||||
return result.message
|
return result.message
|
||||||
},
|
},
|
||||||
@@ -115,7 +127,7 @@ tools.set(
|
|||||||
index: zod.int().min(0),
|
index: zod.int().min(0),
|
||||||
text: zod.string(),
|
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)
|
const result = await this.pageController.selectOption(input.index, input.text)
|
||||||
return result.message
|
return result.message
|
||||||
},
|
},
|
||||||
@@ -136,7 +148,7 @@ tools.set(
|
|||||||
pixels: zod.number().int().min(0).optional(),
|
pixels: zod.number().int().min(0).optional(),
|
||||||
index: 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({
|
const result = await this.pageController.scroll({
|
||||||
...input,
|
...input,
|
||||||
numPages: input.num_pages,
|
numPages: input.num_pages,
|
||||||
@@ -156,7 +168,7 @@ tools.set(
|
|||||||
pixels: zod.number().int().min(0),
|
pixels: zod.number().int().min(0),
|
||||||
index: 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.scrollHorizontally(input)
|
const result = await this.pageController.scrollHorizontally(input)
|
||||||
return result.message
|
return result.message
|
||||||
},
|
},
|
||||||
@@ -171,7 +183,7 @@ tools.set(
|
|||||||
inputSchema: zod.object({
|
inputSchema: zod.object({
|
||||||
script: zod.string(),
|
script: zod.string(),
|
||||||
}),
|
}),
|
||||||
execute: async function (this: PageAgent, input) {
|
execute: async function (this: PageAgentCore, input) {
|
||||||
const result = await this.pageController.executeJavascript(input.script)
|
const result = await this.pageController.executeJavascript(input.script)
|
||||||
return result.message
|
return result.message
|
||||||
},
|
},
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import chalk from 'chalk'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize LLM response and fix common format issues.
|
||||||
|
*
|
||||||
|
* Handles:
|
||||||
|
* - No tool_calls but JSON in message.content (fallback)
|
||||||
|
* - Model returns action name as tool call instead of AgentOutput
|
||||||
|
* - Arguments wrapped as double JSON string
|
||||||
|
* - Nested function call format
|
||||||
|
* - Missing action field (fallback to wait)
|
||||||
|
* - etc.
|
||||||
|
*/
|
||||||
|
export function normalizeResponse(response: any): any {
|
||||||
|
let resolvedArguments = null as any
|
||||||
|
|
||||||
|
const choice = (response as { choices?: Choice[] }).choices?.[0]
|
||||||
|
if (!choice) throw new Error('No choices in response')
|
||||||
|
|
||||||
|
const message = choice.message
|
||||||
|
if (!message) throw new Error('No message in choice')
|
||||||
|
|
||||||
|
const toolCall = message.tool_calls?.[0]
|
||||||
|
|
||||||
|
// fix level and location of arguments
|
||||||
|
|
||||||
|
if (toolCall?.function?.arguments) {
|
||||||
|
resolvedArguments = safeJsonParse(toolCall.function.arguments)
|
||||||
|
|
||||||
|
// case: sometimes the model only returns the action level
|
||||||
|
if (toolCall.function.name && toolCall.function.name !== 'AgentOutput') {
|
||||||
|
console.log(chalk.yellow(`[normalizeResponse] #1: fixing tool_call`))
|
||||||
|
resolvedArguments = { action: safeJsonParse(resolvedArguments) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// case: sometimes the model returns json in content instead of tool_calls
|
||||||
|
if (message.content) {
|
||||||
|
const content = message.content.trim()
|
||||||
|
const jsonInContent = retrieveJsonFromString(content)
|
||||||
|
if (jsonInContent) {
|
||||||
|
resolvedArguments = safeJsonParse(jsonInContent)
|
||||||
|
|
||||||
|
// case: sometimes the content json includes upper level wrapper
|
||||||
|
if (resolvedArguments?.name === 'AgentOutput') {
|
||||||
|
console.log(chalk.yellow(`[normalizeResponse] #2: fixing tool_call`))
|
||||||
|
resolvedArguments = safeJsonParse(resolvedArguments.arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// case: sometimes even 2-levels of wrapping
|
||||||
|
if (resolvedArguments?.type === 'function') {
|
||||||
|
console.log(chalk.yellow(`[normalizeResponse] #3: fixing tool_call`))
|
||||||
|
resolvedArguments = safeJsonParse(resolvedArguments.function.arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// case: and sometimes action level only
|
||||||
|
// todo: needs better detection logic
|
||||||
|
if (
|
||||||
|
!resolvedArguments?.action &&
|
||||||
|
!resolvedArguments?.evaluation_previous_goal &&
|
||||||
|
!resolvedArguments?.memory &&
|
||||||
|
!resolvedArguments?.next_goal &&
|
||||||
|
!resolvedArguments?.thinking
|
||||||
|
) {
|
||||||
|
console.log(chalk.yellow(`[normalizeResponse] #4: fixing tool_call`))
|
||||||
|
resolvedArguments = { action: safeJsonParse(resolvedArguments) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('No tool_call and the message content does not contain valid JSON')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('No tool_call nor message content is present')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fix double stringified arguments
|
||||||
|
resolvedArguments = safeJsonParse(resolvedArguments)
|
||||||
|
if (resolvedArguments.action) {
|
||||||
|
resolvedArguments.action = safeJsonParse(resolvedArguments.action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fix incomplete formats
|
||||||
|
if (!resolvedArguments.action) {
|
||||||
|
console.log(chalk.yellow(`[normalizeResponse] #5: fixing tool_call`))
|
||||||
|
resolvedArguments.action = { name: 'wait', input: { seconds: 1 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// pack back to standard format
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
...choice,
|
||||||
|
message: {
|
||||||
|
...message,
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
...(toolCall || {}),
|
||||||
|
function: {
|
||||||
|
...(toolCall?.function || {}),
|
||||||
|
name: 'AgentOutput',
|
||||||
|
arguments: JSON.stringify(resolvedArguments),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely parse JSON, return original input if not json.
|
||||||
|
*/
|
||||||
|
function safeJsonParse(input: any): any {
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(input.trim())
|
||||||
|
} catch {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract and parse JSON from a string.
|
||||||
|
* - Treat content between the first `{` and the last `}` as JSON.
|
||||||
|
* - Try to parse that content as JSON and return the parsed value (object/array/primitive) if successful, otherwise return null.
|
||||||
|
*/
|
||||||
|
function retrieveJsonFromString(str: string): any {
|
||||||
|
try {
|
||||||
|
const json = /({[\s\S]*})/.exec(str) ?? []
|
||||||
|
if (json.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return JSON.parse(json[0]!)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Choice {
|
||||||
|
message?: {
|
||||||
|
role?: 'assistant'
|
||||||
|
content?: string
|
||||||
|
tool_calls?: {
|
||||||
|
id?: string
|
||||||
|
type?: 'function'
|
||||||
|
function?: {
|
||||||
|
name?: string
|
||||||
|
arguments?: string
|
||||||
|
}
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
index?: 0
|
||||||
|
finish_reason?: 'tool_calls'
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export { normalizeResponse } from './autoFixer'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait until condition becomes true
|
* Wait until condition becomes true
|
||||||
* @returns Returns when condition becomes true, throws otherwise
|
* @returns Returns when condition becomes true, throws otherwise
|
||||||
@@ -66,12 +68,13 @@ export function randomID(existingIDs?: string[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
const _global = globalThis as any
|
||||||
|
|
||||||
if (!window.__PAGE_AGENT_IDS__) {
|
if (!_global.__PAGE_AGENT_IDS__) {
|
||||||
window.__PAGE_AGENT_IDS__ = []
|
_global.__PAGE_AGENT_IDS__ = []
|
||||||
}
|
}
|
||||||
|
|
||||||
const ids = window.__PAGE_AGENT_IDS__
|
const ids = _global.__PAGE_AGENT_IDS__
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a random ID.
|
* 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"',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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",
|
"name": "@page-agent/llms",
|
||||||
"version": "0.0.14",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/lib/page-agent-llms.js",
|
"main": "./dist/lib/page-agent-llms.js",
|
||||||
"module": "./dist/lib/page-agent-llms.js",
|
"module": "./dist/lib/page-agent-llms.js",
|
||||||
"types": "./dist/lib/index.d.ts",
|
"types": "./dist/lib/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/lib/index.d.ts",
|
"types": "./dist/lib/index.d.ts",
|
||||||
"import": "./dist/lib/page-agent-llms.js",
|
"import": "./dist/lib/page-agent-llms.js",
|
||||||
"default": "./dist/lib/page-agent-llms.js"
|
"default": "./dist/lib/page-agent-llms.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/"
|
"dist/"
|
||||||
],
|
],
|
||||||
"description": "LLM client with reflection-before-action mental model for page-agent",
|
"description": "LLM client with reflection-before-action mental model for page-agent",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"page-agent",
|
"page-agent",
|
||||||
"llm",
|
"llm",
|
||||||
"openai",
|
"openai",
|
||||||
"tool-calling",
|
"tool-calling",
|
||||||
"agent"
|
"agent"
|
||||||
],
|
],
|
||||||
"author": "Simon<gaomeng1900>",
|
"author": "Simon<gaomeng1900>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/alibaba/page-agent.git",
|
"url": "https://github.com/alibaba/page-agent.git",
|
||||||
"directory": "packages/llms"
|
"directory": "packages/llms"
|
||||||
},
|
},
|
||||||
"homepage": "https://alibaba.github.io/page-agent/",
|
"homepage": "https://alibaba.github.io/page-agent/",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
"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{}})\""
|
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"zod": "^4.2.0"
|
"zod": "^4.3.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,55 +2,57 @@
|
|||||||
* OpenAI Client implementation
|
* OpenAI Client implementation
|
||||||
*/
|
*/
|
||||||
import { InvokeError, InvokeErrorType } from './errors'
|
import { InvokeError, InvokeErrorType } from './errors'
|
||||||
import type { InvokeResult, LLMClient, Message, OpenAIClientConfig, Tool } from './types'
|
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
|
||||||
import { modelPatch, zodToOpenAITool } from './utils'
|
import { modelPatch, zodToOpenAITool } from './utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client for OpenAI compatible APIs
|
||||||
|
*/
|
||||||
export class OpenAIClient implements LLMClient {
|
export class OpenAIClient implements LLMClient {
|
||||||
config: OpenAIClientConfig
|
config: Required<LLMConfig>
|
||||||
|
private fetch: typeof globalThis.fetch
|
||||||
|
|
||||||
constructor(config: OpenAIClientConfig) {
|
constructor(config: Required<LLMConfig>) {
|
||||||
this.config = config
|
this.config = config
|
||||||
|
this.fetch = config.customFetch
|
||||||
}
|
}
|
||||||
|
|
||||||
async invoke(
|
async invoke(
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
tools: Record<string, Tool>,
|
tools: Record<string, Tool>,
|
||||||
abortSignal?: AbortSignal
|
abortSignal?: AbortSignal,
|
||||||
|
options?: InvokeOptions
|
||||||
): Promise<InvokeResult> {
|
): Promise<InvokeResult> {
|
||||||
// 1. Convert tools to OpenAI format
|
// 1. Convert tools to OpenAI format
|
||||||
const openaiTools = Object.entries(tools).map(([name, tool]) => zodToOpenAITool(name, tool))
|
const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t))
|
||||||
|
|
||||||
|
// Build request body
|
||||||
|
const requestBody: Record<string, unknown> = {
|
||||||
|
model: this.config.model,
|
||||||
|
temperature: this.config.temperature,
|
||||||
|
messages,
|
||||||
|
tools: openaiTools,
|
||||||
|
parallel_tool_calls: false,
|
||||||
|
// Require tool call: specific tool if provided, otherwise any tool
|
||||||
|
tool_choice: options?.toolChoiceName
|
||||||
|
? { type: 'function', function: { name: options.toolChoiceName } }
|
||||||
|
: 'required',
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Call API
|
// 2. Call API
|
||||||
let response: Response
|
let response: Response
|
||||||
try {
|
try {
|
||||||
response = await fetch(`${this.config.baseURL}/chat/completions`, {
|
response = await this.fetch(`${this.config.baseURL}/chat/completions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${this.config.apiKey}`,
|
Authorization: `Bearer ${this.config.apiKey}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(
|
body: JSON.stringify(modelPatch(requestBody)),
|
||||||
modelPatch({
|
|
||||||
model: this.config.model,
|
|
||||||
temperature: this.config.temperature,
|
|
||||||
max_tokens: this.config.maxTokens,
|
|
||||||
messages,
|
|
||||||
|
|
||||||
tools: openaiTools,
|
|
||||||
// tool_choice: 'required',
|
|
||||||
tool_choice: { type: 'function', function: { name: 'AgentOutput' } },
|
|
||||||
|
|
||||||
// model specific params
|
|
||||||
|
|
||||||
// reasoning_effort: 'minimal',
|
|
||||||
// verbosity: 'low',
|
|
||||||
parallel_tool_calls: false,
|
|
||||||
})
|
|
||||||
),
|
|
||||||
signal: abortSignal,
|
signal: abortSignal,
|
||||||
})
|
})
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Network error
|
console.error(error)
|
||||||
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, 'Network request failed', error)
|
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, 'Network request failed', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,91 +90,122 @@ export class OpenAIClient implements LLMClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Parse and validate response
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
// 4. Check finish_reason
|
|
||||||
const choice = data.choices?.[0]
|
const choice = data.choices?.[0]
|
||||||
if (!choice) {
|
if (!choice) {
|
||||||
throw new InvokeError(InvokeErrorType.UNKNOWN, 'No choices in response', data)
|
throw new InvokeError(InvokeErrorType.UNKNOWN, 'No choices in response', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check finish_reason
|
||||||
switch (choice.finish_reason) {
|
switch (choice.finish_reason) {
|
||||||
case 'tool_calls':
|
case 'tool_calls':
|
||||||
// ✅ Normal
|
case 'function_call': // gemini
|
||||||
|
case 'stop': // some models use this even with tool calls
|
||||||
break
|
break
|
||||||
case 'length':
|
case 'length':
|
||||||
// ⚠️ Token limit reached
|
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.CONTEXT_LENGTH,
|
InvokeErrorType.CONTEXT_LENGTH,
|
||||||
'Response truncated: max tokens reached',
|
'Response truncated: max tokens reached',
|
||||||
|
undefined,
|
||||||
data
|
data
|
||||||
)
|
)
|
||||||
case 'content_filter':
|
case 'content_filter':
|
||||||
// ❌ Content filtered
|
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.CONTENT_FILTER,
|
InvokeErrorType.CONTENT_FILTER,
|
||||||
'Content filtered by safety system',
|
'Content filtered by safety system',
|
||||||
|
undefined,
|
||||||
data
|
data
|
||||||
)
|
)
|
||||||
case 'stop':
|
|
||||||
// ❌ Did not call tool (we require tool call)
|
|
||||||
throw new InvokeError(InvokeErrorType.NO_TOOL_CALL, 'Model did not call any tool', data)
|
|
||||||
default:
|
default:
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.UNKNOWN,
|
InvokeErrorType.UNKNOWN,
|
||||||
`Unexpected finish_reason: ${choice.finish_reason}`,
|
`Unexpected finish_reason: ${choice.finish_reason}`,
|
||||||
|
undefined,
|
||||||
data
|
data
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Parse tool call
|
// Apply normalizeResponse if provided (for fixing format issues automatically)
|
||||||
const toolCall = choice.message?.tool_calls?.[0]
|
const normalizedData = options?.normalizeResponse ? options.normalizeResponse(data) : data
|
||||||
if (!toolCall) {
|
const normalizedChoice = (normalizedData as any).choices?.[0]
|
||||||
throw new InvokeError(InvokeErrorType.NO_TOOL_CALL, 'No tool call found in response', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolName = toolCall.function.name
|
// Get tool name from response
|
||||||
const tool = tools[toolName]
|
const toolCallName = normalizedChoice?.message?.tool_calls?.[0]?.function?.name
|
||||||
if (!tool) {
|
if (!toolCallName) {
|
||||||
throw new InvokeError(InvokeErrorType.UNKNOWN, `Tool ${toolName} not found`, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Parse and validate arguments
|
|
||||||
let toolArgs: unknown
|
|
||||||
try {
|
|
||||||
toolArgs = JSON.parse(toolCall.function.arguments)
|
|
||||||
} catch (e) {
|
|
||||||
throw new InvokeError(InvokeErrorType.INVALID_TOOL_ARGS, 'Invalid JSON in tool arguments', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate against zod schema
|
|
||||||
const validation = tool.inputSchema.safeParse(toolArgs)
|
|
||||||
if (!validation.success) {
|
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
InvokeErrorType.NO_TOOL_CALL,
|
||||||
`Tool arguments validation failed: ${validation.error.message}`,
|
'No tool call found in response',
|
||||||
validation.error
|
undefined,
|
||||||
|
data
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Execute tool
|
const tool = tools[toolCallName]
|
||||||
|
if (!tool) {
|
||||||
|
throw new InvokeError(
|
||||||
|
InvokeErrorType.UNKNOWN,
|
||||||
|
`Tool "${toolCallName}" not found in tools`,
|
||||||
|
undefined,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and parse tool arguments
|
||||||
|
const argString = normalizedChoice.message?.tool_calls?.[0]?.function?.arguments
|
||||||
|
if (!argString) {
|
||||||
|
throw new InvokeError(
|
||||||
|
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||||
|
'No tool call arguments found',
|
||||||
|
undefined,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedArgs: unknown
|
||||||
|
try {
|
||||||
|
parsedArgs = JSON.parse(argString)
|
||||||
|
} catch (error) {
|
||||||
|
throw new InvokeError(
|
||||||
|
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||||
|
'Failed to parse tool arguments as JSON',
|
||||||
|
error,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate with schema
|
||||||
|
const validation = tool.inputSchema.safeParse(parsedArgs)
|
||||||
|
if (!validation.success) {
|
||||||
|
console.error(validation.error)
|
||||||
|
throw new InvokeError(
|
||||||
|
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||||
|
'Tool arguments validation failed',
|
||||||
|
validation.error,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const toolInput = validation.data
|
||||||
|
|
||||||
|
// 5. Execute tool
|
||||||
let toolResult: unknown
|
let toolResult: unknown
|
||||||
try {
|
try {
|
||||||
toolResult = await tool.execute(validation.data)
|
toolResult = await tool.execute(toolInput)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new InvokeError(
|
throw new InvokeError(
|
||||||
InvokeErrorType.TOOL_EXECUTION_ERROR,
|
InvokeErrorType.TOOL_EXECUTION_ERROR,
|
||||||
`Tool execution failed: ${(e as Error).message}`,
|
`Tool execution failed: ${(e as Error).message}`,
|
||||||
e
|
e,
|
||||||
|
data
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Return result (including cache tokens)
|
// Return result
|
||||||
return {
|
return {
|
||||||
toolCall: {
|
toolCall: {
|
||||||
// id: toolCall.id,
|
name: toolCallName,
|
||||||
name: toolName,
|
args: toolInput,
|
||||||
args: validation.data as Record<string, unknown>,
|
|
||||||
},
|
},
|
||||||
toolResult,
|
toolResult,
|
||||||
usage: {
|
usage: {
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
/**
|
|
||||||
* OpenAI Client implementation
|
|
||||||
*/
|
|
||||||
import { InvokeError, InvokeErrorType } from './errors'
|
|
||||||
import type {
|
|
||||||
InvokeResult,
|
|
||||||
LLMClient,
|
|
||||||
MacroToolInput,
|
|
||||||
Message,
|
|
||||||
OpenAIClientConfig,
|
|
||||||
Tool,
|
|
||||||
} from './types'
|
|
||||||
import { lenientParseMacroToolCall, modelPatch, zodToOpenAITool } from './utils'
|
|
||||||
|
|
||||||
export class OpenAIClient implements LLMClient {
|
|
||||||
config: OpenAIClientConfig
|
|
||||||
|
|
||||||
constructor(config: OpenAIClientConfig) {
|
|
||||||
this.config = config
|
|
||||||
}
|
|
||||||
|
|
||||||
async invoke(
|
|
||||||
messages: Message[],
|
|
||||||
tools: { AgentOutput: Tool<MacroToolInput> },
|
|
||||||
abortSignal?: AbortSignal
|
|
||||||
): Promise<InvokeResult> {
|
|
||||||
// 1. Convert tools to OpenAI format
|
|
||||||
const openaiTools = Object.entries(tools).map(([name, tool]) => zodToOpenAITool(name, tool))
|
|
||||||
|
|
||||||
// 2. Call API
|
|
||||||
let response: Response
|
|
||||||
try {
|
|
||||||
response = await fetch(`${this.config.baseURL}/chat/completions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${this.config.apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(
|
|
||||||
modelPatch({
|
|
||||||
model: this.config.model,
|
|
||||||
temperature: this.config.temperature,
|
|
||||||
max_tokens: this.config.maxTokens,
|
|
||||||
messages,
|
|
||||||
|
|
||||||
tools: openaiTools,
|
|
||||||
// tool_choice: 'required',
|
|
||||||
tool_choice: { type: 'function', function: { name: 'AgentOutput' } },
|
|
||||||
|
|
||||||
// model specific params
|
|
||||||
|
|
||||||
// reasoning_effort: 'minimal',
|
|
||||||
// verbosity: 'low',
|
|
||||||
parallel_tool_calls: false,
|
|
||||||
})
|
|
||||||
),
|
|
||||||
signal: abortSignal,
|
|
||||||
})
|
|
||||||
} catch (error: unknown) {
|
|
||||||
// Network error
|
|
||||||
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, 'Network request failed', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Handle HTTP errors
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch()
|
|
||||||
const errorMessage =
|
|
||||||
(errorData as { error?: { message?: string } }).error?.message || response.statusText
|
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.AUTH_ERROR,
|
|
||||||
`Authentication failed: ${errorMessage}`,
|
|
||||||
errorData
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (response.status === 429) {
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.RATE_LIMIT,
|
|
||||||
`Rate limit exceeded: ${errorMessage}`,
|
|
||||||
errorData
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (response.status >= 500) {
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.SERVER_ERROR,
|
|
||||||
`Server error: ${errorMessage}`,
|
|
||||||
errorData
|
|
||||||
)
|
|
||||||
}
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.UNKNOWN,
|
|
||||||
`HTTP ${response.status}: ${errorMessage}`,
|
|
||||||
errorData
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parse response
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
const tool = tools.AgentOutput
|
|
||||||
const macroToolInput = lenientParseMacroToolCall(data, tool.inputSchema as any)
|
|
||||||
|
|
||||||
// Execute tool
|
|
||||||
let toolResult: unknown
|
|
||||||
try {
|
|
||||||
toolResult = await tool.execute(macroToolInput)
|
|
||||||
} catch (e) {
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.TOOL_EXECUTION_ERROR,
|
|
||||||
`Tool execution failed: ${(e as Error).message}`,
|
|
||||||
e
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return result (including cache tokens)
|
|
||||||
return {
|
|
||||||
toolCall: {
|
|
||||||
// id: toolCall.id,
|
|
||||||
name: 'AgentOutput',
|
|
||||||
args: macroToolInput,
|
|
||||||
},
|
|
||||||
toolResult,
|
|
||||||
usage: {
|
|
||||||
promptTokens: data.usage?.prompt_tokens ?? 0,
|
|
||||||
completionTokens: data.usage?.completion_tokens ?? 0,
|
|
||||||
totalTokens: data.usage?.total_tokens ?? 0,
|
|
||||||
cachedTokens: data.usage?.prompt_tokens_details?.cached_tokens,
|
|
||||||
reasoningTokens: data.usage?.completion_tokens_details?.reasoning_tokens,
|
|
||||||
},
|
|
||||||
rawResponse: data,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,3 @@
|
|||||||
// Dev environment: use .env config if available, otherwise fallback to testing api
|
// Internal constants
|
||||||
export const DEFAULT_MODEL_NAME: string =
|
|
||||||
import.meta.env.DEV && import.meta.env.LLM_MODEL_NAME
|
|
||||||
? import.meta.env.LLM_MODEL_NAME
|
|
||||||
: 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
|
||||||
|
|
||||||
export const DEFAULT_API_KEY: string =
|
|
||||||
import.meta.env.DEV && import.meta.env.LLM_API_KEY
|
|
||||||
? import.meta.env.LLM_API_KEY
|
|
||||||
: 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
|
||||||
|
|
||||||
export const DEFAULT_BASE_URL: string =
|
|
||||||
import.meta.env.DEV && import.meta.env.LLM_BASE_URL
|
|
||||||
? import.meta.env.LLM_BASE_URL
|
|
||||||
: 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
|
|
||||||
|
|
||||||
// internal
|
|
||||||
|
|
||||||
export const LLM_MAX_RETRIES = 2
|
export const LLM_MAX_RETRIES = 2
|
||||||
export const DEFAULT_TEMPERATURE = 0.7 // higher randomness helps auto-recovery
|
export const DEFAULT_TEMPERATURE = 0.7 // higher randomness helps auto-recovery
|
||||||
export const DEFAULT_MAX_TOKENS = 4096
|
|
||||||
|
|||||||
@@ -25,14 +25,18 @@ export class InvokeError extends Error {
|
|||||||
type: InvokeErrorType
|
type: InvokeErrorType
|
||||||
retryable: boolean
|
retryable: boolean
|
||||||
statusCode?: number
|
statusCode?: number
|
||||||
|
/* raw error (provided if this error is caused by another error) */
|
||||||
rawError?: unknown
|
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)
|
super(message)
|
||||||
this.name = 'InvokeError'
|
this.name = 'InvokeError'
|
||||||
this.type = type
|
this.type = type
|
||||||
this.retryable = this.isRetryable(type)
|
this.retryable = this.isRetryable(type)
|
||||||
this.rawError = rawError
|
this.rawError = rawError
|
||||||
|
this.rawResponse = rawResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
private isRetryable(type: InvokeErrorType): boolean {
|
private isRetryable(type: InvokeErrorType): boolean {
|
||||||
|
|||||||
+28
-83
@@ -1,76 +1,26 @@
|
|||||||
/**
|
import { OpenAIClient } from './OpenAIClient'
|
||||||
* @topic LLM 与主流程的隔离
|
import { DEFAULT_TEMPERATURE, LLM_MAX_RETRIES } from './constants'
|
||||||
* @reasoning
|
|
||||||
* 将 llm 的调用和主流程分开是复杂的,
|
|
||||||
* 因为 agent 的 tool call 通常集成在 llm 模块中,而而先得到 llm 返回,然后处理工具调用
|
|
||||||
* tools 和 llm 调用的逻辑不可避免地耦合在一起,tool 的执行又和主流程耦合在一起
|
|
||||||
* 而 history 的维护和更新逻辑,又必须嵌入多轮 tool call 中
|
|
||||||
* @reasoning
|
|
||||||
* - 放弃框架提供的自动的多轮调用,每轮调用都由主流程发起
|
|
||||||
* - 理想情况下,llm 调用应该获得 structured output,然后由额外的模块触发 tool call,目前模型和框架都无法实现
|
|
||||||
* - 当前只能将 llm api 和 本地 tool call 耦合在一起,不关心其中的衔接方式
|
|
||||||
* @conclusion
|
|
||||||
* - @llm responsibility boundary:
|
|
||||||
* - call llm api with given messages and tools
|
|
||||||
* - invoke tool call and get the result of the tool
|
|
||||||
* - return the result to main loop
|
|
||||||
* - @main_loop responsibility boundary:
|
|
||||||
* - maintain all behaviors of an **agent**
|
|
||||||
* @conclusion
|
|
||||||
* - 这里的 llm 模块不是 agent,只负责一轮 llm 调用和工具调用,无状态
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* @topic 结构化输出
|
|
||||||
* @facts
|
|
||||||
* - 几乎所有模型都支持 tool call schema
|
|
||||||
* - 几乎所有模型都支持返回 json
|
|
||||||
* - 只有 openAI/grok/gemini 支持 schema 并保证格式
|
|
||||||
* - 主流模型都支持 tool_choice: required
|
|
||||||
* - 除了 qwen 必须指定一个函数名 (9月上新后支持)
|
|
||||||
* @conclusion
|
|
||||||
* - 永远使用 tool call 来返回结构化数据,禁止模型直接返回(视为出错)
|
|
||||||
* - 不能假设 tool 参数合法,必须有修复机制,而且修复也应该使用 tool call 返回
|
|
||||||
*/
|
|
||||||
import { OpenAIClient } from './OpenAILenientClient'
|
|
||||||
import {
|
|
||||||
DEFAULT_API_KEY,
|
|
||||||
DEFAULT_BASE_URL,
|
|
||||||
DEFAULT_MAX_TOKENS,
|
|
||||||
DEFAULT_MODEL_NAME,
|
|
||||||
DEFAULT_TEMPERATURE,
|
|
||||||
LLM_MAX_RETRIES,
|
|
||||||
} from './constants'
|
|
||||||
import { InvokeError } from './errors'
|
import { InvokeError } from './errors'
|
||||||
import type {
|
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
|
||||||
AgentBrain,
|
|
||||||
InvokeResult,
|
|
||||||
LLMClient,
|
|
||||||
LLMConfig,
|
|
||||||
MacroToolInput,
|
|
||||||
MacroToolResult,
|
|
||||||
Message,
|
|
||||||
Tool,
|
|
||||||
} from './types'
|
|
||||||
|
|
||||||
export type {
|
export type { InvokeError, InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool }
|
||||||
AgentBrain,
|
|
||||||
InvokeResult,
|
|
||||||
LLMClient,
|
|
||||||
LLMConfig,
|
|
||||||
MacroToolInput,
|
|
||||||
MacroToolResult,
|
|
||||||
Message,
|
|
||||||
Tool,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
|
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
|
||||||
|
// Runtime validation as defensive programming (types already guarantee these)
|
||||||
|
if (!config.baseURL || !config.apiKey || !config.model) {
|
||||||
|
throw new Error(
|
||||||
|
'[PageAgent] LLM configuration required. Please provide: baseURL, apiKey, model. ' +
|
||||||
|
'See: https://alibaba.github.io/page-agent/#/docs/features/models'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseURL: config.baseURL ?? DEFAULT_BASE_URL,
|
baseURL: config.baseURL,
|
||||||
apiKey: config.apiKey ?? DEFAULT_API_KEY,
|
apiKey: config.apiKey,
|
||||||
model: config.model ?? DEFAULT_MODEL_NAME,
|
model: config.model,
|
||||||
temperature: config.temperature ?? DEFAULT_TEMPERATURE,
|
temperature: config.temperature ?? DEFAULT_TEMPERATURE,
|
||||||
maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
||||||
maxRetries: config.maxRetries ?? LLM_MAX_RETRIES,
|
maxRetries: config.maxRetries ?? LLM_MAX_RETRIES,
|
||||||
|
customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,13 +33,7 @@ export class LLM extends EventTarget {
|
|||||||
this.config = parseLLMConfig(config)
|
this.config = parseLLMConfig(config)
|
||||||
|
|
||||||
// Default to OpenAI client
|
// Default to OpenAI client
|
||||||
this.client = new OpenAIClient({
|
this.client = new OpenAIClient(this.config)
|
||||||
model: this.config.model,
|
|
||||||
apiKey: this.config.apiKey,
|
|
||||||
baseURL: this.config.baseURL,
|
|
||||||
temperature: this.config.temperature,
|
|
||||||
maxTokens: this.config.maxTokens,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,20 +44,21 @@ export class LLM extends EventTarget {
|
|||||||
async invoke(
|
async invoke(
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
tools: Record<string, Tool>,
|
tools: Record<string, Tool>,
|
||||||
abortSignal: AbortSignal
|
abortSignal: AbortSignal,
|
||||||
|
options?: InvokeOptions
|
||||||
): Promise<InvokeResult> {
|
): Promise<InvokeResult> {
|
||||||
return await withRetry(
|
return await withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
const result = await this.client.invoke(messages, tools, abortSignal)
|
const result = await this.client.invoke(messages, tools, abortSignal, options)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
// retry settings
|
// retry settings
|
||||||
{
|
{
|
||||||
maxRetries: this.config.maxRetries,
|
maxRetries: this.config.maxRetries,
|
||||||
onRetry: (current: number) => {
|
onRetry: (attempt: number) => {
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
new CustomEvent('retry', { detail: { current, max: this.config.maxRetries } })
|
new CustomEvent('retry', { detail: { attempt, maxAttempts: this.config.maxRetries } })
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
@@ -128,15 +73,15 @@ async function withRetry<T>(
|
|||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
settings: {
|
settings: {
|
||||||
maxRetries: number
|
maxRetries: number
|
||||||
onRetry: (retries: number) => void
|
onRetry: (attempt: number) => void
|
||||||
onError: (error: Error) => void
|
onError: (error: Error) => void
|
||||||
}
|
}
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
let retries = 0
|
let attempt = 0
|
||||||
let lastError: Error | null = null
|
let lastError: Error | null = null
|
||||||
while (retries <= settings.maxRetries) {
|
while (attempt <= settings.maxRetries) {
|
||||||
if (retries > 0) {
|
if (attempt > 0) {
|
||||||
settings.onRetry(retries)
|
settings.onRetry(attempt)
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +98,7 @@ async function withRetry<T>(
|
|||||||
if (error instanceof InvokeError && !error.retryable) throw error
|
if (error instanceof InvokeError && !error.retryable) throw error
|
||||||
|
|
||||||
lastError = error as Error
|
lastError = error as Error
|
||||||
retries++
|
attempt++
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-50
@@ -32,6 +32,24 @@ export interface Tool<TParams = any, TResult = any> {
|
|||||||
execute: (args: TParams) => Promise<TResult>
|
execute: (args: TParams) => Promise<TResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke options for LLM call
|
||||||
|
*/
|
||||||
|
export interface InvokeOptions {
|
||||||
|
/**
|
||||||
|
* Force LLM to call a specific tool by name.
|
||||||
|
* If provided: tool_choice = { type: 'function', function: { name: toolChoiceName } }
|
||||||
|
* If not provided: tool_choice = 'required' (must call some tool, but model chooses which)
|
||||||
|
*/
|
||||||
|
toolChoiceName?: string
|
||||||
|
/**
|
||||||
|
* Response normalization function.
|
||||||
|
* Called before parsing the response.
|
||||||
|
* Used to fix various response format errors from the model.
|
||||||
|
*/
|
||||||
|
normalizeResponse?: (response: any) => any
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LLM Client interface
|
* LLM Client interface
|
||||||
* Note: Does not use generics because each tool in the tools array has different types
|
* Note: Does not use generics because each tool in the tools array has different types
|
||||||
@@ -40,7 +58,8 @@ export interface LLMClient {
|
|||||||
invoke(
|
invoke(
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
tools: Record<string, Tool>,
|
tools: Record<string, Tool>,
|
||||||
abortSignal?: AbortSignal
|
abortSignal?: AbortSignal,
|
||||||
|
options?: InvokeOptions
|
||||||
): Promise<InvokeResult>
|
): Promise<InvokeResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,58 +84,20 @@ export interface InvokeResult<TResult = unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenAI Client config
|
* LLM configuration
|
||||||
*/
|
|
||||||
export interface OpenAIClientConfig {
|
|
||||||
model: string
|
|
||||||
apiKey: string
|
|
||||||
baseURL: string
|
|
||||||
temperature?: number
|
|
||||||
maxTokens?: number
|
|
||||||
maxRetries?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* LLM configuration for PageAgent
|
|
||||||
*/
|
*/
|
||||||
export interface LLMConfig {
|
export interface LLMConfig {
|
||||||
baseURL?: string
|
baseURL: string
|
||||||
apiKey?: string
|
apiKey: string
|
||||||
model?: string
|
model: string
|
||||||
|
|
||||||
temperature?: number
|
temperature?: number
|
||||||
maxTokens?: number
|
|
||||||
maxRetries?: number
|
maxRetries?: number
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent brain state - the reflection-before-action model
|
* Custom fetch function for LLM API requests.
|
||||||
*
|
* Use this to customize headers, credentials, proxy, etc.
|
||||||
* Every tool call must first reflect on:
|
* The response should follow OpenAI API format.
|
||||||
* - evaluation_previous_goal: How well did the previous action achieve its goal?
|
*/
|
||||||
* - memory: Key information to remember for future steps
|
customFetch?: typeof globalThis.fetch
|
||||||
* - next_goal: What should be accomplished in the next action?
|
|
||||||
*/
|
|
||||||
export interface AgentBrain {
|
|
||||||
// thinking?: string
|
|
||||||
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 AgentBrain {
|
|
||||||
action: Record<string, any>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MacroTool output structure
|
|
||||||
*/
|
|
||||||
export interface MacroToolResult {
|
|
||||||
input: MacroToolInput
|
|
||||||
output: string
|
|
||||||
}
|
}
|
||||||
|
|||||||
+84
-177
@@ -4,8 +4,11 @@
|
|||||||
import chalk from 'chalk'
|
import chalk from 'chalk'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { InvokeError, InvokeErrorType } from './errors'
|
import type { Tool } from './types'
|
||||||
import type { MacroToolInput, Tool } from './types'
|
|
||||||
|
function debug(message: string) {
|
||||||
|
console.debug(chalk.gray('[LLM]'), message)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert Zod schema to OpenAI tool format
|
* Convert Zod schema to OpenAI tool format
|
||||||
@@ -23,191 +26,95 @@ export function zodToOpenAITool(name: string, tool: Tool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Although some models cannot guarantee correct response. Common issues are fixable:
|
* Patch model specific parameters
|
||||||
* - Instead of returning a proper tool call. Return the tool call parameters in the message content.
|
|
||||||
* - Returned tool calls or messages don't follow the nested MacroToolInput format.
|
|
||||||
*/
|
*/
|
||||||
export function lenientParseMacroToolCall(
|
|
||||||
responseData: any,
|
|
||||||
inputSchema: z.ZodObject<MacroToolInput & Record<string, any>>
|
|
||||||
): MacroToolInput {
|
|
||||||
// check
|
|
||||||
const choice = responseData.choices?.[0]
|
|
||||||
if (!choice) {
|
|
||||||
throw new InvokeError(InvokeErrorType.UNKNOWN, 'No choices in response', responseData)
|
|
||||||
}
|
|
||||||
|
|
||||||
// check
|
|
||||||
switch (choice.finish_reason) {
|
|
||||||
case 'tool_calls':
|
|
||||||
case 'function_call': // gemini
|
|
||||||
case 'stop': // will try a robust parse
|
|
||||||
// ✅ Normal
|
|
||||||
break
|
|
||||||
case 'length':
|
|
||||||
// ⚠️ Token limit reached
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.CONTEXT_LENGTH,
|
|
||||||
'Response truncated: max tokens reached'
|
|
||||||
)
|
|
||||||
case 'content_filter':
|
|
||||||
// ❌ Content filtered
|
|
||||||
throw new InvokeError(InvokeErrorType.CONTENT_FILTER, 'Content filtered by safety system')
|
|
||||||
default:
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.UNKNOWN,
|
|
||||||
`Unexpected finish_reason: ${choice.finish_reason}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract action schema from MacroToolInput schema
|
|
||||||
const actionSchema = inputSchema.shape.action
|
|
||||||
if (!actionSchema) {
|
|
||||||
throw new Error('inputSchema must have an "action" field')
|
|
||||||
}
|
|
||||||
|
|
||||||
// patch stopReason mis-format
|
|
||||||
|
|
||||||
let arg: string | null = null
|
|
||||||
|
|
||||||
// try to use tool call
|
|
||||||
const toolCall = choice.message?.tool_calls?.[0]?.function
|
|
||||||
arg = toolCall?.arguments ?? null
|
|
||||||
|
|
||||||
if (arg && toolCall.name !== 'AgentOutput') {
|
|
||||||
// TODO: check if toolCall.name is a valid action name
|
|
||||||
// case: instead of AgentOutput, the model returned a action name as tool call
|
|
||||||
console.log(chalk.yellow('lenientParseMacroToolCall: #1 fixing incorrect tool call'))
|
|
||||||
let tmpArg
|
|
||||||
try {
|
|
||||||
tmpArg = JSON.parse(arg)
|
|
||||||
} catch (error) {
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
|
||||||
'Failed to parse tool arguments as JSON',
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
arg = JSON.stringify({ action: { [toolCall.name]: tmpArg } })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!arg) {
|
|
||||||
// try to use message content as JSON
|
|
||||||
arg = choice.message?.content.trim() || null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!arg) {
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.NO_TOOL_CALL,
|
|
||||||
'No tool call or content found in response',
|
|
||||||
responseData
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// make sure is valid JSON
|
|
||||||
|
|
||||||
let parsedArgs: any
|
|
||||||
try {
|
|
||||||
parsedArgs = JSON.parse(arg)
|
|
||||||
} catch (error) {
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
|
||||||
'Failed to parse tool arguments as JSON',
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// patch incomplete formats
|
|
||||||
|
|
||||||
if (parsedArgs.action || parsedArgs.evaluation_previous_goal || parsedArgs.next_goal) {
|
|
||||||
// case: nested MacroToolInput format (correct format)
|
|
||||||
|
|
||||||
// some models may give a empty action (they may think reasoning and action should be separate)
|
|
||||||
if (!parsedArgs.action) {
|
|
||||||
console.log(chalk.yellow('lenientParseMacroToolCall: #2 fixing incorrect tool call'))
|
|
||||||
parsedArgs.action = {
|
|
||||||
wait: { seconds: 1 },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (parsedArgs.type && parsedArgs.function) {
|
|
||||||
// case: upper level function call format provided. only keep its arguments
|
|
||||||
// TODO: check if function name is a valid action name
|
|
||||||
if (parsedArgs.function.name !== 'AgentOutput')
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
|
||||||
`Expected function name "AgentOutput", got "${parsedArgs.function.name}"`,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log(chalk.yellow('lenientParseMacroToolCall: #3 fixing incorrect tool call'))
|
|
||||||
parsedArgs = parsedArgs.function.arguments
|
|
||||||
} else if (parsedArgs.name && parsedArgs.arguments) {
|
|
||||||
// case: upper level function call format provided. only keep its arguments
|
|
||||||
// TODO: check if function name is a valid action name
|
|
||||||
if (parsedArgs.name !== 'AgentOutput')
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
|
||||||
`Expected function name "AgentOutput", got "${parsedArgs.name}"`,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log(chalk.yellow('lenientParseMacroToolCall: #4 fixing incorrect tool call'))
|
|
||||||
parsedArgs = parsedArgs.arguments
|
|
||||||
} else {
|
|
||||||
// case: only action parameters provided, wrap into MacroToolInput
|
|
||||||
// TODO: check if action name is valid
|
|
||||||
console.log(chalk.yellow('lenientParseMacroToolCall: #5 fixing incorrect tool call'))
|
|
||||||
parsedArgs = { action: parsedArgs } as MacroToolInput
|
|
||||||
}
|
|
||||||
|
|
||||||
// make sure it's not wrapped as string
|
|
||||||
if (typeof parsedArgs === 'string') {
|
|
||||||
console.log(chalk.yellow('lenientParseMacroToolCall: #6 fixing incorrect tool call'))
|
|
||||||
try {
|
|
||||||
parsedArgs = JSON.parse(parsedArgs)
|
|
||||||
} catch (error) {
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
|
||||||
'Failed to parse nested tool arguments as JSON',
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const validation = inputSchema.safeParse(parsedArgs)
|
|
||||||
if (validation.success) {
|
|
||||||
return validation.data as unknown as MacroToolInput
|
|
||||||
} else {
|
|
||||||
const action = parsedArgs.action ?? {}
|
|
||||||
const actionName = Object.keys(action)[0] || 'unknown'
|
|
||||||
const actionArgs = JSON.stringify(action[actionName] || 'unknown')
|
|
||||||
|
|
||||||
// TODO: check if action name is valid. give a readable error message
|
|
||||||
|
|
||||||
throw new InvokeError(
|
|
||||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
|
||||||
`Tool arguments validation failed: action "${actionName}" with args ${actionArgs}`,
|
|
||||||
validation.error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function modelPatch(body: Record<string, any>) {
|
export function modelPatch(body: Record<string, any>) {
|
||||||
const model: string = body.model || ''
|
const model: string = body.model || ''
|
||||||
|
if (!model) return body
|
||||||
|
|
||||||
if (model.toLowerCase().startsWith('claude')) {
|
const modelName = normalizeModelName(model)
|
||||||
body.tool_choice = { type: 'tool', name: 'AgentOutput' }
|
|
||||||
body.thinking = { type: 'disabled' }
|
if (modelName.startsWith('qwen')) {
|
||||||
// body.reasoning = { enabled: 'disabled' }
|
debug('Applying Qwen patch: use higher temperature for auto fixing')
|
||||||
|
body.temperature = Math.max(body.temperature || 0, 1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.toLowerCase().includes('grok')) {
|
if (modelName.startsWith('claude')) {
|
||||||
console.log('Applying Grok patch: removing tool_choice')
|
debug('Applying Claude patch: disable thinking')
|
||||||
|
body.thinking = { type: 'disabled' }
|
||||||
|
|
||||||
|
// Convert tool_choice to Claude format
|
||||||
|
if (body.tool_choice === 'required') {
|
||||||
|
// 'required' -> { type: 'any' } (must call some tool)
|
||||||
|
debug('Applying Claude patch: convert tool_choice "required" to { type: "any" }')
|
||||||
|
body.tool_choice = { type: 'any' }
|
||||||
|
} else if (body.tool_choice?.function?.name) {
|
||||||
|
// { type: 'function', function: { name: '...' } } -> { type: 'tool', name: '...' }
|
||||||
|
debug('Applying Claude patch: convert tool_choice format')
|
||||||
|
body.tool_choice = { type: 'tool', name: body.tool_choice.function.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelName.startsWith('grok')) {
|
||||||
|
debug('Applying Grok patch: removing tool_choice')
|
||||||
delete body.tool_choice
|
delete body.tool_choice
|
||||||
console.log('Applying Grok patch: disable reasoning and thinking')
|
debug('Applying Grok patch: disable reasoning and thinking')
|
||||||
body.thinking = { type: 'disabled', effort: 'minimal' }
|
body.thinking = { type: 'disabled', effort: 'minimal' }
|
||||||
body.reasoning = { enabled: false, effort: 'low' }
|
body.reasoning = { enabled: false, effort: 'low' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (modelName.startsWith('gpt')) {
|
||||||
|
debug('Applying GPT patch: set verbosity to low')
|
||||||
|
body.verbosity = 'low'
|
||||||
|
|
||||||
|
if (modelName.startsWith('gpt-52')) {
|
||||||
|
debug('Applying GPT-52 patch: disable reasoning')
|
||||||
|
body.reasoning_effort = 'none'
|
||||||
|
} else if (modelName.startsWith('gpt-51')) {
|
||||||
|
debug('Applying GPT-51 patch: disable reasoning')
|
||||||
|
body.reasoning_effort = 'none'
|
||||||
|
} else if (modelName.startsWith('gpt-5')) {
|
||||||
|
debug('Applying GPT-5 patch: set reasoning effort to low')
|
||||||
|
body.reasoning_effort = 'low'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelName.startsWith('gemini')) {
|
||||||
|
debug('Applying Gemini patch: set reasoning effort to minimal')
|
||||||
|
body.reasoning_effort = 'minimal'
|
||||||
|
}
|
||||||
|
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check if a given model ID fits a specific model name
|
||||||
|
*
|
||||||
|
* @note
|
||||||
|
* Different model providers may use different model IDs for the same model.
|
||||||
|
* For example, openai's `gpt-5.2` may called:
|
||||||
|
*
|
||||||
|
* - `gpt-5.2-version`
|
||||||
|
* - `gpt-5_2-date`
|
||||||
|
* - `GPT-52-version-date`
|
||||||
|
* - `openai/gpt-5.2-chat`
|
||||||
|
*
|
||||||
|
* They should be treated as the same model.
|
||||||
|
* Normalize them to `gpt-52`
|
||||||
|
*/
|
||||||
|
function normalizeModelName(modelName: string): string {
|
||||||
|
let normalizedName = modelName.toLowerCase()
|
||||||
|
|
||||||
|
// remove prefix before '/'
|
||||||
|
if (normalizedName.includes('/')) {
|
||||||
|
normalizedName = normalizedName.split('/')[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove '_'
|
||||||
|
normalizedName = normalizedName.replace(/_/g, '')
|
||||||
|
|
||||||
|
// remove '.'
|
||||||
|
normalizedName = normalizedName.replace(/\./g, '')
|
||||||
|
|
||||||
|
return normalizedName
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// @workaround DTS bug
|
// @workaround DTS bug
|
||||||
// dts do not work with monorepo path mapping
|
// dts do not work with monorepo path mapping
|
||||||
// disable path mapping for it
|
// disable path mapping for it
|
||||||
"paths": {}
|
"paths": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-11
@@ -1,13 +1,12 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
"allowImportingTsExtensions": false,
|
"allowImportingTsExtensions": false,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"outDir": "dist"
|
"outDir": "dist"
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts"],
|
"include": ["**/*.ts"],
|
||||||
"exclude": ["dist", "node_modules"]
|
"exclude": ["dist", "node_modules"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,4 +34,3 @@ export default defineConfig({
|
|||||||
'process.env.NODE_ENV': '"production"',
|
'process.env.NODE_ENV': '"production"',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +1,54 @@
|
|||||||
{
|
{
|
||||||
"name": "page-agent",
|
"name": "page-agent",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "0.0.14",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/esm/page-agent.js",
|
"main": "./dist/esm/page-agent.js",
|
||||||
"module": "./dist/esm/page-agent.js",
|
"module": "./dist/esm/page-agent.js",
|
||||||
"types": "./dist/esm/PageAgent.d.ts",
|
"types": "./dist/esm/PageAgent.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/esm/PageAgent.d.ts",
|
"types": "./dist/esm/PageAgent.d.ts",
|
||||||
"import": "./dist/esm/page-agent.js",
|
"import": "./dist/esm/page-agent.js",
|
||||||
"default": "./dist/esm/page-agent.js"
|
"default": "./dist/esm/page-agent.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/"
|
"dist/"
|
||||||
],
|
],
|
||||||
"description": "GUI agent for web applications - add intelligent automation to any webpage with a single script",
|
"description": "GUI agent for web applications - add intelligent automation to any webpage with a single script",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ai",
|
"ai",
|
||||||
"automation",
|
"automation",
|
||||||
"ui-agent",
|
"ui-agent",
|
||||||
"GUI-agent",
|
"GUI-agent",
|
||||||
"browser-automation",
|
"browser-automation",
|
||||||
"web-agent",
|
"web-agent",
|
||||||
"llm",
|
"llm",
|
||||||
"dom-interaction",
|
"dom-interaction",
|
||||||
"web-automation",
|
"web-automation",
|
||||||
"GUI-simulation"
|
"GUI-simulation"
|
||||||
],
|
],
|
||||||
"author": "Simon<gaomeng1900>",
|
"author": "Simon<gaomeng1900>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/alibaba/page-agent.git"
|
"url": "https://github.com/alibaba/page-agent.git"
|
||||||
},
|
},
|
||||||
"homepage": "https://alibaba.github.io/page-agent/",
|
"homepage": "https://alibaba.github.io/page-agent/",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build && vite build --config vite.umd.config.js",
|
"build": "vite build && npm run build:demo",
|
||||||
"serve": "npx serve dist/umd -p 5173",
|
"build:demo": "vite build --config vite.iife.config.js",
|
||||||
"dev:umd": "concurrently \"vite build --config vite.umd.config.js --watch\" \"npm run serve\"",
|
"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))\"",
|
"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{}})\""
|
"postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"zod": "^4.2.0",
|
"zod": "^4.3.5",
|
||||||
"@page-agent/llms": "0.0.14",
|
"@page-agent/llms": "1.0.0",
|
||||||
"@page-agent/page-controller": "0.0.14",
|
"@page-agent/page-controller": "1.0.0",
|
||||||
"@page-agent/ui": "0.0.14"
|
"@page-agent/core": "1.0.0",
|
||||||
}
|
"@page-agent/ui": "1.0.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,499 +2,26 @@
|
|||||||
* Copyright (C) 2025 Alibaba Group Holding Limited
|
* Copyright (C) 2025 Alibaba Group Holding Limited
|
||||||
* All rights reserved.
|
* All rights reserved.
|
||||||
*/
|
*/
|
||||||
import {
|
import { type PageAgentConfig, PageAgentCore, type PageAgentTool, tool } from '@page-agent/core'
|
||||||
type AgentBrain,
|
|
||||||
LLM,
|
|
||||||
type MacroToolInput,
|
|
||||||
type MacroToolResult,
|
|
||||||
type Tool,
|
|
||||||
} from '@page-agent/llms'
|
|
||||||
import { PageController } from '@page-agent/page-controller'
|
import { PageController } from '@page-agent/page-controller'
|
||||||
import { Panel, SimulatorMask } from '@page-agent/ui'
|
import { Panel } from '@page-agent/ui'
|
||||||
import chalk from 'chalk'
|
|
||||||
import zod from 'zod'
|
|
||||||
|
|
||||||
import type { PageAgentConfig } from './config'
|
export type { PageAgentConfig, PageAgentTool }
|
||||||
import { MAX_STEPS } from './config/constants'
|
export { tool }
|
||||||
import SYSTEM_PROMPT from './prompts/system_prompt.md?raw'
|
|
||||||
import { tools } from './tools'
|
|
||||||
import { trimLines, uid, waitUntil } from './utils'
|
|
||||||
import { assert } from './utils/assert'
|
|
||||||
|
|
||||||
export type { PageAgentConfig }
|
export class PageAgent extends PageAgentCore {
|
||||||
export { tool, type PageAgentTool } from './tools'
|
|
||||||
export type { AgentBrain, MacroToolInput, MacroToolResult }
|
|
||||||
|
|
||||||
export interface AgentHistory {
|
|
||||||
brain: AgentBrain
|
|
||||||
action: {
|
|
||||||
name: string
|
|
||||||
input: any
|
|
||||||
output: string
|
|
||||||
}
|
|
||||||
usage: {
|
|
||||||
promptTokens: number
|
|
||||||
completionTokens: number
|
|
||||||
totalTokens: number
|
|
||||||
cachedTokens?: number
|
|
||||||
reasoningTokens?: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExecutionResult {
|
|
||||||
success: boolean
|
|
||||||
data: string
|
|
||||||
history: AgentHistory[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PageAgent extends EventTarget {
|
|
||||||
config: PageAgentConfig
|
|
||||||
id = uid()
|
|
||||||
panel: Panel
|
panel: Panel
|
||||||
tools: typeof tools
|
|
||||||
paused = false
|
|
||||||
disposed = false
|
|
||||||
task = ''
|
|
||||||
taskId = ''
|
|
||||||
|
|
||||||
#llm: LLM
|
constructor(config: PageAgentConfig) {
|
||||||
#totalWaitTime = 0
|
const pageController = new PageController({
|
||||||
#abortController = new AbortController()
|
...config,
|
||||||
#llmRetryListener: ((e: Event) => void) | null = null
|
enableMask: config.enableMask ?? true,
|
||||||
#llmErrorListener: ((e: Event) => void) | null = null
|
|
||||||
#beforeUnloadListener: ((e: Event) => void) | null = null
|
|
||||||
|
|
||||||
/** PageController for DOM operations */
|
|
||||||
pageController: PageController
|
|
||||||
|
|
||||||
/** Fullscreen mask */
|
|
||||||
mask = new SimulatorMask()
|
|
||||||
/** History records */
|
|
||||||
history: AgentHistory[] = []
|
|
||||||
|
|
||||||
constructor(config: PageAgentConfig = {}) {
|
|
||||||
super()
|
|
||||||
|
|
||||||
this.config = config
|
|
||||||
this.#llm = new LLM(this.config)
|
|
||||||
this.panel = new Panel({
|
|
||||||
language: this.config.language,
|
|
||||||
onExecuteTask: (task) => this.execute(task),
|
|
||||||
onStop: () => this.dispose(),
|
|
||||||
onPauseToggle: () => {
|
|
||||||
this.paused = !this.paused
|
|
||||||
return this.paused
|
|
||||||
},
|
|
||||||
getPaused: () => this.paused,
|
|
||||||
})
|
|
||||||
this.tools = new Map(tools)
|
|
||||||
|
|
||||||
// Initialize PageController with config
|
|
||||||
this.pageController = new PageController(this.config)
|
|
||||||
|
|
||||||
// Listen to LLM events
|
|
||||||
this.#llmRetryListener = (e) => {
|
|
||||||
const { current, max } = (e as CustomEvent).detail
|
|
||||||
this.panel.update({ type: 'retry', current, max })
|
|
||||||
}
|
|
||||||
this.#llmErrorListener = (e) => {
|
|
||||||
const { error } = (e as CustomEvent).detail
|
|
||||||
this.panel.update({ type: 'error', message: `step failed: ${error.message}` })
|
|
||||||
}
|
|
||||||
this.#llm.addEventListener('retry', this.#llmRetryListener)
|
|
||||||
this.#llm.addEventListener('error', this.#llmErrorListener)
|
|
||||||
|
|
||||||
if (this.config.customTools) {
|
|
||||||
for (const [name, tool] of Object.entries(this.config.customTools)) {
|
|
||||||
if (tool === null) {
|
|
||||||
this.tools.delete(name)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
this.tools.set(name, tool)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.config.experimentalScriptExecutionTool) {
|
|
||||||
this.tools.delete('execute_javascript')
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#beforeUnloadListener = (e) => {
|
|
||||||
if (!this.disposed) this.dispose('PAGE_UNLOADING')
|
|
||||||
}
|
|
||||||
window.addEventListener('beforeunload', this.#beforeUnloadListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @todo maybe return something?
|
|
||||||
*/
|
|
||||||
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.mask.show()
|
|
||||||
|
|
||||||
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 = []
|
|
||||||
|
|
||||||
try {
|
|
||||||
let step = 0
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
await onBeforeStep.call(this, step)
|
|
||||||
|
|
||||||
console.group(`step: ${step}`)
|
|
||||||
|
|
||||||
// abort
|
|
||||||
if (this.#abortController.signal.aborted) throw new Error('AbortError')
|
|
||||||
// pause
|
|
||||||
await waitUntil(() => !this.paused)
|
|
||||||
|
|
||||||
// Update status to thinking
|
|
||||||
console.log(chalk.blue('Thinking...'))
|
|
||||||
this.panel.update({ type: 'thinking' })
|
|
||||||
|
|
||||||
const result = await this.#llm.invoke(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: this.#getSystemPrompt(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: await this.#assembleUserPrompt(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
{ AgentOutput: this.#packMacroTool() },
|
|
||||||
this.#abortController.signal
|
|
||||||
)
|
|
||||||
|
|
||||||
const macroResult = result.toolResult as MacroToolResult
|
|
||||||
const input = macroResult.input
|
|
||||||
const output = macroResult.output
|
|
||||||
const brain = {
|
|
||||||
evaluation_previous_goal: input.evaluation_previous_goal || '',
|
|
||||||
memory: input.memory || '',
|
|
||||||
next_goal: input.next_goal || '',
|
|
||||||
}
|
|
||||||
const actionName = Object.keys(input.action)[0]
|
|
||||||
const action = {
|
|
||||||
name: actionName,
|
|
||||||
input: input.action[actionName],
|
|
||||||
output: output,
|
|
||||||
}
|
|
||||||
|
|
||||||
this.history.push({
|
|
||||||
brain,
|
|
||||||
action,
|
|
||||||
usage: result.usage,
|
|
||||||
})
|
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const actionSchema = zod.union(
|
super({ ...config, pageController })
|
||||||
actionSchemas as unknown as [zod.ZodType, zod.ZodType, ...zod.ZodType[]]
|
|
||||||
)
|
|
||||||
|
|
||||||
const macroToolSchema = zod.object({
|
this.panel = new Panel(this, {
|
||||||
// thinking: zod.string().optional(),
|
language: config.language,
|
||||||
evaluation_previous_goal: zod.string().optional(),
|
|
||||||
memory: zod.string().optional(),
|
|
||||||
next_goal: zod.string().optional(),
|
|
||||||
action: actionSchema,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
|
||||||
inputSchema: macroToolSchema as zod.ZodType<MacroToolInput>,
|
|
||||||
execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
|
|
||||||
// abort
|
|
||||||
if (this.#abortController.signal.aborted) throw new Error('AbortError')
|
|
||||||
// pause
|
|
||||||
await waitUntil(() => !this.paused)
|
|
||||||
|
|
||||||
console.log(chalk.blue.bold('MacroTool execute'), input)
|
|
||||||
const action = input.action
|
|
||||||
|
|
||||||
const toolName = Object.keys(action)[0]
|
|
||||||
const toolInput = action[toolName]
|
|
||||||
const brain = trimLines(`✅: ${input.evaluation_previous_goal}
|
|
||||||
💾: ${input.memory}
|
|
||||||
🎯: ${input.next_goal}
|
|
||||||
`)
|
|
||||||
|
|
||||||
console.log(brain)
|
|
||||||
this.panel.update({ type: 'thinking', text: brain })
|
|
||||||
|
|
||||||
// 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
|
|
||||||
let result = await tool.execute.bind(this)(toolInput)
|
|
||||||
|
|
||||||
const duration = Date.now() - startTime
|
|
||||||
console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result)
|
|
||||||
|
|
||||||
if (toolName === 'wait') {
|
|
||||||
this.#totalWaitTime += Math.round(toolInput.seconds + duration / 1000)
|
|
||||||
result += `\n<sys> You have waited ${this.#totalWaitTime} seconds accumulatively.`
|
|
||||||
if (this.#totalWaitTime >= 3)
|
|
||||||
result += '\nDo NOT wait any longer unless you have a good reason.\n'
|
|
||||||
result += '</sys>'
|
|
||||||
} else {
|
|
||||||
// For other tools, reset wait time
|
|
||||||
this.#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
|
|
||||||
}
|
|
||||||
|
|
||||||
async #assembleUserPrompt(): Promise<string> {
|
|
||||||
let prompt = ''
|
|
||||||
|
|
||||||
// <agent_history>
|
|
||||||
// - <step_>
|
|
||||||
|
|
||||||
prompt += '<agent_history>\n'
|
|
||||||
|
|
||||||
this.history.forEach((history, index) => {
|
|
||||||
prompt += `<step_${index + 1}>
|
|
||||||
Evaluation of Previous Step: ${history.brain.evaluation_previous_goal}
|
|
||||||
Memory: ${history.brain.memory}
|
|
||||||
Next Goal: ${history.brain.next_goal}
|
|
||||||
Action Results: ${history.action.output}
|
|
||||||
</step_${index + 1}>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
|
|
||||||
prompt += '</agent_history>\n\n'
|
|
||||||
|
|
||||||
// <agent_state>
|
|
||||||
// - <user_request>
|
|
||||||
// - <step_info>
|
|
||||||
// <agent_state>
|
|
||||||
|
|
||||||
prompt += `<agent_state>
|
|
||||||
<user_request>
|
|
||||||
${this.task}
|
|
||||||
</user_request>
|
|
||||||
<step_info>
|
|
||||||
Step ${this.history.length + 1} of ${MAX_STEPS} max possible steps
|
|
||||||
Current date and time: ${new Date().toISOString()}
|
|
||||||
</step_info>
|
|
||||||
</agent_state>
|
|
||||||
`
|
|
||||||
|
|
||||||
// <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.mask.hide()
|
|
||||||
|
|
||||||
this.#abortController.abort()
|
|
||||||
}
|
|
||||||
|
|
||||||
async #getBrowserState(): Promise<string> {
|
|
||||||
const pageUrl = await this.pageController.getCurrentUrl()
|
|
||||||
const pageTitle = await this.pageController.getPageTitle()
|
|
||||||
const pi = await this.pageController.getPageInfo()
|
|
||||||
const viewportExpansion = await this.pageController.getViewportExpansion()
|
|
||||||
|
|
||||||
this.mask.wrapper.style.pointerEvents = 'none'
|
|
||||||
await this.pageController.updateTree()
|
|
||||||
this.mask.wrapper.style.pointerEvents = 'auto'
|
|
||||||
|
|
||||||
const simplifiedHTML = await this.pageController.getSimplifiedHTML()
|
|
||||||
|
|
||||||
let prompt = trimLines(`<browser_state>
|
|
||||||
Current Page: [${pageTitle}](${pageUrl})
|
|
||||||
|
|
||||||
Page info: ${pi.viewport_width}x${pi.viewport_height}px viewport, ${pi.page_width}x${pi.page_height}px total page size, ${pi.pages_above.toFixed(1)} pages above, ${pi.pages_below.toFixed(1)} pages below, ${pi.total_pages.toFixed(1)} total pages, at ${(pi.current_page_position * 100).toFixed(0)}% of page
|
|
||||||
|
|
||||||
${viewportExpansion === -1 ? 'Interactive elements from top layer of the current page (full page):' : 'Interactive elements from top layer of the current page inside the viewport:'}
|
|
||||||
|
|
||||||
`)
|
|
||||||
|
|
||||||
// Page header info
|
|
||||||
const has_content_above = pi.pixels_above > 4
|
|
||||||
if (has_content_above && viewportExpansion !== -1) {
|
|
||||||
prompt += `... ${pi.pixels_above} pixels above (${pi.pages_above.toFixed(1)} pages) - scroll to see more ...\n`
|
|
||||||
} else {
|
|
||||||
prompt += `[Start of page]\n`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Current viewport info
|
|
||||||
prompt += simplifiedHTML
|
|
||||||
prompt += `\n`
|
|
||||||
|
|
||||||
// Page footer info
|
|
||||||
const has_content_below = pi.pixels_below > 4
|
|
||||||
if (has_content_below && viewportExpansion !== -1) {
|
|
||||||
prompt += `... ${pi.pixels_below} pixels below (${pi.pages_below.toFixed(1)} pages) - scroll to see more ...\n`
|
|
||||||
} else {
|
|
||||||
prompt += `[End of page]\n`
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt += `</browser_state>\n`
|
|
||||||
|
|
||||||
return prompt
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose(reason?: string) {
|
|
||||||
console.log('Disposing PageAgent...')
|
|
||||||
this.disposed = true
|
|
||||||
this.pageController.dispose()
|
|
||||||
this.panel.dispose()
|
|
||||||
this.mask.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,84 +0,0 @@
|
|||||||
import type { LLMConfig } from '@page-agent/llms'
|
|
||||||
import type { PageControllerConfig } from '@page-agent/page-controller'
|
|
||||||
import type { SupportedLanguage } from '@page-agent/ui'
|
|
||||||
|
|
||||||
import type { AgentHistory, ExecutionResult, PageAgent } from '../PageAgent'
|
|
||||||
import type { PageAgentTool } from '../tools'
|
|
||||||
|
|
||||||
export type { LLMConfig }
|
|
||||||
|
|
||||||
export interface AgentConfig {
|
|
||||||
// theme?: 'light' | 'dark'
|
|
||||||
language?: SupportedLanguage
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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)
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // override internal tool
|
|
||||||
* import { tool } from 'page-agent'
|
|
||||||
* const customTools = {
|
|
||||||
* ask_user: tool({
|
|
||||||
* description:
|
|
||||||
* 'Ask the user or parent model a question and wait for their answer. Use this if you need more information or clarification.',
|
|
||||||
* inputSchema: zod.object({
|
|
||||||
* question: zod.string(),
|
|
||||||
* }),
|
|
||||||
* execute: async function (this: PageAgent, input) {
|
|
||||||
* const answer = await do_some_thing(input.question)
|
|
||||||
* return "✅ Received user answer: " + answer
|
|
||||||
* },
|
|
||||||
* })
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // remove internal tool
|
|
||||||
* const customTools = {
|
|
||||||
* ask_user: null // never ask user questions
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
customTools?: Record<string, PageAgentTool | null>
|
|
||||||
|
|
||||||
// lifecycle hooks
|
|
||||||
// @todo: use event instead of hooks
|
|
||||||
|
|
||||||
onBeforeStep?: (this: PageAgent, stepCnt: number) => Promise<void> | void
|
|
||||||
onAfterStep?: (this: PageAgent, stepCnt: number, history: AgentHistory[]) => Promise<void> | void
|
|
||||||
onBeforeTask?: (this: PageAgent) => Promise<void> | void
|
|
||||||
onAfterTask?: (this: PageAgent, result: ExecutionResult) => Promise<void> | void
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @note this hook can block the disposal process
|
|
||||||
* @note when dispose caused by page unload, reason will be 'PAGE_UNLOADING'. this method CANNOT block unloading. async operations may be cut.
|
|
||||||
*/
|
|
||||||
onDispose?: (this: PageAgent, reason?: string) => void
|
|
||||||
|
|
||||||
// page behavior hooks
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @experimental
|
|
||||||
* Enable the experimental script execution tool that allows executing generated JavaScript code on the page.
|
|
||||||
* @note Can cause unpredictable side effects.
|
|
||||||
* @note May bypass some safe guards and data-masking mechanisms.
|
|
||||||
*/
|
|
||||||
experimentalScriptExecutionTool?: boolean
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PageAgentConfig = LLMConfig & AgentConfig & PageControllerConfig
|
|
||||||
@@ -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'
|
import { PageAgent, type PageAgentConfig } from './PageAgent'
|
||||||
|
|
||||||
@@ -11,9 +11,6 @@ if (window.pageAgent) {
|
|||||||
// Mount to global window object
|
// Mount to global window object
|
||||||
window.PageAgent = PageAgent
|
window.PageAgent = PageAgent
|
||||||
|
|
||||||
// Export for ES module usage
|
|
||||||
// export { PageAgent }
|
|
||||||
|
|
||||||
console.log('🚀 page-agent.js loaded!')
|
console.log('🚀 page-agent.js loaded!')
|
||||||
|
|
||||||
const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
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'
|
const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||||
|
|
||||||
// in case document.x is not ready yet
|
// in case document.x is not ready yet
|
||||||
// @todo give a switch to disable auto-init
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const currentScript = document.currentScript as HTMLScriptElement | null
|
const currentScript = document.currentScript as HTMLScriptElement | null
|
||||||
|
let config: PageAgentConfig
|
||||||
|
|
||||||
if (currentScript) {
|
if (currentScript) {
|
||||||
console.log('🚀 page-agent.js detected current script:', currentScript.src)
|
console.log('🚀 page-agent.js detected current script:', currentScript.src)
|
||||||
const url = new URL(currentScript.src)
|
const url = new URL(currentScript.src)
|
||||||
@@ -31,14 +29,19 @@ setTimeout(() => {
|
|||||||
const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL
|
const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL
|
||||||
const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY
|
const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY
|
||||||
const language = (url.searchParams.get('lang') as 'zh-CN' | 'en-US') || 'zh-CN'
|
const language = (url.searchParams.get('lang') as 'zh-CN' | 'en-US') || 'zh-CN'
|
||||||
const config: PageAgentConfig = { model, baseURL, apiKey, language }
|
config = { model, baseURL, apiKey, language }
|
||||||
window.pageAgent = new PageAgent(config)
|
|
||||||
} else {
|
} else {
|
||||||
console.log('🚀 page-agent.js no current script detected, using default demo config')
|
console.log('🚀 page-agent.js no current script detected, using default demo config')
|
||||||
window.pageAgent = new PageAgent()
|
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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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" />
|
/// <reference types="vite/client" />
|
||||||
import type { PageAgent } from './PageAgent'
|
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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
pageAgent?: PageAgent
|
pageAgent?: PageAgent
|
||||||
PageAgent: typeof PageAgent
|
PageAgent: typeof PageAgent
|
||||||
__PAGE_AGENT_IDS__: string[]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,100 +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:paused': { params: undefined }
|
|
||||||
// 'agent:resumed': { params: undefined }
|
|
||||||
// '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 }
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// @workaround DTS bug
|
// @workaround DTS bug
|
||||||
// dts do not work with monorepo path mapping
|
// dts do not work with monorepo path mapping
|
||||||
// disable path mapping for it
|
// disable path mapping for it
|
||||||
"paths": {}
|
"paths": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
"allowImportingTsExtensions": false,
|
"allowImportingTsExtensions": false,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"paths": {
|
"paths": {
|
||||||
//
|
//
|
||||||
"@page-agent/llms": ["../llms/src/index.ts"],
|
"@page-agent/llms": ["../llms/src/index.ts"],
|
||||||
"@page-agent/page-controller": ["../page-controller/src/PageController.ts"],
|
"@page-agent/page-controller": ["../page-controller/src/PageController.ts"],
|
||||||
"@page-agent/ui": ["../ui/src/index.ts"]
|
"@page-agent/core": ["../core/src/PageAgentCore.ts"],
|
||||||
}
|
"@page-agent/ui": ["../ui/src/index.ts"]
|
||||||
},
|
}
|
||||||
"include": ["**/*.ts"],
|
},
|
||||||
"exclude": ["dist", "node_modules"],
|
"include": ["**/*.ts"],
|
||||||
"references": [
|
"exclude": ["dist", "node_modules"],
|
||||||
//
|
"references": [
|
||||||
{ "path": "../llms" },
|
//
|
||||||
{ "path": "../page-controller" },
|
{ "path": "../llms" },
|
||||||
{ "path": "../ui" }
|
{ "path": "../page-controller" },
|
||||||
]
|
{ "path": "../core" },
|
||||||
|
{ "path": "../ui" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
import { config as dotenvConfig } from 'dotenv'
|
||||||
import { dirname, resolve } from 'path'
|
import { dirname, resolve } from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
@@ -6,11 +7,14 @@ import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
|||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
// Load .env from repo root
|
||||||
|
dotenvConfig({ path: resolve(__dirname, '../../.env') })
|
||||||
|
|
||||||
// UMD Bundle for CDN
|
// UMD Bundle for CDN
|
||||||
// - alias all local packages so that they can be build in
|
// - alias all local packages so that they can be build in
|
||||||
// - no external
|
// - no external
|
||||||
// - no d.ts. dts does not work with monorepo aliasing
|
// - no d.ts. dts does not work with monorepo aliasing
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => ({
|
||||||
plugins: [cssInjectedByJsPlugin({ relativeCSSInjection: true })],
|
plugins: [cssInjectedByJsPlugin({ relativeCSSInjection: true })],
|
||||||
publicDir: false,
|
publicDir: false,
|
||||||
esbuild: {
|
esbuild: {
|
||||||
@@ -20,26 +24,34 @@ export default defineConfig({
|
|||||||
alias: {
|
alias: {
|
||||||
'@page-agent/page-controller': resolve(__dirname, '../page-controller/src/PageController.ts'),
|
'@page-agent/page-controller': resolve(__dirname, '../page-controller/src/PageController.ts'),
|
||||||
'@page-agent/llms': resolve(__dirname, '../llms/src/index.ts'),
|
'@page-agent/llms': resolve(__dirname, '../llms/src/index.ts'),
|
||||||
|
'@page-agent/core': resolve(__dirname, '../core/src/PageAgentCore.ts'),
|
||||||
'@page-agent/ui': resolve(__dirname, '../ui/src/index.ts'),
|
'@page-agent/ui': resolve(__dirname, '../ui/src/index.ts'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
lib: {
|
lib: {
|
||||||
entry: resolve(__dirname, 'src/umd.ts'),
|
entry: resolve(__dirname, 'src/demo.ts'),
|
||||||
name: 'PageAgent',
|
name: 'PageAgent',
|
||||||
fileName: 'page-agent',
|
fileName: () => `page-agent.demo.js`,
|
||||||
formats: ['umd'],
|
formats: ['iife'],
|
||||||
},
|
},
|
||||||
outDir: resolve(__dirname, 'dist', 'umd'),
|
outDir: resolve(__dirname, 'dist', 'iife'),
|
||||||
|
cssCodeSplit: true,
|
||||||
|
// minify: false,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
// output: {
|
||||||
// force use .js as extension
|
// // force use .js as extension
|
||||||
entryFileNames: 'page-agent.js',
|
// entryFileNames: 'page-agent.js',
|
||||||
|
// },
|
||||||
|
onwarn: function (message, handler) {
|
||||||
|
if (message.code === 'EVAL') return
|
||||||
|
handler(message)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
cssCodeSplit: true,
|
|
||||||
},
|
},
|
||||||
define: {
|
define: {
|
||||||
'process.env.NODE_ENV': '"production"',
|
'import.meta.env.LLM_MODEL_NAME': JSON.stringify(process.env.LLM_MODEL_NAME),
|
||||||
|
'import.meta.env.LLM_API_KEY': JSON.stringify(process.env.LLM_API_KEY),
|
||||||
|
'import.meta.env.LLM_BASE_URL': JSON.stringify(process.env.LLM_BASE_URL),
|
||||||
},
|
},
|
||||||
})
|
}))
|
||||||
@@ -1,38 +1,41 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/page-controller",
|
"name": "@page-agent/page-controller",
|
||||||
"version": "0.0.14",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/lib/page-controller.js",
|
"main": "./dist/lib/page-controller.js",
|
||||||
"module": "./dist/lib/page-controller.js",
|
"module": "./dist/lib/page-controller.js",
|
||||||
"types": "./dist/lib/PageController.d.ts",
|
"types": "./dist/lib/PageController.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/lib/PageController.d.ts",
|
"types": "./dist/lib/PageController.d.ts",
|
||||||
"import": "./dist/lib/page-controller.js",
|
"import": "./dist/lib/page-controller.js",
|
||||||
"default": "./dist/lib/page-controller.js"
|
"default": "./dist/lib/page-controller.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/"
|
"dist/"
|
||||||
],
|
],
|
||||||
"description": "Page controller for page-agent - DOM operations and element interactions",
|
"description": "Page controller for page-agent - DOM operations and element interactions",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"page-agent",
|
"page-agent",
|
||||||
"dom",
|
"dom",
|
||||||
"browser-automation",
|
"browser-automation",
|
||||||
"web-automation"
|
"web-automation"
|
||||||
],
|
],
|
||||||
"author": "Simon<gaomeng1900>",
|
"author": "Simon<gaomeng1900>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/alibaba/page-agent.git",
|
"url": "https://github.com/alibaba/page-agent.git",
|
||||||
"directory": "packages/page-controller"
|
"directory": "packages/page-controller"
|
||||||
},
|
},
|
||||||
"homepage": "https://alibaba.github.io/page-agent/",
|
"homepage": "https://alibaba.github.io/page-agent/",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
"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{}})\""
|
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||||
}
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ai-motion": "^0.4.8"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,22 @@ import { patchReact } from './patches/react'
|
|||||||
*/
|
*/
|
||||||
export interface PageControllerConfig extends dom.DomConfig {
|
export interface PageControllerConfig extends dom.DomConfig {
|
||||||
viewportExpansion?: number
|
viewportExpansion?: number
|
||||||
|
/** Enable visual mask overlay during operations (default: false) */
|
||||||
|
enableMask?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured browser state for LLM consumption
|
||||||
|
*/
|
||||||
|
export interface BrowserState {
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
/** Page info + scroll position hint (e.g. "Page info: 1920x1080px...\n[Start of page]") */
|
||||||
|
header: string
|
||||||
|
/** Simplified HTML of interactive elements */
|
||||||
|
content: string
|
||||||
|
/** Page footer hint (e.g. "... 300 pixels below ..." or "[End of page]") */
|
||||||
|
footer: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActionResult {
|
interface ActionResult {
|
||||||
@@ -64,14 +80,32 @@ export class PageController extends EventTarget {
|
|||||||
/** last time the tree was updated */
|
/** last time the tree was updated */
|
||||||
private lastTimeUpdate = 0
|
private lastTimeUpdate = 0
|
||||||
|
|
||||||
|
/** Whether the tree has been indexed at least once */
|
||||||
|
private isIndexed = false
|
||||||
|
|
||||||
|
/** Visual mask overlay for blocking user interaction during automation */
|
||||||
|
private mask: InstanceType<typeof import('./mask/SimulatorMask').SimulatorMask> | null = null
|
||||||
|
private maskReady: Promise<void> | null = null
|
||||||
|
|
||||||
constructor(config: PageControllerConfig = {}) {
|
constructor(config: PageControllerConfig = {}) {
|
||||||
super()
|
super()
|
||||||
|
|
||||||
this.config = config
|
this.config = config
|
||||||
|
|
||||||
patchReact(this)
|
patchReact(this)
|
||||||
|
|
||||||
|
if (config.enableMask) {
|
||||||
|
this.maskReady = this.initMask()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize mask asynchronously (dynamic import to avoid CSS loading in Node)
|
||||||
|
*/
|
||||||
|
private async initMask(): Promise<void> {
|
||||||
|
const { SimulatorMask } = await import('./mask/SimulatorMask')
|
||||||
|
this.mask = new SimulatorMask()
|
||||||
|
}
|
||||||
// ======= State Queries =======
|
// ======= State Queries =======
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,42 +115,6 @@ export class PageController extends EventTarget {
|
|||||||
return window.location.href
|
return window.location.href
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current page title
|
|
||||||
*/
|
|
||||||
async getPageTitle(): Promise<string> {
|
|
||||||
return document.title
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get page scroll and size info
|
|
||||||
*/
|
|
||||||
async getPageInfo() {
|
|
||||||
return getPageInfo()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the simplified HTML representation of the page.
|
|
||||||
* This is used by LLM to understand the page structure.
|
|
||||||
*/
|
|
||||||
async getSimplifiedHTML(): Promise<string> {
|
|
||||||
return this.simplifiedHTML
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get text description for an element by index
|
|
||||||
*/
|
|
||||||
async getElementText(index: number): Promise<string | undefined> {
|
|
||||||
return this.elementTextMap.get(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get total number of indexed interactive elements
|
|
||||||
*/
|
|
||||||
async getElementCount(): Promise<number> {
|
|
||||||
return this.selectorMap.size
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get last tree update timestamp
|
* Get last tree update timestamp
|
||||||
*/
|
*/
|
||||||
@@ -125,10 +123,43 @@ export class PageController extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the viewport expansion setting
|
* Get structured browser state for LLM consumption.
|
||||||
|
* Automatically calls updateTree() to refresh the DOM state.
|
||||||
*/
|
*/
|
||||||
async getViewportExpansion(): Promise<number> {
|
async getBrowserState(): Promise<BrowserState> {
|
||||||
return this.config.viewportExpansion ?? VIEWPORT_EXPANSION
|
const url = window.location.href
|
||||||
|
const title = document.title
|
||||||
|
const pi = getPageInfo()
|
||||||
|
const viewportExpansion = this.config.viewportExpansion ?? VIEWPORT_EXPANSION
|
||||||
|
|
||||||
|
await this.updateTree()
|
||||||
|
|
||||||
|
const content = this.simplifiedHTML
|
||||||
|
|
||||||
|
// Build header: page info + scroll position hint
|
||||||
|
const pageInfoLine = `Page info: ${pi.viewport_width}x${pi.viewport_height}px viewport, ${pi.page_width}x${pi.page_height}px total page size, ${pi.pages_above.toFixed(1)} pages above, ${pi.pages_below.toFixed(1)} pages below, ${pi.total_pages.toFixed(1)} total pages, at ${(pi.current_page_position * 100).toFixed(0)}% of page`
|
||||||
|
|
||||||
|
const elementsLabel =
|
||||||
|
viewportExpansion === -1
|
||||||
|
? 'Interactive elements from top layer of the current page (full page):'
|
||||||
|
: 'Interactive elements from top layer of the current page inside the viewport:'
|
||||||
|
|
||||||
|
const hasContentAbove = pi.pixels_above > 4
|
||||||
|
const scrollHintAbove =
|
||||||
|
hasContentAbove && viewportExpansion !== -1
|
||||||
|
? `... ${pi.pixels_above} pixels above (${pi.pages_above.toFixed(1)} pages) - scroll to see more ...`
|
||||||
|
: '[Start of page]'
|
||||||
|
|
||||||
|
const header = `${pageInfoLine}\n\n${elementsLabel}\n\n${scrollHintAbove}`
|
||||||
|
|
||||||
|
// Build footer: scroll position hint
|
||||||
|
const hasContentBelow = pi.pixels_below > 4
|
||||||
|
const footer =
|
||||||
|
hasContentBelow && viewportExpansion !== -1
|
||||||
|
? `... ${pi.pixels_below} pixels below (${pi.pages_below.toFixed(1)} pages) - scroll to see more ...`
|
||||||
|
: '[End of page]'
|
||||||
|
|
||||||
|
return { url, title, header, content, footer }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ======= DOM Tree Operations =======
|
// ======= DOM Tree Operations =======
|
||||||
@@ -136,12 +167,18 @@ export class PageController extends EventTarget {
|
|||||||
/**
|
/**
|
||||||
* Update DOM tree, returns simplified HTML for LLM.
|
* Update DOM tree, returns simplified HTML for LLM.
|
||||||
* This is the main method to refresh the page state.
|
* This is the main method to refresh the page state.
|
||||||
|
* Automatically bypasses mask during DOM extraction if enabled.
|
||||||
*/
|
*/
|
||||||
async updateTree(): Promise<string> {
|
async updateTree(): Promise<string> {
|
||||||
this.dispatchEvent(new Event('beforeUpdate'))
|
this.dispatchEvent(new Event('beforeUpdate'))
|
||||||
|
|
||||||
this.lastTimeUpdate = Date.now()
|
this.lastTimeUpdate = Date.now()
|
||||||
|
|
||||||
|
// Temporarily bypass mask to allow DOM extraction
|
||||||
|
if (this.mask) {
|
||||||
|
this.mask.wrapper.style.pointerEvents = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
dom.cleanUpHighlights()
|
dom.cleanUpHighlights()
|
||||||
|
|
||||||
const blacklist = [
|
const blacklist = [
|
||||||
@@ -162,6 +199,14 @@ export class PageController extends EventTarget {
|
|||||||
this.elementTextMap.clear()
|
this.elementTextMap.clear()
|
||||||
this.elementTextMap = dom.getElementTextMap(this.simplifiedHTML)
|
this.elementTextMap = dom.getElementTextMap(this.simplifiedHTML)
|
||||||
|
|
||||||
|
// Mark as indexed - now element actions are allowed
|
||||||
|
this.isIndexed = true
|
||||||
|
|
||||||
|
// Restore mask blocking
|
||||||
|
if (this.mask) {
|
||||||
|
this.mask.wrapper.style.pointerEvents = 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
this.dispatchEvent(new Event('afterUpdate'))
|
this.dispatchEvent(new Event('afterUpdate'))
|
||||||
|
|
||||||
return this.simplifiedHTML
|
return this.simplifiedHTML
|
||||||
@@ -176,11 +221,22 @@ export class PageController extends EventTarget {
|
|||||||
|
|
||||||
// ======= Element Actions =======
|
// ======= Element Actions =======
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the tree has been indexed before any index-based operation.
|
||||||
|
* Throws if updateTree() hasn't been called yet.
|
||||||
|
*/
|
||||||
|
private assertIndexed(): void {
|
||||||
|
if (!this.isIndexed) {
|
||||||
|
throw new Error('DOM tree not indexed. Can not perform actions on elements.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Click element by index
|
* Click element by index
|
||||||
*/
|
*/
|
||||||
async clickElement(index: number): Promise<ActionResult> {
|
async clickElement(index: number): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
|
this.assertIndexed()
|
||||||
const element = getElementByIndex(this.selectorMap, index)
|
const element = getElementByIndex(this.selectorMap, index)
|
||||||
const elemText = this.elementTextMap.get(index)
|
const elemText = this.elementTextMap.get(index)
|
||||||
await clickElement(element)
|
await clickElement(element)
|
||||||
@@ -210,6 +266,7 @@ export class PageController extends EventTarget {
|
|||||||
*/
|
*/
|
||||||
async inputText(index: number, text: string): Promise<ActionResult> {
|
async inputText(index: number, text: string): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
|
this.assertIndexed()
|
||||||
const element = getElementByIndex(this.selectorMap, index)
|
const element = getElementByIndex(this.selectorMap, index)
|
||||||
const elemText = this.elementTextMap.get(index)
|
const elemText = this.elementTextMap.get(index)
|
||||||
await inputTextElement(element, text)
|
await inputTextElement(element, text)
|
||||||
@@ -231,6 +288,7 @@ export class PageController extends EventTarget {
|
|||||||
*/
|
*/
|
||||||
async selectOption(index: number, optionText: string): Promise<ActionResult> {
|
async selectOption(index: number, optionText: string): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
|
this.assertIndexed()
|
||||||
const element = getElementByIndex(this.selectorMap, index)
|
const element = getElementByIndex(this.selectorMap, index)
|
||||||
const elemText = this.elementTextMap.get(index)
|
const elemText = this.elementTextMap.get(index)
|
||||||
await selectOptionElement(element as HTMLSelectElement, optionText)
|
await selectOptionElement(element as HTMLSelectElement, optionText)
|
||||||
@@ -259,6 +317,8 @@ export class PageController extends EventTarget {
|
|||||||
try {
|
try {
|
||||||
const { down, numPages, pixels, index } = options
|
const { down, numPages, pixels, index } = options
|
||||||
|
|
||||||
|
this.assertIndexed()
|
||||||
|
|
||||||
const scrollAmount = pixels ?? numPages * (down ? 1 : -1) * window.innerHeight
|
const scrollAmount = pixels ?? numPages * (down ? 1 : -1) * window.innerHeight
|
||||||
|
|
||||||
const element = index !== undefined ? getElementByIndex(this.selectorMap, index) : null
|
const element = index !== undefined ? getElementByIndex(this.selectorMap, index) : null
|
||||||
@@ -288,6 +348,8 @@ export class PageController extends EventTarget {
|
|||||||
try {
|
try {
|
||||||
const { right, pixels, index } = options
|
const { right, pixels, index } = options
|
||||||
|
|
||||||
|
this.assertIndexed()
|
||||||
|
|
||||||
const scrollAmount = pixels * (right ? 1 : -1)
|
const scrollAmount = pixels * (right ? 1 : -1)
|
||||||
|
|
||||||
const element = index !== undefined ? getElementByIndex(this.selectorMap, index) : null
|
const element = index !== undefined ? getElementByIndex(this.selectorMap, index) : null
|
||||||
@@ -326,6 +388,26 @@ export class PageController extends EventTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ======= Mask Operations =======
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the visual mask overlay.
|
||||||
|
* Only works if enableMask was set to true in config.
|
||||||
|
*/
|
||||||
|
async showMask(): Promise<void> {
|
||||||
|
await this.maskReady
|
||||||
|
this.mask?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the visual mask overlay.
|
||||||
|
* Only works if enableMask was set to true in config.
|
||||||
|
*/
|
||||||
|
async hideMask(): Promise<void> {
|
||||||
|
await this.maskReady
|
||||||
|
this.mask?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispose and clean up resources
|
* Dispose and clean up resources
|
||||||
*/
|
*/
|
||||||
@@ -335,5 +417,8 @@ export class PageController extends EventTarget {
|
|||||||
this.selectorMap.clear()
|
this.selectorMap.clear()
|
||||||
this.elementTextMap.clear()
|
this.elementTextMap.clear()
|
||||||
this.simplifiedHTML = '<EMPTY>'
|
this.simplifiedHTML = '<EMPTY>'
|
||||||
|
this.isIndexed = false
|
||||||
|
this.mask?.dispose()
|
||||||
|
this.mask = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,12 +287,52 @@ export async function scrollVertically(
|
|||||||
(document.documentElement as HTMLElement)
|
(document.documentElement as HTMLElement)
|
||||||
|
|
||||||
if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
|
if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
|
||||||
|
// Page-level scroll
|
||||||
|
const scrollBefore = window.scrollY
|
||||||
|
const scrollMax = document.documentElement.scrollHeight - window.innerHeight
|
||||||
|
|
||||||
window.scrollBy(0, dy)
|
window.scrollBy(0, dy)
|
||||||
return `✅ Scrolled page by ${dy}px.`
|
|
||||||
|
const scrollAfter = window.scrollY
|
||||||
|
const scrolled = scrollAfter - scrollBefore
|
||||||
|
|
||||||
|
if (Math.abs(scrolled) < 1) {
|
||||||
|
return dy > 0
|
||||||
|
? `⚠️ Already at the bottom of the page, cannot scroll down further.`
|
||||||
|
: `⚠️ Already at the top of the page, cannot scroll up further.`
|
||||||
|
}
|
||||||
|
|
||||||
|
const reachedBottom = dy > 0 && scrollAfter >= scrollMax - 1
|
||||||
|
const reachedTop = dy < 0 && scrollAfter <= 1
|
||||||
|
|
||||||
|
if (reachedBottom) return `✅ Scrolled page by ${scrolled}px. Reached the bottom of the page.`
|
||||||
|
if (reachedTop) return `✅ Scrolled page by ${scrolled}px. Reached the top of the page.`
|
||||||
|
return `✅ Scrolled page by ${scrolled}px.`
|
||||||
} else {
|
} else {
|
||||||
|
// Container scroll
|
||||||
|
const scrollBefore = el!.scrollTop
|
||||||
|
const scrollMax = el!.scrollHeight - el!.clientHeight
|
||||||
|
|
||||||
el!.scrollBy({ top: dy, behavior: 'smooth' })
|
el!.scrollBy({ top: dy, behavior: 'smooth' })
|
||||||
await waitFor(0.1) // Animation playback
|
await waitFor(0.1)
|
||||||
return `✅ Scrolled container (${el!.tagName}) by ${dy}px.`
|
|
||||||
|
const scrollAfter = el!.scrollTop
|
||||||
|
const scrolled = scrollAfter - scrollBefore
|
||||||
|
|
||||||
|
if (Math.abs(scrolled) < 1) {
|
||||||
|
return dy > 0
|
||||||
|
? `⚠️ Already at the bottom of container (${el!.tagName}), cannot scroll down further.`
|
||||||
|
: `⚠️ Already at the top of container (${el!.tagName}), cannot scroll up further.`
|
||||||
|
}
|
||||||
|
|
||||||
|
const reachedBottom = dy > 0 && scrollAfter >= scrollMax - 1
|
||||||
|
const reachedTop = dy < 0 && scrollAfter <= 1
|
||||||
|
|
||||||
|
if (reachedBottom)
|
||||||
|
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the bottom.`
|
||||||
|
if (reachedTop)
|
||||||
|
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the top.`
|
||||||
|
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px.`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,11 +450,52 @@ export async function scrollHorizontally(
|
|||||||
(document.documentElement as HTMLElement)
|
(document.documentElement as HTMLElement)
|
||||||
|
|
||||||
if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
|
if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
|
||||||
|
// Page-level scroll
|
||||||
|
const scrollBefore = window.scrollX
|
||||||
|
const scrollMax = document.documentElement.scrollWidth - window.innerWidth
|
||||||
|
|
||||||
window.scrollBy(dx, 0)
|
window.scrollBy(dx, 0)
|
||||||
return `✅ Scrolled page horizontally by ${dx}px`
|
|
||||||
|
const scrollAfter = window.scrollX
|
||||||
|
const scrolled = scrollAfter - scrollBefore
|
||||||
|
|
||||||
|
if (Math.abs(scrolled) < 1) {
|
||||||
|
return dx > 0
|
||||||
|
? `⚠️ Already at the right edge of the page, cannot scroll right further.`
|
||||||
|
: `⚠️ Already at the left edge of the page, cannot scroll left further.`
|
||||||
|
}
|
||||||
|
|
||||||
|
const reachedRight = dx > 0 && scrollAfter >= scrollMax - 1
|
||||||
|
const reachedLeft = dx < 0 && scrollAfter <= 1
|
||||||
|
|
||||||
|
if (reachedRight)
|
||||||
|
return `✅ Scrolled page by ${scrolled}px. Reached the right edge of the page.`
|
||||||
|
if (reachedLeft) return `✅ Scrolled page by ${scrolled}px. Reached the left edge of the page.`
|
||||||
|
return `✅ Scrolled page horizontally by ${scrolled}px.`
|
||||||
} else {
|
} else {
|
||||||
|
// Container scroll
|
||||||
|
const scrollBefore = el!.scrollLeft
|
||||||
|
const scrollMax = el!.scrollWidth - el!.clientWidth
|
||||||
|
|
||||||
el!.scrollBy({ left: dx, behavior: 'smooth' })
|
el!.scrollBy({ left: dx, behavior: 'smooth' })
|
||||||
await waitFor(0.1) // Animation playback
|
await waitFor(0.1)
|
||||||
return `✅ Scrolled container (${el!.tagName}) horizontally by ${dx}px`
|
|
||||||
|
const scrollAfter = el!.scrollLeft
|
||||||
|
const scrolled = scrollAfter - scrollBefore
|
||||||
|
|
||||||
|
if (Math.abs(scrolled) < 1) {
|
||||||
|
return dx > 0
|
||||||
|
? `⚠️ Already at the right edge of container (${el!.tagName}), cannot scroll right further.`
|
||||||
|
: `⚠️ Already at the left edge of container (${el!.tagName}), cannot scroll left further.`
|
||||||
|
}
|
||||||
|
|
||||||
|
const reachedRight = dx > 0 && scrollAfter >= scrollMax - 1
|
||||||
|
const reachedLeft = dx < 0 && scrollAfter <= 1
|
||||||
|
|
||||||
|
if (reachedRight)
|
||||||
|
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the right edge.`
|
||||||
|
if (reachedLeft)
|
||||||
|
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the left edge.`
|
||||||
|
return `✅ Scrolled container (${el!.tagName}) horizontally by ${scrolled}px.`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
* @edit scrollable element detection
|
* @edit scrollable element detection
|
||||||
* @edit add `data-browser-use-ignore` attribute
|
* @edit add `data-browser-use-ignore` attribute
|
||||||
* @edit improve `sampleRect`, filter out rects with 0 area
|
* @edit improve `sampleRect`, filter out rects with 0 area
|
||||||
|
* @edit exclude aria-hidden elements
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export default (
|
export default (
|
||||||
@@ -1446,7 +1447,14 @@ export default (
|
|||||||
* @edit add `data-browser-use-ignore` attribute
|
* @edit add `data-browser-use-ignore` attribute
|
||||||
*/
|
*/
|
||||||
if (node.dataset?.browserUseIgnore === 'true') {
|
if (node.dataset?.browserUseIgnore === 'true') {
|
||||||
return true // Skip this node and its children
|
return null // Skip this node and its children
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @edit exclude aria-hidden elements
|
||||||
|
*/
|
||||||
|
if (node.getAttribute && node.getAttribute('aria-hidden') === 'true') {
|
||||||
|
return null // Skip this node and its children
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special handling for root node (body)
|
// Special handling for root node (body)
|
||||||
|
|||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.module.css' {
|
||||||
|
const classes: Record<string, string>
|
||||||
|
export default classes
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// @workaround DTS bug
|
// @workaround DTS bug
|
||||||
// dts do not work with monorepo path mapping
|
// dts do not work with monorepo path mapping
|
||||||
// disable path mapping for it
|
// disable path mapping for it
|
||||||
"paths": {}
|
"paths": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
"allowImportingTsExtensions": false,
|
"allowImportingTsExtensions": false,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"outDir": "dist"
|
"outDir": "dist"
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts", "**/*.js"],
|
"include": ["**/*.ts", "**/*.js"],
|
||||||
"exclude": ["dist", "node_modules"]
|
"exclude": ["dist", "node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
outDir: resolve(__dirname, 'dist', 'lib'),
|
outDir: resolve(__dirname, 'dist', 'lib'),
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: ['@page-agent/*'],
|
external: ['@page-agent/*', 'ai-motion'],
|
||||||
|
onwarn: function (message, handler) {
|
||||||
|
if (message.code === 'EVAL') return
|
||||||
|
handler(message)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
minify: false,
|
minify: false,
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
|||||||
+36
-39
@@ -1,41 +1,38 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/ui",
|
"name": "@page-agent/ui",
|
||||||
"version": "0.0.14",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/lib/page-agent-ui.js",
|
"main": "./dist/lib/page-agent-ui.js",
|
||||||
"module": "./dist/lib/page-agent-ui.js",
|
"module": "./dist/lib/page-agent-ui.js",
|
||||||
"types": "./dist/lib/index.d.ts",
|
"types": "./dist/lib/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/lib/index.d.ts",
|
"types": "./dist/lib/index.d.ts",
|
||||||
"import": "./dist/lib/page-agent-ui.js",
|
"import": "./dist/lib/page-agent-ui.js",
|
||||||
"default": "./dist/lib/page-agent-ui.js"
|
"default": "./dist/lib/page-agent-ui.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/"
|
"dist/"
|
||||||
],
|
],
|
||||||
"description": "UI components for page-agent - Panel, SimulatorMask, and i18n",
|
"description": "UI components for page-agent - Panel and i18n",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"page-agent",
|
"page-agent",
|
||||||
"ui",
|
"ui",
|
||||||
"panel",
|
"panel",
|
||||||
"i18n"
|
"i18n"
|
||||||
],
|
],
|
||||||
"author": "Simon<gaomeng1900>",
|
"author": "Simon<gaomeng1900>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/alibaba/page-agent.git",
|
"url": "https://github.com/alibaba/page-agent.git",
|
||||||
"directory": "packages/ui"
|
"directory": "packages/ui"
|
||||||
},
|
},
|
||||||
"homepage": "https://alibaba.github.io/page-agent/",
|
"homepage": "https://alibaba.github.io/page-agent/",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
"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{}})\""
|
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||||
},
|
}
|
||||||
"dependencies": {
|
|
||||||
"ai-motion": "^0.4.7"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
/**
|
|
||||||
* Agent execution state management
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface Step {
|
|
||||||
id: string
|
|
||||||
stepNumber: number
|
|
||||||
timestamp: Date
|
|
||||||
type: 'thinking' | 'tool_executing' | 'completed' | 'error' | 'output' | 'input' | 'retry'
|
|
||||||
|
|
||||||
// Tool execution related
|
|
||||||
toolName?: string
|
|
||||||
toolArgs?: any
|
|
||||||
toolResult?: any
|
|
||||||
|
|
||||||
// Display data
|
|
||||||
displayText: string
|
|
||||||
duration?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AgentStatus = 'idle' | 'running' | 'paused' | 'completed' | 'error'
|
|
||||||
|
|
||||||
export class UIState {
|
|
||||||
private steps: Step[] = []
|
|
||||||
private currentStep: Step | null = null
|
|
||||||
private status: AgentStatus = 'idle'
|
|
||||||
private stepCounter = 0
|
|
||||||
|
|
||||||
addStep(stepData: Omit<Step, 'id' | 'stepNumber' | 'timestamp'>): Step {
|
|
||||||
const step: Step = {
|
|
||||||
id: this.generateId(),
|
|
||||||
stepNumber: ++this.stepCounter,
|
|
||||||
timestamp: new Date(),
|
|
||||||
...stepData,
|
|
||||||
}
|
|
||||||
|
|
||||||
this.steps.push(step)
|
|
||||||
this.currentStep = step
|
|
||||||
|
|
||||||
// Update overall status
|
|
||||||
this.updateStatus(step.type)
|
|
||||||
|
|
||||||
return step
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCurrentStep(updates: Partial<Step>): Step | null {
|
|
||||||
if (!this.currentStep) return null
|
|
||||||
|
|
||||||
Object.assign(this.currentStep, updates)
|
|
||||||
return this.currentStep
|
|
||||||
}
|
|
||||||
|
|
||||||
getCurrentStep(): Step | null {
|
|
||||||
return this.currentStep
|
|
||||||
}
|
|
||||||
|
|
||||||
getAllSteps(): Step[] {
|
|
||||||
return [...this.steps]
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatus(): AgentStatus {
|
|
||||||
return this.status
|
|
||||||
}
|
|
||||||
|
|
||||||
reset(): void {
|
|
||||||
this.steps = []
|
|
||||||
this.currentStep = null
|
|
||||||
this.status = 'idle'
|
|
||||||
this.stepCounter = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateStatus(stepType: Step['type']): void {
|
|
||||||
switch (stepType) {
|
|
||||||
case 'thinking':
|
|
||||||
case 'tool_executing':
|
|
||||||
case 'output':
|
|
||||||
case 'input':
|
|
||||||
case 'retry':
|
|
||||||
this.status = 'running'
|
|
||||||
break
|
|
||||||
case 'completed':
|
|
||||||
this.status = 'completed'
|
|
||||||
break
|
|
||||||
case 'error':
|
|
||||||
this.status = 'error'
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateId(): string {
|
|
||||||
return `step_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ export class I18n {
|
|||||||
|
|
||||||
constructor(language: SupportedLanguage = 'en-US') {
|
constructor(language: SupportedLanguage = 'en-US') {
|
||||||
this.language = language in locales ? language : 'en-US'
|
this.language = language in locales ? language : 'en-US'
|
||||||
this.translations = locales[language]
|
this.translations = locales[this.language]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 类型安全的翻译方法
|
// 类型安全的翻译方法
|
||||||
|
|||||||
@@ -4,17 +4,13 @@ const enUS = {
|
|||||||
panel: {
|
panel: {
|
||||||
ready: 'Ready',
|
ready: 'Ready',
|
||||||
thinking: 'Thinking...',
|
thinking: 'Thinking...',
|
||||||
paused: 'Paused',
|
|
||||||
taskInput: 'Enter new task, describe steps in detail, press Enter to submit',
|
taskInput: 'Enter new task, describe steps in detail, press Enter to submit',
|
||||||
userAnswerPrompt: 'Please answer the question above, press Enter to submit',
|
userAnswerPrompt: 'Please answer the question above, press Enter to submit',
|
||||||
taskTerminated: 'Task terminated',
|
taskTerminated: 'Task terminated',
|
||||||
taskCompleted: 'Task completed',
|
taskCompleted: 'Task completed',
|
||||||
continueExecution: 'Continue execution',
|
|
||||||
userAnswer: 'User answer: {{input}}',
|
userAnswer: 'User answer: {{input}}',
|
||||||
question: 'Question: {{question}}',
|
question: 'Question: {{question}}',
|
||||||
waitingPlaceholder: 'Waiting for task to start...',
|
waitingPlaceholder: 'Waiting for task to start...',
|
||||||
pause: 'Pause',
|
|
||||||
continue: 'Continue',
|
|
||||||
stop: 'Stop',
|
stop: 'Stop',
|
||||||
expand: 'Expand history',
|
expand: 'Expand history',
|
||||||
collapse: 'Collapse history',
|
collapse: 'Collapse history',
|
||||||
@@ -26,6 +22,7 @@ const enUS = {
|
|||||||
selecting: 'Selecting option "{{text}}"...',
|
selecting: 'Selecting option "{{text}}"...',
|
||||||
scrolling: 'Scrolling page...',
|
scrolling: 'Scrolling page...',
|
||||||
waiting: 'Waiting {{seconds}} seconds...',
|
waiting: 'Waiting {{seconds}} seconds...',
|
||||||
|
askingUser: 'Asking user...',
|
||||||
done: 'Task done',
|
done: 'Task done',
|
||||||
clicked: '🖱️ Clicked element [{{index}}]',
|
clicked: '🖱️ Clicked element [{{index}}]',
|
||||||
inputted: '⌨️ Inputted text "{{text}}"',
|
inputted: '⌨️ Inputted text "{{text}}"',
|
||||||
@@ -54,17 +51,13 @@ const zhCN = {
|
|||||||
panel: {
|
panel: {
|
||||||
ready: '准备就绪',
|
ready: '准备就绪',
|
||||||
thinking: '正在思考...',
|
thinking: '正在思考...',
|
||||||
paused: '暂停中,稍后',
|
|
||||||
taskInput: '输入新任务,详细描述步骤,回车提交',
|
taskInput: '输入新任务,详细描述步骤,回车提交',
|
||||||
userAnswerPrompt: '请回答上面问题,回车提交',
|
userAnswerPrompt: '请回答上面问题,回车提交',
|
||||||
taskTerminated: '任务已终止',
|
taskTerminated: '任务已终止',
|
||||||
taskCompleted: '任务结束',
|
taskCompleted: '任务结束',
|
||||||
continueExecution: '继续执行',
|
|
||||||
userAnswer: '用户回答: {{input}}',
|
userAnswer: '用户回答: {{input}}',
|
||||||
question: '询问: {{question}}',
|
question: '询问: {{question}}',
|
||||||
waitingPlaceholder: '等待任务开始...',
|
waitingPlaceholder: '等待任务开始...',
|
||||||
pause: '暂停',
|
|
||||||
continue: '继续',
|
|
||||||
stop: '终止',
|
stop: '终止',
|
||||||
expand: '展开历史',
|
expand: '展开历史',
|
||||||
collapse: '收起历史',
|
collapse: '收起历史',
|
||||||
@@ -76,6 +69,7 @@ const zhCN = {
|
|||||||
selecting: '正在选择选项 "{{text}}"...',
|
selecting: '正在选择选项 "{{text}}"...',
|
||||||
scrolling: '正在滚动页面...',
|
scrolling: '正在滚动页面...',
|
||||||
waiting: '等待 {{seconds}} 秒...',
|
waiting: '等待 {{seconds}} 秒...',
|
||||||
|
askingUser: '正在询问用户...',
|
||||||
done: '结束任务',
|
done: '结束任务',
|
||||||
clicked: '🖱️ 已点击元素 [{{index}}]',
|
clicked: '🖱️ 已点击元素 [{{index}}]',
|
||||||
inputted: '⌨️ 已输入文本 "{{text}}"',
|
inputted: '⌨️ 已输入文本 "{{text}}"',
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export { Panel, type PanelConfig, type PanelUpdate } from './Panel'
|
export { Panel, type PanelConfig } from './panel/Panel'
|
||||||
export { SimulatorMask } from './SimulatorMask'
|
export type { AgentActivity, PanelAgentAdapter } from './panel/types'
|
||||||
export { UIState, type Step, type AgentStatus } from './UIState'
|
|
||||||
export { I18n, type SupportedLanguage, type TranslationKey } from './i18n'
|
export { I18n, type SupportedLanguage, type TranslationKey } from './i18n'
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ This is the CSS implementation of ai-motion.
|
|||||||
|
|
||||||
Easy to use but Terrible performance. Causing full screen glitching in some browsers.
|
Easy to use but Terrible performance. Causing full screen glitching in some browsers.
|
||||||
|
|
||||||
Use it only in a small area.
|
Use it only in a small area.
|
||||||
|
|||||||
@@ -209,18 +209,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pauseButton {
|
|
||||||
font-weight: 600;
|
|
||||||
&.paused {
|
|
||||||
background: rgba(34, 197, 94, 0.2); /* 绿色背景表示可以继续 */
|
|
||||||
color: rgb(34, 197, 94);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(34, 197, 94, 0.3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stopButton {
|
.stopButton {
|
||||||
background: rgba(239, 68, 68, 0.2);
|
background: rgba(239, 68, 68, 0.2);
|
||||||
color: rgb(255, 41, 41);
|
color: rgb(255, 41, 41);
|
||||||
@@ -364,6 +352,16 @@
|
|||||||
background: linear-gradient(135deg, rgba(255, 214, 0, 0.1), rgba(255, 214, 0, 0.05));
|
background: linear-gradient(135deg, rgba(255, 214, 0, 0.1), rgba(255, 214, 0, 0.05));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.observation {
|
||||||
|
border-left-color: rgb(147, 51, 234);
|
||||||
|
background: linear-gradient(135deg, rgba(147, 51, 234, 0.1), rgba(147, 51, 234, 0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.question {
|
||||||
|
border-left-color: rgb(255, 159, 67);
|
||||||
|
background: linear-gradient(135deg, rgba(255, 159, 67, 0.15), rgba(255, 159, 67, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
/* 突出显示 done 成功结果 */
|
/* 突出显示 done 成功结果 */
|
||||||
&.doneSuccess {
|
&.doneSuccess {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
@@ -446,7 +444,7 @@
|
|||||||
|
|
||||||
.historyContent {
|
.historyContent {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
@@ -460,6 +458,12 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reflectionLines {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.historyMeta {
|
.historyMeta {
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type Step, UIState } from './UIState'
|
import { I18n, type SupportedLanguage } from '../i18n'
|
||||||
import { I18n, type SupportedLanguage } from './i18n'
|
import { truncate } from '../utils'
|
||||||
import { truncate } from './utils'
|
import { createCard, createReflectionLines, formatTime } from './cards'
|
||||||
|
import type { AgentActivity, PanelAgentAdapter } from './types'
|
||||||
|
|
||||||
import styles from './Panel.module.css'
|
import styles from './Panel.module.css'
|
||||||
|
|
||||||
@@ -9,29 +10,22 @@ import styles from './Panel.module.css'
|
|||||||
*/
|
*/
|
||||||
export interface PanelConfig {
|
export interface PanelConfig {
|
||||||
language?: SupportedLanguage
|
language?: SupportedLanguage
|
||||||
onExecuteTask: (task: string) => void
|
/**
|
||||||
onStop: () => void
|
* Whether to prompt for next task after task completion
|
||||||
onPauseToggle: () => boolean // returns new paused state
|
* @default true
|
||||||
getPaused: () => boolean
|
*/
|
||||||
|
promptForNextTask?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Semantic update types - Panel handles i18n internally
|
|
||||||
*/
|
|
||||||
export type PanelUpdate =
|
|
||||||
| { type: 'thinking'; text?: string } // text is optional, defaults to i18n thinking text
|
|
||||||
| { type: 'input'; task: string }
|
|
||||||
| { type: 'question'; question: string }
|
|
||||||
| { type: 'userAnswer'; input: string }
|
|
||||||
| { type: 'retry'; current: number; max: number }
|
|
||||||
| { type: 'error'; message: string }
|
|
||||||
| { type: 'output'; text: string }
|
|
||||||
| { type: 'completed' }
|
|
||||||
| { type: 'toolExecuting'; toolName: string; args: any }
|
|
||||||
| { type: 'toolCompleted'; toolName: string; args: any; result?: string; duration?: number }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent control panel
|
* Agent control panel
|
||||||
|
*
|
||||||
|
* Architecture:
|
||||||
|
* - History list: renders directly from agent.history (historical events)
|
||||||
|
* - Header bar: shows activity events (transient state) and agent status
|
||||||
|
*
|
||||||
|
* This separation ensures data consistency - history is the single source of truth
|
||||||
|
* for what has been done, while activity shows what is happening now.
|
||||||
*/
|
*/
|
||||||
export class Panel {
|
export class Panel {
|
||||||
#wrapper: HTMLElement
|
#wrapper: HTMLElement
|
||||||
@@ -39,14 +33,13 @@ export class Panel {
|
|||||||
#statusText: HTMLElement
|
#statusText: HTMLElement
|
||||||
#historySection: HTMLElement
|
#historySection: HTMLElement
|
||||||
#expandButton: HTMLElement
|
#expandButton: HTMLElement
|
||||||
#pauseButton: HTMLElement
|
|
||||||
#stopButton: HTMLElement
|
#stopButton: HTMLElement
|
||||||
#inputSection: HTMLElement
|
#inputSection: HTMLElement
|
||||||
#taskInput: HTMLInputElement
|
#taskInput: HTMLInputElement
|
||||||
|
|
||||||
#state = new UIState()
|
#agent: PanelAgentAdapter
|
||||||
#isExpanded = false
|
|
||||||
#config: PanelConfig
|
#config: PanelConfig
|
||||||
|
#isExpanded = false
|
||||||
#i18n: I18n
|
#i18n: I18n
|
||||||
#userAnswerResolver: ((input: string) => void) | null = null
|
#userAnswerResolver: ((input: string) => void) | null = null
|
||||||
#isWaitingForUserAnswer: boolean = false
|
#isWaitingForUserAnswer: boolean = false
|
||||||
@@ -54,47 +47,145 @@ export class Panel {
|
|||||||
#pendingHeaderText: string | null = null
|
#pendingHeaderText: string | null = null
|
||||||
#isAnimating = false
|
#isAnimating = false
|
||||||
|
|
||||||
|
// Event handlers (bound for removal)
|
||||||
|
#onStatusChange = () => this.#handleStatusChange()
|
||||||
|
#onHistoryChange = () => this.#handleHistoryChange()
|
||||||
|
#onActivity = (e: Event) => this.#handleActivity((e as CustomEvent<AgentActivity>).detail)
|
||||||
|
#onAgentDispose = () => this.dispose()
|
||||||
|
|
||||||
get wrapper(): HTMLElement {
|
get wrapper(): HTMLElement {
|
||||||
return this.#wrapper
|
return this.#wrapper
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(config: PanelConfig) {
|
/**
|
||||||
|
* Create a Panel bound to an agent
|
||||||
|
* @param agent - Agent instance that implements PanelAgentAdapter
|
||||||
|
* @param config - Optional panel configuration
|
||||||
|
*/
|
||||||
|
constructor(agent: PanelAgentAdapter, config: PanelConfig = {}) {
|
||||||
|
this.#agent = agent
|
||||||
this.#config = config
|
this.#config = config
|
||||||
this.#i18n = new I18n(config.language ?? 'en-US')
|
this.#i18n = new I18n(config.language ?? 'en-US')
|
||||||
|
|
||||||
|
// Set up askUser callback on agent
|
||||||
|
this.#agent.onAskUser = (question) => this.#askUser(question)
|
||||||
|
|
||||||
|
// Create UI elements
|
||||||
this.#wrapper = this.#createWrapper()
|
this.#wrapper = this.#createWrapper()
|
||||||
this.#indicator = this.#wrapper.querySelector(`.${styles.indicator}`)!
|
this.#indicator = this.#wrapper.querySelector(`.${styles.indicator}`)!
|
||||||
this.#statusText = this.#wrapper.querySelector(`.${styles.statusText}`)!
|
this.#statusText = this.#wrapper.querySelector(`.${styles.statusText}`)!
|
||||||
this.#historySection = this.#wrapper.querySelector(`.${styles.historySection}`)!
|
this.#historySection = this.#wrapper.querySelector(`.${styles.historySection}`)!
|
||||||
this.#expandButton = this.#wrapper.querySelector(`.${styles.expandButton}`)!
|
this.#expandButton = this.#wrapper.querySelector(`.${styles.expandButton}`)!
|
||||||
this.#pauseButton = this.#wrapper.querySelector(`.${styles.pauseButton}`)!
|
|
||||||
this.#stopButton = this.#wrapper.querySelector(`.${styles.stopButton}`)!
|
this.#stopButton = this.#wrapper.querySelector(`.${styles.stopButton}`)!
|
||||||
this.#inputSection = this.#wrapper.querySelector(`.${styles.inputSectionWrapper}`)!
|
this.#inputSection = this.#wrapper.querySelector(`.${styles.inputSectionWrapper}`)!
|
||||||
this.#taskInput = this.#wrapper.querySelector(`.${styles.taskInput}`)!
|
this.#taskInput = this.#wrapper.querySelector(`.${styles.taskInput}`)!
|
||||||
|
|
||||||
|
// Listen to agent events
|
||||||
|
this.#agent.addEventListener('statuschange', this.#onStatusChange)
|
||||||
|
this.#agent.addEventListener('historychange', this.#onHistoryChange)
|
||||||
|
this.#agent.addEventListener('activity', this.#onActivity)
|
||||||
|
this.#agent.addEventListener('dispose', this.#onAgentDispose)
|
||||||
|
|
||||||
this.#setupEventListeners()
|
this.#setupEventListeners()
|
||||||
this.#startHeaderUpdateLoop()
|
this.#startHeaderUpdateLoop()
|
||||||
|
|
||||||
this.#showInputArea()
|
this.#showInputArea()
|
||||||
|
|
||||||
|
this.hide() // Start hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Agent event handlers ==========
|
||||||
|
|
||||||
|
/** Handle agent status change */
|
||||||
|
#handleStatusChange(): void {
|
||||||
|
const status = this.#agent.status
|
||||||
|
|
||||||
|
// Map agent status to UI indicator type
|
||||||
|
const indicatorType =
|
||||||
|
status === 'running' ? 'thinking' : status === 'idle' ? 'thinking' : status
|
||||||
|
this.#updateStatusIndicator(indicatorType)
|
||||||
|
|
||||||
|
// Show/hide based on status
|
||||||
|
if (status === 'running') {
|
||||||
|
this.show()
|
||||||
|
this.#hideInputArea() // Hide input while running
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle completion
|
||||||
|
if (status === 'completed' || status === 'error') {
|
||||||
|
if (!this.#isExpanded) {
|
||||||
|
this.#expand()
|
||||||
|
}
|
||||||
|
if (this.#shouldShowInputArea()) {
|
||||||
|
this.#showInputArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle agent history change - re-render history list from agent.history */
|
||||||
|
#handleHistoryChange(): void {
|
||||||
|
this.#renderHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ask for user input
|
* Handle agent activity - transient state for immediate UI feedback
|
||||||
|
* Activity events are NOT persisted in history, only used for header bar updates
|
||||||
*/
|
*/
|
||||||
async askUser(question: string): Promise<string> {
|
#handleActivity(activity: AgentActivity): void {
|
||||||
|
switch (activity.type) {
|
||||||
|
case 'thinking':
|
||||||
|
this.#pendingHeaderText = this.#i18n.t('ui.panel.thinking')
|
||||||
|
this.#updateStatusIndicator('thinking')
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'executing':
|
||||||
|
this.#pendingHeaderText = this.#getToolExecutingText(activity.tool, activity.input)
|
||||||
|
this.#updateStatusIndicator('executing')
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'executed':
|
||||||
|
this.#pendingHeaderText = truncate(activity.output, 50)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'retrying':
|
||||||
|
this.#pendingHeaderText = `Retrying (${activity.attempt}/${activity.maxAttempts})`
|
||||||
|
this.#updateStatusIndicator('retrying')
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
this.#pendingHeaderText = truncate(activity.message, 50)
|
||||||
|
this.#updateStatusIndicator('error')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask for user input (internal, called by agent via onAskUser)
|
||||||
|
*/
|
||||||
|
#askUser(question: string): Promise<string> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
// Set `waiting for user answer` state
|
// Set `waiting for user answer` state
|
||||||
this.#isWaitingForUserAnswer = true
|
this.#isWaitingForUserAnswer = true
|
||||||
this.#userAnswerResolver = resolve
|
this.#userAnswerResolver = resolve
|
||||||
|
|
||||||
// Update state to `running`
|
// Expand history panel
|
||||||
this.#updateInternal({
|
|
||||||
type: 'output',
|
|
||||||
displayText: this.#i18n.t('ui.panel.question', { question }),
|
|
||||||
}) // Expand history panel
|
|
||||||
if (!this.#isExpanded) {
|
if (!this.#isExpanded) {
|
||||||
this.#expand()
|
this.#expand()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add temporary question card so user can see the full question
|
||||||
|
const tempCard = document.createElement('div')
|
||||||
|
tempCard.innerHTML = createCard({
|
||||||
|
icon: '❓',
|
||||||
|
content: `Question: ${question}`,
|
||||||
|
meta: formatTime(this.#config.language ?? 'en-US'),
|
||||||
|
type: 'question',
|
||||||
|
})
|
||||||
|
const cardElement = tempCard.firstElementChild as HTMLElement
|
||||||
|
cardElement.setAttribute('data-temp-card', 'true')
|
||||||
|
this.#historySection.appendChild(cardElement)
|
||||||
|
this.#scrollToBottom()
|
||||||
|
|
||||||
this.#showInputArea(this.#i18n.t('ui.panel.userAnswerPrompt'))
|
this.#showInputArea(this.#i18n.t('ui.panel.userAnswerPrompt'))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -115,16 +206,10 @@ export class Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.#state.reset()
|
|
||||||
this.#statusText.textContent = this.#i18n.t('ui.panel.ready')
|
this.#statusText.textContent = this.#i18n.t('ui.panel.ready')
|
||||||
this.#updateStatusIndicator('thinking')
|
this.#updateStatusIndicator('thinking')
|
||||||
this.#updateHistory()
|
this.#renderHistory()
|
||||||
this.#collapse()
|
this.#collapse()
|
||||||
// Reset pause state via callback
|
|
||||||
if (this.#config.getPaused()) {
|
|
||||||
this.#config.onPauseToggle()
|
|
||||||
}
|
|
||||||
this.#updatePauseButton()
|
|
||||||
// Reset user input state
|
// Reset user input state
|
||||||
this.#isWaitingForUserAnswer = false
|
this.#isWaitingForUserAnswer = false
|
||||||
this.#userAnswerResolver = null
|
this.#userAnswerResolver = null
|
||||||
@@ -141,17 +226,16 @@ export class Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update panel with semantic data - i18n handled internally
|
* Dispose panel and clean up event listeners
|
||||||
*/
|
|
||||||
update(data: PanelUpdate): void {
|
|
||||||
const stepData = this.#toStepData(data)
|
|
||||||
this.#updateInternal(stepData)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dispose panel
|
|
||||||
*/
|
*/
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
|
// Remove agent event listeners
|
||||||
|
this.#agent.removeEventListener('statuschange', this.#onStatusChange)
|
||||||
|
this.#agent.removeEventListener('historychange', this.#onHistoryChange)
|
||||||
|
this.#agent.removeEventListener('activity', this.#onActivity)
|
||||||
|
this.#agent.removeEventListener('dispose', this.#onAgentDispose)
|
||||||
|
|
||||||
|
// Clean up UI
|
||||||
this.#isWaitingForUserAnswer = false
|
this.#isWaitingForUserAnswer = false
|
||||||
this.#stopHeaderUpdateLoop()
|
this.#stopHeaderUpdateLoop()
|
||||||
this.wrapper.remove()
|
this.wrapper.remove()
|
||||||
@@ -159,67 +243,21 @@ export class Panel {
|
|||||||
|
|
||||||
// ========== Private methods ==========
|
// ========== Private methods ==========
|
||||||
|
|
||||||
/**
|
#getToolExecutingText(toolName: string, args: unknown): string {
|
||||||
* Convert semantic update to step data with i18n
|
const a = args as Record<string, string | number>
|
||||||
*/
|
|
||||||
#toStepData(data: PanelUpdate): Omit<Step, 'id' | 'stepNumber' | 'timestamp'> {
|
|
||||||
switch (data.type) {
|
|
||||||
case 'thinking':
|
|
||||||
return { type: 'thinking', displayText: data.text ?? this.#i18n.t('ui.panel.thinking') }
|
|
||||||
case 'input':
|
|
||||||
return { type: 'input', displayText: data.task }
|
|
||||||
case 'question':
|
|
||||||
return {
|
|
||||||
type: 'output',
|
|
||||||
displayText: this.#i18n.t('ui.panel.question', { question: data.question }),
|
|
||||||
}
|
|
||||||
case 'userAnswer':
|
|
||||||
return {
|
|
||||||
type: 'input',
|
|
||||||
displayText: this.#i18n.t('ui.panel.userAnswer', { input: data.input }),
|
|
||||||
}
|
|
||||||
case 'retry':
|
|
||||||
return { type: 'retry', displayText: `retry-ing (${data.current} / ${data.max})` }
|
|
||||||
case 'error':
|
|
||||||
return { type: 'error', displayText: data.message }
|
|
||||||
case 'output':
|
|
||||||
return { type: 'output', displayText: data.text }
|
|
||||||
case 'completed':
|
|
||||||
return { type: 'completed', displayText: this.#i18n.t('ui.panel.taskCompleted') }
|
|
||||||
case 'toolExecuting':
|
|
||||||
return {
|
|
||||||
type: 'tool_executing',
|
|
||||||
toolName: data.toolName,
|
|
||||||
toolArgs: data.args,
|
|
||||||
displayText: this.#getToolExecutingText(data.toolName, data.args),
|
|
||||||
}
|
|
||||||
case 'toolCompleted': {
|
|
||||||
const displayText = this.#getToolCompletedText(data.toolName, data.args)
|
|
||||||
if (!displayText) return { type: 'tool_executing', displayText: '' } // will be filtered
|
|
||||||
return {
|
|
||||||
type: 'tool_executing',
|
|
||||||
toolName: data.toolName,
|
|
||||||
toolArgs: data.args,
|
|
||||||
toolResult: data.result,
|
|
||||||
displayText,
|
|
||||||
duration: data.duration,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#getToolExecutingText(toolName: string, args: any): string {
|
|
||||||
switch (toolName) {
|
switch (toolName) {
|
||||||
case 'click_element_by_index':
|
case 'click_element_by_index':
|
||||||
return this.#i18n.t('ui.tools.clicking', { index: args.index })
|
return this.#i18n.t('ui.tools.clicking', { index: a.index })
|
||||||
case 'input_text':
|
case 'input_text':
|
||||||
return this.#i18n.t('ui.tools.inputting', { index: args.index })
|
return this.#i18n.t('ui.tools.inputting', { index: a.index })
|
||||||
case 'select_dropdown_option':
|
case 'select_dropdown_option':
|
||||||
return this.#i18n.t('ui.tools.selecting', { text: args.text })
|
return this.#i18n.t('ui.tools.selecting', { text: a.text })
|
||||||
case 'scroll':
|
case 'scroll':
|
||||||
return this.#i18n.t('ui.tools.scrolling')
|
return this.#i18n.t('ui.tools.scrolling')
|
||||||
case 'wait':
|
case 'wait':
|
||||||
return this.#i18n.t('ui.tools.waiting', { seconds: args.seconds })
|
return this.#i18n.t('ui.tools.waiting', { seconds: a.seconds })
|
||||||
|
case 'ask_user':
|
||||||
|
return this.#i18n.t('ui.tools.askingUser')
|
||||||
case 'done':
|
case 'done':
|
||||||
return this.#i18n.t('ui.tools.done')
|
return this.#i18n.t('ui.tools.done')
|
||||||
default:
|
default:
|
||||||
@@ -227,100 +265,11 @@ export class Panel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#getToolCompletedText(toolName: string, args: any): string | null {
|
|
||||||
switch (toolName) {
|
|
||||||
case 'click_element_by_index':
|
|
||||||
return this.#i18n.t('ui.tools.clicked', { index: args.index })
|
|
||||||
case 'input_text':
|
|
||||||
return this.#i18n.t('ui.tools.inputted', { text: args.text })
|
|
||||||
case 'select_dropdown_option':
|
|
||||||
return this.#i18n.t('ui.tools.selected', { text: args.text })
|
|
||||||
case 'scroll':
|
|
||||||
return this.#i18n.t('ui.tools.scrolled')
|
|
||||||
case 'wait':
|
|
||||||
return this.#i18n.t('ui.tools.waited')
|
|
||||||
case 'done':
|
|
||||||
return null
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update status (internal)
|
|
||||||
*/
|
|
||||||
#updateInternal(stepData: Omit<Step, 'id' | 'stepNumber' | 'timestamp'>): void {
|
|
||||||
// Skip empty displayText (filtered toolCompleted for 'done')
|
|
||||||
if (!stepData.displayText) return
|
|
||||||
|
|
||||||
const step = this.#state.addStep(stepData)
|
|
||||||
|
|
||||||
// Queue header text update (will be processed by periodic check)
|
|
||||||
const headerText = truncate(step.displayText, 20)
|
|
||||||
this.#pendingHeaderText = headerText
|
|
||||||
|
|
||||||
this.#updateStatusIndicator(step.type)
|
|
||||||
this.#updateHistory()
|
|
||||||
|
|
||||||
// Auto-expand history after task completion
|
|
||||||
if (step.type === 'completed' || step.type === 'error') {
|
|
||||||
if (!this.#isExpanded) {
|
|
||||||
this.#expand()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Control input area display based on status
|
|
||||||
if (this.#shouldShowInputArea()) {
|
|
||||||
this.#showInputArea()
|
|
||||||
} else {
|
|
||||||
this.#hideInputArea()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle pause state
|
|
||||||
*/
|
|
||||||
#togglePause(): void {
|
|
||||||
const paused = this.#config.onPauseToggle()
|
|
||||||
this.#updatePauseButton()
|
|
||||||
|
|
||||||
// Update status display
|
|
||||||
if (paused) {
|
|
||||||
this.#statusText.textContent = this.#i18n.t('ui.panel.paused')
|
|
||||||
this.#updateStatusIndicator('thinking')
|
|
||||||
} else {
|
|
||||||
this.#statusText.textContent = this.#i18n.t('ui.panel.continueExecution')
|
|
||||||
this.#updateStatusIndicator('tool_executing')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update pause button state
|
|
||||||
*/
|
|
||||||
#updatePauseButton(): void {
|
|
||||||
const paused = this.#config.getPaused()
|
|
||||||
if (paused) {
|
|
||||||
this.#pauseButton.textContent = '▶'
|
|
||||||
this.#pauseButton.title = this.#i18n.t('ui.panel.continue')
|
|
||||||
this.#pauseButton.classList.add(styles.paused)
|
|
||||||
} else {
|
|
||||||
this.#pauseButton.textContent = '⏸︎'
|
|
||||||
this.#pauseButton.title = this.#i18n.t('ui.panel.pause')
|
|
||||||
this.#pauseButton.classList.remove(styles.paused)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop Agent
|
* Stop Agent
|
||||||
*/
|
*/
|
||||||
#stopAgent(): void {
|
#stopAgent(): void {
|
||||||
// Update status display
|
this.#agent.dispose()
|
||||||
this.#updateInternal({
|
|
||||||
type: 'error',
|
|
||||||
displayText: this.#i18n.t('ui.panel.taskTerminated'),
|
|
||||||
})
|
|
||||||
|
|
||||||
this.#config.onStop()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -337,7 +286,8 @@ export class Panel {
|
|||||||
// Handle user input mode
|
// Handle user input mode
|
||||||
this.#handleUserAnswer(input)
|
this.#handleUserAnswer(input)
|
||||||
} else {
|
} else {
|
||||||
this.#config.onExecuteTask(input)
|
// Execute task via agent
|
||||||
|
this.#agent.execute(input)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,10 +295,11 @@ export class Panel {
|
|||||||
* Handle user answer
|
* Handle user answer
|
||||||
*/
|
*/
|
||||||
#handleUserAnswer(input: string): void {
|
#handleUserAnswer(input: string): void {
|
||||||
// Add user input to history
|
// Remove temporary question cards (only direct children for safety)
|
||||||
this.#updateInternal({
|
Array.from(this.#historySection.children).forEach((child) => {
|
||||||
type: 'input',
|
if (child.getAttribute('data-temp-card') === 'true') {
|
||||||
displayText: this.#i18n.t('ui.panel.userAnswer', { input }),
|
child.remove()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
@@ -389,32 +340,38 @@ export class Panel {
|
|||||||
// Always show input area if waiting for user input
|
// Always show input area if waiting for user input
|
||||||
if (this.#isWaitingForUserAnswer) return true
|
if (this.#isWaitingForUserAnswer) return true
|
||||||
|
|
||||||
const steps = this.#state.getAllSteps()
|
const history = this.#agent.history
|
||||||
if (steps.length === 0) {
|
if (history.length === 0) {
|
||||||
return true // Initial state
|
return true // Initial state
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastStep = steps[steps.length - 1]
|
const status = this.#agent.status
|
||||||
return lastStep.type === 'completed' || lastStep.type === 'error'
|
const isTaskEnded = status === 'completed' || status === 'error'
|
||||||
|
|
||||||
|
// Only show input area after task completion if configured to do so
|
||||||
|
if (isTaskEnded) {
|
||||||
|
return this.#config.promptForNextTask ?? true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
#createWrapper(): HTMLElement {
|
#createWrapper(): HTMLElement {
|
||||||
const wrapper = document.createElement('div')
|
const wrapper = document.createElement('div')
|
||||||
wrapper.id = 'page-agent-runtime_agent-panel'
|
wrapper.id = 'page-agent-runtime_agent-panel'
|
||||||
wrapper.className = `${styles.wrapper} ${styles.collapsed}`
|
wrapper.className = styles.wrapper
|
||||||
wrapper.setAttribute('data-browser-use-ignore', 'true')
|
wrapper.setAttribute('data-browser-use-ignore', 'true')
|
||||||
|
|
||||||
wrapper.innerHTML = `
|
wrapper.innerHTML = `
|
||||||
<div class="${styles.background}"></div>
|
<div class="${styles.background}"></div>
|
||||||
<div class="${styles.historySectionWrapper}">
|
<div class="${styles.historySectionWrapper}">
|
||||||
<div class="${styles.historySection}">
|
<div class="${styles.historySection}">
|
||||||
${this.#createHistoryItem({
|
<div class="${styles.historyItem}">
|
||||||
id: 'placeholder',
|
<div class="${styles.historyContent}">
|
||||||
stepNumber: 0,
|
<span class="${styles.statusIcon}">🧠</span>
|
||||||
timestamp: new Date(),
|
<span>${this.#i18n.t('ui.panel.waitingPlaceholder')}</span>
|
||||||
type: 'thinking',
|
</div>
|
||||||
displayText: this.#i18n.t('ui.panel.waitingPlaceholder'),
|
</div>
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="${styles.header}">
|
<div class="${styles.header}">
|
||||||
@@ -426,9 +383,6 @@ export class Panel {
|
|||||||
<button class="${styles.controlButton} ${styles.expandButton}" title="${this.#i18n.t('ui.panel.expand')}">
|
<button class="${styles.controlButton} ${styles.expandButton}" title="${this.#i18n.t('ui.panel.expand')}">
|
||||||
▼
|
▼
|
||||||
</button>
|
</button>
|
||||||
<button class="${styles.controlButton} ${styles.pauseButton}" title="${this.#i18n.t('ui.panel.pause')}">
|
|
||||||
⏸︎
|
|
||||||
</button>
|
|
||||||
<button class="${styles.controlButton} ${styles.stopButton}" title="${this.#i18n.t('ui.panel.stop')}">
|
<button class="${styles.controlButton} ${styles.stopButton}" title="${this.#i18n.t('ui.panel.stop')}">
|
||||||
X
|
X
|
||||||
</button>
|
</button>
|
||||||
@@ -466,12 +420,6 @@ export class Panel {
|
|||||||
this.#toggle()
|
this.#toggle()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Pause/continue button
|
|
||||||
this.#pauseButton.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
this.#togglePause()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Stop button
|
// Stop button
|
||||||
this.#stopButton.addEventListener('click', (e) => {
|
this.#stopButton.addEventListener('click', (e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -503,7 +451,6 @@ export class Panel {
|
|||||||
|
|
||||||
#expand(): void {
|
#expand(): void {
|
||||||
this.#isExpanded = true
|
this.#isExpanded = true
|
||||||
this.wrapper.classList.remove(styles.collapsed)
|
|
||||||
this.wrapper.classList.add(styles.expanded)
|
this.wrapper.classList.add(styles.expanded)
|
||||||
this.#expandButton.textContent = '▲'
|
this.#expandButton.textContent = '▲'
|
||||||
}
|
}
|
||||||
@@ -511,7 +458,6 @@ export class Panel {
|
|||||||
#collapse(): void {
|
#collapse(): void {
|
||||||
this.#isExpanded = false
|
this.#isExpanded = false
|
||||||
this.wrapper.classList.remove(styles.expanded)
|
this.wrapper.classList.remove(styles.expanded)
|
||||||
this.wrapper.classList.add(styles.collapsed)
|
|
||||||
this.#expandButton.textContent = '▼'
|
this.#expandButton.textContent = '▼'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,7 +526,9 @@ export class Panel {
|
|||||||
}, 150) // Half the duration of fade out animation
|
}, 150) // Half the duration of fade out animation
|
||||||
}
|
}
|
||||||
|
|
||||||
#updateStatusIndicator(type: Step['type']): void {
|
#updateStatusIndicator(
|
||||||
|
type: 'thinking' | 'executing' | 'executed' | 'retrying' | 'completed' | 'error'
|
||||||
|
): void {
|
||||||
// Clear all status classes
|
// Clear all status classes
|
||||||
this.#indicator.className = styles.indicator
|
this.#indicator.className = styles.indicator
|
||||||
|
|
||||||
@@ -588,15 +536,6 @@ export class Panel {
|
|||||||
this.#indicator.classList.add(styles[type])
|
this.#indicator.classList.add(styles[type])
|
||||||
}
|
}
|
||||||
|
|
||||||
#updateHistory(): void {
|
|
||||||
const steps = this.#state.getAllSteps()
|
|
||||||
|
|
||||||
this.#historySection.innerHTML = steps.map((step) => this.#createHistoryItem(step)).join('')
|
|
||||||
|
|
||||||
// Scroll to bottom to show latest records
|
|
||||||
this.#scrollToBottom()
|
|
||||||
}
|
|
||||||
|
|
||||||
#scrollToBottom(): void {
|
#scrollToBottom(): void {
|
||||||
// Execute in next event loop to ensure DOM update completion
|
// Execute in next event loop to ensure DOM update completion
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -604,68 +543,116 @@ export class Panel {
|
|||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
#createHistoryItem(step: Step): string {
|
/**
|
||||||
const time = step.timestamp.toLocaleTimeString('zh-CN', {
|
* Render history directly from agent.history
|
||||||
hour12: false,
|
*
|
||||||
hour: '2-digit',
|
* Renders:
|
||||||
minute: '2-digit',
|
* 1. Task (first item, from agent.task)
|
||||||
second: '2-digit',
|
* 2. Reflection cards (evaluation, memory, next_goal)
|
||||||
})
|
* 3. Tool execution with output
|
||||||
|
* 4. Observations
|
||||||
|
*/
|
||||||
|
#renderHistory(): void {
|
||||||
|
const items: string[] = []
|
||||||
|
|
||||||
let typeClass = ''
|
// 1. Task card (always first)
|
||||||
let statusIcon = ''
|
const task = this.#agent.task
|
||||||
|
if (task) {
|
||||||
// Set styles and icons based on step type
|
items.push(this.#createTaskCard(task))
|
||||||
if (step.type === 'completed') {
|
|
||||||
// Check if this is a result from done tool
|
|
||||||
if (step.toolName === 'done') {
|
|
||||||
// Judge success or failure based on result
|
|
||||||
const failureKeyword = this.#i18n.t('ui.tools.resultFailure')
|
|
||||||
const errorKeyword = this.#i18n.t('ui.tools.resultError')
|
|
||||||
const isSuccess =
|
|
||||||
!step.toolResult ||
|
|
||||||
(!step.toolResult.includes(failureKeyword) && !step.toolResult.includes(errorKeyword))
|
|
||||||
typeClass = isSuccess ? styles.doneSuccess : styles.doneError
|
|
||||||
statusIcon = isSuccess ? '🎉' : '❌'
|
|
||||||
} else {
|
|
||||||
typeClass = styles.completed
|
|
||||||
statusIcon = '✅'
|
|
||||||
}
|
|
||||||
} else if (step.type === 'error') {
|
|
||||||
typeClass = styles.error
|
|
||||||
statusIcon = '❌'
|
|
||||||
} else if (step.type === 'tool_executing') {
|
|
||||||
statusIcon = '⚙️'
|
|
||||||
} else if (step.type === 'output') {
|
|
||||||
typeClass = styles.output
|
|
||||||
statusIcon = '🤖'
|
|
||||||
} else if (step.type === 'input') {
|
|
||||||
typeClass = styles.input
|
|
||||||
statusIcon = '🎯'
|
|
||||||
} else if (step.type === 'retry') {
|
|
||||||
typeClass = styles.retry
|
|
||||||
statusIcon = '🔄'
|
|
||||||
} else {
|
|
||||||
statusIcon = '🧠'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const durationText = step.duration ? ` · ${step.duration}ms` : ''
|
// 2. Render each history event
|
||||||
const stepLabel = this.#i18n.t('ui.panel.step', {
|
const history = this.#agent.history
|
||||||
number: step.stepNumber.toString(),
|
for (const event of history) {
|
||||||
time,
|
items.push(...this.#createHistoryCards(event))
|
||||||
duration: durationText || '', // Explicitly pass empty string to replace template
|
}
|
||||||
})
|
|
||||||
|
|
||||||
return `
|
this.#historySection.innerHTML = items.join('')
|
||||||
<div class="${styles.historyItem} ${typeClass}">
|
this.#scrollToBottom()
|
||||||
<div class="${styles.historyContent}">
|
}
|
||||||
<span class="${styles.statusIcon}">${statusIcon}</span>
|
|
||||||
<span>${step.displayText}</span>
|
#createTaskCard(task: string): string {
|
||||||
</div>
|
return createCard({ icon: '🎯', content: task, type: 'input' })
|
||||||
<div class="${styles.historyMeta}">
|
}
|
||||||
${stepLabel}
|
|
||||||
</div>
|
/** Create cards for a history event */
|
||||||
</div>
|
#createHistoryCards(event: PanelAgentAdapter['history'][number]): string[] {
|
||||||
`
|
const cards: string[] = []
|
||||||
|
const time = formatTime(this.#config.language ?? 'en-US')
|
||||||
|
const meta =
|
||||||
|
event.type === 'step' && event.stepIndex !== undefined
|
||||||
|
? this.#i18n.t('ui.panel.step', {
|
||||||
|
number: (event.stepIndex + 1).toString(),
|
||||||
|
time,
|
||||||
|
duration: '',
|
||||||
|
})
|
||||||
|
: time
|
||||||
|
|
||||||
|
if (event.type === 'step') {
|
||||||
|
// Reflection card
|
||||||
|
if (event.reflection) {
|
||||||
|
const lines = createReflectionLines(event.reflection)
|
||||||
|
if (lines.length > 0) {
|
||||||
|
cards.push(createCard({ icon: '🧠', content: lines, meta }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action card
|
||||||
|
const action = event.action
|
||||||
|
if (action) {
|
||||||
|
cards.push(...this.#createActionCards(action, meta))
|
||||||
|
}
|
||||||
|
} else if (event.type === 'observation') {
|
||||||
|
cards.push(
|
||||||
|
createCard({ icon: '👁️', content: event.content || '', meta, type: 'observation' })
|
||||||
|
)
|
||||||
|
} else if (event.type === 'user_takeover') {
|
||||||
|
cards.push(createCard({ icon: '👤', content: 'User takeover', meta, type: 'input' }))
|
||||||
|
} else if (event.type === 'retry') {
|
||||||
|
const retryInfo = `${event.message || 'Retrying'} (${event.attempt}/${event.maxAttempts})`
|
||||||
|
cards.push(createCard({ icon: '🔄', content: retryInfo, meta, type: 'observation' }))
|
||||||
|
} else if (event.type === 'error') {
|
||||||
|
cards.push(
|
||||||
|
createCard({ icon: '❌', content: event.message || 'Error', meta, type: 'observation' })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cards
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create cards for an action */
|
||||||
|
#createActionCards(
|
||||||
|
action: { name: string; input: unknown; output: string },
|
||||||
|
meta: string
|
||||||
|
): string[] {
|
||||||
|
const cards: string[] = []
|
||||||
|
|
||||||
|
if (action.name === 'done') {
|
||||||
|
const input = action.input as { text?: string }
|
||||||
|
const text = input.text || action.output || ''
|
||||||
|
if (text) {
|
||||||
|
cards.push(createCard({ icon: '🤖', content: text, meta, type: 'output' }))
|
||||||
|
}
|
||||||
|
} else if (action.name === 'ask_user') {
|
||||||
|
const input = action.input as { question?: string }
|
||||||
|
const answer = action.output.replace(/^User answered:\s*/i, '')
|
||||||
|
cards.push(
|
||||||
|
createCard({
|
||||||
|
icon: '❓',
|
||||||
|
content: `Question: ${input.question || ''}`,
|
||||||
|
meta,
|
||||||
|
type: 'question',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
cards.push(createCard({ icon: '💬', content: `Answer: ${answer}`, meta, type: 'input' }))
|
||||||
|
} else {
|
||||||
|
const toolText = this.#getToolExecutingText(action.name, action.input)
|
||||||
|
cards.push(createCard({ icon: '🔨', content: toolText, meta }))
|
||||||
|
if (action.output?.length > 0) {
|
||||||
|
cards.push(createCard({ icon: '🔨', content: action.output, meta, type: 'output' }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cards
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Card HTML generation utilities for Panel
|
||||||
|
*/
|
||||||
|
import { escapeHtml } from '../utils'
|
||||||
|
|
||||||
|
import styles from './Panel.module.css'
|
||||||
|
|
||||||
|
type CardType = 'default' | 'input' | 'output' | 'question' | 'observation'
|
||||||
|
|
||||||
|
interface CardOptions {
|
||||||
|
icon: string
|
||||||
|
content: string | string[]
|
||||||
|
meta?: string
|
||||||
|
type?: CardType
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a single history card */
|
||||||
|
export function createCard({ icon, content, meta, type }: CardOptions): string {
|
||||||
|
const typeClass = type ? styles[type] : ''
|
||||||
|
const contentHtml = Array.isArray(content)
|
||||||
|
? `<div class="${styles.reflectionLines}">${content.join('')}</div>`
|
||||||
|
: `<span>${escapeHtml(content)}</span>`
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="${styles.historyItem} ${typeClass}">
|
||||||
|
<div class="${styles.historyContent}">
|
||||||
|
<span class="${styles.statusIcon}">${icon}</span>
|
||||||
|
${contentHtml}
|
||||||
|
</div>
|
||||||
|
${meta ? `<div class="${styles.historyMeta}">${meta}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format timestamp for cards */
|
||||||
|
export function formatTime(locale: string = 'en-US'): string {
|
||||||
|
return new Date().toLocaleTimeString(locale, {
|
||||||
|
hour12: false,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create reflection lines from reflection object */
|
||||||
|
export function createReflectionLines(reflection: {
|
||||||
|
evaluation_previous_goal?: string
|
||||||
|
memory?: string
|
||||||
|
next_goal?: string
|
||||||
|
}): string[] {
|
||||||
|
const lines: string[] = []
|
||||||
|
if (reflection.evaluation_previous_goal) {
|
||||||
|
lines.push(`<div>🔍 ${escapeHtml(reflection.evaluation_previous_goal)}</div>`)
|
||||||
|
}
|
||||||
|
if (reflection.memory) {
|
||||||
|
lines.push(`<div>💾 ${escapeHtml(reflection.memory)}</div>`)
|
||||||
|
}
|
||||||
|
if (reflection.next_goal) {
|
||||||
|
lines.push(`<div>🎯 ${escapeHtml(reflection.next_goal)}</div>`)
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Agent activity - transient state for immediate UI feedback.
|
||||||
|
*
|
||||||
|
* Unlike historical events (which are persisted), activities are ephemeral
|
||||||
|
* and represent "what the agent is doing right now". UI components should
|
||||||
|
* listen to 'activity' events to show real-time feedback.
|
||||||
|
*
|
||||||
|
* Note: There is no 'idle' activity - absence of activity events means idle.
|
||||||
|
*
|
||||||
|
* Events dispatched: CustomEvent<AgentActivity>
|
||||||
|
*/
|
||||||
|
export type AgentActivity =
|
||||||
|
| { type: 'thinking' }
|
||||||
|
| { type: 'executing'; tool: string; input: unknown }
|
||||||
|
| { type: 'executed'; tool: string; input: unknown; output: string; duration: number }
|
||||||
|
| { type: 'retrying'; attempt: number; maxAttempts: number }
|
||||||
|
| { type: 'error'; message: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal interface that Panel expects from an agent.
|
||||||
|
* Panel does not depend on PageAgent directly - it only requires this interface.
|
||||||
|
* This enables decoupling and allows any agent implementation to work with Panel.
|
||||||
|
*
|
||||||
|
* Events:
|
||||||
|
* - 'statuschange': Agent status changed (idle/running/completed/error)
|
||||||
|
* - 'historychange': Historical events updated (persisted)
|
||||||
|
* - 'activity': Transient activity for immediate UI feedback (thinking/executing/etc)
|
||||||
|
* - 'dispose': Agent is being disposed
|
||||||
|
*/
|
||||||
|
export interface PanelAgentAdapter extends EventTarget {
|
||||||
|
/** Current agent status */
|
||||||
|
readonly status: 'idle' | 'running' | 'completed' | 'error'
|
||||||
|
|
||||||
|
/** History of agent events */
|
||||||
|
readonly history: readonly {
|
||||||
|
type: 'step' | 'observation' | 'user_takeover' | 'retry' | 'error'
|
||||||
|
stepIndex?: number
|
||||||
|
/** For 'step' type */
|
||||||
|
reflection?: {
|
||||||
|
evaluation_previous_goal?: string
|
||||||
|
memory?: string
|
||||||
|
next_goal?: string
|
||||||
|
}
|
||||||
|
/** For 'step' type */
|
||||||
|
action?: {
|
||||||
|
name: string
|
||||||
|
input: unknown
|
||||||
|
output: string
|
||||||
|
}
|
||||||
|
/** For 'observation' type */
|
||||||
|
content?: string
|
||||||
|
/** For 'retry' type */
|
||||||
|
attempt?: number
|
||||||
|
maxAttempts?: number
|
||||||
|
/** For 'retry' and 'error' types */
|
||||||
|
message?: string
|
||||||
|
}[]
|
||||||
|
|
||||||
|
/** Current task being executed */
|
||||||
|
readonly task: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for when agent needs user input.
|
||||||
|
* Panel will set this to handle user questions via its UI.
|
||||||
|
*/
|
||||||
|
onAskUser?: (question: string) => Promise<string>
|
||||||
|
|
||||||
|
/** Execute a task */
|
||||||
|
execute(task: string): Promise<unknown>
|
||||||
|
|
||||||
|
/** Dispose the agent */
|
||||||
|
dispose(): void
|
||||||
|
}
|
||||||
@@ -4,3 +4,15 @@ export function truncate(text: string, maxLength: number): string {
|
|||||||
}
|
}
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML special characters to prevent XSS and rendering issues
|
||||||
|
*/
|
||||||
|
export function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// @workaround DTS bug
|
// @workaround DTS bug
|
||||||
// dts do not work with monorepo path mapping
|
// dts do not work with monorepo path mapping
|
||||||
// disable path mapping for it
|
// disable path mapping for it
|
||||||
"paths": {}
|
"paths": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-11
@@ -1,13 +1,12 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
"allowImportingTsExtensions": false,
|
"allowImportingTsExtensions": false,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"outDir": "dist"
|
"outDir": "dist"
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts", "**/*.js"],
|
"include": ["**/*.ts", "**/*.js"],
|
||||||
"exclude": ["dist", "node_modules"]
|
"exclude": ["dist", "node_modules"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
outDir: resolve(__dirname, 'dist', 'lib'),
|
outDir: resolve(__dirname, 'dist', 'lib'),
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: ['ai-motion'],
|
external: [],
|
||||||
},
|
},
|
||||||
minify: false,
|
minify: false,
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Website Package - Instructions for Coding Assistants
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **React** with TypeScript
|
||||||
|
- **Vite** for dev server and build
|
||||||
|
- **Tailwind CSS** for styling
|
||||||
|
- **shadcn/ui** (new-york style) for UI components
|
||||||
|
- **Magic UI** for animations and effects
|
||||||
|
- **wouter** with hash routing for static hosting
|
||||||
|
- **lucide-react** for icons
|
||||||
|
|
||||||
|
## Component Guidelines
|
||||||
|
|
||||||
|
### Use shadcn/ui Components First
|
||||||
|
|
||||||
|
**ALWAYS prefer shadcn/ui components over custom implementations.**
|
||||||
|
|
||||||
|
Before creating any UI component, check if shadcn already provides it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# IMPORTANT: Run from packages/website/, NOT from repo root
|
||||||
|
cd packages/website
|
||||||
|
|
||||||
|
# Add a new shadcn component
|
||||||
|
npx shadcn@latest add <component-name>
|
||||||
|
|
||||||
|
# Add a Magic UI component
|
||||||
|
npx shadcn@latest add "@magicui/<component-name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Available shadcn components: https://ui.shadcn.com/docs/components
|
||||||
|
Available Magic UI components: https://magicui.design/docs/components
|
||||||
|
|
||||||
|
### Current UI Components
|
||||||
|
|
||||||
|
Located in `src/components/ui/`:
|
||||||
|
|
||||||
|
**From shadcn/ui:**
|
||||||
|
|
||||||
|
- `alert`, `badge`, `button`, `separator`, `sonner`, `switch`, `tooltip`
|
||||||
|
|
||||||
|
**From Magic UI:**
|
||||||
|
|
||||||
|
- `animated-gradient-text`, `animated-shiny-text`, `aurora-text`
|
||||||
|
- `hyper-text`, `magic-card`, `neon-gradient-card`, `particles`
|
||||||
|
- `sparkles-text`, `text-animate`, `typing-animation`
|
||||||
|
|
||||||
|
**Custom:**
|
||||||
|
|
||||||
|
- `highlighter`, `kbd`, `spinner`
|
||||||
|
|
||||||
|
### Styling Rules
|
||||||
|
|
||||||
|
1. **Prefer Tailwind classes** over custom CSS
|
||||||
|
2. Use CSS modules only for complex component-specific styles
|
||||||
|
3. Support dark mode via `dark:` classes
|
||||||
|
4. Use CSS variables from `src/index.css` for theme colors
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── pages/
|
||||||
|
│ ├── Home.tsx # Homepage
|
||||||
|
│ └── docs/
|
||||||
|
│ ├── Layout.tsx # Documentation sidebar
|
||||||
|
│ └── [section]/[topic]/page.tsx
|
||||||
|
├── components/
|
||||||
|
│ ├── ui/ # shadcn/ui + Magic UI components
|
||||||
|
│ ├── Header.tsx # Site header
|
||||||
|
│ └── Footer.tsx # Site footer
|
||||||
|
├── i18n/ # Internationalization
|
||||||
|
├── router.tsx # Central routing
|
||||||
|
└── main.tsx # App entry
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding New Pages
|
||||||
|
|
||||||
|
### Documentation Page
|
||||||
|
|
||||||
|
1. Create `src/pages/docs/<section>/<slug>/page.tsx`
|
||||||
|
2. Add route to `src/router.tsx` with `<Header /> + <DocsLayout>` wrapper
|
||||||
|
3. Add navigation item to `pages/docs/Layout.tsx`
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
|
||||||
|
Uses hash-based routing for static hosting:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Router } from 'wouter'
|
||||||
|
import { useHashLocation } from 'wouter/use-hash-location'
|
||||||
|
|
||||||
|
;<Router hook={useHashLocation}>{/* routes */}</Router>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| ----------------- | ----------------------- |
|
||||||
|
| `components.json` | shadcn/ui configuration |
|
||||||
|
| `vite.config.js` | Vite build settings |
|
||||||
|
| `tsconfig.json` | TypeScript config |
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start # Dev server (from root)
|
||||||
|
npm run build:website # Build website (from root)
|
||||||
|
```
|
||||||
@@ -1 +0,0 @@
|
|||||||
# Landing Page & Docs
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {
|
||||||
|
"@magicui": "https://magicui.design/r/{name}.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,42 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/website",
|
"name": "@page-agent/website",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.14",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build:website": "vite build",
|
"build:website": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.8",
|
||||||
"@types/react-dom": "^19.2.1",
|
"@types/react-dom": "^19.2.1",
|
||||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||||
"i18next": "^25.7.3",
|
"i18next": "^25.7.4",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-i18next": "^16.5.0",
|
"react-i18next": "^16.5.3",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"wouter": "^3.8.1"
|
"tw-animate-css": "^1.4.0",
|
||||||
}
|
"wouter": "^3.9.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.562.0",
|
||||||
|
"motion": "^12.27.1",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"rough-notation": "^0.5.1",
|
||||||
|
"simple-icons": "^16.6.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.4.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { siGithub } from 'simple-icons'
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
@@ -19,8 +20,13 @@ export default function Footer() {
|
|||||||
className="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-200"
|
className="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-200"
|
||||||
aria-label={t('footer.github_label')}
|
aria-label={t('footer.github_label')}
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
<svg
|
||||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
role="img"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="w-5 h-5 fill-current"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d={siGithub.path} />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import { BookOpen, Menu, X } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { siGithub } from 'simple-icons'
|
||||||
import { Link } from 'wouter'
|
import { Link } from 'wouter'
|
||||||
|
|
||||||
import LanguageSwitcher from './LanguageSwitcher'
|
import LanguageSwitcher from './LanguageSwitcher'
|
||||||
import ThemeSwitcher from './ThemeSwitcher'
|
import ThemeSwitcher from './ThemeSwitcher'
|
||||||
import { BookIcon, CloseIcon, GithubIcon, MenuIcon } from './icons'
|
import { HyperText } from './ui/hyper-text'
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
@@ -20,7 +22,7 @@ export default function Header() {
|
|||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="~/"
|
||||||
className="flex items-center gap-2 sm:gap-3 group shrink-0"
|
className="flex items-center gap-2 sm:gap-3 group shrink-0"
|
||||||
aria-label={t('header.logo_alt')}
|
aria-label={t('header.logo_alt')}
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
@@ -34,9 +36,15 @@ export default function Header() {
|
|||||||
<span className="text-base sm:text-xl font-bold text-gray-900 dark:text-white block leading-tight">
|
<span className="text-base sm:text-xl font-bold text-gray-900 dark:text-white block leading-tight">
|
||||||
page-agent
|
page-agent
|
||||||
</span>
|
</span>
|
||||||
<p className="hidden sm:block text-xs text-gray-600 dark:text-gray-300">
|
<HyperText
|
||||||
|
as="p"
|
||||||
|
className="hidden sm:block text-xs text-gray-600 dark:text-gray-300 py-0 font-normal overflow-visible"
|
||||||
|
duration={600}
|
||||||
|
animateOnHover={true}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
{t('header.slogan')}
|
{t('header.slogan')}
|
||||||
</p>
|
</HyperText>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -47,11 +55,11 @@ export default function Header() {
|
|||||||
aria-label="Mobile navigation"
|
aria-label="Mobile navigation"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href="/docs/introduction/overview"
|
href="~/docs/introduction/overview"
|
||||||
className="p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 shrink-0"
|
className="p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 shrink-0"
|
||||||
aria-label={t('header.nav_docs')}
|
aria-label={t('header.nav_docs')}
|
||||||
>
|
>
|
||||||
<BookIcon className="w-5 h-5" />
|
<BookOpen className="w-5 h-5" />
|
||||||
</Link>
|
</Link>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/alibaba/page-agent"
|
href="https://github.com/alibaba/page-agent"
|
||||||
@@ -60,7 +68,14 @@ export default function Header() {
|
|||||||
className="p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 shrink-0"
|
className="p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 shrink-0"
|
||||||
aria-label={t('header.nav_source')}
|
aria-label={t('header.nav_source')}
|
||||||
>
|
>
|
||||||
<GithubIcon className="w-5 h-5" />
|
<svg
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="w-5 h-5 fill-current"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d={siGithub.path} />
|
||||||
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -70,11 +85,14 @@ export default function Header() {
|
|||||||
role="navigation"
|
role="navigation"
|
||||||
aria-label={t('header.nav_docs')}
|
aria-label={t('header.nav_docs')}
|
||||||
>
|
>
|
||||||
|
<span className="text-xs font-mono text-gray-400 dark:text-gray-500 tabular-nums before:content-['v']">
|
||||||
|
{import.meta.env.VERSION}
|
||||||
|
</span>
|
||||||
<Link
|
<Link
|
||||||
href="/docs/introduction/overview"
|
href="~/docs/introduction/overview"
|
||||||
className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
||||||
>
|
>
|
||||||
<BookIcon />
|
<BookOpen className="w-4 h-4" />
|
||||||
{t('header.nav_docs')}
|
{t('header.nav_docs')}
|
||||||
</Link>
|
</Link>
|
||||||
<a
|
<a
|
||||||
@@ -84,7 +102,14 @@ export default function Header() {
|
|||||||
className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
||||||
aria-label={t('header.nav_source')}
|
aria-label={t('header.nav_source')}
|
||||||
>
|
>
|
||||||
<GithubIcon />
|
<svg
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="w-4 h-4 fill-current"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d={siGithub.path} />
|
||||||
|
</svg>
|
||||||
{t('header.nav_source')}
|
{t('header.nav_source')}
|
||||||
</a>
|
</a>
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
@@ -100,7 +125,7 @@ export default function Header() {
|
|||||||
aria-controls="mobile-menu"
|
aria-controls="mobile-menu"
|
||||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
>
|
>
|
||||||
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
|
{mobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -112,11 +137,11 @@ export default function Header() {
|
|||||||
role="navigation"
|
role="navigation"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href="/docs/introduction/overview"
|
href="~/docs/introduction/overview"
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
className="flex items-center gap-2 px-3 py-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
>
|
>
|
||||||
<BookIcon className="w-5 h-5" />
|
<BookOpen className="w-5 h-5" />
|
||||||
{t('header.nav_docs')}
|
{t('header.nav_docs')}
|
||||||
</Link>
|
</Link>
|
||||||
<a
|
<a
|
||||||
@@ -126,7 +151,14 @@ export default function Header() {
|
|||||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
className="flex items-center gap-2 px-3 py-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
||||||
aria-label={t('header.nav_source')}
|
aria-label={t('header.nav_source')}
|
||||||
>
|
>
|
||||||
<GithubIcon className="w-5 h-5" />
|
<svg
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="w-5 h-5 fill-current"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d={siGithub.path} />
|
||||||
|
</svg>
|
||||||
{t('header.nav_source')}
|
{t('header.nav_source')}
|
||||||
</a>
|
</a>
|
||||||
<div className="flex items-center gap-3 px-3 py-2">
|
<div className="flex items-center gap-3 px-3 py-2">
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
// SVG图标组件集合,用于Header等地方复用
|
|
||||||
|
|
||||||
interface IconProps {
|
|
||||||
className?: string
|
|
||||||
'aria-hidden'?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BookIcon({ className = 'w-4 h-4', ...props }: IconProps) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
className={className}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
aria-hidden="true"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GithubIcon({ className = 'w-4 h-4', ...props }: IconProps) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
className={className}
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
aria-hidden="true"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MenuIcon({ className = 'w-6 h-6', ...props }: IconProps) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
className={className}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
aria-hidden="true"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 6h16M4 12h16M4 18h16"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CloseIcon({ className = 'w-6 h-6', ...props }: IconProps) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
className={className}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
aria-hidden="true"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { type VariantProps, cva } from 'class-variance-authority'
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-card text-card-foreground',
|
||||||
|
destructive:
|
||||||
|
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { ComponentPropsWithoutRef } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface AnimatedGradientTextProps extends ComponentPropsWithoutRef<'div'> {
|
||||||
|
speed?: number
|
||||||
|
colorFrom?: string
|
||||||
|
colorTo?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnimatedGradientText({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
speed = 1,
|
||||||
|
colorFrom = '#ffaa40',
|
||||||
|
colorTo = '#9c40ff',
|
||||||
|
...props
|
||||||
|
}: AnimatedGradientTextProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--bg-size': `${speed * 300}%`,
|
||||||
|
'--color-from': colorFrom,
|
||||||
|
'--color-to': colorTo,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
`animate-gradient inline bg-gradient-to-r from-[var(--color-from)] via-[var(--color-to)] to-[var(--color-from)] bg-[length:var(--bg-size)_100%] bg-clip-text text-transparent`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { CSSProperties, ComponentPropsWithoutRef, FC } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface AnimatedShinyTextProps extends ComponentPropsWithoutRef<'span'> {
|
||||||
|
shimmerWidth?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AnimatedShinyText: FC<AnimatedShinyTextProps> = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
shimmerWidth = 100,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--shiny-width': `${shimmerWidth}px`,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'mx-auto max-w-md text-neutral-600/70 dark:text-neutral-400/70',
|
||||||
|
|
||||||
|
// Shine effect
|
||||||
|
'animate-shiny-text [background-size:var(--shiny-width)_100%] bg-clip-text [background-position:0_0] bg-no-repeat [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]',
|
||||||
|
|
||||||
|
// Shine gradient
|
||||||
|
'bg-gradient-to-r from-transparent via-black/80 via-50% to-transparent dark:via-white/80',
|
||||||
|
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* API Reference component for displaying TypeScript interface definitions
|
||||||
|
*
|
||||||
|
* Provides a beautiful, readable table for documenting API interfaces
|
||||||
|
*/
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
import { Badge } from './badge'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface PropDefinition {
|
||||||
|
/** Property name */
|
||||||
|
name: string
|
||||||
|
/** TypeScript type (can include generics, unions, etc.) */
|
||||||
|
type: string
|
||||||
|
/** Whether the property is required */
|
||||||
|
required?: boolean
|
||||||
|
/** Default value if any */
|
||||||
|
defaultValue?: string
|
||||||
|
/** Description of the property */
|
||||||
|
description: React.ReactNode
|
||||||
|
/** Mark as experimental/deprecated */
|
||||||
|
status?: 'experimental' | 'deprecated'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface APIReferenceProps {
|
||||||
|
/** Title for the API section */
|
||||||
|
title?: string
|
||||||
|
/** Optional description */
|
||||||
|
description?: React.ReactNode
|
||||||
|
/** Property definitions */
|
||||||
|
properties: PropDefinition[]
|
||||||
|
/** Additional CSS classes */
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function APIReference({ title, description, properties, className }: APIReferenceProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('my-6', className)}>
|
||||||
|
{title && (
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">{title}</h3>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">{description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50 dark:bg-gray-800/50">
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-300">
|
||||||
|
Property
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-300">
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-300 hidden md:table-cell">
|
||||||
|
Default
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-300">
|
||||||
|
Description
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
{properties.map((prop) => (
|
||||||
|
<PropRow key={prop.name} {...prop} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PropRow({ name, type, required, defaultValue, description, status }: PropDefinition) {
|
||||||
|
return (
|
||||||
|
<tr className="bg-white dark:bg-gray-900 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
||||||
|
{/* Property name */}
|
||||||
|
<td className="px-4 py-3 align-top">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<code className="font-mono text-sm font-medium text-indigo-600 dark:text-indigo-400">
|
||||||
|
{name}
|
||||||
|
</code>
|
||||||
|
{required && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] px-1.5 py-0 border-red-300 text-red-600 dark:border-red-800 dark:text-red-400"
|
||||||
|
>
|
||||||
|
required
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{status === 'experimental' && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] px-1.5 py-0 border-amber-300 text-amber-600 dark:border-amber-800 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
experimental
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{status === 'deprecated' && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] px-1.5 py-0 border-gray-300 text-gray-500 dark:border-gray-700 dark:text-gray-500 line-through"
|
||||||
|
>
|
||||||
|
deprecated
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Type */}
|
||||||
|
<td className="px-4 py-3 align-top">
|
||||||
|
<code className="font-mono text-xs text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded whitespace-nowrap">
|
||||||
|
{type}
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Default value */}
|
||||||
|
<td className="px-4 py-3 align-top hidden md:table-cell">
|
||||||
|
{defaultValue ? (
|
||||||
|
<code className="font-mono text-xs text-gray-600 dark:text-gray-400">{defaultValue}</code>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 dark:text-gray-600">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<td className="px-4 py-3 align-top text-gray-600 dark:text-gray-400">{description}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utility Components
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Code inline span for type references in descriptions */
|
||||||
|
export function TypeRef({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<code className="font-mono text-xs text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-950/50 px-1 py-0.5 rounded">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Section divider for grouping related APIs */
|
||||||
|
export function APIDivider({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4 my-8">
|
||||||
|
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-gray-200 dark:via-gray-700 to-transparent" />
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-gray-200 dark:via-gray-700 to-transparent" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import React, { memo } from 'react'
|
||||||
|
|
||||||
|
interface AuroraTextProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
colors?: string[]
|
||||||
|
speed?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuroraText = memo(
|
||||||
|
({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
colors = ['#FF0080', '#7928CA', '#0070F3', '#38bdf8'],
|
||||||
|
speed = 1,
|
||||||
|
}: AuroraTextProps) => {
|
||||||
|
const gradientStyle = {
|
||||||
|
backgroundImage: `linear-gradient(135deg, ${colors.join(', ')}, ${colors[0]})`,
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
animationDuration: `${10 / speed}s`,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`relative inline-block ${className}`}>
|
||||||
|
<span className="sr-only">{children}</span>
|
||||||
|
<span
|
||||||
|
className="animate-aurora relative bg-[length:200%_auto] bg-clip-text text-transparent"
|
||||||
|
style={gradientStyle}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
AuroraText.displayName = 'AuroraText'
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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 badgeVariants = cva(
|
||||||
|
'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-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 transition-[color,box-shadow] overflow-hidden',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||||
|
secondary:
|
||||||
|
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||||
|
destructive:
|
||||||
|
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||||
|
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : 'span'
|
||||||
|
|
||||||
|
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { ArrowRightIcon } from '@radix-ui/react-icons'
|
||||||
|
import { ComponentPropsWithoutRef, ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface BentoGridProps extends ComponentPropsWithoutRef<'div'> {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BentoCardProps extends ComponentPropsWithoutRef<'div'> {
|
||||||
|
name: string
|
||||||
|
className: string
|
||||||
|
background: ReactNode
|
||||||
|
Icon: React.ElementType
|
||||||
|
description: string
|
||||||
|
href: string
|
||||||
|
cta: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BentoGrid = ({ children, className, ...props }: BentoGridProps) => {
|
||||||
|
return (
|
||||||
|
<div className={cn('grid w-full auto-rows-[22rem] grid-cols-3 gap-4', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const BentoCard = ({
|
||||||
|
name,
|
||||||
|
className,
|
||||||
|
background,
|
||||||
|
Icon,
|
||||||
|
description,
|
||||||
|
href,
|
||||||
|
cta,
|
||||||
|
...props
|
||||||
|
}: BentoCardProps) => (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
className={cn(
|
||||||
|
'group relative col-span-3 flex flex-col justify-between overflow-hidden rounded-xl',
|
||||||
|
// light styles
|
||||||
|
'bg-background [box-shadow:0_0_0_1px_rgba(0,0,0,.03),0_2px_4px_rgba(0,0,0,.05),0_12px_24px_rgba(0,0,0,.05)]',
|
||||||
|
// dark styles
|
||||||
|
'dark:bg-background transform-gpu dark:[box-shadow:0_-20px_80px_-20px_#ffffff1f_inset] dark:[border:1px_solid_rgba(255,255,255,.1)]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div>{background}</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="pointer-events-none z-10 flex transform-gpu flex-col gap-1 transition-all duration-300 lg:group-hover:-translate-y-10">
|
||||||
|
<Icon className="h-12 w-12 origin-left transform-gpu text-neutral-700 transition-all duration-300 ease-in-out group-hover:scale-75" />
|
||||||
|
<h3 className="text-xl font-semibold text-neutral-700 dark:text-neutral-300">{name}</h3>
|
||||||
|
<p className="max-w-lg text-neutral-400">{description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none flex w-full translate-y-0 transform-gpu flex-row items-center transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100 lg:hidden'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button variant="link" asChild size="sm" className="pointer-events-auto p-0">
|
||||||
|
<a href={href}>
|
||||||
|
{cta}
|
||||||
|
<ArrowRightIcon className="ms-2 h-4 w-4 rtl:rotate-180" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute bottom-0 hidden w-full translate-y-10 transform-gpu flex-row items-center p-4 opacity-0 transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100 lg:flex'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button variant="link" asChild size="sm" className="pointer-events-auto p-0">
|
||||||
|
<a href={href}>
|
||||||
|
{cta}
|
||||||
|
<ArrowRightIcon className="ms-2 h-4 w-4 rtl:rotate-180" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pointer-events-none absolute inset-0 transform-gpu transition-all duration-300 group-hover:bg-black/[.03] group-hover:dark:bg-neutral-800/10" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export { BentoCard, BentoGrid }
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
AnimatePresence,
|
||||||
|
MotionProps,
|
||||||
|
UseInViewOptions,
|
||||||
|
Variants,
|
||||||
|
motion,
|
||||||
|
useInView,
|
||||||
|
} from 'motion/react'
|
||||||
|
import { useRef } from 'react'
|
||||||
|
|
||||||
|
type MarginType = UseInViewOptions['margin']
|
||||||
|
|
||||||
|
interface BlurFadeProps extends MotionProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
variant?: {
|
||||||
|
hidden: { y: number }
|
||||||
|
visible: { y: number }
|
||||||
|
}
|
||||||
|
duration?: number
|
||||||
|
delay?: number
|
||||||
|
offset?: number
|
||||||
|
direction?: 'up' | 'down' | 'left' | 'right'
|
||||||
|
inView?: boolean
|
||||||
|
inViewMargin?: MarginType
|
||||||
|
blur?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BlurFade({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
duration = 0.4,
|
||||||
|
delay = 0,
|
||||||
|
offset = 6,
|
||||||
|
direction = 'down',
|
||||||
|
inView = false,
|
||||||
|
inViewMargin = '-50px',
|
||||||
|
blur = '6px',
|
||||||
|
...props
|
||||||
|
}: BlurFadeProps) {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const inViewResult = useInView(ref, { once: true, margin: inViewMargin })
|
||||||
|
const isInView = !inView || inViewResult
|
||||||
|
const defaultVariants: Variants = {
|
||||||
|
hidden: {
|
||||||
|
[direction === 'left' || direction === 'right' ? 'x' : 'y']:
|
||||||
|
direction === 'right' || direction === 'down' ? -offset : offset,
|
||||||
|
opacity: 0,
|
||||||
|
filter: `blur(${blur})`,
|
||||||
|
},
|
||||||
|
visible: {
|
||||||
|
[direction === 'left' || direction === 'right' ? 'x' : 'y']: 0,
|
||||||
|
opacity: 1,
|
||||||
|
filter: `blur(0px)`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const combinedVariants = variant || defaultVariants
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
initial="hidden"
|
||||||
|
animate={isInView ? 'visible' : 'hidden'}
|
||||||
|
exit="hidden"
|
||||||
|
variants={combinedVariants}
|
||||||
|
transition={{
|
||||||
|
delay: 0.04 + delay,
|
||||||
|
duration,
|
||||||
|
ease: 'easeOut',
|
||||||
|
}}
|
||||||
|
className={className}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,92 @@
|
|||||||
|
import { useInView } from 'motion/react'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import type React from 'react'
|
||||||
|
import { annotate } from 'rough-notation'
|
||||||
|
import { type RoughAnnotation } from 'rough-notation/lib/model'
|
||||||
|
|
||||||
|
type AnnotationAction =
|
||||||
|
| 'highlight'
|
||||||
|
| 'underline'
|
||||||
|
| 'box'
|
||||||
|
| 'circle'
|
||||||
|
| 'strike-through'
|
||||||
|
| 'crossed-off'
|
||||||
|
| 'bracket'
|
||||||
|
|
||||||
|
interface HighlighterProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
action?: AnnotationAction
|
||||||
|
color?: string
|
||||||
|
strokeWidth?: number
|
||||||
|
animationDuration?: number
|
||||||
|
iterations?: number
|
||||||
|
padding?: number
|
||||||
|
multiline?: boolean
|
||||||
|
isView?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Highlighter({
|
||||||
|
children,
|
||||||
|
action = 'highlight',
|
||||||
|
color = '#ffd1dc',
|
||||||
|
strokeWidth = 1.5,
|
||||||
|
animationDuration = 600,
|
||||||
|
iterations = 2,
|
||||||
|
padding = 2,
|
||||||
|
multiline = true,
|
||||||
|
isView = false,
|
||||||
|
}: HighlighterProps) {
|
||||||
|
const elementRef = useRef<HTMLSpanElement>(null)
|
||||||
|
const annotationRef = useRef<RoughAnnotation | null>(null)
|
||||||
|
|
||||||
|
const isInView = useInView(elementRef, {
|
||||||
|
once: true,
|
||||||
|
margin: '-10%',
|
||||||
|
})
|
||||||
|
|
||||||
|
// If isView is false, always show. If isView is true, wait for inView
|
||||||
|
const shouldShow = !isView || isInView
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldShow) return
|
||||||
|
|
||||||
|
const element = elementRef.current
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
|
const annotationConfig = {
|
||||||
|
type: action,
|
||||||
|
color,
|
||||||
|
strokeWidth,
|
||||||
|
animationDuration,
|
||||||
|
iterations,
|
||||||
|
padding,
|
||||||
|
multiline,
|
||||||
|
}
|
||||||
|
|
||||||
|
const annotation = annotate(element, annotationConfig)
|
||||||
|
|
||||||
|
annotationRef.current = annotation
|
||||||
|
annotationRef.current.show()
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
annotation.hide()
|
||||||
|
annotation.show()
|
||||||
|
})
|
||||||
|
|
||||||
|
resizeObserver.observe(element)
|
||||||
|
resizeObserver.observe(document.body)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (element) {
|
||||||
|
annotate(element, { type: action }).remove()
|
||||||
|
resizeObserver.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [shouldShow, action, color, strokeWidth, animationDuration, iterations, padding, multiline])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span ref={elementRef} className="relative inline-block bg-transparent">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { AnimatePresence, MotionProps, motion } from 'motion/react'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type CharacterSet = string[] | readonly string[]
|
||||||
|
|
||||||
|
interface HyperTextProps extends MotionProps {
|
||||||
|
/** The text content to be animated */
|
||||||
|
children: string
|
||||||
|
/** Optional className for styling */
|
||||||
|
className?: string
|
||||||
|
/** Duration of the animation in milliseconds */
|
||||||
|
duration?: number
|
||||||
|
/** Delay before animation starts in milliseconds */
|
||||||
|
delay?: number
|
||||||
|
/** Component to render as - defaults to div */
|
||||||
|
as?: React.ElementType
|
||||||
|
/** Whether to start animation when element comes into view */
|
||||||
|
startOnView?: boolean
|
||||||
|
/** Whether to trigger animation on hover */
|
||||||
|
animateOnHover?: boolean
|
||||||
|
/** Custom character set for scramble effect. Defaults to uppercase alphabet */
|
||||||
|
characterSet?: CharacterSet
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CHARACTER_SET = Object.freeze(
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
||||||
|
) as readonly string[]
|
||||||
|
|
||||||
|
const getRandomInt = (max: number): number => Math.floor(Math.random() * max)
|
||||||
|
|
||||||
|
export function HyperText({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
duration = 800,
|
||||||
|
delay = 0,
|
||||||
|
as: Component = 'div',
|
||||||
|
startOnView = false,
|
||||||
|
animateOnHover = true,
|
||||||
|
characterSet = DEFAULT_CHARACTER_SET,
|
||||||
|
...props
|
||||||
|
}: HyperTextProps) {
|
||||||
|
const MotionComponent = motion.create(Component, {
|
||||||
|
forwardMotionProps: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [displayText, setDisplayText] = useState<string[]>(() => children.split(''))
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false)
|
||||||
|
const iterationCount = useRef(0)
|
||||||
|
const elementRef = useRef<HTMLElement>(null)
|
||||||
|
|
||||||
|
const handleAnimationTrigger = () => {
|
||||||
|
if (animateOnHover && !isAnimating) {
|
||||||
|
iterationCount.current = 0
|
||||||
|
setIsAnimating(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle animation start based on view or delay
|
||||||
|
useEffect(() => {
|
||||||
|
if (!startOnView) {
|
||||||
|
const startTimeout = setTimeout(() => {
|
||||||
|
setIsAnimating(true)
|
||||||
|
}, delay)
|
||||||
|
return () => clearTimeout(startTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsAnimating(true)
|
||||||
|
}, delay)
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1, rootMargin: '-30% 0px -30% 0px' }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (elementRef.current) {
|
||||||
|
observer.observe(elementRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [delay, startOnView])
|
||||||
|
|
||||||
|
// Handle scramble animation
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAnimating) return
|
||||||
|
|
||||||
|
const maxIterations = children.length
|
||||||
|
const startTime = performance.now()
|
||||||
|
let animationFrameId: number
|
||||||
|
|
||||||
|
const animate = (currentTime: number) => {
|
||||||
|
const elapsed = currentTime - startTime
|
||||||
|
const progress = Math.min(elapsed / duration, 1)
|
||||||
|
|
||||||
|
iterationCount.current = progress * maxIterations
|
||||||
|
|
||||||
|
setDisplayText((currentText) =>
|
||||||
|
currentText.map((letter, index) =>
|
||||||
|
letter === ' '
|
||||||
|
? letter
|
||||||
|
: index <= iterationCount.current
|
||||||
|
? children[index]
|
||||||
|
: characterSet[getRandomInt(characterSet.length)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
animationFrameId = requestAnimationFrame(animate)
|
||||||
|
} else {
|
||||||
|
setIsAnimating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(animate)
|
||||||
|
|
||||||
|
return () => cancelAnimationFrame(animationFrameId)
|
||||||
|
}, [children, duration, isAnimating, characterSet])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MotionComponent
|
||||||
|
ref={elementRef}
|
||||||
|
className={cn('overflow-hidden py-2 text-4xl font-bold', className)}
|
||||||
|
onMouseEnter={handleAnimationTrigger}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<AnimatePresence>
|
||||||
|
{displayText.map((letter, index) => (
|
||||||
|
<motion.span key={index} className={cn('font-mono', letter === ' ' ? 'w-3' : '')}>
|
||||||
|
{letter.toUpperCase()}
|
||||||
|
</motion.span>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</MotionComponent>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd"
|
||||||
|
className={cn(
|
||||||
|
'bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none',
|
||||||
|
"[&_svg:not([class*='size-'])]:size-3",
|
||||||
|
'[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd-group"
|
||||||
|
className={cn('inline-flex items-center gap-1', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Kbd, KbdGroup }
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { motion, useMotionTemplate, useMotionValue } from 'motion/react'
|
||||||
|
import React, { useCallback, useEffect } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface MagicCardProps {
|
||||||
|
children?: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
gradientSize?: number
|
||||||
|
gradientColor?: string
|
||||||
|
gradientOpacity?: number
|
||||||
|
gradientFrom?: string
|
||||||
|
gradientTo?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MagicCard({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
gradientSize = 200,
|
||||||
|
gradientColor = '#262626',
|
||||||
|
gradientOpacity = 0.8,
|
||||||
|
gradientFrom = '#9E7AFF',
|
||||||
|
gradientTo = '#FE8BBB',
|
||||||
|
}: MagicCardProps) {
|
||||||
|
const mouseX = useMotionValue(-gradientSize)
|
||||||
|
const mouseY = useMotionValue(-gradientSize)
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
mouseX.set(-gradientSize)
|
||||||
|
mouseY.set(-gradientSize)
|
||||||
|
}, [gradientSize, mouseX, mouseY])
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback(
|
||||||
|
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
mouseX.set(e.clientX - rect.left)
|
||||||
|
mouseY.set(e.clientY - rect.top)
|
||||||
|
},
|
||||||
|
[mouseX, mouseY]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reset()
|
||||||
|
}, [reset])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleGlobalPointerOut = (e: PointerEvent) => {
|
||||||
|
if (!e.relatedTarget) {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVisibility = () => {
|
||||||
|
if (document.visibilityState !== 'visible') {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('pointerout', handleGlobalPointerOut)
|
||||||
|
window.addEventListener('blur', reset)
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointerout', handleGlobalPointerOut)
|
||||||
|
window.removeEventListener('blur', reset)
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibility)
|
||||||
|
}
|
||||||
|
}, [reset])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('group relative rounded-[inherit]', className)}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerLeave={reset}
|
||||||
|
onPointerEnter={reset}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="bg-border pointer-events-none absolute inset-0 rounded-[inherit] duration-300 group-hover:opacity-100"
|
||||||
|
style={{
|
||||||
|
background: useMotionTemplate`
|
||||||
|
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px,
|
||||||
|
${gradientFrom},
|
||||||
|
${gradientTo},
|
||||||
|
var(--border) 100%
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="bg-background absolute inset-px rounded-[inherit]" />
|
||||||
|
<motion.div
|
||||||
|
className="pointer-events-none absolute inset-px rounded-[inherit] opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||||
|
style={{
|
||||||
|
background: useMotionTemplate`
|
||||||
|
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px, ${gradientColor}, transparent 100%)
|
||||||
|
`,
|
||||||
|
opacity: gradientOpacity,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="relative">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user