Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fc88c0080 | |||
| 02f97ef2fa | |||
| 0853ffbbc6 | |||
| 60ac6da09f | |||
| 11a9fb1181 | |||
| 3762d0f1e0 | |||
| ebbe92f7ca | |||
| 9f239b2de3 | |||
| 2410c27b55 | |||
| 6d6c82648c | |||
| f41119c1ad | |||
| 9e6e1b996c | |||
| 1ab23e181f | |||
| a1b485691f | |||
| 694e05b800 | |||
| b761795c1a | |||
| 8cc54983aa | |||
| 59a599285e | |||
| 2f8c2b395c | |||
| 28f836674c | |||
| 6f01632231 | |||
| 41123ac561 | |||
| c8bf0dd20f | |||
| ec2b744bab | |||
| eab9cf64e8 | |||
| 42130d6f1d | |||
| c0ce6e7d14 | |||
| 8f5f98f889 | |||
| ee4719cdcf | |||
| b4c82b7833 | |||
| aefc3cfb89 | |||
| 7f2b8c2766 | |||
| 62e24a3050 | |||
| 4f6249a252 | |||
| 62519416bb | |||
| a1e70e663d | |||
| 338e317be1 | |||
| 9a66d5205f | |||
| 7d45bf6598 | |||
| 78ee72eea4 | |||
| 3b71c535b1 | |||
| 6615f7b300 | |||
| 38caae5e23 | |||
| 1751ba325d | |||
| 066b045822 | |||
| 3b21c42cdb | |||
| 8b1e91d665 | |||
| aa5909264a | |||
| b6232d4e21 | |||
| 0d3cef5578 | |||
| 1e737f3a71 | |||
| 2f2bdb4697 | |||
| 3fa7c8b85a | |||
| 7efce91a91 | |||
| a11d370f2b | |||
| d7bfa42f85 | |||
| 551ba49280 | |||
| adab24bb22 | |||
| 64aea7e84c | |||
| 67f6bd619e | |||
| 35d1fd1166 | |||
| d5b8019fb1 | |||
| 526cb4dbb3 | |||
| 6dc56c57c6 | |||
| a3263fdd3a | |||
| 14974c0257 | |||
| e70ae40096 | |||
| 3e04a131f1 | |||
| b487a7900b | |||
| 78b49c8b7a | |||
| 949e511dcf | |||
| d9faca5d01 | |||
| 398c6d0869 | |||
| 54d478df1e | |||
| 43c80f5418 | |||
| b186ffcaa6 | |||
| 778bbda4b8 |
Vendored
+1
@@ -5,6 +5,7 @@
|
||||
"HITL",
|
||||
"innerhtml",
|
||||
"llms",
|
||||
"onwarn",
|
||||
"opensource",
|
||||
"qwen",
|
||||
"retryable",
|
||||
|
||||
@@ -9,9 +9,10 @@ This is a **monorepo** with npm workspaces:
|
||||
|
||||
Internal packages:
|
||||
|
||||
- **CDN** (`packages/cdn/`) - IIFE builds for script tag usage (npm: `@page-agent/cdn`)
|
||||
- **LLMs** (`packages/llms/`) - LLM client with reflection-before-action mental model
|
||||
- **Page Controller** (`packages/page-controller/`) - DOM operations, independent of LLM
|
||||
- **UI** (`packages/ui/`) - Panel, SimulatorMask, i18n. Decoupled from PageAgent
|
||||
- **Page Controller** (`packages/page-controller/`) - DOM operations and visual feedback (SimulatorMask), independent of LLM
|
||||
- **UI** (`packages/ui/`) - Panel and i18n. Decoupled from PageAgent
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -31,6 +32,7 @@ Simple monorepo solution: TypeScript references + Vite aliases. Update tsconfig
|
||||
```
|
||||
packages/
|
||||
├── page-agent/ # npm: "page-agent" ⭐ MAIN
|
||||
├── cdn/ # npm: "@page-agent/cdn" (IIFE builds)
|
||||
├── website/ # @page-agent/website (private)
|
||||
├── llms/ # @page-agent/llms
|
||||
├── page-controller/ # @page-agent/page-controller
|
||||
@@ -43,8 +45,8 @@ packages/
|
||||
|
||||
- **Page Agent**: Core lib. Imports from `@page-agent/llms`, `@page-agent/page-controller`, `@page-agent/ui`
|
||||
- **LLMs**: LLM client with MacroToolInput contract. No dependency on page-agent
|
||||
- **UI**: Panel, Mask, i18n. No dependency on page-agent
|
||||
- **Page Controller**: Pure DOM operations. No LLM or UI dependency
|
||||
- **UI**: Panel and i18n. No dependency on page-agent
|
||||
- **Page Controller**: DOM operations with optional visual feedback (SimulatorMask). No LLM dependency. Enable mask via `enableMask: true` config
|
||||
|
||||
### PageController ↔ PageAgent Communication
|
||||
|
||||
@@ -69,41 +71,43 @@ const pageInfo = await this.pageController.getPageInfo()
|
||||
3. **LLM Processing**: AI returns action plans (page-agent)
|
||||
4. **Indexed Operations**: PageAgent calls PageController by element index
|
||||
|
||||
### CDN Auto-Injection
|
||||
### CDN Builds (`packages/cdn/`)
|
||||
|
||||
Library auto-initializes via script tag:
|
||||
Two IIFE builds for script tag usage:
|
||||
|
||||
```html
|
||||
<script src="page-agent.js?model=gpt-4"></script>
|
||||
```
|
||||
| Build | File | Description |
|
||||
| ----- | -------------------- | -------------------------------- |
|
||||
| Demo | `page-agent.demo.js` | Auto-init with built-in test API |
|
||||
| Full | `page-agent.js` | Exposes `PageAgent` class only |
|
||||
|
||||
Query params configure `PageAgentConfig` in `src/umd.ts`.
|
||||
Demo build supports query params (e.g., `?model=gpt-4&lang=en-US`).
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
### Page Agent (`packages/page-agent/`)
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `src/PageAgent.ts` | ⭐ Main AI agent class |
|
||||
| `src/umd.ts` | CDN/UMD entry with auto-init |
|
||||
| `src/tools/` | Tool definitions calling PageController |
|
||||
| File | Description |
|
||||
| ------------------ | --------------------------------------- |
|
||||
| `src/PageAgent.ts` | ⭐ Main AI agent class |
|
||||
| `src/umd.ts` | CDN/UMD entry with auto-init |
|
||||
| `src/tools/` | Tool definitions calling PageController |
|
||||
|
||||
### LLMs (`packages/llms/`)
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `src/index.ts` | ⭐ LLM class with retry logic |
|
||||
| `src/types.ts` | MacroToolInput, AgentBrain, LLMConfig |
|
||||
| `src/OpenAILenientClient.ts` | OpenAI-compatible client |
|
||||
| File | Description |
|
||||
| ---------------------------- | ------------------------------------- |
|
||||
| `src/index.ts` | ⭐ LLM class with retry logic |
|
||||
| `src/types.ts` | MacroToolInput, AgentBrain, LLMConfig |
|
||||
| `src/OpenAILenientClient.ts` | OpenAI-compatible client |
|
||||
|
||||
### Page Controller (`packages/page-controller/`)
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `src/PageController.ts` | ⭐ Main controller class |
|
||||
| `src/actions.ts` | Element interactions (click, input, scroll) |
|
||||
| `src/dom/dom_tree/index.js` | Core DOM extraction engine |
|
||||
| File | Description |
|
||||
| --------------------------- | ---------------------------------------------------------- |
|
||||
| `src/PageController.ts` | ⭐ Main controller class with optional mask support |
|
||||
| `src/SimulatorMask.ts` | Visual overlay blocking user interaction during automation |
|
||||
| `src/actions.ts` | Element interactions (click, input, scroll) |
|
||||
| `src/dom/dom_tree/index.js` | Core DOM extraction engine |
|
||||
|
||||
## Adding New Features
|
||||
|
||||
@@ -123,4 +127,5 @@ Query params configure `PageAgentConfig` in `src/umd.ts`.
|
||||
|
||||
- Explicit typing for exported/public APIs
|
||||
- ESLint relaxes some unsafe rules for rapid iteration
|
||||
- Every change you make should not only implement the desired functionality but also improve the quality of the codebase
|
||||
- All code and comments must be in English.
|
||||
|
||||
+37
-27
@@ -14,7 +14,7 @@ Thank you for your interest in contributing to Page-Agent! We welcome contributi
|
||||
2. **Setup**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm ci
|
||||
npm start # Start demo and documentation site
|
||||
```
|
||||
|
||||
@@ -25,11 +25,11 @@ This is a **monorepo** with npm workspaces containing **two main packages**:
|
||||
1. **Core Library** (`packages/page-agent/`) - Pure JavaScript/TypeScript AI agent library for browser DOM automation, published as `page-agent` on npm
|
||||
2. **Website** (`packages/website/`) - React documentation and landing page. Also as demo and test page for the core lib. private package `@page-agent/website`
|
||||
|
||||
We use a simplified monorepo solution with `native npm-workspace + ts reference + vite alias`. No fancy tooling. Hoisting is required.
|
||||
We use a simplified monorepo solution with `native npm-workspace + ts reference + vite alias`. No fancy tooling. Hoisting is required.
|
||||
|
||||
- When developing. Use alias so that we don't have to pre-build.
|
||||
- When bundling. Use external and disable ts `paths` alias to leave deps out.
|
||||
- When bundling `UMD` and `Website`. Bundle everything including local packages.
|
||||
- When bundling `IIFE` and `Website`. Bundle everything including local packages.
|
||||
|
||||
## 🤝 How to Contribute
|
||||
|
||||
@@ -61,10 +61,9 @@ We use a simplified monorepo solution with `native npm-workspace + ts reference
|
||||
- Update documentation as needed
|
||||
|
||||
4. **Test Your Changes**
|
||||
|
||||
```bash
|
||||
# TODO
|
||||
```
|
||||
- Test in our demo website
|
||||
- Test it on other websites if applicable
|
||||
- `@TODO: test suite`
|
||||
|
||||
5. **Commit and Push**
|
||||
|
||||
@@ -94,41 +93,52 @@ We use a simplified monorepo solution with `native npm-workspace + ts reference
|
||||
- It's **recommended** to heavily rely on AI (aka "vibe coding") when maintaining **demo pages and tests**.
|
||||
- BUT **NOT the core lib!!!** Be very careful if AI ever touched the core lib!!!
|
||||
- Review anything AI wrote before make a commit. You are the author of anything you commit. NOT AI.
|
||||
- Update the AI instructions when structure changed.
|
||||
- Cursor and Cline: `./.cursor/rules`
|
||||
- Github Copilot: `./.github/copilot-instructions.md`
|
||||
- Claude Code: `./CLAUDE.md`
|
||||
- Update the [AI instructions](AGENTS.md) when structure changed.
|
||||
|
||||
## 🔧 Development Workflows
|
||||
|
||||
### Test With Your Own LLM API
|
||||
|
||||
- Create a `.env` file in the repo root with your LLM API config
|
||||
|
||||
```env
|
||||
LLM_MODEL_NAME=gpt-5.2
|
||||
LLM_API_KEY=your-api-key
|
||||
LLM_BASE_URL=https://api.your-llm-provider.com/v1
|
||||
```
|
||||
|
||||
- Restart the dev server to load new env vars
|
||||
- If not provided, the demo will the free testing proxy by default
|
||||
|
||||
### Website Development
|
||||
|
||||
```bash
|
||||
npm start # React development server
|
||||
npm start
|
||||
```
|
||||
|
||||
### Core Lib Development and Testing
|
||||
### Testing on Other Websites
|
||||
|
||||
> @TODO this part is outdated. Update this.
|
||||
- Start and serve a local `iife` script
|
||||
|
||||
- Config your LLM API
|
||||
- Start and serve a local umd script
|
||||
```bash
|
||||
npm run dev:iife # Serving IIFE with auto rebuild at http://localhost:5174/page-agent.js
|
||||
```
|
||||
|
||||
```bash
|
||||
npm run dev:umd # Serving UMD with auto rebuild at http://localhost:5173/page-agent.umd.cjs
|
||||
```
|
||||
- Add a new bookmark
|
||||
|
||||
- Add a new bookmark enable it on other website
|
||||
```javascript
|
||||
javascript:(function(){var s=document.createElement('script');s.src=`http://localhost:5174/page-agent.js?t=${Math.random()}`;s.onload=()=>console.log(%27PageAgent ready!%27);document.head.appendChild(s);})();
|
||||
```
|
||||
|
||||
```
|
||||
javascript:(function(){var s=document.createElement('script');s.src=`http://localhost:5173/page-agent.umd.cjs?t=${Math.random()}`;s.onload=()=>console.log(%27PageAgent ready!%27);document.head.appendChild(s);})();
|
||||
```
|
||||
- Click the bookmark on any page to load Page-Agent
|
||||
|
||||
> Warning: AK in your local `.env` will be inlined in the iife script.
|
||||
|
||||
### Adding Documentation
|
||||
|
||||
1. Create `website/src/docs/section/page-name/page.tsx`
|
||||
2. Add route to `website/src/router.tsx`
|
||||
3. Add navigation link to `website/src/components/DocsLayout.tsx`
|
||||
Ask an AI to help you add documentation to the `website/` package. Follow the existing style.
|
||||
|
||||
> Our AGENTS.md file and guardrails are designed for this purpose. But please be careful and review anything AI generated.
|
||||
|
||||
## 🎯 Contribution Areas
|
||||
|
||||
@@ -143,7 +153,7 @@ We especially welcome contributions in:
|
||||
|
||||
## 🚫 What We Don't Accept
|
||||
|
||||
- Changes that break existing API compatibility
|
||||
- Changes that break existing API compatibility (Discuss first)
|
||||
- Heavy dependencies to core library
|
||||
- Contributions without proper testing
|
||||
- Code that doesn't follow project conventions
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Alibaba
|
||||
Copyright (c) 2026 Alibaba
|
||||
Copyright (c) 2026 Simon
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
+40
-40
@@ -1,16 +1,19 @@
|
||||
# PageAgent 🤖🪄
|
||||
|
||||

|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://img.alicdn.com/imgextra/i4/O1CN01qKig1P1FnhpFKNdi6_!!6000000000532-2-tps-1280-256.png">
|
||||
<img alt="Page Agent Banner" src="https://img.alicdn.com/imgextra/i1/O1CN01NCMKXj1Gn4tkFTsxf_!!6000000000666-2-tps-1280-256.png">
|
||||
</picture>
|
||||
|
||||
[](https://badge.fury.io/js/page-agent) [](https://opensource.org/licenses/MIT) [](http://www.typescriptlang.org/) [](https://www.npmjs.com/package/page-agent) [](https://bundlephobia.com/package/page-agent) [](https://github.com/alibaba/page-agent)
|
||||
[](https://opensource.org/licenses/MIT) [](http://www.typescriptlang.org/) [](https://www.npmjs.com/package/page-agent) [](https://bundlephobia.com/package/page-agent) [](https://github.com/alibaba/page-agent)
|
||||
|
||||
纯 JS 实现的 GUI agent。使用自然语言操作你的 Web 应用。无须后端、客户端、浏览器插件。
|
||||
|
||||
🌐 [English](./README.md) | **中文**
|
||||
|
||||
👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/#/docs/introduction/overview" target="_blank"><b>📖 Documentation</b></a>
|
||||
👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/#/docs/introduction/overview" target="_blank"><b>📖 Documentation</b></a>
|
||||
|
||||
<video id="demo-video" src="https://github.com/user-attachments/assets/141bbb01-8022-4d1f-919d-9efc9a1dc1cf" width="640" crossorigin muted autoplay loop></video>
|
||||
<video id="demo-video" src="https://github.com/user-attachments/assets/34d8444d-cbfb-44a3-a24e-fd5c167bb0bf" controls crossorigin muted></video>
|
||||
|
||||
---
|
||||
|
||||
@@ -24,22 +27,28 @@
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
👉 [**Roadmap**](./ROADMAP.md)
|
||||
👉 [**Roadmap**](https://github.com/alibaba/page-agent/issues/96)
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### CDN 集成
|
||||
### 快速体验 (With Testing LLM)
|
||||
|
||||
最快的体验方式:
|
||||
|
||||
```html
|
||||
// CDN - https://cdn.jsdelivr.net/npm/page-agent@latest/dist/umd/page-agent.js
|
||||
// Mirror(CN) - https://registry.npmmirror.com/page-agent/latest/files/dist/umd/page-agent.js
|
||||
<script
|
||||
src="https://registry.npmmirror.com/page-agent/latest/files/dist/umd/page-agent.js"
|
||||
crossorigin="true"
|
||||
type="text/javascript"
|
||||
<script
|
||||
src="https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.demo.js"
|
||||
crossorigin="true"
|
||||
></script>
|
||||
```
|
||||
|
||||
> ⚠️ **仅用于技术评估。** Demo 模型有速率限制和使用限制,生产环境请使用 NPM 方式。
|
||||
|
||||
| Mirrors | URL |
|
||||
| ------- | ----------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.demo.js |
|
||||
|
||||
### NPM 安装
|
||||
|
||||
```bash
|
||||
@@ -49,23 +58,18 @@ npm install page-agent
|
||||
```javascript
|
||||
import { PageAgent } from 'page-agent'
|
||||
|
||||
// 测试接口
|
||||
// @note: 限流,限制 prompt 内容,限制来源,随时变更,请替换成你自己的
|
||||
// @note: 使用 DeepSeek-chat(3.2) 官方版本,使用协议和隐私策略见 DeepSeek 网站
|
||||
const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
const DEMO_BASE_URL = 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
|
||||
const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
|
||||
const agent = new PageAgent({
|
||||
model: DEMO_MODEL,
|
||||
baseURL: DEMO_BASE_URL,
|
||||
apiKey: DEMO_API_KEY,
|
||||
model: 'deepseek-chat',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
language: 'zh-CN',
|
||||
})
|
||||
|
||||
await agent.execute('点击登录按钮')
|
||||
```
|
||||
|
||||
适用于无法使用 NPM 的环境,我们也提供了 IIFE 构建的 CDN 方式。[@see CDN Usage](https://alibaba.github.io/page-agent/#/docs/integration/cdn-setup)
|
||||
|
||||
## 🏗️ 架构设计
|
||||
|
||||
PageAgent adopts a simplified monorepo structure:
|
||||
@@ -74,8 +78,9 @@ PageAgent adopts a simplified monorepo structure:
|
||||
packages/
|
||||
├── page-agent/ # AI agent (npm: page-agent)
|
||||
├── llms/ # LLM 客户端 (npm: @page-agent/llms)
|
||||
├── page-controller/ # DOM 操作 (npm: @page-agent/page-controller)
|
||||
├── ui/ # 面板 & 蒙层 & 模拟鼠标 (npm: @page-agent/ui)
|
||||
├── page-controller/ # DOM 操作 & 蒙层 & 模拟鼠标 (npm: @page-agent/page-controller)
|
||||
├── ui/ # 面板 & i18n (npm: @page-agent/ui)
|
||||
├── cdn/ # CDN IIFE builds (npm: @page-agent/cdn)
|
||||
└── website/ # 文档站点
|
||||
```
|
||||
|
||||
@@ -83,28 +88,19 @@ packages/
|
||||
|
||||
欢迎社区贡献!以下是参与方式:
|
||||
|
||||
### 开发环境
|
||||
1. Fork 并 clone。`git clone https://github.com/alibaba/page-agent.git && cd page-agent`
|
||||
2. 安装依赖: `npm install`
|
||||
3. 启动开发: `npm start`
|
||||
|
||||
1. Fork 项目仓库
|
||||
2. Clone or fork: `git clone https://github.com/alibaba/page-agent.git && cd page-agent`
|
||||
3. 安装依赖: `npm install`
|
||||
4. 启动开发: `npm start`
|
||||
更多详情请参阅 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
|
||||
### 贡献指南
|
||||
|
||||
请在贡献前阅读我们的[行为准则](CODE_OF_CONDUCT.md)和[贡献指南](CONTRIBUTING.md)。
|
||||
请在贡献前阅读我们的[行为准则](CODE_OF_CONDUCT.md)。
|
||||
|
||||
## 👏 致谢
|
||||
|
||||
本项目基于以下优秀项目构建:
|
||||
本项目基于 **[`browser-use`](https://github.com/browser-use/browser-use)** 的优秀工作构建。
|
||||
|
||||
- **[browser-use](https://github.com/browser-use/browser-use)**
|
||||
|
||||
PageAgent 专为**客户端网页增强**设计,不是服务端自动化工具。
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT 许可证 - 详见 [LICENSE](LICENSE) 文件。
|
||||
`PageAgent` 专为**客户端网页增强**设计,不是服务端自动化工具。
|
||||
|
||||
```
|
||||
DOM processing components and prompt are derived from browser-use:
|
||||
@@ -123,6 +119,10 @@ Third-party dependencies and their licenses can be found in the package.json
|
||||
file and in the node_modules directory after installation.
|
||||
```
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
[MIT License](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
**⭐ 如果觉得 PageAgent 有用或有趣,请给项目点个星!**
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
# PageAgent 🤖🪄
|
||||
|
||||

|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://img.alicdn.com/imgextra/i4/O1CN01qKig1P1FnhpFKNdi6_!!6000000000532-2-tps-1280-256.png">
|
||||
<img alt="Page Agent Banner" src="https://img.alicdn.com/imgextra/i1/O1CN01NCMKXj1Gn4tkFTsxf_!!6000000000666-2-tps-1280-256.png">
|
||||
</picture>
|
||||
|
||||
[](https://badge.fury.io/js/page-agent) [](https://opensource.org/licenses/MIT) [](http://www.typescriptlang.org/) [](https://www.npmjs.com/package/page-agent) [](https://bundlephobia.com/package/page-agent) [](https://github.com/alibaba/page-agent)
|
||||
[](https://opensource.org/licenses/MIT) [](http://www.typescriptlang.org/) [](https://www.npmjs.com/package/page-agent) [](https://bundlephobia.com/package/page-agent) [](https://github.com/alibaba/page-agent)
|
||||
|
||||
The GUI Agent Living in Your Webpage. Control web interfaces with natural language.
|
||||
|
||||
🌐 **English** | [中文](./README-zh.md)
|
||||
|
||||
👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/#/docs/introduction/overview" target="_blank"><b>📖 Documentation</b></a>
|
||||
👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/#/docs/introduction/overview" target="_blank"><b>📖 Documentation</b></a>
|
||||
|
||||
<video id="demo-video" src="https://github.com/user-attachments/assets/de8d1964-8bde-494f-a52f-2975469557a5" width="640" crossorigin muted autoplay loop></video>
|
||||
<video id="demo-video" src="https://github.com/user-attachments/assets/34d8444d-cbfb-44a3-a24e-fd5c167bb0bf" controls crossorigin muted></video>
|
||||
|
||||
---
|
||||
|
||||
@@ -24,22 +27,28 @@ The GUI Agent Living in Your Webpage. Control web interfaces with natural langua
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
👉 [**Roadmap**](./ROADMAP.md)
|
||||
👉 [**Roadmap**](https://github.com/alibaba/page-agent/issues/96)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### CDN Integration
|
||||
### Quick Try (With Testing LLM)
|
||||
|
||||
Fastest way to try PageAgent:
|
||||
|
||||
```html
|
||||
// CDN - https://cdn.jsdelivr.net/npm/page-agent@latest/dist/umd/page-agent.js
|
||||
// Mirror(CN) - https://registry.npmmirror.com/page-agent/latest/files/dist/umd/page-agent.js
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/page-agent@latest/dist/umd/page-agent.js"
|
||||
crossorigin="true"
|
||||
type="text/javascript"
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.demo.js"
|
||||
crossorigin="true"
|
||||
></script>
|
||||
```
|
||||
|
||||
> ⚠️ **For technical evaluation only.** Demo model has rate limits and usage restrictions. Use NPM for production.
|
||||
|
||||
| Mirrors | URL |
|
||||
| ------- | ----------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.demo.js |
|
||||
|
||||
### NPM Installation
|
||||
|
||||
```bash
|
||||
@@ -49,23 +58,18 @@ npm install page-agent
|
||||
```javascript
|
||||
import { PageAgent } from 'page-agent'
|
||||
|
||||
// test server
|
||||
// @note: rate limit. prompt limit. Origin limit. May change anytime. Use your own llm!
|
||||
// @note Using official DeepSeek-chat(3.2). Go to DeepSeek website for privacy policy.
|
||||
const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
const DEMO_BASE_URL = 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
|
||||
const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
|
||||
const agent = new PageAgent({
|
||||
model: DEMO_MODEL,
|
||||
baseURL: DEMO_BASE_URL,
|
||||
apiKey: DEMO_API_KEY,
|
||||
model: 'deepseek-chat',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
language: 'en-US',
|
||||
})
|
||||
|
||||
await agent.execute('Click the login button')
|
||||
```
|
||||
|
||||
For environments where NPM is not available. We do offer a IIFE build via CDN. [@see CDN Usage](https://alibaba.github.io/page-agent/#/docs/integration/cdn-setup)
|
||||
|
||||
## 🏗️ Structure
|
||||
|
||||
PageAgent adopts a simplified monorepo structure:
|
||||
@@ -74,8 +78,9 @@ PageAgent adopts a simplified monorepo structure:
|
||||
packages/
|
||||
├── page-agent/ # AI agent (npm: page-agent)
|
||||
├── llms/ # LLM client (npm: @page-agent/llms)
|
||||
├── page-controller/ # DOM operations (npm: @page-agent/page-controller)
|
||||
├── ui/ # Panel & Mask & Mouse Animation (npm: @page-agent/ui)
|
||||
├── page-controller/ # DOM operations & Visual Mask (npm: @page-agent/page-controller)
|
||||
├── ui/ # Panel & i18n (npm: @page-agent/ui)
|
||||
├── cdn/ # CDN IIFE builds (npm: @page-agent/cdn)
|
||||
└── website/ # Demo & Documentation site
|
||||
```
|
||||
|
||||
@@ -83,28 +88,19 @@ packages/
|
||||
|
||||
We welcome contributions from the community! Here's how to get started:
|
||||
|
||||
### Setup
|
||||
1. Fork and clone. `git clone https://github.com/alibaba/page-agent.git && cd page-agent`
|
||||
2. Install dependencies: `npm install`
|
||||
3. Start development: `npm start`
|
||||
|
||||
1. Fork the repository
|
||||
2. Clone your fork: `git clone https://github.com/alibaba/page-agent.git && cd page-agent`
|
||||
3. Install dependencies: `npm install`
|
||||
4. Start development: `npm start`
|
||||
More details in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
### Contributing Guidelines
|
||||
|
||||
Please read our [Code of Conduct](CODE_OF_CONDUCT.md) and [Contributing Guide](CONTRIBUTING.md) before contributing.
|
||||
Please read our [Code of Conduct](CODE_OF_CONDUCT.md) before contributing.
|
||||
|
||||
## 👏 Acknowledgments
|
||||
|
||||
This project builds upon the excellent work of:
|
||||
This project builds upon the excellent work of **[`browser-use`](https://github.com/browser-use/browser-use)**.
|
||||
|
||||
- **[browser-use](https://github.com/browser-use/browser-use)**
|
||||
|
||||
PageAgent is designed for **client-side web enhancement**, not server-side automation.
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
`PageAgent` is designed for **client-side web enhancement**, not server-side automation.
|
||||
|
||||
```
|
||||
DOM processing components and prompt are derived from browser-use:
|
||||
@@ -123,6 +119,10 @@ Third-party dependencies and their licenses can be found in the package.json
|
||||
file and in the node_modules directory after installation.
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
|
||||
[MIT License](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
**⭐ Star this repo if you find PageAgent helpful!**
|
||||
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
# 🗺️ PageAgent Roadmap
|
||||
|
||||
The development progress and future plans for PageAgent.
|
||||
|
||||
## 🚀 Current Works
|
||||
|
||||
- [x] **MVP**
|
||||
- Core functionality implemented.
|
||||
- [x] **SPA interaction**
|
||||
- [x] **Reasoning and (short) memory**
|
||||
- [x] **Multi model provider integration and testing**
|
||||
- [x] **UI with HITL**
|
||||
- Human-in-the-loop user interface. Agent can ask user questions.
|
||||
- [x] **Landing and doc pages**
|
||||
- [x] **Remove `ai-sdk`**
|
||||
- Only one function of AI-ADK is being used.
|
||||
- Our agent memory and thinking mechanism does not suite ai-sdk.
|
||||
- [x] **Robust LLM output**
|
||||
- Auto-fix incomplete output format of DeepSeek and QWen.
|
||||
- [x] **Working homepage with live LLM API**
|
||||
- [x] **~~free~~ CDN**
|
||||
- [x] **Free evaluation plan**
|
||||
- [x] **Custom actions and HITL**
|
||||
- [ ] **Hooks and Events**
|
||||
- [x] **lifecycle hooks**
|
||||
- [ ] **lifecycle events**
|
||||
- [ ] **User takeover**
|
||||
- [#64](https://github.com/alibaba/page-agent/issues/64)
|
||||
- [ ] **❗Hijack `page_open/page_change/page_unload` behavior**
|
||||
- [ ] **Custom knowledge base and instructions**
|
||||
- [#45](https://github.com/alibaba/page-agent/issues/45)
|
||||
- [ ] **Black/white-list safeguard**
|
||||
- [ ] **Data-masking**
|
||||
- [#44](https://github.com/alibaba/page-agent/issues/44)
|
||||
- [ ] **Improve Memory**
|
||||
- [#66](https://github.com/alibaba/page-agent/issues/66)
|
||||
- [#65](https://github.com/alibaba/page-agent/issues/65)
|
||||
- [ ] **Optimize for popular UI frameworks**
|
||||
- [x] **i18n of the website**
|
||||
- [x] Chinese version
|
||||
- [x] English version
|
||||
- [x] **Refactor: Separate `Agent` and `PageController`**
|
||||
- [ ] **Chrome-ext wrapper for multi-page tasks**
|
||||
|
||||
♻️ Following browser-use's update and contribute back.
|
||||
|
||||
## 📋 Pending Features
|
||||
|
||||
- [ ] **Tools for more complex tasks**
|
||||
- todo list
|
||||
- file sys
|
||||
- [x] **Support custom llm fetch**
|
||||
- [ ] **Testing suits**
|
||||
- [ ] **Same-origin multi-page-app rally**
|
||||
- [ ] **Local MCP proxy**
|
||||
|
||||
## 🤔 To Be Decided
|
||||
|
||||
- [ ] **Cross-origin multi-page?**
|
||||
- Tricky
|
||||
- Need some kind of "memory rally"
|
||||
+1
-6
@@ -8,12 +8,7 @@ import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores([
|
||||
'**/dist',
|
||||
'**/test-pages',
|
||||
'**/node_modules',
|
||||
'packages/website/src/components/ui',
|
||||
]),
|
||||
globalIgnores(['**/dist', '**/node_modules', 'packages/website/src/components/ui']),
|
||||
{
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
|
||||
Generated
+267
-268
@@ -1,43 +1,44 @@
|
||||
{
|
||||
"name": "root",
|
||||
"version": "0.0.19",
|
||||
"version": "0.2.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "root",
|
||||
"version": "0.0.19",
|
||||
"version": "0.2.3",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"packages/page-controller",
|
||||
"packages/ui",
|
||||
"packages/llms",
|
||||
"packages/page-agent",
|
||||
"packages/cdn",
|
||||
"packages/website"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.3.0",
|
||||
"@commitlint/config-conventional": "^20.3.0",
|
||||
"@commitlint/cli": "^20.3.1",
|
||||
"@commitlint/config-conventional": "^20.3.1",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@microsoft/api-extractor": "^7.55.2",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.1",
|
||||
"@types/node": "^25.0.3",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/node": "^25.0.7",
|
||||
"concurrently": "^9.2.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-dom": "^2.5.1",
|
||||
"eslint-plugin-react-dom": "^2.5.7",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"eslint-plugin-react-x": "^2.5.1",
|
||||
"globals": "^16.4.0",
|
||||
"eslint-plugin-react-x": "^2.5.7",
|
||||
"globals": "^17.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.4",
|
||||
"prettier": "^3.7.4",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.52.0",
|
||||
"typescript-eslint": "^8.53.0",
|
||||
"unplugin-dts": "^1.0.0-beta.6",
|
||||
"vite": "^7.3.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-css-injected-by-js": "^3.5.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -316,17 +317,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@commitlint/cli": {
|
||||
"version": "20.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.3.0.tgz",
|
||||
"integrity": "sha512-HXO8YVfqdBK+MnlX2zqNrv6waGYPs6Ysjm5W2Y0GMagWXwiIKx7C8dcIX9ca+QdHq4WA0lcMnZLQ0pzQh1piZg==",
|
||||
"version": "20.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.3.1.tgz",
|
||||
"integrity": "sha512-NtInjSlyev/+SLPvx/ulz8hRE25Wf5S9dLNDcIwazq0JyB4/w1ROF/5nV0ObPTX8YpRaKYeKtXDYWqumBNHWsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@commitlint/format": "^20.2.0",
|
||||
"@commitlint/lint": "^20.3.0",
|
||||
"@commitlint/load": "^20.3.0",
|
||||
"@commitlint/read": "^20.2.0",
|
||||
"@commitlint/types": "^20.2.0",
|
||||
"@commitlint/format": "^20.3.1",
|
||||
"@commitlint/lint": "^20.3.1",
|
||||
"@commitlint/load": "^20.3.1",
|
||||
"@commitlint/read": "^20.3.1",
|
||||
"@commitlint/types": "^20.3.1",
|
||||
"tinyexec": "^1.0.0",
|
||||
"yargs": "^17.0.0"
|
||||
},
|
||||
@@ -338,13 +339,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@commitlint/config-conventional": {
|
||||
"version": "20.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.3.0.tgz",
|
||||
"integrity": "sha512-g1OXVl6E2v0xF1Ru2RpxQ+Vfy7XUcUsCmLKzGUrhFLS4hSNykje0QSy6djBtzOiOBQCepBrmIlqx/gRlzrSh5A==",
|
||||
"version": "20.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.3.1.tgz",
|
||||
"integrity": "sha512-NCzwvxepstBZbmVXsvg49s+shCxlJDJPWxXqONVcAtJH9wWrOlkMQw/zyl+dJmt8lyVopt5mwQ3mR5M2N2rUWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@commitlint/types": "^20.2.0",
|
||||
"@commitlint/types": "^20.3.1",
|
||||
"conventional-changelog-conventionalcommits": "^7.0.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -352,13 +353,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@commitlint/config-validator": {
|
||||
"version": "20.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.2.0.tgz",
|
||||
"integrity": "sha512-SQCBGsL9MFk8utWNSthdxd9iOD1pIVZSHxGBwYIGfd67RTjxqzFOSAYeQVXOu3IxRC3YrTOH37ThnTLjUlyF2w==",
|
||||
"version": "20.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.3.1.tgz",
|
||||
"integrity": "sha512-ErVLC/IsHhcvxCyh+FXo7jy12/nkQySjWXYgCoQbZLkFp4hysov8KS6CdxBB0cWjbZWjvNOKBMNoUVqkmGmahw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@commitlint/types": "^20.2.0",
|
||||
"@commitlint/types": "^20.3.1",
|
||||
"ajv": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -366,13 +367,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@commitlint/ensure": {
|
||||
"version": "20.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.2.0.tgz",
|
||||
"integrity": "sha512-+8TgIGv89rOWyt3eC6lcR1H7hqChAKkpawytlq9P1i/HYugFRVqgoKJ8dhd89fMnlrQTLjA5E97/4sF09QwdoA==",
|
||||
"version": "20.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.3.1.tgz",
|
||||
"integrity": "sha512-h664FngOEd7bHAm0j8MEKq+qm2mH+V+hwJiIE2bWcw3pzJMlO0TPKtk0ATyRAtV6jQw+xviRYiIjjSjfajiB5w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@commitlint/types": "^20.2.0",
|
||||
"@commitlint/types": "^20.3.1",
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"lodash.kebabcase": "^4.1.1",
|
||||
"lodash.snakecase": "^4.1.1",
|
||||
@@ -394,13 +395,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@commitlint/format": {
|
||||
"version": "20.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.2.0.tgz",
|
||||
"integrity": "sha512-PhNoLNhxpfIBlW/i90uZ3yG3hwSSYx7n4d9Yc+2FAorAHS0D9btYRK4ZZXX+Gm3W5tDtu911ow/eWRfcRVgNWg==",
|
||||
"version": "20.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.3.1.tgz",
|
||||
"integrity": "sha512-jfsjGPFTd2Yti2YHwUH4SPRPbWKAJAwrfa3eNa9bXEdrXBb9mCwbIrgYX38LdEJK9zLJ3AsLBP4/FLEtxyu2AA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@commitlint/types": "^20.2.0",
|
||||
"@commitlint/types": "^20.3.1",
|
||||
"chalk": "^5.3.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -408,13 +409,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@commitlint/is-ignored": {
|
||||
"version": "20.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.2.0.tgz",
|
||||
"integrity": "sha512-Lz0OGeZCo/QHUDLx5LmZc0EocwanneYJUM8z0bfWexArk62HKMLfLIodwXuKTO5y0s6ddXaTexrYHs7v96EOmw==",
|
||||
"version": "20.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.3.1.tgz",
|
||||
"integrity": "sha512-tWwAoh93QvAhxgp99CzCuHD86MgxE4NBtloKX+XxQxhfhSwHo7eloiar/yzx53YW9eqSLP95zgW2KDDk4/WX+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@commitlint/types": "^20.2.0",
|
||||
"@commitlint/types": "^20.3.1",
|
||||
"semver": "^7.6.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -422,32 +423,32 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@commitlint/lint": {
|
||||
"version": "20.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.3.0.tgz",
|
||||
"integrity": "sha512-X19HOGU5nRo6i9DIY0kG0mhgtvpn1UGO1D6aLX1ILLyeqSM5yJyMcrRqNj8SLgeSeUDODhLY9QYsBIG0LdNHkA==",
|
||||
"version": "20.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.3.1.tgz",
|
||||
"integrity": "sha512-LaOtrQ24+6SfUaWg8A+a+Wc77bvLbO5RIr6iy9F7CI3/0iq1uPEWgGRCwqWTuLGHkZDAcwaq0gZ01zpwZ1jCGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@commitlint/is-ignored": "^20.2.0",
|
||||
"@commitlint/parse": "^20.2.0",
|
||||
"@commitlint/rules": "^20.3.0",
|
||||
"@commitlint/types": "^20.2.0"
|
||||
"@commitlint/is-ignored": "^20.3.1",
|
||||
"@commitlint/parse": "^20.3.1",
|
||||
"@commitlint/rules": "^20.3.1",
|
||||
"@commitlint/types": "^20.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v18"
|
||||
}
|
||||
},
|
||||
"node_modules/@commitlint/load": {
|
||||
"version": "20.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.3.0.tgz",
|
||||
"integrity": "sha512-amkdVZTXp5R65bsRXRSCwoNXbJHR2aAIY/RGFkoyd63t8UEwqEgT3f0MgeLqYw4hwXyq+TYXKdaW133E29pnGQ==",
|
||||
"version": "20.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.3.1.tgz",
|
||||
"integrity": "sha512-YDD9XA2XhgYgbjju8itZ/weIvOOobApDqwlPYCX5NLO/cPtw2UMO5Cmn44Ks8RQULUVI5fUT6roKvyxcoLbNmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@commitlint/config-validator": "^20.2.0",
|
||||
"@commitlint/config-validator": "^20.3.1",
|
||||
"@commitlint/execute-rule": "^20.0.0",
|
||||
"@commitlint/resolve-extends": "^20.2.0",
|
||||
"@commitlint/types": "^20.2.0",
|
||||
"@commitlint/resolve-extends": "^20.3.1",
|
||||
"@commitlint/types": "^20.3.1",
|
||||
"chalk": "^5.3.0",
|
||||
"cosmiconfig": "^9.0.0",
|
||||
"cosmiconfig-typescript-loader": "^6.1.0",
|
||||
@@ -470,13 +471,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@commitlint/parse": {
|
||||
"version": "20.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.2.0.tgz",
|
||||
"integrity": "sha512-LXStagGU1ivh07X7sM+hnEr4BvzFYn1iBJ6DRg2QsIN8lBfSzyvkUcVCDwok9Ia4PWiEgei5HQjju6xfJ1YaSQ==",
|
||||
"version": "20.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.3.1.tgz",
|
||||
"integrity": "sha512-TuUTdbLpyUNLgDzLDYlI2BeTE6V/COZbf3f8WwsV0K6eq/2nSpNTMw7wHtXb+YxeY9wwxBp/Ldad4P+YIxHJoA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@commitlint/types": "^20.2.0",
|
||||
"@commitlint/types": "^20.3.1",
|
||||
"conventional-changelog-angular": "^7.0.0",
|
||||
"conventional-commits-parser": "^5.0.0"
|
||||
},
|
||||
@@ -485,14 +486,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@commitlint/read": {
|
||||
"version": "20.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.2.0.tgz",
|
||||
"integrity": "sha512-+SjF9mxm5JCbe+8grOpXCXMMRzAnE0WWijhhtasdrpJoAFJYd5UgRTj/oCq5W3HJTwbvTOsijEJ0SUGImECD7Q==",
|
||||
"version": "20.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.3.1.tgz",
|
||||
"integrity": "sha512-nCmJAdIg3OdNVUpQW0Idk/eF/vfOo2W2xzmvRmNeptLrzFK7qhwwl/kIwy1Q1LZrKHUFNj7PGNpIT5INbgZWzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@commitlint/top-level": "^20.0.0",
|
||||
"@commitlint/types": "^20.2.0",
|
||||
"@commitlint/types": "^20.3.1",
|
||||
"git-raw-commits": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"tinyexec": "^1.0.0"
|
||||
@@ -502,14 +503,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@commitlint/resolve-extends": {
|
||||
"version": "20.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.2.0.tgz",
|
||||
"integrity": "sha512-KVoLDi9BEuqeq+G0wRABn4azLRiCC22/YHR2aCquwx6bzCHAIN8hMt3Nuf1VFxq/c8ai6s8qBxE8+ZD4HeFTlQ==",
|
||||
"version": "20.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.3.1.tgz",
|
||||
"integrity": "sha512-iGTGeyaoDyHDEZNjD8rKeosjSNs8zYanmuowY4ful7kFI0dnY4b5QilVYaFQJ6IM27S57LAeH5sKSsOHy4bw5w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@commitlint/config-validator": "^20.2.0",
|
||||
"@commitlint/types": "^20.2.0",
|
||||
"@commitlint/config-validator": "^20.3.1",
|
||||
"@commitlint/types": "^20.3.1",
|
||||
"global-directory": "^4.0.1",
|
||||
"import-meta-resolve": "^4.0.0",
|
||||
"lodash.mergewith": "^4.6.2",
|
||||
@@ -520,16 +521,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@commitlint/rules": {
|
||||
"version": "20.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.3.0.tgz",
|
||||
"integrity": "sha512-TGgXN/qBEhbzVD13crE1l7YSMJRrbPbUL0OBZALbUM5ER36RZmiZRu2ud2W/AA7HO9YLBRbyx6YVi2t/2Be0yQ==",
|
||||
"version": "20.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.3.1.tgz",
|
||||
"integrity": "sha512-/uic4P+4jVNpqQxz02+Y6vvIC0A2J899DBztA1j6q3f3MOKwydlNrojSh0dQmGDxxT1bXByiRtDhgFnOFnM6Pg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@commitlint/ensure": "^20.2.0",
|
||||
"@commitlint/ensure": "^20.3.1",
|
||||
"@commitlint/message": "^20.0.0",
|
||||
"@commitlint/to-lines": "^20.0.0",
|
||||
"@commitlint/types": "^20.2.0"
|
||||
"@commitlint/types": "^20.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v18"
|
||||
@@ -559,9 +560,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@commitlint/types": {
|
||||
"version": "20.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.2.0.tgz",
|
||||
"integrity": "sha512-KTy0OqRDLR5y/zZMnizyx09z/rPlPC/zKhYgH8o/q6PuAjoQAKlRfY4zzv0M64yybQ//6//4H1n14pxaLZfUnA==",
|
||||
"version": "20.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.3.1.tgz",
|
||||
"integrity": "sha512-VmIFV/JkBRhDRRv7N5B7zEUkNZIx9Mp+8Pe65erz0rKycXLsi8Epcw0XJ+btSeRXgTzE7DyOyA9bkJ9mn/yqVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1057,16 +1058,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-react/ast": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-react/ast/-/ast-2.5.1.tgz",
|
||||
"integrity": "sha512-l+pEb3kS/RhFN+8ZOhL5+P5+Yj7iw+ta7+abUBNH+5PMyiixlnAJlJ7Wy+rsTGxjUJtzEff3CzSr06V7qwJuwQ==",
|
||||
"version": "2.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-react/ast/-/ast-2.5.7.tgz",
|
||||
"integrity": "sha512-yQvKvyIzo9DPKFUOtFSEfpX0He763G1jFf/LQcIY9xoSOWP9pQ2X8ADNE6ZnmIc0L5zZLQ9nF/H0+MGh2NF3fA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-react/eff": "2.5.1",
|
||||
"@typescript-eslint/types": "^8.51.0",
|
||||
"@typescript-eslint/typescript-estree": "^8.51.0",
|
||||
"@typescript-eslint/utils": "^8.51.0",
|
||||
"@eslint-react/eff": "2.5.7",
|
||||
"@typescript-eslint/types": "^8.53.0",
|
||||
"@typescript-eslint/typescript-estree": "^8.53.0",
|
||||
"@typescript-eslint/utils": "^8.53.0",
|
||||
"string-ts": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1078,19 +1079,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-react/core": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-react/core/-/core-2.5.1.tgz",
|
||||
"integrity": "sha512-48YQYhjHga0TAv7FI0uGGT+X3SITec89fJLKSTqIEa7jTLpXTV56B+B/V/IcfZ0s8I0lNVEmtHFTXaibAxmfyw==",
|
||||
"version": "2.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-react/core/-/core-2.5.7.tgz",
|
||||
"integrity": "sha512-LqY4YV4Z/uiymdy2OLXkjsEVnoDjoFsxJZI4GWsZ9aBgGWYAsuoKKUG0bdMLp1U9jwMaY0OY9bYgBB5R1ANkoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-react/ast": "2.5.1",
|
||||
"@eslint-react/eff": "2.5.1",
|
||||
"@eslint-react/shared": "2.5.1",
|
||||
"@eslint-react/var": "2.5.1",
|
||||
"@typescript-eslint/scope-manager": "^8.51.0",
|
||||
"@typescript-eslint/types": "^8.51.0",
|
||||
"@typescript-eslint/utils": "^8.51.0",
|
||||
"@eslint-react/ast": "2.5.7",
|
||||
"@eslint-react/eff": "2.5.7",
|
||||
"@eslint-react/shared": "2.5.7",
|
||||
"@eslint-react/var": "2.5.7",
|
||||
"@typescript-eslint/scope-manager": "^8.53.0",
|
||||
"@typescript-eslint/types": "^8.53.0",
|
||||
"@typescript-eslint/utils": "^8.53.0",
|
||||
"birecord": "^0.1.1",
|
||||
"ts-pattern": "^5.9.0"
|
||||
},
|
||||
@@ -1103,9 +1104,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-react/eff": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-react/eff/-/eff-2.5.1.tgz",
|
||||
"integrity": "sha512-henDz7sTB67Q+YPb+Kiy0UpD7eHLvDtB2nOrRtHLTatS/LFr7wVr4J2muQPkPzY9KFF8pv811nrwW58FuDphPg==",
|
||||
"version": "2.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-react/eff/-/eff-2.5.7.tgz",
|
||||
"integrity": "sha512-oxd8eP+dykyOUmQUEItnOZhitlKyo9yFq8Ne0k9+CJeV0Pk1db1+lNcDWswWYoeb1IFEloqHo6y63fVxQXvJew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1113,14 +1114,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-react/shared": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-react/shared/-/shared-2.5.1.tgz",
|
||||
"integrity": "sha512-JSTnpZdBu57ZvrJkBwUZXgkBW8iPiQfSiQMYHEjVFR2NHWFgcOd9qYJLX7mJLrdcw/j+GgRQeOAGok0a0xcxGA==",
|
||||
"version": "2.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-react/shared/-/shared-2.5.7.tgz",
|
||||
"integrity": "sha512-ZjKO3rNDoLN2ZTn3PxmMcgoASPNiL3umK1BHQvYwCXqqnXlLEouu7sxK6htOvDu1NC+Dbwsqpu4JGUY7t0XQTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-react/eff": "2.5.1",
|
||||
"@typescript-eslint/utils": "^8.51.0",
|
||||
"@eslint-react/eff": "2.5.7",
|
||||
"@typescript-eslint/utils": "^8.53.0",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
@@ -1133,17 +1134,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-react/var": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-react/var/-/var-2.5.1.tgz",
|
||||
"integrity": "sha512-O3rbdK8pHKZ4NYPD39Zq7zhWu76dvhyeroqG8nQzf667F/FdHGpGhsCPJOeIU5L/ooYO/3/Qz2vJD1pB5JeROQ==",
|
||||
"version": "2.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-react/var/-/var-2.5.7.tgz",
|
||||
"integrity": "sha512-VWoyZENbK+OV70s/sE9E6T4HD0WO6lsg2CnYjjpKBKBDOBW67SRdIlM7dUPKzqQKsj6p4yNh59eQ5iKaH82LLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-react/ast": "2.5.1",
|
||||
"@eslint-react/eff": "2.5.1",
|
||||
"@typescript-eslint/scope-manager": "^8.51.0",
|
||||
"@typescript-eslint/types": "^8.51.0",
|
||||
"@typescript-eslint/utils": "^8.51.0",
|
||||
"@eslint-react/ast": "2.5.7",
|
||||
"@eslint-react/eff": "2.5.7",
|
||||
"@typescript-eslint/scope-manager": "^8.53.0",
|
||||
"@typescript-eslint/types": "^8.53.0",
|
||||
"@typescript-eslint/utils": "^8.53.0",
|
||||
"ts-pattern": "^5.9.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1539,20 +1540,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/api-extractor/node_modules/typescript": {
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
|
||||
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/api-extractor/node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
@@ -1597,6 +1584,10 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@page-agent/cdn": {
|
||||
"resolved": "packages/cdn",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@page-agent/llms": {
|
||||
"resolved": "packages/llms",
|
||||
"link": true
|
||||
@@ -3458,9 +3449,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@trivago/prettier-plugin-sort-imports": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-6.0.1.tgz",
|
||||
"integrity": "sha512-6B13DCWDfAfh4AEJ43gRgeCSAQmlKG5LHqHzHc0lbUwgBy0rX7o41US+46Fd4XiXBx+JDGEz3NBadCbUls0dUQ==",
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-6.0.2.tgz",
|
||||
"integrity": "sha512-3DgfkukFyC/sE/VuYjaUUWoFfuVjPK55vOFDsxD56XXynFMCZDYFogH2l/hDfOsQAm1myoU/1xByJ3tWqtulXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -3556,9 +3547,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz",
|
||||
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
|
||||
"version": "25.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.7.tgz",
|
||||
"integrity": "sha512-C/er7DlIZgRJO7WtTdYovjIFzGsz0I95UlMyR9anTb4aCpBSRWe5Jc1/RvLKUfzmOxHPGjSE5+63HgLtndxU4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3566,9 +3557,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
|
||||
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3586,17 +3577,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.52.0.tgz",
|
||||
"integrity": "sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q==",
|
||||
"version": "8.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz",
|
||||
"integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.52.0",
|
||||
"@typescript-eslint/type-utils": "8.52.0",
|
||||
"@typescript-eslint/utils": "8.52.0",
|
||||
"@typescript-eslint/visitor-keys": "8.52.0",
|
||||
"@typescript-eslint/scope-manager": "8.53.0",
|
||||
"@typescript-eslint/type-utils": "8.53.0",
|
||||
"@typescript-eslint/utils": "8.53.0",
|
||||
"@typescript-eslint/visitor-keys": "8.53.0",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.4.0"
|
||||
@@ -3609,7 +3600,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.52.0",
|
||||
"@typescript-eslint/parser": "^8.53.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
@@ -3625,16 +3616,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.52.0.tgz",
|
||||
"integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==",
|
||||
"version": "8.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.0.tgz",
|
||||
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.52.0",
|
||||
"@typescript-eslint/types": "8.52.0",
|
||||
"@typescript-eslint/typescript-estree": "8.52.0",
|
||||
"@typescript-eslint/visitor-keys": "8.52.0",
|
||||
"@typescript-eslint/scope-manager": "8.53.0",
|
||||
"@typescript-eslint/types": "8.53.0",
|
||||
"@typescript-eslint/typescript-estree": "8.53.0",
|
||||
"@typescript-eslint/visitor-keys": "8.53.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3650,14 +3641,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.52.0.tgz",
|
||||
"integrity": "sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw==",
|
||||
"version": "8.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.0.tgz",
|
||||
"integrity": "sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.52.0",
|
||||
"@typescript-eslint/types": "^8.52.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.53.0",
|
||||
"@typescript-eslint/types": "^8.53.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3672,14 +3663,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.52.0.tgz",
|
||||
"integrity": "sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA==",
|
||||
"version": "8.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.0.tgz",
|
||||
"integrity": "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.52.0",
|
||||
"@typescript-eslint/visitor-keys": "8.52.0"
|
||||
"@typescript-eslint/types": "8.53.0",
|
||||
"@typescript-eslint/visitor-keys": "8.53.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -3690,9 +3681,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.52.0.tgz",
|
||||
"integrity": "sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg==",
|
||||
"version": "8.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.0.tgz",
|
||||
"integrity": "sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3707,15 +3698,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.52.0.tgz",
|
||||
"integrity": "sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ==",
|
||||
"version": "8.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.0.tgz",
|
||||
"integrity": "sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.52.0",
|
||||
"@typescript-eslint/typescript-estree": "8.52.0",
|
||||
"@typescript-eslint/utils": "8.52.0",
|
||||
"@typescript-eslint/types": "8.53.0",
|
||||
"@typescript-eslint/typescript-estree": "8.53.0",
|
||||
"@typescript-eslint/utils": "8.53.0",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.4.0"
|
||||
},
|
||||
@@ -3732,9 +3723,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.52.0.tgz",
|
||||
"integrity": "sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg==",
|
||||
"version": "8.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.0.tgz",
|
||||
"integrity": "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3746,16 +3737,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.52.0.tgz",
|
||||
"integrity": "sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ==",
|
||||
"version": "8.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.0.tgz",
|
||||
"integrity": "sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.52.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.52.0",
|
||||
"@typescript-eslint/types": "8.52.0",
|
||||
"@typescript-eslint/visitor-keys": "8.52.0",
|
||||
"@typescript-eslint/project-service": "8.53.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.53.0",
|
||||
"@typescript-eslint/types": "8.53.0",
|
||||
"@typescript-eslint/visitor-keys": "8.53.0",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^9.0.5",
|
||||
"semver": "^7.7.3",
|
||||
@@ -3800,16 +3791,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.52.0.tgz",
|
||||
"integrity": "sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ==",
|
||||
"version": "8.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.0.tgz",
|
||||
"integrity": "sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.52.0",
|
||||
"@typescript-eslint/types": "8.52.0",
|
||||
"@typescript-eslint/typescript-estree": "8.52.0"
|
||||
"@typescript-eslint/scope-manager": "8.53.0",
|
||||
"@typescript-eslint/types": "8.53.0",
|
||||
"@typescript-eslint/typescript-estree": "8.53.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -3824,13 +3815,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.52.0.tgz",
|
||||
"integrity": "sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ==",
|
||||
"version": "8.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.0.tgz",
|
||||
"integrity": "sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.52.0",
|
||||
"@typescript-eslint/types": "8.53.0",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3911,9 +3902,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ai-motion": {
|
||||
"version": "0.4.7",
|
||||
"resolved": "https://registry.npmjs.org/ai-motion/-/ai-motion-0.4.7.tgz",
|
||||
"integrity": "sha512-yuFtVgWeaxKyvfSFocA01Mr4UbRvwwy7HWZ6Mq9NM/yccAkqp3UCGdWX6AflNB8cU27PgwTBiMRfx6C9di7xkQ==",
|
||||
"version": "0.4.8",
|
||||
"resolved": "https://registry.npmjs.org/ai-motion/-/ai-motion-0.4.8.tgz",
|
||||
"integrity": "sha512-9nQVjf8lrYQWSW0sZdb9bES4x8Ei1w9C0S62po2KvRbMjslmjm0WMeszyW771SEauDnu4YGWqurLZ/gCkCTosQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
@@ -4622,9 +4613,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz",
|
||||
"integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==",
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz",
|
||||
"integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
@@ -4860,20 +4851,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-dom": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-dom/-/eslint-plugin-react-dom-2.5.1.tgz",
|
||||
"integrity": "sha512-7x3k0bQwtTOSxjReH11buYL6aSMopBzSaWr2bU/LPOuy1dDxvqE6HC2Y6QPEyZjLJ1ppUSKwRmcX85uiF0jHAA==",
|
||||
"version": "2.5.7",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-dom/-/eslint-plugin-react-dom-2.5.7.tgz",
|
||||
"integrity": "sha512-KHCU7KRXeLYlrrXKtInpCqHzt+nYhIGpYIuMM4Q0FyGBOcswRS2Gv9PhN+yQqDI9PRBhnjCuRf+q2Y3ZrYWjsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-react/ast": "2.5.1",
|
||||
"@eslint-react/core": "2.5.1",
|
||||
"@eslint-react/eff": "2.5.1",
|
||||
"@eslint-react/shared": "2.5.1",
|
||||
"@eslint-react/var": "2.5.1",
|
||||
"@typescript-eslint/scope-manager": "^8.51.0",
|
||||
"@typescript-eslint/types": "^8.51.0",
|
||||
"@typescript-eslint/utils": "^8.51.0",
|
||||
"@eslint-react/ast": "2.5.7",
|
||||
"@eslint-react/core": "2.5.7",
|
||||
"@eslint-react/eff": "2.5.7",
|
||||
"@eslint-react/shared": "2.5.7",
|
||||
"@eslint-react/var": "2.5.7",
|
||||
"@typescript-eslint/scope-manager": "^8.53.0",
|
||||
"@typescript-eslint/types": "^8.53.0",
|
||||
"@typescript-eslint/utils": "^8.53.0",
|
||||
"compare-versions": "^6.1.1",
|
||||
"string-ts": "^2.3.1",
|
||||
"ts-pattern": "^5.9.0"
|
||||
@@ -4917,21 +4908,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-x": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-x/-/eslint-plugin-react-x-2.5.1.tgz",
|
||||
"integrity": "sha512-w3KtEG8M7sFRh/psUmep3H+i2O/5Nn9wI8GXjlqzwp8zLb4ax66Ue+1oecLTzY/0j/M1xRfB3hzk5oio54RwLA==",
|
||||
"version": "2.5.7",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-x/-/eslint-plugin-react-x-2.5.7.tgz",
|
||||
"integrity": "sha512-EU+h7eUHNg9ikLaGOa0Tjsc/o4FO2KdyT3M97TkQmaKc9tzpcy8nlPQp/czBMbUuxAgbj2495wzOvCZ0fEK4mw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-react/ast": "2.5.1",
|
||||
"@eslint-react/core": "2.5.1",
|
||||
"@eslint-react/eff": "2.5.1",
|
||||
"@eslint-react/shared": "2.5.1",
|
||||
"@eslint-react/var": "2.5.1",
|
||||
"@typescript-eslint/scope-manager": "^8.51.0",
|
||||
"@typescript-eslint/type-utils": "^8.51.0",
|
||||
"@typescript-eslint/types": "^8.51.0",
|
||||
"@typescript-eslint/utils": "^8.51.0",
|
||||
"@eslint-react/ast": "2.5.7",
|
||||
"@eslint-react/core": "2.5.7",
|
||||
"@eslint-react/eff": "2.5.7",
|
||||
"@eslint-react/shared": "2.5.7",
|
||||
"@eslint-react/var": "2.5.7",
|
||||
"@typescript-eslint/scope-manager": "^8.53.0",
|
||||
"@typescript-eslint/type-utils": "^8.53.0",
|
||||
"@typescript-eslint/types": "^8.53.0",
|
||||
"@typescript-eslint/utils": "^8.53.0",
|
||||
"compare-versions": "^6.1.1",
|
||||
"is-immutable-type": "^5.0.1",
|
||||
"string-ts": "^2.3.1",
|
||||
@@ -5310,13 +5301,13 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.24.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.24.0.tgz",
|
||||
"integrity": "sha512-ggTMRkIDPc76lHmM+dRT1MmVfFV6t/y+jkWjWuzR7FG5xRvnAAl/5wFPjzSkLE8Nu5E5uIQRCNxmIXtWJVo6XQ==",
|
||||
"version": "12.26.1",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.26.1.tgz",
|
||||
"integrity": "sha512-Uzc8wGldU4FpmGotthjjcj0SZhigcODjqvKT7lzVZHsmYkzQMFfMIv0vHQoXCeoe/Ahxqp4by4A6QbzFA/lblw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.24.0",
|
||||
"motion-utils": "^12.23.28",
|
||||
"motion-dom": "^12.24.11",
|
||||
"motion-utils": "^12.24.10",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -5457,9 +5448,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "16.5.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
|
||||
"integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==",
|
||||
"version": "17.0.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.0.0.tgz",
|
||||
"integrity": "sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -5543,9 +5534,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.7.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.3.tgz",
|
||||
"integrity": "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA==",
|
||||
"version": "25.7.4",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.4.tgz",
|
||||
"integrity": "sha512-hRkpEblXXcXSNbw8mBNq9042OEetgyB/ahc/X17uV/khPwzV+uB8RHceHh3qavyrkPJvmXFKXME2Sy1E0KjAfw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -6523,12 +6514,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/motion": {
|
||||
"version": "12.24.0",
|
||||
"resolved": "https://registry.npmjs.org/motion/-/motion-12.24.0.tgz",
|
||||
"integrity": "sha512-FAnpl/DhCFct3p+T2hCjAY95w+3EggUk3b8Ql4jQ6mmdRbEaGD1000goqWeEYVoD7mqx9H0As9ORmRADD4LQAw==",
|
||||
"version": "12.26.1",
|
||||
"resolved": "https://registry.npmjs.org/motion/-/motion-12.26.1.tgz",
|
||||
"integrity": "sha512-IVhzx9HOQTiJ9ykthMOlZPnLwrkXziN5Q/yebsqBYlFJb2rHP8yhmKc8O/YUT9byPJlxOeqkzfNYCrVKZx8vqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.24.0",
|
||||
"framer-motion": "^12.26.1",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -6549,18 +6540,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.24.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.24.0.tgz",
|
||||
"integrity": "sha512-RD2kZkFd/GH4fITI8IJvypGgn0vIu5vkrJaXIAkYqORGs5P0CKDHKNvswmoY1H+tbUAOPSh6VtUqoAmc/3Gvig==",
|
||||
"version": "12.24.11",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.24.11.tgz",
|
||||
"integrity": "sha512-DlWOmsXMJrV8lzZyd+LKjG2CXULUs++bkq8GZ2Sr0R0RRhs30K2wtY+LKiTjhmJU3W61HK+rB0GLz6XmPvTA1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.23.28"
|
||||
"motion-utils": "^12.24.10"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.23.28",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.28.tgz",
|
||||
"integrity": "sha512-0W6cWd5Okoyf8jmessVK3spOmbyE0yTdNKujHctHH9XdAE4QDuZ1/LjSXC68rrhsJU+TkzXURC5OdSWh9ibOwQ==",
|
||||
"version": "12.24.10",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.24.10.tgz",
|
||||
"integrity": "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
@@ -6935,9 +6926,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "16.5.1",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.1.tgz",
|
||||
"integrity": "sha512-Hks6UIRZWW4c+qDAnx1csVsCGYeIR4MoBGQgJ+NUoNnO6qLxXuf8zu0xdcinyXUORgGzCdRsexxO1Xzv3sTdnw==",
|
||||
"version": "16.5.2",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.2.tgz",
|
||||
"integrity": "sha512-GG/SBVxx9dvrO1uCs8VYdKfOP8NEBUhNP+2VDQLCifRJ8DL1qPq296k2ACNGyZMDe7iyIlz/LMJTQOs8HXSRvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7174,9 +7165,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/simple-icons": {
|
||||
"version": "16.4.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.4.0.tgz",
|
||||
"integrity": "sha512-8CKtCvx1Zq3L0CBsR4RR1MjGCXkXbzdspwl2yCxs8oWkstbzj2+DatRKDee/tuj3Ffd/2CDzwEky9RgG2yggew==",
|
||||
"version": "16.5.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.5.0.tgz",
|
||||
"integrity": "sha512-72nn0oHADKx6Hknu7q6M0vfL8LiCUMKABOHane2+4xdqaFBSHfNNBjuZioihiqVQMz7IvVle4NKAM0IlXvl/9A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -7577,16 +7568,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.52.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.52.0.tgz",
|
||||
"integrity": "sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA==",
|
||||
"version": "8.53.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.53.0.tgz",
|
||||
"integrity": "sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.52.0",
|
||||
"@typescript-eslint/parser": "8.52.0",
|
||||
"@typescript-eslint/typescript-estree": "8.52.0",
|
||||
"@typescript-eslint/utils": "8.52.0"
|
||||
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||
"@typescript-eslint/parser": "8.53.0",
|
||||
"@typescript-eslint/typescript-estree": "8.53.0",
|
||||
"@typescript-eslint/utils": "8.53.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -7772,9 +7763,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -8140,9 +8131,17 @@
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"packages/cdn": {
|
||||
"name": "@page-agent/cdn",
|
||||
"version": "0.2.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"page-agent": "0.2.3"
|
||||
}
|
||||
},
|
||||
"packages/llms": {
|
||||
"name": "@page-agent/llms",
|
||||
"version": "0.0.19",
|
||||
"version": "0.2.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
@@ -8150,32 +8149,32 @@
|
||||
}
|
||||
},
|
||||
"packages/page-agent": {
|
||||
"version": "0.0.19",
|
||||
"version": "0.2.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@page-agent/llms": "0.0.19",
|
||||
"@page-agent/page-controller": "0.0.19",
|
||||
"@page-agent/ui": "0.0.19",
|
||||
"@page-agent/llms": "0.2.3",
|
||||
"@page-agent/page-controller": "0.2.3",
|
||||
"@page-agent/ui": "0.2.3",
|
||||
"chalk": "^5.6.2",
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
},
|
||||
"packages/page-controller": {
|
||||
"name": "@page-agent/page-controller",
|
||||
"version": "0.0.19",
|
||||
"license": "MIT"
|
||||
"version": "0.2.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ai-motion": "^0.4.8"
|
||||
}
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@page-agent/ui",
|
||||
"version": "0.0.19",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ai-motion": "^0.4.7"
|
||||
}
|
||||
"version": "0.2.3",
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@page-agent/website",
|
||||
"version": "0.0.19",
|
||||
"version": "0.2.3",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
@@ -8185,23 +8184,23 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.24.0",
|
||||
"motion": "^12.26.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"rough-notation": "^0.5.1",
|
||||
"simple-icons": "^16.4.0",
|
||||
"simple-icons": "^16.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||
"i18next": "^25.7.3",
|
||||
"i18next": "^25.7.4",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-i18next": "^16.5.1",
|
||||
"react-i18next": "^16.5.2",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"wouter": "^3.9.0"
|
||||
|
||||
+15
-11
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"version": "0.0.19",
|
||||
"version": "0.2.3",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/page-controller",
|
||||
"packages/ui",
|
||||
"packages/llms",
|
||||
"packages/page-agent",
|
||||
"packages/cdn",
|
||||
"packages/website"
|
||||
],
|
||||
"description": "AI-powered UI agent for web applications",
|
||||
@@ -28,36 +29,39 @@
|
||||
"build:website": "npm run build:website --workspace=@page-agent/website",
|
||||
"build:libs": "npm run build --workspaces --if-present",
|
||||
"build": "npm run build:libs && npm run build:website",
|
||||
"dev:umd": "npm run dev:umd --workspace=page-agent",
|
||||
"dev:iife": "npm run dev:iife --workspace=page-agent",
|
||||
"version": "node scripts/sync-version.js",
|
||||
"lint": "eslint .",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.3.0",
|
||||
"@commitlint/config-conventional": "^20.3.0",
|
||||
"@commitlint/cli": "^20.3.1",
|
||||
"@commitlint/config-conventional": "^20.3.1",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@microsoft/api-extractor": "^7.55.2",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.1",
|
||||
"@types/node": "^25.0.3",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/node": "^25.0.7",
|
||||
"concurrently": "^9.2.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-dom": "^2.5.1",
|
||||
"eslint-plugin-react-dom": "^2.5.7",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"eslint-plugin-react-x": "^2.5.1",
|
||||
"globals": "^16.4.0",
|
||||
"eslint-plugin-react-x": "^2.5.7",
|
||||
"globals": "^17.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.4",
|
||||
"prettier": "^3.7.4",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.52.0",
|
||||
"typescript-eslint": "^8.53.0",
|
||||
"unplugin-dts": "^1.0.0-beta.6",
|
||||
"vite": "^7.3.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-css-injected-by-js": "^3.5.2"
|
||||
},
|
||||
"overrides": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,cjs,cts,mjs,mts}": [
|
||||
"npx prettier --write --ignore-unknown",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@page-agent/cdn",
|
||||
"private": false,
|
||||
"version": "0.2.3",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"description": "CDN builds for page-agent - IIFE bundles for script tag usage",
|
||||
"author": "Simon<gaomeng1900>",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/alibaba/page-agent.git"
|
||||
},
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"scripts": {
|
||||
"build": "vite build && vite build --mode demo"
|
||||
},
|
||||
"dependencies": {
|
||||
"page-agent": "0.2.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Demo CDN build for page-agent
|
||||
* Auto-initializes with built-in demo API for testing
|
||||
*
|
||||
* Usage:
|
||||
* <script src="https://unpkg.com/@page-agent/cdn/dist/page-agent.demo.js"></script>
|
||||
*/
|
||||
import { PageAgent } from 'page-agent'
|
||||
|
||||
// Clean up existing instances to prevent multiple injections from bookmarklet
|
||||
if (window.pageAgent) {
|
||||
window.pageAgent.dispose()
|
||||
}
|
||||
|
||||
// Mount to global window object
|
||||
window.PageAgent = PageAgent
|
||||
|
||||
// Export for IIFE
|
||||
export { PageAgent }
|
||||
|
||||
console.log('🚀 page-agent.js loaded!')
|
||||
|
||||
const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
const DEMO_BASE_URL = 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
|
||||
const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
|
||||
// Demo warning
|
||||
console.log(
|
||||
'%c⚠️ DEMO MODE %c\n' +
|
||||
'This build uses a shared testing LLM with rate limits.\n' +
|
||||
'For production, use page-agent.js with your own API key:\n' +
|
||||
'https://alibaba.github.io/page-agent/#/docs/features/models',
|
||||
'background: #f59e0b; color: #000; font-size: 14px; font-weight: bold; padding: 4px 8px; border-radius: 4px;',
|
||||
'color: #f59e0b; font-size: 12px;'
|
||||
)
|
||||
|
||||
// in case document.x is not ready yet
|
||||
setTimeout(() => {
|
||||
const currentScript = document.currentScript
|
||||
|
||||
if (currentScript) {
|
||||
console.log('🚀 page-agent.js detected current script:', currentScript.src)
|
||||
const url = new URL(currentScript.src)
|
||||
const model = url.searchParams.get('model') || DEMO_MODEL
|
||||
const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL
|
||||
const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY
|
||||
const language = url.searchParams.get('lang') || 'zh-CN'
|
||||
const config = { model, baseURL, apiKey, language }
|
||||
window.pageAgent = new PageAgent(config)
|
||||
} else {
|
||||
console.log('🚀 page-agent.js no current script detected, using default demo config')
|
||||
const config = {
|
||||
model: DEMO_MODEL,
|
||||
baseURL: DEMO_BASE_URL,
|
||||
apiKey: DEMO_API_KEY,
|
||||
}
|
||||
window.pageAgent = new PageAgent(config)
|
||||
}
|
||||
|
||||
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
|
||||
|
||||
window.pageAgent.panel.show()
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Full CDN build for page-agent
|
||||
* Exposes PageAgent class for manual instantiation and configuration
|
||||
*
|
||||
* Usage:
|
||||
* <script src="https://unpkg.com/@page-agent/cdn/dist/page-agent.js"></script>
|
||||
* <script>
|
||||
* const agent = new PageAgent({ model: 'gpt-4o', apiKey: 'your-key' })
|
||||
* agent.panel.show()
|
||||
* </script>
|
||||
*/
|
||||
import { PageAgent } from 'page-agent'
|
||||
|
||||
export { PageAgent }
|
||||
@@ -0,0 +1,49 @@
|
||||
// @ts-nocheck
|
||||
import { dirname, resolve } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig } from 'vite'
|
||||
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
/**
|
||||
* CDN IIFE builds for page-agent
|
||||
*
|
||||
* Build targets (via --mode):
|
||||
* - demo: page-agent.demo.js with auto-init and built-in API
|
||||
* - full (default): page-agent.js exposes PageAgent class only
|
||||
*/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const isDemo = mode === 'demo'
|
||||
const entry = isDemo ? resolve(__dirname, 'src/demo.js') : resolve(__dirname, 'src/full.js')
|
||||
const fileName = isDemo ? 'page-agent.demo' : 'page-agent'
|
||||
|
||||
return {
|
||||
plugins: [cssInjectedByJsPlugin({ relativeCSSInjection: true })],
|
||||
publicDir: false,
|
||||
esbuild: {
|
||||
keepNames: true,
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry,
|
||||
name: 'PageAgent',
|
||||
fileName: () => `${fileName}.js`,
|
||||
formats: ['iife'],
|
||||
},
|
||||
outDir: resolve(__dirname, 'dist'),
|
||||
emptyOutDir: !isDemo, // only empty on first build (full)
|
||||
minify: false,
|
||||
cssCodeSplit: true,
|
||||
rollupOptions: {
|
||||
onwarn: function (message, handler) {
|
||||
if (message.code === 'EVAL') return
|
||||
handler(message)
|
||||
},
|
||||
},
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
# @page-agent/llms
|
||||
|
||||
LLM client with a **reflection-before-action** mental model for page-agent.
|
||||
|
||||
## Why This Package Exists
|
||||
|
||||
The LLM module and the agent logic are inherently coupled. This package exists not to decouple them, but to **define the interface contract** between the LLM and the agent.
|
||||
|
||||
The core abstraction is the `MacroToolInput` — a structured output format that **forces the model to reflect before acting**.
|
||||
|
||||
## The Reflection-Before-Action Model
|
||||
|
||||
Every tool call must first output its reasoning state before the actual action:
|
||||
|
||||
```typescript
|
||||
interface MacroToolInput {
|
||||
// Reflection (mandatory before any action)
|
||||
evaluation_previous_goal?: string // How well did the previous action work?
|
||||
memory?: string // Key information to remember
|
||||
next_goal?: string // What to accomplish next
|
||||
|
||||
// Action (the actual operation)
|
||||
action: Record<string, any>
|
||||
}
|
||||
```
|
||||
|
||||
This design ensures that:
|
||||
|
||||
1. **The model evaluates its previous action** before deciding the next step
|
||||
2. **Working memory is explicitly maintained** across conversation turns
|
||||
3. **Goals are clearly stated**, making the agent's reasoning transparent and debuggable
|
||||
|
||||
## Key Components
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `LLM` | Main LLM client class with retry logic |
|
||||
| `MacroToolInput` | The reflection-before-action input schema |
|
||||
| `AgentBrain` | Agent's thinking state (eval, memory, goal) |
|
||||
| `LLMConfig` | Configuration for LLM connection |
|
||||
| `parseLLMConfig` | Parse and apply defaults to config |
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@page-agent/llms",
|
||||
"version": "0.0.19",
|
||||
"version": "0.2.3",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-agent-llms.js",
|
||||
"module": "./dist/lib/page-agent-llms.js",
|
||||
|
||||
@@ -1,60 +1,58 @@
|
||||
/**
|
||||
* OpenAI Client implementation
|
||||
* @note This client is only for demonstrating how to implement a LLM client.
|
||||
* @note Use OpenAILenientClient instead.
|
||||
*/
|
||||
import { InvokeError, InvokeErrorType } from './errors'
|
||||
import type { InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
|
||||
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
|
||||
import { modelPatch, zodToOpenAITool } from './utils'
|
||||
|
||||
/**
|
||||
* @deprecated Use OpenAILenientClient instead.
|
||||
* Client for OpenAI compatible APIs
|
||||
*/
|
||||
export class OpenAIClient implements LLMClient {
|
||||
config: LLMConfig
|
||||
config: Required<LLMConfig>
|
||||
private fetch: typeof globalThis.fetch
|
||||
|
||||
constructor(config: LLMConfig) {
|
||||
constructor(config: Required<LLMConfig>) {
|
||||
this.config = config
|
||||
this.fetch = config.customFetch
|
||||
}
|
||||
|
||||
async invoke(
|
||||
messages: Message[],
|
||||
tools: Record<string, Tool>,
|
||||
abortSignal?: AbortSignal
|
||||
abortSignal?: AbortSignal,
|
||||
options?: InvokeOptions
|
||||
): Promise<InvokeResult> {
|
||||
// 1. Convert tools to OpenAI format
|
||||
const openaiTools = Object.entries(tools).map(([name, tool]) => zodToOpenAITool(name, tool))
|
||||
const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t))
|
||||
|
||||
// Build request body
|
||||
const requestBody: Record<string, unknown> = {
|
||||
model: this.config.model,
|
||||
temperature: this.config.temperature,
|
||||
messages,
|
||||
tools: openaiTools,
|
||||
parallel_tool_calls: false,
|
||||
// Require tool call: specific tool if provided, otherwise any tool
|
||||
tool_choice: options?.toolChoiceName
|
||||
? { type: 'function', function: { name: options.toolChoiceName } }
|
||||
: 'required',
|
||||
}
|
||||
|
||||
// 2. Call API
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`${this.config.baseURL}/chat/completions`, {
|
||||
response = await this.fetch(`${this.config.baseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(
|
||||
modelPatch({
|
||||
model: this.config.model,
|
||||
temperature: this.config.temperature,
|
||||
messages,
|
||||
|
||||
tools: openaiTools,
|
||||
// tool_choice: 'required',
|
||||
tool_choice: { type: 'function', function: { name: 'AgentOutput' } },
|
||||
|
||||
// model specific params
|
||||
|
||||
// reasoning_effort: 'minimal',
|
||||
// verbosity: 'low',
|
||||
parallel_tool_calls: false,
|
||||
})
|
||||
),
|
||||
body: JSON.stringify(modelPatch(requestBody)),
|
||||
signal: abortSignal,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
// Network error
|
||||
console.error(error)
|
||||
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, 'Network request failed', error)
|
||||
}
|
||||
|
||||
@@ -92,77 +90,94 @@ export class OpenAIClient implements LLMClient {
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Parse and validate response
|
||||
const data = await response.json()
|
||||
|
||||
// 4. Check finish_reason
|
||||
const choice = data.choices?.[0]
|
||||
if (!choice) {
|
||||
throw new InvokeError(InvokeErrorType.UNKNOWN, 'No choices in response', data)
|
||||
}
|
||||
|
||||
// Check finish_reason
|
||||
switch (choice.finish_reason) {
|
||||
case 'tool_calls':
|
||||
// ✅ Normal
|
||||
case 'function_call': // gemini
|
||||
case 'stop': // some models use this even with tool calls
|
||||
break
|
||||
case 'length':
|
||||
// ⚠️ Token limit reached
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.CONTEXT_LENGTH,
|
||||
'Response truncated: max tokens reached',
|
||||
data
|
||||
'Response truncated: max tokens reached'
|
||||
)
|
||||
case 'content_filter':
|
||||
// ❌ Content filtered
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.CONTENT_FILTER,
|
||||
'Content filtered by safety system',
|
||||
data
|
||||
)
|
||||
case 'stop':
|
||||
// ❌ Did not call tool (we require tool call)
|
||||
throw new InvokeError(InvokeErrorType.NO_TOOL_CALL, 'Model did not call any tool', data)
|
||||
throw new InvokeError(InvokeErrorType.CONTENT_FILTER, 'Content filtered by safety system')
|
||||
default:
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.UNKNOWN,
|
||||
`Unexpected finish_reason: ${choice.finish_reason}`,
|
||||
data
|
||||
`Unexpected finish_reason: ${choice.finish_reason}`
|
||||
)
|
||||
}
|
||||
|
||||
// 5. Parse tool call
|
||||
const toolCall = choice.message?.tool_calls?.[0]
|
||||
if (!toolCall) {
|
||||
throw new InvokeError(InvokeErrorType.NO_TOOL_CALL, 'No tool call found in response', data)
|
||||
}
|
||||
// Apply normalizeResponse if provided (for fixing format issues automatically)
|
||||
const normalizedData = options?.normalizeResponse ? options.normalizeResponse(data) : data
|
||||
const normalizedChoice = (normalizedData as any).choices?.[0]
|
||||
|
||||
const toolName = toolCall.function.name
|
||||
const tool = tools[toolName]
|
||||
if (!tool) {
|
||||
throw new InvokeError(InvokeErrorType.UNKNOWN, `Tool ${toolName} not found`, data)
|
||||
}
|
||||
|
||||
// 6. Parse and validate arguments
|
||||
let toolArgs: unknown
|
||||
try {
|
||||
toolArgs = JSON.parse(toolCall.function.arguments)
|
||||
} catch (e) {
|
||||
throw new InvokeError(InvokeErrorType.INVALID_TOOL_ARGS, 'Invalid JSON in tool arguments', e)
|
||||
}
|
||||
|
||||
// Validate against zod schema
|
||||
const validation = tool.inputSchema.safeParse(toolArgs)
|
||||
if (!validation.success) {
|
||||
// Get tool name from response
|
||||
const toolCallName = normalizedChoice?.message?.tool_calls?.[0]?.function?.name
|
||||
if (!toolCallName) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
`Tool arguments validation failed: ${validation.error.message}`,
|
||||
validation.error
|
||||
InvokeErrorType.NO_TOOL_CALL,
|
||||
'No tool call found in response',
|
||||
normalizedData
|
||||
)
|
||||
}
|
||||
|
||||
// 7. Execute tool
|
||||
const tool = tools[toolCallName]
|
||||
if (!tool) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.UNKNOWN,
|
||||
`Tool "${toolCallName}" not found in tools`,
|
||||
normalizedData
|
||||
)
|
||||
}
|
||||
|
||||
// Extract and parse tool arguments
|
||||
const argString = normalizedChoice.message?.tool_calls?.[0]?.function?.arguments
|
||||
if (!argString) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
'No tool call arguments found',
|
||||
normalizedData
|
||||
)
|
||||
}
|
||||
|
||||
let parsedArgs: unknown
|
||||
try {
|
||||
parsedArgs = JSON.parse(argString)
|
||||
} catch (error) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
'Failed to parse tool arguments as JSON',
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
// Validate with schema
|
||||
const validation = tool.inputSchema.safeParse(parsedArgs)
|
||||
if (!validation.success) {
|
||||
console.error(validation.error)
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
'Tool arguments validation failed',
|
||||
validation.error
|
||||
)
|
||||
}
|
||||
const toolInput = validation.data
|
||||
|
||||
// 5. Execute tool
|
||||
let toolResult: unknown
|
||||
try {
|
||||
toolResult = await tool.execute(validation.data)
|
||||
toolResult = await tool.execute(toolInput)
|
||||
} catch (e) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.TOOL_EXECUTION_ERROR,
|
||||
@@ -171,12 +186,11 @@ export class OpenAIClient implements LLMClient {
|
||||
)
|
||||
}
|
||||
|
||||
// 8. Return result (including cache tokens)
|
||||
// Return result
|
||||
return {
|
||||
toolCall: {
|
||||
// id: toolCall.id,
|
||||
name: toolName,
|
||||
args: validation.data as Record<string, unknown>,
|
||||
name: toolCallName,
|
||||
args: toolInput,
|
||||
},
|
||||
toolResult,
|
||||
usage: {
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* OpenAI Client implementation
|
||||
*/
|
||||
import { InvokeError, InvokeErrorType } from './errors'
|
||||
import type { InvokeResult, LLMClient, LLMConfig, MacroToolInput, Message, Tool } from './types'
|
||||
import { lenientParseMacroToolCall, modelPatch, zodToOpenAITool } from './utils'
|
||||
|
||||
export class OpenAIClient implements LLMClient {
|
||||
config: Required<LLMConfig>
|
||||
private fetch: typeof globalThis.fetch
|
||||
|
||||
constructor(config: Required<LLMConfig>) {
|
||||
this.config = config
|
||||
this.fetch = config.customFetch
|
||||
}
|
||||
|
||||
async invoke(
|
||||
messages: Message[],
|
||||
tools: { AgentOutput: Tool<MacroToolInput> },
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<InvokeResult> {
|
||||
// 1. Convert tools to OpenAI format
|
||||
const openaiTools = Object.entries(tools).map(([name, tool]) => zodToOpenAITool(name, tool))
|
||||
|
||||
// 2. Call API
|
||||
let response: Response
|
||||
try {
|
||||
response = await this.fetch(`${this.config.baseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(
|
||||
modelPatch({
|
||||
model: this.config.model,
|
||||
temperature: this.config.temperature,
|
||||
messages,
|
||||
|
||||
tools: openaiTools,
|
||||
// tool_choice: 'required',
|
||||
tool_choice: { type: 'function', function: { name: 'AgentOutput' } },
|
||||
parallel_tool_calls: false,
|
||||
})
|
||||
),
|
||||
signal: abortSignal,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
// Network error
|
||||
console.error(error)
|
||||
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, 'Network request failed', error)
|
||||
}
|
||||
|
||||
// 3. Handle HTTP errors
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch()
|
||||
const errorMessage =
|
||||
(errorData as { error?: { message?: string } }).error?.message || response.statusText
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.AUTH_ERROR,
|
||||
`Authentication failed: ${errorMessage}`,
|
||||
errorData
|
||||
)
|
||||
}
|
||||
if (response.status === 429) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.RATE_LIMIT,
|
||||
`Rate limit exceeded: ${errorMessage}`,
|
||||
errorData
|
||||
)
|
||||
}
|
||||
if (response.status >= 500) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.SERVER_ERROR,
|
||||
`Server error: ${errorMessage}`,
|
||||
errorData
|
||||
)
|
||||
}
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.UNKNOWN,
|
||||
`HTTP ${response.status}: ${errorMessage}`,
|
||||
errorData
|
||||
)
|
||||
}
|
||||
|
||||
// parse response
|
||||
|
||||
const data = await response.json()
|
||||
const tool = tools.AgentOutput
|
||||
const macroToolInput = lenientParseMacroToolCall(data, tool.inputSchema as any)
|
||||
|
||||
// Execute tool
|
||||
let toolResult: unknown
|
||||
try {
|
||||
toolResult = await tool.execute(macroToolInput)
|
||||
} catch (e) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.TOOL_EXECUTION_ERROR,
|
||||
`Tool execution failed: ${(e as Error).message}`,
|
||||
e
|
||||
)
|
||||
}
|
||||
|
||||
// Return result (including cache tokens)
|
||||
return {
|
||||
toolCall: {
|
||||
// id: toolCall.id,
|
||||
name: 'AgentOutput',
|
||||
args: macroToolInput,
|
||||
},
|
||||
toolResult,
|
||||
usage: {
|
||||
promptTokens: data.usage?.prompt_tokens ?? 0,
|
||||
completionTokens: data.usage?.completion_tokens ?? 0,
|
||||
totalTokens: data.usage?.total_tokens ?? 0,
|
||||
cachedTokens: data.usage?.prompt_tokens_details?.cached_tokens,
|
||||
reasoningTokens: data.usage?.completion_tokens_details?.reasoning_tokens,
|
||||
},
|
||||
rawResponse: data,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,3 @@
|
||||
// Dev environment: use .env config if available, otherwise fallback to testing api
|
||||
export const DEFAULT_MODEL_NAME: string =
|
||||
import.meta.env.DEV && import.meta.env.LLM_MODEL_NAME
|
||||
? import.meta.env.LLM_MODEL_NAME
|
||||
: 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
|
||||
export const DEFAULT_API_KEY: string =
|
||||
import.meta.env.DEV && import.meta.env.LLM_API_KEY
|
||||
? import.meta.env.LLM_API_KEY
|
||||
: 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
|
||||
export const DEFAULT_BASE_URL: string =
|
||||
import.meta.env.DEV && import.meta.env.LLM_BASE_URL
|
||||
? import.meta.env.LLM_BASE_URL
|
||||
: 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
|
||||
|
||||
// internal
|
||||
|
||||
// Internal constants
|
||||
export const LLM_MAX_RETRIES = 2
|
||||
export const DEFAULT_TEMPERATURE = 0.7 // higher randomness helps auto-recovery
|
||||
|
||||
+18
-66
@@ -1,72 +1,23 @@
|
||||
/**
|
||||
* @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 { OpenAIClient } from './OpenAILenientClient'
|
||||
import {
|
||||
DEFAULT_API_KEY,
|
||||
DEFAULT_BASE_URL,
|
||||
DEFAULT_MODEL_NAME,
|
||||
DEFAULT_TEMPERATURE,
|
||||
LLM_MAX_RETRIES,
|
||||
} from './constants'
|
||||
import { OpenAIClient } from './OpenAIClient'
|
||||
import { DEFAULT_TEMPERATURE, LLM_MAX_RETRIES } from './constants'
|
||||
import { InvokeError } from './errors'
|
||||
import type {
|
||||
AgentBrain,
|
||||
InvokeResult,
|
||||
LLMClient,
|
||||
LLMConfig,
|
||||
MacroToolInput,
|
||||
MacroToolResult,
|
||||
Message,
|
||||
Tool,
|
||||
} from './types'
|
||||
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
|
||||
|
||||
export type {
|
||||
AgentBrain,
|
||||
InvokeResult,
|
||||
LLMClient,
|
||||
LLMConfig,
|
||||
MacroToolInput,
|
||||
MacroToolResult,
|
||||
Message,
|
||||
Tool,
|
||||
}
|
||||
export type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool }
|
||||
|
||||
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
|
||||
// Runtime validation as defensive programming (types already guarantee these)
|
||||
if (!config.baseURL || !config.apiKey || !config.model) {
|
||||
throw new Error(
|
||||
'[PageAgent] LLM configuration required. Please provide: baseURL, apiKey, model. ' +
|
||||
'See: https://alibaba.github.io/page-agent/#/docs/features/models'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
baseURL: config.baseURL ?? DEFAULT_BASE_URL,
|
||||
apiKey: config.apiKey ?? DEFAULT_API_KEY,
|
||||
model: config.model ?? DEFAULT_MODEL_NAME,
|
||||
baseURL: config.baseURL,
|
||||
apiKey: config.apiKey,
|
||||
model: config.model,
|
||||
temperature: config.temperature ?? DEFAULT_TEMPERATURE,
|
||||
maxRetries: config.maxRetries ?? LLM_MAX_RETRIES,
|
||||
customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound
|
||||
@@ -93,11 +44,12 @@ export class LLM extends EventTarget {
|
||||
async invoke(
|
||||
messages: Message[],
|
||||
tools: Record<string, Tool>,
|
||||
abortSignal: AbortSignal
|
||||
abortSignal: AbortSignal,
|
||||
options?: InvokeOptions
|
||||
): Promise<InvokeResult> {
|
||||
return await withRetry(
|
||||
async () => {
|
||||
const result = await this.client.invoke(messages, tools, abortSignal)
|
||||
const result = await this.client.invoke(messages, tools, abortSignal, options)
|
||||
|
||||
return result
|
||||
},
|
||||
|
||||
+23
-37
@@ -32,6 +32,24 @@ export interface Tool<TParams = any, TResult = any> {
|
||||
execute: (args: TParams) => Promise<TResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke options for LLM call
|
||||
*/
|
||||
export interface InvokeOptions {
|
||||
/**
|
||||
* Force LLM to call a specific tool by name.
|
||||
* If provided: tool_choice = { type: 'function', function: { name: toolChoiceName } }
|
||||
* If not provided: tool_choice = 'required' (must call some tool, but model chooses which)
|
||||
*/
|
||||
toolChoiceName?: string
|
||||
/**
|
||||
* Response normalization function.
|
||||
* Called before parsing the response.
|
||||
* Used to fix various response format errors from the model.
|
||||
*/
|
||||
normalizeResponse?: (response: any) => any
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM Client interface
|
||||
* Note: Does not use generics because each tool in the tools array has different types
|
||||
@@ -40,7 +58,8 @@ export interface LLMClient {
|
||||
invoke(
|
||||
messages: Message[],
|
||||
tools: Record<string, Tool>,
|
||||
abortSignal?: AbortSignal
|
||||
abortSignal?: AbortSignal,
|
||||
options?: InvokeOptions
|
||||
): Promise<InvokeResult>
|
||||
}
|
||||
|
||||
@@ -68,9 +87,9 @@ export interface InvokeResult<TResult = unknown> {
|
||||
* LLM configuration
|
||||
*/
|
||||
export interface LLMConfig {
|
||||
baseURL?: string
|
||||
apiKey?: string
|
||||
model?: string
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
|
||||
temperature?: number
|
||||
maxRetries?: number
|
||||
@@ -82,36 +101,3 @@ export interface LLMConfig {
|
||||
*/
|
||||
customFetch?: typeof globalThis.fetch
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent brain state - the reflection-before-action model
|
||||
*
|
||||
* Every tool call must first reflect on:
|
||||
* - evaluation_previous_goal: How well did the previous action achieve its goal?
|
||||
* - memory: Key information to remember for future steps
|
||||
* - next_goal: What should be accomplished in the next action?
|
||||
*/
|
||||
export interface AgentBrain {
|
||||
// thinking?: string
|
||||
evaluation_previous_goal: string
|
||||
memory: string
|
||||
next_goal: string
|
||||
}
|
||||
|
||||
/**
|
||||
* MacroTool input structure
|
||||
*
|
||||
* This is the core abstraction that enforces the "reflection-before-action" mental model.
|
||||
* Before executing any action, the LLM must output its reasoning state.
|
||||
*/
|
||||
export interface MacroToolInput extends AgentBrain {
|
||||
action: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* MacroTool output structure
|
||||
*/
|
||||
export interface MacroToolResult {
|
||||
input: MacroToolInput
|
||||
output: string
|
||||
}
|
||||
|
||||
+18
-175
@@ -4,8 +4,7 @@
|
||||
import chalk from 'chalk'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { InvokeError, InvokeErrorType } from './errors'
|
||||
import type { MacroToolInput, Tool } from './types'
|
||||
import type { Tool } from './types'
|
||||
|
||||
function debug(message: string) {
|
||||
console.debug(chalk.gray('[LLM]'), message)
|
||||
@@ -26,176 +25,6 @@ export function zodToOpenAITool(name: string, tool: Tool) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Although some models cannot guarantee correct response. Common issues are fixable:
|
||||
* - Instead of returning a proper tool call. Return the tool call parameters in the message content.
|
||||
* - Returned tool calls or messages don't follow the nested MacroToolInput format.
|
||||
*/
|
||||
export function lenientParseMacroToolCall(
|
||||
responseData: any,
|
||||
inputSchema: z.ZodObject<MacroToolInput & Record<string, any>>
|
||||
): MacroToolInput {
|
||||
// check
|
||||
const choice = responseData.choices?.[0]
|
||||
if (!choice) {
|
||||
throw new InvokeError(InvokeErrorType.UNKNOWN, 'No choices in response', responseData)
|
||||
}
|
||||
|
||||
// check
|
||||
switch (choice.finish_reason) {
|
||||
case 'tool_calls':
|
||||
case 'function_call': // gemini
|
||||
case 'stop': // will try a robust parse
|
||||
// ✅ Normal
|
||||
break
|
||||
case 'length':
|
||||
// ⚠️ Token limit reached
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.CONTEXT_LENGTH,
|
||||
'Response truncated: max tokens reached'
|
||||
)
|
||||
case 'content_filter':
|
||||
// ❌ Content filtered
|
||||
throw new InvokeError(InvokeErrorType.CONTENT_FILTER, 'Content filtered by safety system')
|
||||
default:
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.UNKNOWN,
|
||||
`Unexpected finish_reason: ${choice.finish_reason}`
|
||||
)
|
||||
}
|
||||
|
||||
// Extract action schema from MacroToolInput schema
|
||||
const actionSchema = inputSchema.shape.action
|
||||
if (!actionSchema) {
|
||||
throw new Error('inputSchema must have an "action" field')
|
||||
}
|
||||
|
||||
// patch stopReason mis-format
|
||||
|
||||
let arg: string | null = null
|
||||
|
||||
// try to use tool call
|
||||
const toolCall = choice.message?.tool_calls?.[0]?.function
|
||||
arg = toolCall?.arguments ?? null
|
||||
|
||||
if (arg && toolCall.name !== 'AgentOutput') {
|
||||
// TODO: check if toolCall.name is a valid action name
|
||||
// case: instead of AgentOutput, the model returned a action name as tool call
|
||||
console.log(chalk.yellow('lenientParseMacroToolCall: #1 fixing incorrect tool call'))
|
||||
let tmpArg
|
||||
try {
|
||||
tmpArg = JSON.parse(arg)
|
||||
} catch (error) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
'Failed to parse tool arguments as JSON',
|
||||
error
|
||||
)
|
||||
}
|
||||
arg = JSON.stringify({ action: { [toolCall.name]: tmpArg } })
|
||||
}
|
||||
|
||||
if (!arg) {
|
||||
// try to use message content as JSON
|
||||
arg = choice.message?.content.trim() || null
|
||||
}
|
||||
|
||||
if (!arg) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.NO_TOOL_CALL,
|
||||
'No tool call or content found in response',
|
||||
responseData
|
||||
)
|
||||
}
|
||||
|
||||
// make sure is valid JSON
|
||||
|
||||
let parsedArgs: any
|
||||
try {
|
||||
parsedArgs = JSON.parse(arg)
|
||||
} catch (error) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
'Failed to parse tool arguments as JSON',
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
// patch incomplete formats
|
||||
|
||||
if (parsedArgs.action || parsedArgs.evaluation_previous_goal || parsedArgs.next_goal) {
|
||||
// case: nested MacroToolInput format (correct format)
|
||||
|
||||
// some models may give a empty action (they may think reasoning and action should be separate)
|
||||
if (!parsedArgs.action) {
|
||||
console.log(chalk.yellow('lenientParseMacroToolCall: #2 fixing incorrect tool call'))
|
||||
parsedArgs.action = {
|
||||
wait: { seconds: 1 },
|
||||
}
|
||||
}
|
||||
} else if (parsedArgs.type && parsedArgs.function) {
|
||||
// case: upper level function call format provided. only keep its arguments
|
||||
// TODO: check if function name is a valid action name
|
||||
if (parsedArgs.function.name !== 'AgentOutput')
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
`Expected function name "AgentOutput", got "${parsedArgs.function.name}"`,
|
||||
null
|
||||
)
|
||||
|
||||
console.log(chalk.yellow('lenientParseMacroToolCall: #3 fixing incorrect tool call'))
|
||||
parsedArgs = parsedArgs.function.arguments
|
||||
} else if (parsedArgs.name && parsedArgs.arguments) {
|
||||
// case: upper level function call format provided. only keep its arguments
|
||||
// TODO: check if function name is a valid action name
|
||||
if (parsedArgs.name !== 'AgentOutput')
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
`Expected function name "AgentOutput", got "${parsedArgs.name}"`,
|
||||
null
|
||||
)
|
||||
|
||||
console.log(chalk.yellow('lenientParseMacroToolCall: #4 fixing incorrect tool call'))
|
||||
parsedArgs = parsedArgs.arguments
|
||||
} else {
|
||||
// case: only action parameters provided, wrap into MacroToolInput
|
||||
// TODO: check if action name is valid
|
||||
console.log(chalk.yellow('lenientParseMacroToolCall: #5 fixing incorrect tool call'))
|
||||
parsedArgs = { action: parsedArgs } as MacroToolInput
|
||||
}
|
||||
|
||||
// make sure it's not wrapped as string
|
||||
if (typeof parsedArgs === 'string') {
|
||||
console.log(chalk.yellow('lenientParseMacroToolCall: #6 fixing incorrect tool call'))
|
||||
try {
|
||||
parsedArgs = JSON.parse(parsedArgs)
|
||||
} catch (error) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
'Failed to parse nested tool arguments as JSON',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const validation = inputSchema.safeParse(parsedArgs)
|
||||
if (validation.success) {
|
||||
return validation.data as unknown as MacroToolInput
|
||||
} else {
|
||||
const action = parsedArgs.action ?? {}
|
||||
const actionName = Object.keys(action)[0] || 'unknown'
|
||||
const actionArgs = JSON.stringify(action[actionName] || 'unknown')
|
||||
|
||||
// TODO: check if action name is valid. give a readable error message
|
||||
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
`Tool arguments validation failed: action "${actionName}" with args ${actionArgs}`,
|
||||
validation.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch model specific parameters
|
||||
*/
|
||||
@@ -205,11 +34,25 @@ export function modelPatch(body: Record<string, any>) {
|
||||
|
||||
const modelName = normalizeModelName(model)
|
||||
|
||||
if (modelName.startsWith('qwen')) {
|
||||
debug('Applying Qwen patch: use higher temperature for auto fixing')
|
||||
body.temperature = Math.max(body.temperature || 0, 1.0)
|
||||
}
|
||||
|
||||
if (modelName.startsWith('claude')) {
|
||||
debug('Applying Claude patch: change tool_choice and disable thinking')
|
||||
body.tool_choice = { type: 'tool', name: 'AgentOutput' }
|
||||
debug('Applying Claude patch: disable thinking')
|
||||
body.thinking = { type: 'disabled' }
|
||||
// body.reasoning = { enabled: 'disabled' }
|
||||
|
||||
// Convert tool_choice to Claude format
|
||||
if (body.tool_choice === 'required') {
|
||||
// 'required' -> { type: 'any' } (must call some tool)
|
||||
debug('Applying Claude patch: convert tool_choice "required" to { type: "any" }')
|
||||
body.tool_choice = { type: 'any' }
|
||||
} else if (body.tool_choice?.function?.name) {
|
||||
// { type: 'function', function: { name: '...' } } -> { type: 'tool', name: '...' }
|
||||
debug('Applying Claude patch: convert tool_choice format')
|
||||
body.tool_choice = { type: 'tool', name: body.tool_choice.function.name }
|
||||
}
|
||||
}
|
||||
|
||||
if (modelName.startsWith('grok')) {
|
||||
|
||||
@@ -34,4 +34,3 @@ export default defineConfig({
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "page-agent",
|
||||
"private": false,
|
||||
"version": "0.0.19",
|
||||
"version": "0.2.3",
|
||||
"type": "module",
|
||||
"main": "./dist/esm/page-agent.js",
|
||||
"module": "./dist/esm/page-agent.js",
|
||||
@@ -37,17 +37,16 @@
|
||||
},
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"scripts": {
|
||||
"build": "vite build && vite build --config vite.umd.config.js",
|
||||
"serve": "npx serve dist/umd -p 5173",
|
||||
"dev:umd": "concurrently \"vite build --config vite.umd.config.js --watch\" \"npm run serve\"",
|
||||
"build": "vite build",
|
||||
"dev:iife": "concurrently \"vite build --config vite.iife.config.js --watch\" \"npx serve dist/iife -p 5174\"",
|
||||
"prepublishOnly": "node -e \"const fs=require('fs');['README.md','LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
||||
"postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"zod": "^4.3.5",
|
||||
"@page-agent/llms": "0.0.19",
|
||||
"@page-agent/page-controller": "0.0.19",
|
||||
"@page-agent/ui": "0.0.19"
|
||||
"@page-agent/llms": "0.2.3",
|
||||
"@page-agent/page-controller": "0.2.3",
|
||||
"@page-agent/ui": "0.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,9 @@
|
||||
* Copyright (C) 2025 Alibaba Group Holding Limited
|
||||
* All rights reserved.
|
||||
*/
|
||||
import {
|
||||
type AgentBrain,
|
||||
LLM,
|
||||
type MacroToolInput,
|
||||
type MacroToolResult,
|
||||
type Tool,
|
||||
} from '@page-agent/llms'
|
||||
import { LLM, type Tool } from '@page-agent/llms'
|
||||
import { PageController } from '@page-agent/page-controller'
|
||||
import { Panel, SimulatorMask } from '@page-agent/ui'
|
||||
import { Panel } from '@page-agent/ui'
|
||||
import chalk from 'chalk'
|
||||
import zod from 'zod'
|
||||
|
||||
@@ -18,15 +12,50 @@ import type { PageAgentConfig } from './config'
|
||||
import { MAX_STEPS } from './config/constants'
|
||||
import SYSTEM_PROMPT from './prompts/system_prompt.md?raw'
|
||||
import { tools } from './tools'
|
||||
import { trimLines, uid, waitUntil } from './utils'
|
||||
import { normalizeResponse, trimLines, uid } from './utils'
|
||||
import { assert } from './utils/assert'
|
||||
|
||||
/**
|
||||
* Agent reflection state - the reflection-before-action model
|
||||
*
|
||||
* Every tool call must first reflect on:
|
||||
* - evaluation_previous_goal: How well did the previous action achieve its goal?
|
||||
* - memory: Key information to remember for future steps
|
||||
* - next_goal: What should be accomplished in the next action?
|
||||
*/
|
||||
export interface AgentReflection {
|
||||
evaluation_previous_goal: string
|
||||
memory: string
|
||||
next_goal: string
|
||||
}
|
||||
|
||||
/**
|
||||
* MacroTool input structure
|
||||
*
|
||||
* This is the core abstraction that enforces the "reflection-before-action" mental model.
|
||||
* Before executing any action, the LLM must output its reasoning state.
|
||||
*/
|
||||
export interface MacroToolInput extends Partial<AgentReflection> {
|
||||
action: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* MacroTool output structure
|
||||
*/
|
||||
export interface MacroToolResult {
|
||||
input: MacroToolInput
|
||||
output: string
|
||||
}
|
||||
|
||||
export type { PageAgentConfig }
|
||||
export { tool, type PageAgentTool } from './tools'
|
||||
export type { AgentBrain, MacroToolInput, MacroToolResult }
|
||||
|
||||
export interface AgentHistory {
|
||||
brain: AgentBrain
|
||||
/**
|
||||
* A single agent step with reflection and action
|
||||
*/
|
||||
export interface AgentStep {
|
||||
type: 'step'
|
||||
reflection: Partial<AgentReflection>
|
||||
action: {
|
||||
name: string
|
||||
input: any
|
||||
@@ -41,10 +70,30 @@ export interface AgentHistory {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent observation event (stays in memory)
|
||||
*/
|
||||
export interface ObservationEvent {
|
||||
type: 'observation'
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* User takeover event
|
||||
*/
|
||||
export interface UserTakeoverEvent {
|
||||
type: 'user_takeover'
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all history events
|
||||
*/
|
||||
export type HistoryEvent = AgentStep | ObservationEvent | UserTakeoverEvent
|
||||
|
||||
export interface ExecutionResult {
|
||||
success: boolean
|
||||
data: string
|
||||
history: AgentHistory[]
|
||||
history: HistoryEvent[]
|
||||
}
|
||||
|
||||
export class PageAgent extends EventTarget {
|
||||
@@ -52,13 +101,11 @@ export class PageAgent extends EventTarget {
|
||||
id = uid()
|
||||
panel: Panel
|
||||
tools: typeof tools
|
||||
paused = false
|
||||
disposed = false
|
||||
task = ''
|
||||
taskId = ''
|
||||
|
||||
#llm: LLM
|
||||
#totalWaitTime = 0
|
||||
#abortController = new AbortController()
|
||||
#llmRetryListener: ((e: Event) => void) | null = null
|
||||
#llmErrorListener: ((e: Event) => void) | null = null
|
||||
@@ -67,12 +114,18 @@ export class PageAgent extends EventTarget {
|
||||
/** PageController for DOM operations */
|
||||
pageController: PageController
|
||||
|
||||
/** Fullscreen mask */
|
||||
mask = new SimulatorMask()
|
||||
/** History records */
|
||||
history: AgentHistory[] = []
|
||||
/** Runtime states for tracking across steps */
|
||||
states = {
|
||||
/** Accumulated wait time in seconds, used by wait tool */
|
||||
totalWaitTime: 0,
|
||||
/** Last known URL for detecting navigation */
|
||||
lastURL: '',
|
||||
}
|
||||
|
||||
constructor(config: PageAgentConfig = {}) {
|
||||
/** History event stream */
|
||||
history: HistoryEvent[] = []
|
||||
|
||||
constructor(config: PageAgentConfig) {
|
||||
super()
|
||||
|
||||
this.config = config
|
||||
@@ -81,16 +134,14 @@ export class PageAgent extends EventTarget {
|
||||
language: this.config.language,
|
||||
onExecuteTask: (task) => this.execute(task),
|
||||
onStop: () => this.dispose(),
|
||||
onPauseToggle: () => {
|
||||
this.paused = !this.paused
|
||||
return this.paused
|
||||
},
|
||||
getPaused: () => this.paused,
|
||||
})
|
||||
this.tools = new Map(tools)
|
||||
|
||||
// Initialize PageController with config
|
||||
this.pageController = new PageController(this.config)
|
||||
// Initialize PageController with config (mask enabled by default)
|
||||
this.pageController = new PageController({
|
||||
...this.config,
|
||||
enableMask: this.config.enableMask ?? true,
|
||||
})
|
||||
|
||||
// Listen to LLM events
|
||||
this.#llmRetryListener = (e) => {
|
||||
@@ -125,8 +176,14 @@ export class PageAgent extends EventTarget {
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo maybe return something?
|
||||
* Push a persistent observation to the history event stream.
|
||||
* This will be visible in <agent_history> and remain in memory across steps.
|
||||
*/
|
||||
pushObservation(content: string): void {
|
||||
this.history.push({ type: 'observation', content })
|
||||
this.panel.update({ type: 'observation', content })
|
||||
}
|
||||
|
||||
async execute(task: string): Promise<ExecutionResult> {
|
||||
if (!task) throw new Error('Task is required')
|
||||
this.task = task
|
||||
@@ -140,7 +197,7 @@ export class PageAgent extends EventTarget {
|
||||
await onBeforeTask.call(this)
|
||||
|
||||
// Show mask and panel
|
||||
this.mask.show()
|
||||
this.pageController.showMask()
|
||||
|
||||
this.panel.show()
|
||||
this.panel.reset()
|
||||
@@ -154,18 +211,24 @@ export class PageAgent extends EventTarget {
|
||||
|
||||
this.history = []
|
||||
|
||||
// Reset states
|
||||
this.states = {
|
||||
totalWaitTime: 0,
|
||||
lastURL: '',
|
||||
}
|
||||
|
||||
try {
|
||||
let step = 0
|
||||
|
||||
while (true) {
|
||||
await this.#generateObservations(step)
|
||||
|
||||
await onBeforeStep.call(this, step)
|
||||
|
||||
console.group(`step: ${step}`)
|
||||
|
||||
// abort
|
||||
if (this.#abortController.signal.aborted) throw new Error('AbortError')
|
||||
// pause
|
||||
await waitUntil(() => !this.paused)
|
||||
|
||||
// Update status to thinking
|
||||
console.log(chalk.blue('Thinking...'))
|
||||
@@ -183,29 +246,34 @@ export class PageAgent extends EventTarget {
|
||||
},
|
||||
],
|
||||
{ AgentOutput: this.#packMacroTool() },
|
||||
this.#abortController.signal
|
||||
this.#abortController.signal,
|
||||
{
|
||||
toolChoiceName: 'AgentOutput',
|
||||
normalizeResponse,
|
||||
}
|
||||
)
|
||||
|
||||
const macroResult = result.toolResult as MacroToolResult
|
||||
const input = macroResult.input
|
||||
const output = macroResult.output
|
||||
const brain = {
|
||||
evaluation_previous_goal: input.evaluation_previous_goal || '',
|
||||
memory: input.memory || '',
|
||||
next_goal: input.next_goal || '',
|
||||
const reflection: Partial<AgentReflection> = {
|
||||
evaluation_previous_goal: input.evaluation_previous_goal,
|
||||
memory: input.memory,
|
||||
next_goal: input.next_goal,
|
||||
}
|
||||
const actionName = Object.keys(input.action)[0]
|
||||
const action = {
|
||||
const action: AgentStep['action'] = {
|
||||
name: actionName,
|
||||
input: input.action[actionName],
|
||||
output: output,
|
||||
}
|
||||
|
||||
this.history.push({
|
||||
brain,
|
||||
type: 'step',
|
||||
reflection,
|
||||
action,
|
||||
usage: result.usage,
|
||||
})
|
||||
} as AgentStep)
|
||||
|
||||
console.log(chalk.green('Step finished:'), actionName)
|
||||
console.groupEnd()
|
||||
@@ -279,25 +347,31 @@ export class PageAgent extends EventTarget {
|
||||
})
|
||||
|
||||
return {
|
||||
description: 'You MUST call this tool every step. Outputs your reflections and next action.',
|
||||
inputSchema: macroToolSchema as zod.ZodType<MacroToolInput>,
|
||||
execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
|
||||
// abort
|
||||
if (this.#abortController.signal.aborted) throw new Error('AbortError')
|
||||
// pause
|
||||
await waitUntil(() => !this.paused)
|
||||
|
||||
console.log(chalk.blue.bold('MacroTool execute'), input)
|
||||
const action = input.action
|
||||
|
||||
const toolName = Object.keys(action)[0]
|
||||
const toolInput = action[toolName]
|
||||
const brain = trimLines(`✅: ${input.evaluation_previous_goal}
|
||||
💾: ${input.memory}
|
||||
🎯: ${input.next_goal}
|
||||
`)
|
||||
|
||||
console.log(brain)
|
||||
this.panel.update({ type: 'thinking', text: brain })
|
||||
// Build reflection text, only include non-empty fields
|
||||
const reflectionLines: string[] = []
|
||||
if (input.evaluation_previous_goal)
|
||||
reflectionLines.push(`✅: ${input.evaluation_previous_goal}`)
|
||||
if (input.memory) reflectionLines.push(`💾: ${input.memory}`)
|
||||
if (input.next_goal) reflectionLines.push(`🎯: ${input.next_goal}`)
|
||||
|
||||
const reflectionText = reflectionLines.length > 0 ? reflectionLines.join('\n') : ''
|
||||
|
||||
if (reflectionText) {
|
||||
console.log(reflectionText)
|
||||
this.panel.update({ type: 'thinking', text: reflectionText })
|
||||
}
|
||||
|
||||
// Find the corresponding tool
|
||||
const tool = tools.get(toolName)
|
||||
@@ -309,20 +383,14 @@ export class PageAgent extends EventTarget {
|
||||
const startTime = Date.now()
|
||||
|
||||
// Execute tool, bind `this` to PageAgent
|
||||
let result = await tool.execute.bind(this)(toolInput)
|
||||
const result = await tool.execute.bind(this)(toolInput)
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result)
|
||||
|
||||
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
|
||||
// Reset wait time for non-wait tools
|
||||
if (toolName !== 'wait') {
|
||||
this.states.totalWaitTime = 0
|
||||
}
|
||||
|
||||
// Briefly display execution result
|
||||
@@ -399,6 +467,34 @@ export class PageAgent extends EventTarget {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate observations before each step
|
||||
* - URL change detection
|
||||
* - Too many steps warning
|
||||
* @todo loop detection
|
||||
* @todo console error
|
||||
*/
|
||||
async #generateObservations(stepCount: number): Promise<void> {
|
||||
// Detect URL change
|
||||
const currentURL = await this.pageController.getCurrentUrl()
|
||||
if (currentURL !== this.states.lastURL) {
|
||||
this.pushObservation(`Page navigated to → ${currentURL}`)
|
||||
this.states.lastURL = currentURL
|
||||
}
|
||||
|
||||
// Warn about remaining steps
|
||||
const remaining = MAX_STEPS - stepCount
|
||||
if (remaining === 5) {
|
||||
this.pushObservation(
|
||||
`⚠️ Only ${remaining} steps remaining. Consider wrapping up or calling done with partial results.`
|
||||
)
|
||||
} else if (remaining === 2) {
|
||||
this.pushObservation(
|
||||
`⚠️ Critical: Only ${remaining} steps left! You must finish the task or call done immediately.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async #assembleUserPrompt(): Promise<string> {
|
||||
let prompt = ''
|
||||
|
||||
@@ -410,31 +506,42 @@ export class PageAgent extends EventTarget {
|
||||
// - <step_info>
|
||||
// <agent_state>
|
||||
|
||||
const stepCount = this.history.filter((e) => e.type === 'step').length
|
||||
|
||||
prompt += `<agent_state>
|
||||
<user_request>
|
||||
${this.task}
|
||||
</user_request>
|
||||
<step_info>
|
||||
Step ${this.history.length + 1} of ${MAX_STEPS} max possible steps
|
||||
Step ${stepCount + 1} of ${MAX_STEPS} max possible steps
|
||||
Current date and time: ${new Date().toISOString()}
|
||||
</step_info>
|
||||
</agent_state>
|
||||
`
|
||||
|
||||
// <agent_history>
|
||||
// - <step_>
|
||||
// - <step_N> for steps
|
||||
// - <sys> for observations and system messages
|
||||
|
||||
prompt += '\n<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}>
|
||||
let stepIndex = 0
|
||||
for (const event of this.history) {
|
||||
if (event.type === 'step') {
|
||||
stepIndex++
|
||||
prompt += `<step_${stepIndex}>
|
||||
Evaluation of Previous Step: ${event.reflection.evaluation_previous_goal}
|
||||
Memory: ${event.reflection.memory}
|
||||
Next Goal: ${event.reflection.next_goal}
|
||||
Action Results: ${event.action.output}
|
||||
</step_${stepIndex}>
|
||||
`
|
||||
})
|
||||
} else if (event.type === 'observation') {
|
||||
prompt += `<sys>${event.content}</sys>\n`
|
||||
} else if (event.type === 'user_takeover') {
|
||||
prompt += `<sys>User took over control and made changes to the page.</sys>\n`
|
||||
}
|
||||
}
|
||||
|
||||
prompt += '</agent_history>\n\n'
|
||||
|
||||
@@ -458,55 +565,28 @@ export class PageAgent extends EventTarget {
|
||||
// Task completed
|
||||
this.panel.update({ type: 'completed' })
|
||||
|
||||
this.mask.hide()
|
||||
this.pageController.hideMask()
|
||||
|
||||
this.#abortController.abort()
|
||||
}
|
||||
|
||||
async #getBrowserState(): Promise<string> {
|
||||
const pageUrl = await this.pageController.getCurrentUrl()
|
||||
const pageTitle = await this.pageController.getPageTitle()
|
||||
const pi = await this.pageController.getPageInfo()
|
||||
const viewportExpansion = await this.pageController.getViewportExpansion()
|
||||
const state = await this.pageController.getBrowserState()
|
||||
|
||||
this.mask.wrapper.style.pointerEvents = 'none'
|
||||
await this.pageController.updateTree()
|
||||
this.mask.wrapper.style.pointerEvents = 'auto'
|
||||
let content = state.content
|
||||
if (this.config.transformPageContent) {
|
||||
content = await this.config.transformPageContent(content)
|
||||
}
|
||||
|
||||
const simplifiedHTML = await this.pageController.getSimplifiedHTML()
|
||||
return trimLines(`<browser_state>
|
||||
Current Page: [${state.title}](${state.url})
|
||||
|
||||
let prompt = trimLines(`<browser_state>
|
||||
Current Page: [${pageTitle}](${pageUrl})
|
||||
|
||||
Page info: ${pi.viewport_width}x${pi.viewport_height}px viewport, ${pi.page_width}x${pi.page_height}px total page size, ${pi.pages_above.toFixed(1)} pages above, ${pi.pages_below.toFixed(1)} pages below, ${pi.total_pages.toFixed(1)} total pages, at ${(pi.current_page_position * 100).toFixed(0)}% of page
|
||||
|
||||
${viewportExpansion === -1 ? 'Interactive elements from top layer of the current page (full page):' : 'Interactive elements from top layer of the current page inside the viewport:'}
|
||||
${state.header}
|
||||
${content}
|
||||
${state.footer}
|
||||
|
||||
</browser_state>
|
||||
`)
|
||||
|
||||
// Page header info
|
||||
const has_content_above = pi.pixels_above > 4
|
||||
if (has_content_above && viewportExpansion !== -1) {
|
||||
prompt += `... ${pi.pixels_above} pixels above (${pi.pages_above.toFixed(1)} pages) - scroll to see more ...\n`
|
||||
} else {
|
||||
prompt += `[Start of page]\n`
|
||||
}
|
||||
|
||||
// Current viewport info
|
||||
prompt += simplifiedHTML
|
||||
prompt += `\n`
|
||||
|
||||
// Page footer info
|
||||
const has_content_below = pi.pixels_below > 4
|
||||
if (has_content_below && viewportExpansion !== -1) {
|
||||
prompt += `... ${pi.pixels_below} pixels below (${pi.pages_below.toFixed(1)} pages) - scroll to see more ...\n`
|
||||
} else {
|
||||
prompt += `[End of page]\n`
|
||||
}
|
||||
|
||||
prompt += `</browser_state>\n`
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
dispose(reason?: string) {
|
||||
@@ -514,7 +594,6 @@ export class PageAgent extends EventTarget {
|
||||
this.disposed = true
|
||||
this.pageController.dispose()
|
||||
this.panel.dispose()
|
||||
this.mask.dispose()
|
||||
this.history = []
|
||||
this.#abortController.abort(reason ?? 'PageAgent disposed')
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { LLMConfig } from '@page-agent/llms'
|
||||
import type { PageControllerConfig } from '@page-agent/page-controller'
|
||||
import type { SupportedLanguage } from '@page-agent/ui'
|
||||
|
||||
import type { AgentHistory, ExecutionResult, PageAgent } from '../PageAgent'
|
||||
import type { ExecutionResult, HistoryEvent, PageAgent } from '../PageAgent'
|
||||
import type { PageAgentTool } from '../tools'
|
||||
|
||||
export type { LLMConfig }
|
||||
@@ -62,15 +62,17 @@ export interface AgentConfig {
|
||||
|
||||
// lifecycle hooks
|
||||
// @todo: use event instead of hooks
|
||||
// @todo: remove `this` binding, pass agent as explicit parameter instead
|
||||
|
||||
onBeforeStep?: (this: PageAgent, stepCnt: number) => Promise<void> | void
|
||||
onAfterStep?: (this: PageAgent, stepCnt: number, history: AgentHistory[]) => Promise<void> | void
|
||||
onAfterStep?: (this: PageAgent, stepCnt: number, history: HistoryEvent[]) => Promise<void> | void
|
||||
onBeforeTask?: (this: PageAgent) => Promise<void> | void
|
||||
onAfterTask?: (this: PageAgent, result: ExecutionResult) => Promise<void> | void
|
||||
|
||||
/**
|
||||
* @note this hook can block the disposal process
|
||||
* @note when dispose caused by page unload, reason will be 'PAGE_UNLOADING'. this method CANNOT block unloading. async operations may be cut.
|
||||
* @todo remove `this` binding, pass agent as explicit parameter instead
|
||||
*/
|
||||
onDispose?: (this: PageAgent, reason?: string) => void
|
||||
|
||||
@@ -84,19 +86,36 @@ export interface AgentConfig {
|
||||
*/
|
||||
experimentalScriptExecutionTool?: boolean
|
||||
|
||||
/**
|
||||
* Transform page content before sending to LLM.
|
||||
* Called after DOM extraction and simplification, before LLM invocation.
|
||||
* Use cases: inspect extraction results, modify page info, mask sensitive data.
|
||||
*
|
||||
* @param content - Simplified page content that will be sent to LLM
|
||||
* @returns Transformed content
|
||||
*
|
||||
* @example
|
||||
* // Mask phone numbers
|
||||
* transformPageContent: async (content) => {
|
||||
* return content.replace(/1[3-9]\d{9}/g, '***********')
|
||||
* }
|
||||
*/
|
||||
transformPageContent?: (content: string) => Promise<string> | string
|
||||
|
||||
/**
|
||||
* TODO: @unimplemented
|
||||
* hook when action causes a new page to be opened
|
||||
* @note PageAgent will try to detect new pages and decide if it's caused by an action. But not very reliable.
|
||||
* @todo remove `this` binding, pass agent as explicit parameter instead
|
||||
*/
|
||||
onNewPageOpen?: (this: PageAgent, url: string) => Promise<void> | void
|
||||
// onNewPageOpen?: (this: PageAgent, url: string) => Promise<void> | void
|
||||
|
||||
/**
|
||||
* TODO: @unimplemented
|
||||
* try to navigate to a new page instead of opening a new tab/window.
|
||||
* @note will unload the current page when a action tries to open a new page. so that things keep in the same tab/window.
|
||||
*/
|
||||
experimentalPreventNewPage?: boolean
|
||||
// experimentalPreventNewPage?: boolean
|
||||
}
|
||||
|
||||
export type PageAgentConfig = LLMConfig & AgentConfig & PageControllerConfig
|
||||
|
||||
@@ -35,7 +35,16 @@ setTimeout(() => {
|
||||
window.pageAgent = new PageAgent(config)
|
||||
} else {
|
||||
console.log('🚀 page-agent.js no current script detected, using default demo config')
|
||||
window.pageAgent = new PageAgent()
|
||||
const config: PageAgentConfig = {
|
||||
// model: DEMO_MODEL,
|
||||
// baseURL: DEMO_BASE_URL,
|
||||
// apiKey: DEMO_API_KEY,
|
||||
|
||||
model: import.meta.env.LLM_MODEL_NAME ? import.meta.env.LLM_MODEL_NAME : DEMO_MODEL,
|
||||
baseURL: import.meta.env.LLM_BASE_URL ? import.meta.env.LLM_BASE_URL : DEMO_BASE_URL,
|
||||
apiKey: import.meta.env.LLM_API_KEY ? import.meta.env.LLM_API_KEY : DEMO_API_KEY,
|
||||
}
|
||||
window.pageAgent = new PageAgent(config)
|
||||
}
|
||||
|
||||
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
|
||||
@@ -57,6 +57,15 @@ tools.set(
|
||||
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000)
|
||||
console.log(`actualWaitTime: ${actualWaitTime} seconds`)
|
||||
await waitFor(actualWaitTime)
|
||||
|
||||
this.states.totalWaitTime += input.seconds
|
||||
|
||||
if (this.states.totalWaitTime >= 3) {
|
||||
this.pushObservation(
|
||||
`You have waited ${this.states.totalWaitTime} seconds accumulatively. Do NOT wait any longer unless you have a good reason.`
|
||||
)
|
||||
}
|
||||
|
||||
return `✅ Waited for ${input.seconds} seconds.`
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import chalk from 'chalk'
|
||||
|
||||
/**
|
||||
* Normalize LLM response and fix common format issues.
|
||||
*
|
||||
* Handles:
|
||||
* - No tool_calls but JSON in message.content (fallback)
|
||||
* - Model returns action name as tool call instead of AgentOutput
|
||||
* - Arguments wrapped as double JSON string
|
||||
* - Nested function call format
|
||||
* - Missing action field (fallback to wait)
|
||||
* - etc.
|
||||
*/
|
||||
export function normalizeResponse(response: any): any {
|
||||
let resolvedArguments = null as any
|
||||
|
||||
const choice = (response as { choices?: Choice[] }).choices?.[0]
|
||||
if (!choice) throw new Error('No choices in response')
|
||||
|
||||
const message = choice.message
|
||||
if (!message) throw new Error('No message in choice')
|
||||
|
||||
const toolCall = message.tool_calls?.[0]
|
||||
|
||||
// fix level and location of arguments
|
||||
|
||||
if (toolCall?.function?.arguments) {
|
||||
resolvedArguments = safeJsonParse(toolCall.function.arguments)
|
||||
|
||||
// case: sometimes the model only returns the action level
|
||||
if (toolCall.function.name && toolCall.function.name !== 'AgentOutput') {
|
||||
console.log(chalk.yellow(`[normalizeResponse] #1: fixing tool_call`))
|
||||
resolvedArguments = { action: safeJsonParse(resolvedArguments) }
|
||||
}
|
||||
} else {
|
||||
// case: sometimes the model returns json in content instead of tool_calls
|
||||
if (message.content) {
|
||||
const content = message.content.trim()
|
||||
const jsonInContent = retrieveJsonFromString(content)
|
||||
if (jsonInContent) {
|
||||
resolvedArguments = safeJsonParse(jsonInContent)
|
||||
|
||||
// case: sometimes the content json includes upper level wrapper
|
||||
if (resolvedArguments?.name === 'AgentOutput') {
|
||||
console.log(chalk.yellow(`[normalizeResponse] #2: fixing tool_call`))
|
||||
resolvedArguments = safeJsonParse(resolvedArguments.arguments)
|
||||
}
|
||||
|
||||
// case: sometimes even 2-levels of wrapping
|
||||
if (resolvedArguments?.type === 'function') {
|
||||
console.log(chalk.yellow(`[normalizeResponse] #3: fixing tool_call`))
|
||||
resolvedArguments = safeJsonParse(resolvedArguments.function.arguments)
|
||||
}
|
||||
|
||||
// case: and sometimes action level only
|
||||
// todo: needs better detection logic
|
||||
if (
|
||||
!resolvedArguments?.action &&
|
||||
!resolvedArguments?.evaluation_previous_goal &&
|
||||
!resolvedArguments?.memory &&
|
||||
!resolvedArguments?.next_goal &&
|
||||
!resolvedArguments?.thinking
|
||||
) {
|
||||
console.log(chalk.yellow(`[normalizeResponse] #4: fixing tool_call`))
|
||||
resolvedArguments = { action: safeJsonParse(resolvedArguments) }
|
||||
}
|
||||
} else {
|
||||
throw new Error('No tool_call and the message content does not contain valid JSON')
|
||||
}
|
||||
} else {
|
||||
throw new Error('No tool_call nor message content is present')
|
||||
}
|
||||
}
|
||||
|
||||
// fix double stringified arguments
|
||||
resolvedArguments = safeJsonParse(resolvedArguments)
|
||||
if (resolvedArguments.action) {
|
||||
resolvedArguments.action = safeJsonParse(resolvedArguments.action)
|
||||
}
|
||||
|
||||
// fix incomplete formats
|
||||
if (!resolvedArguments.action) {
|
||||
console.log(chalk.yellow(`[normalizeResponse] #5: fixing tool_call`))
|
||||
resolvedArguments.action = { name: 'wait', input: { seconds: 1 } }
|
||||
}
|
||||
|
||||
// pack back to standard format
|
||||
return {
|
||||
...response,
|
||||
choices: [
|
||||
{
|
||||
...choice,
|
||||
message: {
|
||||
...message,
|
||||
tool_calls: [
|
||||
{
|
||||
...(toolCall || {}),
|
||||
function: {
|
||||
...(toolCall?.function || {}),
|
||||
name: 'AgentOutput',
|
||||
arguments: JSON.stringify(resolvedArguments),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse JSON, return original input if not json.
|
||||
*/
|
||||
function safeJsonParse(input: any): any {
|
||||
if (typeof input === 'string') {
|
||||
try {
|
||||
return JSON.parse(input.trim())
|
||||
} catch {
|
||||
return input
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and parse JSON from a string.
|
||||
* - Treat content between the first `{` and the last `}` as JSON.
|
||||
* - Try to parse that content as JSON and return the parsed value (object/array/primitive) if successful, otherwise return null.
|
||||
*/
|
||||
function retrieveJsonFromString(str: string): any {
|
||||
try {
|
||||
const json = /({[\s\S]*})/.exec(str) ?? []
|
||||
if (json.length === 0) {
|
||||
return null
|
||||
}
|
||||
return JSON.parse(json[0]!)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface Choice {
|
||||
message?: {
|
||||
role?: 'assistant'
|
||||
content?: string
|
||||
tool_calls?: {
|
||||
id?: string
|
||||
type?: 'function'
|
||||
function?: {
|
||||
name?: string
|
||||
arguments?: string
|
||||
}
|
||||
}[]
|
||||
}
|
||||
index?: 0
|
||||
finish_reason?: 'tool_calls'
|
||||
}
|
||||
@@ -6,8 +6,6 @@ export interface PageAgentEventMap {
|
||||
// PageAgent status events
|
||||
// 'agent:execute': { params: { task: string } }
|
||||
// 'agent:done': { params: { text: string; success: boolean } }
|
||||
// 'agent:paused': { params: undefined }
|
||||
// 'agent:resumed': { params: undefined }
|
||||
// 'agent:disposed': { params: undefined }
|
||||
// 'agent:error': { params: { error: string | Error } }
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export { normalizeResponse } from './autoFixer'
|
||||
|
||||
/**
|
||||
* Wait until condition becomes true
|
||||
* @returns Returns when condition becomes true, throws otherwise
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @ts-check
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import { dirname, resolve } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig } from 'vite'
|
||||
@@ -6,11 +7,14 @@ import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// Load .env from repo root
|
||||
dotenvConfig({ path: resolve(__dirname, '../../.env') })
|
||||
|
||||
// UMD Bundle for CDN
|
||||
// - alias all local packages so that they can be build in
|
||||
// - no external
|
||||
// - no d.ts. dts does not work with monorepo aliasing
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => ({
|
||||
plugins: [cssInjectedByJsPlugin({ relativeCSSInjection: true })],
|
||||
publicDir: false,
|
||||
esbuild: {
|
||||
@@ -25,21 +29,28 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/umd.ts'),
|
||||
entry: resolve(__dirname, 'src/iife.ts'),
|
||||
name: 'PageAgent',
|
||||
fileName: 'page-agent',
|
||||
formats: ['umd'],
|
||||
formats: ['iife'],
|
||||
},
|
||||
outDir: resolve(__dirname, 'dist', 'umd'),
|
||||
outDir: resolve(__dirname, 'dist', 'iife'),
|
||||
cssCodeSplit: true,
|
||||
minify: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// force use .js as extension
|
||||
entryFileNames: 'page-agent.js',
|
||||
},
|
||||
onwarn: function (message, handler) {
|
||||
if (message.code === 'EVAL') return
|
||||
handler(message)
|
||||
},
|
||||
},
|
||||
cssCodeSplit: true,
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
'import.meta.env.LLM_MODEL_NAME': JSON.stringify(process.env.LLM_MODEL_NAME),
|
||||
'import.meta.env.LLM_API_KEY': JSON.stringify(process.env.LLM_API_KEY),
|
||||
'import.meta.env.LLM_BASE_URL': JSON.stringify(process.env.LLM_BASE_URL),
|
||||
},
|
||||
})
|
||||
}))
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@page-agent/page-controller",
|
||||
"version": "0.0.19",
|
||||
"version": "0.2.3",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-controller.js",
|
||||
"module": "./dist/lib/page-controller.js",
|
||||
@@ -34,5 +34,8 @@
|
||||
"build": "vite build",
|
||||
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
||||
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||
},
|
||||
"dependencies": {
|
||||
"ai-motion": "^0.4.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,13 +18,32 @@ import { VIEWPORT_EXPANSION } from './constants'
|
||||
import * as dom from './dom'
|
||||
import type { FlatDomTree, InteractiveElementDomNode } from './dom/dom_tree/type'
|
||||
import { getPageInfo } from './dom/getPageInfo'
|
||||
import { SimulatorMask } from './mask/SimulatorMask'
|
||||
import { patchReact } from './patches/react'
|
||||
|
||||
export { SimulatorMask }
|
||||
|
||||
/**
|
||||
* Configuration for PageController
|
||||
*/
|
||||
export interface PageControllerConfig extends dom.DomConfig {
|
||||
viewportExpansion?: number
|
||||
/** Enable visual mask overlay during operations (default: false) */
|
||||
enableMask?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured browser state for LLM consumption
|
||||
*/
|
||||
export interface BrowserState {
|
||||
url: string
|
||||
title: string
|
||||
/** Page info + scroll position hint (e.g. "Page info: 1920x1080px...\n[Start of page]") */
|
||||
header: string
|
||||
/** Simplified HTML of interactive elements */
|
||||
content: string
|
||||
/** Page footer hint (e.g. "... 300 pixels below ..." or "[End of page]") */
|
||||
footer: string
|
||||
}
|
||||
|
||||
interface ActionResult {
|
||||
@@ -64,12 +83,19 @@ export class PageController extends EventTarget {
|
||||
/** last time the tree was updated */
|
||||
private lastTimeUpdate = 0
|
||||
|
||||
/** Visual mask overlay for blocking user interaction during automation */
|
||||
private mask: SimulatorMask | null = null
|
||||
|
||||
constructor(config: PageControllerConfig = {}) {
|
||||
super()
|
||||
|
||||
this.config = config
|
||||
|
||||
patchReact(this)
|
||||
|
||||
if (config.enableMask) {
|
||||
this.mask = new SimulatorMask()
|
||||
}
|
||||
}
|
||||
|
||||
// ======= State Queries =======
|
||||
@@ -81,42 +107,6 @@ export class PageController extends EventTarget {
|
||||
return window.location.href
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current page title
|
||||
*/
|
||||
async getPageTitle(): Promise<string> {
|
||||
return document.title
|
||||
}
|
||||
|
||||
/**
|
||||
* Get page scroll and size info
|
||||
*/
|
||||
async getPageInfo() {
|
||||
return getPageInfo()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the simplified HTML representation of the page.
|
||||
* This is used by LLM to understand the page structure.
|
||||
*/
|
||||
async getSimplifiedHTML(): Promise<string> {
|
||||
return this.simplifiedHTML
|
||||
}
|
||||
|
||||
/**
|
||||
* Get text description for an element by index
|
||||
*/
|
||||
async getElementText(index: number): Promise<string | undefined> {
|
||||
return this.elementTextMap.get(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total number of indexed interactive elements
|
||||
*/
|
||||
async getElementCount(): Promise<number> {
|
||||
return this.selectorMap.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last tree update timestamp
|
||||
*/
|
||||
@@ -125,10 +115,43 @@ export class PageController extends EventTarget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the viewport expansion setting
|
||||
* Get structured browser state for LLM consumption.
|
||||
* Automatically calls updateTree() to refresh the DOM state.
|
||||
*/
|
||||
async getViewportExpansion(): Promise<number> {
|
||||
return this.config.viewportExpansion ?? VIEWPORT_EXPANSION
|
||||
async getBrowserState(): Promise<BrowserState> {
|
||||
const url = window.location.href
|
||||
const title = document.title
|
||||
const pi = getPageInfo()
|
||||
const viewportExpansion = this.config.viewportExpansion ?? VIEWPORT_EXPANSION
|
||||
|
||||
await this.updateTree()
|
||||
|
||||
const content = this.simplifiedHTML
|
||||
|
||||
// Build header: page info + scroll position hint
|
||||
const pageInfoLine = `Page info: ${pi.viewport_width}x${pi.viewport_height}px viewport, ${pi.page_width}x${pi.page_height}px total page size, ${pi.pages_above.toFixed(1)} pages above, ${pi.pages_below.toFixed(1)} pages below, ${pi.total_pages.toFixed(1)} total pages, at ${(pi.current_page_position * 100).toFixed(0)}% of page`
|
||||
|
||||
const elementsLabel =
|
||||
viewportExpansion === -1
|
||||
? 'Interactive elements from top layer of the current page (full page):'
|
||||
: 'Interactive elements from top layer of the current page inside the viewport:'
|
||||
|
||||
const hasContentAbove = pi.pixels_above > 4
|
||||
const scrollHintAbove =
|
||||
hasContentAbove && viewportExpansion !== -1
|
||||
? `... ${pi.pixels_above} pixels above (${pi.pages_above.toFixed(1)} pages) - scroll to see more ...`
|
||||
: '[Start of page]'
|
||||
|
||||
const header = `${pageInfoLine}\n\n${elementsLabel}\n\n${scrollHintAbove}`
|
||||
|
||||
// Build footer: scroll position hint
|
||||
const hasContentBelow = pi.pixels_below > 4
|
||||
const footer =
|
||||
hasContentBelow && viewportExpansion !== -1
|
||||
? `... ${pi.pixels_below} pixels below (${pi.pages_below.toFixed(1)} pages) - scroll to see more ...`
|
||||
: '[End of page]'
|
||||
|
||||
return { url, title, header, content, footer }
|
||||
}
|
||||
|
||||
// ======= DOM Tree Operations =======
|
||||
@@ -136,12 +159,18 @@ export class PageController extends EventTarget {
|
||||
/**
|
||||
* Update DOM tree, returns simplified HTML for LLM.
|
||||
* This is the main method to refresh the page state.
|
||||
* Automatically bypasses mask during DOM extraction if enabled.
|
||||
*/
|
||||
async updateTree(): Promise<string> {
|
||||
this.dispatchEvent(new Event('beforeUpdate'))
|
||||
|
||||
this.lastTimeUpdate = Date.now()
|
||||
|
||||
// Temporarily bypass mask to allow DOM extraction
|
||||
if (this.mask) {
|
||||
this.mask.wrapper.style.pointerEvents = 'none'
|
||||
}
|
||||
|
||||
dom.cleanUpHighlights()
|
||||
|
||||
const blacklist = [
|
||||
@@ -162,6 +191,11 @@ export class PageController extends EventTarget {
|
||||
this.elementTextMap.clear()
|
||||
this.elementTextMap = dom.getElementTextMap(this.simplifiedHTML)
|
||||
|
||||
// Restore mask blocking
|
||||
if (this.mask) {
|
||||
this.mask.wrapper.style.pointerEvents = 'auto'
|
||||
}
|
||||
|
||||
this.dispatchEvent(new Event('afterUpdate'))
|
||||
|
||||
return this.simplifiedHTML
|
||||
@@ -326,6 +360,24 @@ export class PageController extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
// ======= Mask Operations =======
|
||||
|
||||
/**
|
||||
* Show the visual mask overlay.
|
||||
* Only works if enableMask was set to true in config.
|
||||
*/
|
||||
showMask(): void {
|
||||
this.mask?.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the visual mask overlay.
|
||||
* Only works if enableMask was set to true in config.
|
||||
*/
|
||||
hideMask(): void {
|
||||
this.mask?.hide()
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose and clean up resources
|
||||
*/
|
||||
@@ -335,5 +387,7 @@ export class PageController extends EventTarget {
|
||||
this.selectorMap.clear()
|
||||
this.elementTextMap.clear()
|
||||
this.simplifiedHTML = '<EMPTY>'
|
||||
this.mask?.dispose()
|
||||
this.mask = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,12 +287,52 @@ export async function scrollVertically(
|
||||
(document.documentElement as HTMLElement)
|
||||
|
||||
if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
|
||||
// Page-level scroll
|
||||
const scrollBefore = window.scrollY
|
||||
const scrollMax = document.documentElement.scrollHeight - window.innerHeight
|
||||
|
||||
window.scrollBy(0, dy)
|
||||
return `✅ Scrolled page by ${dy}px.`
|
||||
|
||||
const scrollAfter = window.scrollY
|
||||
const scrolled = scrollAfter - scrollBefore
|
||||
|
||||
if (Math.abs(scrolled) < 1) {
|
||||
return dy > 0
|
||||
? `⚠️ Already at the bottom of the page, cannot scroll down further.`
|
||||
: `⚠️ Already at the top of the page, cannot scroll up further.`
|
||||
}
|
||||
|
||||
const reachedBottom = dy > 0 && scrollAfter >= scrollMax - 1
|
||||
const reachedTop = dy < 0 && scrollAfter <= 1
|
||||
|
||||
if (reachedBottom) return `✅ Scrolled page by ${scrolled}px. Reached the bottom of the page.`
|
||||
if (reachedTop) return `✅ Scrolled page by ${scrolled}px. Reached the top of the page.`
|
||||
return `✅ Scrolled page by ${scrolled}px.`
|
||||
} else {
|
||||
// Container scroll
|
||||
const scrollBefore = el!.scrollTop
|
||||
const scrollMax = el!.scrollHeight - el!.clientHeight
|
||||
|
||||
el!.scrollBy({ top: dy, behavior: 'smooth' })
|
||||
await waitFor(0.1) // Animation playback
|
||||
return `✅ Scrolled container (${el!.tagName}) by ${dy}px.`
|
||||
await waitFor(0.1)
|
||||
|
||||
const scrollAfter = el!.scrollTop
|
||||
const scrolled = scrollAfter - scrollBefore
|
||||
|
||||
if (Math.abs(scrolled) < 1) {
|
||||
return dy > 0
|
||||
? `⚠️ Already at the bottom of container (${el!.tagName}), cannot scroll down further.`
|
||||
: `⚠️ Already at the top of container (${el!.tagName}), cannot scroll up further.`
|
||||
}
|
||||
|
||||
const reachedBottom = dy > 0 && scrollAfter >= scrollMax - 1
|
||||
const reachedTop = dy < 0 && scrollAfter <= 1
|
||||
|
||||
if (reachedBottom)
|
||||
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the bottom.`
|
||||
if (reachedTop)
|
||||
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the top.`
|
||||
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px.`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,11 +450,52 @@ export async function scrollHorizontally(
|
||||
(document.documentElement as HTMLElement)
|
||||
|
||||
if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
|
||||
// Page-level scroll
|
||||
const scrollBefore = window.scrollX
|
||||
const scrollMax = document.documentElement.scrollWidth - window.innerWidth
|
||||
|
||||
window.scrollBy(dx, 0)
|
||||
return `✅ Scrolled page horizontally by ${dx}px`
|
||||
|
||||
const scrollAfter = window.scrollX
|
||||
const scrolled = scrollAfter - scrollBefore
|
||||
|
||||
if (Math.abs(scrolled) < 1) {
|
||||
return dx > 0
|
||||
? `⚠️ Already at the right edge of the page, cannot scroll right further.`
|
||||
: `⚠️ Already at the left edge of the page, cannot scroll left further.`
|
||||
}
|
||||
|
||||
const reachedRight = dx > 0 && scrollAfter >= scrollMax - 1
|
||||
const reachedLeft = dx < 0 && scrollAfter <= 1
|
||||
|
||||
if (reachedRight)
|
||||
return `✅ Scrolled page by ${scrolled}px. Reached the right edge of the page.`
|
||||
if (reachedLeft) return `✅ Scrolled page by ${scrolled}px. Reached the left edge of the page.`
|
||||
return `✅ Scrolled page horizontally by ${scrolled}px.`
|
||||
} else {
|
||||
// Container scroll
|
||||
const scrollBefore = el!.scrollLeft
|
||||
const scrollMax = el!.scrollWidth - el!.clientWidth
|
||||
|
||||
el!.scrollBy({ left: dx, behavior: 'smooth' })
|
||||
await waitFor(0.1) // Animation playback
|
||||
return `✅ Scrolled container (${el!.tagName}) horizontally by ${dx}px`
|
||||
await waitFor(0.1)
|
||||
|
||||
const scrollAfter = el!.scrollLeft
|
||||
const scrolled = scrollAfter - scrollBefore
|
||||
|
||||
if (Math.abs(scrolled) < 1) {
|
||||
return dx > 0
|
||||
? `⚠️ Already at the right edge of container (${el!.tagName}), cannot scroll right further.`
|
||||
: `⚠️ Already at the left edge of container (${el!.tagName}), cannot scroll left further.`
|
||||
}
|
||||
|
||||
const reachedRight = dx > 0 && scrollAfter >= scrollMax - 1
|
||||
const reachedLeft = dx < 0 && scrollAfter <= 1
|
||||
|
||||
if (reachedRight)
|
||||
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the right edge.`
|
||||
if (reachedLeft)
|
||||
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the left edge.`
|
||||
return `✅ Scrolled container (${el!.tagName}) horizontally by ${scrolled}px.`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* @edit scrollable element detection
|
||||
* @edit add `data-browser-use-ignore` attribute
|
||||
* @edit improve `sampleRect`, filter out rects with 0 area
|
||||
* @edit exclude aria-hidden elements
|
||||
*/
|
||||
|
||||
export default (
|
||||
@@ -1446,7 +1447,14 @@ export default (
|
||||
* @edit add `data-browser-use-ignore` attribute
|
||||
*/
|
||||
if (node.dataset?.browserUseIgnore === 'true') {
|
||||
return true // Skip this node and its children
|
||||
return null // Skip this node and its children
|
||||
}
|
||||
|
||||
/**
|
||||
* @edit exclude aria-hidden elements
|
||||
*/
|
||||
if (node.getAttribute && node.getAttribute('aria-hidden') === 'true') {
|
||||
return null // Skip this node and its children
|
||||
}
|
||||
|
||||
// Special handling for root node (body)
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
@@ -29,7 +29,11 @@ export default defineConfig({
|
||||
},
|
||||
outDir: resolve(__dirname, 'dist', 'lib'),
|
||||
rollupOptions: {
|
||||
external: ['@page-agent/*'],
|
||||
external: ['@page-agent/*', 'ai-motion'],
|
||||
onwarn: function (message, handler) {
|
||||
if (message.code === 'EVAL') return
|
||||
handler(message)
|
||||
},
|
||||
},
|
||||
minify: false,
|
||||
sourcemap: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@page-agent/ui",
|
||||
"version": "0.0.19",
|
||||
"version": "0.2.3",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-agent-ui.js",
|
||||
"module": "./dist/lib/page-agent-ui.js",
|
||||
@@ -15,7 +15,7 @@
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"description": "UI components for page-agent - Panel, SimulatorMask, and i18n",
|
||||
"description": "UI components for page-agent - Panel and i18n",
|
||||
"keywords": [
|
||||
"page-agent",
|
||||
"ui",
|
||||
@@ -34,8 +34,5 @@
|
||||
"build": "vite build",
|
||||
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
||||
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||
},
|
||||
"dependencies": {
|
||||
"ai-motion": "^0.4.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,18 +209,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.pauseButton {
|
||||
font-weight: 600;
|
||||
&.paused {
|
||||
background: rgba(34, 197, 94, 0.2); /* 绿色背景表示可以继续 */
|
||||
color: rgb(34, 197, 94);
|
||||
|
||||
&:hover {
|
||||
background: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stopButton {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: rgb(255, 41, 41);
|
||||
@@ -364,6 +352,11 @@
|
||||
background: linear-gradient(135deg, rgba(255, 214, 0, 0.1), rgba(255, 214, 0, 0.05));
|
||||
}
|
||||
|
||||
&.observation {
|
||||
border-left-color: rgb(147, 51, 234);
|
||||
background: linear-gradient(135deg, rgba(147, 51, 234, 0.1), rgba(147, 51, 234, 0.05));
|
||||
}
|
||||
|
||||
/* 突出显示 done 成功结果 */
|
||||
&.doneSuccess {
|
||||
background: linear-gradient(
|
||||
|
||||
+12
-60
@@ -1,6 +1,6 @@
|
||||
import { type Step, UIState } from './UIState'
|
||||
import { I18n, type SupportedLanguage } from './i18n'
|
||||
import { truncate } from './utils'
|
||||
import { escapeHtml, truncate } from './utils'
|
||||
|
||||
import styles from './Panel.module.css'
|
||||
|
||||
@@ -11,8 +11,6 @@ export interface PanelConfig {
|
||||
language?: SupportedLanguage
|
||||
onExecuteTask: (task: string) => void
|
||||
onStop: () => void
|
||||
onPauseToggle: () => boolean // returns new paused state
|
||||
getPaused: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,6 +27,7 @@ export type PanelUpdate =
|
||||
| { type: 'completed' }
|
||||
| { type: 'toolExecuting'; toolName: string; args: any }
|
||||
| { type: 'toolCompleted'; toolName: string; args: any; result?: string; duration?: number }
|
||||
| { type: 'observation'; content: string }
|
||||
|
||||
/**
|
||||
* Agent control panel
|
||||
@@ -39,7 +38,6 @@ export class Panel {
|
||||
#statusText: HTMLElement
|
||||
#historySection: HTMLElement
|
||||
#expandButton: HTMLElement
|
||||
#pauseButton: HTMLElement
|
||||
#stopButton: HTMLElement
|
||||
#inputSection: HTMLElement
|
||||
#taskInput: HTMLInputElement
|
||||
@@ -66,7 +64,6 @@ export class Panel {
|
||||
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}`)!
|
||||
@@ -75,6 +72,8 @@ export class Panel {
|
||||
this.#startHeaderUpdateLoop()
|
||||
|
||||
this.#showInputArea()
|
||||
|
||||
this.hide() // Start hidden
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,11 +119,6 @@ export class Panel {
|
||||
this.#updateStatusIndicator('thinking')
|
||||
this.#updateHistory()
|
||||
this.#collapse()
|
||||
// Reset pause state via callback
|
||||
if (this.#config.getPaused()) {
|
||||
this.#config.onPauseToggle()
|
||||
}
|
||||
this.#updatePauseButton()
|
||||
// Reset user input state
|
||||
this.#isWaitingForUserAnswer = false
|
||||
this.#userAnswerResolver = null
|
||||
@@ -205,6 +199,8 @@ export class Panel {
|
||||
duration: data.duration,
|
||||
}
|
||||
}
|
||||
case 'observation':
|
||||
return { type: 'observation', displayText: data.content }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,39 +273,6 @@ export class Panel {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle pause state
|
||||
*/
|
||||
#togglePause(): void {
|
||||
const paused = this.#config.onPauseToggle()
|
||||
this.#updatePauseButton()
|
||||
|
||||
// Update status display
|
||||
if (paused) {
|
||||
this.#statusText.textContent = this.#i18n.t('ui.panel.paused')
|
||||
this.#updateStatusIndicator('thinking')
|
||||
} else {
|
||||
this.#statusText.textContent = this.#i18n.t('ui.panel.continueExecution')
|
||||
this.#updateStatusIndicator('tool_executing')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update pause button state
|
||||
*/
|
||||
#updatePauseButton(): void {
|
||||
const paused = this.#config.getPaused()
|
||||
if (paused) {
|
||||
this.#pauseButton.textContent = '▶'
|
||||
this.#pauseButton.title = this.#i18n.t('ui.panel.continue')
|
||||
this.#pauseButton.classList.add(styles.paused)
|
||||
} else {
|
||||
this.#pauseButton.textContent = '⏸︎'
|
||||
this.#pauseButton.title = this.#i18n.t('ui.panel.pause')
|
||||
this.#pauseButton.classList.remove(styles.paused)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop Agent
|
||||
*/
|
||||
@@ -401,7 +364,7 @@ export class Panel {
|
||||
#createWrapper(): HTMLElement {
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.id = 'page-agent-runtime_agent-panel'
|
||||
wrapper.className = `${styles.wrapper} ${styles.collapsed}`
|
||||
wrapper.className = styles.wrapper
|
||||
wrapper.setAttribute('data-browser-use-ignore', 'true')
|
||||
|
||||
wrapper.innerHTML = `
|
||||
@@ -426,9 +389,6 @@ export class Panel {
|
||||
<button class="${styles.controlButton} ${styles.expandButton}" title="${this.#i18n.t('ui.panel.expand')}">
|
||||
▼
|
||||
</button>
|
||||
<button class="${styles.controlButton} ${styles.pauseButton}" title="${this.#i18n.t('ui.panel.pause')}">
|
||||
⏸︎
|
||||
</button>
|
||||
<button class="${styles.controlButton} ${styles.stopButton}" title="${this.#i18n.t('ui.panel.stop')}">
|
||||
X
|
||||
</button>
|
||||
@@ -466,12 +426,6 @@ export class Panel {
|
||||
this.#toggle()
|
||||
})
|
||||
|
||||
// Pause/continue button
|
||||
this.#pauseButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
this.#togglePause()
|
||||
})
|
||||
|
||||
// Stop button
|
||||
this.#stopButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
@@ -503,7 +457,6 @@ export class Panel {
|
||||
|
||||
#expand(): void {
|
||||
this.#isExpanded = true
|
||||
this.wrapper.classList.remove(styles.collapsed)
|
||||
this.wrapper.classList.add(styles.expanded)
|
||||
this.#expandButton.textContent = '▲'
|
||||
}
|
||||
@@ -511,7 +464,6 @@ export class Panel {
|
||||
#collapse(): void {
|
||||
this.#isExpanded = false
|
||||
this.wrapper.classList.remove(styles.expanded)
|
||||
this.wrapper.classList.add(styles.collapsed)
|
||||
this.#expandButton.textContent = '▼'
|
||||
}
|
||||
|
||||
@@ -590,10 +542,7 @@ export class Panel {
|
||||
|
||||
#updateHistory(): void {
|
||||
const steps = this.#state.getAllSteps()
|
||||
|
||||
this.#historySection.innerHTML = steps.map((step) => this.#createHistoryItem(step)).join('')
|
||||
|
||||
// Scroll to bottom to show latest records
|
||||
this.#scrollToBottom()
|
||||
}
|
||||
|
||||
@@ -635,7 +584,7 @@ export class Panel {
|
||||
typeClass = styles.error
|
||||
statusIcon = '❌'
|
||||
} else if (step.type === 'tool_executing') {
|
||||
statusIcon = '⚙️'
|
||||
statusIcon = '🔨'
|
||||
} else if (step.type === 'output') {
|
||||
typeClass = styles.output
|
||||
statusIcon = '🤖'
|
||||
@@ -645,6 +594,9 @@ export class Panel {
|
||||
} else if (step.type === 'retry') {
|
||||
typeClass = styles.retry
|
||||
statusIcon = '🔄'
|
||||
} else if (step.type === 'observation') {
|
||||
typeClass = styles.observation
|
||||
statusIcon = '👁️'
|
||||
} else {
|
||||
statusIcon = '🧠'
|
||||
}
|
||||
@@ -660,7 +612,7 @@ export class Panel {
|
||||
<div class="${styles.historyItem} ${typeClass}">
|
||||
<div class="${styles.historyContent}">
|
||||
<span class="${styles.statusIcon}">${statusIcon}</span>
|
||||
<span>${step.displayText}</span>
|
||||
<span>${escapeHtml(step.displayText)}</span>
|
||||
</div>
|
||||
<div class="${styles.historyMeta}">
|
||||
${stepLabel}
|
||||
|
||||
@@ -6,7 +6,15 @@ export interface Step {
|
||||
id: string
|
||||
stepNumber: number
|
||||
timestamp: Date
|
||||
type: 'thinking' | 'tool_executing' | 'completed' | 'error' | 'output' | 'input' | 'retry'
|
||||
type:
|
||||
| 'thinking'
|
||||
| 'tool_executing'
|
||||
| 'completed'
|
||||
| 'error'
|
||||
| 'output'
|
||||
| 'input'
|
||||
| 'retry'
|
||||
| 'observation'
|
||||
|
||||
// Tool execution related
|
||||
toolName?: string
|
||||
@@ -18,7 +26,7 @@ export interface Step {
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export type AgentStatus = 'idle' | 'running' | 'paused' | 'completed' | 'error'
|
||||
export type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
|
||||
|
||||
export class UIState {
|
||||
private steps: Step[] = []
|
||||
|
||||
@@ -4,17 +4,13 @@ const enUS = {
|
||||
panel: {
|
||||
ready: 'Ready',
|
||||
thinking: 'Thinking...',
|
||||
paused: 'Paused',
|
||||
taskInput: 'Enter new task, describe steps in detail, press Enter to submit',
|
||||
userAnswerPrompt: 'Please answer the question above, press Enter to submit',
|
||||
taskTerminated: 'Task terminated',
|
||||
taskCompleted: 'Task completed',
|
||||
continueExecution: 'Continue execution',
|
||||
userAnswer: 'User answer: {{input}}',
|
||||
question: 'Question: {{question}}',
|
||||
waitingPlaceholder: 'Waiting for task to start...',
|
||||
pause: 'Pause',
|
||||
continue: 'Continue',
|
||||
stop: 'Stop',
|
||||
expand: 'Expand history',
|
||||
collapse: 'Collapse history',
|
||||
@@ -54,17 +50,13 @@ const zhCN = {
|
||||
panel: {
|
||||
ready: '准备就绪',
|
||||
thinking: '正在思考...',
|
||||
paused: '暂停中,稍后',
|
||||
taskInput: '输入新任务,详细描述步骤,回车提交',
|
||||
userAnswerPrompt: '请回答上面问题,回车提交',
|
||||
taskTerminated: '任务已终止',
|
||||
taskCompleted: '任务结束',
|
||||
continueExecution: '继续执行',
|
||||
userAnswer: '用户回答: {{input}}',
|
||||
question: '询问: {{question}}',
|
||||
waitingPlaceholder: '等待任务开始...',
|
||||
pause: '暂停',
|
||||
continue: '继续',
|
||||
stop: '终止',
|
||||
expand: '展开历史',
|
||||
collapse: '收起历史',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { Panel, type PanelConfig, type PanelUpdate } from './Panel'
|
||||
export { SimulatorMask } from './SimulatorMask'
|
||||
export { UIState, type Step, type AgentStatus } from './UIState'
|
||||
export { I18n, type SupportedLanguage, type TranslationKey } from './i18n'
|
||||
|
||||
@@ -4,3 +4,15 @@ export function truncate(text: string, maxLength: number): string {
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters to prevent XSS and rendering issues
|
||||
*/
|
||||
export function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export default defineConfig({
|
||||
},
|
||||
outDir: resolve(__dirname, 'dist', 'lib'),
|
||||
rollupOptions: {
|
||||
external: ['ai-motion'],
|
||||
external: [],
|
||||
},
|
||||
minify: false,
|
||||
sourcemap: true,
|
||||
|
||||
+16
-21
@@ -37,14 +37,17 @@ Available Magic UI components: https://magicui.design/docs/components
|
||||
Located in `src/components/ui/`:
|
||||
|
||||
**From shadcn/ui:**
|
||||
|
||||
- `alert`, `badge`, `button`, `separator`, `sonner`, `switch`, `tooltip`
|
||||
|
||||
**From Magic UI:**
|
||||
|
||||
- `animated-gradient-text`, `animated-shiny-text`, `aurora-text`
|
||||
- `hyper-text`, `magic-card`, `neon-gradient-card`, `particles`
|
||||
- `sparkles-text`, `text-animate`, `typing-animation`
|
||||
|
||||
**Custom:**
|
||||
|
||||
- `highlighter`, `kbd`, `spinner`
|
||||
|
||||
### Styling Rules
|
||||
@@ -58,17 +61,17 @@ Located in `src/components/ui/`:
|
||||
|
||||
```
|
||||
src/
|
||||
├── pages/
|
||||
│ ├── Home.tsx # Homepage
|
||||
│ └── docs/
|
||||
│ ├── Layout.tsx # Documentation sidebar
|
||||
│ └── [section]/[topic]/page.tsx
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui + Magic UI components
|
||||
│ ├── Header.tsx # Site header
|
||||
│ ├── Footer.tsx # Site footer
|
||||
│ └── DocsLayout.tsx # Documentation sidebar
|
||||
├── docs/ # Documentation pages
|
||||
│ └── [section]/[topic]/page.tsx
|
||||
├── test-pages/ # Library integration tests
|
||||
│ └── Footer.tsx # Site footer
|
||||
├── i18n/ # Internationalization
|
||||
├── router.tsx # Central routing
|
||||
├── page.tsx # Homepage
|
||||
└── main.tsx # App entry
|
||||
```
|
||||
|
||||
@@ -76,14 +79,9 @@ src/
|
||||
|
||||
### Documentation Page
|
||||
|
||||
1. Create `src/docs/<section>/<slug>/page.tsx`
|
||||
1. Create `src/pages/docs/<section>/<slug>/page.tsx`
|
||||
2. Add route to `src/router.tsx` with `<Header /> + <DocsLayout>` wrapper
|
||||
3. Add navigation item to `DocsLayout.tsx`
|
||||
|
||||
### Test Page
|
||||
|
||||
1. Create `src/test-pages/<name>.tsx`
|
||||
2. Add route to `src/test-pages/router.tsx`
|
||||
3. Add navigation item to `pages/docs/Layout.tsx`
|
||||
|
||||
## Routing
|
||||
|
||||
@@ -93,18 +91,16 @@ Uses hash-based routing for static hosting:
|
||||
import { Router } from 'wouter'
|
||||
import { useHashLocation } from 'wouter/use-hash-location'
|
||||
|
||||
<Router hook={useHashLocation}>
|
||||
{/* routes */}
|
||||
</Router>
|
||||
;<Router hook={useHashLocation}>{/* routes */}</Router>
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| File | Purpose |
|
||||
| ----------------- | ----------------------- |
|
||||
| `components.json` | shadcn/ui configuration |
|
||||
| `vite.config.js` | Vite build settings |
|
||||
| `tsconfig.json` | TypeScript config |
|
||||
| `vite.config.js` | Vite build settings |
|
||||
| `tsconfig.json` | TypeScript config |
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -112,4 +108,3 @@ import { useHashLocation } from 'wouter/use-hash-location'
|
||||
npm start # Dev server (from root)
|
||||
npm run build:website # Build website (from root)
|
||||
```
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# Landing Page & Docs
|
||||
@@ -1,24 +1,24 @@
|
||||
{
|
||||
"name": "@page-agent/website",
|
||||
"private": true,
|
||||
"version": "0.0.19",
|
||||
"version": "0.2.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build:website": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||
"i18next": "^25.7.3",
|
||||
"i18next": "^25.7.4",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-i18next": "^16.5.1",
|
||||
"react-i18next": "^16.5.2",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"wouter": "^3.9.0"
|
||||
@@ -32,10 +32,10 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.24.0",
|
||||
"motion": "^12.26.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"rough-notation": "^0.5.1",
|
||||
"simple-icons": "^16.4.0",
|
||||
"simple-icons": "^16.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function Header() {
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href="/"
|
||||
href="~/"
|
||||
className="flex items-center gap-2 sm:gap-3 group shrink-0"
|
||||
aria-label={t('header.logo_alt')}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
@@ -41,6 +41,7 @@ export default function Header() {
|
||||
className="hidden sm:block text-xs text-gray-600 dark:text-gray-300 py-0 font-normal overflow-visible"
|
||||
duration={600}
|
||||
animateOnHover={true}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{t('header.slogan')}
|
||||
</HyperText>
|
||||
@@ -54,7 +55,7 @@ export default function Header() {
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
<Link
|
||||
href="/docs/introduction/overview"
|
||||
href="~/docs/introduction/overview"
|
||||
className="p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 shrink-0"
|
||||
aria-label={t('header.nav_docs')}
|
||||
>
|
||||
@@ -84,8 +85,11 @@ export default function Header() {
|
||||
role="navigation"
|
||||
aria-label={t('header.nav_docs')}
|
||||
>
|
||||
<span className="text-xs font-mono text-gray-400 dark:text-gray-500 tabular-nums before:content-['v']">
|
||||
{import.meta.env.VERSION}
|
||||
</span>
|
||||
<Link
|
||||
href="/docs/introduction/overview"
|
||||
href="~/docs/introduction/overview"
|
||||
className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
||||
>
|
||||
<BookOpen className="w-4 h-4" />
|
||||
@@ -133,7 +137,7 @@ export default function Header() {
|
||||
role="navigation"
|
||||
>
|
||||
<Link
|
||||
href="/docs/introduction/overview"
|
||||
href="~/docs/introduction/overview"
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
export const CDN_URL = 'https://cdn.jsdelivr.net/npm/page-agent@latest/dist/umd/page-agent.js'
|
||||
export const CDN_CN_URL =
|
||||
'https://registry.npmmirror.com/page-agent/latest/files/dist/umd/page-agent.js'
|
||||
// Demo build (auto-init with demo LLM, for quick testing)
|
||||
export const CDN_DEMO_URL = 'https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.demo.js'
|
||||
export const CDN_DEMO_CN_URL =
|
||||
'https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.demo.js'
|
||||
|
||||
// Full build (exports PageAgent class, usage identical to npm)
|
||||
export const CDN_FULL_URL = 'https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.js'
|
||||
export const CDN_FULL_CN_URL =
|
||||
'https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.js'
|
||||
|
||||
// Demo LLM for website testing
|
||||
export const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
export const DEMO_BASE_URL =
|
||||
'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
|
||||
export const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import BetaNotice from '@/components/BetaNotice'
|
||||
import CodeEditor from '@/components/CodeEditor'
|
||||
|
||||
export default function DataMasking() {
|
||||
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">
|
||||
保护敏感数据,确保 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-gray-600 dark:text-gray-300">
|
||||
自动识别并脱敏手机号、身份证号、银行卡号等敏感信息。
|
||||
</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-gray-600 dark:text-gray-300">
|
||||
支持自定义脱敏规则,适应不同业务场景的数据保护需求。
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import BetaNotice from '@/components/BetaNotice'
|
||||
|
||||
export default function SecurityPermissions() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-6">安全与权限</h1>
|
||||
|
||||
<BetaNotice />
|
||||
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 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-gray-600 dark:text-gray-300">
|
||||
禁止 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-gray-600 dark:text-gray-300">明确定义 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-gray-600 dark:text-gray-300">禁止 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-gray-600 dark:text-gray-300">限制 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-gray-600 dark:text-gray-300 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-gray-500 dark:text-gray-400">
|
||||
对极高风险操作明确禁止执行
|
||||
</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-gray-500 dark:text-gray-400">
|
||||
对中等风险操作要求用户明确同意
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import BetaNotice from '@/components/BetaNotice'
|
||||
import CodeEditor from '@/components/CodeEditor'
|
||||
import { CDN_CN_URL, CDN_URL } from '@/constants'
|
||||
|
||||
export default function CdnSetup() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-6">CDN 引入</h1>
|
||||
|
||||
<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={`
|
||||
// CDN: \t${CDN_URL}
|
||||
// Mirror: \t${CDN_CN_URL}
|
||||
<script
|
||||
src="${CDN_URL}"
|
||||
crossorigin="true"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
|
||||
<script>
|
||||
// window.pageAgent
|
||||
</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-gray-600 dark:text-gray-300 space-y-1">
|
||||
<li>• 生产环境建议使用固定版本号</li>
|
||||
<li>• 确保 HTTPS 环境下使用</li>
|
||||
<li>• 配置 CSP 策略允许脚本执行</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import CodeEditor from '@/components/CodeEditor'
|
||||
|
||||
export default function Configuration() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-6">配置选项</h1>
|
||||
|
||||
<CodeEditor
|
||||
className="mb-8"
|
||||
language="typescript"
|
||||
code={`// config
|
||||
type PageAgentConfig = LLMConfig & AgentConfig & PageControllerConfig
|
||||
|
||||
interface LLMConfig {
|
||||
baseURL?: string
|
||||
apiKey?: string
|
||||
model?: string
|
||||
temperature?: number
|
||||
maxRetries?: number
|
||||
|
||||
/**
|
||||
* Custom fetch function for LLM API requests.
|
||||
* Use this to customize headers, credentials, proxy, etc.
|
||||
* The response should follow OpenAI API format.
|
||||
*/
|
||||
customFetch?: typeof globalThis.fetch
|
||||
}
|
||||
|
||||
interface AgentConfig {
|
||||
language?: "en-US" | "zh-CN"
|
||||
|
||||
/**
|
||||
* Custom tools to extend PageAgent capabilities
|
||||
* @experimental
|
||||
* @note You can also override or remove internal tools by using the same name.
|
||||
* @see [tools](../tools/index.ts)
|
||||
*
|
||||
* @example
|
||||
* // override internal tool
|
||||
* import { tool } from 'page-agent'
|
||||
* const customTools = {
|
||||
* ask_user: tool({
|
||||
* description:
|
||||
* 'Ask the user or parent model a question and wait for their answer. Use this if you need more information or clarification.',
|
||||
* inputSchema: zod.object({
|
||||
* question: zod.string(),
|
||||
* }),
|
||||
* execute: async function (this: PageAgent, input) {
|
||||
* const answer = await do_some_thing(input.question)
|
||||
* return "✅ Received user answer: " + answer
|
||||
* },
|
||||
* })
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // remove internal tool
|
||||
* const customTools = {
|
||||
* ask_user: null // never ask user questions
|
||||
* }
|
||||
*/
|
||||
customTools?: Record<string, PageAgentTool | null>
|
||||
|
||||
// lifecycle hooks
|
||||
// @todo: use event instead of hooks
|
||||
|
||||
onBeforeStep?: (this: PageAgent, stepCnt: number) => Promise<void> | void
|
||||
onAfterStep?: (this: PageAgent, stepCnt: number, history: AgentHistory[]) => Promise<void> | void
|
||||
onBeforeTask?: (this: PageAgent) => Promise<void> | void
|
||||
onAfterTask?: (this: PageAgent, result: ExecutionResult) => Promise<void> | void
|
||||
|
||||
/**
|
||||
* @note this hook can block the disposal process
|
||||
* @note when dispose caused by page unload, "reason" will be 'PAGE_UNLOADING'. this method CANNOT block unloading. async operations may be cut.
|
||||
*/
|
||||
onDispose?: (this: PageAgent, reason?: string) => void
|
||||
|
||||
// page behavior hooks
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Enable the experimental script execution tool that allows executing generated JavaScript code on the page.
|
||||
* @note Can cause unpredictable side effects.
|
||||
* @note May bypass some safe guards and data-masking mechanisms.
|
||||
*/
|
||||
experimentalScriptExecutionTool?: boolean
|
||||
|
||||
/**
|
||||
* TODO: @unimplemented
|
||||
* hook when action causes a new page to be opened
|
||||
* @note PageAgent will try to detect new pages and decide if it's caused by an action. But not very reliable.
|
||||
*/
|
||||
onNewPageOpen?: (this: PageAgent, url: string) => Promise<void> | void
|
||||
|
||||
/**
|
||||
* TODO: @unimplemented
|
||||
* try to navigate to a new page instead of opening a new tab/window.
|
||||
* @note will unload the current page when a action tries to open a new page. so that things keep in the same tab/window.
|
||||
*/
|
||||
experimentalPreventNewPage?: boolean
|
||||
}
|
||||
|
||||
interface PageControllerConfig {
|
||||
interactiveBlacklist?: (Element | (() => Element))[]
|
||||
interactiveWhitelist?: (Element | (() => Element))[]
|
||||
include_attributes?: string[]
|
||||
highlightOpacity?: number
|
||||
highlightLabelOpacity?: number
|
||||
viewportExpansion?: number
|
||||
}
|
||||
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import BetaNotice from '@/components/BetaNotice'
|
||||
import CodeEditor from '@/components/CodeEditor'
|
||||
import { CDN_CN_URL, CDN_URL } from '@/constants'
|
||||
|
||||
export default function QuickStart() {
|
||||
const { t } = useTranslation('docs')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-6">{t('quick_start.title')}</h1>
|
||||
|
||||
<p className=" mb-6 leading-relaxed">{t('quick_start.subtitle')}</p>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-3">{t('quick_start.installation')}</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">
|
||||
{t('quick_start.step1_title')}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">{t('quick_start.step1_cdn')}</p>
|
||||
<CodeEditor
|
||||
code={`// CDN: \t${CDN_URL}
|
||||
// Mirror: \t${CDN_CN_URL}
|
||||
<script src="${CDN_URL}" crossorigin="true" type="text/javascript"></script>`}
|
||||
language="html"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">{t('quick_start.step1_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">
|
||||
{t('quick_start.step2_title')}
|
||||
</h3>
|
||||
<CodeEditor
|
||||
code={`// 仅供测试使用,生产环境需要配置 LLM 接入点,本工具不提供 LLM 服务
|
||||
// 测试接口
|
||||
// @note: 限流,限制 prompt 内容,限制来源,随时变更,请替换成你自己的
|
||||
// @note: 使用 DeepSeek-chat(3.2) 官方版本,使用协议和隐私策略见 DeepSeek 网站
|
||||
const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
const DEMO_BASE_URL = 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
|
||||
const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
|
||||
const agent = new PageAgent({
|
||||
model: DEMO_MODEL,
|
||||
baseURL: DEMO_BASE_URL,
|
||||
apiKey: DEMO_API_KEY,
|
||||
language: 'zh-CN'
|
||||
})`}
|
||||
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">
|
||||
{t('quick_start.step3_title')}
|
||||
</h3>
|
||||
<CodeEditor
|
||||
code={`// 程序化执行自然语言指令
|
||||
await pageAgent.execute('点击提交按钮,然后填写用户名为张三');
|
||||
|
||||
// 或者显示对话框让用户输入指令
|
||||
pageAgent.panel.show()
|
||||
`}
|
||||
language="javascript"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Vendored
+4
@@ -1,5 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VERSION: string
|
||||
}
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
|
||||
@@ -27,14 +27,14 @@ export default {
|
||||
overview: 'Overview',
|
||||
quick_start: 'Quick Start',
|
||||
limitations: 'Limitations',
|
||||
model_integration: 'Model Integration',
|
||||
models: 'Models',
|
||||
custom_tools: 'Custom Tools',
|
||||
knowledge_injection: 'Instructions',
|
||||
security_permissions: 'Security & Permissions',
|
||||
data_masking: 'Data Masking',
|
||||
cdn_setup: 'CDN Setup',
|
||||
configuration: 'Configuration',
|
||||
best_practices: 'Best Practices',
|
||||
third_party_agent: 'Third-party Agent',
|
||||
security_permissions: 'Security & Permissions',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export default {
|
||||
'page-agent is a purely web-based GUI Agent. Gives your website an AI operator in simple steps.',
|
||||
what_is: 'What is page-agent?',
|
||||
what_is_desc:
|
||||
'page-agent is an embedded GUI Agent. Unlike traditional browser automation tools, page-agent targets web developers, not scrapers or agent clients builders. Integrate it into your site to let users interact with pages through natural language.',
|
||||
'page-agent is an embedded GUI Agent. Unlike traditional browser automation tools, page-agent is built for web developers and web applications first. Integrate it into your site to let users interact with pages through natural language.',
|
||||
features_title: 'Core Features',
|
||||
feature_dom: {
|
||||
title: '🧠 Smart DOM Analysis',
|
||||
@@ -112,8 +112,8 @@ export default {
|
||||
future_3: '• Basic visual understanding',
|
||||
future_4: '• Smarter error recovery',
|
||||
},
|
||||
model_integration: {
|
||||
title: 'Model Integration',
|
||||
models: {
|
||||
title: 'Models',
|
||||
subtitle:
|
||||
'Supports models that comply with OpenAI API specification and support tool calls, including public cloud services and private deployments.',
|
||||
recommendation_logic: 'Recommended: Lightweight models with strong ToolCall capabilities.',
|
||||
|
||||
@@ -26,14 +26,14 @@ export default {
|
||||
overview: '概览',
|
||||
quick_start: '快速开始',
|
||||
limitations: '使用限制',
|
||||
model_integration: '模型接入',
|
||||
models: '模型',
|
||||
custom_tools: '自定义工具',
|
||||
knowledge_injection: '知识注入',
|
||||
security_permissions: '安全与权限',
|
||||
data_masking: '数据脱敏',
|
||||
cdn_setup: 'CDN 引入',
|
||||
configuration: '配置选项',
|
||||
best_practices: '最佳实践',
|
||||
third_party_agent: '接入第三方 Agent',
|
||||
security_permissions: '安全与权限',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -111,8 +111,8 @@ export default {
|
||||
future_3: '• 基础的视觉理解能力',
|
||||
future_4: '• 更智能的错误恢复机制',
|
||||
},
|
||||
model_integration: {
|
||||
title: '模型接入',
|
||||
models: {
|
||||
title: '模型',
|
||||
subtitle: '当前支持符合 OpenAI 接口规范且支持 tool call 的模型,包括公有云服务和私有部署方案。',
|
||||
recommendation_logic: '推荐使用 ToolCall 能力强的轻量级模型。',
|
||||
recommended: '推荐模型',
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Route, Router, Switch } from 'wouter'
|
||||
import { Router } from 'wouter'
|
||||
import { useHashLocation } from 'wouter/use-hash-location'
|
||||
|
||||
import './i18n/config'
|
||||
import './i18n/types'
|
||||
import { default as PagesRouter } from './router'
|
||||
import { default as TestPagesRouter } from './test-pages/router'
|
||||
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<Router hook={useHashLocation}>
|
||||
<Switch>
|
||||
<Route path="/test-pages" component={TestPagesRouter} nest />
|
||||
<Route path="/" component={PagesRouter} nest />
|
||||
</Switch>
|
||||
<PagesRouter />
|
||||
</Router>
|
||||
)
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
/* eslint-disable react-dom/no-dangerously-set-innerhtml */
|
||||
import { Bot, Box, MessageSquare, PlayCircle, Shield, Sparkles, Users, Zap } from 'lucide-react'
|
||||
import { PageAgent } from 'page-agent'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, useSearchParams } from 'wouter'
|
||||
|
||||
import Footer from './components/Footer'
|
||||
import Header from './components/Header'
|
||||
import { AnimatedGradientText } from './components/ui/animated-gradient-text'
|
||||
import { Highlighter } from './components/ui/highlighter'
|
||||
import { NeonGradientCard } from './components/ui/neon-gradient-card'
|
||||
import { Particles } from './components/ui/particles'
|
||||
import { SparklesText } from './components/ui/sparkles-text'
|
||||
import { CDN_CN_URL, CDN_URL } from './constants'
|
||||
import Footer from '../components/Footer'
|
||||
import Header from '../components/Header'
|
||||
import { AnimatedGradientText } from '../components/ui/animated-gradient-text'
|
||||
import { Highlighter } from '../components/ui/highlighter'
|
||||
import { NeonGradientCard } from '../components/ui/neon-gradient-card'
|
||||
import { Particles } from '../components/ui/particles'
|
||||
import { SparklesText } from '../components/ui/sparkles-text'
|
||||
import {
|
||||
CDN_DEMO_CN_URL,
|
||||
CDN_DEMO_URL,
|
||||
DEMO_API_KEY,
|
||||
DEMO_BASE_URL,
|
||||
DEMO_MODEL,
|
||||
} from '../constants'
|
||||
|
||||
function getInjection(useCN?: boolean) {
|
||||
const cdn = useCN ? CDN_CN_URL : CDN_URL
|
||||
const cdn = useCN ? CDN_DEMO_CN_URL : CDN_DEMO_URL
|
||||
|
||||
const injection = encodeURI(
|
||||
`javascript:(function(){var s=document.createElement('script');s.src=\`${cdn}?t=\${Math.random()}\`;s.setAttribute('crossorigin', true);s.type="text/javascript";s.onload=()=>console.log('PageAgent script loaded!');document.body.appendChild(s);})();`
|
||||
@@ -53,13 +58,12 @@ export default function HomePage() {
|
||||
const handleExecute = async () => {
|
||||
if (!task.trim()) return
|
||||
|
||||
let pageAgent: PageAgent
|
||||
const win = window as any
|
||||
|
||||
if (win.pageAgent && !win.pageAgent.disposed) {
|
||||
pageAgent = win.pageAgent
|
||||
} else {
|
||||
pageAgent = new PageAgent({
|
||||
// Lazy load PageAgent only when user clicks execute
|
||||
if (!win.pageAgent || win.pageAgent.disposed) {
|
||||
const { PageAgent } = await import('page-agent')
|
||||
win.pageAgent = new PageAgent({
|
||||
// 把 react 根元素排除掉,挂了很多冒泡时间导致假阳
|
||||
interactiveBlacklist: [document.getElementById('root')!],
|
||||
language: i18n.language as any,
|
||||
@@ -73,19 +77,22 @@ export default function HomePage() {
|
||||
},
|
||||
},
|
||||
|
||||
// experimentalScriptExecutionTool: true,
|
||||
|
||||
// testing server
|
||||
// @note: rate limit. prompt limit.
|
||||
// model: DEMO_MODEL,
|
||||
// baseURL: DEMO_BASE_URL,
|
||||
// apiKey: DEMO_API_KEY,
|
||||
model:
|
||||
import.meta.env.DEV && import.meta.env.LLM_MODEL_NAME
|
||||
? import.meta.env.LLM_MODEL_NAME
|
||||
: DEMO_MODEL,
|
||||
baseURL:
|
||||
import.meta.env.DEV && import.meta.env.LLM_BASE_URL
|
||||
? import.meta.env.LLM_BASE_URL
|
||||
: DEMO_BASE_URL,
|
||||
apiKey:
|
||||
import.meta.env.DEV && import.meta.env.LLM_API_KEY
|
||||
? import.meta.env.LLM_API_KEY
|
||||
: DEMO_API_KEY,
|
||||
})
|
||||
win.pageAgent = pageAgent
|
||||
}
|
||||
|
||||
const result = await pageAgent.execute(task)
|
||||
|
||||
const result = await win.pageAgent.execute(task)
|
||||
console.log(result)
|
||||
}
|
||||
|
||||
@@ -95,7 +102,10 @@ export default function HomePage() {
|
||||
|
||||
{/* Hero Section */}
|
||||
<main id="main-content">
|
||||
<section className="relative px-6 py-22 lg:py-28" aria-labelledby="hero-heading">
|
||||
<section
|
||||
className="relative px-6 py-22 pb-18 lg:py-28 overflow-hidden"
|
||||
aria-labelledby="hero-heading"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto text-center">
|
||||
{/* Background Pattern + Particles */}
|
||||
<div className="absolute inset-0 opacity-30" aria-hidden="true">
|
||||
+17
-14
@@ -24,38 +24,41 @@ export default function DocsLayout({ children }: DocsLayoutProps) {
|
||||
{
|
||||
title: t('nav.introduction'),
|
||||
items: [
|
||||
{ title: t('nav.overview'), path: '/docs/introduction/overview' },
|
||||
{ title: t('nav.quick_start'), path: '/docs/introduction/quick-start' },
|
||||
{ title: t('nav.limitations'), path: '/docs/introduction/limitations' },
|
||||
{ title: t('nav.overview'), path: '/introduction/overview' },
|
||||
{ title: t('nav.quick_start'), path: '/introduction/quick-start' },
|
||||
{ title: t('nav.limitations'), path: '/introduction/limitations' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('nav.features'),
|
||||
items: [
|
||||
{ title: t('nav.model_integration'), path: '/docs/features/model-integration' },
|
||||
{ title: t('nav.custom_tools'), path: '/docs/features/custom-tools' },
|
||||
{ title: t('nav.knowledge_injection'), path: '/docs/features/custom-instructions' },
|
||||
{ title: t('nav.security_permissions'), path: '/docs/features/security-permissions' },
|
||||
{ title: t('nav.data_masking'), path: '/docs/features/data-masking' },
|
||||
{ title: t('nav.models'), path: '/features/models' },
|
||||
{ title: t('nav.custom_tools'), path: '/features/custom-tools' },
|
||||
{ title: t('nav.knowledge_injection'), path: '/features/custom-instructions' },
|
||||
{ title: t('nav.data_masking'), path: '/features/data-masking' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('nav.integration'),
|
||||
items: [
|
||||
{ title: t('nav.cdn_setup'), path: '/docs/integration/cdn-setup' },
|
||||
{ title: t('nav.configuration'), path: '/docs/integration/configuration' },
|
||||
{ title: t('nav.best_practices'), path: '/docs/integration/best-practices' },
|
||||
{ title: t('nav.third_party_agent'), path: '/docs/integration/third-party-agent' },
|
||||
{ title: t('nav.configuration'), path: '/integration/configuration' },
|
||||
{ title: t('nav.third_party_agent'), path: '/integration/third-party-agent' },
|
||||
{ title: t('nav.cdn_setup'), path: '/integration/cdn-setup' },
|
||||
{
|
||||
title: '🚧 ' + t('nav.security_permissions'),
|
||||
path: '/integration/security-permissions',
|
||||
},
|
||||
{ title: '🚧 ' + t('nav.best_practices'), path: '/integration/best-practices' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 py-8 overflow-x-auto">
|
||||
<div className="flex gap-8 min-w-[900px]">
|
||||
<div className="flex gap-8 min-w-225">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 shrink-0" role="complementary" aria-label="文档导航">
|
||||
<div className="sticky top-8">
|
||||
<div className="sticky">
|
||||
<nav className="space-y-8" role="navigation" aria-label="文档章节">
|
||||
{navigationSections.map((section) => (
|
||||
<section key={section.title}>
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import CodeEditor from '@/components/CodeEditor'
|
||||
|
||||
export default function DataMasking() {
|
||||
const { i18n } = useTranslation()
|
||||
const isZh = i18n.language === 'zh-CN'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-6">{isZh ? '数据脱敏' : 'Data Masking'}</h1>
|
||||
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
|
||||
{isZh
|
||||
? '使用 transformPageContent 钩子在页面内容发送给 LLM 之前进行处理,可用于检查清洗效果、修改页面信息、隐藏敏感数据等。'
|
||||
: 'Use the transformPageContent hook to process page content before sending to LLM. Useful for inspecting extraction results, modifying page info, and masking sensitive data.'}
|
||||
</p>
|
||||
|
||||
<section className="mb-12">
|
||||
<h2 className="text-3xl font-bold mb-6">{isZh ? '接口定义' : 'API Definition'}</h2>
|
||||
|
||||
<CodeEditor
|
||||
className="mb-6"
|
||||
code={`interface PageAgentConfig {
|
||||
/**
|
||||
* Transform page content before sending to LLM.
|
||||
* Called after DOM extraction and simplification.
|
||||
*/
|
||||
transformPageContent?: (content: string) => Promise<string> | string
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="mb-12">
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
{isZh ? '常用脱敏规则' : 'Common Masking Patterns'}
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
{isZh
|
||||
? '以下示例展示了如何脱敏常见的敏感信息:'
|
||||
: 'The following example shows how to mask common sensitive data:'}
|
||||
</p>
|
||||
|
||||
<CodeEditor
|
||||
code={`const agent = new PageAgent({
|
||||
transformPageContent: async (content) => {
|
||||
// China phone number (11 digits starting with 1)
|
||||
content = content.replace(/\\b(1[3-9]\\d)(\\d{4})(\\d{4})\\b/g, '$1****$3')
|
||||
|
||||
// Email address
|
||||
content = content.replace(
|
||||
/\\b([a-zA-Z0-9._%+-])[^@]*(@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})\\b/g,
|
||||
'$1***$2'
|
||||
)
|
||||
|
||||
// China ID card number (18 digits)
|
||||
content = content.replace(
|
||||
/\\b(\\d{6})(19|20\\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])(\\d{3}[\\dXx])\\b/g,
|
||||
'$1********$5'
|
||||
)
|
||||
|
||||
// Bank card number (16-19 digits)
|
||||
content = content.replace(/\\b(\\d{4})\\d{8,11}(\\d{4})\\b/g, '$1********$2')
|
||||
|
||||
return content
|
||||
}
|
||||
})`}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+25
-21
@@ -4,13 +4,19 @@ import CodeEditor from '@/components/CodeEditor'
|
||||
|
||||
// Recommended models: lightweight with excellent tool call capabilities
|
||||
const MODELS = {
|
||||
recommended: ['gpt-4.1-mini', 'claude-haiku-4.5', 'gemini-3-flash', 'deepseek-3.2', 'gpt-5.2'],
|
||||
verified: [
|
||||
recommended: [
|
||||
'gpt-4.1-mini',
|
||||
'claude-haiku-4.5',
|
||||
'gemini-3-flash',
|
||||
'deepseek-3.2',
|
||||
'gpt-5.2',
|
||||
'qwen-3-max',
|
||||
],
|
||||
verified: [
|
||||
'qwen-3-plus',
|
||||
'gpt-4.1',
|
||||
'gpt-5',
|
||||
'gpt-5-mini',
|
||||
'gpt-5.1',
|
||||
'grok-4',
|
||||
'grok-code-fast',
|
||||
'claude-sonnet-3.5',
|
||||
@@ -21,22 +27,20 @@ const MODELS = {
|
||||
],
|
||||
}
|
||||
|
||||
export default function ModelIntegration() {
|
||||
export default function Models() {
|
||||
const { t } = useTranslation('docs')
|
||||
const allModels = [...MODELS.recommended, ...MODELS.verified]
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<h1 className="text-4xl font-bold mb-4">{t('model_integration.title')}</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400 mb-8">
|
||||
{t('model_integration.subtitle')}
|
||||
</p>
|
||||
<h1 className="text-4xl font-bold mb-4">{t('models.title')}</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400 mb-8">{t('models.subtitle')}</p>
|
||||
|
||||
{/* Models Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-2xl font-semibold mb-3">{t('model_integration.available')}</h2>
|
||||
<h2 className="text-2xl font-semibold mb-3">{t('models.available')}</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
{t('model_integration.recommendation_logic')}
|
||||
{t('models.recommendation_logic')}
|
||||
</p>
|
||||
<div className="bg-linear-to-br from-emerald-50 to-cyan-50 dark:from-emerald-950/30 dark:to-cyan-950/30 rounded-xl p-6 border border-emerald-200/50 dark:border-emerald-800/50">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -63,41 +67,41 @@ export default function ModelIntegration() {
|
||||
|
||||
{/* Tips Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-2xl font-semibold mb-4">{t('model_integration.tips')}</h2>
|
||||
<h2 className="text-2xl font-semibold mb-4">{t('models.tips')}</h2>
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<ul className="text-sm text-gray-700 dark:text-gray-300 space-y-2 list-disc pl-5">
|
||||
<li>{t('model_integration.tip_2')}</li>
|
||||
<li>{t('model_integration.tip_3')}</li>
|
||||
<li>{t('models.tip_2')}</li>
|
||||
<li>{t('models.tip_3')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Security Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-2xl font-semibold mb-4">{t('model_integration.security')}</h2>
|
||||
<h2 className="text-2xl font-semibold mb-4">{t('models.security')}</h2>
|
||||
<div className="bg-yellow-50 dark:bg-yellow-950/20 border-l-4 border-yellow-500 p-5 rounded-r-lg mb-4">
|
||||
<p className="text-sm font-semibold text-yellow-900 dark:text-yellow-200">
|
||||
{t('model_integration.security_warning')}
|
||||
{t('models.security_warning')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-900/30 rounded-lg p-5 border border-gray-200 dark:border-gray-800">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
{t('model_integration.security_backend_proxy')}
|
||||
{t('models.security_backend_proxy')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
{t('model_integration.security_backend_desc')}
|
||||
{t('models.security_backend_desc')}
|
||||
</p>
|
||||
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<li>{t('model_integration.security_method_1')}</li>
|
||||
<li>{t('model_integration.security_method_2')}</li>
|
||||
<li>{t('model_integration.security_method_3')}</li>
|
||||
<li>{t('models.security_method_1')}</li>
|
||||
<li>{t('models.security_method_2')}</li>
|
||||
<li>{t('models.security_method_3')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Configuration Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-2xl font-semibold mb-4">{t('model_integration.configuration')}</h2>
|
||||
<h2 className="text-2xl font-semibold mb-4">{t('models.configuration')}</h2>
|
||||
<CodeEditor
|
||||
code={`// OpenAI-compatible services (e.g., Alibaba Bailian)
|
||||
const pageAgent = new PageAgent({
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Suspense } from 'react'
|
||||
import { Route, Switch } from 'wouter'
|
||||
|
||||
import Header from '../../components/Header'
|
||||
import DocsLayout from './Layout'
|
||||
import Instructions from './features/custom-instructions/page'
|
||||
// Features
|
||||
import CustomTools from './features/custom-tools/page'
|
||||
import DataMasking from './features/data-masking/page'
|
||||
import Models from './features/models/page'
|
||||
import BestPractices from './integration/best-practices/page'
|
||||
// Integration
|
||||
import CdnSetup from './integration/cdn-setup/page'
|
||||
import Configuration from './integration/configuration/page'
|
||||
import SecurityPermissions from './integration/security-permissions/page'
|
||||
import ThirdPartyAgent from './integration/third-party-agent/page'
|
||||
import Limitations from './introduction/limitations/page'
|
||||
// Introduction
|
||||
import Overview from './introduction/overview/page'
|
||||
import QuickStart from './introduction/quick-start/page'
|
||||
|
||||
function DocsPage({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
||||
<Header />
|
||||
<DocsLayout>
|
||||
<Suspense>{children}</Suspense>
|
||||
</DocsLayout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DocsRouter() {
|
||||
return (
|
||||
<Switch>
|
||||
{/* Introduction */}
|
||||
<Route path="/introduction/overview">
|
||||
<DocsPage>
|
||||
<Overview />
|
||||
</DocsPage>
|
||||
</Route>
|
||||
<Route path="/introduction/quick-start">
|
||||
<DocsPage>
|
||||
<QuickStart />
|
||||
</DocsPage>
|
||||
</Route>
|
||||
<Route path="/introduction/limitations">
|
||||
<DocsPage>
|
||||
<Limitations />
|
||||
</DocsPage>
|
||||
</Route>
|
||||
|
||||
{/* Features */}
|
||||
<Route path="/features/custom-tools">
|
||||
<DocsPage>
|
||||
<CustomTools />
|
||||
</DocsPage>
|
||||
</Route>
|
||||
<Route path="/features/data-masking">
|
||||
<DocsPage>
|
||||
<DataMasking />
|
||||
</DocsPage>
|
||||
</Route>
|
||||
<Route path="/features/custom-instructions">
|
||||
<DocsPage>
|
||||
<Instructions />
|
||||
</DocsPage>
|
||||
</Route>
|
||||
<Route path="/features/models">
|
||||
<DocsPage>
|
||||
<Models />
|
||||
</DocsPage>
|
||||
</Route>
|
||||
|
||||
{/* Integration */}
|
||||
<Route path="/integration/cdn-setup">
|
||||
<DocsPage>
|
||||
<CdnSetup />
|
||||
</DocsPage>
|
||||
</Route>
|
||||
<Route path="/integration/security-permissions">
|
||||
<DocsPage>
|
||||
<SecurityPermissions />
|
||||
</DocsPage>
|
||||
</Route>
|
||||
<Route path="/integration/configuration">
|
||||
<DocsPage>
|
||||
<Configuration />
|
||||
</DocsPage>
|
||||
</Route>
|
||||
<Route path="/integration/best-practices">
|
||||
<DocsPage>
|
||||
<BestPractices />
|
||||
</DocsPage>
|
||||
</Route>
|
||||
<Route path="/integration/third-party-agent">
|
||||
<DocsPage>
|
||||
<ThirdPartyAgent />
|
||||
</DocsPage>
|
||||
</Route>
|
||||
|
||||
{/* Default redirect or 404 */}
|
||||
<Route path="/docs">
|
||||
<DocsPage>
|
||||
<Overview />
|
||||
</DocsPage>
|
||||
</Route>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import CodeEditor from '@/components/CodeEditor'
|
||||
import { CDN_DEMO_CN_URL, CDN_DEMO_URL, CDN_FULL_CN_URL, CDN_FULL_URL } from '@/constants'
|
||||
|
||||
export default function CdnSetup() {
|
||||
const { i18n } = useTranslation()
|
||||
const isZh = i18n.language === 'zh-CN'
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<header>
|
||||
<h1 className="text-4xl font-bold mb-4">{isZh ? 'CDN 引入' : 'CDN Setup'}</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{isZh
|
||||
? 'CDN 提供两种构建版本:Demo 版用于快速体验,标准版用法与 NPM 一致。'
|
||||
: 'CDN provides two builds: Demo for quick testing, standard build with usage identical to NPM.'}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Demo Build Section */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold mb-3">{isZh ? '🚀 快速体验' : '🚀 Quick Try'}</h2>
|
||||
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-3">
|
||||
{isZh
|
||||
? '自动初始化并使用内置 Demo LLM,无需任何配置:'
|
||||
: 'Auto-initializes with built-in demo LLM, no configuration needed:'}
|
||||
</p>
|
||||
|
||||
<CodeEditor
|
||||
className="mb-3"
|
||||
code={`<script src="${CDN_DEMO_URL}" crossorigin="true"></script>`}
|
||||
/>
|
||||
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 px-4 py-3 rounded-lg mb-4 text-sm">
|
||||
<span className="text-yellow-800 dark:text-yellow-200">
|
||||
⚠️{' '}
|
||||
{isZh
|
||||
? '仅用于技术评估,Demo 模型有速率限制。'
|
||||
: 'For evaluation only. Demo model has rate limits.'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-2 px-3 font-semibold w-28">
|
||||
{isZh ? '位置' : 'Location'}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 font-semibold">URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-2 px-3">{isZh ? '全球' : 'Global'}</td>
|
||||
<td className="py-2 px-3 font-mono text-xs break-all">{CDN_DEMO_URL}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3">{isZh ? '中国' : 'China'}</td>
|
||||
<td className="py-2 px-3 font-mono text-xs break-all">{CDN_DEMO_CN_URL}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{/* CDN Build Section */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold mb-3">{isZh ? '📦 标准版' : '📦 Standard Build'}</h2>
|
||||
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-3">
|
||||
{isZh
|
||||
? '将 PageAgent 类暴露到 globalThis,用法与 NPM 一致:'
|
||||
: 'Exposes PageAgent class to globalThis, usage identical to NPM:'}
|
||||
</p>
|
||||
|
||||
<CodeEditor
|
||||
className="mb-4"
|
||||
code={`<script src="${CDN_FULL_URL}" crossorigin="true"></script>
|
||||
<script>
|
||||
const agent = new PageAgent({
|
||||
model: 'deepseek-chat',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
})
|
||||
await agent.execute('Click the submit button')
|
||||
</script>`}
|
||||
/>
|
||||
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-2 px-3 font-semibold w-28">
|
||||
{isZh ? '位置' : 'Location'}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 font-semibold">URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-2 px-3">{isZh ? '全球' : 'Global'}</td>
|
||||
<td className="py-2 px-3 font-mono text-xs break-all">{CDN_FULL_URL}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3">{isZh ? '中国' : 'China'}</td>
|
||||
<td className="py-2 px-3 font-mono text-xs break-all">{CDN_FULL_CN_URL}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{/* Tips */}
|
||||
<section className="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg">
|
||||
<h3 className="text-base font-semibold mb-2 text-blue-900 dark:text-blue-300">
|
||||
💡 {isZh ? '提示' : 'Tips'}
|
||||
</h3>
|
||||
<ul className="text-gray-600 dark:text-gray-300 text-sm space-y-1">
|
||||
<li>• {isZh ? '生产环境推荐使用 NPM' : 'NPM is recommended for production'}</li>
|
||||
<li>• {isZh ? '生产环境建议锁定版本号' : 'Lock version number in production'}</li>
|
||||
<li>• {isZh ? '确保 HTTPS 环境' : 'Ensure HTTPS environment'}</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import CodeEditor from '@/components/CodeEditor'
|
||||
|
||||
export default function Configuration() {
|
||||
const { i18n } = useTranslation()
|
||||
const isZh = i18n.language === 'zh-CN'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-6">{isZh ? '配置选项' : 'Configuration'}</h1>
|
||||
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
|
||||
{isZh
|
||||
? 'PageAgent 的完整配置接口定义。'
|
||||
: 'Complete configuration interface for PageAgent.'}
|
||||
</p>
|
||||
|
||||
{/* LLM Configuration */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-2xl font-semibold mb-4">{isZh ? 'LLM 配置' : 'LLM Configuration'}</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{isZh
|
||||
? '配置与大语言模型的连接参数。'
|
||||
: 'Configure connection parameters for the language model.'}
|
||||
</p>
|
||||
<CodeEditor
|
||||
className="mb-4"
|
||||
language="typescript"
|
||||
code={`interface LLMConfig {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
|
||||
temperature?: number
|
||||
maxRetries?: number
|
||||
|
||||
/**
|
||||
* Custom fetch function for LLM API requests.
|
||||
* Use this to customize headers, credentials, proxy, etc.
|
||||
* The response should follow OpenAI API format.
|
||||
*/
|
||||
customFetch?: typeof globalThis.fetch
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Agent Configuration */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
{isZh ? 'Agent 配置' : 'Agent Configuration'}
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{isZh
|
||||
? '配置 Agent 的行为、生命周期钩子和扩展能力。'
|
||||
: 'Configure agent behavior, lifecycle hooks, and extension capabilities.'}
|
||||
</p>
|
||||
<CodeEditor
|
||||
className="mb-4"
|
||||
language="typescript"
|
||||
code={`interface AgentConfig {
|
||||
language?: 'en-US' | 'zh-CN'
|
||||
|
||||
/** Custom tools to extend or override built-in tools */
|
||||
customTools?: Record<string, PageAgentTool | null>
|
||||
|
||||
/** Instructions to guide the agent's behavior */
|
||||
instructions?: {
|
||||
/** Global system-level instructions, applied to all tasks */
|
||||
system?: string
|
||||
|
||||
/** Dynamic page-level instructions callback */
|
||||
getPageInstructions?: (url: string) => string | undefined | null
|
||||
}
|
||||
|
||||
// Lifecycle hooks (with \`this\` bound to PageAgent instance)
|
||||
onBeforeStep?: (this: PageAgent, stepCnt: number) => Promise<void> | void
|
||||
onAfterStep?: (this: PageAgent, stepCnt: number, history: HistoryEvent[]) => Promise<void> | void
|
||||
onBeforeTask?: (this: PageAgent) => Promise<void> | void
|
||||
onAfterTask?: (this: PageAgent, result: ExecutionResult) => Promise<void> | void
|
||||
onDispose?: (this: PageAgent, reason?: string) => void
|
||||
|
||||
/**
|
||||
* Transform page content before sending to LLM.
|
||||
* Use cases: inspect extraction results, modify page info, mask sensitive data.
|
||||
*/
|
||||
transformPageContent?: (content: string) => Promise<string> | string
|
||||
|
||||
/** @experimental Enable JavaScript execution tool */
|
||||
experimentalScriptExecutionTool?: boolean
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* PageController Configuration */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
{isZh ? 'PageController 配置' : 'PageController Configuration'}
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{isZh
|
||||
? '配置 DOM 提取、元素交互和视觉高亮的细节。'
|
||||
: 'Configure DOM extraction, element interaction, and visual highlighting.'}
|
||||
</p>
|
||||
<CodeEditor
|
||||
className="mb-4"
|
||||
language="typescript"
|
||||
code={`interface DomConfig {
|
||||
/** Elements to exclude from interaction */
|
||||
interactiveBlacklist?: (Element | (() => Element))[]
|
||||
|
||||
/** Elements to force include for interaction */
|
||||
interactiveWhitelist?: (Element | (() => Element))[]
|
||||
|
||||
/** Additional attributes to include in DOM extraction */
|
||||
include_attributes?: string[]
|
||||
|
||||
/** Highlight overlay opacity (0-1) */
|
||||
highlightOpacity?: number
|
||||
|
||||
/** Highlight label opacity (0-1) */
|
||||
highlightLabelOpacity?: number
|
||||
}
|
||||
|
||||
interface PageControllerConfig extends DomConfig {
|
||||
/** Viewport expansion in pixels */
|
||||
viewportExpansion?: number
|
||||
|
||||
/** Enable visual mask overlay during operations (default: false) */
|
||||
enableMask?: boolean
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Complete Type */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-2xl font-semibold mb-4">{isZh ? '完整类型' : 'Complete Type'}</h2>
|
||||
<CodeEditor
|
||||
language="typescript"
|
||||
code={`type PageAgentConfig = LLMConfig & AgentConfig & PageControllerConfig`}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import BetaNotice from '@/components/BetaNotice'
|
||||
|
||||
export default function SecurityPermissions() {
|
||||
const { i18n } = useTranslation()
|
||||
const isZh = i18n.language === 'zh-CN'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BetaNotice />
|
||||
|
||||
<h1 className="text-4xl font-bold mb-6">{isZh ? '安全与权限' : 'Security & Permissions'}</h1>
|
||||
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
|
||||
{isZh
|
||||
? 'page-agent 提供多种安全机制,确保 AI 操作在可控范围内进行。'
|
||||
: 'page-agent provides multiple security mechanisms to ensure AI operations stay within controlled boundaries.'}
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold mb-3">
|
||||
{isZh ? '元素操作黑白名单' : 'Element Interaction Allowlist/Blocklist'}
|
||||
</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">
|
||||
🚫 {isZh ? '操作黑名单' : 'Blocklist'}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{isZh
|
||||
? '禁止 AI 操作敏感元素,如删除按钮、支付按钮等。'
|
||||
: 'Prevent AI from interacting with sensitive elements like delete buttons, payment buttons, etc.'}
|
||||
</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">
|
||||
✅ {isZh ? '操作白名单' : 'Allowlist'}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{isZh
|
||||
? '明确定义 AI 可以操作的元素范围。'
|
||||
: 'Explicitly define which elements AI can interact with.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold mb-3">
|
||||
{isZh ? 'Instruction 安全约束' : 'Instruction Safety Constraints'}
|
||||
</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">
|
||||
⚠️ {isZh ? '高危操作控制' : 'High-Risk Operation Control'}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-3">
|
||||
{isZh
|
||||
? '在 AI 指令中明确列举高危操作,通过两种策略进行控制:'
|
||||
: 'Define high-risk operations in AI instructions and control them through two strategies:'}
|
||||
</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">
|
||||
{isZh ? '完全禁止操作' : 'Completely Forbidden'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{isZh
|
||||
? '对极高风险操作明确禁止执行'
|
||||
: 'Explicitly prohibit execution of extremely high-risk operations'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="pl-3 border-l-2 border-orange-400">
|
||||
<p className="font-medium text-orange-700 dark:text-orange-300">
|
||||
{isZh ? '需用户确认操作' : 'Requires User Confirmation'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{isZh
|
||||
? '对中等风险操作要求用户明确同意'
|
||||
: 'Require explicit user consent for medium-risk operations'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-26
@@ -74,19 +74,6 @@ const pageAgentTool = {
|
||||
</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 text-gray-900 dark:text-white">错误处理</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 text-gray-900 dark:text-white">权限控制</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">
|
||||
⚠️ 注意事项
|
||||
@@ -97,19 +84,6 @@ const pageAgentTool = {
|
||||
<li>• 敏感操作建议要求人工确认</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-linear-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 text-gray-900 dark:text-white">🎉 开始集成</h3>
|
||||
<p className="mb-3 text-gray-700 dark:text-gray-300">
|
||||
通过这种方式,你的 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>
|
||||
)
|
||||
}
|
||||
+2
-9
@@ -6,13 +6,13 @@ export default function Overview() {
|
||||
return (
|
||||
<article>
|
||||
{/* 头图 */}
|
||||
<figure className="mb-8 rounded-xl overflow-hidden">
|
||||
{/* <figure className="mb-8 rounded-xl overflow-hidden">
|
||||
<img
|
||||
src="https://img.alicdn.com/imgextra/i3/O1CN014xLeic1gnF2iWdzjJ_!!6000000004186-49-tps-1280-353.webp"
|
||||
alt="page-agent"
|
||||
className="w-full h-64 object-cover"
|
||||
/>
|
||||
</figure>
|
||||
</figure> */}
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold mb-4">{t('overview.title')}</h1>
|
||||
@@ -22,13 +22,6 @@ export default function Overview() {
|
||||
|
||||
{/* Status Badges */}
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<a
|
||||
href="https://www.npmjs.com/package/page-agent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img src="https://badge.fury.io/js/page-agent.svg" alt="npm version" />
|
||||
</a>
|
||||
<a href="https://opensource.org/licenses/MIT" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="MIT License" />
|
||||
</a>
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import BetaNotice from '@/components/BetaNotice'
|
||||
import CodeEditor from '@/components/CodeEditor'
|
||||
import { CDN_DEMO_CN_URL, CDN_DEMO_URL } from '@/constants'
|
||||
|
||||
export default function QuickStart() {
|
||||
const { t, i18n } = useTranslation('docs')
|
||||
const isZh = i18n.language === 'zh-CN'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-6">{t('quick_start.title')}</h1>
|
||||
|
||||
<p className=" mb-6 leading-relaxed">{t('quick_start.subtitle')}</p>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-3">{t('quick_start.installation')}</h2>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
{/* Demo CDN - One Line */}
|
||||
<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">
|
||||
{isZh ? '🚀 快速体验(Demo CDN)' : '🚀 Quick Try (Demo CDN)'}
|
||||
</h3>
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 p-2 rounded mb-3 text-sm">
|
||||
<span className="text-yellow-800 dark:text-yellow-200">
|
||||
⚠️ {isZh ? '仅用于技术评估' : 'For evaluation only'}
|
||||
</span>
|
||||
</div>
|
||||
<CodeEditor
|
||||
code={`// Global: ${CDN_DEMO_URL}
|
||||
// China: ${CDN_DEMO_CN_URL}
|
||||
<script src="${isZh ? CDN_DEMO_CN_URL : CDN_DEMO_URL}" crossorigin="true"></script>`}
|
||||
language="html"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* NPM - Recommended */}
|
||||
<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">
|
||||
{isZh ? '📦 NPM 安装(推荐)' : '📦 NPM Install (Recommended)'}
|
||||
</h3>
|
||||
<CodeEditor
|
||||
code={`npm install page-agent
|
||||
|
||||
import { PageAgent } from 'page-agent'`}
|
||||
language="bash"
|
||||
/>
|
||||
</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">
|
||||
{t('quick_start.step2_title')}
|
||||
</h3>
|
||||
<CodeEditor
|
||||
code={`const agent = new PageAgent({
|
||||
model: 'deepseek-chat',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
language: '${isZh ? 'zh-CN' : 'en-US'}'
|
||||
})`}
|
||||
language="javascript"
|
||||
/>
|
||||
</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">
|
||||
{t('quick_start.step3_title')}
|
||||
</h3>
|
||||
<CodeEditor
|
||||
code={`// ${isZh ? '程序化执行自然语言指令' : 'Execute natural language instructions programmatically'}
|
||||
await agent.execute('${isZh ? '点击提交按钮,然后填写用户名为张三' : 'Click submit button, then fill username as John'}');
|
||||
|
||||
// ${isZh ? '或者显示对话框让用户输入指令' : 'Or show panel for user to input instructions'}
|
||||
agent.panel.show()
|
||||
`}
|
||||
language="javascript"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+24
-138
@@ -1,148 +1,34 @@
|
||||
import { Suspense, lazy } from 'react'
|
||||
import { Route, Switch } from 'wouter'
|
||||
|
||||
import DocsLayout from './components/DocsLayout'
|
||||
import Header from './components/Header'
|
||||
import Instructions from './docs/features/custom-instructions/page'
|
||||
// Features pages
|
||||
import CustomTools from './docs/features/custom-tools/page'
|
||||
import DataMasking from './docs/features/data-masking/page'
|
||||
import ModelIntegration from './docs/features/model-integration/page'
|
||||
import SecurityPermissions from './docs/features/security-permissions/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'
|
||||
// Lazy load pages
|
||||
const HomePage = lazy(() => import('./pages/Home'))
|
||||
const DocsPages = lazy(() => import('./pages/docs/index'))
|
||||
|
||||
export default function Router() {
|
||||
return (
|
||||
<Switch>
|
||||
{/* Home page */}
|
||||
<Route path="/" component={HomePage} />
|
||||
<Suspense>
|
||||
<Switch>
|
||||
{/* Home page */}
|
||||
<Route path="/">
|
||||
<HomePage />
|
||||
</Route>
|
||||
|
||||
{/* 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>
|
||||
{/* All docs pages */}
|
||||
<Route path="/docs" nest>
|
||||
<DocsPages />
|
||||
</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/custom-instructions">
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
||||
<Header />
|
||||
<DocsLayout>
|
||||
<Instructions />
|
||||
</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/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>
|
||||
{/* 404 */}
|
||||
<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>
|
||||
</div>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
# 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 构建
|
||||
- 响应式设计,支持不同屏幕尺寸
|
||||
- 深色模式支持
|
||||
- 无需外部依赖,完全自包含
|
||||
- 模拟真实的网络延迟和错误
|
||||
|
||||
## 扩展建议
|
||||
|
||||
如需添加新的测试场景,建议考虑以下方面:
|
||||
- 特定行业的业务流程
|
||||
- 更复杂的数据可视化交互
|
||||
- 多媒体内容处理
|
||||
- 实时协作功能
|
||||
- 移动端特有的交互模式
|
||||
|
||||
每个新页面都应该:
|
||||
- 有明确的测试目标
|
||||
- 包含多种难度级别的任务
|
||||
- 提供清晰的状态反馈
|
||||
- 模拟真实的用户场景
|
||||
@@ -1,543 +0,0 @@
|
||||
import { useEffect, useState } 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) => (
|
||||
<div
|
||||
key={data}
|
||||
className={`p-3 rounded-lg border transition-all duration-300 ${
|
||||
data === realTimeData[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, logIdx) => {
|
||||
const logKey = `${logIdx + 1}-${log.substring(0, 30)}`
|
||||
return (
|
||||
<div
|
||||
key={logKey}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,582 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,464 +0,0 @@
|
||||
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="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="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">
|
||||
选择文件 (最大5MB,支持图片和PDF)
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { Link } from 'wouter'
|
||||
|
||||
export default function IndexPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-linear-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>
|
||||
)
|
||||
}
|
||||
@@ -1,481 +0,0 @@
|
||||
import { useEffect, useMemo, useState } 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),
|
||||
}))
|
||||
}
|
||||
|
||||
// Loading skeleton component
|
||||
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 }, (_, i) => `skeleton-item-${i}`).map((id) => (
|
||||
<div key={id} 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>
|
||||
)
|
||||
|
||||
// Product card component
|
||||
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) => (
|
||||
<span key={tag} 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>
|
||||
)
|
||||
|
||||
// Product list item component
|
||||
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 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>
|
||||
)
|
||||
|
||||
// Pagination component
|
||||
interface PaginationProps {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
startIndex: number
|
||||
endIndex: number
|
||||
totalItems: number
|
||||
onPageChange: (page: number) => void
|
||||
}
|
||||
|
||||
const Pagination = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
startIndex,
|
||||
endIndex,
|
||||
totalItems,
|
||||
onPageChange,
|
||||
}: PaginationProps) => {
|
||||
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, totalItems)} 条, 共 {totalItems} 条结果
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => onPageChange(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={() => onPageChange(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={() => onPageChange(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>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ListTestPage() {
|
||||
const [products, setProducts] = 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 = ['全部', '手机', '电脑', '平板', '耳机', '手表', '相机']
|
||||
|
||||
// Helper to set filters and reset page
|
||||
const handleSearchChange = (term: string) => {
|
||||
setSearchTerm(term)
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
const handleCategoryChange = (category: string) => {
|
||||
setSelectedCategory(category)
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
const handleSortChange = (sort: string) => {
|
||||
setSortBy(sort)
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
const handleSortOrderChange = (order: 'asc' | 'desc') => {
|
||||
setSortOrder(order)
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
// 模拟数据加载
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
// 模拟网络延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
const data = generateProducts(150)
|
||||
setProducts(data)
|
||||
setLoading(false)
|
||||
}
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
// 搜索和过滤 - Use useMemo to compute filtered products
|
||||
const filteredProducts = useMemo(() => {
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
return filtered
|
||||
}, [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' })
|
||||
}
|
||||
|
||||
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) => handleSearchChange(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) => handleCategoryChange(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) => handleSortChange(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) => handleSortOrderChange(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: Product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{currentProducts.map((product: Product) => (
|
||||
<ProductListItem key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
startIndex={startIndex}
|
||||
endIndex={endIndex}
|
||||
totalItems={filteredProducts.length}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 返回顶部按钮 */}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,566 +0,0 @@
|
||||
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, crumbIdx) => {
|
||||
const isLast = crumbIdx === breadcrumbs.length - 1
|
||||
const showSeparator = crumbIdx > 0
|
||||
return (
|
||||
<li key={`${crumb}-${crumbIdx + 1}`} className="flex items-center">
|
||||
{showSeparator && (
|
||||
<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(crumbIdx)}
|
||||
className={`text-sm font-medium transition-colors ${
|
||||
isLast
|
||||
? '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) => (
|
||||
<div
|
||||
key={product}
|
||||
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) => (
|
||||
<tr key={order.id}>
|
||||
<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-linear-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-linear-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>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Route, Switch } from 'wouter'
|
||||
|
||||
import AsyncTestPage from './async-test'
|
||||
import ComplexTestPage from './complex-test'
|
||||
import ErrorTestPage from './error-test'
|
||||
import FormTestPage from './form-test'
|
||||
import IndexPage from './index'
|
||||
import ListTestPage from './list-test'
|
||||
import NavigationTestPage from './navigation-test'
|
||||
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +1,35 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import process from 'node:process'
|
||||
import { dirname, resolve } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const pageAgentPkg = JSON.parse(
|
||||
readFileSync(resolve(__dirname, '../page-agent/package.json'), 'utf-8')
|
||||
)
|
||||
|
||||
// Load .env from repo root
|
||||
dotenvConfig({ path: resolve(__dirname, '../../.env') })
|
||||
|
||||
// Website Config (React Documentation Site)
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: './',
|
||||
clearScreen: false,
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
chunkSizeWarningLimit: 2000,
|
||||
cssCodeSplit: true,
|
||||
rollupOptions: {
|
||||
onwarn: function (message, handler) {
|
||||
if (message.code === 'EVAL') return
|
||||
handler(message)
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
// Self root
|
||||
@@ -29,8 +43,11 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
define: {
|
||||
'import.meta.env.LLM_MODEL_NAME': JSON.stringify(process.env.LLM_MODEL_NAME),
|
||||
'import.meta.env.LLM_API_KEY': JSON.stringify(process.env.LLM_API_KEY),
|
||||
'import.meta.env.LLM_BASE_URL': JSON.stringify(process.env.LLM_BASE_URL),
|
||||
...(mode === 'development' && {
|
||||
'import.meta.env.LLM_MODEL_NAME': JSON.stringify(process.env.LLM_MODEL_NAME),
|
||||
'import.meta.env.LLM_API_KEY': JSON.stringify(process.env.LLM_API_KEY),
|
||||
'import.meta.env.LLM_BASE_URL': JSON.stringify(process.env.LLM_BASE_URL),
|
||||
}),
|
||||
'import.meta.env.VERSION': JSON.stringify(pageAgentPkg.version),
|
||||
},
|
||||
})
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user