feat: init

This commit is contained in:
Simon
2025-09-29 16:33:15 +08:00
parent e8041e0582
commit 847620b5e8
98 changed files with 20166 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
---
globs: *.ts,*.tsx,*.js,*.cjs,*.mjs
description: Build, lint, and env guidance for this repo
---
### Build and env
- **Dev**: `npm run dev` starts Vite with base `./` and React SWC, Tailwind v4 plugin. Hash routing is required; use `useHashLocation`.
- **App build**: `npm run build` runs TypeScript build then Vite static build to `dist/` (site) and then `npm run build:lib`.
- **Library build**: `vite.lib.config.ts` builds UMD and ES to `dist/lib/` with entry `src/entry.ts`, file name `page-agent`.
- **Env injection**: `vite.config.ts` defines `import.meta.env.OPEN_ROUTER_*` from real env. When adding new env vars, add them under `define` in `vite.config.ts` and reference via `import.meta.env.*`.
- **Base path**: keep `base: './'` in both dev and build to make hash routing work under static hosting. If you change it, review `pages/main.tsx` URL logic.
### ESLint & TypeScript
- **Lint**: Config in `eslint.config.js` uses TypeScript ESLint with typeaware rules, React plugins, and relaxed unsafe checks for rapid iteration. Run `npm run lint` or rely on lintstaged.
- **TypeScript**: `tsconfig.app.json` is strict with `noEmit` and bundler resolution. Use `@/*` alias per config. Prefer explicit types on exported functions and public classes.
### Output hygiene
- Never commit changes under `dist/` manually. If you need to test CDN/UMD output locally, run library build and open `dist/index.html` or consume `dist/lib/page-agent.umd.cjs`.
- Do not import from `dist/` in source files. Always import from `src/`.
### Routing & deploy gotchas
- This site is SPA with hash routing. Ensure hosting serves `index.html` and does not rewrite hash routes.
- When embedding via CDN bookmarklet, the script is `dist/lib/page-agent.umd.cjs`. Query param `?model=` is parsed in `src/entry.ts`.

View File

@@ -0,0 +1,67 @@
---
alwaysApply: true
---
### Project structure and navigation map
- **Library entry (CDN/UMD)**: Public script entry is `src/entry.ts`. It attaches `PageAgent` to `window` and auto-initializes if injected via a `<script>` tag. Library build outputs to `dist/lib/` with file name `page-agent.*`.
- **Main Page entry**: `pages/main.tsx` mounts a `wouter` `Router` with `useHashLocation`. Routes delegate to:
- `pages/router.tsx` for docs/marketing pages
- `pages/test-pages/router.tsx` for internal test pages under `#/test-pages`
- **Docs layout**: Use `pages/components/DocsLayout.tsx` for all docs pages. When adding a new doc page, add it to:
- the navigation in `DocsLayout.tsx`, and
- the switch in `pages/router.tsx` with `<Header /> + <DocsLayout>...` wrapper
- **Hash routing**: Always use hash routes (e.g. `#/docs/...`). The `Router` is created as:
- `createRoot(...).render(<Router hook={useHashLocation}> ...)`
- Base URL in dev is `/`; otherwise computed relative to `index.html`. Do not change `vite.config.ts` base from `./` without updating `pages/main.tsx`.
- **Aliases**: Use `@/` as alias for `src/` and `@pages/` as alias for `pages/` (set in both Vite configs). Prefer absolute alias imports over deep relative chains.
- **Styling**: Tailwind v4 is enabled via `@tailwindcss/vite`. Use semantic utility classes; avoid adhoc inline styles.
- **Do not edit generated files**: Never edit anything under `dist/`. Add sources under `src/` and let builds emit artifacts.
- **Event bus**: Type-safe event system for decoupling components, primarily used for PageAgent-UI communication.
### Conventions
- **Routing (wouter)**:
- Prefer `<Route path="/segment" component={Page} />` for simple pages; use children form when wrapping with layout.
- Keep 404 as the final catchall `Route` in `pages/router.tsx`.
- For test pages, mount under `#/test-pages` only.
- **Docs pages**: Place in `pages/docs/**/page.tsx`. Export a default React component. Pages are rendered inside `DocsLayout` and must be linked in the sidebar.
- **Typescript**:
- TS strict is enabled; however, ESLint relaxes many unsafe rules for DX. Keep exported/public APIs typed explicitly.
- JSX runtime is `react-jsx` (React 19).
- **Global objects**: The library exposes `window.PageAgent` and `window.pageAgent`. Guard reinjection by disposing `window.pageAgent` before recreating, as done in `src/entry.ts`.
### Core modules (src/)
- **PageAgent.ts**: Core agent implementation and public API
- **entry.ts**: Library entry point for CDN/UMD usage
- **config/**: Configuration constants and settings management
- **tools/**: Agent tool implementations for web actions and page manipulation
- **ui/**: User interface components (Panel, SimulatorMask, etc.) for agent visualization
- **llms/**: LLM integration and communication layer
- **dom/**: HTML serialization, page analysis utilities, and DOM manipulation helpers
- **utils/**: Shared utilities including the type-safe event bus system
- **patches/**: Framework-specific optimizations and compatibility fixes (React, Antd, etc.)
- **prompts/**: System prompts and LLM instruction templates
- **i18n/**: Internationalization and language support
### Site structure (pages/)
- **pages/**: Documentation site and marketing pages
- **pages/test-pages/**: Demo pages for testing agent capabilities
### When adding features
- New docs page: create `pages/docs/<section>/<slug>/page.tsx`, add route and sidebar link.
- New tool: implement under `src/tools/`, export via `src/tools/index.ts`, wire into `PageAgent` if needed.
- Visual tweaks: edit under `src/ui/` and keep styles in colocated CSS modules.

1
.cursorignore Normal file
View File

@@ -0,0 +1 @@
.env

214
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,214 @@
# page-agent AI Coding Instructions
## Project Overview
This project has **TWO SEPARATE PARTS**:
1. **Core Library** (`src/`) - Pure JavaScript AI agent library that can be injected into any webpage
2. **Marketing Website** (`pages/`) - React web app for landing page and documentation
## Architecture & Tech Stack
### Core Library (`src/`) - Pure JavaScript AI Agent
- **Technology**: Vanilla JavaScript/TypeScript (no React dependency)
- **Purpose**: AI agent with DOM automation capabilities
- **Output**: UMD/ES modules via `vite.lib.config.ts`
- **Key Features**: DOM processing, LLM integration, type-safe event bus
### Marketing Website (`pages/`) - React Web App
- **Technology**: React 19 + TypeScript + Wouter routing + Tailwind CSS v4
- **Purpose**: Landing page and documentation site
- **Output**: Static website via `vite.config.ts`
- **Usage**: Hosted documentation and demos
## Critical Development Workflows
### Dual Build System - Two Separate Projects
```bash
npm start # React website (pages/) development
npm run build # Build BOTH library AND website
npm run build:lib # Pure JS library only (src/ → dist/lib/)
npm run build:lib:watch # Library development with auto-rebuild
```
### Library Development (Pure JavaScript)
**Working on the core AI agent (`src/`):**
1. Edit pure JavaScript/TypeScript files in `src/`
2. Use `npm run build:lib:watch` for development
3. Test via CDN injection: `<script src="dist/lib/page-agent.umd.js"></script>`
4. No React dependencies allowed in `src/`
### Website Development (React)
**Working on marketing/docs (`pages/`):**
1. Use `npm start` for React development server
2. Can import from `src/` via `@/` alias to demo library features
3. Create test pages in `pages/test-pages/` to validate library integration
### DOM Pipeline
1. **DOM Extraction**: `src/dom/` converts live DOM to `FlatDomTree`
2. **Dehydration**: DOM tree → simplified text representation for LLM
3. **LLM Processing**: Send text to AI model for action planning
4. **Indexed Operations**: Map LLM responses back to specific DOM elements via indices
### Event Bus Communication
Use `src/utils/bus.ts` instead of prop drilling for PageAgent ↔ UI communication:
```typescript
// Emit events from PageAgent
getEventBus().emit('panel:show', undefined)
getEventBus().emit('panel:update', { status: 'thinking', message: 'Processing...' })
// Listen in UI components
getEventBus().on('panel:show', () => panel.show())
```
## Key Patterns & Conventions
### Strict Module Boundaries
- **Core library** (`src/`): Never import from `pages/` - must remain pure JavaScript
- **React website** (`pages/`): Can import from `src/` via `@/` alias to demo features
- **Entry points**: `src/entry.ts` (CDN/UMD), `pages/main.tsx` (React website)
### Manual Route Registration Pattern
Routes in `pages/router.tsx` require explicit definition:
```tsx
<Route path="/docs/features/dom-operations">
<div className="min-h-screen bg-white dark:bg-gray-900">
<Header />
<DocsLayout>
<DomOperations />
</DocsLayout>
</div>
</Route>
```
**Adding new docs pages**: 1) Add route to `router.tsx`, 2) Add nav item to `DocsLayout.tsx`
### Hash Routing Requirement
Uses wouter with `useHashLocation` for static hosting compatibility:
```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.
### CSS & Design System
- **Prefer Tailwind CSS over custom CSS** - Use utility classes for styling
- Custom CSS variables in `src/index.css` define theme gradients:
```css
--theme-color-1: rgb(88, 192, 252);
--theme-color-2: rgb(189, 69, 251);
```
- Design follows modern SaaS aesthetic with blue/purple gradients
- **Accessibility**: Ensure proper contrast ratios, semantic HTML, ARIA labels
- Dark mode support throughout via Tailwind `dark:` classes
- Responsive grid layouts for features and content sections
## File Organization
### Core Library (`src/`)
- **PageAgent.ts**: Core agent implementation and public API
- **entry.ts**: Library entry point for CDN/UMD usage
- **config/**: Configuration constants and settings management
- **tools/**: Agent tool implementations for web actions and page manipulation
- **ui/**: User interface components (Panel, SimulatorMask, etc.) for agent visualization
- **llms/**: LLM integration and communication layer
- **dom/**: HTML serialization, page analysis utilities, and DOM manipulation helpers
- **utils/**: Shared utilities including the type-safe event bus system
- **patches/**: Framework-specific optimizations and compatibility fixes (React, Antd, etc.)
- **prompts/**: System prompts and LLM instruction templates
- **i18n/**: Internationalization and language support
### Documentation Site (`pages/`)
- `main.tsx` - Site entry point with hash routing setup
- `router.tsx` - Manual route definitions (requires explicit registration)
- `components/DocsLayout.tsx` - Navigation structure (hardcoded nav items)
- `docs/[section]/[topic]/page.tsx` - Documentation pages
- `test-pages/` - Library integration test pages
## Development Workflow
### Commands
- `npm start` - Development server (Vite)
- `npm run build` - Production build (TypeScript check + Vite build)
- `npm run lint` - ESLint with strict TypeScript rules
### Debugging Routing Issues
**Common problem**: Blank pages in docs sections. Debug steps:
1. Verify route exists in `pages/router.tsx`
2. Check component import path
3. Test with minimal component first
4. Verify CSS isn't hiding content (check dark mode classes)
### TypeScript Configuration
- Strict mode enabled with `noUnusedLocals` and `noUnusedParameters`
- Path alias `@/*` maps to `src/*`
- ESLint uses strict TypeScript rules but disables some for pragmatism:
```js
'@typescript-eslint/no-non-null-assertion': 'off'
'@typescript-eslint/no-unsafe-assignment': 'off'
```
## Content & Messaging
- **Tone**: Technical but approachable, targeting web developers
- **Core value prop**: "一行 CDN 引入,为任何网站添加智能 UI Agent"
- **Differentiator**: Page-embedded vs external automation (vs browser-use)
- Feature categories: DOM operations, security, data masking, knowledge injection, model integration
## Code Quality Standards
- Use TypeScript strict mode
- Prefer functional components with hooks
- **Prefer Tailwind CSS over custom CSS** - Use utility classes for styling
- Consistent file naming: kebab-case for multi-word files
- Import organization: React imports first, then components, then styles
## Critical Files to Understand
- `pages/router.tsx` - Central routing definition
- `pages/components/DocsLayout.tsx` - Navigation structure
- `pages/page.tsx` - Homepage showcasing product features
- `src/PageAgent.ts` - **Core library**: AI agent class with DOM manipulation
- `src/dom/dom_tree/index.js` - **DOM extraction engine** (ported from Python)
- `src/utils/bus.ts` - **Type-safe event bus** for decoupled communication
- `src/entry.ts` - CDN/UMD entry point with auto-initialization
- `vite.config.ts` and `vite.lib.config.ts` - Dual build configuration
- `tsconfig.app.json` - TypeScript strictness settings
## Core Library Architecture
The core library (`src/`) is a standalone AI agent with these key components:
- **PageAgent.ts**: Main agent class managing DOM tree updates, element highlighting, and LLM communication
- **DOM processing pipeline**: Converts complex web pages into LLM-friendly text while preserving interactive element mappings for precise actions
- **Event bus system**: Type-safe communication between PageAgent and UI components

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
# /lib
dist-ssr
*.local
# Editor directories and files
# .vscode/*
# !.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
#
.env

1
.husky/commit-msg Normal file
View File

@@ -0,0 +1 @@
npx --no -- commitlint --edit $1

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npx lint-staged

3
.markdownlint.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "markdownlint/style/relaxed"
}

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
**/test-pages

16
.prettierrc Normal file
View File

@@ -0,0 +1,16 @@
{
"singleQuote": true,
"semi": false,
"useTabs": true,
"printWidth": 100,
"trailingComma": "es5",
"plugins": ["@trivago/prettier-plugin-sort-imports"],
"importOrder": [
"<THIRD_PARTY_MODULES>",
"^(@/).*(?<!css)$",
"^[./].*(?<!css)$",
".css$"
],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true
}

4
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"editor.fontLigatures": true,
"cSpell.words": ["retryable"]
}

158
CLAUDE.md Normal file
View File

@@ -0,0 +1,158 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a dual-architecture project with **two separate parts**:
1. **Core Library** (`src/`) - Pure JavaScript/TypeScript AI agent library for browser DOM automation
2. **Marketing Website** (`pages/`) - React documentation and landing page
## Development Commands
### Core Commands
```bash
npm start # Start React website development server
npm run build # Build both library AND website
npm run build:lib # Build pure JS library only (src/ → dist/lib/)
npm run build:lib:watch # Library development with auto-rebuild
npm run lint # ESLint with TypeScript strict rules
```
### Development Workflows
- **Library development**: Use `npm run build:lib:watch` while editing `src/`
- **Website development**: Use `npm start` while editing `pages/`
- **Testing library**: Inject `dist/lib/page-agent.umd.js` via script tag
## Architecture & Critical Patterns
### Dual Build System
- **Website build**: `vite.config.ts` → React SPA with hash routing → `dist/`
- **Library build**: `vite.lib.config.ts` → UMD/ES modules → `dist/lib/`
- **Entry points**: `src/entry.ts` (library), `pages/main.tsx` (website)
### Module Boundaries (Critical)
- **Core library** (`src/`): NEVER import from `pages/` - must remain pure JavaScript
- **Website** (`pages/`): CAN import from `src/` via `@/` alias for demos
- **Import aliases**: `@/``src/`, `@pages/``pages/`
### DOM Pipeline
1. **DOM Extraction**: Convert live DOM to `FlatDomTree` via `src/dom/dom_tree/`
2. **Dehydration**: DOM tree → simplified text for LLM processing
3. **LLM Processing**: AI model returns action plans
4. **Indexed Operations**: Map LLM responses back to specific DOM elements
### Event Bus Communication
Use `src/utils/bus.ts` for decoupled PageAgent ↔ UI communication:
```typescript
// Emit from PageAgent
getEventBus().emit('panel:show', undefined)
getEventBus().emit('panel:update', { status: 'thinking' })
// Listen in UI components
getEventBus().on('panel:show', () => panel.show())
```
### 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`.
## File Organization
### Core Library (`src/`)
- `entry.ts` - CDN/UMD entry point with auto-initialization
- `PageAgent.ts` - **Main AI agent class** orchestrating DOM operations
- `tools/` - Agent tool implementations for web actions
- `ui/` - UI components (Panel, SimulatorMask) with CSS modules
- `utils/bus.ts` - **Type-safe event bus** for decoupled communication
- `patches/` - Framework-specific optimizations (React, Antd compatibility)
- `llms/` - LLM integration and communication layer
- `dom/` - HTML serialization and page analysis utilities
- `config/` - Configuration constants and settings
### Website (`pages/`)
- `main.tsx` - Site entry with hash routing setup
- `router.tsx` - **Manual route definitions** (requires explicit registration)
- `components/DocsLayout.tsx` - Navigation structure (hardcoded nav items)
- `docs/[section]/[topic]/page.tsx` - Documentation pages
- `test-pages/` - Library integration test pages
## Adding New Features
### New Documentation Page
1. Create `pages/docs/<section>/<slug>/page.tsx`
2. Add route to `pages/router.tsx` with `<Header /> + <DocsLayout>` wrapper
3. Add navigation item to `DocsLayout.tsx`
### New Agent Tool
1. Implement under `src/tools/`
2. Export via `src/tools/index.ts`
3. Wire into `PageAgent.ts` if needed
### New UI Component
1. Create in `src/ui/` with colocated CSS modules
2. Use event bus for PageAgent communication
3. Test via `pages/test-pages/`
## Code Standards
### TypeScript
- Strict mode enabled with `noUnusedLocals`/`noUnusedParameters`
- Explicit typing for exported/public APIs
- ESLint relaxes some unsafe rules for rapid iteration
### CSS & Styling
- **Prefer Tailwind CSS over custom CSS**
- Custom CSS variables for theme gradients in `src/index.css`
- Dark mode support via `dark:` classes
- CSS modules for component-specific styles
### Import Organization
- External libraries first
- Internal modules (`@/`, `@pages/`)
- Relative imports last
- Blank lines between groups
## Critical Files to Understand
- `pages/router.tsx` - Central routing definition (manual registration required)
- `pages/components/DocsLayout.tsx` - Navigation structure
- `src/PageAgent.ts` - Core AI agent class with DOM manipulation
- `src/dom/dom_tree/index.js` - DOM extraction engine
- `src/utils/bus.ts` - Type-safe event bus system
- `src/entry.ts` - Library entry point for CDN usage
- `vite.config.ts` / `vite.lib.config.ts` - Dual build configuration
## Environment Variables
Add new environment variables to `vite.config.ts` under `define`:
```typescript
define: {
'import.meta.env.YOUR_VAR': JSON.stringify(process.env.YOUR_VAR),
}
```
## Debugging Common Issues
### Blank Documentation Pages
1. Verify route exists in `pages/router.tsx`
2. Check component import path
3. Verify CSS isn't hiding content (check dark mode classes)
4. Test with minimal component first
### Library Integration Issues
1. Check `dist/lib/page-agent.umd.js` builds correctly
2. Test CDN injection with query params
3. Verify event bus communications are properly typed
4. Use `pages/test-pages/` for isolated testing

71
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,71 @@
# Alibaba Open Source Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at opensource@alibaba-inc.com. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html

50
CODE_OF_CONDUCT_zh.md Normal file
View File

@@ -0,0 +1,50 @@
# 阿里巴巴开源行为准则
## 我们的保证
为了促进一个开放透明且友好的环境,我们作为贡献者和维护者保证:无论年龄、种族、民族、性别认同和表达(方式)、体型、身体健全与否、经验水平、国籍、个人表现、宗教或性别取向,参与者在我们项目和社区中都免于骚扰。
## 我们的标准
有助于创造正面环境的行为包括但不限于:
* 使用友好和包容性语言
* 尊重不同的观点和经历
* 耐心地接受建设性批评
* 关注对社区最有利的事情
* 友善对待其他社区成员
身为参与者不能接受的行为包括但不限于:
* 使用与性有关的言语或是图像,以及不受欢迎的性骚扰
* 捣乱/煽动/造谣的行为或进行侮辱/贬损的评论,人身攻击及政治攻击
* 公开或私下的骚扰
* 未经许可地发布他人的个人资料,例如住址或是电子地址
* 其他可以被合理地认定为不恰当或者违反职业操守的行为
## 我们的责任
项目维护者有责任为「可接受的行为」标准做出诠释,以及对已发生的不被接受的行为采取恰当且公平的纠正措施。
项目维护者有权利及责任去删除、编辑、拒绝与本行为标准有所违背的评论 (comments)、提交 (commits)、代码、wiki 编辑、问题 (issues) 和其他贡献,以及项目维护者可暂时或永久性的禁止任何他们认为有不适当、威胁、冒犯、有害行为的贡献者。
## 使用范围
当一个人代表该项目或是其社区时,本行为标准适用于其项目平台和公共平台。
代表项目或是社区的情况,举例来说包括使用官方项目的电子邮件地址、通过官方的社区媒体账号发布或线上或线下事件中担任指定代表。
该项目的呈现方式可由其项目维护者进行进一步的定义及解释。
## 强制执行
可以通过 opensource@alibaba-inc.com 来联系项目团队来举报滥用、骚扰或其他不被接受的行为。
任何维护团队认为有必要且适合的所有投诉都将进行审查及调查,并做出相对应的回应。项目小组有对事件回报者有保密的义务。具体执行的方针近一步细节可能会单独公布。
没有切实地遵守或是执行本行为标准的项目维护人员,可能会因项目领导人或是其他成员的决定,暂时或是永久地取消其参与资格。
## 来源
本行为标准改编自[贡献者公约](https://www.contributor-covenant.org),版本 1.4
可在此查看[https://www.contributor-covenant.org/zh-cn/version/1/4/code-of-conduct.html](https://www.contributor-covenant.org/zh-cn/version/1/4/code-of-conduct.html)

161
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,161 @@
# Contributing to Page-Agent
Thank you for your interest in contributing to Page-Agent! We welcome contributions from everyone.
## 🚀 Quick Start
### Development Setup
1. **Prerequisites**
- Node.js 20+
- npm
2. **Install Dependencies**
```bash
npm install
```
3. **Development Commands**
```bash
npm run dev # Start documentation site
npm run build:lib:watch # Library development with auto-rebuild
npm run lint # Run linting
```
### Project Structure
This project has **two separate parts**:
- **Core Library** (`src/`) - Pure JavaScript AI agent library
- **Documentation Website** (`pages/`) - React web app for landing page and docs
## 🤝 How to Contribute
### Reporting Issues
- Use the GitHub issue tracker to report bugs or request features
- Search existing issues before creating new ones
- Provide clear reproduction steps for bugs
- Include browser version and environment details
### Code Contributions
1. **Fork and Clone**
```bash
git fork https://github.com/your-username/page-agent
git clone https://github.com/your-username/page-agent.git
cd page-agent
```
2. **Create Feature Branch**
```bash
git checkout -b feature/your-feature-name
```
3. **Make Changes**
- Follow existing code style and patterns
- Add tests for new functionality
- Update documentation as needed
4. **Test Your Changes**
```bash
npm run lint
npm run build
npm run build:lib
```
5. **Commit and Push**
```bash
git add .
git commit -m "feat: add awesome feature"
git push origin feature/your-feature-name
```
6. **Create Pull Request**
- Provide clear description of changes
- Link related issues
- Include screenshots for UI changes
## 📝 Code Style
### General Guidelines
- Use TypeScript for type safety
- Follow existing naming conventions
- Write meaningful commit messages
- Keep functions small and focused
- Add JSDoc comments for public APIs
### Core Library (`src/`)
- **No React dependencies** - Pure JavaScript/TypeScript only
- Use event bus for component communication
- Follow browser-use patterns for DOM operations
- Maintain compatibility with CDN injection
### Documentation Site (`pages/`)
- Use React 19 + TypeScript
- Follow hash routing patterns (`useHashLocation`)
- Use Tailwind CSS for styling
- Add new docs pages to both router and sidebar
## 🔧 Development Workflows
### Library Development
```bash
npm run build:lib:watch # Auto-rebuild on changes
# Test via: <script src="dist/lib/page-agent.umd.js"></script>
```
### Website Development
```bash
npm run dev # React development server
# Import from src/ via @/ alias to demo library features
```
### Adding Documentation
1. Create `pages/docs/section/page-name/page.tsx`
2. Add route to `pages/router.tsx`
3. Add navigation link to `pages/components/DocsLayout.tsx`
## 🎯 Contribution Areas
We especially welcome contributions in:
- **Browser compatibility** improvements
- **Performance optimizations** for DOM processing
- **Documentation** and examples
- **Testing** and quality assurance
- **Accessibility** features
- **Internationalization** support
## 🚫 What We Don't Accept
- Changes that break existing API compatibility
- Features that add React dependencies to core library
- Contributions without proper testing
- Code that doesn't follow project conventions
## 📄 Legal
By contributing to this project, you agree that your contributions will be licensed under the MIT License.
### Browser-Use Attribution
Parts of this project are derived from the [browser-use](https://github.com/browser-use/browser-use) project (MIT License). When contributing to DOM-related functionality:
- Maintain existing attribution comments
- Follow similar patterns for consistency
- Credit browser-use for derived concepts
## 💬 Questions?
- Open a GitHub issue for technical questions
- Check existing documentation and issues first
- Be respectful and constructive in discussions
Thank you for helping make Page-Agent better! 🎉

23
NOTICE Normal file
View File

@@ -0,0 +1,23 @@
Page-Agent
Copyright (c) 2025 Alibaba Group Holding Limited
Author: Simon<gaomeng1900>
This project incorporates work covered by the following copyright and permission notices:
================================================================================
Browser Use
Copyright (c) 2024 Gregor Zunic
Licensed under the MIT License
Original browser-use project: https://github.com/browser-use/browser-use
License: MIT License
We gratefully acknowledge the browser-use project and its contributors for their
excellent work on web automation and DOM interaction patterns that helped make
this project possible.
================================================================================
Third-party dependencies and their licenses can be found in the package.json
file and in the node_modules directory after installation.

102
README-zh.md Normal file
View File

@@ -0,0 +1,102 @@
# PageAgent 🤖
[![npm version](https://badge.fury.io/js/page-agent.svg)](https://badge.fury.io/js/page-agent) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg)](http://www.typescriptlang.org/) [![Downloads](https://img.shields.io/npm/dt/page-agent.svg)](https://www.npmjs.com/package/page-agent) [![Bundle Size](https://img.shields.io/bundlephobia/minzip/page-agent)](https://bundlephobia.com/package/page-agent) [![GitHub stars](https://img.shields.io/github/stars/gaomeng1900/page-agent.svg)](https://github.com/gaomeng1900/page-agent)
**一个脚本标签,让任何网页支持 AI 自动化操作。**
PageAgent 是基于 browser-use 架构的 Web UI 自动化代理,通过 LLM 集成实现网页界面的自然语言控制。
🌐 [English](./README.md) | **中文**
👉 [📖 **文档**](#) | [🚀 **试用**](#)
---
## ✨ Features
- **🎯 轻松集成** - 通过 CDN 或 npm 添加到任何网页
- **🔐 端侧运行** - 数据不离开浏览器
- **🧠 HTML 脱水**
- **💬 自然语言接口**
- **🎨 HITL 交互界面**
## 🗺️ Roadmap
👉 [**Roadmap**](./ROADMAP.md)
## 🚀 快速开始
### CDN 集成
> **TODO**: CDN 地址待确定。
```html
<!-- CDN 脚本标签 - URL 待更新 -->
<script src="TODO-CDN-URL"></script>
```
### NPM 安装
```bash
npm install page-agent
```
```javascript
import { PageAgent } from 'page-agent'
const agent = new PageAgent({
modelName: 'gpt-4.1-mini'
baseURL: 'xxxx',
apiKey: 'xxxx'
})
await agent.execute("点击登录按钮")
```
## 🏗️ 架构设计
PageAgent 采用清晰的模块化架构:
```
src/
├── PageAgent.ts # Agent 主流程
├── dom/ # DOM 理解
├── tools/ # 代理交互工具
├── ui/ # UI 组件和面板
├── llms/ # LLM 集成层
└── utils/ # 事件总线和工具
```
## 🤝 贡献
欢迎社区贡献!以下是参与方式:
### 开发环境
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)**
- **[ai-sdk](https://ai-sdk.dev/)**
PageAgent 专为**客户端网页增强**设计,不是服务端自动化工具。
## 📄 许可证
MIT 许可证 - 详见 [LICENSE](LICENSE) 文件。
DOM 处理与提示词参考了 [browser-use](https://github.com/browser-use/browser-use)MIT 许可证)。完整归属请见 [NOTICE](NOTICE)。
---
**⭐ 如果觉得 PageAgent 有用或有趣,请给项目点个星!**

102
README.md Normal file
View File

@@ -0,0 +1,102 @@
# PageAgent 🤖
[![npm version](https://badge.fury.io/js/page-agent.svg)](https://badge.fury.io/js/page-agent) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg)](http://www.typescriptlang.org/) [![Downloads](https://img.shields.io/npm/dt/page-agent.svg)](https://www.npmjs.com/package/page-agent) [![Bundle Size](https://img.shields.io/bundlephobia/minzip/page-agent)](https://bundlephobia.com/package/page-agent) [![GitHub stars](https://img.shields.io/github/stars/gaomeng1900/page-agent.svg)](https://github.com/gaomeng1900/page-agent)
**Transform any webpage into an AI-powered application with a single script tag.**
PageAgent is an intelligent UI agent for web automation and DOM interaction. Built on browser-use architecture, it enables natural language control of web interfaces through LLM integration.
🌐 **English** | [中文](./README-zh.md)
👉 [📖 **Documentation**](#) | [🚀 **Try Demo**](#)
---
## ✨ Features
- **🎯 Easy Integration** - Add to any webpage via CDN or npm
- **🔐 Client-Side Processing** - No data leaves the browser
- **🧠 DOM Extraction**
- **💬 Natural Language Interface**
- **🎨 UI with Human in the loop**
## 🗺️ Roadmap
👉 [**Roadmap**](./ROADMAP.md)
## 🚀 Quick Start
### CDN Integration
> **TODO**: CDN endpoint to be determined.
```html
<!-- CDN script tag - URL to be updated -->
<script src="TODO-CDN-URL"></script>
```
### NPM Installation
```bash
npm install page-agent
```
```javascript
import { PageAgent } from 'page-agent'
const agent = new PageAgent({
modelName: 'gpt-4.1-mini'
baseURL: 'xxxx',
apiKey: 'xxxx'
})
await agent.execute("Click the login button")
```
## 🏗️ Structure
PageAgent follows a clean, modular architecture:
```
src/
├── PageAgent.ts # Agent main loop
├── dom/ # DOM processing
├── tools/ # Agent tools
├── ui/ # UI components & panels
├── llms/ # LLM integration layer
└── utils/ # Event bus & utilities
```
## 🤝 Contributing
We welcome contributions from the community! Here's how to get started:
### Setup
1. Fork the repository
2. Clone your fork: `git clone https://github.com/alibaba/page-agent.git && cd page-agent`
3. Install dependencies: `npm install`
4. Start development: `npm start`
### Contributing Guidelines
Please read our [Code of Conduct](CODE_OF_CONDUCT.md) and [Contributing Guide](CONTRIBUTING.md) before contributing.
## 👏 Acknowledgments
This project builds upon the excellent work of:
- **[browser-use](https://github.com/browser-use/browser-use)**
- **[ai-sdk](https://ai-sdk.dev/)**
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](https://github.com/browser-use/browser-use) (MIT License). See [NOTICE](NOTICE) for full attribution.
---
**⭐ Star this repo if you find PageAgent helpful!**

34
ROADMAP.md Normal file
View File

@@ -0,0 +1,34 @@
# 🗺️ PageAgent Roadmap
The development progress and future plans for PageAgent.
## 🚀 Current Works
- [x] **MVP** - Core functionality implemented
- [x] **SPA interaction** - Single Page Application support
- [x] **Reasoning and (short) memory**
- [x] **Multi model provider integration and testing**
- [x] **UI with HITL** - Human-in-the-loop user interface
- [x] **Landing and testing page**
- [ ] **Testing suits**
- [ ] **Custom knowledge base and instructions**
- [ ] **Black/white-list safeguard**
- [ ] **Data-masking**
- [ ] **Custom actions and hooks**
- [ ] **Free evaluation plan?**
- [ ] **Working homepage with live LLM API**
- [ ] **free CDN**
♻️ Following browser-use's update and contribute back
## 📋 Pending Features
- [ ] **Same-origin multi-page-app rally**
- [ ] **Simple chrome-ext wrapper**
- [ ] **Local MCP proxy**
## 🤔 To Be Decided
- [ ] **Remove ai-sdk?** - Only one function is being used
- [ ] **Cross-origin multi-page?** - Tricky

21
env.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
/// <reference types="vite/client" />
import type { PageAgent } from './src/PageAgent'
declare global {
interface Window {
pageAgent?: PageAgent
PageAgent: typeof PageAgent
__PAGE_AGENT_IDS__: string[]
}
}
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.md?raw' {
const content: string
export default content
}

65
eslint.config.js Normal file
View File

@@ -0,0 +1,65 @@
import js from '@eslint/js'
import reactDom from 'eslint-plugin-react-dom'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import reactX from 'eslint-plugin-react-x'
import { globalIgnores } from 'eslint/config'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default tseslint.config([
globalIgnores(['dist', 'test-pages']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
// Remove tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
// Add any additional rules here
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-floating-promises': 'off',
'@typescript-eslint/no-confusing-void-expression': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-inferrable-types': 'off',
'@typescript-eslint/restrict-template-expressions': 'off',
'@typescript-eslint/no-dynamic-delete': 'off',
'@typescript-eslint/no-unnecessary-condition': 'off',
'@typescript-eslint/prefer-nullish-coalescing': 'off',
'@typescript-eslint/no-unnecessary-type-assertion': 'off',
'@typescript-eslint/no-misused-promises': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/restrict-plus-operands': 'off',
'react-dom/no-missing-button-type': 'off',
'react-x/no-nested-component-definitions': 'off',
},
},
])

30
index.html Normal file
View File

@@ -0,0 +1,30 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<!-- <link rel="icon" type="image/jpeg" href="/icon-256.jpg" /> -->
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PageAgent - 网页内的交互助手</title>
<meta
name="description"
content="PageAgent.js一行 CDN 引入,为任何网站添加智能 UI Agent。现代网页 AI 自动化,极简集成,开发者专用。"
/>
<meta
name="keywords"
content="PageAgent, AI Agent, Web Automation, UI 自动化, 前端, CDN, JavaScript, React, Vite"
/>
<meta name="theme-color" content="#58c0fc" />
<meta name="color-scheme" content="light dark" />
<meta name="author" content="PageAgent.js Team" />
<meta property="og:title" content="PageAgent.js" />
<meta property="og:description" content="一行 CDN 引入,为任何网站添加智能 UI Agent。" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="zh_CN" />
<!-- 可根据实际情况补充 og:image 等 -->
</head>
<body>
<div id="root"></div>
<script type="module" src="./pages/main.tsx"></script>
</body>
</html>

6001
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

116
package.json Normal file
View File

@@ -0,0 +1,116 @@
{
"name": "page-agent",
"private": false,
"version": "0.0.0",
"type": "module",
"main": "./dist/lib/page-agent.js",
"module": "./dist/lib/page-agent.js",
"types": "./dist/types/entry.d.ts",
"exports": {
".": {
"types": "./dist/types/entry.d.ts",
"import": "./dist/lib/page-agent.js",
"default": "./dist/lib/page-agent.js"
}
},
"files": [
"dist/lib/",
"dist/types/",
"README.md",
"LICENSE",
"NOTICE"
],
"description": "AI-powered UI agent for web applications - add intelligent automation to any webpage with a single script tag",
"keywords": [
"ai",
"automation",
"ui-agent",
"browser-automation",
"web-agent",
"llm",
"dom-interaction",
"intelligent-ui"
],
"author": "Simon<gaomeng1900>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/alibaba/page-agent.git"
},
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"dev": "vite",
"start": "vite",
"build": "tsc -b && vite build && npm run build:lib",
"build:lib": "npm run types:lib && vite build --config vite.lib.config.ts",
"build:lib:watch": "vite build --config vite.lib.config.ts --watch",
"types:lib": "tsc -p tsconfig.lib.json",
"lint": "eslint .",
"prepare": "husky"
},
"dependencies": {
"@ai-sdk/openai": "^2.0.22",
"ai": "^5.0.26",
"ai-motion": "^0.4.6",
"chalk": "^5.6.0",
"zod": "^4.1.3"
},
"devDependencies": {
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@eslint/js": "^9.30.1",
"@tailwindcss/vite": "^4.1.11",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react-swc": "^3.10.2",
"dotenv": "^17.2.1",
"eslint": "^9.31.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-dom": "^1.52.3",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"eslint-plugin-react-x": "^1.52.3",
"globals": "^16.3.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.2",
"prettier": "^3.6.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwindcss": "^4.1.11",
"typescript": "^5.9.2",
"typescript-eslint": "^8.39.0",
"vite": "^7.0.4",
"vite-plugin-css-injected-by-js": "^3.5.2",
"wouter": "^3.7.1"
},
"overrides": {
"zod": "^4.1.3"
},
"lint-staged": {
"*.{js,ts,cjs,cts,mjs,mts}": [
"npx prettier --write --ignore-unknown",
"npx eslint --quiet"
],
"*.{jsx,tsx}": [
"npx prettier --write --ignore-unknown",
"npx eslint --quiet"
],
"*.css": [
"npx prettier --write --ignore-unknown"
]
},
"commitlint": {
"extends": [
"@commitlint/config-conventional"
],
"rules": {
"subject-case": [
0,
"never"
]
}
}
}

1
pages/README.md Normal file
View File

@@ -0,0 +1 @@
# Landing Page & Docs

View File

@@ -0,0 +1,19 @@
export default function BetaNotice() {
return (
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4 mb-8">
<div className="flex items-start">
<div className="flex-shrink-0">
<span className="text-xl">🚧</span>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-orange-800 dark:text-orange-200 mb-1">
Beta
</h3>
<p className="text-sm text-orange-700 dark:text-orange-300">
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,126 @@
/**
* 代码编辑器组件,模拟现代代码编辑器的外观
*/
import React from 'react'
import HighlightSyntax from './HighlightSyntax'
interface CodeEditorProps {
code: string
language?: string
title?: string
showLineNumbers?: boolean
showHeader?: boolean
showFooter?: boolean
className?: string
}
const CodeEditor: React.FC<CodeEditorProps> = ({
code,
language = 'javascript',
title,
showLineNumbers = false,
showHeader = false,
showFooter = false,
className = '',
}) => {
const lines = code.split('\n')
// 使用 Tailwind 的 dark: 前缀实现自动主题切换
const containerClasses =
'bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 border-gray-300 dark:border-gray-700'
const headerClasses = 'bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-700'
const headerTextClasses = 'text-gray-700 dark:text-gray-300'
const languageTextClasses = 'text-gray-600 dark:text-gray-400'
const lineNumbersClasses =
'bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-500'
const codeAreaClasses = 'bg-white dark:bg-gray-900'
const footerClasses =
'bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-400'
const copyButtonClasses =
'bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-600 hover:text-gray-800 dark:text-gray-300 dark:hover:text-white'
return (
<div
className={`group relative ${containerClasses} rounded-xl border shadow-2xl overflow-hidden ${className}`}
>
{/* 编辑器顶部栏 */}
{showHeader && (
<div className={`flex items-center justify-between px-4 py-3 ${headerClasses} border-b`}>
<div className="flex items-center space-x-3">
{/* 窗口控制按钮 */}
<div className="flex space-x-2">
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
</div>
{title && (
<span className={`text-sm ${headerTextClasses} font-medium ml-2`}>{title}</span>
)}
</div>
<div className="flex items-center space-x-2">
<span className={`text-xs ${languageTextClasses} uppercase tracking-wide`}>
{language}
</span>
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
</div>
</div>
)}
{/* 代码内容区域 */}
<div className="relative">
<div className="flex">
{/* 行号 */}
{showLineNumbers && (
<div className={`flex-shrink-0 px-4 py-4 ${lineNumbersClasses} border-r select-none`}>
<div className="text-xs font-mono leading-6">
{lines.map((_, index) => (
<div key={index} className="text-right">
{index + 1}
</div>
))}
</div>
</div>
)}
{/* 代码内容 */}
<div className={`flex-1 px-4 py-4 ${codeAreaClasses} overflow-x-auto`}>
<div className="text-sm font-mono leading-6">
<HighlightSyntax code={code} />
</div>
</div>
</div>
{/* 复制按钮 */}
<button
onClick={() => {
navigator.clipboard.writeText(code).catch(console.error)
}}
className={`absolute top-3 right-3 p-2 ${copyButtonClasses} rounded-lg transition-all duration-200 opacity-0 group-hover:opacity-100`}
title="复制代码"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</button>
</div>
{/* 底部状态栏 */}
{showFooter && (
<div className={`px-4 py-2 ${footerClasses} border-t`}>
<div className="flex items-center justify-between text-xs">
<span>{lines.length} lines</span>
<span>UTF-8</span>
</div>
</div>
)}
</div>
)
}
export default CodeEditor

View File

@@ -0,0 +1,95 @@
import { ReactNode } from 'react'
import { Link, useLocation } from 'wouter'
interface DocsLayoutProps {
children: ReactNode
}
interface NavItem {
title: string
path: string
}
interface NavSection {
title: string
items: NavItem[]
}
const navigationSections: NavSection[] = [
{
title: 'Introduction',
items: [
{ title: 'Overview', path: '/docs/introduction/overview' },
{ title: 'Quick Start', path: '/docs/introduction/quick-start' },
{ title: '使用限制', path: '/docs/introduction/limitations' },
],
},
{
title: 'Features',
items: [
{ title: '模型接入', path: '/docs/features/model-integration' },
{ title: '安全与权限', path: '/docs/features/security-permissions' },
{ title: '数据脱敏', path: '/docs/features/data-masking' },
{ title: '知识库注入', path: '/docs/features/knowledge-injection' },
{ title: '自定义工具', path: '/docs/features/custom-tools' },
],
},
{
title: 'Integration',
items: [
{ title: 'CDN 引入', path: '/docs/integration/cdn-setup' },
{ title: '配置选项', path: '/docs/integration/configuration' },
{ title: 'API 参考', path: '/docs/integration/api-reference' },
{ title: '最佳实践', path: '/docs/integration/best-practices' },
{ title: '接入第三方 Agent', path: '/docs/integration/third-party-agent' },
],
},
]
export default function DocsLayout({ children }: DocsLayoutProps) {
const [location] = useLocation()
return (
<div className="max-w-7xl mx-auto px-6 py-8">
<div className="flex gap-8">
{/* Sidebar */}
<aside className="w-64 flex-shrink-0" role="complementary" aria-label="文档导航">
<div className="sticky top-8">
<nav className="space-y-8" role="navigation" aria-label="文档章节">
{navigationSections.map((section) => (
<section key={section.title}>
<h3 className="font-semibold uppercase tracking-wider mb-3">{section.title}</h3>
<ul className="space-y-2" role="list">
{section.items.map((item) => {
const isActive = location === item.path
return (
<li key={item.path}>
<Link
href={item.path}
className={`block px-3 py-2 rounded-lg transition-colors duration-200 ${
isActive
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-medium'
: ' hover:text-foreground hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
aria-current={isActive ? 'page' : undefined}
>
{item.title}
</Link>
</li>
)
})}
</ul>
</section>
))}
</nav>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 min-w-0" id="main-content" role="main">
<div className="prose prose-lg dark:prose-invert max-w-none">{children}</div>
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,91 @@
import { Link } from 'wouter'
export default function Footer() {
return (
<footer
className="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700"
role="contentinfo"
>
<div className="max-w-7xl mx-auto px-6 py-12">
<div className="grid md:grid-cols-3 gap-8">
{/* Brand */}
<section className="space-y-4">
<div className="flex items-center space-x-3">
<div
className="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center"
aria-hidden="true"
>
<span className="text-white font-bold">P</span>
</div>
<h3 className="text-lg font-bold text-foreground">page-agent</h3>
</div>
<p className="text-foreground/80 leading-relaxed">
Web AI
<br />
</p>
</section>
{/* Links */}
<section className="space-y-4">
<h4 className="font-semibold text-foreground uppercase tracking-wider"></h4>
<nav className="space-y-2" role="navigation" aria-label="页脚导航">
<Link
href="/docs/introduction/overview"
className="block text-foreground/80 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
>
</Link>
<Link
href="TODO"
target="_blank"
rel="noopener noreferrer"
className="block text-foreground/80 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
aria-label="查看源码(在新窗口打开)"
>
</Link>
<Link
href="/docs/introduction/quick-start"
className="block text-foreground/80 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
>
</Link>
</nav>
</section>
{/* Contact */}
<section className="space-y-4">
<h4 className="font-semibold text-foreground uppercase tracking-wider"></h4>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<span className="text-foreground/80 text-sm">:</span>
<span className="text-blue-600 dark:text-blue-400 font-medium">@TODO</span>
</div>
</div>
</section>
</div>
{/* Bottom */}
<div className="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
<p className="text-foreground/80 text-sm">© 2025 page-agent. All rights reserved.</p>
<div className="flex items-center space-x-6">
<a
href="TODO"
target="_blank"
rel="noopener noreferrer"
className="text-foreground/80 hover:text-foreground transition-colors duration-200"
aria-label="访问 GitHub 仓库"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
</a>
</div>
</div>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,74 @@
import { Link } from 'wouter'
export default function Header() {
return (
<header
className="relative z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-gray-200 dark:border-gray-700"
role="banner"
>
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
{/* Logo */}
<Link href="/" className="flex items-center space-x-3 group" aria-label="page-agent 首页">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform duration-200">
<span className="text-white font-bold text-2xl lg:text-2xl" aria-hidden="true">
P
</span>
</div>
<div>
<span className="text-xl font-bold text-foreground">page-agent</span>
<p className="text-xs text-foreground/80">UI Agent in your webpage</p>
</div>
</Link>
{/* Navigation */}
<nav
className="hidden md:flex items-center space-x-8"
role="navigation"
aria-label="主导航"
>
<Link
href="/docs/introduction/overview"
className="text-foreground/80 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
>
</Link>
<a
href="TODO"
target="_blank"
rel="noopener noreferrer"
className="text-foreground/80 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
aria-label="查看源码(在新窗口打开)"
>
</a>
</nav>
{/* Mobile menu button */}
<button
type="button"
className="md:hidden p-2 rounded-lg text-foreground/80 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-200"
aria-label="打开移动端菜单"
aria-expanded="false"
aria-controls="mobile-menu"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,21 @@
.syntax {
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word;
font-family: monospace;
}
.keyword {
color: #d73a49;
font-weight: 600;
}
.string {
color: #1d6eca;
}
.number {
color: #00c583;
}
.comment {
color: #6a737d;
font-style: italic;
}

View File

@@ -0,0 +1,77 @@
/**
* js 语法高亮组件,适合在文章中演示代码片段
*/
import React from 'react'
import styles from './HighlightSyntax.module.css'
interface HighlightSyntaxProps {
code: string
}
const keywords =
'async|await|function|const|let|var|if|else|for|while|return|try|catch|finally|class|extends|from|import|export|default|undefined|throw|true|false|null|this|new|in|of|instanceof|break|continue|switch|case|default|do|while|with|yield'
// 语法高亮函数,先整体提取字符串/注释等token再高亮
function highlightSyntax(code: string): string {
// 先转义HTML特殊字符
const escaped = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
// 单行字符串,所有反斜杠双重转义,保证正则安全
const pattern = new RegExp(
'("([^"\\\\]|\\\\.)*"|\'([^\'\\\\]|\\\\.)*\'|`([^`\\\\]|\\\\.)*`|//[^\\n]*|/\\*[\\s\\S]*?\\*/|\\b\\d+\\.?\\d*\\b|\\b(?:' +
keywords +
')\\b)',
'g'
)
const tokens: string[] = []
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = pattern.exec(escaped)) !== null) {
if (match.index > lastIndex) {
tokens.push(...escaped.slice(lastIndex, match.index).split(/([ \t\n\r.])/))
}
tokens.push(match[0])
lastIndex = pattern.lastIndex
}
if (lastIndex < escaped.length) {
tokens.push(...escaped.slice(lastIndex).split(/([ \t\n\r.])/))
}
const highlighted = tokens
.map((token) => {
if (
/^"([^"\\]|\\.)*"$/.test(token) ||
/^'([^'\\]|\\.)*'$/.test(token) ||
/^`([^`\\]|\\.)*`$/.test(token)
) {
return `<span style="color: #1d6eca;">${token}</span>`
}
if (/^\b\d+\.?\d*\b$/.test(token)) {
return `<span style="color: #00c583;">${token}</span>`
}
if (/^\/\/.*$/.test(token)) {
return `<span style="color: #6a737d; font-style: italic;">${token}</span>`
}
if (/^\/\*[\s\S]*?\*\/$/.test(token)) {
return `<span style="color: #6a737d; font-style: italic;">${token}</span>`
}
if (new RegExp(`\\b(?:${keywords})\\b`).test(token)) {
return `<span style="color: #d73a49; font-weight: 600;">${token}</span>`
}
return token
})
.join('')
return highlighted
}
const HighlightSyntaxClient: React.FC<HighlightSyntaxProps> = ({ code }) => {
const htmlContent = highlightSyntax(code)
// eslint-disable-next-line react-dom/no-dangerously-set-innerhtml
return <code className={styles.syntax} dangerouslySetInnerHTML={{ __html: htmlContent }} />
}
export default HighlightSyntaxClient

View File

@@ -0,0 +1,164 @@
.console {
display: flex;
flex-direction: column;
background-color: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-family: monospace;
font-size: 12px;
line-height: 1;
overflow: hidden;
scroll-behavior: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.historyArea {
flex: 1;
overflow-y: auto;
padding: 12px;
background-color: #fafafa;
min-height: 200px;
display: flex;
flex-direction: column;
scroll-behavior: contain;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: #d0d0d0;
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background-color: #b0b0b0;
}
.historyItem {
display: flex;
align-items: center;
font-size: 12px;
line-height: 1;
padding-bottom: 6px;
border-bottom: #ccdeeebd 1px solid;
margin-bottom: 6px;
flex: 0 0 auto;
&:last-child {
margin-bottom: 0;
border-bottom: none;
}
&.input {
}
&.output {
}
.content {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
flex: 1;
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: #2563eb;
}
/* 错误样式 */
&.error .content {
color: #dc2626;
background-color: #fef2f2;
padding: 4px 8px;
border-radius: 4px;
border-left: 3px solid #dc2626;
}
}
}
.prompt {
display: flex;
height: 100%;
align-items: flex-start;
width: 12px;
color: #666;
margin-right: 8px;
font-weight: 500;
flex-shrink: 0;
user-select: none;
}
.executing {
color: #f59e0b;
font-style: italic;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.inputArea {
display: flex;
align-items: center;
padding: 12px;
background-color: #ffffff;
border-top: 1px solid #e0e0e0;
.prompt {
margin-top: 8px;
}
.input {
flex: auto;
border: none;
outline: none;
background: transparent;
color: #333;
resize: none;
line-height: 20px;
}
.input::placeholder {
color: #999;
font-style: italic;
}
.input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.console {
font-size: 12px;
border-radius: 6px;
}
.historyArea,
.inputLine {
padding: 8px;
}
.prompt {
margin-right: 6px;
}
}

View File

@@ -0,0 +1,369 @@
/**
* JS 调试台,适合在文档中直接让用户运行代码,并且实时查看运行结果
*/
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-base-to-string */
import { KeyboardEvent, forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
import HighlightSyntax from './HighlightSyntax'
import styles from './JSConsole.module.css'
// 全局console拦截管理器
class ConsoleInterceptor {
private static instance: ConsoleInterceptor
private subscribers = new Set<(type: string, args: unknown[]) => void>()
private originalConsole: {
log: typeof console.log
warn: typeof console.warn
error: typeof console.error
}
private isIntercepting = false
private constructor() {
this.originalConsole = {
log: console.log.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
}
}
static getInstance() {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!ConsoleInterceptor.instance) {
ConsoleInterceptor.instance = new ConsoleInterceptor()
}
return ConsoleInterceptor.instance
}
subscribe(callback: (type: string, args: unknown[]) => void) {
this.subscribers.add(callback)
this.startIntercepting()
}
unsubscribe(callback: (type: string, args: unknown[]) => void) {
this.subscribers.delete(callback)
if (this.subscribers.size === 0) {
this.stopIntercepting()
}
}
private startIntercepting() {
if (this.isIntercepting) return
this.isIntercepting = true
console.log = (...args: unknown[]) => {
this.originalConsole.log(...args)
this.notifySubscribers('log', args)
}
console.warn = (...args: unknown[]) => {
this.originalConsole.warn(...args)
this.notifySubscribers('warn', args)
}
console.error = (...args: unknown[]) => {
this.originalConsole.error(...args)
this.notifySubscribers('error', args)
}
}
private stopIntercepting() {
if (!this.isIntercepting) return
this.isIntercepting = false
console.log = this.originalConsole.log
console.warn = this.originalConsole.warn
console.error = this.originalConsole.error
}
private notifySubscribers(type: string, args: unknown[]) {
this.subscribers.forEach((callback) => {
callback(type, args)
})
}
}
interface JSConsoleProps {
context?: Record<string, unknown>
height?: string
onExecute?: (code: string, result: unknown) => void
placeholder?: string
}
export interface JSConsoleRef {
executeCode: (code: string) => Promise<unknown>
clear: () => void
appendOutput: (content: string) => void
}
interface OutputItem {
type: 'input' | 'output' | 'error' | 'log'
content: string
timestamp: number
}
const JSConsole = forwardRef<JSConsoleRef, JSConsoleProps>(
(
{ context = {}, height = '400px', onExecute, placeholder = 'Enter JavaScript code...' },
ref
) => {
const [input, setInput] = useState('')
const [outputs, setOutputs] = useState<OutputItem[]>([])
const [isExecuting, setIsExecuting] = useState(false)
const inputRef = useRef<HTMLTextAreaElement>(null)
const outputRef = useRef<HTMLDivElement>(null)
// 持久的执行上下文,用于多轮对话共享作用域
const executionContextRef = useRef<Record<string, unknown>>({})
// 格式化结果
const formatResult = (value: unknown): string => {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
if (typeof value === 'string') return `"${value}"`
if (typeof value === 'function') return `[Function: ${value.name || 'anonymous'}]`
if (typeof value === 'object') {
try {
return JSON.stringify(value, null, 2)
} catch {
return value.toString()
}
}
return String(value)
}
// 全局console拦截处理
useEffect(() => {
const interceptor = ConsoleInterceptor.getInstance()
const handleGlobalConsole = (type: string, args: unknown[]) => {
const content = args.map((arg) => formatResult(arg)).join(' ')
const outputItem: OutputItem = {
type: type as any,
content: content,
timestamp: Date.now(),
}
setOutputs((prev) => [...prev, outputItem])
// 自动滚动到底部
setTimeout(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight
}
}, 0)
}
interceptor.subscribe(handleGlobalConsole)
return () => {
interceptor.unsubscribe(handleGlobalConsole)
}
}, [])
// 执行代码
const executeCode = async (code: string): Promise<unknown> => {
if (!code.trim()) return
setIsExecuting(true)
// 添加输入到输出
const inputItem: OutputItem = {
type: 'input',
content: code,
timestamp: Date.now(),
}
setOutputs((prev) => [...prev, inputItem])
try {
// 创建异步函数以支持 await
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
// 合并外部上下文和持久执行上下文
const allContext = { ...context, ...executionContextRef.current }
const contextKeys = Object.keys(allContext)
const contextValues = Object.values(allContext)
// 注入 console.log 重定向
const logs: string[] = []
const mockConsole = {
log: (...args: unknown[]) => {
logs.push(args.map((arg) => formatResult(arg)).join(' '))
},
error: (...args: unknown[]) => {
logs.push('ERROR: ' + args.map((arg) => formatResult(arg)).join(' '))
},
warn: (...args: unknown[]) => {
logs.push('WARN: ' + args.map((arg) => formatResult(arg)).join(' '))
},
}
// 检测代码是否是表达式还是语句
const trimmedCode = code.trim()
const isExpression =
!trimmedCode.includes(';') &&
!trimmedCode.startsWith('const ') &&
!trimmedCode.startsWith('let ') &&
!trimmedCode.startsWith('var ') &&
!trimmedCode.startsWith('function ') &&
!trimmedCode.startsWith('class ') &&
!trimmedCode.startsWith('if ') &&
!trimmedCode.startsWith('for ') &&
!trimmedCode.startsWith('while ') &&
!trimmedCode.startsWith('try ') &&
!trimmedCode.startsWith('{') &&
!trimmedCode.includes('\n')
// 如果是表达式,自动添加 return
const codeToExecute = isExpression ? `return ${code}` : code
const wrappedCode = `
return (async function() {
${codeToExecute}
})();
`
// 执行代码
const func = new AsyncFunction('console', ...contextKeys, wrappedCode)
const result = await func(mockConsole, ...contextValues)
// 添加 console.log 输出
if (logs.length > 0) {
const logItem: OutputItem = {
type: 'log',
content: logs.join('\n'),
timestamp: Date.now(),
}
setOutputs((prev) => [...prev, logItem])
}
// 总是添加执行结果输出(包括 undefined
const outputItem: OutputItem = {
type: 'output',
content: formatResult(result),
timestamp: Date.now(),
}
setOutputs((prev) => [...prev, outputItem])
onExecute?.(code, result)
return result
} catch (error) {
const errorItem: OutputItem = {
type: 'error',
content: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
}
setOutputs((prev) => [...prev, errorItem])
throw error
} finally {
setIsExecuting(false)
// 滚动到底部
setTimeout(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight
}
}, 0)
}
}
// 清空控制台
const clear = () => {
setOutputs([])
// 同时清空执行上下文
executionContextRef.current = {}
}
// 添加输出
const appendOutput = (content: string) => {
const outputItem: OutputItem = {
type: 'output',
content,
timestamp: Date.now(),
}
setOutputs((prev) => [...prev, outputItem])
}
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
executeCode,
clear,
appendOutput,
}))
// 处理键盘事件
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter') {
if (e.shiftKey) {
// Shift+Enter 换行
return
} else {
// Enter 执行
e.preventDefault()
if (!isExecuting && input.trim()) {
executeCode(input)
setInput('')
setTimeout(() => inputRef.current?.focus(), 0)
}
}
}
}
function getPrompt(type: string) {
let prompt = ' '
if (type === 'input') {
prompt = '>'
} else if (type === 'output') {
prompt = '<'
}
return prompt
}
return (
<div className={styles.console} style={{ height }}>
{/* 历史记录和输入区域 */}
<div className={styles.historyArea} ref={outputRef}>
{outputs.map((item, index) => (
<div key={index} className={`${styles.historyItem} ${styles[item.type]}`}>
<span className={styles.prompt}>{getPrompt(item.type)}</span>
<pre className={styles.content}>
<HighlightSyntax code={item.content} />
</pre>
</div>
))}
{isExecuting && (
<div className={styles.historyItem}>
<span className={styles.prompt}>{'> '}</span>
<span className={styles.executing}>Executing...</span>
</div>
)}
</div>
{/* 当前输入行 */}
<div className={styles.inputArea}>
<span className={styles.prompt}>{'> '}</span>
<textarea
ref={inputRef}
className={styles.input}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={isExecuting}
rows={1}
style={{
height: Math.min(Math.max(20, input.split('\n').length * 20), 120),
}}
/>
</div>
</div>
)
}
)
JSConsole.displayName = 'JSConsole'
export default JSConsole

View File

@@ -0,0 +1,156 @@
import BetaNotice from '@pages/components/BetaNotice'
import CodeEditor from '@pages/components/CodeEditor'
export default function CustomTools() {
return (
<div>
<h1 className="text-4xl font-bold mb-6"></h1>
<BetaNotice />
<p className="text-xl text-foreground/80 mb-8 leading-relaxed">
AI Agent 使 Zod AI
</p>
<div className="space-y-8">
<section>
<h2 className="text-2xl font-bold mb-4"></h2>
<p className="text-foreground/80 mb-4">
namedescriptioninput schema execute
</p>
<CodeEditor
code={`import { z } from 'zod'
import { pageAgent } from 'page-agent'
// 定义输入 schema
const CreateUserSchema = z.object({
name: z.string().min(1, '姓名不能为空'),
email: z.string().email('邮箱格式不正确'),
role: z.enum(['admin', 'user', 'guest']).default('user')
})
// 注册工具
pageAgent.registerTool({
name: 'createUser',
description: '创建新用户账户',
input: CreateUserSchema,
execute: async (params) => {
// 执行业务逻辑
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
})
return await response.json()
}
})`}
language="javascript"
/>
</section>
<section>
<h2 className="text-2xl font-bold mb-4"></h2>
<div className="space-y-4">
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<h3 className="text-lg font-semibold text-blue-900 dark:text-blue-300 mb-2">
📝 name ()
</h3>
<p className="text-foreground/80 mb-2">AI </p>
<div className="bg-white dark:bg-gray-800 rounded p-3 text-sm">
<code>name: 'searchProducts' // 驼峰命名,语义清晰</code>
</div>
</div>
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<h3 className="text-lg font-semibold text-green-900 dark:text-green-300 mb-2">
💬 description ()
</h3>
<p className="text-foreground/80 mb-2"> AI 使</p>
<div className="bg-white dark:bg-gray-800 rounded p-3 text-sm">
<code>description: '根据关键词搜索商品,支持价格区间和分类筛选'</code>
</div>
</div>
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<h3 className="text-lg font-semibold text-purple-900 dark:text-purple-300 mb-2">
🔧 input ()
</h3>
<p className="text-foreground/80 mb-2">Zod schema </p>
<div className="bg-white dark:bg-gray-800 rounded p-3 text-sm">
<code>{`input: z.object({
keyword: z.string().min(1),
priceRange: z.object({
min: z.number().optional(),
max: z.number().optional()
}).optional()
})`}</code>
</div>
</div>
<div className="p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
<h3 className="text-lg font-semibold text-orange-900 dark:text-orange-300 mb-2">
execute ()
</h3>
<p className="text-foreground/80 mb-2"></p>
<div className="bg-white dark:bg-gray-800 rounded p-3 text-sm">
<code>{`execute: async (params) => {
// params 已通过 Zod 验证
const result = await businessLogic(params)
return result // 返回结果给 AI
}`}</code>
</div>
</div>
</div>
</section>
<section>
<h2 className="text-2xl font-bold mb-4"></h2>
<p className="text-foreground/80 mb-4">
<code className="bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded">pageFilter</code>{' '}
</p>
<CodeEditor
code={`pageAgent.registerTool({
name: 'approveOrder',
description: '审批订单',
input: z.object({
orderId: z.string(),
approved: z.boolean()
}),
execute: async (params) => {
// 审批逻辑
},
// 可选:页面过滤器
pageFilter: {
// 只在订单管理页面显示
include: ['/admin/orders', '/admin/orders/*'],
// 排除特定页面
exclude: ['/admin/orders/archived']
}
})`}
language="javascript"
/>
</section>
<section>
<h2 className="text-2xl font-bold mb-4"></h2>
<div className="space-y-4">
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
<h3 className="text-lg font-semibold text-yellow-900 dark:text-yellow-300 mb-2">
</h3>
<ul className="text-foreground/80 space-y-1 text-sm">
<li> 使 pageFilter </li>
<li> execute </li>
<li> </li>
</ul>
</div>
</div>
</section>
</div>
</div>
)
}

View File

@@ -0,0 +1,44 @@
import BetaNotice from '@pages/components/BetaNotice'
import CodeEditor from '@pages/components/CodeEditor'
export default function DataMasking() {
return (
<div>
<h1 className="text-4xl font-bold mb-6"></h1>
<BetaNotice />
<p className="text-xl text-foreground/80 mb-6 leading-relaxed">
AI
</p>
<h2 className="text-2xl font-bold mb-3"></h2>
<div className="space-y-4 mb-6">
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-300">
🔒
</h3>
<p className="text-foreground/80"></p>
</div>
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-purple-900 dark:text-purple-300">
</h3>
<p className="text-foreground/80"></p>
</div>
</div>
<CodeEditor
code={`// 数据脱敏配置
// @todo
const rules = [
{ pattern: /\\d{11}/, replacement: '***-****-****' },
{ pattern: /\\d{4}-\\d{4}-\\d{4}-\\d{4}/, replacement: '****-****-****-****' }
]
pageAgent.maskData(rules)`}
/>
</div>
)
}

View File

@@ -0,0 +1,156 @@
import BetaNotice from '@pages/components/BetaNotice'
import CodeEditor from '@pages/components/CodeEditor'
export default function KnowledgeInjection() {
return (
<div>
<h1 className="text-4xl font-bold mb-6"></h1>
<BetaNotice />
<p className="text-xl text-foreground/80 mb-8 leading-relaxed">
AI
</p>
{/* Custom Instruction */}
<section className="mb-12">
<h2 className="text-3xl font-bold mb-6">Instruction - </h2>
<div className="p-6 bg-purple-50 dark:bg-purple-900/20 rounded-lg mb-6">
<h3 className="text-xl font-semibold mb-3 text-purple-900 dark:text-purple-300">
🎯
</h3>
<p className="text-foreground/80 mb-4"> AI </p>
<ul className="list-disc list-inside space-y-2 text-foreground/70">
<li> AI </li>
<li></li>
<li></li>
<li></li>
</ul>
</div>
<CodeEditor
className="mb-6"
code={`// 构造函数中设置系统指令
const pageAgent = new PageAgent({
instruction: \`
# 角色定义
你是专业的电商运营助手。
# 工作风格
- 谨慎:操作前确认
- 准确:确保正确性
- 高效:优化流程
# 错误处理
遇到错误时暂停并报告。
\`
});`}
/>
</section>
{/* App Knowledge */}
<section className="mb-12">
<h2 className="text-3xl font-bold mb-6">App Knowledge - </h2>
<div className="p-6 bg-blue-50 dark:bg-blue-900/20 rounded-lg mb-6">
<h3 className="text-xl font-semibold mb-3 text-blue-900 dark:text-blue-300">
<EFBFBD>
</h3>
<p className="text-foreground/80 mb-4">
AI
</p>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<h4 className="font-semibold text-blue-800 dark:text-blue-200"></h4>
<ul className="list-disc list-inside text-sm text-foreground/70 space-y-1">
<li></li>
<li></li>
<li></li>
</ul>
</div>
<div className="space-y-2">
<h4 className="font-semibold text-blue-800 dark:text-blue-200"></h4>
<ul className="list-disc list-inside text-sm text-foreground/70 space-y-1">
<li></li>
<li></li>
<li></li>
</ul>
</div>
</div>
</div>
<CodeEditor
className="mb-6"
code={`// 应用知识
pageAgent.knowledge.setAppKnowledge(\`
# 产品介绍
电商管理系统:面向中小企业的一站式解决方案。
# 操作流程
## 商品上架
1. 进入商品管理页面 2. 点击新增商品 3. 填写基本信息 4. 设置库存 5. 提交审核
# 术语解释
- SKU库存量单位
- SPU标准产品单位
- 运费模板:物流费用计算规则
# 业务规则
- 库存为0时自动下架
- VIP会员享9.5折
\`);`}
/>
</section>
{/* Page Knowledge */}
<section className="mb-12">
<h2 className="text-3xl font-bold mb-6">Page Knowledge - </h2>
<div className="p-6 bg-green-50 dark:bg-green-900/20 rounded-lg mb-6">
<h3 className="text-xl font-semibold mb-3 text-green-900 dark:text-green-300">
📄
</h3>
<p className="text-foreground/80 mb-4">
AI
</p>
<div className="grid md:grid-cols-3 gap-4">
<div className="space-y-2">
<h4 className="font-semibold text-green-800 dark:text-green-200"></h4>
<p className="text-sm text-foreground/70"></p>
</div>
<div className="space-y-2">
<h4 className="font-semibold text-green-800 dark:text-green-200"></h4>
<p className="text-sm text-foreground/70"></p>
</div>
<div className="space-y-2">
<h4 className="font-semibold text-green-800 dark:text-green-200"></h4>
<p className="text-sm text-foreground/70"></p>
</div>
</div>
</div>
<CodeEditor
className="mb-6"
code={`// 页面知识库
// 添加页面知识
pageAgent.knowledge.addPageKnowledge("/products", \`
商品列表页面,包含搜索、筛选、批量操作功能。
#add-product-btn新增商品按钮
.product-item商品列表项
#search-input搜索框最少2个字符
\`);
pageAgent.knowledge.addPageKnowledge("/orders/*", \`
订单详情页面。
.order-status订单状态标签
#update-status-btn状态更新按钮
\`);
// 移除页面知识
pageAgent.knowledge.removePageKnowledge("/products");`}
/>
</section>
</div>
)
}

View File

@@ -0,0 +1,162 @@
import BetaNotice from '@pages/components/BetaNotice'
import CodeEditor from '@pages/components/CodeEditor'
export default function ModelIntegration() {
return (
<div>
<h1 className="text-4xl font-bold mb-6"></h1>
<BetaNotice />
<p className="text-xl text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
OpenAI tool call
</p>
<h2 className="text-2xl font-bold mb-3"></h2>
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg mb-6">
<h3 className="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-300">
🔌 OpenAI
</h3>
<p className="text-foreground/80">
OpenAI API chat/completions OpenAIAzure
使 vLLMOllama
</p>
<p className="text-foreground/80">
tool call json schema tool call
</p>
</div>
<h2 className="text-2xl font-bold mb-3"></h2>
<div className="grid md:grid-cols-3 gap-4 mb-6">
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-green-900 dark:text-green-300">
gpt-4.1-mini
</h3>
<p className="text-sm text-foreground/80 mb-2">使 </p>
<ul className="text-sm text-foreground/70 space-y-1">
<li> </li>
<li> </li>
<li> i/o $0.4/$1.6 ( M token)</li>
</ul>
</div>
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-purple-900 dark:text-purple-300">
🚀 gpt-4.1
</h3>
<p className="text-sm text-foreground/80 mb-2"></p>
<ul className="text-sm text-foreground/70 space-y-1">
<li> </li>
<li> 4.1-mini 5 </li>
<li> </li>
</ul>
</div>
<div className="p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-orange-900 dark:text-orange-300">
🛡 qwen-plus (qwen3)
</h3>
<p className="text-sm text-foreground/80 mb-2"></p>
<ul className="text-sm text-foreground/70 space-y-1">
<li> 便</li>
<li> ToolCall </li>
<li>
<strong></strong>
</li>
</ul>
</div>
</div>
<h2 className="text-2xl font-bold mb-3"></h2>
<div className="p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg mb-6">
<h3 className="text-lg font-semibold mb-3 text-emerald-900 dark:text-emerald-300">
</h3>
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-900 dark:text-emerald-200 px-3 py-1 text-sm">
gpt-4.1-mini
</span>
<span className="inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-900 dark:text-emerald-200 px-3 py-1 text-sm">
gpt-4.1
</span>
<span className="inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-900 dark:text-emerald-200 px-3 py-1 text-sm">
gpt-5
</span>
<span className="inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-900 dark:text-emerald-200 px-3 py-1 text-sm">
gpt-5-mini
</span>
<span className="inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-900 dark:text-emerald-200 px-3 py-1 text-sm">
qwen-plus
</span>
<span className="inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-900 dark:text-emerald-200 px-3 py-1 text-sm">
deepseek-v3.1
</span>
<span className="inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-900 dark:text-emerald-200 px-3 py-1 text-sm">
claude-4-sonnet
</span>
<span className="inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-900 dark:text-emerald-200 px-3 py-1 text-sm">
claude-3.7-sonnet
</span>
</div>
</div>
<h2 className="text-2xl font-bold mb-3"></h2>
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg mb-6">
<h3 className="text-lg font-semibold mb-2 text-red-900 dark:text-red-300">
🚫
</h3>
<ul className="text-sm text-foreground/80 space-y-1 list-disc pl-5">
<li>reasoning </li>
<li>GPT-5 </li>
<li> agent coder </li>
<li>
json schema openAI tool call
</li>
<li>nano </li>
<li>Gemini OpenAI tool call </li>
</ul>
</div>
<h2 className="text-2xl font-bold mb-3"></h2>
<CodeEditor
code={`
// 百炼等其他兼容服务
const pageAgent = new PageAgent({
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
apiKey: 'your-api-key',
modelName: 'qwen-plus'
});
// 私有部署模型
const pageAgent = new PageAgent({
baseURL: 'http://localhost:11434/v1',
apiKey: 'ollama', // Ollama 通常使用任意值
modelName: 'qwen3:latest'
});`}
/>
<div className="mt-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-yellow-900 dark:text-yellow-300">
💡
</h3>
<ul className="text-sm text-foreground/80 space-y-2">
<li>
<strong>baseURL</strong>: API OpenAI
</li>
<li>
<strong>apiKey</strong>: API
</li>
<li>
<strong>modelName</strong>: gpt-4.1-mini
</li>
</ul>
</div>
</div>
)
}

View File

@@ -0,0 +1,75 @@
import BetaNotice from '@pages/components/BetaNotice'
export default function SecurityPermissions() {
return (
<div>
<h1 className="text-4xl font-bold mb-6"></h1>
<BetaNotice />
<p className="text-xl text-foreground/80 mb-8 leading-relaxed">
page-agent AI
</p>
<div className="space-y-6">
<section>
<h2 className="text-2xl font-bold mb-3"></h2>
<div className="space-y-3">
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg">
<h3 className="text-lg font-semibold text-red-900 dark:text-red-300">
🚫
</h3>
<p className="text-foreground/80"> AI </p>
</div>
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<h3 className="text-lg font-semibold text-green-900 dark:text-green-300">
</h3>
<p className="text-foreground/80"> AI </p>
</div>
</div>
</section>
<section>
<h2 className="text-2xl font-bold mb-3">URL </h2>
<div className="space-y-3">
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg">
<h3 className="text-lg font-semibold text-red-900 dark:text-red-300">
🚫 URL
</h3>
<p className="text-foreground/80"> AI 访</p>
</div>
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<h3 className="text-lg font-semibold text-green-900 dark:text-green-300">
URL
</h3>
<p className="text-foreground/80"> AI 访</p>
</div>
</div>
</section>
<section>
<h2 className="text-2xl font-bold mb-3">Instruction </h2>
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-yellow-900 dark:text-yellow-300">
</h3>
<p className="text-foreground/80 mb-3">
AI
</p>
<div className="space-y-2">
<div className="pl-3 border-l-2 border-red-400">
<p className="font-medium text-red-700 dark:text-red-300"></p>
<p className="text-sm text-foreground/70"></p>
</div>
<div className="pl-3 border-l-2 border-orange-400">
<p className="font-medium text-orange-700 dark:text-orange-300"></p>
<p className="text-sm text-foreground/70"></p>
</div>
</div>
</div>
</section>
</div>
</div>
)
}

View File

@@ -0,0 +1,41 @@
import BetaNotice from '@pages/components/BetaNotice'
import CodeEditor from '@pages/components/CodeEditor'
export default function ApiReference() {
return (
<div>
<h1 className="text-4xl font-bold mb-6">API </h1>
<BetaNotice />
<p className="text-xl text-foreground/80 mb-6 leading-relaxed">
API
</p>
<h2 className="text-2xl font-bold mb-3"></h2>
<div className="space-y-4 mb-6">
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 className="text-lg font-semibold mb-2 font-mono">pageAgent.init(config)</h3>
<p className="text-foreground/80 mb-3"> page-agent</p>
<CodeEditor code={`const pageAgent = new PageAgent`} />
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 className="text-lg font-semibold mb-2 font-mono">pageAgent.execute(instruction)</h3>
<p className="text-foreground/80 mb-3"></p>
<CodeEditor
code={`await pageAgent.execute('点击提交按钮');
await pageAgent.execute('填写用户名为张三');`}
/>
</div>
</div>
<h2 className="text-2xl font-bold mb-3"></h2>
<CodeEditor code={`// TODO`} />
</div>
)
}

View File

@@ -0,0 +1,62 @@
import BetaNotice from '@pages/components/BetaNotice'
import CodeEditor from '@pages/components/CodeEditor'
export default function BestPractices() {
return (
<div>
<h1 className="text-4xl font-bold mb-6"></h1>
<BetaNotice />
<p className="text-xl text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
使 page-agent
</p>
<h2 className="text-2xl font-bold mb-3"></h2>
<div className="space-y-4 mb-6">
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-green-900 dark:text-green-300">
API
</h3>
<p className="text-foreground/80 mb-3"> AI </p>
<CodeEditor
code={`// 推荐:合并操作
await pageAgent.execute('填写表单姓名张三邮箱test@example.com然后提交');
// 不推荐:分别操作
await pageAgent.execute('填写姓名张三');
await pageAgent.execute('填写邮箱test@example.com');
await pageAgent.execute('点击提交按钮');`}
/>
</div>
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-300">
🎯
</h3>
<p className="text-foreground/80">使</p>
</div>
</div>
<h2 className="text-2xl font-bold mb-3"></h2>
<div className="space-y-3 mb-6">
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border-l-4 border-red-500">
<h3 className="font-semibold mb-1 text-red-900 dark:text-red-300"></h3>
<p className="text-foreground/80"></p>
</div>
<div className="p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border-l-4 border-yellow-500">
<h3 className="font-semibold mb-1 text-yellow-900 dark:text-yellow-300"></h3>
<p className="text-foreground/80"></p>
</div>
</div>
<h2 className="text-2xl font-bold mb-3"></h2>
<CodeEditor code={`// TODO`} />
</div>
)
}

View File

@@ -0,0 +1,41 @@
import BetaNotice from '@pages/components/BetaNotice'
import CodeEditor from '@pages/components/CodeEditor'
export default function CdnSetup() {
return (
<div>
<h1 className="text-4xl font-bold mb-6">CDN </h1>
<BetaNotice />
<p className="text-xl text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
CDN page-agent
</p>
<h2 className="text-2xl font-bold mb-3"></h2>
<CodeEditor
className="mb-8"
code={`<!-- 在 HTML 中引入 -->
// @todo find a cdn
<script src="https://some-cdn.com/page-agent.umd.js"></script>
<script>
const pageAgent = new PageAgent()
pageAgent.panel.show()
</script>`}
/>
<div className="bg-yellow-50 dark:bg-yellow-900/20 p-4 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-yellow-900 dark:text-yellow-300">
</h3>
<ul className="text-foreground/80 space-y-1">
<li> 使</li>
<li> HTTPS 使</li>
<li> CSP </li>
</ul>
</div>
</div>
)
}

View File

@@ -0,0 +1,38 @@
import BetaNotice from '@pages/components/BetaNotice'
import CodeEditor from '@pages/components/CodeEditor'
export default function Configuration() {
return (
<div>
<h1 className="text-4xl font-bold mb-6"></h1>
<BetaNotice />
<p className="text-xl text-foreground/80 mb-6 leading-relaxed">
page-agent
</p>
<h2 className="text-2xl font-bold mb-3"></h2>
<CodeEditor className="mb-8" code={`// TODO`} />
<h2 className="text-2xl font-bold mb-3"></h2>
<div className="space-y-4">
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-300">
🎯
</h3>
<p className="text-foreground/80"> AI </p>
</div>
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-green-900 dark:text-green-300">
</h3>
<p className="text-foreground/80"></p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,113 @@
import CodeEditor from '@pages/components/CodeEditor'
export default function ThirdPartyAgentPage() {
return (
<div>
<h1 className="text-4xl font-bold mb-6"> Agent</h1>
<p className="mb-6 leading-relaxed">
pageAgent Agent Agent
</p>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 mb-6">
<h3 className="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-300">💡 </h3>
<p className="text-blue-800 dark:text-blue-200">
Agent "嘴巴""眼睛""手"
</p>
</div>
<h2 className="text-2xl font-bold mb-4"></h2>
<div className="space-y-4 mb-6">
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-green-900 dark:text-green-300">
1. Function Calling
</h3>
<CodeEditor
code={`// 定义工具
const pageAgentTool = {
name: "page_agent",
description: "执行网页操作",
parameters: {
type: "object",
properties: {
instruction: { type: "string", description: "操作指令" }
},
required: ["instruction"]
},
execute: async (params) => {
const result = await pageAgent.execute(params.instruction)
return { success: result.success, message: result.message }
}
}
// 注册到你的 agent 中`}
language="javascript"
/>
</div>
</div>
<h2 className="text-2xl font-bold mb-4"></h2>
<div className="grid md:grid-cols-2 gap-4 mb-6">
<div className="bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-800 dark:to-gray-700 p-4 rounded-lg">
<h4 className="font-semibold mb-2">🤖 </h4>
<p className="text-sm text-gray-600 dark:text-gray-300">
"帮我提交工单"
</p>
</div>
<div className="bg-gradient-to-br from-green-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 p-4 rounded-lg">
<h4 className="font-semibold mb-2">📋 </h4>
<p className="text-sm text-gray-600 dark:text-gray-300">
"完成客户入职"
</p>
</div>
<div className="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-gray-800 dark:to-gray-700 p-4 rounded-lg">
<h4 className="font-semibold mb-2">🎯 </h4>
<p className="text-sm text-gray-600 dark:text-gray-300">
"预订会议室"
</p>
</div>
<div className="bg-gradient-to-br from-orange-50 to-red-50 dark:from-gray-800 dark:to-gray-700 p-4 rounded-lg">
<h4 className="font-semibold mb-2">🔧 </h4>
<p className="text-sm text-gray-600 dark:text-gray-300">
"重启服务器"
</p>
</div>
</div>
<h2 className="text-2xl font-bold mb-4"></h2>
<div className="space-y-4 mb-6">
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 className="text-lg font-semibold mb-2"></h3>
<CodeEditor code={`// @TODO`} language="javascript" />
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 className="text-lg font-semibold mb-2"></h3>
<CodeEditor code={`// @TODO`} language="javascript" />
</div>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6">
<h3 className="text-lg font-semibold text-yellow-900 dark:text-yellow-100 mb-2">
</h3>
<ul className="text-yellow-800 dark:text-yellow-200 space-y-1 text-sm">
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
<div className="bg-gradient-to-r from-green-50 to-blue-50 dark:from-green-900/20 dark:to-blue-900/20 p-4 rounded-lg">
<h3 className="text-lg font-semibold mb-2">🎉 </h3>
<p className="mb-3"> Agent </p>
<a
href="/docs/integration/configuration"
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors duration-200"
>
</a>
</div>
</div>
)
}

View File

@@ -0,0 +1,156 @@
export default function LimitationsPage() {
return (
<div className="max-w-4xl mx-auto">
<div className="mb-8">
<h1 className="text-4xl font-bold mb-4 text-gray-900 dark:text-white">使</h1>
<p className="text-xl text-gray-600 dark:text-gray-300">
page-agent
</p>
</div>
<div className="prose prose-lg dark:prose-invert max-w-none">
<h2 className="text-2xl font-bold mb-3"></h2>
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-4 border-blue-400 p-4 mb-6">
<h3 className="font-semibold text-blue-800 dark:text-blue-200 mb-2"></h3>
<ul className="text-blue-700 dark:text-blue-300 space-y-2">
<li>
<strong>SPA</strong>
</li>
<li>
<strong></strong>
</li>
<li>
<strong></strong> page-agent
</li>
</ul>
</div>
<h2 className="text-2xl font-bold mb-3"></h2>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-6 mb-6">
<h3 className="font-semibold mb-4"></h3>
<div className="grid md:grid-cols-2 gap-4 mb-6">
<div className="space-y-2">
<div className="flex items-center text-green-600 dark:text-green-400">
<span className="mr-2"></span>
<span></span>
</div>
<div className="flex items-center text-green-600 dark:text-green-400">
<span className="mr-2"></span>
<span></span>
</div>
<div className="flex items-center text-green-600 dark:text-green-400">
<span className="mr-2"></span>
<span></span>
</div>
<div className="flex items-center text-green-600 dark:text-green-400">
<span className="mr-2"></span>
<span></span>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center text-green-600 dark:text-green-400">
<span className="mr-2"></span>
<span></span>
</div>
<div className="flex items-center text-green-600 dark:text-green-400">
<span className="mr-2"></span>
<span></span>
</div>
</div>
</div>
<h3 className="font-semibold mb-4"></h3>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex items-center text-red-600 dark:text-red-400">
<span className="mr-2"></span>
<span>hover</span>
</div>
<div className="flex items-center text-red-600 dark:text-red-400">
<span className="mr-2"></span>
<span></span>
</div>
<div className="flex items-center text-red-600 dark:text-red-400">
<span className="mr-2"></span>
<span></span>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center text-red-600 dark:text-red-400">
<span className="mr-2"></span>
<span></span>
</div>
<div className="flex items-center text-red-600 dark:text-red-400">
<span className="mr-2"></span>
<span></span>
</div>
<div className="flex items-center text-red-600 dark:text-red-400">
<span className="mr-2"></span>
<span></span>
</div>
</div>
</div>
</div>
<h2 className="text-2xl font-bold mb-3"></h2>
<div className="bg-red-50 dark:bg-red-900/20 border-l-4 border-red-400 p-4 mb-6">
<h3 className="font-semibold text-red-800 dark:text-red-200 mb-2"></h3>
<p className="text-red-700 dark:text-red-300 mb-3">
page-agent DOM <strong></strong>
</p>
<ul className="text-red-700 dark:text-red-300 space-y-1">
<li>
<strong></strong>
</li>
<li>
<strong>Canvas </strong> Canvas
</li>
<li>
<strong>WebGL 3D </strong> 3D
</li>
<li>
<strong>SVG </strong> SVG
</li>
</ul>
</div>
<h2 className="text-2xl font-bold mb-3"></h2>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-6 mb-6">
<div className="space-y-4">
<div>
<h3 className="font-semibold mb-2"></h3>
<p className="text-gray-600 dark:text-gray-300">
DOM
accessibility AI
</p>
</div>
<div>
<h3 className="font-semibold mb-2">UI/UX</h3>
<p className="text-gray-600 dark:text-gray-300">
AI
</p>
</div>
<div>
<h3 className="font-semibold mb-2"></h3>
<p className="text-gray-600 dark:text-gray-300">modern browser</p>
</div>
</div>
</div>
<h2></h2>
<div className="bg-green-50 dark:bg-green-900/20 border-l-4 border-green-400 p-4">
<h3 className="font-semibold text-green-800 dark:text-green-200 mb-2"></h3>
<ul className="text-green-700 dark:text-green-300 space-y-1">
<li> </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,188 @@
export default function Overview() {
return (
<article>
{/* 头图 */}
<figure className="mb-8 rounded-xl overflow-hidden">
<img
src="https://img.alicdn.com/imgextra/i4/O1CN01eppTGh27iefUEegnN_!!6000000007831-0-tps-3015-1024.jpg"
alt="page-agent 概览图示"
className="w-full h-64 object-cover"
/>
</figure>
<h1 className="text-4xl font-bold mb-6">Overview</h1>
<p className="text-xl text-foreground/80 mb-8 leading-relaxed">
page-agent Web技术的 UI Agent AI CDN
Web
</p>
<section>
<h2 className="text-2xl font-bold mb-4"> page-agent</h2>
<p className="text-foreground/80 mb-8 leading-relaxed ">
page-agent <strong> UI Agent</strong>
page-agent <strong></strong>
Agent开发者 Agent
</p>
</section>
<section>
<h2 className="text-2xl font-bold mb-3"></h2>
<div className="grid md:grid-cols-2 gap-4 mb-8" role="list">
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-300">
🧠 DOM
</h3>
<p className="">
token DOM
</p>
</div>
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-purple-900 dark:text-purple-300">
🔒
</h3>
<p className="">
AI
</p>
</div>
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-green-900 dark:text-green-300">
</h3>
<p className="">CDN LLM OpenAI qwen3</p>
</div>
<div className="p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-orange-900 dark:text-orange-300">
</h3>
<p className=""> B端系统</p>
</div>
</div>
<h2 className="text-2xl font-bold mb-4"> browser-use </h2>
<div className="overflow-x-auto mb-8">
<table className="w-full border-collapse border border-gray-300 dark:border-gray-600">
<thead>
<tr className="bg-gray-50 dark:bg-gray-800">
<th className="border border-gray-300 dark:border-gray-600 px-4 py-3 text-left">
</th>
<th className="border border-gray-300 dark:border-gray-600 px-4 py-3 text-left">
page-agent
</th>
<th className="border border-gray-300 dark:border-gray-600 px-4 py-3 text-left">
browser-use
</th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3 font-medium">
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3">
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3"></td>
</tr>
<tr>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3 font-medium">
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3"></td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3">
</td>
</tr>
<tr>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3 font-medium">
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3">
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3">
/Agent
</td>
</tr>
<tr>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3 font-medium">
使
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3">
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3">
</td>
</tr>
</tbody>
</table>
</div>
<h2 className="text-2xl font-bold mb-4"></h2>
<ul className="space-y-4 mb-8">
<li className="flex items-start space-x-3">
<span className="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center font-bold mt-0.5">
1
</span>
<div className="">
<strong></strong>
Agent"请先点击设置按钮然后点击..."
</div>
</li>
<li className="flex items-start space-x-3">
<span className="w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center font-bold mt-0.5">
2
</span>
<div className="">
<strong>/</strong>
Agent B
</div>
</li>
<li className="flex items-start space-x-3">
<span className="w-6 h-6 bg-purple-500 text-white rounded-full flex items-center justify-center font-bold mt-0.5">
3
</span>
<div className="">
<strong></strong>
AI演示"如何提交报销申请"
</div>
</li>
<li className="flex items-start space-x-3">
<span className="w-6 h-6 bg-orange-500 text-white rounded-full flex items-center justify-center font-bold mt-0.5">
4
</span>
<div className="">
<strong></strong>
</div>
</li>
</ul>
<div className="bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-900/20 dark:to-purple-900/20 p-4 rounded-lg">
<h3 className="text-lg font-semibold mb-2">🚀 使</h3>
<p className="mb-3 ">
AI
</p>
<a
href="/docs/introduction/quick-start"
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors duration-200"
role="button"
>
</a>
</div>
</section>
</article>
)
}

View File

@@ -0,0 +1,81 @@
import BetaNotice from '@pages/components/BetaNotice'
import CodeEditor from '@pages/components/CodeEditor'
export default function QuickStart() {
return (
<div>
<h1 className="text-4xl font-bold mb-6">Quick Start</h1>
<BetaNotice />
<p className=" mb-6 leading-relaxed">
page-agent AI
</p>
<h2 className="text-2xl font-bold mb-3"></h2>
<div className="space-y-4 mb-6">
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-300">
1.
</h3>
<div className="space-y-3">
<div>
<p className="text-sm font-medium mb-2">CDN </p>
<CodeEditor
code={`// 仅供测试使用,生产环境联系 @西萌
<script src="https://dev.g.alicdn.com/dt/page-use.js/0.0.1/lib/page-agent.umd.cjs"></script>`}
language="html"
/>
</div>
<div>
<p className="text-sm font-medium mb-2">NPM </p>
<CodeEditor
code={`// npm install page-agent
import PageAgent from 'page-agent'`}
language="bash"
/>
</div>
</div>
</div>
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-green-900 dark:text-green-300">
2.
</h3>
<CodeEditor
code={`// 仅供测试使用,生产环境需要配置 LLM 接入点,本工具不提供 LLM 服务
const pageAgent = new PageAgent()`}
language="javascript"
/>
</div>
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-purple-900 dark:text-purple-300">
3. 使
</h3>
<CodeEditor
code={`// 程序化执行自然语言指令
await pageAgent.execute('点击提交按钮,然后填写用户名为张三');
// 或者显示对话框让用户输入指令
pageAgent.panel.show()
`}
language="javascript"
/>
</div>
</div>
<div className="bg-gradient-to-r from-green-50 to-blue-50 dark:from-green-900/20 dark:to-blue-900/20 p-4 rounded-lg">
<h3 className="text-lg font-semibold mb-2">🎉 </h3>
<p className="mb-3 "> AI </p>
<a
href="/docs/features/security-permissions"
className="inline-flex items-center px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors duration-200"
>
</a>
</div>
</div>
)
}

33
pages/index.css Normal file
View File

@@ -0,0 +1,33 @@
@import 'tailwindcss';
:root {
--background: #ffffff;
--foreground: #171717;
/* 主题色渐变 */
--theme-color-1: rgb(88, 192, 252);
--theme-color-2: rgb(189, 69, 251);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
/* 添加 Tailwind 自定义颜色 */
@theme {
--color-background: var(--background);
--color-foreground: var(--foreground);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

30
pages/main.tsx Normal file
View File

@@ -0,0 +1,30 @@
// import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Route, Router, Switch } from 'wouter'
import { useHashLocation } from 'wouter/use-hash-location'
import { default as PagesRouter } from './router.tsx'
import { default as TestPagesRouter } from './test-pages/router.tsx'
import './index.css'
let baseURL: string
// 如果是 localhost就用 /
// 如果是其他环境,阶段到 index.html
if (window.location.hostname === 'localhost') {
baseURL = '/'
} else {
baseURL = window.location.pathname.split('index.html')[0] + 'index.html'
}
createRoot(document.getElementById('root')!).render(
// <StrictMode>
<Router hook={useHashLocation}>
<Switch>
<Route path="/test-pages" component={TestPagesRouter} nest />
<Route path="/" component={PagesRouter} nest />
</Switch>
</Router>
// </StrictMode>
)

460
pages/page.tsx Normal file
View File

@@ -0,0 +1,460 @@
/* eslint-disable react-dom/no-dangerously-set-innerhtml */
import { useState } from 'react'
import { Link, useSearchParams } from 'wouter'
import { PageAgent } from '@/PageAgent.js'
import Footer from './components/Footer'
import Header from './components/Header'
const injection = encodeURI(
"javascript:(function(){var s=document.createElement('script');s.src=`https://dev.g.alicdn.com/dt/page-use.js/0.0.1/lib/page-agent.umd.cjs?t=${Math.random()}`;s.setAttribute('crossorigin', true);s.onload=()=>console.log('PageAgent ready!');document.head.appendChild(s);})();"
)
const injectionQwen = encodeURI(
"javascript:(function(){var s=document.createElement('script');s.src=`https://dev.g.alicdn.com/dt/page-use.js/0.0.1/lib/page-agent.umd.cjs?t=${Math.random()}&model=qwen-plus-latest`;s.setAttribute('crossorigin', true);s.onload=()=>console.log('PageAgent ready!');document.head.appendChild(s);})();"
)
const injectionA = `
<a
href=${injection}
class="inline-flex items-center text-xs px-3 py-2 bg-blue-500 text-white font-medium rounded-lg hover:shadow-md transform hover:scale-105 transition-all duration-200 cursor-move border-2 border-dashed border-green-300"
draggable="true"
title="拖拽我到收藏夹栏"
>
✨PageAgent
</a>
<a
href=${injectionQwen}
class="inline-flex items-center text-xs px-3 py-2 bg-purple-500 text-white font-medium rounded-lg hover:shadow-md transform hover:scale-105 transition-all duration-200 cursor-move border-2 border-dashed border-green-300"
draggable="true"
title="拖拽我到收藏夹栏"
>
✨PageAgent (Qwen)
</a>
`
export default function HomePage() {
const [task, setTask] = useState('进入文档页,打开数据脱敏相关的文档,帮我总结成 markdown')
const [params, setParams] = useSearchParams()
const isOther = params.has('try_other')
const [activeTab, setActiveTab] = useState<'try' | 'other'>(isOther ? 'other' : 'try')
const handleExecute = async () => {
if (!task.trim()) return
let pageAgent: PageAgent
if (window.pageAgent && !window.pageAgent.disposed) {
pageAgent = window.pageAgent
} else {
pageAgent = new PageAgent({
// 把 react 根元素排除掉,挂了很多冒泡时间导致假阳
interactiveBlacklist: [document.getElementById('root')!],
language: 'zh-CN',
})
window.pageAgent = pageAgent
}
const result = await pageAgent.execute(task)
console.log(result)
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-900 dark:to-gray-800">
<Header />
{/* Hero Section */}
<main id="main-content">
<section className="relative px-6 py-22 lg:py-28" aria-labelledby="hero-heading">
<div className="max-w-7xl mx-auto text-center">
{/* Background Pattern */}
<div className="absolute inset-0 opacity-30" aria-hidden="true">
<div className="absolute inset-0 bg-gradient-to-r from-blue-400/20 to-purple-400/20 rounded-3xl transform rotate-1"></div>
<div className="absolute inset-0 bg-gradient-to-l from-purple-400/20 to-blue-400/20 rounded-3xl transform -rotate-1"></div>
</div>
<div className="relative z-10">
<div className="inline-flex items-center px-4 py-2 mb-8 text-sm font-medium text-blue-700 bg-blue-100 rounded-full dark:text-blue-300 dark:bg-blue-900/30">
<span
className="w-2 h-2 bg-blue-500 rounded-full mr-2 animate-pulse"
aria-hidden="true"
></span>
UI Agent in your webpage
</div>
<h1
id="hero-heading"
className="text-5xl lg:text-7xl font-bold mb-8 bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent"
>
Web
<br />
AI
</h1>
<p className="text-xl lg:text-2xl text-gray-600 dark:text-gray-300 mb-12 max-w-4xl mx-auto leading-relaxed">
<span className="bg-gradient-to-r from-blue-500 to-purple-500 bg-clip-text text-transparent font-bold">
🪄 CDN
</span>
UI Agent
<br />
/AI
</p>
{/* Try It Now Section - Tab Card */}
<div className="mt-8 mb-6">
<div className="max-w-3xl mx-auto">
<div className="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Tab Headers */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setActiveTab('try')}
className={`flex-1 px-4 py-4 text-lg font-medium transition-colors duration-200 ${
activeTab === 'try'
? 'bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-900/30 dark:to-purple-900/30 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
🚀
</button>
<button
onClick={() => setActiveTab('other')}
className={`flex-1 px-4 py-4 text-lg font-medium transition-colors duration-200 ${
activeTab === 'other'
? 'bg-gradient-to-r from-green-50 to-blue-50 dark:from-green-900/30 dark:to-blue-900/30 text-green-700 dark:text-green-300 border-b-2 border-green-500'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
🌐
</button>
</div>
{/* Tab Content */}
<div className="p-4">
{activeTab === 'try' && (
<div className="space-y-4">
<div className="relative">
<input
value={task}
onChange={(e) => setTask(e.target.value)}
placeholder="输入您想要 AI 执行的任务..."
className="w-full px-4 py-3 pr-20 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none text-sm mb-0"
data-page-agent-not-interactive
/>
<button
onClick={handleExecute}
disabled={!task.trim()}
className="absolute right-2 top-2 px-5 py-1.5 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-medium rounded-md hover:shadow-md transform hover:scale-105 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none text-sm"
data-page-agent-not-interactive
>
</button>
</div>
</div>
)}
{activeTab === 'other' && (
<div className="grid md:grid-cols-2 gap-6">
{/* 左侧:操作步骤 */}
<div className="space-y-4">
{/* Keyboard Shortcut Hint */}
<div className="bg-blue-50 dark:bg-gray-700 p-4 rounded-lg">
<p className="text-gray-700 dark:text-gray-300 text-sm mb-3">
<span className="font-semibold"> 1:</span>
</p>
<div className="flex items-center justify-center gap-2">
<kbd className="px-2 py-1 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded text-xs font-mono">
Ctrl + Shift + B
</kbd>
<span className="text-gray-500 dark:text-gray-400"></span>
<kbd className="px-2 py-1 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded text-xs font-mono">
+ Shift + B
</kbd>
</div>
</div>
{/* Draggable Bookmarklet */}
<div className="bg-green-50 dark:bg-gray-700 p-4 rounded-lg">
<p className="text-gray-700 dark:text-gray-300 text-sm mb-3">
<span className="font-semibold"> 2:</span>{' '}
</p>
<div
className="flex items-center justify-center gap-2 text-gray-500 dark:text-gray-400"
dangerouslySetInnerHTML={{ __html: injectionA }}
></div>
</div>
{/* Usage Instructions */}
<div className="bg-purple-50 dark:bg-gray-700 p-4 rounded-lg">
<p className="text-gray-700 dark:text-gray-300 text-sm">
<span className="font-semibold"> 3:</span>{' '}
使
</p>
</div>
</div>
{/* 右侧:注意事项 */}
<div className="bg-yellow-50 dark:bg-gray-700 p-4 rounded-lg">
<h4 className="font-semibold text-gray-900 dark:text-white mb-3 text-sm">
</h4>
<ul className="space-y-2 text-sm text-gray-700 dark:text-gray-300">
<li className="flex items-start text-left">
<span className="w-1.5 h-1.5 bg-yellow-500 rounded-full mt-2 mr-2 flex-shrink-0 "></span>
</li>
<li className="flex items-start text-left">
<span className="w-1.5 h-1.5 bg-yellow-500 rounded-full mt-2 mr-2 flex-shrink-0 "></span>
</li>
<li className="flex items-start text-left">
<span className="w-1.5 h-1.5 bg-yellow-500 rounded-full mt-2 mr-2 flex-shrink-0 "></span>
</li>
<li className="flex items-start text-left">
<span className="w-1.5 h-1.5 bg-yellow-500 rounded-full mt-2 mr-2 flex-shrink-0 "></span>
使{' '}
<Link
href="/docs/introduction/limitations"
className="text-blue-600 dark:text-blue-400 hover:underline"
>
</Link>
</li>
<li className="flex items-start text-left">
<span className="w-1.5 h-1.5 bg-yellow-500 rounded-full mt-2 mr-2 flex-shrink-0 "></span>
CSP
</li>
<li className="flex items-start text-left">
<span className="w-1.5 h-1.5 bg-yellow-500 rounded-full mt-2 mr-2 flex-shrink-0 "></span>
使 gpt-4.1-mini qwen
</li>
</ul>
</div>
</div>
)}
</div>
</div>
</div>
</div>
<ul
className="flex flex-wrap justify-center gap-6 text-sm text-gray-500 dark:text-gray-400"
role="list"
>
<li className="flex items-center">
<span
className="w-2 h-2 bg-green-500 rounded-full mr-2"
aria-hidden="true"
></span>
</li>
<li className="flex items-center">
<span
className="w-2 h-2 bg-green-500 rounded-full mr-2"
aria-hidden="true"
></span>
</li>
<li className="flex items-center">
<span
className="w-2 h-2 bg-green-500 rounded-full mr-2"
aria-hidden="true"
></span>
</li>
<li className="flex items-center">
<span
className="w-2 h-2 bg-green-500 rounded-full mr-2"
aria-hidden="true"
></span>
DOM
</li>
</ul>
</div>
</div>
</section>
{/* Features Section */}
<section
className="px-6 py-20 bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm"
aria-labelledby="features-heading"
>
<div className="max-w-7xl mx-auto">
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8" role="list">
{/* Feature 1 */}
<article
className="group p-8 bg-gradient-to-br from-blue-100 to-purple-100 dark:from-gray-700 dark:to-gray-800 rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 border border-gray-100 dark:border-gray-700"
role="listitem"
>
<div
className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300"
aria-hidden="true"
>
<span className="text-white text-xl">🧠</span>
</div>
<h3 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">
DOM
</h3>
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
DOM
</p>
</article>
{/* Feature 2 */}
<article
className="group p-8 bg-gradient-to-br from-green-100 to-blue-100 dark:from-gray-700 dark:to-gray-800 rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 border border-gray-100 dark:border-gray-700"
role="listitem"
>
<div
className="w-12 h-12 bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300"
aria-hidden="true"
>
<span className="text-white text-xl">🔒</span>
</div>
<h3 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">
</h3>
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
AI
</p>
</article>
{/* Feature 3 */}
<article
className="group p-8 bg-gradient-to-br from-purple-100 to-pink-100 dark:from-gray-700 dark:to-gray-800 rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 border border-gray-100 dark:border-gray-700"
role="listitem"
>
<div
className="w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300"
aria-hidden="true"
>
<span className="text-white text-xl"></span>
</div>
<h3 className="text-xl font-bold mb-4 text-gray-900 dark:text-white"></h3>
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
CDN LLM OpenAI qwen3
</p>
</article>
{/* Feature 4 */}
<article
className="group p-8 bg-gradient-to-br from-orange-100 to-red-100 dark:from-gray-700 dark:to-gray-800 rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 border border-gray-100 dark:border-gray-700"
role="listitem"
>
<div
className="w-12 h-12 bg-gradient-to-br from-orange-500 to-orange-600 rounded-xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300"
aria-hidden="true"
>
<span className="text-white text-xl"></span>
</div>
<h3 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">
</h3>
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
B端系统
</p>
</article>
</div>
</div>
</section>
{/* Use Cases Section */}
<section className="px-6 py-20" aria-labelledby="use-cases-heading">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-16">
<h2
id="use-cases-heading"
className="text-4xl lg:text-5xl mb-6 text-gray-900 dark:text-white"
>
</h2>
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto">
AI
</p>
</div>
<div className="grid lg:grid-cols-2 gap-12" role="list">
{/* Use Case 1 */}
<div className="bg-gradient-to-br from-blue-100 to-purple-100 dark:from-gray-700 dark:to-gray-800 p-8 rounded-2xl">
<div className="flex items-start space-x-4 h-20">
<div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<span className="text-white font-bold">1</span>
</div>
<div>
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-white">
</h3>
<p className="text-gray-600 dark:text-gray-300">
Agent"请先点击设置按钮然后点击..."
</p>
</div>
</div>
</div>
{/* Use Case 2 */}
<div className="bg-gradient-to-br from-green-100 to-blue-100 dark:from-gray-700 dark:to-gray-800 p-8 rounded-2xl">
<div className="flex items-start space-x-4 h-20">
<div className="w-10 h-10 bg-green-500 rounded-lg flex items-center justify-center flex-shrink-0">
<span className="text-white font-bold">2</span>
</div>
<div>
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-white">
/
</h3>
<p className="text-gray-600 dark:text-gray-300">
Agent B
</p>
</div>
</div>
</div>
{/* Use Case 3 */}
<div className="bg-gradient-to-br from-purple-100 to-pink-100 dark:from-gray-700 dark:to-gray-800 p-8 rounded-2xl">
<div className="flex items-start space-x-4 h-20">
<div className="w-10 h-10 bg-purple-500 rounded-lg flex items-center justify-center flex-shrink-0">
<span className="text-white font-bold">3</span>
</div>
<div>
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-white">
</h3>
<p className="text-gray-600 dark:text-gray-300">
AI演示"如何提交报销申请"
</p>
</div>
</div>
</div>
{/* Use Case 4 */}
<div className="bg-gradient-to-br from-orange-100 to-red-100 dark:from-gray-700 dark:to-gray-800 p-8 rounded-2xl">
<div className="flex items-start space-x-4 h-20">
<div className="w-10 h-10 bg-orange-500 rounded-lg flex items-center justify-center flex-shrink-0">
<span className="text-white font-bold">4</span>
</div>
<div>
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-white">
</h3>
<p className="text-gray-600 dark:text-gray-300">
</p>
</div>
</div>
</div>
</div>
</div>
</section>
</main>
<Footer />
</div>
)
}

158
pages/router.tsx Normal file
View File

@@ -0,0 +1,158 @@
import { Route, Switch } from 'wouter'
import DocsLayout from './components/DocsLayout'
import Header from './components/Header'
// Features pages
import CustomTools from './docs/features/custom-tools/page'
import DataMasking from './docs/features/data-masking/page'
import KnowledgeInjection from './docs/features/knowledge-injection/page'
import ModelIntegration from './docs/features/model-integration/page'
import SecurityPermissions from './docs/features/security-permissions/page'
import ApiReference from './docs/integration/api-reference/page'
import BestPractices from './docs/integration/best-practices/page'
// Integration pages
import CdnSetup from './docs/integration/cdn-setup/page'
import Configuration from './docs/integration/configuration/page'
import ThirdPartyAgent from './docs/integration/third-party-agent/page'
import Limitations from './docs/introduction/limitations/page'
// Introduction pages
import Overview from './docs/introduction/overview/page'
import QuickStart from './docs/introduction/quick-start/page'
import HomePage from './page'
export default function Router() {
return (
<Switch>
{/* Home page */}
<Route path="/" component={HomePage} />
{/* Documentation pages with layout */}
<Route path="/docs/introduction/overview">
<div className="min-h-screen bg-white dark:bg-gray-900">
<Header />
<DocsLayout>
<Overview />
</DocsLayout>
</div>
</Route>
<Route path="/docs/introduction/quick-start">
<div className="min-h-screen bg-white dark:bg-gray-900">
<Header />
<DocsLayout>
<QuickStart />
</DocsLayout>
</div>
</Route>
<Route path="/docs/introduction/limitations">
<div className="min-h-screen bg-white dark:bg-gray-900">
<Header />
<DocsLayout>
<Limitations />
</DocsLayout>
</div>
</Route>
<Route path="/docs/features/security-permissions">
<div className="min-h-screen bg-white dark:bg-gray-900">
<Header />
<DocsLayout>
<SecurityPermissions />
</DocsLayout>
</div>
</Route>
<Route path="/docs/features/custom-tools">
<div className="min-h-screen bg-white dark:bg-gray-900">
<Header />
<DocsLayout>
<CustomTools />
</DocsLayout>
</div>
</Route>
<Route path="/docs/features/data-masking">
<div className="min-h-screen bg-white dark:bg-gray-900">
<Header />
<DocsLayout>
<DataMasking />
</DocsLayout>
</div>
</Route>
<Route path="/docs/features/knowledge-injection">
<div className="min-h-screen bg-white dark:bg-gray-900">
<Header />
<DocsLayout>
<KnowledgeInjection />
</DocsLayout>
</div>
</Route>
<Route path="/docs/features/model-integration">
<div className="min-h-screen bg-white dark:bg-gray-900">
<Header />
<DocsLayout>
<ModelIntegration />
</DocsLayout>
</div>
</Route>
<Route path="/docs/integration/cdn-setup">
<div className="min-h-screen bg-white dark:bg-gray-900">
<Header />
<DocsLayout>
<CdnSetup />
</DocsLayout>
</div>
</Route>
<Route path="/docs/integration/configuration">
<div className="min-h-screen bg-white dark:bg-gray-900">
<Header />
<DocsLayout>
<Configuration />
</DocsLayout>
</div>
</Route>
<Route path="/docs/integration/api-reference">
<div className="min-h-screen bg-white dark:bg-gray-900">
<Header />
<DocsLayout>
<ApiReference />
</DocsLayout>
</div>
</Route>
<Route path="/docs/integration/best-practices">
<div className="min-h-screen bg-white dark:bg-gray-900">
<Header />
<DocsLayout>
<BestPractices />
</DocsLayout>
</div>
</Route>
<Route path="/docs/integration/third-party-agent">
<div className="min-h-screen bg-white dark:bg-gray-900">
<Header />
<DocsLayout>
<ThirdPartyAgent />
</DocsLayout>
</div>
</Route>
{/* 404 page */}
<Route>
<div className="min-h-screen bg-white dark:bg-gray-900 flex items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4 text-gray-900 dark:text-white">404</h1>
<p className="text-xl text-gray-600 dark:text-gray-300"></p>
</div>
</div>
</Route>
</Switch>
)
}

209
pages/test-pages/README.md Normal file
View File

@@ -0,0 +1,209 @@
# Page Use Agent 测试页面
这个目录包含了一系列专门设计的测试页面,用于验证 Page Use Agent 的各种能力。每个页面都模拟了真实 Web 应用中的常见交互模式和边界情况。
## 测试页面列表
### 1. 表单测试页面 (`/test-pages/form`)
**测试目标:** 表单填写、验证和提交能力
**包含功能:**
- 各种输入类型文本、邮箱、密码、数字、日期、电话、URL
- 下拉选择框和复选框
- 实时表单验证
- 异步提交和错误处理
- 重置表单功能
**测试任务示例:**
- 填写完整的用户注册表单并提交
- 故意输入错误信息触发验证错误
- 测试密码确认功能
- 尝试提交空表单查看错误提示
### 2. 导航测试页面 (`/test-pages/navigation`)
**测试目标:** 复杂导航和交互元素处理
**包含功能:**
- 顶部导航栏和下拉菜单
- 面包屑导航
- 标签页切换
- 模态框弹窗
- 通知系统
- 用户菜单
**测试任务示例:**
- 点击产品下拉菜单选择不同选项
- 切换不同的标签页查看内容
- 打开和关闭模态框
- 点击面包屑导航
- 添加新通知并标记为已读
### 3. 列表测试页面 (`/test-pages/list`)
**测试目标:** 列表操作、搜索、过滤和分页
**包含功能:**
- 产品列表展示(网格和列表视图)
- 搜索功能
- 类别过滤
- 排序功能
- 分页导航
- 加载状态和骨架屏
**测试任务示例:**
- 搜索特定产品名称
- 按价格排序产品列表
- 切换网格和列表视图
- 使用分页浏览不同页面
- 按类别过滤产品
### 4. 复杂交互测试页面 (`/test-pages/complex`)
**测试目标:** 多步骤流程和状态管理
**包含功能:**
- 购物车管理(添加、删除、修改数量)
- 多步骤向导流程
- 步骤验证和导航
- 订单确认流程
- 异步提交处理
**测试任务示例:**
- 完成完整的购买流程
- 在向导中前进和后退
- 修改购物车中的商品数量
- 添加新商品到购物车
- 提交订单并处理可能的错误
### 5. 错误处理测试页面 (`/test-pages/errors`)
**测试目标:** 错误识别和重试机制
**包含功能:**
- 网络连接错误模拟
- 表单验证错误
- 权限不足错误
- 请求超时错误
- 服务器内部错误
- 文件上传错误处理
**测试任务示例:**
- 触发网络错误并重试
- 提交不完整表单查看验证错误
- 测试权限验证(用户名需为"admin"
- 上传超大文件触发错误
- 处理各种错误场景的重试逻辑
### 6. 异步操作测试页面 (`/test-pages/async`)
**测试目标:** 等待和异步操作处理
**包含功能:**
- 文件上传进度条
- 实时数据更新
- 数据加载骨架屏
- 长时间运行任务
- 进度跟踪和日志显示
**测试任务示例:**
- 启动文件上传并等待完成
- 开启实时数据更新功能
- 加载数据并等待所有项目完成
- 执行长时间任务并监控进度
- 处理上传失败的重试
## 测试任务集合
### 基础操作测试
1. **导航测试**
- 前往表单测试页面
- 返回测试页面首页
- 前往导航测试页面
2. **表单填写测试**
- 填写用户注册表单的所有必填字段
- 提交表单并等待结果
- 重置表单并重新填写
3. **搜索和过滤测试**
- 在列表页面搜索"Apple"
- 按价格降序排列产品
- 过滤显示"手机"类别的产品
### 中级交互测试
4. **购物流程测试**
- 前往复杂交互页面
- 添加商品到购物车
- 完成多步骤购买流程
- 填写个人信息、地址和支付信息
- 提交订单
5. **导航和菜单测试**
- 点击产品下拉菜单选择"手机"
- 切换到"订单管理"标签页
- 打开模态框并关闭
- 添加新的面包屑导航
6. **异步操作测试**
- 启动文件上传
- 开启实时数据更新
- 执行长时间任务并等待完成
### 高级错误处理测试
7. **错误恢复测试**
- 触发网络连接错误
- 重试失败的操作
- 处理表单验证错误
- 测试权限验证(用户名输入"admin"
8. **边界情况测试**
- 提交空表单查看错误
- 上传不支持的文件类型
- 在向导中跳过必填步骤
- 处理超时错误
### 综合场景测试
9. **完整用户流程**
- 浏览产品列表
- 搜索并过滤产品
- 添加产品到购物车
- 完成购买流程
- 处理可能出现的错误
10. **压力和边界测试**
- 快速连续点击按钮
- 在加载过程中尝试其他操作
- 测试各种错误恢复场景
- 验证所有异步操作的完成
## 使用说明
### 对于 Agent 开发者
- 每个页面都包含了详细的状态指示器和反馈信息
- 错误信息清晰明确,便于 Agent 理解和处理
- 异步操作都有明确的完成标志
- 所有交互元素都有适当的可访问性标记
### 对于测试人员
- 可以按照测试任务逐一验证 Agent 的能力
- 每个页面都是独立的,可以单独测试
- 包含了各种真实场景的模拟
- 错误场景是随机的,确保测试的真实性
### 技术特性
- 使用 React + TypeScript 构建
- 响应式设计,支持不同屏幕尺寸
- 深色模式支持
- 无需外部依赖,完全自包含
- 模拟真实的网络延迟和错误
## 扩展建议
如需添加新的测试场景,建议考虑以下方面:
- 特定行业的业务流程
- 更复杂的数据可视化交互
- 多媒体内容处理
- 实时协作功能
- 移动端特有的交互模式
每个新页面都应该:
- 有明确的测试目标
- 包含多种难度级别的任务
- 提供清晰的状态反馈
- 模拟真实的用户场景

View File

@@ -0,0 +1,520 @@
import { useState, useEffect } from 'react'
import { Link } from 'wouter'
interface UploadProgress {
id: string
name: string
progress: number
status: 'uploading' | 'completed' | 'error'
speed: string
timeRemaining: string
}
interface DataItem {
id: number
title: string
content: string
timestamp: string
status: 'loading' | 'loaded' | 'error'
}
export default function AsyncTestPage() {
const [uploads, setUploads] = useState<UploadProgress[]>([])
const [dataItems, setDataItems] = useState<DataItem[]>([])
const [isLoadingData, setIsLoadingData] = useState(false)
const [realTimeData, setRealTimeData] = useState<string[]>([])
const [isRealTimeActive, setIsRealTimeActive] = useState(false)
const [longRunningTask, setLongRunningTask] = useState<{
isRunning: boolean
progress: number
currentStep: string
logs: string[]
}>({
isRunning: false,
progress: 0,
currentStep: '',
logs: []
})
// 模拟实时数据更新
useEffect(() => {
let interval: NodeJS.Timeout
if (isRealTimeActive) {
interval = setInterval(() => {
const newData = `数据更新 ${new Date().toLocaleTimeString()}: ${Math.floor(Math.random() * 1000)}`
setRealTimeData(prev => [newData, ...prev.slice(0, 9)]) // 保持最新10条
}, 2000)
}
return () => {
if (interval) clearInterval(interval)
}
}, [isRealTimeActive])
// 模拟文件上传
const simulateFileUpload = (fileName: string) => {
const uploadId = Date.now().toString()
const newUpload: UploadProgress = {
id: uploadId,
name: fileName,
progress: 0,
status: 'uploading',
speed: '0 KB/s',
timeRemaining: '计算中...'
}
setUploads(prev => [...prev, newUpload])
// 模拟上传进度
const interval = setInterval(() => {
setUploads(prev => prev.map(upload => {
if (upload.id === uploadId) {
const newProgress = Math.min(upload.progress + Math.random() * 15, 100)
const speed = `${(Math.random() * 500 + 100).toFixed(0)} KB/s`
const timeRemaining = newProgress >= 100 ? '完成' : `${Math.ceil((100 - newProgress) / 10)}`
// 模拟随机失败
if (newProgress > 50 && Math.random() < 0.1) {
clearInterval(interval)
return {
...upload,
status: 'error' as const,
speed: '0 KB/s',
timeRemaining: '失败'
}
}
if (newProgress >= 100) {
clearInterval(interval)
return {
...upload,
progress: 100,
status: 'completed' as const,
speed,
timeRemaining
}
}
return {
...upload,
progress: newProgress,
speed,
timeRemaining
}
}
return upload
}))
}, 500)
}
// 模拟数据加载
const loadData = async () => {
setIsLoadingData(true)
setDataItems([])
// 创建骨架屏数据
const skeletonItems: DataItem[] = Array.from({ length: 6 }, (_, i) => ({
id: i,
title: '',
content: '',
timestamp: '',
status: 'loading'
}))
setDataItems(skeletonItems)
// 逐个加载数据项
for (let i = 0; i < 6; i++) {
await new Promise(resolve => setTimeout(resolve, 800 + Math.random() * 1000))
setDataItems(prev => prev.map(item => {
if (item.id === i) {
// 模拟随机加载失败
if (Math.random() < 0.15) {
return {
...item,
status: 'error',
title: '加载失败',
content: '数据加载失败,请重试'
}
}
return {
...item,
status: 'loaded',
title: `数据项 ${i + 1}`,
content: `这是第 ${i + 1} 个数据项的内容,包含了一些示例文本用于展示加载效果。`,
timestamp: new Date().toLocaleString()
}
}
return item
}))
}
setIsLoadingData(false)
}
// 模拟长时间运行的任务
const startLongRunningTask = async () => {
setLongRunningTask({
isRunning: true,
progress: 0,
currentStep: '初始化任务...',
logs: ['任务开始']
})
const steps = [
{ name: '初始化任务...', duration: 2000 },
{ name: '连接服务器...', duration: 1500 },
{ name: '验证权限...', duration: 1000 },
{ name: '下载数据...', duration: 3000 },
{ name: '处理数据...', duration: 2500 },
{ name: '生成报告...', duration: 2000 },
{ name: '保存结果...', duration: 1000 },
{ name: '清理资源...', duration: 500 }
]
for (let i = 0; i < steps.length; i++) {
const step = steps[i]
setLongRunningTask(prev => ({
...prev,
currentStep: step.name,
logs: [...prev.logs, `开始: ${step.name}`]
}))
// 模拟步骤执行时间
const startTime = Date.now()
while (Date.now() - startTime < step.duration) {
await new Promise(resolve => setTimeout(resolve, 100))
const elapsed = Date.now() - startTime
const stepProgress = Math.min((elapsed / step.duration) * 100, 100)
const totalProgress = ((i + stepProgress / 100) / steps.length) * 100
setLongRunningTask(prev => ({
...prev,
progress: totalProgress
}))
}
setLongRunningTask(prev => ({
...prev,
logs: [...prev.logs, `完成: ${step.name}`]
}))
// 模拟随机失败
if (i === 3 && Math.random() < 0.2) {
setLongRunningTask(prev => ({
...prev,
isRunning: false,
currentStep: '任务失败',
logs: [...prev.logs, '错误: 数据下载失败,请重试']
}))
return
}
}
setLongRunningTask(prev => ({
...prev,
isRunning: false,
progress: 100,
currentStep: '任务完成',
logs: [...prev.logs, '任务成功完成!']
}))
}
const clearUploads = () => {
setUploads([])
}
const retryFailedUpload = (uploadId: string) => {
const failedUpload = uploads.find(u => u.id === uploadId)
if (failedUpload) {
setUploads(prev => prev.filter(u => u.id !== uploadId))
simulateFileUpload(failedUpload.name)
}
}
const retryDataLoad = (itemId: number) => {
setDataItems(prev => prev.map(item => {
if (item.id === itemId) {
return { ...item, status: 'loading', title: '', content: '', timestamp: '' }
}
return item
}))
setTimeout(() => {
setDataItems(prev => prev.map(item => {
if (item.id === itemId) {
return {
...item,
status: 'loaded',
title: `数据项 ${itemId + 1}`,
content: `这是重新加载的第 ${itemId + 1} 个数据项的内容。`,
timestamp: new Date().toLocaleString()
}
}
return item
}))
}, 1000)
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
<div className="max-w-6xl mx-auto px-4">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
</h1>
<p className="text-gray-600 dark:text-gray-300">
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* 文件上传进度 */}
<div className="space-y-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
</h2>
<div className="space-x-2">
<button
type="button"
onClick={() => simulateFileUpload(`文件_${Date.now()}.pdf`)}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors text-sm"
>
</button>
<button
type="button"
onClick={clearUploads}
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-md transition-colors text-sm"
>
</button>
</div>
</div>
<div className="space-y-4">
{uploads.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
"开始上传"
</div>
) : (
uploads.map(upload => (
<div key={upload.id} className="border border-gray-200 dark:border-gray-600 rounded-lg p-4">
<div className="flex justify-between items-center mb-2">
<span className="font-medium text-gray-900 dark:text-white">
{upload.name}
</span>
<span className={`text-sm ${
upload.status === 'completed' ? 'text-green-600 dark:text-green-400' :
upload.status === 'error' ? 'text-red-600 dark:text-red-400' :
'text-blue-600 dark:text-blue-400'
}`}>
{upload.status === 'completed' ? '✓ 完成' :
upload.status === 'error' ? '✗ 失败' :
'上传中...'}
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2">
<div
className={`h-2 rounded-full transition-all duration-300 ${
upload.status === 'completed' ? 'bg-green-500' :
upload.status === 'error' ? 'bg-red-500' :
'bg-blue-500'
}`}
style={{ width: `${upload.progress}%` }}
/>
</div>
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-300">
<span>{upload.progress.toFixed(1)}%</span>
<span>{upload.speed}</span>
<span>{upload.timeRemaining}</span>
</div>
{upload.status === 'error' && (
<button
type="button"
onClick={() => retryFailedUpload(upload.id)}
className="mt-2 px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-sm transition-colors"
>
</button>
)}
</div>
))
)}
</div>
</div>
{/* 实时数据更新 */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
</h2>
<button
type="button"
onClick={() => setIsRealTimeActive(!isRealTimeActive)}
className={`px-4 py-2 rounded-md transition-colors text-sm ${
isRealTimeActive
? 'bg-red-600 hover:bg-red-700 text-white'
: 'bg-green-600 hover:bg-green-700 text-white'
}`}
>
{isRealTimeActive ? '停止更新' : '开始更新'}
</button>
</div>
<div className="space-y-2 max-h-64 overflow-y-auto">
{realTimeData.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
"开始更新"
</div>
) : (
realTimeData.map((data, index) => (
<div
key={index}
className={`p-3 rounded-lg border transition-all duration-300 ${
index === 0
? 'bg-blue-50 dark:bg-blue-900 border-blue-200 dark:border-blue-700'
: 'bg-gray-50 dark:bg-gray-700 border-gray-200 dark:border-gray-600'
}`}
>
<span className="text-sm text-gray-900 dark:text-white">
{data}
</span>
</div>
))
)}
</div>
</div>
</div>
{/* 数据加载和长时间任务 */}
<div className="space-y-6">
{/* 数据加载骨架屏 */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
</h2>
<button
type="button"
onClick={loadData}
disabled={isLoadingData}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-md transition-colors text-sm"
>
{isLoadingData ? '加载中...' : '加载数据'}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{dataItems.map(item => (
<div key={item.id} className="border border-gray-200 dark:border-gray-600 rounded-lg p-4">
{item.status === 'loading' ? (
<div className="animate-pulse">
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-3/4 mb-2"></div>
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded w-full mb-1"></div>
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded w-2/3"></div>
</div>
) : item.status === 'error' ? (
<div>
<h3 className="font-medium text-red-600 dark:text-red-400 mb-2">
{item.title}
</h3>
<p className="text-sm text-red-500 dark:text-red-400 mb-2">
{item.content}
</p>
<button
type="button"
onClick={() => retryDataLoad(item.id)}
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-sm transition-colors"
>
</button>
</div>
) : (
<div>
<h3 className="font-medium text-gray-900 dark:text-white mb-2">
{item.title}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-2">
{item.content}
</p>
<span className="text-xs text-gray-500 dark:text-gray-400">
{item.timestamp}
</span>
</div>
)}
</div>
))}
</div>
</div>
{/* 长时间运行任务 */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
</h2>
<button
type="button"
onClick={startLongRunningTask}
disabled={longRunningTask.isRunning}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-400 text-white rounded-md transition-colors text-sm"
>
{longRunningTask.isRunning ? '执行中...' : '开始任务'}
</button>
</div>
{longRunningTask.progress > 0 && (
<div className="mb-4">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-900 dark:text-white">
{longRunningTask.currentStep}
</span>
<span className="text-sm text-gray-600 dark:text-gray-300">
{longRunningTask.progress.toFixed(1)}%
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-purple-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${longRunningTask.progress}%` }}
/>
</div>
</div>
)}
{longRunningTask.logs.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 max-h-48 overflow-y-auto">
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-2">
:
</h4>
<div className="space-y-1">
{longRunningTask.logs.map((log, index) => (
<div key={index} className="text-sm text-gray-600 dark:text-gray-300 font-mono">
{log}
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
{/* 返回链接 */}
<div className="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
<Link href="/" className="text-blue-600 hover:text-blue-500 dark:text-blue-400">
</Link>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,532 @@
import { useState } from 'react'
import { Link } from 'wouter'
interface CartItem {
id: number
name: string
price: number
quantity: number
image: string
}
interface WizardStep {
id: number
title: string
description: string
completed: boolean
}
export default function ComplexTestPage() {
const [currentStep, setCurrentStep] = useState(1)
const [cartItems, setCartItems] = useState<CartItem[]>([
{ id: 1, name: 'iPhone 15 Pro', price: 7999, quantity: 1, image: 'https://picsum.photos/100/100?random=1' },
{ id: 2, name: 'MacBook Air', price: 8999, quantity: 1, image: 'https://picsum.photos/100/100?random=2' }
])
const [wizardData, setWizardData] = useState({
personalInfo: { name: '', email: '', phone: '' },
address: { street: '', city: '', zipCode: '' },
payment: { cardNumber: '', expiryDate: '', cvv: '' }
})
const [wizardSteps, setWizardSteps] = useState<WizardStep[]>([
{ id: 1, title: '个人信息', description: '填写基本信息', completed: false },
{ id: 2, title: '收货地址', description: '填写收货地址', completed: false },
{ id: 3, title: '支付方式', description: '选择支付方式', completed: false },
{ id: 4, title: '确认订单', description: '确认订单信息', completed: false }
])
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [orderComplete, setOrderComplete] = useState(false)
// 购物车操作
const updateQuantity = (id: number, newQuantity: number) => {
if (newQuantity <= 0) {
removeItem(id)
return
}
setCartItems(prev =>
prev.map(item =>
item.id === id ? { ...item, quantity: newQuantity } : item
)
)
}
const removeItem = (id: number) => {
setCartItems(prev => prev.filter(item => item.id !== id))
}
const addItem = () => {
const newItem: CartItem = {
id: Date.now(),
name: `新产品 ${cartItems.length + 1}`,
price: Math.floor(Math.random() * 5000) + 1000,
quantity: 1,
image: `https://picsum.photos/100/100?random=${Date.now()}`
}
setCartItems(prev => [...prev, newItem])
}
const getTotalPrice = () => {
return cartItems.reduce((total, item) => total + item.price * item.quantity, 0)
}
// 向导步骤验证
const validateStep = (step: number): boolean => {
switch (step) {
case 1:
return !!(wizardData.personalInfo.name && wizardData.personalInfo.email && wizardData.personalInfo.phone)
case 2:
return !!(wizardData.address.street && wizardData.address.city && wizardData.address.zipCode)
case 3:
return !!(wizardData.payment.cardNumber && wizardData.payment.expiryDate && wizardData.payment.cvv)
default:
return true
}
}
const goToStep = (step: number) => {
// 验证当前步骤
if (step > currentStep && !validateStep(currentStep)) {
alert('请完成当前步骤的必填信息')
return
}
// 更新步骤完成状态
if (step > currentStep) {
setWizardSteps(prev =>
prev.map(s =>
s.id === currentStep ? { ...s, completed: true } : s
)
)
}
setCurrentStep(step)
}
const handleInputChange = (section: string, field: string, value: string) => {
setWizardData(prev => ({
...prev,
[section]: {
...prev[section as keyof typeof prev],
[field]: value
}
}))
}
const handleSubmitOrder = async () => {
setIsProcessing(true)
// 模拟处理时间
await new Promise(resolve => setTimeout(resolve, 3000))
// 模拟随机失败
if (Math.random() < 0.2) {
setIsProcessing(false)
alert('订单提交失败,请重试')
return
}
setIsProcessing(false)
setOrderComplete(true)
setShowConfirmDialog(false)
}
const resetWizard = () => {
setCurrentStep(1)
setWizardData({
personalInfo: { name: '', email: '', phone: '' },
address: { street: '', city: '', zipCode: '' },
payment: { cardNumber: '', expiryDate: '', cvv: '' }
})
setWizardSteps(prev => prev.map(s => ({ ...s, completed: false })))
setOrderComplete(false)
setShowConfirmDialog(false)
}
if (orderComplete) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="max-w-md mx-auto text-center">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
<div className="text-6xl mb-4">🎉</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
</h2>
<p className="text-gray-600 dark:text-gray-300 mb-6">
</p>
<div className="space-y-3">
<button
type="button"
onClick={resetWizard}
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-md transition-colors"
>
</button>
<Link href="/test-pages" className="block w-full bg-gray-600 hover:bg-gray-700 text-white py-2 px-4 rounded-md transition-colors text-center">
</Link>
</div>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
<div className="max-w-6xl mx-auto px-4">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
</h1>
<p className="text-gray-600 dark:text-gray-300">
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 购物车区域 */}
<div className="lg:col-span-1">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 sticky top-8">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
({cartItems.length})
</h3>
<div className="space-y-4 mb-6">
{cartItems.map(item => (
<div key={item.id} className="flex items-center space-x-3 p-3 border border-gray-200 dark:border-gray-600 rounded-lg">
<img
src={item.image}
alt={item.name}
className="w-12 h-12 object-cover rounded"
/>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-gray-900 dark:text-white truncate">
{item.name}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
¥{item.price.toLocaleString()}
</p>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => updateQuantity(item.id, item.quantity - 1)}
className="w-6 h-6 flex items-center justify-center bg-gray-200 dark:bg-gray-600 rounded text-sm hover:bg-gray-300 dark:hover:bg-gray-500"
>
-
</button>
<span className="text-sm font-medium w-8 text-center">
{item.quantity}
</span>
<button
onClick={() => updateQuantity(item.id, item.quantity + 1)}
className="w-6 h-6 flex items-center justify-center bg-gray-200 dark:bg-gray-600 rounded text-sm hover:bg-gray-300 dark:hover:bg-gray-500"
>
+
</button>
<button
onClick={() => removeItem(item.id)}
className="w-6 h-6 flex items-center justify-center bg-red-500 text-white rounded text-sm hover:bg-red-600"
>
×
</button>
</div>
</div>
))}
</div>
<button
onClick={addItem}
className="w-full mb-4 py-2 px-4 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg text-gray-500 dark:text-gray-400 hover:border-blue-500 hover:text-blue-500 transition-colors"
>
+
</button>
<div className="border-t border-gray-200 dark:border-gray-600 pt-4">
<div className="flex justify-between items-center text-lg font-semibold text-gray-900 dark:text-white">
<span>:</span>
<span>¥{getTotalPrice().toLocaleString()}</span>
</div>
</div>
</div>
</div>
{/* 向导区域 */}
<div className="lg:col-span-2">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
{/* 步骤指示器 */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-600">
<div className="flex items-center justify-between">
{wizardSteps.map((step, index) => (
<div key={step.id} className="flex items-center">
<button
onClick={() => goToStep(step.id)}
className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${
step.completed
? 'bg-green-500 text-white'
: step.id === currentStep
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-600 text-gray-500 dark:text-gray-400'
}`}
>
{step.completed ? '✓' : step.id}
</button>
{index < wizardSteps.length - 1 && (
<div className={`w-16 h-1 mx-2 ${
step.completed ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-600'
}`} />
)}
</div>
))}
</div>
<div className="mt-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{wizardSteps[currentStep - 1].title}
</h3>
<p className="text-gray-600 dark:text-gray-300">
{wizardSteps[currentStep - 1].description}
</p>
</div>
</div>
{/* 步骤内容 */}
<div className="p-6">
{currentStep === 1 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
*
</label>
<input
type="text"
value={wizardData.personalInfo.name}
onChange={(e) => handleInputChange('personalInfo', 'name', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="请输入您的姓名"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
*
</label>
<input
type="email"
value={wizardData.personalInfo.email}
onChange={(e) => handleInputChange('personalInfo', 'email', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="请输入您的邮箱"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
*
</label>
<input
type="tel"
value={wizardData.personalInfo.phone}
onChange={(e) => handleInputChange('personalInfo', 'phone', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="请输入您的手机号"
/>
</div>
</div>
)}
{currentStep === 2 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
*
</label>
<input
type="text"
value={wizardData.address.street}
onChange={(e) => handleInputChange('address', 'street', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="请输入详细地址"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
*
</label>
<input
type="text"
value={wizardData.address.city}
onChange={(e) => handleInputChange('address', 'city', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="请输入城市"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
*
</label>
<input
type="text"
value={wizardData.address.zipCode}
onChange={(e) => handleInputChange('address', 'zipCode', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="请输入邮政编码"
/>
</div>
</div>
)}
{currentStep === 3 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
*
</label>
<input
type="text"
value={wizardData.payment.cardNumber}
onChange={(e) => handleInputChange('payment', 'cardNumber', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="请输入银行卡号"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
*
</label>
<input
type="text"
value={wizardData.payment.expiryDate}
onChange={(e) => handleInputChange('payment', 'expiryDate', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="MM/YY"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
CVV *
</label>
<input
type="text"
value={wizardData.payment.cvv}
onChange={(e) => handleInputChange('payment', 'cvv', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="CVV"
/>
</div>
</div>
</div>
)}
{currentStep === 4 && (
<div className="space-y-6">
<div>
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-4"></h4>
<div className="space-y-4">
<div className="bg-gray-50 dark:bg-gray-700 p-4 rounded-lg">
<h5 className="font-medium text-gray-900 dark:text-white mb-2"></h5>
<p className="text-sm text-gray-600 dark:text-gray-300">
{wizardData.personalInfo.name} | {wizardData.personalInfo.email} | {wizardData.personalInfo.phone}
</p>
</div>
<div className="bg-gray-50 dark:bg-gray-700 p-4 rounded-lg">
<h5 className="font-medium text-gray-900 dark:text-white mb-2"></h5>
<p className="text-sm text-gray-600 dark:text-gray-300">
{wizardData.address.street}, {wizardData.address.city} {wizardData.address.zipCode}
</p>
</div>
<div className="bg-gray-50 dark:bg-gray-700 p-4 rounded-lg">
<h5 className="font-medium text-gray-900 dark:text-white mb-2"></h5>
<p className="text-sm text-gray-600 dark:text-gray-300">
**** **** **** {wizardData.payment.cardNumber.slice(-4)}
</p>
</div>
</div>
</div>
</div>
)}
</div>
{/* 导航按钮 */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-600 flex justify-between">
<button
onClick={() => goToStep(currentStep - 1)}
disabled={currentStep === 1}
className="px-4 py-2 text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
</button>
{currentStep < 4 ? (
<button
onClick={() => goToStep(currentStep + 1)}
disabled={!validateStep(currentStep)}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-md transition-colors"
>
</button>
) : (
<button
onClick={() => setShowConfirmDialog(true)}
disabled={cartItems.length === 0}
className="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white rounded-md transition-colors"
>
</button>
)}
</div>
</div>
</div>
</div>
{/* 确认对话框 */}
{showConfirmDialog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
</h3>
<p className="text-gray-600 dark:text-gray-300 mb-6">
¥{getTotalPrice().toLocaleString()}
</p>
<div className="flex justify-end space-x-3">
<button
onClick={() => setShowConfirmDialog(false)}
disabled={isProcessing}
className="px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 transition-colors"
>
</button>
<button
onClick={handleSubmitOrder}
disabled={isProcessing}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-md transition-colors flex items-center"
>
{isProcessing ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
...
</>
) : (
'确认提交'
)}
</button>
</div>
</div>
</div>
)}
{/* 返回链接 */}
<div className="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
<Link href="/" className="text-blue-600 hover:text-blue-500 dark:text-blue-400">
</Link>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,433 @@
import { useState } from 'react'
import { Link } from 'wouter'
interface ErrorScenario {
id: string
title: string
description: string
type: 'network' | 'validation' | 'permission' | 'timeout' | 'server'
}
export default function ErrorTestPage() {
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [retryCount, setRetryCount] = useState(0)
const [formData, setFormData] = useState({
username: '',
password: '',
email: '',
file: null as File | null
})
const errorScenarios: ErrorScenario[] = [
{
id: 'network-error',
title: '网络连接错误',
description: '模拟网络连接失败,测试重试机制',
type: 'network'
},
{
id: 'validation-error',
title: '表单验证错误',
description: '模拟表单验证失败,测试错误提示',
type: 'validation'
},
{
id: 'permission-error',
title: '权限不足错误',
description: '模拟权限验证失败,测试权限处理',
type: 'permission'
},
{
id: 'timeout-error',
title: '请求超时错误',
description: '模拟请求超时,测试超时处理',
type: 'timeout'
},
{
id: 'server-error',
title: '服务器内部错误',
description: '模拟服务器500错误测试错误恢复',
type: 'server'
}
]
const simulateError = async (scenario: ErrorScenario): Promise<void> => {
setIsLoading(true)
setError(null)
setSuccess(null)
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 2000))
switch (scenario.type) {
case 'network':
// 70% 概率失败
if (Math.random() < 0.7) {
throw new Error('网络连接失败:无法连接到服务器,请检查您的网络连接')
}
break
case 'validation':
// 检查表单数据
if (!formData.username || formData.username.length < 3) {
throw new Error('用户名验证失败用户名至少需要3个字符')
}
if (!formData.password || formData.password.length < 6) {
throw new Error('密码验证失败密码至少需要6个字符')
}
if (!formData.email?.includes('@')) {
throw new Error('邮箱验证失败:请输入有效的邮箱地址')
}
break
case 'permission':
// 模拟权限检查
if (formData.username !== 'admin') {
throw new Error('权限不足:您没有执行此操作的权限,请联系管理员')
}
break
case 'timeout':
// 模拟超时
await new Promise(resolve => setTimeout(resolve, 8000))
throw new Error('请求超时:服务器响应时间过长,请稍后重试')
case 'server':
// 50% 概率服务器错误
if (Math.random() < 0.5) {
throw new Error('服务器内部错误:服务器遇到了一个错误,请稍后重试')
}
break
default:
throw new Error('未知错误:发生了未预期的错误')
}
// 成功情况
return Promise.resolve()
}
const handleScenarioTest = async (scenario: ErrorScenario) => {
try {
await simulateError(scenario)
setSuccess(`${scenario.title} 测试成功完成!`)
setRetryCount(0)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '未知错误'
setError(errorMessage)
setRetryCount(prev => prev + 1)
} finally {
setIsLoading(false)
}
}
const handleRetry = async (scenario: ErrorScenario) => {
if (retryCount >= 3) {
setError('重试次数已达上限,请稍后再试或联系技术支持')
return
}
await handleScenarioTest(scenario)
}
const handleFileUpload = async () => {
if (!formData.file) {
setError('请选择要上传的文件')
return
}
setIsLoading(true)
setError(null)
setSuccess(null)
try {
// 模拟文件大小检查
if (formData.file.size > 5 * 1024 * 1024) {
throw new Error('文件上传失败文件大小不能超过5MB')
}
// 模拟文件类型检查
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf']
if (!allowedTypes.includes(formData.file.type)) {
throw new Error('文件上传失败不支持的文件类型请上传图片或PDF文件')
}
// 模拟上传过程
await new Promise(resolve => setTimeout(resolve, 2000))
// 模拟随机失败
if (Math.random() < 0.3) {
throw new Error('文件上传失败:上传过程中发生错误,请重试')
}
setSuccess('文件上传成功!')
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '文件上传失败'
setError(errorMessage)
} finally {
setIsLoading(false)
}
}
const clearMessages = () => {
setError(null)
setSuccess(null)
setRetryCount(0)
}
const getErrorIcon = (type: string) => {
switch (type) {
case 'network': return '🌐'
case 'validation': return '⚠️'
case 'permission': return '🔒'
case 'timeout': return '⏰'
case 'server': return '🔧'
default: return '❌'
}
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
<div className="max-w-4xl mx-auto px-4">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
</h1>
<p className="text-gray-600 dark:text-gray-300">
Agent
</p>
</div>
{/* 全局消息显示 */}
{(error || success) && (
<div className="mb-8">
{error && (
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg p-4 mb-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
</h3>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{error}
</p>
{retryCount > 0 && (
<p className="mt-2 text-xs text-red-600 dark:text-red-400">
{retryCount} {retryCount >= 3 && '(已达最大重试次数)'}
</p>
)}
</div>
<button
onClick={clearMessages}
className="ml-3 text-red-400 hover:text-red-600 dark:hover:text-red-300"
>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
)}
{success && (
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-lg p-4 mb-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-green-800 dark:text-green-200">
</h3>
<p className="mt-1 text-sm text-green-700 dark:text-green-300">
{success}
</p>
</div>
<button
onClick={clearMessages}
className="ml-3 text-green-400 hover:text-green-600 dark:hover:text-green-300"
>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
)}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* 错误场景测试 */}
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
</h2>
{errorScenarios.map((scenario) => (
<div key={scenario.id} className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="flex items-start space-x-4">
<div className="text-3xl">{getErrorIcon(scenario.type)}</div>
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{scenario.title}
</h3>
<p className="text-gray-600 dark:text-gray-300 text-sm mb-4">
{scenario.description}
</p>
<div className="flex space-x-3">
<button
onClick={() => handleScenarioTest(scenario)}
disabled={isLoading}
className="px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white rounded-md transition-colors text-sm"
>
{isLoading ? '测试中...' : '触发错误'}
</button>
{error && retryCount > 0 && retryCount < 3 && (
<button
onClick={() => handleRetry(scenario)}
disabled={isLoading}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-md transition-colors text-sm"
>
({retryCount}/3)
</button>
)}
</div>
</div>
</div>
</div>
))}
</div>
{/* 表单验证测试 */}
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
</h2>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
(3)
</label>
<input
type="text"
value={formData.username}
onChange={(e) => setFormData(prev => ({ ...prev, username: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="请输入用户名"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
(6)
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="请输入密码"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="请输入邮箱地址"
/>
</div>
<button
onClick={() => handleScenarioTest(errorScenarios.find(s => s.type === 'validation')!)}
disabled={isLoading}
className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-md transition-colors"
>
{isLoading ? '验证中...' : '提交表单'}
</button>
</div>
</div>
{/* 文件上传测试 */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
(5MBPDF)
</label>
<input
type="file"
onChange={(e) => setFormData(prev => ({ ...prev, file: e.target.files?.[0] || null }))}
accept="image/*,.pdf"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
</div>
{formData.file && (
<div className="text-sm text-gray-600 dark:text-gray-300">
: {formData.file.name} ({(formData.file.size / 1024 / 1024).toFixed(2)} MB)
</div>
)}
<button
onClick={handleFileUpload}
disabled={isLoading || !formData.file}
className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white rounded-md transition-colors"
>
{isLoading ? '上传中...' : '上传文件'}
</button>
</div>
</div>
{/* 权限测试说明 */}
<div className="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 rounded-lg p-4">
<h4 className="text-sm font-medium text-yellow-800 dark:text-yellow-200 mb-2">
💡
</h4>
<p className="text-sm text-yellow-700 dark:text-yellow-300">
"admin""触发错误"
</p>
</div>
</div>
</div>
{/* 加载状态指示器 */}
{isLoading && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 flex items-center space-x-4">
<svg className="animate-spin h-8 w-8 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span className="text-gray-900 dark:text-white">...</span>
</div>
</div>
)}
{/* 返回链接 */}
<div className="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
<Link href="/" className="text-blue-600 hover:text-blue-500 dark:text-blue-400">
</Link>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,451 @@
import { useState } from 'react'
import { Link } from 'wouter'
interface FormData {
username: string
email: string
password: string
confirmPassword: string
age: string
birthDate: string
phone: string
website: string
bio: string
country: string
newsletter: boolean
terms: boolean
}
type FormErrors = Record<string, string>;
export default function FormTestPage() {
const [formData, setFormData] = useState<FormData>({
username: '',
email: '',
password: '',
confirmPassword: '',
age: '',
birthDate: '',
phone: '',
website: '',
bio: '',
country: '',
newsletter: false,
terms: false
})
const [errors, setErrors] = useState<FormErrors>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [submitResult, setSubmitResult] = useState<'success' | 'error' | null>(null)
const [submitMessage, setSubmitMessage] = useState('')
const validateField = (name: string, value: string | boolean): string => {
switch (name) {
case 'username':
if (!value) return '用户名不能为空'
if (typeof value === 'string' && value.length < 3) return '用户名至少需要3个字符'
if (typeof value === 'string' && !/^[a-zA-Z0-9_]+$/.test(value)) return '用户名只能包含字母、数字和下划线'
return ''
case 'email':
if (!value) return '邮箱不能为空'
if (typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return '请输入有效的邮箱地址'
return ''
case 'password':
if (!value) return '密码不能为空'
if (typeof value === 'string' && value.length < 6) return '密码至少需要6个字符'
if (typeof value === 'string' && !/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value)) return '密码必须包含大小写字母和数字'
return ''
case 'confirmPassword':
if (!value) return '请确认密码'
if (value !== formData.password) return '两次输入的密码不一致'
return ''
case 'age': {
if (!value) return '年龄不能为空'
const age = parseInt(value as string)
if (isNaN(age) || age < 18 || age > 120) return '年龄必须在18-120之间'
return ''
}
case 'phone':
if (!value) return '手机号不能为空'
if (typeof value === 'string' && !/^1[3-9]\d{9}$/.test(value)) return '请输入有效的手机号'
return ''
case 'terms':
if (!value) return '请同意服务条款'
return ''
default:
return ''
}
}
const handleInputChange = (name: string, value: string | boolean) => {
console.log(`Input changed: ${name} = ${value}`)
setFormData(prev => ({ ...prev, [name]: value }))
// 实时验证
const error = validateField(name, value)
setErrors(prev => ({ ...prev, [name]: error }))
}
const validateForm = (): boolean => {
const newErrors: FormErrors = {}
let isValid = true
Object.keys(formData).forEach(key => {
const error = validateField(key, formData[key as keyof FormData])
if (error) {
newErrors[key] = error
isValid = false
}
})
setErrors(newErrors)
return isValid
}
const simulateSubmit = async (): Promise<{ success: boolean; message: string }> => {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 2000 + Math.random() * 2000))
// 模拟随机失败
if (Math.random() < 0.3) {
throw new Error('网络错误:服务器暂时不可用,请稍后重试')
}
// 模拟服务器验证错误
if (formData.username.toLowerCase() === 'admin') {
throw new Error('用户名 "admin" 已被占用,请选择其他用户名')
}
return {
success: true,
message: '注册成功!欢迎加入我们的平台。'
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateForm()) {
setSubmitResult('error')
setSubmitMessage('请修正表单中的错误')
return
}
setIsSubmitting(true)
setSubmitResult(null)
setSubmitMessage('')
try {
const result = await simulateSubmit()
setSubmitResult('success')
setSubmitMessage(result.message)
} catch (error) {
setSubmitResult('error')
setSubmitMessage(error instanceof Error ? error.message : '提交失败,请重试')
} finally {
setIsSubmitting(false)
}
}
const resetForm = () => {
setFormData({
username: '',
email: '',
password: '',
confirmPassword: '',
age: '',
birthDate: '',
phone: '',
website: '',
bio: '',
country: '',
newsletter: false,
terms: false
})
setErrors({})
setSubmitResult(null)
setSubmitMessage('')
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
<div className="max-w-2xl mx-auto px-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
</h1>
<p className="text-gray-600 dark:text-gray-300">
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* 用户名 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
*
</label>
<input
type="text"
value={formData.username}
onChange={(e) => handleInputChange('username', e.target.value)}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white ${
errors.username ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
}`}
placeholder="请输入用户名"
/>
{errors.username && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.username}</p>
)}
</div>
{/* 邮箱 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
*
</label>
<input
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white ${
errors.email ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
}`}
placeholder="请输入邮箱地址"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.email}</p>
)}
</div>
{/* 密码 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
*
</label>
<input
type="password"
value={formData.password}
onChange={(e) => handleInputChange('password', e.target.value)}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white ${
errors.password ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
}`}
placeholder="请输入密码"
/>
{errors.password && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.password}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
*
</label>
<input
type="password"
value={formData.confirmPassword}
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white ${
errors.confirmPassword ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
}`}
placeholder="请再次输入密码"
/>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.confirmPassword}</p>
)}
</div>
</div>
{/* 年龄和生日 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
*
</label>
<input
type="number"
value={formData.age}
onChange={(e) => handleInputChange('age', e.target.value)}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white ${
errors.age ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
}`}
placeholder="请输入年龄"
min="18"
max="120"
/>
{errors.age && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.age}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="date"
value={formData.birthDate}
onChange={(e) => handleInputChange('birthDate', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
{/* 手机和网站 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
*
</label>
<input
type="tel"
value={formData.phone}
onChange={(e) => handleInputChange('phone', e.target.value)}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white ${
errors.phone ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
}`}
placeholder="请输入手机号"
/>
{errors.phone && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.phone}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="url"
value={formData.website}
onChange={(e) => handleInputChange('website', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="https://example.com"
/>
</div>
</div>
{/* 国家选择 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
/
</label>
<select
value={formData.country}
onChange={(e) => handleInputChange('country', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value="">/</option>
<option value="CN"></option>
<option value="US"></option>
<option value="JP"></option>
<option value="KR"></option>
<option value="GB"></option>
<option value="DE"></option>
<option value="FR"></option>
<option value="CA"></option>
<option value="AU"></option>
</select>
</div>
{/* 个人简介 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<textarea
value={formData.bio}
onChange={(e) => handleInputChange('bio', e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="请简单介绍一下自己..."
/>
</div>
{/* 复选框 */}
<div className="space-y-3">
<div className="flex items-center">
<input
type="checkbox"
id="newsletter"
checked={formData.newsletter}
onChange={(e) => handleInputChange('newsletter', e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="newsletter" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="terms"
checked={formData.terms}
onChange={(e) => handleInputChange('terms', e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="terms" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
<a href="#" className="text-blue-600 hover:text-blue-500"></a> <a href="#" className="text-blue-600 hover:text-blue-500"></a> *
</label>
</div>
{errors.terms && (
<p className="text-sm text-red-600 dark:text-red-400">{errors.terms}</p>
)}
</div>
{/* 提交结果 */}
{submitResult && (
<div className={`p-4 rounded-md ${
submitResult === 'success'
? 'bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700'
: 'bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700'
}`}>
<p className={`text-sm ${
submitResult === 'success'
? 'text-green-800 dark:text-green-200'
: 'text-red-800 dark:text-red-200'
}`}>
{submitMessage}
</p>
</div>
)}
{/* 按钮组 */}
<div className="flex flex-col sm:flex-row gap-4">
<button
type="submit"
disabled={isSubmitting}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
{isSubmitting ? (
<span className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
...
</span>
) : (
'注册账户'
)}
</button>
<button
type="button"
onClick={resetForm}
className="flex-1 bg-gray-600 hover:bg-gray-700 text-white font-medium py-2 px-4 rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
>
</button>
</div>
</form>
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<Link href="/" className="text-blue-600 hover:text-blue-500 dark:text-blue-400">
</Link>
</div>
</div>
</div>
</div>
)
}

107
pages/test-pages/index.tsx Normal file
View File

@@ -0,0 +1,107 @@
import { Link } from 'wouter'
export default function IndexPage() {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-900 dark:to-gray-800 p-8">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-4">
Page Use Agent
</h1>
<p className="text-lg text-gray-600 dark:text-gray-300">
AI Agent
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<TestPageCard
title="表单测试"
description="测试输入、验证、提交等表单操作"
path="/form"
icon="📝"
difficulty="简单"
/>
<TestPageCard
title="导航测试"
description="测试菜单、下拉框、弹窗等交互"
path="/navigation"
icon="🧭"
difficulty="中等"
/>
<TestPageCard
title="列表测试"
description="测试滚动、分页、搜索、排序"
path="/list"
icon="📋"
difficulty="中等"
/>
<TestPageCard
title="复杂交互"
description="测试多步骤操作和状态管理"
path="/complex"
icon="⚙️"
difficulty="困难"
/>
<TestPageCard
title="错误处理"
description="测试错误识别和重试机制"
path="/errors"
icon="⚠️"
difficulty="困难"
/>
<TestPageCard
title="异步操作"
description="测试等待、加载状态识别"
path="/async"
icon="⏳"
difficulty="中等"
/>
</div>
<div className="text-center">
<Link href="/" className="inline-flex items-center px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
Page Use
</Link>
</div>
</div>
</div>
)
}
interface TestPageCardProps {
title: string
description: string
path: string
icon: string
difficulty: string
}
function TestPageCard({ title, description, path, icon, difficulty }: TestPageCardProps) {
const difficultyColors = {
'简单': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
'中等': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
'困难': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}
return (
<Link href={path}>
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105 cursor-pointer border border-gray-200 dark:border-gray-700">
<div className="text-4xl mb-4">{icon}</div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
{title}
</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4 text-sm">
{description}
</p>
<div className="flex justify-between items-center">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${difficultyColors[difficulty as keyof typeof difficultyColors]}`}>
{difficulty}
</span>
<span className="text-blue-600 dark:text-blue-400 text-sm font-medium">
</span>
</div>
</div>
</Link>
)
}

View File

@@ -0,0 +1,448 @@
import { useState, useEffect } from 'react'
import { Link } from 'wouter'
interface Product {
id: number
name: string
category: string
price: number
stock: number
rating: number
image: string
description: string
tags: string[]
}
const generateProducts = (count: number): Product[] => {
const categories = ['手机', '电脑', '平板', '耳机', '手表', '相机']
const brands = ['Apple', 'Samsung', 'Huawei', 'Xiaomi', 'Sony', 'Dell']
const adjectives = ['Pro', 'Max', 'Ultra', 'Plus', 'Air', 'Mini']
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `${brands[i % brands.length]} ${categories[i % categories.length]} ${adjectives[i % adjectives.length]}`,
category: categories[i % categories.length],
price: Math.floor(Math.random() * 10000) + 500,
stock: Math.floor(Math.random() * 100),
rating: Math.round((Math.random() * 2 + 3) * 10) / 10,
image: `https://picsum.photos/200/200?random=${i}`,
description: `这是一款优秀的${categories[i % categories.length]}产品,具有出色的性能和设计。`,
tags: ['热销', '新品', '推荐'].slice(0, Math.floor(Math.random() * 3) + 1)
}))
}
export default function ListTestPage() {
const [products, setProducts] = useState<Product[]>([])
const [filteredProducts, setFilteredProducts] = useState<Product[]>([])
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [selectedCategory, setSelectedCategory] = useState('全部')
const [sortBy, setSortBy] = useState('name')
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
const [currentPage, setCurrentPage] = useState(1)
const [itemsPerPage, setItemsPerPage] = useState(12)
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const categories = ['全部', '手机', '电脑', '平板', '耳机', '手表', '相机']
// 模拟数据加载
useEffect(() => {
const loadData = async () => {
setLoading(true)
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 1500))
const data = generateProducts(150)
setProducts(data)
setFilteredProducts(data)
setLoading(false)
}
loadData()
}, [])
// 搜索和过滤
useEffect(() => {
let filtered = products
// 按类别过滤
if (selectedCategory !== '全部') {
filtered = filtered.filter(product => product.category === selectedCategory)
}
// 按搜索词过滤
if (searchTerm) {
filtered = filtered.filter(product =>
product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
product.description.toLowerCase().includes(searchTerm.toLowerCase())
)
}
// 排序
filtered.sort((a, b) => {
let aValue: any = a[sortBy as keyof Product]
let bValue: any = b[sortBy as keyof Product]
if (typeof aValue === 'string') {
aValue = aValue.toLowerCase()
bValue = bValue.toLowerCase()
}
if (sortOrder === 'asc') {
return aValue > bValue ? 1 : -1
} else {
return aValue < bValue ? 1 : -1
}
})
setFilteredProducts(filtered)
setCurrentPage(1) // 重置到第一页
}, [products, searchTerm, selectedCategory, sortBy, sortOrder])
// 分页计算
const totalPages = Math.ceil(filteredProducts.length / itemsPerPage)
const startIndex = (currentPage - 1) * itemsPerPage
const endIndex = startIndex + itemsPerPage
const currentProducts = filteredProducts.slice(startIndex, endIndex)
const handlePageChange = (page: number) => {
setCurrentPage(page)
// 滚动到顶部
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const LoadingSkeleton = () => (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 animate-pulse">
<div className="bg-gray-300 dark:bg-gray-600 h-48 rounded-lg mb-4"></div>
<div className="space-y-2">
<div className="bg-gray-300 dark:bg-gray-600 h-4 rounded w-3/4"></div>
<div className="bg-gray-300 dark:bg-gray-600 h-4 rounded w-1/2"></div>
<div className="bg-gray-300 dark:bg-gray-600 h-4 rounded w-1/4"></div>
</div>
</div>
))}
</div>
)
const ProductCard = ({ product }: { product: Product }) => (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-4">
<div className="relative mb-4">
<img
src={product.image}
alt={product.name}
className="w-full h-48 object-cover rounded-lg"
loading="lazy"
/>
<div className="absolute top-2 right-2 flex flex-wrap gap-1">
{product.tags.map((tag, index) => (
<span
key={index}
className="bg-red-500 text-white text-xs px-2 py-1 rounded-full"
>
{tag}
</span>
))}
</div>
</div>
<h3 className="font-semibold text-gray-900 dark:text-white mb-2 line-clamp-2">
{product.name}
</h3>
<p className="text-gray-600 dark:text-gray-300 text-sm mb-3 line-clamp-2">
{product.description}
</p>
<div className="flex items-center justify-between mb-3">
<span className="text-2xl font-bold text-blue-600 dark:text-blue-400">
¥{product.price.toLocaleString()}
</span>
<div className="flex items-center">
<span className="text-yellow-400"></span>
<span className="text-sm text-gray-600 dark:text-gray-300 ml-1">
{product.rating}
</span>
</div>
</div>
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-gray-500 dark:text-gray-400">
: {product.stock}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
{product.category}
</span>
</div>
<button className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-md transition-colors">
</button>
</div>
)
const ProductListItem = ({ product }: { product: Product }) => (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 flex items-center space-x-4">
<img
src={product.image}
alt={product.name}
className="w-20 h-20 object-cover rounded-lg flex-shrink-0"
loading="lazy"
/>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">
{product.name}
</h3>
<p className="text-gray-600 dark:text-gray-300 text-sm mb-2 line-clamp-1">
{product.description}
</p>
<div className="flex items-center space-x-4 text-sm text-gray-500 dark:text-gray-400">
<span>{product.category}</span>
<span>: {product.stock}</span>
<div className="flex items-center">
<span className="text-yellow-400"></span>
<span className="ml-1">{product.rating}</span>
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<span className="text-xl font-bold text-blue-600 dark:text-blue-400">
¥{product.price.toLocaleString()}
</span>
<button className="bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-md transition-colors">
</button>
</div>
</div>
)
const Pagination = () => {
const getPageNumbers = () => {
const pages = []
const maxVisible = 5
let start = Math.max(1, currentPage - Math.floor(maxVisible / 2))
const end = Math.min(totalPages, start + maxVisible - 1)
if (end - start + 1 < maxVisible) {
start = Math.max(1, end - maxVisible + 1)
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
}
return (
<div className="flex items-center justify-between mt-8">
<div className="text-sm text-gray-700 dark:text-gray-300">
{startIndex + 1}-{Math.min(endIndex, filteredProducts.length)}
{filteredProducts.length}
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
</button>
{getPageNumbers().map(page => (
<button
key={page}
onClick={() => handlePageChange(page)}
className={`px-3 py-2 text-sm font-medium rounded-md ${
page === currentPage
? 'bg-blue-600 text-white'
: 'text-gray-500 bg-white border border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
{page}
</button>
))}
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
</button>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
<div className="max-w-7xl mx-auto px-4">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
</h1>
<p className="text-gray-600 dark:text-gray-300">
</p>
</div>
{/* 搜索和过滤栏 */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
{/* 搜索框 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="输入产品名称或描述..."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
</div>
{/* 类别过滤 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
{categories.map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
</div>
{/* 排序方式 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value="name"></option>
<option value="price"></option>
<option value="rating"></option>
<option value="stock"></option>
</select>
</div>
{/* 排序顺序 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<select
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value as 'asc' | 'desc')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value="asc"></option>
<option value="desc"></option>
</select>
</div>
</div>
{/* 视图控制 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
:
</span>
<select
value={itemsPerPage}
onChange={(e) => setItemsPerPage(Number(e.target.value))}
className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value={12}>12</option>
<option value={24}>24</option>
<option value={48}>48</option>
</select>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
:
</span>
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded-md ${
viewMode === 'grid'
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300'
}`}
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded-md ${
viewMode === 'list'
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300'
}`}
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 8a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 12a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 16a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" />
</svg>
</button>
</div>
</div>
</div>
{/* 产品列表 */}
{loading ? (
<LoadingSkeleton />
) : filteredProducts.length === 0 ? (
<div className="text-center py-12">
<div className="text-6xl mb-4">🔍</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
</h3>
<p className="text-gray-600 dark:text-gray-300">
</p>
</div>
) : (
<>
{viewMode === 'grid' ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{currentProducts.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
) : (
<div className="space-y-4">
{currentProducts.map(product => (
<ProductListItem key={product.id} product={product} />
))}
</div>
)}
<Pagination />
</>
)}
{/* 返回顶部按钮 */}
<button
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
className="fixed bottom-8 right-8 bg-blue-600 hover:bg-blue-700 text-white p-3 rounded-full shadow-lg transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
</button>
{/* 返回链接 */}
<div className="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
<Link href="/" className="text-blue-600 hover:text-blue-500 dark:text-blue-400">
</Link>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,453 @@
import { useState } from 'react'
import { Link } from 'wouter'
export default function NavigationTestPage() {
const [activeTab, setActiveTab] = useState('home')
const [isModalOpen, setIsModalOpen] = useState(false)
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false)
const [breadcrumbs, setBreadcrumbs] = useState(['首页', '产品', '手机'])
const [notifications, setNotifications] = useState([
{ id: 1, title: '新消息', content: '您有一条新的私信', time: '2分钟前', unread: true },
{ id: 2, title: '系统通知', content: '系统将于今晚维护', time: '1小时前', unread: true },
{ id: 3, title: '订单更新', content: '您的订单已发货', time: '3小时前', unread: false }
])
const handleBreadcrumbClick = (index: number) => {
const newBreadcrumbs = breadcrumbs.slice(0, index + 1)
setBreadcrumbs(newBreadcrumbs)
}
const markNotificationAsRead = (id: number) => {
setNotifications(prev =>
prev.map(notif =>
notif.id === id ? { ...notif, unread: false } : notif
)
)
}
const unreadCount = notifications.filter(n => n.unread).length
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* 顶部导航栏 */}
<nav className="bg-white dark:bg-gray-800 shadow-lg border-b border-gray-200 dark:border-gray-700">
<div className="max-w-7xl mx-auto px-4">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<div className="flex items-center">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
TestNav
</div>
</div>
{/* 主导航菜单 */}
<div className="hidden md:flex space-x-8">
<a href="#" className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 px-3 py-2 rounded-md text-sm font-medium transition-colors">
</a>
{/* 产品下拉菜单 */}
<div className="relative">
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 px-3 py-2 rounded-md text-sm font-medium transition-colors flex items-center"
>
<svg className="ml-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isDropdownOpen && (
<div className="absolute top-full left-0 mt-1 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div className="py-1">
<a href="#" className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
</a>
<a href="#" className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
</a>
<a href="#" className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
</a>
<div className="border-t border-gray-200 dark:border-gray-600 my-1"></div>
<a href="#" className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
</a>
</div>
</div>
)}
</div>
<a href="#" className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 px-3 py-2 rounded-md text-sm font-medium transition-colors">
</a>
<a href="#" className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 px-3 py-2 rounded-md text-sm font-medium transition-colors">
</a>
</div>
{/* 右侧菜单 */}
<div className="flex items-center space-x-4">
{/* 通知铃铛 */}
<div className="relative">
<button className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 p-2 rounded-full transition-colors">
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-5 5v-5zM10.5 3.75a6 6 0 0 1 6 6v2.25l2.25 2.25v2.25H2.25V14.25L4.5 12V9.75a6 6 0 0 1 6-6z" />
</svg>
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center">
{unreadCount}
</span>
)}
</button>
</div>
{/* 用户菜单 */}
<div className="relative">
<button
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
className="flex items-center text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 p-2 rounded-full transition-colors"
>
<div className="h-8 w-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
U
</div>
</button>
{isUserMenuOpen && (
<div className="absolute top-full right-0 mt-1 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div className="py-1">
<div className="px-4 py-2 text-sm text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-600">
user@example.com
</div>
<a href="#" className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
</a>
<a href="#" className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
</a>
<a href="#" className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
</a>
<div className="border-t border-gray-200 dark:border-gray-600 my-1"></div>
<a href="#" className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
退
</a>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</nav>
{/* 面包屑导航 */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="max-w-7xl mx-auto px-4 py-3">
<nav className="flex" aria-label="Breadcrumb">
<ol className="flex items-center space-x-2">
{breadcrumbs.map((crumb, index) => (
<li key={index} className="flex items-center">
{index > 0 && (
<svg className="h-4 w-4 text-gray-400 mx-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
)}
<button
onClick={() => handleBreadcrumbClick(index)}
className={`text-sm font-medium transition-colors ${
index === breadcrumbs.length - 1
? 'text-gray-500 dark:text-gray-400 cursor-default'
: 'text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300'
}`}
>
{crumb}
</button>
</li>
))}
</ol>
</nav>
</div>
</div>
{/* 主要内容区域 */}
<div className="max-w-7xl mx-auto px-4 py-8">
{/* 标签页导航 */}
<div className="mb-8">
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex space-x-8">
{[
{ id: 'home', label: '概览', icon: '🏠' },
{ id: 'products', label: '产品列表', icon: '📱' },
{ id: 'orders', label: '订单管理', icon: '📦' },
{ id: 'analytics', label: '数据分析', icon: '📊' },
{ id: 'settings', label: '设置', icon: '⚙️' }
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === tab.id
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<span className="mr-2">{tab.icon}</span>
{tab.label}
</button>
))}
</nav>
</div>
</div>
{/* 标签页内容 */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
{activeTab === 'home' && (
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"></h2>
<p className="text-gray-600 dark:text-gray-300 mb-6">
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-blue-50 dark:bg-blue-900 p-4 rounded-lg">
<h3 className="font-semibold text-blue-900 dark:text-blue-100"></h3>
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">¥123,456</p>
</div>
<div className="bg-green-50 dark:bg-green-900 p-4 rounded-lg">
<h3 className="font-semibold text-green-900 dark:text-green-100"></h3>
<p className="text-2xl font-bold text-green-600 dark:text-green-400">1,234</p>
</div>
<div className="bg-purple-50 dark:bg-purple-900 p-4 rounded-lg">
<h3 className="font-semibold text-purple-900 dark:text-purple-100"></h3>
<p className="text-2xl font-bold text-purple-600 dark:text-purple-400">5,678</p>
</div>
</div>
</div>
)}
{activeTab === 'products' && (
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"></h2>
<div className="space-y-4">
{['iPhone 15 Pro', 'MacBook Air', 'iPad Pro', 'Apple Watch'].map((product, index) => (
<div key={index} className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<div>
<h3 className="font-medium text-gray-900 dark:text-white">{product}</h3>
<p className="text-gray-500 dark:text-gray-400">...</p>
</div>
<button className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md transition-colors">
</button>
</div>
))}
</div>
</div>
)}
{activeTab === 'orders' && (
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"></h2>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-600">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"></th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-600">
{[
{ id: '#001', customer: '张三', status: '已发货', amount: '¥1,299' },
{ id: '#002', customer: '李四', status: '处理中', amount: '¥2,599' },
{ id: '#003', customer: '王五', status: '已完成', amount: '¥899' }
].map((order, index) => (
<tr key={index}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">{order.id}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">{order.customer}</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
order.status === '已完成' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' :
order.status === '已发货' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' :
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
}`}>
{order.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">{order.amount}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{activeTab === 'analytics' && (
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"></h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4"></h3>
<div className="h-32 bg-gradient-to-r from-blue-400 to-purple-500 rounded-lg flex items-center justify-center text-white">
📈
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4"></h3>
<div className="h-32 bg-gradient-to-r from-green-400 to-blue-500 rounded-lg flex items-center justify-center text-white">
🗺
</div>
</div>
</div>
</div>
)}
{activeTab === 'settings' && (
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"></h2>
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2"></h3>
<div className="space-y-2">
<label className="flex items-center">
<input type="checkbox" className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" defaultChecked />
<span className="ml-2 text-gray-700 dark:text-gray-300"></span>
</label>
<label className="flex items-center">
<input type="checkbox" className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
<span className="ml-2 text-gray-700 dark:text-gray-300"></span>
</label>
</div>
</div>
<div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2"></h3>
<div className="space-y-2">
<label className="flex items-center">
<input type="checkbox" className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" defaultChecked />
<span className="ml-2 text-gray-700 dark:text-gray-300"></span>
</label>
<label className="flex items-center">
<input type="checkbox" className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" defaultChecked />
<span className="ml-2 text-gray-700 dark:text-gray-300"></span>
</label>
</div>
</div>
</div>
</div>
)}
</div>
{/* 操作按钮 */}
<div className="mt-8 flex flex-wrap gap-4">
<button
onClick={() => setIsModalOpen(true)}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md transition-colors"
>
</button>
<button
onClick={() => setBreadcrumbs([...breadcrumbs, `新页面${breadcrumbs.length}`])}
className="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-md transition-colors"
>
</button>
<button
onClick={() => {
const newNotif = {
id: Date.now(),
title: '新通知',
content: `这是第 ${notifications.length + 1} 条通知`,
time: '刚刚',
unread: true
}
setNotifications(prev => [newNotif, ...prev])
}}
className="bg-purple-600 hover:bg-purple-700 text-white px-6 py-2 rounded-md transition-colors"
>
</button>
</div>
{/* 通知列表 */}
{notifications.length > 0 && (
<div className="mt-8">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4"></h3>
<div className="space-y-2">
{notifications.map((notification) => (
<div
key={notification.id}
className={`p-4 rounded-lg border cursor-pointer transition-colors ${
notification.unread
? 'bg-blue-50 dark:bg-blue-900 border-blue-200 dark:border-blue-700'
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-600'
}`}
onClick={() => markNotificationAsRead(notification.id)}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<h4 className="font-medium text-gray-900 dark:text-white">{notification.title}</h4>
<p className="text-gray-600 dark:text-gray-300 text-sm">{notification.content}</p>
</div>
<div className="flex items-center space-x-2">
<span className="text-xs text-gray-500 dark:text-gray-400">{notification.time}</span>
{notification.unread && (
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* 模态框 */}
{isModalOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white"></h3>
<button
onClick={() => setIsModalOpen(false)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-6">
Agent
</p>
<div className="flex justify-end space-x-3">
<button
onClick={() => setIsModalOpen(false)}
className="px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={() => setIsModalOpen(false)}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors"
>
</button>
</div>
</div>
</div>
)}
{/* 返回链接 */}
<div className="max-w-7xl mx-auto px-4 py-8">
<Link href="/" className="text-blue-600 hover:text-blue-500 dark:text-blue-400">
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { Route, Switch } from 'wouter'
import FormTestPage from './form-test'
import NavigationTestPage from './navigation-test'
import ListTestPage from './list-test'
import ComplexTestPage from './complex-test'
import ErrorTestPage from './error-test'
import AsyncTestPage from './async-test'
import IndexPage from './index'
export default function Router() {
return (
<>
<Switch>
<Route path="/form" component={FormTestPage} />
<Route path="/navigation" component={NavigationTestPage} />
<Route path="/list" component={ListTestPage} />
<Route path="/complex" component={ComplexTestPage} />
<Route path="/errors" component={ErrorTestPage} />
<Route path="/async" component={AsyncTestPage} />
<Route path="" component={IndexPage} />
</Switch>
</>
)
}

11
public/icon.svg Normal file
View File

@@ -0,0 +1,11 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="g1" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
<stop stop-color="#58C0FC" />
<stop offset="1" stop-color="#BD45FB" />
</linearGradient>
</defs>
<rect x="4" y="4" width="56" height="56" rx="14" fill="url(#g1)" />
<text x="32" y="36" text-anchor="middle" font-family="Inter, Arial, sans-serif" font-size="42"
font-weight="bold" fill="#fff" dominant-baseline="middle">P</text>
</svg>

After

Width:  |  Height:  |  Size: 538 B

524
src/PageAgent.ts Normal file
View File

@@ -0,0 +1,524 @@
/**
* Copyright (C) 2025 Alibaba Group Holding Limited
* All rights reserved.
*/
import { tool } from 'ai'
import type { LanguageModelUsage, ToolSet } from 'ai'
import chalk from 'chalk'
import zod from 'zod'
import type { PageAgentConfig } from './config'
import { MACRO_TOOL_NAME, MAX_STEPS, VIEWPORT_EXPANSION } from './config/constants'
import * as dom from './dom'
import { FlatDomTree, InteractiveElementDomNode } from './dom/dom_tree/type'
import { getPageInfo } from './dom/getPageInfo'
import { I18n } from './i18n'
import { LLM } from './llms'
import { patchReact } from './patches/react'
import SYSTEM_PROMPT from './prompts/system_prompt.md?raw'
import { tools } from './tools'
import { Panel, getToolCompletedText, getToolExecutingText } from './ui/Panel'
import { SimulatorMask } from './ui/SimulatorMask'
import { trimLines, uid, waitUntil } from './utils'
import { assert } from './utils/assert'
import { getEventBus } from './utils/bus'
export type { PageAgentConfig }
export interface AgentBrain {
// thinking?: string
evaluation_previous_goal: string
memory: string
next_goal: string
}
export interface AgentHistory {
brain: AgentBrain
action: {
name: string
input: any
output: any
}
usage: LanguageModelUsage
}
export interface ExecutionResult {
success: boolean
data: string
history: AgentHistory[]
}
export class PageAgent extends EventTarget {
config: PageAgentConfig
id = uid()
bus = getEventBus(this.id)
i18n: I18n
paused = false
disposed = false
task = ''
#llm: LLM
#totalWaitTime = 0
#abortController = new AbortController()
/** Corresponds to eval_page in browser-use */
flatTree: FlatDomTree | null = null
/**
* All highlighted index-mapped interactive elements
* Corresponds to DOMState.selector_map in browser-use
*/
selectorMap = new Map<number, InteractiveElementDomNode>()
/** highlight index -> element text */
elementTextMap = new Map<number, string>()
/** Corresponds to clickable_elements_to_string in browser-use */
simplifiedHTML = '<EMPTY>'
/** last time the tree was updated */
lastTimeUpdate = 0
/** Corresponds to actions in browser-use */
tools = new Map(tools)
/** Fullscreen mask */
mask = new SimulatorMask()
/** Interactive panel */
panel = new Panel(this)
/** History records */
history: AgentHistory[] = []
constructor(config: PageAgentConfig = {}) {
super()
this.config = config
this.#llm = new LLM(this.config, this.id)
this.i18n = new I18n(this.config.language)
patchReact(this)
}
/**
* @todo maybe return something?
*/
async execute(task: string): Promise<ExecutionResult> {
if (!task) throw new Error('Task is required')
this.task = task
// Show mask and panel
this.mask.show()
this.bus.emit('panel:show')
this.bus.emit('panel:reset')
this.bus.emit('panel:update', {
type: 'input',
displayText: task,
})
if (this.#abortController) {
this.#abortController.abort()
this.#abortController = new AbortController()
}
this.history = []
try {
let step = 0
while (true) {
console.group(`step: ${step + 1}`)
// 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.bus.emit('panel:update', {
type: 'thinking',
displayText: this.i18n.t('ui.panel.thinking'),
})
const result = await this.#llm.invoke(
[
{
role: 'system',
content: this.#getSystemPrompt(),
},
{
role: 'user',
content: this.#assembleUserPrompt(),
},
],
// tools,
this.#packMacroTool(),
this.#abortController.signal
)
const toolResult = result.toolResult
const input = toolResult.input
const output = toolResult.output
const brain = {
thinking: input.thinking,
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()
step++
if (step > MAX_STEPS) {
this.#onDone('Step count exceeded maximum limit', false)
return {
success: false,
data: 'Step count exceeded maximum limit',
history: this.history,
}
}
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)
return {
success,
data: text,
history: this.history,
}
}
}
} catch (error: unknown) {
console.error('Task failed', error)
this.#onDone(String(error), false)
return {
success: false,
data: String(error),
history: this.history,
}
}
}
/**
* 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
*
* @topic 要不要合并成一个 tool
* @facts
* - 我们需要模型每步返回 evaluation/memory/goal 等思考过程
* - browser use 合并成一个巨大的 tool
* ```json
* {
* "memory": "...",
* "goal": "...",
* "actions": [
* {
* "name": "...",
* "args": "..."
* }
* // ...
* ]
* }
* ```
* - qwen 目前必须指定 function name 来确保 tool call
* @reasoning
* - 不能为了 qwen 的缺陷而设计系统
* - 更复杂的 tool 更容易出错
* - 分散的 tool 更容易利用 ai-sdk 的重试机制,也更容易处理错误
* - 不能用额外的步骤生成这些数据,不仅性能过差,而且 goal 之类的必须和 call 一起生成
* @options
* - Plan @A
* - 和 browser use 使用完全一致的做法,合并成一个大 tool要求每次调用
* - 会把 tool 定义变得非常复杂,增加出错率
* - Plan @B
* - 每次调用两个 tool其中一个用来输出思考
* - 很难用提示词 enforce 这么复杂的规则
* - Plan @C
* - 自动为每个 tool 增加固定的 reasoning/memory/goal 等输入,并自动拦截提取这些数据
* - 会让 tool 定义变得很长
* @conclusion
* - 使用 @A
*/
#packMacroTool(): ToolSet {
const tools = this.tools
// discriminated version
// @note Success rate ~0, model seems unable to understand discriminated union
// // Create discriminated union schemas from tools
// const actionSchemas = Array.from(tools.entries()).map(([toolName, tool]) => {
// return zod.object({
// name: zod.literal(toolName),
// input: tool.inputSchema,
// })
// })
// // Ensure at least one tool exists
// assert(actionSchemas.length, 'No tools available to create macro tool')
// const actionSchema = zod.discriminatedUnion('name', actionSchemas as any)
// union version
const actionSchemas = Array.from(tools.entries()).map(([toolName, tool]) => {
return zod.object({
[toolName]: tool.inputSchema,
})
})
const actionSchema = zod.union(actionSchemas)
return {
[MACRO_TOOL_NAME]: tool({
// description: 'Output the result of the agent',
inputSchema: zod.object({
// thinking: zod.string().optional(),
evaluation_previous_goal: zod.string().optional(),
memory: zod.string().optional(),
next_goal: zod.string().optional(),
action: actionSchema,
}),
execute: async (input, options) => {
// 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.bus.emit('panel:update', {
type: 'thinking',
displayText: 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, options)
this.bus.emit('panel:update', {
type: 'tool_executing',
toolName,
toolArgs: toolInput,
displayText: getToolExecutingText(toolName, toolInput, this.i18n),
})
const startTime = Date.now()
// Execute tool, passing options parameter
let result = await tool.execute!.bind(this)(toolInput, options)
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
const displayResult = getToolCompletedText(toolName, toolInput, this.i18n)
if (displayResult)
this.bus.emit('panel:update', {
type: 'tool_executing',
toolName,
toolArgs: toolInput,
toolResult: result,
displayText: displayResult,
duration,
})
// Wait a moment to let user see the result
await new Promise((resolve) => setTimeout(resolve, 100))
return 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
}
#assembleUserPrompt(): 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 += this.#getBrowserState()
return trimLines(prompt)
}
#onDone(text: string, success = true) {
dom.cleanUpHighlights()
// Update panel status
this.bus.emit('panel:update', {
type: success ? 'output' : 'error',
displayText: text,
})
// Task completed
this.bus.emit('panel:update', {
type: 'completed',
displayText: this.i18n.t('ui.panel.taskCompleted'),
})
this.mask.hide()
this.#abortController.abort()
}
#getBrowserState(): string {
const pageUrl = window.location.href
const pageTitle = document.title
const pi = getPageInfo()
this.#updateTree()
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
${VIEWPORT_EXPANSION === -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 && VIEWPORT_EXPANSION !== -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 += this.simplifiedHTML
prompt += `\n`
// Page footer info
const has_content_below = pi.pixels_below > 4
if (has_content_below && VIEWPORT_EXPANSION !== -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
}
/**
* Update document tree
*/
#updateTree() {
this.dispatchEvent(new Event('beforeUpdate'))
this.lastTimeUpdate = Date.now()
dom.cleanUpHighlights()
this.mask.wrapper.style.pointerEvents = 'none'
this.flatTree = dom.getFlatTree({
...this.config,
interactiveBlacklist: [
...(this.config.interactiveBlacklist || []),
...document.querySelectorAll('[data-page-agent-not-interactive]').values(),
],
})
this.mask.wrapper.style.pointerEvents = 'auto'
this.simplifiedHTML = dom.flatTreeToString(this.flatTree, this.config.include_attributes)
this.selectorMap.clear()
this.selectorMap = dom.getSelectorMap(this.flatTree)
this.elementTextMap.clear()
this.elementTextMap = dom.getElementTextMap(this.simplifiedHTML)
this.dispatchEvent(new Event('afterUpdate'))
}
dispose() {
console.log('Disposing PageAgent...')
this.disposed = true
dom.cleanUpHighlights()
this.flatTree = null
this.selectorMap.clear()
this.elementTextMap.clear()
this.panel.dispose()
this.mask.dispose()
this.history = []
this.#abortController.abort('PageAgent disposed')
}
}

49
src/config/constants.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* @note Since isTopElement depends on elementFromPoint,
* it returns null when out of viewport, this feature has no practical use, only differ between -1 and 0
*/
// export const VIEWPORT_EXPANSION = 100
export const VIEWPORT_EXPANSION = -1
// models
// 🥇 GPT-4.1 (best so far)
export const DEFAULT_MODEL_NAME: string = 'gpt-41-mini-0414-global' // baseline 🌟
// export const DEFAULT_MODEL_NAME: string = 'gpt-41-0414-global' // unnecessary
// 🤞 qwen (tool call format often irregular)
// export const DEFAULT_MODEL_NAME: string = 'qwen-plus-latest' // okay
// export const DEFAULT_MODEL_NAME: string = 'qwen-turbo-latest' // BAD☠
// 👍 Anthropic
// export const DEFAULT_MODEL_NAME: string = 'claude_sonnet4'
// 👌 DeepSeek
// export const DEFAULT_MODEL_NAME: string = 'DeepSeek-V3-671B'
// export const DEFAULT_MODEL_NAME: string = 'deepseek-v3.1'
// export const DEFAULT_MODEL_NAME: string = 'deepseek-v3'
// ☠️❌🙂‍↔️ GPT-5 (slow as hell)
// export const DEFAULT_MODEL_NAME: string = '_gpt-5-nano-0807-global'
// export const DEFAULT_MODEL_NAME: string = '_gpt-5-mini-0807-global'
// export const DEFAULT_MODEL_NAME: string = '_gpt-5-0807-global'
// ❌ Gemini (incapable tool call json schema)
// @todo need a special client for gemini
// export const DEFAULT_MODEL_NAME: string = 'gemini-2.5-pro-06-17'
// export const DEFAULT_MODEL_NAME: string = import.meta.env.OPEN_ROUTER_MODEL!
// ak
export const DEFAULT_API_KEY: string = 'not-needed'
// export const DEFAULT_API_KEY: string = import.meta.env.OPEN_ROUTER_KEY!
// base url
export const DEFAULT_BASE_URL: string = 'http://localhost:3000/api/agent'
// export const DEFAULT_BASE_URL: string = import.meta.env.OPEN_ROUTER_BASE_URL!
// internal
export const MACRO_TOOL_NAME = 'AgentOutput' as const
export const LLM_MAX_RETRIES = 2
export const MAX_STEPS = 20

10
src/config/index.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { DomConfig } from '@/dom'
import type { SupportedLanguage } from '@/i18n'
import type { LLMConfig } from '@/llms'
export interface UIConfig {
// theme?: 'light' | 'dark'
language?: SupportedLanguage
}
export type PageAgentConfig = LLMConfig & DomConfig & UIConfig

1685
src/dom/dom_tree/index.js Normal file

File diff suppressed because it is too large Load Diff

51
src/dom/dom_tree/type.ts Normal file
View File

@@ -0,0 +1,51 @@
// FlatDomTree: 扁平化 DOM 树结构,适用于高效存储和遍历页面结构。
// 每个节点通过 map 索引,支持文本节点和元素节点,字段区分 undefined 和 false。
export interface FlatDomTree {
rootId: string
map: Record<string, DomNode>
}
export type DomNode = TextDomNode | ElementDomNode | InteractiveElementDomNode
export interface TextDomNode {
type: 'TEXT_NODE'
text: string
isVisible: boolean
// 其他可选字段
[key: string]: unknown
}
export interface ElementDomNode {
tagName: string
attributes?: Record<string, string>
xpath?: string
children?: string[]
isVisible?: boolean
isTopElement?: boolean
isInViewport?: boolean
isNew?: boolean
isInteractive?: false
highlightIndex?: number
extra?: Record<string, any>
// 其他可选字段
[key: string]: unknown
}
export interface InteractiveElementDomNode {
tagName: string
attributes?: Record<string, string>
xpath?: string
children?: string[]
isVisible?: boolean
isTopElement?: boolean
isInViewport?: boolean
isInteractive: true
highlightIndex: number
/**
* 可交互元素的 dom 引用
*/
ref: HTMLElement
// 其他可选字段
[key: string]: unknown
}

42
src/dom/getPageInfo.ts Normal file
View File

@@ -0,0 +1,42 @@
export function getPageInfo() {
const viewport_width = window.innerWidth
const viewport_height = window.innerHeight
const page_width = Math.max(document.documentElement.scrollWidth, document.body.scrollWidth || 0)
const page_height = Math.max(
document.documentElement.scrollHeight,
document.body.scrollHeight || 0
)
const scroll_x = window.scrollX || window.pageXOffset || document.documentElement.scrollLeft || 0
const scroll_y = window.scrollY || window.pageYOffset || document.documentElement.scrollTop || 0
const pixels_below = Math.max(0, page_height - (window.innerHeight + scroll_y))
const pixels_right = Math.max(0, page_width - (window.innerWidth + scroll_x))
return {
// Current viewport dimensions
viewport_width,
viewport_height,
// Total page dimensions
page_width,
page_height,
// Current scroll position
scroll_x,
scroll_y,
pixels_above: scroll_y,
pixels_below,
pages_above: viewport_height > 0 ? scroll_y / viewport_height : 0,
pages_below: viewport_height > 0 ? pixels_below / viewport_height : 0,
total_pages: viewport_height > 0 ? page_height / viewport_height : 0,
current_page_position: scroll_y / Math.max(1, page_height - viewport_height),
pixels_left: scroll_x,
pixels_right,
}
}

475
src/dom/index.ts Normal file
View File

@@ -0,0 +1,475 @@
import { VIEWPORT_EXPANSION } from '@/config/constants'
import domTree from '@/dom/dom_tree/index'
import {
ElementDomNode,
FlatDomTree,
InteractiveElementDomNode,
TextDomNode,
} from '@/dom/dom_tree/type'
export interface DomConfig {
interactiveBlacklist?: (Element | (() => Element))[]
interactiveWhitelist?: (Element | (() => Element))[]
include_attributes?: string[]
highlightOpacity?: number
highlightLabelOpacity?: number
}
/**
* 用于检测可交互元素是否是新出现的。
*/
const newElementsCache = new WeakMap<HTMLElement, string>()
export function getFlatTree(config: DomConfig): FlatDomTree {
const interactiveBlacklist = [] as Element[]
for (const item of config.interactiveBlacklist || []) {
if (typeof item === 'function') {
interactiveBlacklist.push(item())
} else {
interactiveBlacklist.push(item)
}
}
const interactiveWhitelist = [] as Element[]
for (const item of config.interactiveWhitelist || []) {
if (typeof item === 'function') {
interactiveWhitelist.push(item())
} else {
interactiveWhitelist.push(item)
}
}
const elements = domTree({
doHighlightElements: true,
debugMode: true,
focusHighlightIndex: -1,
viewportExpansion: VIEWPORT_EXPANSION,
interactiveBlacklist,
interactiveWhitelist,
highlightOpacity: config.highlightOpacity ?? 0.0,
highlightLabelOpacity: config.highlightLabelOpacity ?? 0.1,
}) as FlatDomTree
const currentUrl = window.location.href
/**
* 标记新出现的元素
* @todo browser-use 使用 hash(位置,属性等信息) 来判断是否同一个元素,
* 能够解决 1. 元素被删除后重新添加 2. 页面卸载 等问题。
* 这里先简单做.
*/
for (const nodeId in elements.map) {
const node = elements.map[nodeId]
if (node.isInteractive && node.ref) {
const ref = node.ref as HTMLElement
// @note 这样太严格,元素是可以跨页面存在的
// if (newElementsCache.get(ref) !== currentUrl) {
if (!newElementsCache.has(ref)) {
newElementsCache.set(ref, currentUrl)
node.isNew = true
}
}
}
return elements
}
/**
* elementsToString 内部使用的类型
*/
interface TreeNode {
type: 'text' | 'element'
parent: TreeNode | null
children: TreeNode[]
isVisible: boolean
// Text node properties
text?: string
// Element node properties
tagName?: string
attributes?: Record<string, string>
isInteractive?: boolean
isTopElement?: boolean
isNew?: boolean
highlightIndex?: number
extra?: Record<string, any>
}
/**
* 对应 python 中的 views::clickable_elements_to_string,
* 将 dom 信息处理成适合 llm 阅读的文本格式
* @形如
* ``` text
* [0]<a aria-label=page-agent.js 首页 />
* [1]<div >P />
* [2]<div >page-agent.js
* UI Agent in your webpage />
* [3]<a >文档 />
* [4]<a aria-label=查看源码(在新窗口打开)>源码 />
* UI Agent in your webpage
* 用户输入需求AI 理解页面并自动操作。
* [5]<a role=button>快速开始 />
* [6]<a role=button>查看文档 />
* 无需后端
* ```
* 其中可交互元素用序号标出提示llm可以用序号操作。
* 缩进代表父子关系。
* 普通文本则直接列出来。
*
* @todo 数据脱敏过滤器
*/
export function flatTreeToString(flatTree: FlatDomTree, include_attributes?: string[]): string {
const DEFAULT_INCLUDE_ATTRIBUTES = [
'title',
'type',
'checked',
'name',
'role',
'value',
'placeholder',
'data-date-format',
'alt',
'aria-label',
'aria-expanded',
'data-state',
'aria-checked',
// @edit added for better form handling
'id',
'for',
// for jump check
'target',
// absolute 定位的下拉菜单
'aria-haspopup',
'aria-controls',
'aria-owns',
]
const includeAttrs = [...(include_attributes || []), ...DEFAULT_INCLUDE_ATTRIBUTES]
// Helper function to cap text length
const capTextLength = (text: string, maxLength: number): string => {
if (text.length > maxLength) {
return text.substring(0, maxLength) + '...'
}
return text
}
// Build tree structure from flat map
const buildTreeNode = (nodeId: string): TreeNode | null => {
const node = flatTree.map[nodeId]
if (!node) return null
if (node.type === 'TEXT_NODE') {
const textNode = node as TextDomNode
return {
type: 'text',
text: textNode.text,
isVisible: textNode.isVisible,
parent: null,
children: [],
}
} else {
const elementNode = node as ElementDomNode
const children: TreeNode[] = []
if (elementNode.children) {
for (const childId of elementNode.children) {
const child = buildTreeNode(childId)
if (child) {
child.parent = null // Will be set later
children.push(child)
}
}
}
return {
type: 'element',
tagName: elementNode.tagName,
attributes: elementNode.attributes ?? {},
isVisible: elementNode.isVisible ?? false,
isInteractive: elementNode.isInteractive ?? false,
isTopElement: elementNode.isTopElement ?? false,
isNew: elementNode.isNew ?? false,
highlightIndex: elementNode.highlightIndex,
parent: null,
children,
extra: elementNode.extra ?? {},
}
}
}
// Set parent references
const setParentReferences = (node: TreeNode, parent: TreeNode | null = null) => {
node.parent = parent
for (const child of node.children) {
setParentReferences(child, node)
}
}
// Build root node
const rootNode = buildTreeNode(flatTree.rootId)
if (!rootNode) return ''
setParentReferences(rootNode)
// Helper to check if text node has parent with highlight index
const hasParentWithHighlightIndex = (node: TreeNode): boolean => {
let current = node.parent
while (current) {
if (current.type === 'element' && current.highlightIndex !== undefined) {
return true
}
current = current.parent
}
return false
}
// Helper to check if parent is top element
// const isParentTopElement = (node: TreeNode): boolean => {
// return node.parent?.type === 'element' && node.parent.isTopElement === true
// }
// Main processing function
const processNode = (node: TreeNode, depth: number, result: string[]): void => {
let nextDepth = depth
const depthStr = '\t'.repeat(depth)
if (node.type === 'element') {
// Add element with highlight_index
if (node.highlightIndex !== undefined) {
nextDepth += 1
const text = getAllTextTillNextClickableElement(node)
let attributesHtmlStr = ''
if (includeAttrs.length > 0 && node.attributes) {
const attributesToInclude: Record<string, string> = {}
// Filter attributes
for (const key of includeAttrs) {
const value = node.attributes[key]
if (value && value.trim() !== '') {
attributesToInclude[key] = value.trim()
}
}
// Remove duplicate values (for attributes longer than 5 chars)
const orderedKeys = includeAttrs.filter((key) => key in attributesToInclude)
if (orderedKeys.length > 1) {
const keysToRemove = new Set<string>()
const seenValues: Record<string, string> = {}
for (const key of orderedKeys) {
const value = attributesToInclude[key]
if (value.length > 5) {
if (value in seenValues) {
keysToRemove.add(key)
} else {
seenValues[value] = key
}
}
}
for (const key of keysToRemove) {
delete attributesToInclude[key]
}
}
// Remove role if it matches tagName
if (attributesToInclude.role === node.tagName) {
delete attributesToInclude.role
}
// Remove attributes that duplicate text content
const attrsToRemoveIfTextMatches = ['aria-label', 'placeholder', 'title']
for (const attr of attrsToRemoveIfTextMatches) {
if (
attributesToInclude[attr] &&
attributesToInclude[attr].toLowerCase().trim() === text.toLowerCase().trim()
) {
delete attributesToInclude[attr]
}
}
if (Object.keys(attributesToInclude).length > 0) {
attributesHtmlStr = Object.entries(attributesToInclude)
.map(([key, value]) => `${key}=${capTextLength(value, 20)}`)
.join(' ')
}
}
// Build the line
const highlightIndicator = node.isNew
? `*[${node.highlightIndex}]`
: `[${node.highlightIndex}]`
let line = `${depthStr}${highlightIndicator}<${node.tagName ?? ''}`
if (attributesHtmlStr) {
line += ` ${attributesHtmlStr}`
}
/**
* @edit scrollable 数据
*/
if (node.extra) {
if (node.extra.scrollable) {
let scrollDataText = ''
if (node.extra.scrollData?.left)
scrollDataText += `left=${node.extra.scrollData.left}, `
if (node.extra.scrollData?.top) scrollDataText += `top=${node.extra.scrollData.top}, `
if (node.extra.scrollData?.right)
scrollDataText += `right=${node.extra.scrollData.right}, `
if (node.extra.scrollData?.bottom)
scrollDataText += `bottom=${node.extra.scrollData.bottom}`
line += ` data-scrollable="${scrollDataText}"`
}
}
if (text) {
const trimmedText = text.trim()
if (!attributesHtmlStr) {
line += ' '
}
line += `>${trimmedText}`
} else if (!attributesHtmlStr) {
line += ' '
}
line += ' />'
result.push(line)
}
// Process children regardless
for (const child of node.children) {
processNode(child, nextDepth, result)
}
} else if (node.type === 'text') {
// Add text only if it doesn't have a highlighted parent
if (hasParentWithHighlightIndex(node)) {
return
}
if (
node.parent &&
node.parent.type === 'element' &&
node.parent.isVisible &&
node.parent.isTopElement
) {
result.push(`${depthStr}${node.text ?? ''}`)
}
}
}
const result: string[] = []
processNode(rootNode, 0, result)
return result.join('\n')
}
// Get all text until next clickable element
export const getAllTextTillNextClickableElement = (node: TreeNode, maxDepth = -1): string => {
const textParts: string[] = []
const collectText = (currentNode: TreeNode, currentDepth: number) => {
if (maxDepth !== -1 && currentDepth > maxDepth) {
return
}
// Skip this branch if we hit a highlighted element (except for the current node)
if (
currentNode.type === 'element' &&
currentNode !== node &&
currentNode.highlightIndex !== undefined
) {
return
}
if (currentNode.type === 'text' && currentNode.text) {
textParts.push(currentNode.text)
} else if (currentNode.type === 'element') {
for (const child of currentNode.children) {
collectText(child, currentDepth + 1)
}
}
}
collectText(node, 0)
return textParts.join('\n').trim()
}
export function getSelectorMap(flatTree: FlatDomTree): Map<number, InteractiveElementDomNode> {
const selectorMap = new Map<number, InteractiveElementDomNode>()
const keys = Object.keys(flatTree.map)
for (const key of keys) {
const node = flatTree.map[key]
if (node.isInteractive && typeof node.highlightIndex === 'number') {
selectorMap.set(node.highlightIndex, node as InteractiveElementDomNode)
}
}
return selectorMap
}
export function getElementTextMap(simplifiedHTML: string) {
const lines = simplifiedHTML
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
const elementTextMap = new Map<number, string>()
for (const line of lines) {
const regex = /^\[(\d+)\]<[^>]+>([^<]*)/
const match = regex.exec(line)
if (match) {
const index = parseInt(match[1], 10)
elementTextMap.set(index, line)
}
}
return elementTextMap
}
export function cleanUpHighlights() {
const cleanupFunctions = (window as any)._highlightCleanupFunctions || []
for (const cleanup of cleanupFunctions) {
if (typeof cleanup === 'function') {
cleanup()
}
}
;(window as any)._highlightCleanupFunctions = []
}
// 监听 URL 的任何变化,立刻清空 highLights
window.addEventListener('popstate', () => {
// console.log('URL changed (popstate), highlights cleaned up.')
cleanUpHighlights()
})
window.addEventListener('hashchange', () => {
// console.log('URL changed (hashchange), highlights cleaned up.')
cleanUpHighlights()
})
window.addEventListener('beforeunload', () => {
// console.log('Page is unloading, highlights cleaned up.')
cleanUpHighlights()
})
const navigation = (window as any).navigation
if (navigation && typeof navigation.addEventListener === 'function') {
navigation.addEventListener('navigate', () => {
// console.log('Navigation event detected, highlights cleaned up.')
cleanUpHighlights()
})
} else {
// 定时器
let currentUrl = window.location.href
setInterval(() => {
if (window.location.href !== currentUrl) {
currentUrl = window.location.href
// console.log('URL changed (interval), highlights cleaned up.')
cleanUpHighlights()
}
}, 500)
}

33
src/entry.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* Auto-run entry for page-agent.js. Insert this script into your page to get page-agent functionality.
*/
import { PageAgent, type PageAgentConfig } from './PageAgent'
import { DEFAULT_MODEL_NAME } from './config/constants'
// Clean up existing instances to prevent multiple injections from bookmarklet
if (window.pageAgent) {
window.pageAgent.dispose()
}
// Mount to global window object
window.PageAgent = PageAgent
// Export for ES module usage
export { PageAgent }
console.log('🚀 page-agent.js loaded!')
const currentScript = document.currentScript as HTMLScriptElement | null
if (currentScript) {
const url = new URL(currentScript.src)
const modelName = url.searchParams.get('model') || DEFAULT_MODEL_NAME
const language = (url.searchParams.get('lang') as 'zh-CN' | 'en-US') || 'zh-CN'
const config = { modelName, language } as PageAgentConfig
window.pageAgent = new PageAgent(config)
} else {
window.pageAgent = new PageAgent()
}
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
window.pageAgent.bus.emit('panel:show') // Show panel

44
src/i18n/index.ts Normal file
View File

@@ -0,0 +1,44 @@
import { type SupportedLanguage, locales } from './locales'
import type { TranslationKey, TranslationParams, TranslationSchema } from './types'
export class I18n {
private language: SupportedLanguage
private translations: TranslationSchema
constructor(language: SupportedLanguage = 'en-US') {
this.language = language in locales ? language : 'en-US'
this.translations = locales[language]
}
// 类型安全的翻译方法
t(key: TranslationKey, params?: TranslationParams): string {
const value = this.getNestedValue(this.translations, key)
if (!value) {
console.warn(`Translation key "${key}" not found for language "${this.language}"`)
return key
}
if (params) {
return this.interpolate(value, params)
}
return value
}
private getNestedValue(obj: any, path: string): string | undefined {
return path.split('.').reduce((current, key) => current?.[key], obj)
}
private interpolate(template: string, params: TranslationParams): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
return params[key]?.toString() || match
})
}
getLanguage(): SupportedLanguage {
return this.language
}
}
// 导出类型和实例创建函数
export type { TranslationKey, SupportedLanguage, TranslationParams }
export { locales }

98
src/i18n/locales.ts Normal file
View File

@@ -0,0 +1,98 @@
import type { TranslationSchema } from './types'
// 中文翻译(作为基准)
const zhCN: TranslationSchema = {
ui: {
panel: {
ready: '准备就绪',
thinking: '正在思考...',
paused: '暂停中,稍后',
taskInput: '输入新任务,详细描述步骤,回车提交',
userAnswerPrompt: '请回答上面问题,回车提交',
taskTerminated: '任务已终止',
taskCompleted: '任务结束',
continueExecution: '继续执行',
userAnswer: '用户回答: {{input}}',
pause: '暂停',
continue: '继续',
stop: '终止',
expand: '展开历史',
collapse: '收起历史',
step: '步骤 {{number}} · {{time}}{{duration}}',
},
tools: {
clicking: '正在点击元素 [{{index}}]...',
inputting: '正在输入文本到元素 [{{index}}]...',
selecting: '正在选择选项 "{{text}}"...',
scrolling: '正在滚动页面...',
waiting: '等待 {{seconds}} 秒...',
done: '结束任务',
clicked: '🖱️ 已点击元素 [{{index}}]',
inputted: '⌨️ 已输入文本 "{{text}}"',
selected: '☑️ 已选择选项 "{{text}}"',
scrolled: '🛞 页面滚动完成',
waited: '⌛️ 等待完成',
executing: '正在执行 {{toolName}}...',
},
errors: {
elementNotFound: '未找到索引为 {{index}} 的交互元素',
taskRequired: '任务描述不能为空',
executionFailed: '任务执行失败',
notInputElement: '元素不是输入框或文本域',
notSelectElement: '元素不是选择框',
optionNotFound: '未找到选项 "{{text}}"',
},
},
} as const
// 英文翻译(必须符合相同的结构)
const enUS: TranslationSchema = {
ui: {
panel: {
ready: 'Ready',
thinking: 'Thinking...',
paused: 'Paused',
taskInput: 'Enter new task, describe steps in detail, press Enter to submit',
userAnswerPrompt: 'Please answer the question above, press Enter to submit',
taskTerminated: 'Task terminated',
taskCompleted: 'Task completed',
continueExecution: 'Continue execution',
userAnswer: 'User answer: {{input}}',
pause: 'Pause',
continue: 'Continue',
stop: 'Stop',
expand: 'Expand history',
collapse: 'Collapse history',
step: 'Step {{number}} · {{time}}{{duration}}',
},
tools: {
clicking: 'Clicking element [{{index}}]...',
inputting: 'Inputting text to element [{{index}}]...',
selecting: 'Selecting option "{{text}}"...',
scrolling: 'Scrolling page...',
waiting: 'Waiting {{seconds}} seconds...',
done: 'Task done',
clicked: '🖱️ Clicked element [{{index}}]',
inputted: '⌨️ Inputted text "{{text}}"',
selected: '☑️ Selected option "{{text}}"',
scrolled: '🛞 Page scrolled',
waited: '⌛️ Wait completed',
executing: '正在执行 {{toolName}}...',
},
errors: {
elementNotFound: 'No interactive element found at index {{index}}',
taskRequired: 'Task description is required',
executionFailed: 'Task execution failed',
notInputElement: 'Element is not an input or textarea',
notSelectElement: 'Element is not a select element',
optionNotFound: 'Option "{{text}}" not found',
},
},
} as const
export const locales = {
'zh-CN': zhCN,
'en-US': enUS,
} as const
export type SupportedLanguage = keyof typeof locales

57
src/i18n/types.ts Normal file
View File

@@ -0,0 +1,57 @@
// 定义翻译数据的结构类型
export interface TranslationSchema {
ui: {
panel: {
ready: string
thinking: string
paused: string
taskInput: string
userAnswerPrompt: string
taskTerminated: string
taskCompleted: string
continueExecution: string
userAnswer: string
pause: string
continue: string
stop: string
expand: string
collapse: string
step: string
}
tools: {
clicking: string
inputting: string
selecting: string
scrolling: string
waiting: string
done: string
clicked: string
inputted: string
selected: string
scrolled: string
waited: string
executing: string
}
errors: {
elementNotFound: string
taskRequired: string
executionFailed: string
notInputElement: string
notSelectElement: string
optionNotFound: string
}
}
}
// 工具类型:提取嵌套对象的所有路径
type NestedKeyOf<ObjectType extends object> = {
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object
? `${Key}` | `${Key}.${NestedKeyOf<ObjectType[Key]>}`
: `${Key}`
}[keyof ObjectType & (string | number)]
// 从翻译结构中提取所有可能的key路径
export type TranslationKey = NestedKeyOf<TranslationSchema>
// 参数化翻译的类型
export type TranslationParams = Record<string, string | number>

243
src/llms/index.ts Normal file
View File

@@ -0,0 +1,243 @@
/**
* @topic LLM 与主流程的隔离
* @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 { OpenAIProvider, OpenAIResponsesProviderOptions, createOpenAI } from '@ai-sdk/openai'
import type { LanguageModelV2, LanguageModelV2ToolCall } from '@ai-sdk/provider'
import type { LanguageModelUsage, ModelMessage, TypedToolCall, TypedToolResult } from 'ai'
import { ToolSet, generateText, stepCountIs } from 'ai'
import chalk from 'chalk'
import {
DEFAULT_API_KEY,
DEFAULT_BASE_URL,
DEFAULT_MODEL_NAME,
LLM_MAX_RETRIES,
MACRO_TOOL_NAME,
} from '@/config/constants'
import { assert } from '@/utils/assert'
import { EventBus, getEventBus } from '@/utils/bus'
export interface LLMConfig {
baseURL?: string
apiKey?: string
modelName?: string
maxRetries?: number
}
export class LLM {
config: Required<LLMConfig>
id: string
#openai: OpenAIProvider
#model: LanguageModelV2
#bus: EventBus
constructor(config: LLMConfig, id: string) {
this.config = {
baseURL: DEFAULT_BASE_URL,
apiKey: DEFAULT_API_KEY,
modelName: DEFAULT_MODEL_NAME,
maxRetries: LLM_MAX_RETRIES,
...config,
}
this.id = id
this.#bus = getEventBus(id)
this.#openai = createOpenAI({ baseURL: this.config.baseURL, apiKey: this.config.apiKey })
this.#model = this.#openai.chat(this.config.modelName)
// @note Will throw JSON parsing error
// this.#model = this.#openai.responses(modelName)
}
/**
* - call llm api *once*
* - invoke tool call *once*
* - return the result of the tool
*/
async invoke<T extends ToolSet>(
messages: ModelMessage[],
tools: T,
abortSignal: AbortSignal
): Promise<{
toolCall: TypedToolCall<T>
toolResult: TypedToolResult<T>
usage: LanguageModelUsage
}> {
const isClaude = this.config.modelName.slice(0, 8).includes('claude')
const isQwen = this.config.modelName.slice(0, 6).includes('qwen')
const isGPT = this.config.modelName.slice(0, 5).includes('gpt')
return await withRetry(
async () => {
// try {
const result = await generateText({
model: this.#model,
messages,
tools,
abortSignal,
/**
* 文档中没有说明,从源码看,@facts
* - 只会重试被识别为 retryable 的 API_CALL_ERROR
* - 返回无法解析的 json 应该不会重试
* - experimental_repairToolCall 只会执行一次,不算作重试
* @facts
* - 许多 proxy 过的 openAI 兼容接口返回的错误格式并不规范,通常不会被识别为 retryable
* @conclusion
* - 看起来并不实用,不如完全手工控制粗粒度重试
*/
// maxRetries: this.config.maxRetries,
maxRetries: 0,
// toolChoice: 'required',
// @note incompatible to Claude
toolChoice: isClaude ? undefined : { type: 'tool', toolName: MACRO_TOOL_NAME as any },
/**
* controlled by main loop. our method only call api once
*/
// stopWhen: [hasToolCall('done'), stepCountIs(100)],
stopWhen: [stepCountIs(1)],
// stopWhen: [hasToolCall('AgentOutput')],
providerOptions: {
openai: {
// @note this one needs all fields in tool schema must be `required`
// strictJsonSchema: true,
// This way only at most one tool can be called at a time
parallelToolCalls: false,
reasoningEffort: 'minimal',
// @note not working
// serviceTier: 'priority',
textVerbosity: 'low',
// @note Optimize OpenAI model caching, should be unique per user, currently has no effect
promptCacheKey: 'page-agent:' + this.id,
} as OpenAIResponsesProviderOptions,
},
/**
* schema 出错时执行一次,不确定是否计入重试
* 目前看起来像是会直接抛错,被 withRetry 处理
* @note
* 如果不提供,则 ai-sdk 会把 tool-error 加入 message 中重新调用一次,
* 配合 stepCountIs 或者 hasToolCall 都会导致错误被 silenttoolResults 永远为 0
* 遗憾的是,这里没有办法抛错(抛错后回到默认逻辑),只要这里 repair 不好,就会导致 silent error
* 更糟糕的是,只要传入了 tools无论 stopWhen 如何设置,都会被当作 multi-step
* 本质上就和我们 single step 的逻辑冲突
* 长远来看必须删掉 ai-sdk直接用 openAI API 实现
*/
// experimental_repairToolCall: (options): Promise<LanguageModelV2ToolCall | null> => {
// console.error('hahhah', options)
// throw options.error
// },
})
console.log(chalk.blue.bold('LLM:invoke finished'), result)
const toolError: any = result.content.find((part) => part.type === 'tool-error')
if (toolError) throw toolError.error
assert(!result.text, 'Model returned text without calling done tool', true)
assert(result.toolCalls.length === 1, 'Model must call exactly one tool', true)
assert(result.toolResults.length === 1, 'Step must have exactly one tool result', true)
const toolCall = result.toolCalls[0]
const toolResult = result.toolResults[0]
const usage = result.totalUsage
return {
toolCall,
toolResult,
usage,
}
// } catch (error) {
// // handle ai-sdk internal error here
// // currently useless since we bypassed most of ai-sdk logic
// console.log('generateText error', error)
// console.log('APICallError', APICallError.isInstance(error))
// console.log('isNoSuchModelError', NoSuchModelError.isInstance(error))
// throw error
// }
},
// retry settings
{
maxRetries: this.config.maxRetries,
onRetry: (retries: number) => {
this.#bus.emit('panel:update', {
type: 'retry',
displayText: `retry-ing (${retries} / ${this.config.maxRetries})`,
})
},
onError: (error: Error, withRetry: boolean) => {
this.#bus.emit('panel:update', {
type: 'error',
displayText: `step failed: ${(error as Error).message}`,
})
},
}
)
}
}
async function withRetry<T>(
fn: () => Promise<T>,
settings: {
maxRetries: number
onRetry: (retries: number) => void
onError: (error: Error, withRetry: boolean) => void
}
): Promise<T> {
let retries = 0
let lastError: Error | null = null
while (retries <= settings.maxRetries) {
if (retries > 0) {
settings.onRetry(retries)
await new Promise((resolve) => setTimeout(resolve, 100))
}
try {
return await fn()
} catch (error: any) {
console.error(error)
settings.onError(error as Error, retries < settings.maxRetries)
// do not retry if aborted by user
if (error?.name === 'AbortError') throw error
lastError = error as Error
retries++
await new Promise((resolve) => setTimeout(resolve, 100))
}
}
throw lastError!
}

20
src/patches/antd.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { PageAgent } from '@/PageAgent'
const clearFunctions = [] as (() => void)[]
/**
* antd 的 select 是 div 包 input 的结构,所有信息都在 input 标签上,
* 但是 input 不可见,也不会出现在清洗后的树里,因此这里把他提上来
*/
function fixAntdSelect() {
const selects = [...document.querySelectorAll('input[role="combobox"]')]
// for (const select of selects) {}
}
export function patchAntd(pageAgent: PageAgent) {
pageAgent.addEventListener('beforeUpdate', fixAntdSelect)
pageAgent.addEventListener('afterUpdate', () => {
for (const fn of clearFunctions) fn()
clearFunctions.length = 0
})
}

16
src/patches/react.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { PageAgent } from '@/PageAgent'
// Find common React root elements and add data-page-agent-not-interactive attribute
export function patchReact(pageAgent: PageAgent) {
const reactRootElements = document.querySelectorAll(
'[data-reactroot], [data-reactid], [data-react-checksum], #root, #app, [id^="root-"], [id^="app-"], #adex-wrapper, #adex-root'
)
for (const element of reactRootElements) {
element.setAttribute('data-page-agent-not-interactive', 'true')
}
}
/**
* @todo (Heavy, might have false negatives) Interaction detection, if element width/height equals body offsetWidth/Height, consider it root element and non-interactive (React often attaches many events to root elements, causing false positives)
*/

View File

@@ -0,0 +1,156 @@
You are an AI agent designed to operate in an iterative loop to automate browser tasks. Your ultimate goal is accomplishing the task provided in <user_request>.
<intro>
You excel at following tasks:
1. Navigating complex websites and extracting precise information
2. Automating form submissions and interactive web actions
3. Gathering and saving information
4. Operate effectively in an agent loop
5. Efficiently performing diverse web tasks
</intro>
<language_settings>
- Default working language: **中文**
- Use the language that user is using. Return in user's language.
</language_settings>
<input>
At every step, your input will consist of:
1. <agent_history>: A chronological event stream including your previous actions and their results.
2. <agent_state>: Current <user_request> and <step_info>.
3. <browser_state>: Current URL, interactive elements indexed for actions, and visible page content.
</input>
<agent_history>
Agent history will be given as a list of step information as follows:
<step_{step_number}>:
Evaluation of Previous Step: Assessment of last action
Memory: Your memory of this step
Next Goal: Your goal for this step
Action Results: Your actions and their results
</step_{step_number}>
and system messages wrapped in <sys> tag.
</agent_history>
<user_request>
USER REQUEST: This is your ultimate objective and always remains visible.
- This has the highest priority. Make the user happy.
- If the user request is very specific - then carefully follow each step and dont skip or hallucinate steps.
- If the task is open ended you can plan yourself how to get it done.
</user_request>
<browser_state>
1. Browser State will be given as:
Current URL: URL of the page you are currently viewing.
Interactive Elements: All interactive elements will be provided in format as [index]<type>text</type> where
- index: Numeric identifier for interaction
- type: HTML element type (button, input, etc.)
- text: Element description
Examples:
[33]<div>User form</div>
\t*[35]<button aria-label='Submit form'>Submit</button>
Note that:
- Only elements with numeric indexes in [] are interactive
- (stacked) indentation (with \t) is important and means that the element is a (html) child of the element above (with a lower index)
- Elements tagged with `*[` are the new clickable elements that appeared on the website since the last step - if url has not changed.
- Pure text elements without [] are not interactive.
</browser_state>
<browser_rules>
Strictly follow these rules while using the browser and navigating the web:
- Only interact with elements that have a numeric [index] assigned.
- Only use indexes that are explicitly provided.
- If the page changes after, for example, an input text action, analyze if you need to interact with new elements, e.g. selecting the right option from the list.
- By default, only elements in the visible viewport are listed. Use scrolling actions if you suspect relevant content is offscreen which you need to interact with. Scroll ONLY if there are more pixels below or above the page.
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
- All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed.
- If a captcha appears, tell user you can not solve captcha. finished the task and ask user to solve it.
- If expected elements are missing, try scrolling, or navigating back.
- If the page is not fully loaded, use the `wait` action.
- Do not repeat one action for more than 3 times unless some conditions changed.
- If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field.
- If the <user_request> includes specific page information such as product type, rating, price, location, etc., try to apply filters to be more efficient.
- The <user_request> is the ultimate goal. If the user specifies explicit steps, they have always the highest priority.
- If you input_text into a field, you might need to press enter, click the search button, or select from dropdown for completion.
- Don't login into a page if you don't have to. Don't login if you don't have the credentials.
- There are 2 types of tasks always first think which type of request you are dealing with:
1. Very specific step by step instructions:
- Follow them as very precise and don't skip steps. Try to complete everything as requested.
2. Open ended tasks. Plan yourself, be creative in achieving them.
- If you get stuck e.g. with logins or captcha in open-ended tasks you can re-evaluate the task and try alternative ways, e.g. sometimes accidentally login pops up, even though there some part of the page is accessible or you get some information via web search.
</browser_rules>
<capability>
- You can only handle single page app. Do not jump out of current page.
- Do not click on link if it will open in a new page (etc. <a target="_blank">)
- It is ok to fail the task.
- User can be wrong. If the request of user is not achievable, inappropriate or you do not have enough information or tools to achieve it. Tell user to make a better request.
- Webpage can be broken. All webpages or apps have bugs. Some bug will make it hard for your job. It's encouraged to tell user the problem of current page. Your feedbacks (including failing) are valuable for user.
- Trying to hard can be harmful. Repeating some action back and forth or pushing for a complex procedure with little knowledge can cause unwanted result and harmful side-effects. User would rather you to complete the task with a fail.
- If you are not clear about the request or steps. `ask_user` to clarify it.
- If you do not have knowledge for the current webpage or task. You must require user to give specific instructions and detailed steps.
</capability>
<task_completion_rules>
You must call the `done` action in one of three cases:
- When you have fully completed the USER REQUEST.
- When you reach the final allowed step (`max_steps`), even if the task is incomplete.
- When you feel stuck or unable to solve user request. Or user request is not clear or contains inappropriate content.
- If it is ABSOLUTELY IMPOSSIBLE to continue.
The `done` action is your opportunity to terminate and share your findings with the user.
- Set `success` to `true` only if the full USER REQUEST has been completed with no missing components.
- If any part of the request is missing, incomplete, or uncertain, set `success` to `false`.
- You can use the `text` field of the `done` action to communicate your findings and to provide a coherent reply to the user and fulfill the USER REQUEST.
- You are ONLY ALLOWED to call `done` as a single action. Don't call it together with other actions.
- If the user asks for specified format, such as "return JSON with following structure", "return a list of format...", MAKE sure to use the right format in your answer.
- If the user asks for a structured output, your `done` action's schema may be modified. Take this schema into account when solving the task!
</task_completion_rules>
<reasoning_rules>
Exhibit the following reasoning patterns to successfully achieve the <user_request>:
- Reason about <agent_history> to track progress and context toward <user_request>.
- Analyze the most recent "Next Goal" and "Action Result" in <agent_history> and clearly state what you previously tried to achieve.
- Analyze all relevant items in <agent_history> and <browser_state> to understand your state.
- Explicitly judge success/failure/uncertainty of the last action. Never assume an action succeeded just because it appears to be executed in your last step in <agent_history>. If the expected change is missing, mark the last action as failed (or uncertain) and plan a recovery.
- Analyze whether you are stuck, e.g. when you repeat the same actions multiple times without any progress. Then consider alternative approaches e.g. scrolling for more context or ask user for help.
- `ask_user` for help if you have any difficulty. Users want to be kept in the loop.
- If you see information relevant to <user_request>, plan saving the information to memory.
- Always reason about the <user_request>. Make sure to carefully analyze the specific steps and information required. E.g. specific filters, specific form fields, specific information to search. Make sure to always compare the current trajectory with the user request and think carefully if thats how the user requested it.
</reasoning_rules>
<examples>
Here are examples of good output patterns. Use them as reference but never copy them directly.
<evaluation_examples>
- Positive Examples:
"evaluation_previous_goal": "Successfully navigated to the product page and found the target information. Verdict: Success"
"evaluation_previous_goal": "Clicked the login button and user authentication form appeared. Verdict: Success"
</evaluation_examples>
<memory_examples>
"memory": "Found many pending reports that need to be analyzed in the main page. Successfully processed the first 2 reports on quarterly sales data and moving on to inventory analysis and customer feedback reports."
</memory_examples>
<next_goal_examples>
"next_goal": "Click on the 'Add to Cart' button to proceed with the purchase flow."
"next_goal": "Extract details from the first item on the page."
</next_goal_examples>
</examples>
<output>
You must ALWAYS respond with a valid JSON in this exact format:
{
"evaluation_previous_goal": "Concise one-sentence analysis of your last action. Clearly state success, failure, or uncertain.",
"memory": "1-3 concise sentences of specific memory of this step and overall progress. You should put here everything that will help you track progress in future steps. Like counting pages visited, items found, etc.",
"next_goal": "State the next immediate goal and action to achieve it, in one clear sentence."
"action":{"one_action_name": {// action-specific parameter}}
}
</output>

430
src/tools/actions.ts Normal file
View File

@@ -0,0 +1,430 @@
/**
* Copyright (C) 2025 Alibaba Group Holding Limited
* All rights reserved.
*/
import type { PageAgent } from '../PageAgent'
// ======= general utils =======
export async function waitFor(seconds: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
}
let currentUrl = window.location.href
export async function getSystemInfo() {
// If current URL is already up to date, no need to add message
if (currentUrl === window.location.href) return ''
await waitFor(0.3) // Wait a bit longer for page to load
currentUrl = window.location.href
return `\n<sys> Current URL changed to: ${currentUrl} </sys>`
}
// ======= dom utils =======
export async function movePointerToElement(element: HTMLElement) {
const rect = element.getBoundingClientRect()
const x = rect.left + rect.width / 2
const y = rect.top + rect.height / 2
window.dispatchEvent(new CustomEvent('PageAgent::MovePointerTo', { detail: { x, y } }))
await waitFor(0.3)
}
/**
* Get the HTMLElement by index from the selectorMap in PageAgent.
*/
export function getElementByIndex(pageAgent: PageAgent, index: number): HTMLElement {
const interactiveNode = pageAgent.selectorMap.get(index)
if (!interactiveNode) {
throw new Error(`No interactive element found at index ${index}`)
}
const element = interactiveNode.ref
if (!element) {
throw new Error(`Element at index ${index} does not have a reference`)
}
if (!(element instanceof HTMLElement)) {
throw new Error(`Element at index ${index} is not an HTMLElement`)
}
return element
}
let lastClickedElement: HTMLElement | null = null
function blurLastClickedElement() {
if (lastClickedElement) {
lastClickedElement.blur()
lastClickedElement.dispatchEvent(
new MouseEvent('mouseout', { bubbles: true, cancelable: true })
)
lastClickedElement = null
}
}
/**
* Simulate a click on the element
*/
export async function clickElement(element: HTMLElement) {
blurLastClickedElement()
lastClickedElement = element
await scrollIntoViewIfNeeded(element)
await movePointerToElement(element)
window.dispatchEvent(new CustomEvent('PageAgent::ClickPointer'))
await waitFor(0.1)
// hover it
element.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true, cancelable: true }))
element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true }))
// dispatch a sequence of events to ensure all listeners are triggered
element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }))
// focus it to ensure it gets the click event
element.focus()
element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }))
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
// dispatch a click event
// element.click()
await waitFor(0.1) // Wait to ensure click event processing completes
}
// eslint-disable-next-line @typescript-eslint/unbound-method
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
)!.set!
// eslint-disable-next-line @typescript-eslint/unbound-method
const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
)!.set!
/**
* create a synthetic keyboard event
* with key keycode code
*/
export async function createSyntheticInputEvent(elem: HTMLElement, key: string) {
elem.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key }))
await waitFor(0.01)
if (elem instanceof HTMLInputElement || elem instanceof HTMLTextAreaElement) {
elem.dispatchEvent(new Event('beforeinput', { bubbles: true }))
await waitFor(0.01)
elem.dispatchEvent(new Event('input', { bubbles: true }))
await waitFor(0.01)
}
elem.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: true, key }))
}
export async function inputTextElement(element: HTMLElement, text: string) {
if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) {
throw new Error('Element is not an input or textarea')
}
await clickElement(element)
if (element instanceof HTMLTextAreaElement) {
nativeTextAreaValueSetter.call(element, text)
} else {
nativeInputValueSetter.call(element, text)
}
const inputEvent = new Event('input', { bubbles: true })
element.dispatchEvent(inputEvent)
await waitFor(0.1) // Wait to ensure input event processing completes
blurLastClickedElement()
}
/**
* @todo browser-use version is very complex and supports menu tags, need to follow up
*/
export async function selectOptionElement(selectElement: HTMLSelectElement, optionText: string) {
if (!(selectElement instanceof HTMLSelectElement)) {
throw new Error('Element is not a select element')
}
const options = Array.from(selectElement.options)
const option = options.find((opt) => opt.textContent?.trim() === optionText.trim())
if (!option) {
throw new Error(`Option with text "${optionText}" not found in select element`)
}
selectElement.value = option.value
selectElement.dispatchEvent(new Event('change', { bubbles: true }))
await waitFor(0.1) // Wait to ensure change event processing completes
}
// eslint-disable-next-line @typescript-eslint/require-await
export async function scrollIntoViewIfNeeded(element: HTMLElement) {
const el = element as any
if (el.scrollIntoViewIfNeeded) {
el.scrollIntoViewIfNeeded()
// await waitFor(0.5) // Animation playback
} else {
// @todo visibility check
el.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'nearest' })
// await waitFor(0.5) // Animation playback
}
}
export async function scrollVertically(
down: boolean,
scroll_amount: number,
element?: HTMLElement | null
) {
// Element-specific scrolling if element is provided
if (element) {
const targetElement = element
console.log(
'[SCROLL DEBUG] Starting direct container scroll for element:',
targetElement.tagName
)
let currentElement = targetElement as HTMLElement | null
let scrollSuccess = false
let scrolledElement: HTMLElement | null = null
let scrollDelta = 0
let attempts = 0
const dy = scroll_amount
while (currentElement && attempts < 10) {
const computedStyle = window.getComputedStyle(currentElement)
const hasScrollableY = /(auto|scroll|overlay)/.test(computedStyle.overflowY)
const canScrollVertically = currentElement.scrollHeight > currentElement.clientHeight
console.log(
'[SCROLL DEBUG] Checking element:',
currentElement.tagName,
'hasScrollableY:',
hasScrollableY,
'canScrollVertically:',
canScrollVertically,
'scrollHeight:',
currentElement.scrollHeight,
'clientHeight:',
currentElement.clientHeight
)
if (hasScrollableY && canScrollVertically) {
const beforeScroll = currentElement.scrollTop
const maxScroll = currentElement.scrollHeight - currentElement.clientHeight
let scrollAmount = dy / 3
if (scrollAmount > 0) {
scrollAmount = Math.min(scrollAmount, maxScroll - beforeScroll)
} else {
scrollAmount = Math.max(scrollAmount, -beforeScroll)
}
currentElement.scrollTop = beforeScroll + scrollAmount
const afterScroll = currentElement.scrollTop
const actualScrollDelta = afterScroll - beforeScroll
console.log(
'[SCROLL DEBUG] Scroll attempt:',
currentElement.tagName,
'before:',
beforeScroll,
'after:',
afterScroll,
'delta:',
actualScrollDelta
)
if (Math.abs(actualScrollDelta) > 0.5) {
scrollSuccess = true
scrolledElement = currentElement
scrollDelta = actualScrollDelta
console.log(
'[SCROLL DEBUG] Successfully scrolled container:',
currentElement.tagName,
'delta:',
actualScrollDelta
)
break
}
}
if (currentElement === document.body || currentElement === document.documentElement) {
break
}
currentElement = currentElement.parentElement
attempts++
}
if (scrollSuccess) {
return `Scrolled container (${scrolledElement?.tagName}) by ${scrollDelta}px`
} else {
return `No scrollable container found for element (${targetElement.tagName})`
}
}
// Page-level scrolling (default or fallback)
const dy = scroll_amount
const bigEnough = (el: HTMLElement) => el.clientHeight >= window.innerHeight * 0.5
const canScroll = (el: HTMLElement | null) =>
el &&
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowY) &&
el.scrollHeight > el.clientHeight &&
bigEnough(el)
let el: HTMLElement | null = document.activeElement as HTMLElement | null
while (el && !canScroll(el) && el !== document.body) el = el.parentElement
el = canScroll(el)
? el
: Array.from(document.querySelectorAll<HTMLElement>('*')).find(canScroll) ||
(document.scrollingElement as HTMLElement) ||
(document.documentElement as HTMLElement)
if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
window.scrollBy(0, dy)
return `✅ Scrolled page by ${dy}px.`
} else {
el!.scrollBy({ top: dy, behavior: 'smooth' })
await waitFor(0.1) // Animation playback
return `✅ Scrolled container (${el!.tagName}) by ${dy}px.`
}
}
export async function scrollHorizontally(
right: boolean,
scroll_amount: number,
element?: HTMLElement | null
) {
// Element-specific scrolling if element is provided
if (element) {
const targetElement = element
console.log(
'[SCROLL DEBUG] Starting direct container scroll for element:',
targetElement.tagName
)
let currentElement = targetElement as HTMLElement | null
let scrollSuccess = false
let scrolledElement: HTMLElement | null = null
let scrollDelta = 0
let attempts = 0
const dx = right ? scroll_amount : -scroll_amount
while (currentElement && attempts < 10) {
const computedStyle = window.getComputedStyle(currentElement)
const hasScrollableX = /(auto|scroll|overlay)/.test(computedStyle.overflowX)
const canScrollHorizontally = currentElement.scrollWidth > currentElement.clientWidth
console.log(
'[SCROLL DEBUG] Checking element:',
currentElement.tagName,
'hasScrollableX:',
hasScrollableX,
'canScrollHorizontally:',
canScrollHorizontally,
'scrollWidth:',
currentElement.scrollWidth,
'clientWidth:',
currentElement.clientWidth
)
if (hasScrollableX && canScrollHorizontally) {
const beforeScroll = currentElement.scrollLeft
const maxScroll = currentElement.scrollWidth - currentElement.clientWidth
let scrollAmount = dx / 3
if (scrollAmount > 0) {
scrollAmount = Math.min(scrollAmount, maxScroll - beforeScroll)
} else {
scrollAmount = Math.max(scrollAmount, -beforeScroll)
}
currentElement.scrollLeft = beforeScroll + scrollAmount
const afterScroll = currentElement.scrollLeft
const actualScrollDelta = afterScroll - beforeScroll
console.log(
'[SCROLL DEBUG] Scroll attempt:',
currentElement.tagName,
'before:',
beforeScroll,
'after:',
afterScroll,
'delta:',
actualScrollDelta
)
if (Math.abs(actualScrollDelta) > 0.5) {
scrollSuccess = true
scrolledElement = currentElement
scrollDelta = actualScrollDelta
console.log(
'[SCROLL DEBUG] Successfully scrolled container:',
currentElement.tagName,
'delta:',
actualScrollDelta
)
break
}
}
if (currentElement === document.body || currentElement === document.documentElement) {
break
}
currentElement = currentElement.parentElement
attempts++
}
if (scrollSuccess) {
return `Scrolled container (${scrolledElement?.tagName}) horizontally by ${scrollDelta}px`
} else {
return `No horizontally scrollable container found for element (${targetElement.tagName})`
}
}
// Page-level scrolling (default or fallback)
const dx = right ? scroll_amount : -scroll_amount
const bigEnough = (el: HTMLElement) => el.clientWidth >= window.innerWidth * 0.5
const canScroll = (el: HTMLElement | null) =>
el &&
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowX) &&
el.scrollWidth > el.clientWidth &&
bigEnough(el)
let el: HTMLElement | null = document.activeElement as HTMLElement | null
while (el && !canScroll(el) && el !== document.body) el = el.parentElement
el = canScroll(el)
? el
: Array.from(document.querySelectorAll<HTMLElement>('*')).find(canScroll) ||
(document.scrollingElement as HTMLElement) ||
(document.documentElement as HTMLElement)
if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
window.scrollBy(dx, 0)
return `✅ Scrolled page horizontally by ${dx}px`
} else {
el!.scrollBy({ left: dx, behavior: 'smooth' })
await waitFor(0.1) // Animation playback
return `✅ Scrolled container (${el!.tagName}) horizontally by ${dx}px`
}
}

208
src/tools/index.ts Normal file
View File

@@ -0,0 +1,208 @@
/**
* Internal tools for PageAgent.
* @note Adapted from browser-use
*/
import { Tool, tool } from 'ai'
import zod from 'zod'
import type { PageAgent } from '@/PageAgent'
import {
clickElement,
getElementByIndex,
getSystemInfo,
inputTextElement,
scrollHorizontally,
scrollVertically,
selectOptionElement,
waitFor,
} from './actions'
// debug
import * as utils from './actions'
// @ts-expect-error debug only
window.utils = utils
/**
* Internal tools for PageAgent.
*/
export const tools = new Map<string, Tool>()
// tools.set(
// 'get_current_html',
// tool({
// description: 'Get the current (updated) simplified HTML of the page',
// inputSchema: zod.object({}),
// execute: function (this: PageAgent) {
// this.updateTree()
// return this.simplifiedHTML
// },
// })
// )
tools.set(
'done',
tool({
description:
'Complete task - provide a summary of results for the user. Set success=True if task completed successfully, false otherwise. Text should be your response to the user summarizing results.',
inputSchema: zod.object({
text: zod.string(),
success: zod.boolean().default(true),
}),
execute: function (this: PageAgent, input) {
// @note main loop will handle this one
// this.onDone(input.text, input.success)
},
})
)
tools.set(
'wait',
tool({
description:
'Wait for x seconds. default 1s (max 10 seconds, min 1 second). This can be used to wait until the page or data is fully loaded.',
inputSchema: zod.object({
seconds: zod.number().min(1).max(10).default(1),
}),
execute: async function (this: PageAgent, input) {
const lastTimeUpdate = this.lastTimeUpdate
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000)
console.log(`actualWaitTime: ${actualWaitTime} seconds`)
await waitFor(actualWaitTime)
return `✅ Waited for ${input.seconds} seconds.` + (await getSystemInfo())
},
})
)
tools.set(
'ask_user',
tool({
description:
'Ask the user 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 this.panel.askUser(input.question)
return `✅ Received user answer: ${answer}` + (await getSystemInfo())
},
})
)
tools.set(
'click_element_by_index',
tool({
description: 'Click element by index',
inputSchema: zod.object({
index: zod.int().min(0),
}),
execute: async function (this: PageAgent, input) {
const element = getElementByIndex(this, input.index)
const elemText = this.elementTextMap.get(input.index)
await clickElement(element)
// @workaround: Handle links that open in new tabs
if (element instanceof HTMLAnchorElement && element.target === '_blank') {
return `⚠️ Clicked link that opens in a new tab (${elemText ? elemText : input.index}). You are not capable of reading new tabs.`
}
return `✅ Clicked element (${elemText ? elemText : input.index}).` + (await getSystemInfo())
},
})
)
tools.set(
'input_text',
tool({
description: 'Click and input text into a input interactive element',
inputSchema: zod.object({
index: zod.int().min(0),
text: zod.string(),
}),
execute: async function (this: PageAgent, input) {
const element = getElementByIndex(this, input.index)
const elemText = this.elementTextMap.get(input.index)
await inputTextElement(element, input.text)
return (
`✅ Input text (${input.text}) into element (${elemText ? elemText : input.index}).` +
(await getSystemInfo())
)
},
})
)
tools.set(
'select_dropdown_option',
tool({
description:
'Select dropdown option for interactive element index by the text of the option you want to select',
inputSchema: zod.object({
index: zod.int().min(0),
text: zod.string(),
}),
execute: async function (this: PageAgent, input) {
const element = getElementByIndex(this, input.index)
const elemText = this.elementTextMap.get(input.index)
await selectOptionElement(element as any, input.text)
return (
`✅ Selected option (${input.text}) in element (${elemText ? elemText : input.index}).` +
(await getSystemInfo())
)
},
})
)
/**
* @note Reference from browser-use
*/
tools.set(
'scroll',
tool({
description:
'Scroll the page by specified number of pages (set down=True to scroll down, down=False to scroll up, num_pages=number of pages to scroll like 0.5 for half page, 1.0 for one page, etc.). Optional index parameter to scroll within a specific element or its scroll container (works well for dropdowns and custom UI components). Optional pixels parameter to scroll by a specific number of pixels instead of pages.',
inputSchema: zod.object({
down: zod.boolean().default(true),
num_pages: zod.number().min(0).max(10).optional().default(0.1),
pixels: zod.number().int().min(0).optional(),
index: zod.number().int().min(0).optional(),
}),
execute: async function (this: PageAgent, input) {
const { down, num_pages, index, pixels } = input
const scroll_amount = pixels ? pixels : num_pages * (down ? 1 : -1) * window.innerHeight
const element = index !== undefined ? getElementByIndex(this, index) : null
return (await scrollVertically(down, scroll_amount, element)) + (await getSystemInfo())
},
})
)
tools.set(
'scroll_horizontally',
tool({
description:
'Scroll the page or element horizontally (set right=True to scroll right, right=False to scroll left, pixels=number of pixels to scroll). Optional index parameter to scroll within a specific element or its scroll container (works well for wide tables).',
inputSchema: zod.object({
right: zod.boolean().default(true),
pixels: zod.number().int().min(0),
index: zod.number().int().min(0).optional(),
}),
execute: async function (this: PageAgent, input) {
const { right, pixels, index } = input
const scroll_amount = pixels * (right ? 1 : -1)
const element = index !== undefined ? getElementByIndex(this, index) : null
return (await scrollHorizontally(right, scroll_amount, element)) + (await getSystemInfo())
},
})
)
// @todo get_dropdown_options
// @todo select_dropdown_option
// @todo send_keys
// @todo upload_file
// @todo go_back
// @todo extract_structured_data

598
src/ui/Panel.module.css Normal file
View File

@@ -0,0 +1,598 @@
.wrapper {
position: fixed;
bottom: 100px;
left: 50%;
transform: translateX(-50%) translateY(20px);
opacity: 0;
z-index: 2147483642; /* 比 SimulatorMask 高一层 */
box-sizing: border-box;
overflow: visible;
* {
box-sizing: border-box;
}
--width: 360px;
--height: 40px;
--border-radius: 12px;
--side-space: 12px; /* 控制栏两侧的间距 */
--history-width: calc(var(--width) - var(--side-space) * 2);
--color-1: rgb(57, 182, 255);
--color-2: rgb(189, 69, 251);
--color-3: rgb(255, 87, 51);
--color-4: rgb(255, 214, 0);
width: var(--width);
height: var(--height);
transition: all 0.3s ease-in-out;
/* 响应式设计 */
@media (max-width: 480px) {
width: calc(100vw - 40px);
left: 20px;
transform: none;
}
.background {
position: absolute;
inset: -2px -8px;
border-radius: calc(var(--border-radius) + 4px);
filter: blur(16px);
overflow: hidden;
/* mix-blend-mode: lighten; */
/* display: none; */
&::before {
content: '';
z-index: -1;
pointer-events: none;
position: absolute;
width: 100%;
height: 100%;
/* left: -100%; */
left: 0;
top: 0;
background-image: linear-gradient(
to bottom left,
var(--color-1),
var(--color-2),
var(--color-1)
);
animation: mask-running 2s linear infinite;
}
&::after {
content: '';
z-index: -1;
pointer-events: none;
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
background-image: linear-gradient(
to bottom left,
var(--color-2),
var(--color-1),
var(--color-2)
);
animation: mask-running 2s linear infinite;
animation-delay: 1s;
}
}
}
@keyframes mask-running {
from {
transform: translateX(-100%);
}
to {
transform: translateX(100%);
}
}
/* 控制栏 */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
user-select: none;
position: absolute;
inset: 0;
cursor: pointer;
flex-shrink: 0; /* 防止 header 被压缩 */
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10px);
border-radius: var(--border-radius);
background-clip: padding-box;
box-shadow:
0 0 0px 2px rgba(255, 255, 255, 0.4),
0 0 5px 1px rgba(255, 255, 255, 0.3);
.statusSection {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-height: 24px; /* 确保垂直居中 */
.indicator {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
animation: none; /* 默认无动画 */
/* 运行状态 - 有动画 */
&.thinking {
background: rgb(57, 182, 255);
animation: pulse 0.8s ease-in-out infinite;
}
&.tool_executing {
background: rgb(189, 69, 251);
animation: pulse 0.6s ease-in-out infinite;
}
&.retry {
background: rgb(255, 214, 0);
animation: retryPulse 1s ease-in-out infinite;
}
/* 静止状态 - 无动画 */
&.completed,
&.input,
&.output {
background: rgb(34, 197, 94);
animation: none;
}
&.error {
background: rgb(239, 68, 68);
animation: none;
}
}
.statusText {
color: white;
font-size: 12px;
line-height: 1;
font-weight: 500;
transition: all 0.3s ease-in-out;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
min-height: 24px; /* 确保垂直居中 */
&.fadeOut {
animation: statusTextFadeOut 0.3s ease forwards;
}
&.fadeIn {
animation: statusTextFadeIn 0.3s ease forwards;
}
}
}
.controls {
display: flex;
align-items: center;
gap: 4px;
.controlButton {
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
}
.pauseButton {
font-weight: 600;
&.paused {
background: rgba(34, 197, 94, 0.2); /* 绿色背景表示可以继续 */
color: rgb(34, 197, 94);
&:hover {
background: rgba(34, 197, 94, 0.3);
}
}
}
.stopButton {
background: rgba(239, 68, 68, 0.2);
color: rgb(255, 41, 41);
font-weight: 600;
&:hover {
background: rgba(239, 68, 68, 0.3);
}
}
}
}
@keyframes statusTextFadeIn {
0% {
opacity: 0;
transform: translateY(5px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes statusTextFadeOut {
0% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-5px);
}
}
.historySectionWrapper {
position: absolute;
width: var(--history-width);
bottom: var(--height);
left: var(--side-space);
z-index: -2;
padding-top: 0px;
visibility: collapse;
overflow: hidden;
transition: all 0.2s;
background: rgba(2, 0, 20, 0.5);
/* background: rgba(186, 186, 186, 0.2); */
backdrop-filter: blur(10px);
text-shadow: 0 0 1px rgba(0, 0, 0, 0.2);
border-top-left-radius: calc(var(--border-radius) + 4px);
border-top-right-radius: calc(var(--border-radius) + 4px);
/* border: 2px solid rgba(255, 255, 255, 0.8); */
border: 2px solid rgba(255, 255, 255, 0.4);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
/* @media (prefers-color-scheme: dark) {
box-shadow:
0 8px 32px 0 rgba(0, 0, 0, 0.85),
0 2px 12px 0 rgba(57, 182, 255, 0.1);
} */
.expanded & {
padding-top: 8px;
visibility: visible;
}
.historySection {
position: relative;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
max-height: 0;
padding-inline: 8px;
transition: max-height 0.2s;
.expanded & {
max-height: 400px;
}
.historyItem {
/* backdrop-filter: blur(10px); */
padding: 8px 10px;
margin-bottom: 6px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03));
border-radius: 8px;
border-left: 2px solid rgba(57, 182, 255, 0.5);
font-size: 12px;
color: white;
/* color: black; */
line-height: 1.3;
position: relative;
overflow: hidden;
/* 微妙的内阴影 */
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 1px 3px rgba(0, 0, 0, 0.1);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
}
&:hover {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.06));
/* transform: translateY(-1px); */
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.15),
0 2px 4px rgba(0, 0, 0, 0.15);
}
&:last-child {
margin-bottom: 10px;
}
&.completed,
&.input,
&.output {
border-left-color: rgb(34, 197, 94);
background: linear-gradient(135deg, rgba(34, 197, 94, 0.1), rgba(34, 197, 94, 0.05));
}
&.error {
border-left-color: rgb(239, 68, 68);
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1), rgba(239, 68, 68, 0.05));
}
&.retry {
border-left-color: rgb(255, 214, 0);
background: linear-gradient(135deg, rgba(255, 214, 0, 0.1), rgba(255, 214, 0, 0.05));
}
/* 突出显示 done 成功结果 */
&.doneSuccess {
background: linear-gradient(
135deg,
rgba(34, 197, 94, 0.25),
rgba(34, 197, 94, 0.15),
rgba(34, 197, 94, 0.08)
);
border: none;
border-left: 4px solid rgb(34, 197, 94);
box-shadow:
0 4px 12px rgba(34, 197, 94, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.2),
0 0 20px rgba(34, 197, 94, 0.1);
font-weight: 600;
color: rgb(220, 252, 231);
padding: 10px 12px;
margin-bottom: 8px;
border-radius: 8px;
position: relative;
overflow: hidden;
&::before {
background: linear-gradient(90deg, transparent, rgba(34, 197, 94, 0.4), transparent);
}
&::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
animation: shimmer 2s ease-in-out infinite;
}
.historyContent {
.statusIcon {
font-size: 16px;
animation: celebrate 0.8s ease-in-out;
filter: drop-shadow(0 2px 4px rgba(34, 197, 94, 0.5));
}
}
}
/* 突出显示 done 失败结果 */
&.doneError {
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.25),
rgba(239, 68, 68, 0.15),
rgba(239, 68, 68, 0.08)
);
border: none;
border-left: 4px solid rgb(239, 68, 68);
box-shadow:
0 4px 12px rgba(239, 68, 68, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.2),
0 0 20px rgba(239, 68, 68, 0.1);
font-weight: 600;
color: rgb(254, 226, 226);
padding: 10px 12px;
margin-bottom: 8px;
border-radius: 8px;
position: relative;
overflow: hidden;
&::before {
background: linear-gradient(90deg, transparent, rgba(239, 68, 68, 0.4), transparent);
}
.historyContent {
.statusIcon {
font-size: 16px;
filter: drop-shadow(0 2px 4px rgba(239, 68, 68, 0.5));
}
}
}
.historyContent {
display: flex;
align-items: center;
gap: 8px;
word-break: break-all;
white-space: pre-wrap;
/* overflow-x: auto; */
.statusIcon {
font-size: 12px;
flex-shrink: 0;
line-height: 1;
transition: all 0.3s ease;
}
}
.historyMeta {
font-size: 10px;
color: rgba(255, 255, 255, 0.6);
/* color: rgb(61, 61, 61); */
margin-top: 8px;
line-height: 1;
}
}
}
}
/* 动画关键帧 - 更快的闪烁 */
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.4;
transform: scale(1.3);
}
}
/* 重试动画 - 旋转脉冲 */
@keyframes retryPulse {
0%,
100% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
25% {
opacity: 0.6;
transform: scale(1.2) rotate(90deg);
}
50% {
opacity: 0.8;
transform: scale(1.1) rotate(180deg);
}
75% {
opacity: 0.6;
transform: scale(1.2) rotate(270deg);
}
}
/* 庆祝动画 */
@keyframes celebrate {
0%,
100% {
transform: scale(1);
}
25% {
transform: scale(1.2) rotate(-5deg);
}
75% {
transform: scale(1.2) rotate(5deg);
}
}
/* done 卡片的光泽效果 */
@keyframes shimmer {
0% {
left: -100%;
}
100% {
left: 100%;
}
}
/* 输入区域样式 */
.inputSectionWrapper {
position: absolute;
width: var(--history-width);
top: var(--height);
left: var(--side-space);
z-index: -1;
visibility: visible;
overflow: hidden;
height: 48px;
transition: all 0.2s;
background: rgba(186, 186, 186, 0.2);
backdrop-filter: blur(10px);
border-bottom-left-radius: calc(var(--border-radius) + 4px);
border-bottom-right-radius: calc(var(--border-radius) + 4px);
border: 2px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 1px 16px rgba(0, 0, 0, 0.4);
&.hidden {
visibility: collapse;
height: 0;
}
.inputSection {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 8px;
.taskInput {
flex: 1;
background: rgba(255, 255, 255, 0.4);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 10px;
padding-inline: 10px;
color: rgb(20, 20, 20);
font-size: 12px;
height: 28px;
line-height: 1;
outline: none;
transition: all 0.2s ease;
/* text-shadow: 0 0 2px rgba(255, 255, 255, 0.8); */
/* border-color: rgba(57, 182, 255, 0.3); */
&::placeholder {
color: rgb(53, 53, 53);
}
&:focus {
background: rgba(255, 255, 255, 0.8);
border-color: rgba(57, 182, 255, 0.6);
box-shadow: 0 0 0 2px rgba(57, 182, 255, 0.2);
}
}
}
}

548
src/ui/Panel.ts Normal file
View File

@@ -0,0 +1,548 @@
import type { PageAgent } from '@/PageAgent'
import type { I18n } from '@/i18n'
import { truncate } from '@/utils'
import type { EventBus } from '@/utils/bus'
import { type Step, UIState } from './UIState'
import styles from './Panel.module.css'
/**
* Agent control panel
*/
export class Panel {
#wrapper: HTMLElement
#indicator: HTMLElement
#statusText: HTMLElement
#historySection: HTMLElement
#expandButton: HTMLElement
#pauseButton: HTMLElement
#stopButton: HTMLElement
#inputSection: HTMLElement
#taskInput: HTMLInputElement
#bus: EventBus
#state = new UIState()
#isExpanded = false
#pageAgent: PageAgent
#userAnswerResolver: ((input: string) => void) | null = null
#isWaitingForUserAnswer: boolean = false
get wrapper(): HTMLElement {
return this.#wrapper
}
constructor(pageAgent: PageAgent) {
this.#pageAgent = pageAgent
this.#bus = pageAgent.bus
this.#wrapper = this.#createWrapper()
this.#indicator = this.#wrapper.querySelector(`.${styles.indicator}`)!
this.#statusText = this.#wrapper.querySelector(`.${styles.statusText}`)!
this.#historySection = this.#wrapper.querySelector(`.${styles.historySection}`)!
this.#expandButton = this.#wrapper.querySelector(`.${styles.expandButton}`)!
this.#pauseButton = this.#wrapper.querySelector(`.${styles.pauseButton}`)!
this.#stopButton = this.#wrapper.querySelector(`.${styles.stopButton}`)!
this.#inputSection = this.#wrapper.querySelector(`.${styles.inputSectionWrapper}`)!
this.#taskInput = this.#wrapper.querySelector(`.${styles.taskInput}`)!
this.#setupEventListeners()
// this.#expand() // debug
this.#showInputArea()
this.#bus.on('panel:show', () => this.#show())
this.#bus.on('panel:hide', () => this.#hide())
this.#bus.on('panel:reset', () => this.#reset())
this.#bus.on('panel:update', (stepData) => this.#update(stepData))
this.#bus.on('panel:expand', () => this.#expand())
this.#bus.on('panel:collapse', () => this.#collapse())
}
/**
* Ask for user input
*/
async askUser(question: string): Promise<string> {
return new Promise((resolve) => {
// Set `waiting for user answer` state
this.#isWaitingForUserAnswer = true
this.#userAnswerResolver = resolve
// Update state to `running`
this.#update({
type: 'output',
displayText: `询问: ${question}`,
})
// Expand history panel
if (!this.#isExpanded) {
this.#expand()
}
this.#showInputArea(this.#pageAgent.i18n.t('ui.panel.userAnswerPrompt'))
})
}
/**
* Dispose panel
*/
dispose(): void {
this.#isWaitingForUserAnswer = false
this.wrapper.remove()
}
/**
* Update status
*/
async #update(stepData: Omit<Step, 'id' | 'stepNumber' | 'timestamp'>): Promise<void> {
const step = this.#state.addStep(stepData)
// Show animation if text changes
const headerText = truncate(step.displayText, 20)
if (this.#statusText.textContent !== headerText) {
await this.#animateTextChange(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()
}
}
/**
* Show panel
*/
#show(): void {
this.wrapper.style.display = 'block'
// Force reflow to trigger animation
void this.wrapper.offsetHeight
this.wrapper.style.opacity = '1'
this.wrapper.style.transform = 'translateX(-50%) translateY(0)'
}
/**
* 隐藏面板
*/
#hide(): void {
this.wrapper.style.opacity = '0'
this.wrapper.style.transform = 'translateX(-50%) translateY(20px)'
this.wrapper.style.display = 'none'
}
/**
* 重置状态
*/
#reset(): void {
this.#state.reset()
this.#statusText.textContent = this.#pageAgent.i18n.t('ui.panel.ready')
this.#updateStatusIndicator('thinking')
this.#updateHistory()
this.#collapse()
// Reset pause state
this.#pageAgent.paused = false
this.#updatePauseButton()
// Reset user input state
this.#isWaitingForUserAnswer = false
this.#userAnswerResolver = null
// Show input area
this.#showInputArea()
}
/**
* Toggle pause state
*/
#togglePause(): void {
this.#pageAgent.paused = !this.#pageAgent.paused
this.#updatePauseButton()
// Update status display
if (this.#pageAgent.paused) {
this.#statusText.textContent = '暂停中,稍后'
this.#updateStatusIndicator('thinking') // Use existing thinking state
} else {
this.#statusText.textContent = '继续执行'
this.#updateStatusIndicator('tool_executing') // Restore to execution state
}
}
/**
* 更新暂停按钮状态
*/
#updatePauseButton(): void {
if (this.#pageAgent.paused) {
this.#pauseButton.textContent = '▶'
this.#pauseButton.title = '继续'
this.#pauseButton.classList.add(styles.paused)
} else {
this.#pauseButton.textContent = '⏸︎'
this.#pauseButton.title = '暂停'
this.#pauseButton.classList.remove(styles.paused)
}
}
/**
* 终止 Agent
*/
#stopAgent(): void {
// Update status display
this.#update({
type: 'error',
displayText: '任务已终止',
})
this.#pageAgent.dispose()
}
/**
* 提交任务
*/
#submitTask() {
const input = this.#taskInput.value.trim()
if (!input) return
// Hide input area
this.#hideInputArea()
if (this.#isWaitingForUserAnswer) {
// Handle user input mode
this.#handleUserAnswer(input)
} else {
this.#pageAgent.execute(input)
}
}
/**
* 处理用户回答
*/
#handleUserAnswer(input: string): void {
// Add user input to history
this.#update({
type: 'input',
displayText: `用户回答: ${input}`,
})
// Reset state
this.#isWaitingForUserAnswer = false
// Call resolver to return user input
if (this.#userAnswerResolver) {
this.#userAnswerResolver(input)
this.#userAnswerResolver = null
}
}
/**
* 显示输入区域
*/
#showInputArea(placeholder?: string): void {
// Clear input field
this.#taskInput.value = ''
this.#taskInput.placeholder = placeholder || '输入新任务,详细描述步骤,回车提交'
this.#inputSection.classList.remove(styles.hidden)
// Focus on input field
setTimeout(() => {
this.#taskInput.focus()
}, 100)
}
/**
* 隐藏输入区域
*/
#hideInputArea(): void {
this.#inputSection.classList.add(styles.hidden)
}
/**
* 检查是否应该显示输入区域
*/
#shouldShowInputArea(): boolean {
// Always show input area if waiting for user input
if (this.#isWaitingForUserAnswer) return true
const steps = this.#state.getAllSteps()
if (steps.length === 0) {
return true // Initial state
}
const lastStep = steps[steps.length - 1]
return lastStep.type === 'completed' || lastStep.type === 'error'
}
#createWrapper(): HTMLElement {
const wrapper = document.createElement('div')
wrapper.id = 'page-agent-runtime_agent-panel'
wrapper.className = `${styles.wrapper} ${styles.collapsed}`
wrapper.setAttribute('data-browser-use-ignore', 'true')
wrapper.innerHTML = `
<div class="${styles.background}"></div>
<div class="${styles.historySectionWrapper}">
<div class="${styles.historySection}">
${this.#createHistoryItem({
id: 'placeholder',
stepNumber: 0,
timestamp: new Date(),
type: 'thinking',
displayText: '等待任务开始...',
})}
</div>
</div>
<div class="${styles.header}">
<div class="${styles.statusSection}">
<div class="${styles.indicator} ${styles.thinking}"></div>
<div class="${styles.statusText}">准备就绪</div>
</div>
<div class="${styles.controls}">
<button class="${styles.controlButton} ${styles.expandButton}" title="展开历史">
</button>
<button class="${styles.controlButton} ${styles.pauseButton}" title="暂停">
⏸︎
</button>
<button class="${styles.controlButton} ${styles.stopButton}" title="终止">
X
</button>
</div>
</div>
<div class="${styles.inputSectionWrapper} ${styles.hidden}">
<div class="${styles.inputSection}">
<input
type="text"
class="${styles.taskInput}"
maxlength="200"
/>
</div>
</div>
`
document.body.appendChild(wrapper)
return wrapper
}
#setupEventListeners(): void {
// Click header area to expand/collapse
const header = this.wrapper.querySelector(`.${styles.header}`)!
header.addEventListener('click', (e) => {
// Don't trigger expand/collapse if clicking on buttons
if ((e.target as HTMLElement).closest(`.${styles.controlButton}`)) {
return
}
this.#toggle()
})
// Expand button
this.#expandButton.addEventListener('click', (e) => {
e.stopPropagation()
this.#toggle()
})
// Pause/continue button
this.#pauseButton.addEventListener('click', (e) => {
e.stopPropagation()
this.#togglePause()
})
// Stop button
this.#stopButton.addEventListener('click', (e) => {
e.stopPropagation()
this.#stopAgent()
})
// Submit on Enter key in input field
this.#taskInput.addEventListener('keydown', (e) => {
if (e.isComposing) return // Ignore IME composition keys
if (e.key === 'Enter') {
e.preventDefault()
this.#submitTask()
}
})
// Prevent input area click event bubbling
this.#inputSection.addEventListener('click', (e) => {
e.stopPropagation()
})
}
#toggle(): void {
if (this.#isExpanded) {
this.#collapse()
} else {
this.#expand()
}
}
#expand(): void {
this.#isExpanded = true
this.wrapper.classList.remove(styles.collapsed)
this.wrapper.classList.add(styles.expanded)
this.#expandButton.textContent = '▲'
}
#collapse(): void {
this.#isExpanded = false
this.wrapper.classList.remove(styles.expanded)
this.wrapper.classList.add(styles.collapsed)
this.#expandButton.textContent = '▼'
}
async #animateTextChange(newText: string): Promise<void> {
return new Promise((resolve) => {
// Fade out current text
this.#statusText.classList.add(styles.fadeOut)
setTimeout(() => {
// Update text content
this.#statusText.textContent = newText
// Fade in new text
this.#statusText.classList.remove(styles.fadeOut)
this.#statusText.classList.add(styles.fadeIn)
setTimeout(() => {
this.#statusText.classList.remove(styles.fadeIn)
resolve()
}, 300)
}, 150) // Half the duration of fade out animation
})
}
#updateStatusIndicator(type: Step['type']): void {
// Clear all status classes
this.#indicator.className = styles.indicator
// Add corresponding status class
this.#indicator.classList.add(styles[type])
}
#updateHistory(): void {
const steps = this.#state.getAllSteps()
this.#historySection.innerHTML = steps
.slice(-10) // Only show last 10 items
.map((step) => this.#createHistoryItem(step))
.join('')
// Scroll to bottom to show latest records
this.#scrollToBottom()
}
#scrollToBottom(): void {
// Execute in next event loop to ensure DOM update completion
setTimeout(() => {
this.#historySection.scrollTop = this.#historySection.scrollHeight
}, 0)
}
#createHistoryItem(step: Step): string {
const time = step.timestamp.toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
let typeClass = ''
let statusIcon = ''
// Set styles and icons based on step type
if (step.type === 'completed') {
// Check if this is a result from done tool
if (step.toolName === 'done') {
// @todo not right
// Judge success or failure based on result
const isSuccess =
!step.toolResult ||
(!step.toolResult.includes('失败') && !step.toolResult.includes('错误'))
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 = '🧠'
}
return `
<div class="${styles.historyItem} ${typeClass}">
<div class="${styles.historyContent}">
<span class="${styles.statusIcon}">${statusIcon}</span>
<span>${step.displayText}</span>
</div>
<div class="${styles.historyMeta}">
步骤 ${step.stepNumber} · ${time}
${step.duration ? ` · ${step.duration}ms` : ''}
</div>
</div>
`
}
}
/**
* 获取工具执行时的显示文本
*/
export function getToolExecutingText(toolName: string, args: any, i18n: I18n): string {
switch (toolName) {
case 'click_element_by_index':
return i18n.t('ui.tools.clicking', { index: args.index })
case 'input_text':
return i18n.t('ui.tools.inputting', { index: args.index })
case 'select_dropdown_option':
return i18n.t('ui.tools.selecting', { text: args.text })
case 'scroll':
return i18n.t('ui.tools.scrolling')
case 'wait':
return i18n.t('ui.tools.waiting', { seconds: args.seconds })
case 'done':
return i18n.t('ui.tools.done')
default:
return i18n.t('ui.tools.executing', { toolName })
}
}
/**
* 获取工具完成时的显示文本
*/
export function getToolCompletedText(toolName: string, args: any, i18n: I18n): string | null {
switch (toolName) {
case 'click_element_by_index':
return i18n.t('ui.tools.clicked', { index: args.index })
case 'input_text':
return i18n.t('ui.tools.inputted', { text: args.text })
case 'select_dropdown_option':
return i18n.t('ui.tools.selected', { text: args.text })
case 'scroll':
return i18n.t('ui.tools.scrolled')
case 'wait':
return i18n.t('ui.tools.waited')
case 'done':
return null
default:
return null
}
}

View File

@@ -0,0 +1,10 @@
.wrapper {
position: fixed;
inset: 0;
z-index: 2147483641; /* 确保在所有元素之上,除了 panel */
/* pointer-events: none; */
cursor: not-allowed;
overflow: hidden;
display: none;
}

172
src/ui/SimulatorMask.ts Normal file
View File

@@ -0,0 +1,172 @@
import { Motion } from 'ai-motion'
import { isPageDark } from '@/utils/checkDarkMode'
import styles from './SimulatorMask.module.css'
import cursorStyles from './cursor.module.css'
export class SimulatorMask {
wrapper = document.createElement('div')
motion = new Motion({
mode: isPageDark() ? 'dark' : 'light',
styles: {
position: 'absolute',
inset: '0',
},
})
#cursor = document.createElement('div')
#currentCursorX = 0
#currentCursorY = 0
#targetCursorX = 0
#targetCursorY = 0
constructor() {
this.wrapper.id = 'page-agent-runtime_simulator-mask'
this.wrapper.className = styles.wrapper
this.wrapper.setAttribute('data-browser-use-ignore', 'true')
this.wrapper.appendChild(this.motion.element)
this.motion.autoResize(this.wrapper)
// Capture all mouse, keyboard, and wheel events
this.wrapper.addEventListener('click', (e) => {
e.stopPropagation()
e.preventDefault()
})
this.wrapper.addEventListener('mousedown', (e) => {
e.stopPropagation()
e.preventDefault()
})
this.wrapper.addEventListener('mouseup', (e) => {
e.stopPropagation()
e.preventDefault()
})
this.wrapper.addEventListener('mousemove', (e) => {
e.stopPropagation()
e.preventDefault()
})
this.wrapper.addEventListener('wheel', (e) => {
e.stopPropagation()
e.preventDefault()
})
this.wrapper.addEventListener('keydown', (e) => {
e.stopPropagation()
e.preventDefault()
})
this.wrapper.addEventListener('keyup', (e) => {
e.stopPropagation()
e.preventDefault()
})
// Create AI cursor
this.#createCursor()
// this.show()
document.body.appendChild(this.wrapper)
this.#moveCursorToTarget()
window.addEventListener('PageAgent::MovePointerTo', (event: Event) => {
const { x, y } = (event as CustomEvent).detail
this.setCursorPosition(x, y)
})
window.addEventListener('PageAgent::ClickPointer', (event: Event) => {
this.triggerClickAnimation()
})
}
#createCursor() {
this.#cursor.className = cursorStyles.cursor
// Create ripple effect container
const rippleContainer = document.createElement('div')
rippleContainer.className = cursorStyles.cursorRipple
this.#cursor.appendChild(rippleContainer)
// Create filling layer
const fillingLayer = document.createElement('div')
fillingLayer.className = cursorStyles.cursorFilling
this.#cursor.appendChild(fillingLayer)
// Create border layer
const borderLayer = document.createElement('div')
borderLayer.className = cursorStyles.cursorBorder
this.#cursor.appendChild(borderLayer)
this.wrapper.appendChild(this.#cursor)
}
#moveCursorToTarget() {
const newX = this.#currentCursorX + (this.#targetCursorX - this.#currentCursorX) * 0.2
const newY = this.#currentCursorY + (this.#targetCursorY - this.#currentCursorY) * 0.2
const xDistance = Math.abs(newX - this.#targetCursorX)
if (xDistance > 0) {
if (xDistance < 2) {
this.#currentCursorX = this.#targetCursorX
} else {
this.#currentCursorX = newX
}
this.#cursor.style.left = `${this.#currentCursorX}px`
}
const yDistance = Math.abs(newY - this.#targetCursorY)
if (yDistance > 0) {
if (yDistance < 2) {
this.#currentCursorY = this.#targetCursorY
} else {
this.#currentCursorY = newY
}
this.#cursor.style.top = `${this.#currentCursorY}px`
}
requestAnimationFrame(() => this.#moveCursorToTarget())
}
setCursorPosition(x: number, y: number) {
this.#targetCursorX = x
this.#targetCursorY = y
}
triggerClickAnimation() {
this.#cursor.classList.remove(cursorStyles.clicking)
// Force reflow to restart animation
void this.#cursor.offsetHeight
this.#cursor.classList.add(cursorStyles.clicking)
}
show() {
this.motion.start()
this.motion.fadeIn()
this.wrapper.style.display = 'block'
// Initialize cursor position
this.#currentCursorX = window.innerWidth / 2
this.#currentCursorY = window.innerHeight / 2
this.#targetCursorX = this.#currentCursorX
this.#targetCursorY = this.#currentCursorY
this.#cursor.style.left = `${this.#currentCursorX}px`
this.#cursor.style.top = `${this.#currentCursorY}px`
}
hide() {
this.motion.fadeOut()
this.motion.pause()
this.#cursor.classList.remove(cursorStyles.clicking)
setTimeout(() => {
this.wrapper.style.display = 'none'
}, 800) // Match the animation duration
}
dispose() {
this.motion.dispose()
this.wrapper.remove()
}
}

93
src/ui/UIState.ts Normal file
View File

@@ -0,0 +1,93 @@
/**
* 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)}`
}
}

91
src/ui/cursor.module.css Normal file
View File

@@ -0,0 +1,91 @@
/* AI 光标样式 */
.cursor {
position: absolute;
width: var(--cursor-size, 75px);
height: var(--cursor-size, 75px);
pointer-events: none;
z-index: 10000;
transform: translate(-30%, -30%);
animation: cursor-enter 300ms ease-out forwards;
}
.cursorBorder {
position: absolute;
inset: 0;
background: linear-gradient(45deg, rgb(57, 182, 255), rgb(189, 69, 251));
mask-image: url(https://img.alicdn.com/imgextra/i1/O1CN01YHLVYR1LvqWIyo5kH_!!6000000001362-2-tps-202-202.png);
mask-size: 100% 100%;
mask-repeat: no-repeat;
animation: cursor-breathe 2s ease-in-out infinite;
}
.cursorFilling {
position: absolute;
inset: 0;
background: url(https://img.alicdn.com/imgextra/i3/O1CN01JZOqOS1Tu1sIKbPLW_!!6000000002441-2-tps-202-202.png);
background-size: 100% 100%;
background-repeat: no-repeat;
}
.cursorRipple {
position: absolute;
inset: 0;
pointer-events: none;
}
.cursor.clicking .cursorRipple::after {
content: '';
position: absolute;
width: 100%;
height: 100%;
left: -30%;
top: -30%;
border: 4px solid rgba(57, 182, 255, 1);
border-radius: 50%;
animation: cursor-ripple 300ms ease-out forwards;
}
/* 光标动画关键帧 */
@keyframes cursor-breathe {
0%,
100% {
transform: scale(1);
opacity: 0.9;
}
50% {
transform: scale(1.05);
opacity: 1;
}
}
@keyframes cursor-rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes cursor-enter {
0% {
transform: translate(-30%, -30%) scale(0.5);
opacity: 0;
}
100% {
transform: translate(-30%, -30%) scale(1);
opacity: 1;
}
}
@keyframes cursor-ripple {
0% {
transform: scale(0);
opacity: 1;
}
100% {
transform: scale(2);
opacity: 0;
}
}

View File

@@ -0,0 +1,64 @@
import styles from './motion.module.css'
export function createMotion() {
const wrapper = document.createElement('div')
wrapper.className = styles.wrapper
{
const colorWrapper = document.createElement('div')
colorWrapper.className = styles.colorWrapper
wrapper.appendChild(colorWrapper)
const layerA = document.createElement('div')
layerA.className = styles.colorLayer + ' ' + styles.layerA
colorWrapper.appendChild(layerA)
const layerB = document.createElement('div')
layerB.className = styles.colorLayer + ' ' + styles.layerB
colorWrapper.appendChild(layerB)
const layerC = document.createElement('div')
layerC.className = styles.colorLayer + ' ' + styles.layerC
colorWrapper.appendChild(layerC)
}
{
const borderWrapper = document.createElement('div')
borderWrapper.className = styles.borderWrapper
wrapper.appendChild(borderWrapper)
const layerA = document.createElement('div')
layerA.className = styles.borderLayer + ' ' + styles.layerA
borderWrapper.appendChild(layerA)
const layerB = document.createElement('div')
layerB.className = styles.borderLayer + ' ' + styles.layerB
borderWrapper.appendChild(layerB)
const layerC = document.createElement('div')
layerC.className = styles.borderLayer + ' ' + styles.layerC
borderWrapper.appendChild(layerC)
}
function show() {
wrapper.classList.remove(styles.exit)
wrapper.classList.remove(styles.entry)
// Force reflow to restart animation
void wrapper.offsetHeight
wrapper.classList.add(styles.entry)
}
function hide() {
wrapper.classList.remove(styles.entry)
wrapper.classList.remove(styles.exit)
// Force reflow to restart animation
void wrapper.offsetHeight
wrapper.classList.add(styles.exit)
}
return {
element: wrapper,
show,
hide,
}
}

View File

@@ -0,0 +1,397 @@
.wrapper {
position: absolute;
inset: 0;
pointer-events: none;
transform-origin: center;
--color-1: rgb(57, 182, 255);
--color-2: rgb(189, 69, 251);
--color-3: rgb(255, 87, 51);
--color-4: rgb(255, 214, 0);
--blend-mode: screen;
}
.colorLayer {
position: absolute;
inset: 0;
/* 变亮混合模式 */
/* mix-blend-mode: screen; */
/* mix-blend-mode: overlay; */
/* mix-blend-mode: multiply; */
mix-blend-mode: add;
/* 边框遮罩 - 中间透明,边缘不透明 */
mask-image: url(https://img.alicdn.com/imgextra/i2/O1CN01iW1wfX1C0ICvoPbTq_!!6000000000018-2-tps-512-512.png);
mask-repeat: no-repeat;
mask-size: calc(100% + 10px) calc(100% + 10px);
}
.borderWrapper {
position: absolute;
inset: 0;
/* filter: blur(10px); */
}
.borderLayer {
position: absolute;
inset: 0;
/* 变亮混合模式 */
/* mix-blend-mode: overlay; */
mix-blend-mode: add;
mask-image:
linear-gradient(
to right,
black 0px,
black 2px,
transparent 2px,
transparent calc(100% - 2px),
black calc(100% - 2px),
black 100%
),
linear-gradient(
to top,
black 0px,
black 2px,
transparent 2px,
transparent calc(100% - 2px),
black calc(100% - 2px),
black 100%
);
mask-composite: add;
mask-repeat: no-repeat;
mask-size: 100% 100%;
/* filter: blur(100px); */
}
.blueLayer {
&.colorLayer {
mask-position: left -5px top -5px;
}
&::after {
content: '';
position: absolute;
/* inset: 0; */
width: calc(max(100vw, 100vh) * 1.5);
height: 600px;
top: calc(50% - 300px);
left: 50%;
filter: blur(100px);
background: rgb(57, 182, 255);
animation: rotate-clockwise 4s linear infinite;
animation-delay: -3s;
}
}
.purpleLayer {
&.colorLayer {
mask-position: left -3px top -7px;
}
&::after {
content: '';
position: absolute;
/* inset: 0; */
width: calc(max(100vw, 100vh) * 1.5);
height: 600px;
top: calc(50% - 300px);
left: 50%;
filter: blur(100px);
background: rgb(189, 69, 251);
animation: rotate-clockwise 4s linear infinite;
animation-delay: -2s;
}
}
.orangeLayer {
/* opacity: 0.5; */
&.colorLayer {
mask-position: left -7px top -2px;
}
&::after {
content: '';
position: absolute;
/* inset: 0; */
width: calc(max(100vw, 100vh) * 1.5);
height: 600px;
top: calc(50% - 300px);
left: 50%;
filter: blur(100px);
background: rgb(255, 87, 51);
animation: rotate-counter-clockwise 3s linear infinite;
animation-delay: -2s;
}
}
.yellowLayer {
/* opacity: 0.5; */
&.colorLayer {
mask-position: left -6px top -4px;
}
&::after {
content: '';
position: absolute;
/* inset: 0; */
width: calc(max(100vw, 100vh) * 1.5);
height: 600px;
top: calc(50% - 300px);
left: 50%;
filter: blur(100px);
background: rgb(255, 214, 0);
animation: rotate-counter-clockwise 4s linear infinite;
animation-delay: -1s;
}
}
/* 旋转动画 */
@keyframes rotate-clockwise {
0% {
transform: translateX(-50%) rotate(0deg);
}
100% {
transform: translateX(-50%) rotate(360deg);
}
}
@keyframes rotate-counter-clockwise {
0% {
transform: translateX(-50%) rotate(0deg);
}
100% {
transform: translateX(-50%) rotate(-360deg);
}
}
@keyframes wrapper-entry {
from {
transform: scale(1.1);
}
to {
transform: scale(1);
}
}
/*
rgb(57, 182, 255)
rgb(189, 69, 251)
rgb(255, 87, 51)
rgb(255, 214, 0)
*/
@keyframes mask-running {
from {
transform: translateX(0%);
}
to {
transform: translateX(100%);
}
}
@keyframes mask-running-reverse {
from {
transform: translateX(100%);
}
to {
transform: translateX(0%);
}
}
.colorWrapper {
position: absolute;
inset: 0;
.colorLayer {
position: absolute;
inset: 0;
mix-blend-mode: var(--blend-mode);
/* 边框遮罩 - 中间透明,边缘不透明 */
mask-image: url(https://img.alicdn.com/imgextra/i2/O1CN01iW1wfX1C0ICvoPbTq_!!6000000000018-2-tps-512-512.png);
mask-repeat: no-repeat;
mask-size: 100% 100%;
}
}
.borderWrapper {
position: absolute;
inset: 0;
--blend-mode: lighten;
.borderLayer {
position: absolute;
inset: 0;
mix-blend-mode: var(--blend-mode);
mask-border: url(https://img.alicdn.com/imgextra/i3/O1CN01bFjRug1yssyWEUbKL_!!6000000006635-2-tps-256-256.png)
25;
-webkit-mask-box-image: url(https://img.alicdn.com/imgextra/i3/O1CN01bFjRug1yssyWEUbKL_!!6000000006635-2-tps-256-256.png)
25;
mask-repeat: no-repeat;
mask-size: 100% 100%;
background-color: var(--color-2);
}
}
.entry .colorWrapper,
.entry .borderWrapper {
animation: wrapper-entry 0.8s ease-in-out forwards;
}
.exit .colorWrapper,
.exit .borderWrapper {
animation: wrapper-entry 0.8s ease-in-out reverse forwards;
}
.layerA {
position: absolute;
inset: 0;
&::before {
mix-blend-mode: var(--blend-mode);
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
left: -100%;
top: 0;
background-image: linear-gradient(
to right bottom,
transparent,
var(--color-1),
transparent,
var(--color-1),
transparent
);
animation: mask-running 2s linear infinite;
}
&::after {
mix-blend-mode: var(--blend-mode);
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
background-image: linear-gradient(
to right bottom,
transparent,
var(--color-1),
transparent,
var(--color-1),
transparent
);
animation: mask-running 2s linear infinite;
}
}
.layerB {
position: absolute;
inset: 0;
&::before {
mix-blend-mode: var(--blend-mode);
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
left: -100%;
top: 0;
background: linear-gradient(
to right top,
transparent,
var(--color-2),
transparent,
var(--color-2),
transparent
);
animation: mask-running-reverse 3s linear infinite;
}
&::after {
mix-blend-mode: var(--blend-mode);
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
background: linear-gradient(
to right top,
transparent,
var(--color-2),
transparent,
var(--color-2),
transparent
);
animation: mask-running-reverse 3s linear infinite;
}
}
.layerC {
position: absolute;
inset: 0;
opacity: 0.5;
&::before {
mix-blend-mode: var(--blend-mode);
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
left: -100%;
top: 0;
background: linear-gradient(
to right top,
transparent,
var(--color-3),
transparent,
var(--color-3),
transparent
);
animation: mask-running 1s linear infinite;
}
&::after {
mix-blend-mode: var(--blend-mode);
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
background: linear-gradient(
to right top,
transparent,
var(--color-3),
transparent,
var(--color-3),
transparent
);
animation: mask-running 1s linear infinite;
}
}

5
src/ui/motion-css/readme Normal file
View File

@@ -0,0 +1,5 @@
This is the CSS implementation of ai-motion.
Easy to use but Terrible performance. Causing full screen glitching in some browsers.
Use it only in a small area.

17
src/utils/assert.ts Normal file
View File

@@ -0,0 +1,17 @@
import chalk from 'chalk'
/**
* Simple assertion function that throws an error if the condition is falsy
* @param condition - The condition to assert
* @param message - Optional error message
* @throws Error if condition is falsy
*/
export function assert(condition: unknown, message?: string, silent?: boolean): asserts condition {
if (!condition) {
const errorMessage = message ?? 'Assertion failed'
if (!silent) console.error(chalk.red(`❌ assert: ${errorMessage}`))
throw new Error(errorMessage)
}
}

128
src/utils/bus.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* Type-safe event bus for decoupling PageAgent and Panel
*/
import type { Step } from '@/ui/UIState'
/**
* Event mapping definitions
* @note Event bus callbacks must be repeatable without errors
*/
export interface PageAgentEventMap {
// Panel control events
// call panel.show()
'panel:show': { params: undefined }
// call panel.hide()
'panel:hide': { params: undefined }
// call panel.reset()
'panel:reset': { params: undefined }
// call panel.update()
'panel:update': { params: Omit<Step, 'id' | 'stepNumber' | 'timestamp'> }
// call panel.expand()
'panel:expand': { params: undefined }
// call panel.collapse()
'panel:collapse': { params: undefined }
// PageAgent status events
// 'agent:beforeUpdate': { params: undefined }
// 'agent:afterUpdate': { params: undefined }
// 'agent:execute': { params: { task: string } }
// 'agent:done': { params: { text: string; success: boolean } }
// 'agent:paused': { params: undefined }
// 'agent:resumed': { params: undefined }
// 'agent:disposed': { params: undefined }
// 'agent:error': { params: { error: string | Error } }
// Task status change events
// 'task:start': { params: { task: string } }
// 'task:step': { params: Omit<AgentStep, 'id' | 'stepNumber' | 'timestamp'> }
// '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 & keyof PageAgentEventMap>
): 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 & keyof PageAgentEventMap>
): 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 }

110
src/utils/checkDarkMode.ts Normal file
View File

@@ -0,0 +1,110 @@
/**
* Checks for common dark mode CSS classes on the html or body elements.
* @returns {boolean} - True if a common dark mode class is found.
*/
function hasDarkModeClass() {
const DFEAULT_DARK_MODE_CLASSES = ['dark', 'dark-mode', 'theme-dark', 'night', 'night-mode']
const htmlElement = document.documentElement
const bodyElement = document.body
// Check class names on <html> and <body>
for (const className of DFEAULT_DARK_MODE_CLASSES) {
if (htmlElement.classList.contains(className) || bodyElement.classList.contains(className)) {
return true
}
}
// Some sites use data attributes
const darkThemeAttribute = htmlElement.getAttribute('data-theme')
if (darkThemeAttribute?.toLowerCase().includes('dark')) {
return true
}
return false
}
/**
* Parses an RGB or RGBA color string and returns an object with r, g, b properties.
* @param {string} colorString - e.g., "rgb(34, 34, 34)" or "rgba(0, 0, 0, 0.5)"
* @returns {{r: number, g: number, b: number}|null}
*/
function parseRgbColor(colorString: string) {
const rgbMatch = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(colorString)
if (!rgbMatch) {
return null // Not a valid rgb/rgba string
}
return {
r: parseInt(rgbMatch[1]),
g: parseInt(rgbMatch[2]),
b: parseInt(rgbMatch[3]),
}
}
/**
* Determines if a color is "dark" based on its calculated luminance.
* @param {string} colorString - The CSS color string (e.g., "rgb(50, 50, 50)").
* @param {number} threshold - A value between 0 and 255. Colors with luminance below this will be considered dark. Default is 128.
* @returns {boolean} - True if the color is considered dark.
*/
function isColorDark(colorString: string, threshold = 128) {
if (!colorString || colorString === 'transparent' || colorString.startsWith('rgba(0, 0, 0, 0)')) {
return false // Transparent is not dark
}
const rgb = parseRgbColor(colorString)
if (!rgb) {
return false // Could not parse color
}
// Calculate perceived luminance using the standard formula
const luminance = 0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b
return luminance < threshold
}
/**
* Checks the background color of the body element to determine if the page is dark.
* @returns {boolean}
*/
function isBackgroundDark() {
// We check both <html> and <body> because some pages set the color on <html>
const htmlStyle = window.getComputedStyle(document.documentElement)
const bodyStyle = window.getComputedStyle(document.body)
// Get background colors
const htmlBgColor = htmlStyle.backgroundColor
const bodyBgColor = bodyStyle.backgroundColor
// The body's background might be transparent, in which case we should
// fall back to the html element's background.
if (isColorDark(bodyBgColor)) {
return true
} else if (bodyBgColor === 'transparent' || bodyBgColor.startsWith('rgba(0, 0, 0, 0)')) {
return isColorDark(htmlBgColor)
}
return false
}
/**
* A comprehensive function to determine if the page is currently in a dark theme.
* It combines class checking and background color analysis.
* @returns {boolean} - True if the page is likely dark.
*/
export function isPageDark() {
// Strategy 1: Check for common dark mode classes
if (hasDarkModeClass()) {
return true
}
// Strategy 2: Analyze the computed background color
if (isBackgroundDark()) {
return true
}
// @TODO add more checks here, e.g., analyzing text color,
// or checking the background of major layout elements like <main> or #app.
return false
}

37
src/utils/errors.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* # Error Handling
*
* @kind Abort Error
*
* 无需处理log 即可
*
* @kind Tool Execution Error
*
* Tool 执行过程中抛出的错误。参数是合法的,但是不一定合理,也可能其他页面环境变化导致的错误。
* 重试没有意义,需要上屏并返回给模型,让模型在下一次 tool call 中处理。
*
* @kind Tool Input Error
*
* 在非 openAI 模型中会非常常见,需要上屏并重试。
* 捕获时机:
* - InvalidToolInputError 和 NoSuchToolError 会被 ai-sdk 自动修复
* - 没有说是否计入重试次数
* - 可以定制修复方案
* - @see https://ai-sdk.dev/docs/ai-sdk-core/generating-structured-data#repairing-invalid-or-malformed-json
* - JSONParseError 需要在调用 generateText 时捕获
*
* 重试 3 种思路:
* 1.重新调用,并强调要符合 schema
* 2.加入历史,告诉模型出现的错误,让模型自己在下一次调用中解决
* 3.定义一个专门的 schema 修复模型,将 schema 和错误的数据发给模型,要求返回正确的 schema
*
* 如果重试后继续错误,则以失败结束任务
*
* @kind LLM API Error
*
* 即便一个服务声称自己兼容 openai 的接口 api但是出错的返回格式往往是自定义的
* 因此很难通过返回体来判断真正的错误类型。也很难有完善的错误处理机制。
* 能做的就只有捕获错误并上屏。
* 如果 ai-sdk 识别出来了错误,会自行重试。
* 如果没有,则只能以失败结束任务
*/

80
src/utils/index.ts Normal file
View File

@@ -0,0 +1,80 @@
/**
* Wait until condition becomes true
* @returns Returns when condition becomes true, throws otherwise
* @param timeout Timeout in milliseconds, default 0 means no timeout, throws error on timeout
*/
export async function waitUntil(check: () => boolean, timeout = 60 * 60_1000): Promise<boolean> {
if (check()) return true
return new Promise((resolve, reject) => {
const start = Date.now()
const interval = setInterval(() => {
if (check()) {
clearInterval(interval)
resolve(true)
} else if (Date.now() - start > timeout) {
clearInterval(interval)
reject(new Error('Timeout waiting for condition to become true'))
}
}, 100)
})
}
//
export function truncate(text: string, maxLength: number): string {
if (text.length > maxLength) {
return text.substring(0, maxLength) + '...'
}
return text
}
//
export function trimLines(text: string): string {
return text
.split('\n')
.map((line) => line.trim())
.join('\n')
}
//
export function randomID(existingIDs?: string[]): string {
let id = Math.random().toString(36).substring(2, 11)
if (!existingIDs) {
return id
}
const MAX_TRY = 1000
let tryCount = 0
while (existingIDs.includes(id)) {
id = Math.random().toString(36).substring(2, 11)
tryCount++
if (tryCount > MAX_TRY) {
throw new Error('randomID: too many try')
}
}
return id
}
//
if (!window.__PAGE_AGENT_IDS__) {
window.__PAGE_AGENT_IDS__ = []
}
const ids = window.__PAGE_AGENT_IDS__
/**
* Generate a random ID.
* @note Unique within this window.
*/
export function uid() {
const id = randomID(ids)
ids.push(id)
return id
}

34
tsconfig.app.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable", "ES2024"],
"module": "ESNext",
"skipLibCheck": true,
"allowJs": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": false,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": "./",
"paths": {
"@/*": ["src/*"],
"@pages/*": ["pages/*"]
}
},
"include": ["src", "pages", "env.d.ts"]
}

8
tsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.lib.json" }
]
}

16
tsconfig.lib.json Normal file
View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.lib.tsbuildinfo",
"composite": true,
"noEmit": false,
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "./dist/types",
"rootDir": "./src",
"stripInternal": true
},
"include": ["src", "env.d.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}

25
tsconfig.node.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts", "vite.lib.config.ts"]
}

27
vite.config.ts Normal file
View File

@@ -0,0 +1,27 @@
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react-swc'
import 'dotenv/config'
import process from 'node:process'
import { dirname, resolve } from 'path'
import { fileURLToPath } from 'url'
import { defineConfig } from 'vite'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// https://vite.dev/config/
export default defineConfig({
base: './',
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'@pages': resolve(__dirname, 'pages'),
},
},
define: {
'import.meta.env.OPEN_ROUTER_MODEL': JSON.stringify(process.env.OPEN_ROUTER_MODEL),
'import.meta.env.OPEN_ROUTER_KEY': JSON.stringify(process.env.OPEN_ROUTER_KEY),
'import.meta.env.OPEN_ROUTER_BASE_URL': JSON.stringify(process.env.OPEN_ROUTER_BASE_URL),
},
})

48
vite.lib.config.ts Normal file
View File

@@ -0,0 +1,48 @@
import react from '@vitejs/plugin-react-swc'
import { dirname, resolve } from 'path'
import { fileURLToPath } from 'url'
import { defineConfig } from 'vite'
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// Library build configuration
export default defineConfig({
plugins: [react(), cssInjectedByJsPlugin({ relativeCSSInjection: true })],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
publicDir: false,
esbuild: {
// 禁用严格的未使用变量检查
keepNames: true,
},
build: {
lib: {
entry: resolve(__dirname, 'src/entry.ts'),
name: 'PageAgent',
fileName: 'page-agent',
formats: ['umd', 'es'],
},
outDir: resolve(__dirname, 'dist', 'lib'),
rollupOptions: {
// ESM 包 应该 external但是 UMD 时不能 external
// @TODO 分成两份配置
// external: ['@ai-sdk/openai', 'ai', 'ai-motion', 'chalk', 'zod'],
output: {
globals: {
// 定义全局变量映射
},
},
},
// minify: 'terser',
// sourcemap: true,
},
define: {
// 替换环境变量
'process.env.NODE_ENV': '"production"',
},
})