Compare commits

..

28 Commits

Author SHA1 Message Date
Simon 0d3cef5578 chore(version): bump version to 0.0.21 2026-01-13 15:18:50 +08:00
Simon 1e737f3a71 docs: models recommendation 2026-01-13 15:17:43 +08:00
Simon 2f2bdb4697 fix: update Qwen patch condition for model name 2026-01-13 15:07:01 +08:00
Simon 3fa7c8b85a feat: improve qwen3 compatibility (#106)
* feat: improve qwen3 compatibility

* fix: qwen

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-13 15:04:03 +08:00
dependabot[bot] 7efce91a91 chore(deps-dev): bump the development-dependencies group with 11 updates (#101)
Bumps the development-dependencies group with 11 updates:

| Package | From | To |
| --- | --- | --- |
| [@commitlint/cli](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/cli) | `20.3.0` | `20.3.1` |
| [@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/config-conventional) | `20.3.0` | `20.3.1` |
| [@trivago/prettier-plugin-sort-imports](https://github.com/trivago/prettier-plugin-sort-imports) | `6.0.1` | `6.0.2` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.0.3` | `25.0.6` |
| [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/HEAD/packages/plugins/eslint-plugin-react-dom) | `2.5.1` | `2.5.5` |
| [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/HEAD/packages/plugins/eslint-plugin-react-x) | `2.5.1` | `2.5.5` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.52.0` | `8.53.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `7.3.0` | `7.3.1` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.7` | `19.2.8` |
| [i18next](https://github.com/i18next/i18next) | `25.7.3` | `25.7.4` |
| [react-i18next](https://github.com/i18next/react-i18next) | `16.5.1` | `16.5.2` |


Updates `@commitlint/cli` from 20.3.0 to 20.3.1
- [Release notes](https://github.com/conventional-changelog/commitlint/releases)
- [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/cli/CHANGELOG.md)
- [Commits](https://github.com/conventional-changelog/commitlint/commits/v20.3.1/@commitlint/cli)

Updates `@commitlint/config-conventional` from 20.3.0 to 20.3.1
- [Release notes](https://github.com/conventional-changelog/commitlint/releases)
- [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/config-conventional/CHANGELOG.md)
- [Commits](https://github.com/conventional-changelog/commitlint/commits/v20.3.1/@commitlint/config-conventional)

Updates `@trivago/prettier-plugin-sort-imports` from 6.0.1 to 6.0.2
- [Release notes](https://github.com/trivago/prettier-plugin-sort-imports/releases)
- [Changelog](https://github.com/trivago/prettier-plugin-sort-imports/blob/main/CHANGELOG.md)
- [Commits](https://github.com/trivago/prettier-plugin-sort-imports/compare/v6.0.1...v6.0.2)

Updates `@types/node` from 25.0.3 to 25.0.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint-plugin-react-dom` from 2.5.1 to 2.5.5
- [Release notes](https://github.com/Rel1cx/eslint-react/releases)
- [Changelog](https://github.com/Rel1cx/eslint-react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Rel1cx/eslint-react/commits/v2.5.5/packages/plugins/eslint-plugin-react-dom)

Updates `eslint-plugin-react-x` from 2.5.1 to 2.5.5
- [Release notes](https://github.com/Rel1cx/eslint-react/releases)
- [Changelog](https://github.com/Rel1cx/eslint-react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Rel1cx/eslint-react/commits/v2.5.5/packages/plugins/eslint-plugin-react-x)

Updates `typescript-eslint` from 8.52.0 to 8.53.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.53.0/packages/typescript-eslint)

Updates `vite` from 7.3.0 to 7.3.1
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.1/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.1/packages/vite)

Updates `@types/react` from 19.2.7 to 19.2.8
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `i18next` from 25.7.3 to 25.7.4
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v25.7.3...v25.7.4)

Updates `react-i18next` from 16.5.1 to 16.5.2
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v16.5.1...v16.5.2)

---
updated-dependencies:
- dependency-name: "@commitlint/cli"
  dependency-version: 20.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@commitlint/config-conventional"
  dependency-version: 20.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@trivago/prettier-plugin-sort-imports"
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@types/node"
  dependency-version: 25.0.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: eslint-plugin-react-dom
  dependency-version: 2.5.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: eslint-plugin-react-x
  dependency-version: 2.5.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.53.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: vite
  dependency-version: 7.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: "@types/react"
  dependency-version: 19.2.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: i18next
  dependency-version: 25.7.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: react-i18next
  dependency-version: 16.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-13 14:30:02 +08:00
dependabot[bot] a11d370f2b chore(deps): bump the production-dependencies group with 3 updates (#100)
Bumps the production-dependencies group with 3 updates: [ai-motion](https://github.com/gaomeng1900/ai-motion), [motion](https://github.com/motiondivision/motion) and [simple-icons](https://github.com/simple-icons/simple-icons).


Updates `ai-motion` from 0.4.7 to 0.4.8
- [Release notes](https://github.com/gaomeng1900/ai-motion/releases)
- [Commits](https://github.com/gaomeng1900/ai-motion/compare/v0.4.7...v0.4.8)

Updates `motion` from 12.24.0 to 12.26.1
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.24.0...v12.26.1)

Updates `simple-icons` from 16.4.0 to 16.5.0
- [Release notes](https://github.com/simple-icons/simple-icons/releases)
- [Commits](https://github.com/simple-icons/simple-icons/compare/16.4.0...16.5.0)

---
updated-dependencies:
- dependency-name: ai-motion
  dependency-version: 0.4.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: motion
  dependency-version: 12.26.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: simple-icons
  dependency-version: 16.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-13 14:27:23 +08:00
dependabot[bot] d7bfa42f85 chore(deps-dev): bump globals from 16.5.0 to 17.0.0 (#102)
Bumps [globals](https://github.com/sindresorhus/globals) from 16.5.0 to 17.0.0.
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v16.5.0...v17.0.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-13 14:26:37 +08:00
Simon 551ba49280 Merge pull request #104 from alibaba/refactor/mv-brain-outof-llms
refactor: move agent logic out of `llms`
2026-01-13 14:24:29 +08:00
Simon adab24bb22 Merge branch 'refactor/mv-brain-outof-llms' of https://github.com/alibaba/page-agent into refactor/mv-brain-outof-llms 2026-01-13 14:22:48 +08:00
Simon 64aea7e84c chore: typing 2026-01-13 14:21:57 +08:00
Simon 67f6bd619e chore: rename autoFixer for better understanding 2026-01-13 14:20:52 +08:00
Simon 35d1fd1166 chore: better erroring
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-13 14:16:51 +08:00
Simon d5b8019fb1 chore: better docs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-13 14:14:56 +08:00
Simon 526cb4dbb3 chore: rename OpenAIClient; clean up old notes 2026-01-13 13:58:21 +08:00
Simon 6dc56c57c6 chore: rm legacy llm client 2026-01-13 13:56:30 +08:00
Simon a3263fdd3a chore: rename OpenAICompatibleClient 2026-01-13 13:51:09 +08:00
Simon 14974c0257 feat!: mv brain from llms to agent; redo toolCall auto fixer 2026-01-13 13:49:19 +08:00
Simon e70ae40096 chore: guardrail 2026-01-12 13:55:39 +08:00
Simon 3e04a131f1 chore(version): bump version to 0.0.20 2026-01-11 01:40:54 +08:00
Simon b487a7900b feat(website): layout change 2026-01-11 01:40:21 +08:00
Simon 78b49c8b7a Merge pull request #99 from alibaba/refactor/website-structure
refactor: website structure
2026-01-11 01:21:55 +08:00
Simon 949e511dcf Merge pull request #98 from alibaba/feat/data-masking
feat: data masking
2026-01-11 01:17:08 +08:00
Simon d9faca5d01 refactor: normalize website structure 2026-01-11 01:16:57 +08:00
Simon 398c6d0869 chore: rm test-pages 2026-01-11 01:04:43 +08:00
Simon 54d478df1e docs: update data masking docs 2026-01-11 00:58:42 +08:00
Simon 43c80f5418 feat: transformPageContent hook for data-masking 2026-01-11 00:54:59 +08:00
Simon b186ffcaa6 chore: info about CDN 2026-01-11 00:05:21 +08:00
Simon 778bbda4b8 chore: remove roadmap 2026-01-10 23:53:19 +08:00
55 changed files with 1145 additions and 4719 deletions
+1
View File
@@ -123,4 +123,5 @@ Query params configure `PageAgentConfig` in `src/umd.ts`.
- Explicit typing for exported/public APIs - Explicit typing for exported/public APIs
- ESLint relaxes some unsafe rules for rapid iteration - ESLint relaxes some unsafe rules for rapid iteration
- Every change you make should not only implement the desired functionality but also improve the quality of the codebase
- All code and comments must be in English. - All code and comments must be in English.
+19 -14
View File
@@ -8,7 +8,7 @@
🌐 [English](./README.md) | **中文** 🌐 [English](./README.md) | **中文**
👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/#/docs/introduction/overview" target="_blank"><b>📖 Documentation</b></a> 👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/#/docs/introduction/overview" target="_blank"><b>📖 Documentation</b></a>
<video id="demo-video" src="https://github.com/user-attachments/assets/141bbb01-8022-4d1f-919d-9efc9a1dc1cf" width="640" crossorigin muted autoplay loop></video> <video id="demo-video" src="https://github.com/user-attachments/assets/141bbb01-8022-4d1f-919d-9efc9a1dc1cf" width="640" crossorigin muted autoplay loop></video>
@@ -24,22 +24,10 @@
## 🗺️ Roadmap ## 🗺️ Roadmap
👉 [**Roadmap**](./ROADMAP.md) 👉 [**Roadmap**](https://github.com/alibaba/page-agent/issues/96)
## 🚀 快速开始 ## 🚀 快速开始
### CDN 集成
```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>
```
### NPM 安装 ### NPM 安装
```bash ```bash
@@ -66,6 +54,23 @@ const agent = new PageAgent({
await agent.execute('点击登录按钮') await agent.execute('点击登录按钮')
``` ```
### CDN 集成
Fastest way to try PageAgent is to include it via CDN. Demo model will be used by default.
| Location | URL |
| -------- | ----------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@latest/dist/umd/page-agent.js |
| China | https://registry.npmmirror.com/page-agent/latest/files/dist/umd/page-agent.js |
```html
<script
src="https://registry.npmmirror.com/page-agent/latest/files/dist/umd/page-agent.js"
crossorigin="true"
type="text/javascript"
></script>
```
## 🏗️ 架构设计 ## 🏗️ 架构设计
PageAgent adopts a simplified monorepo structure: PageAgent adopts a simplified monorepo structure:
+19 -14
View File
@@ -8,7 +8,7 @@ The GUI Agent Living in Your Webpage. Control web interfaces with natural langua
🌐 **English** | [中文](./README-zh.md) 🌐 **English** | [中文](./README-zh.md)
👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/#/docs/introduction/overview" target="_blank"><b>📖 Documentation</b></a> 👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/#/docs/introduction/overview" target="_blank"><b>📖 Documentation</b></a>
<video id="demo-video" src="https://github.com/user-attachments/assets/de8d1964-8bde-494f-a52f-2975469557a5" width="640" crossorigin muted autoplay loop></video> <video id="demo-video" src="https://github.com/user-attachments/assets/de8d1964-8bde-494f-a52f-2975469557a5" width="640" crossorigin muted autoplay loop></video>
@@ -24,22 +24,10 @@ The GUI Agent Living in Your Webpage. Control web interfaces with natural langua
## 🗺️ Roadmap ## 🗺️ Roadmap
👉 [**Roadmap**](./ROADMAP.md) 👉 [**Roadmap**](https://github.com/alibaba/page-agent/issues/96)
## 🚀 Quick Start ## 🚀 Quick Start
### CDN Integration
```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>
```
### NPM Installation ### NPM Installation
```bash ```bash
@@ -66,6 +54,23 @@ const agent = new PageAgent({
await agent.execute('Click the login button') await agent.execute('Click the login button')
``` ```
### CDN Integration
Fastest way to try PageAgent is to include it via CDN. Demo model will be used by default.
| Location | URL |
| -------- | ----------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@latest/dist/umd/page-agent.js |
| China | https://registry.npmmirror.com/page-agent/latest/files/dist/umd/page-agent.js |
```html
<script
src="https://cdn.jsdelivr.net/npm/page-agent@latest/dist/umd/page-agent.js"
crossorigin="true"
type="text/javascript"
></script>
```
## 🏗️ Structure ## 🏗️ Structure
PageAgent adopts a simplified monorepo structure: PageAgent adopts a simplified monorepo structure:
-61
View File
@@ -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
View File
@@ -8,12 +8,7 @@ import globals from 'globals'
import tseslint from 'typescript-eslint' import tseslint from 'typescript-eslint'
export default defineConfig([ export default defineConfig([
globalIgnores([ globalIgnores(['**/dist', '**/node_modules', 'packages/website/src/components/ui']),
'**/dist',
'**/test-pages',
'**/node_modules',
'packages/website/src/components/ui',
]),
{ {
plugins: { plugins: {
'react-hooks': reactHooks, 'react-hooks': reactHooks,
+247 -247
View File
@@ -1,12 +1,12 @@
{ {
"name": "root", "name": "root",
"version": "0.0.19", "version": "0.0.21",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "root", "name": "root",
"version": "0.0.19", "version": "0.0.21",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"packages/page-controller", "packages/page-controller",
@@ -16,28 +16,28 @@
"packages/website" "packages/website"
], ],
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^20.3.0", "@commitlint/cli": "^20.3.1",
"@commitlint/config-conventional": "^20.3.0", "@commitlint/config-conventional": "^20.3.1",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@microsoft/api-extractor": "^7.55.2", "@microsoft/api-extractor": "^7.55.2",
"@trivago/prettier-plugin-sort-imports": "^6.0.1", "@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^25.0.3", "@types/node": "^25.0.7",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "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-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26", "eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-react-x": "^2.5.1", "eslint-plugin-react-x": "^2.5.7",
"globals": "^16.4.0", "globals": "^17.0.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.2.4", "lint-staged": "^16.2.4",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.52.0", "typescript-eslint": "^8.53.0",
"unplugin-dts": "^1.0.0-beta.6", "unplugin-dts": "^1.0.0-beta.6",
"vite": "^7.3.0", "vite": "^7.3.1",
"vite-plugin-css-injected-by-js": "^3.5.2" "vite-plugin-css-injected-by-js": "^3.5.2"
}, },
"engines": { "engines": {
@@ -316,17 +316,17 @@
} }
}, },
"node_modules/@commitlint/cli": { "node_modules/@commitlint/cli": {
"version": "20.3.0", "version": "20.3.1",
"resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.3.0.tgz", "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.3.1.tgz",
"integrity": "sha512-HXO8YVfqdBK+MnlX2zqNrv6waGYPs6Ysjm5W2Y0GMagWXwiIKx7C8dcIX9ca+QdHq4WA0lcMnZLQ0pzQh1piZg==", "integrity": "sha512-NtInjSlyev/+SLPvx/ulz8hRE25Wf5S9dLNDcIwazq0JyB4/w1ROF/5nV0ObPTX8YpRaKYeKtXDYWqumBNHWsw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@commitlint/format": "^20.2.0", "@commitlint/format": "^20.3.1",
"@commitlint/lint": "^20.3.0", "@commitlint/lint": "^20.3.1",
"@commitlint/load": "^20.3.0", "@commitlint/load": "^20.3.1",
"@commitlint/read": "^20.2.0", "@commitlint/read": "^20.3.1",
"@commitlint/types": "^20.2.0", "@commitlint/types": "^20.3.1",
"tinyexec": "^1.0.0", "tinyexec": "^1.0.0",
"yargs": "^17.0.0" "yargs": "^17.0.0"
}, },
@@ -338,13 +338,13 @@
} }
}, },
"node_modules/@commitlint/config-conventional": { "node_modules/@commitlint/config-conventional": {
"version": "20.3.0", "version": "20.3.1",
"resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.3.0.tgz", "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.3.1.tgz",
"integrity": "sha512-g1OXVl6E2v0xF1Ru2RpxQ+Vfy7XUcUsCmLKzGUrhFLS4hSNykje0QSy6djBtzOiOBQCepBrmIlqx/gRlzrSh5A==", "integrity": "sha512-NCzwvxepstBZbmVXsvg49s+shCxlJDJPWxXqONVcAtJH9wWrOlkMQw/zyl+dJmt8lyVopt5mwQ3mR5M2N2rUWg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@commitlint/types": "^20.2.0", "@commitlint/types": "^20.3.1",
"conventional-changelog-conventionalcommits": "^7.0.2" "conventional-changelog-conventionalcommits": "^7.0.2"
}, },
"engines": { "engines": {
@@ -352,13 +352,13 @@
} }
}, },
"node_modules/@commitlint/config-validator": { "node_modules/@commitlint/config-validator": {
"version": "20.2.0", "version": "20.3.1",
"resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.2.0.tgz", "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.3.1.tgz",
"integrity": "sha512-SQCBGsL9MFk8utWNSthdxd9iOD1pIVZSHxGBwYIGfd67RTjxqzFOSAYeQVXOu3IxRC3YrTOH37ThnTLjUlyF2w==", "integrity": "sha512-ErVLC/IsHhcvxCyh+FXo7jy12/nkQySjWXYgCoQbZLkFp4hysov8KS6CdxBB0cWjbZWjvNOKBMNoUVqkmGmahw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@commitlint/types": "^20.2.0", "@commitlint/types": "^20.3.1",
"ajv": "^8.11.0" "ajv": "^8.11.0"
}, },
"engines": { "engines": {
@@ -366,13 +366,13 @@
} }
}, },
"node_modules/@commitlint/ensure": { "node_modules/@commitlint/ensure": {
"version": "20.2.0", "version": "20.3.1",
"resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.2.0.tgz", "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.3.1.tgz",
"integrity": "sha512-+8TgIGv89rOWyt3eC6lcR1H7hqChAKkpawytlq9P1i/HYugFRVqgoKJ8dhd89fMnlrQTLjA5E97/4sF09QwdoA==", "integrity": "sha512-h664FngOEd7bHAm0j8MEKq+qm2mH+V+hwJiIE2bWcw3pzJMlO0TPKtk0ATyRAtV6jQw+xviRYiIjjSjfajiB5w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@commitlint/types": "^20.2.0", "@commitlint/types": "^20.3.1",
"lodash.camelcase": "^4.3.0", "lodash.camelcase": "^4.3.0",
"lodash.kebabcase": "^4.1.1", "lodash.kebabcase": "^4.1.1",
"lodash.snakecase": "^4.1.1", "lodash.snakecase": "^4.1.1",
@@ -394,13 +394,13 @@
} }
}, },
"node_modules/@commitlint/format": { "node_modules/@commitlint/format": {
"version": "20.2.0", "version": "20.3.1",
"resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.2.0.tgz", "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.3.1.tgz",
"integrity": "sha512-PhNoLNhxpfIBlW/i90uZ3yG3hwSSYx7n4d9Yc+2FAorAHS0D9btYRK4ZZXX+Gm3W5tDtu911ow/eWRfcRVgNWg==", "integrity": "sha512-jfsjGPFTd2Yti2YHwUH4SPRPbWKAJAwrfa3eNa9bXEdrXBb9mCwbIrgYX38LdEJK9zLJ3AsLBP4/FLEtxyu2AA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@commitlint/types": "^20.2.0", "@commitlint/types": "^20.3.1",
"chalk": "^5.3.0" "chalk": "^5.3.0"
}, },
"engines": { "engines": {
@@ -408,13 +408,13 @@
} }
}, },
"node_modules/@commitlint/is-ignored": { "node_modules/@commitlint/is-ignored": {
"version": "20.2.0", "version": "20.3.1",
"resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.2.0.tgz", "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.3.1.tgz",
"integrity": "sha512-Lz0OGeZCo/QHUDLx5LmZc0EocwanneYJUM8z0bfWexArk62HKMLfLIodwXuKTO5y0s6ddXaTexrYHs7v96EOmw==", "integrity": "sha512-tWwAoh93QvAhxgp99CzCuHD86MgxE4NBtloKX+XxQxhfhSwHo7eloiar/yzx53YW9eqSLP95zgW2KDDk4/WX+A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@commitlint/types": "^20.2.0", "@commitlint/types": "^20.3.1",
"semver": "^7.6.0" "semver": "^7.6.0"
}, },
"engines": { "engines": {
@@ -422,32 +422,32 @@
} }
}, },
"node_modules/@commitlint/lint": { "node_modules/@commitlint/lint": {
"version": "20.3.0", "version": "20.3.1",
"resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.3.0.tgz", "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.3.1.tgz",
"integrity": "sha512-X19HOGU5nRo6i9DIY0kG0mhgtvpn1UGO1D6aLX1ILLyeqSM5yJyMcrRqNj8SLgeSeUDODhLY9QYsBIG0LdNHkA==", "integrity": "sha512-LaOtrQ24+6SfUaWg8A+a+Wc77bvLbO5RIr6iy9F7CI3/0iq1uPEWgGRCwqWTuLGHkZDAcwaq0gZ01zpwZ1jCGw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@commitlint/is-ignored": "^20.2.0", "@commitlint/is-ignored": "^20.3.1",
"@commitlint/parse": "^20.2.0", "@commitlint/parse": "^20.3.1",
"@commitlint/rules": "^20.3.0", "@commitlint/rules": "^20.3.1",
"@commitlint/types": "^20.2.0" "@commitlint/types": "^20.3.1"
}, },
"engines": { "engines": {
"node": ">=v18" "node": ">=v18"
} }
}, },
"node_modules/@commitlint/load": { "node_modules/@commitlint/load": {
"version": "20.3.0", "version": "20.3.1",
"resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.3.0.tgz", "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.3.1.tgz",
"integrity": "sha512-amkdVZTXp5R65bsRXRSCwoNXbJHR2aAIY/RGFkoyd63t8UEwqEgT3f0MgeLqYw4hwXyq+TYXKdaW133E29pnGQ==", "integrity": "sha512-YDD9XA2XhgYgbjju8itZ/weIvOOobApDqwlPYCX5NLO/cPtw2UMO5Cmn44Ks8RQULUVI5fUT6roKvyxcoLbNmw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@commitlint/config-validator": "^20.2.0", "@commitlint/config-validator": "^20.3.1",
"@commitlint/execute-rule": "^20.0.0", "@commitlint/execute-rule": "^20.0.0",
"@commitlint/resolve-extends": "^20.2.0", "@commitlint/resolve-extends": "^20.3.1",
"@commitlint/types": "^20.2.0", "@commitlint/types": "^20.3.1",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"cosmiconfig": "^9.0.0", "cosmiconfig": "^9.0.0",
"cosmiconfig-typescript-loader": "^6.1.0", "cosmiconfig-typescript-loader": "^6.1.0",
@@ -470,13 +470,13 @@
} }
}, },
"node_modules/@commitlint/parse": { "node_modules/@commitlint/parse": {
"version": "20.2.0", "version": "20.3.1",
"resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.2.0.tgz", "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.3.1.tgz",
"integrity": "sha512-LXStagGU1ivh07X7sM+hnEr4BvzFYn1iBJ6DRg2QsIN8lBfSzyvkUcVCDwok9Ia4PWiEgei5HQjju6xfJ1YaSQ==", "integrity": "sha512-TuUTdbLpyUNLgDzLDYlI2BeTE6V/COZbf3f8WwsV0K6eq/2nSpNTMw7wHtXb+YxeY9wwxBp/Ldad4P+YIxHJoA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@commitlint/types": "^20.2.0", "@commitlint/types": "^20.3.1",
"conventional-changelog-angular": "^7.0.0", "conventional-changelog-angular": "^7.0.0",
"conventional-commits-parser": "^5.0.0" "conventional-commits-parser": "^5.0.0"
}, },
@@ -485,14 +485,14 @@
} }
}, },
"node_modules/@commitlint/read": { "node_modules/@commitlint/read": {
"version": "20.2.0", "version": "20.3.1",
"resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.2.0.tgz", "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.3.1.tgz",
"integrity": "sha512-+SjF9mxm5JCbe+8grOpXCXMMRzAnE0WWijhhtasdrpJoAFJYd5UgRTj/oCq5W3HJTwbvTOsijEJ0SUGImECD7Q==", "integrity": "sha512-nCmJAdIg3OdNVUpQW0Idk/eF/vfOo2W2xzmvRmNeptLrzFK7qhwwl/kIwy1Q1LZrKHUFNj7PGNpIT5INbgZWzA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@commitlint/top-level": "^20.0.0", "@commitlint/top-level": "^20.0.0",
"@commitlint/types": "^20.2.0", "@commitlint/types": "^20.3.1",
"git-raw-commits": "^4.0.0", "git-raw-commits": "^4.0.0",
"minimist": "^1.2.8", "minimist": "^1.2.8",
"tinyexec": "^1.0.0" "tinyexec": "^1.0.0"
@@ -502,14 +502,14 @@
} }
}, },
"node_modules/@commitlint/resolve-extends": { "node_modules/@commitlint/resolve-extends": {
"version": "20.2.0", "version": "20.3.1",
"resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.2.0.tgz", "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.3.1.tgz",
"integrity": "sha512-KVoLDi9BEuqeq+G0wRABn4azLRiCC22/YHR2aCquwx6bzCHAIN8hMt3Nuf1VFxq/c8ai6s8qBxE8+ZD4HeFTlQ==", "integrity": "sha512-iGTGeyaoDyHDEZNjD8rKeosjSNs8zYanmuowY4ful7kFI0dnY4b5QilVYaFQJ6IM27S57LAeH5sKSsOHy4bw5w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@commitlint/config-validator": "^20.2.0", "@commitlint/config-validator": "^20.3.1",
"@commitlint/types": "^20.2.0", "@commitlint/types": "^20.3.1",
"global-directory": "^4.0.1", "global-directory": "^4.0.1",
"import-meta-resolve": "^4.0.0", "import-meta-resolve": "^4.0.0",
"lodash.mergewith": "^4.6.2", "lodash.mergewith": "^4.6.2",
@@ -520,16 +520,16 @@
} }
}, },
"node_modules/@commitlint/rules": { "node_modules/@commitlint/rules": {
"version": "20.3.0", "version": "20.3.1",
"resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.3.0.tgz", "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.3.1.tgz",
"integrity": "sha512-TGgXN/qBEhbzVD13crE1l7YSMJRrbPbUL0OBZALbUM5ER36RZmiZRu2ud2W/AA7HO9YLBRbyx6YVi2t/2Be0yQ==", "integrity": "sha512-/uic4P+4jVNpqQxz02+Y6vvIC0A2J899DBztA1j6q3f3MOKwydlNrojSh0dQmGDxxT1bXByiRtDhgFnOFnM6Pg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@commitlint/ensure": "^20.2.0", "@commitlint/ensure": "^20.3.1",
"@commitlint/message": "^20.0.0", "@commitlint/message": "^20.0.0",
"@commitlint/to-lines": "^20.0.0", "@commitlint/to-lines": "^20.0.0",
"@commitlint/types": "^20.2.0" "@commitlint/types": "^20.3.1"
}, },
"engines": { "engines": {
"node": ">=v18" "node": ">=v18"
@@ -559,9 +559,9 @@
} }
}, },
"node_modules/@commitlint/types": { "node_modules/@commitlint/types": {
"version": "20.2.0", "version": "20.3.1",
"resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.2.0.tgz", "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.3.1.tgz",
"integrity": "sha512-KTy0OqRDLR5y/zZMnizyx09z/rPlPC/zKhYgH8o/q6PuAjoQAKlRfY4zzv0M64yybQ//6//4H1n14pxaLZfUnA==", "integrity": "sha512-VmIFV/JkBRhDRRv7N5B7zEUkNZIx9Mp+8Pe65erz0rKycXLsi8Epcw0XJ+btSeRXgTzE7DyOyA9bkJ9mn/yqVQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1057,16 +1057,16 @@
} }
}, },
"node_modules/@eslint-react/ast": { "node_modules/@eslint-react/ast": {
"version": "2.5.1", "version": "2.5.7",
"resolved": "https://registry.npmjs.org/@eslint-react/ast/-/ast-2.5.1.tgz", "resolved": "https://registry.npmjs.org/@eslint-react/ast/-/ast-2.5.7.tgz",
"integrity": "sha512-l+pEb3kS/RhFN+8ZOhL5+P5+Yj7iw+ta7+abUBNH+5PMyiixlnAJlJ7Wy+rsTGxjUJtzEff3CzSr06V7qwJuwQ==", "integrity": "sha512-yQvKvyIzo9DPKFUOtFSEfpX0He763G1jFf/LQcIY9xoSOWP9pQ2X8ADNE6ZnmIc0L5zZLQ9nF/H0+MGh2NF3fA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-react/eff": "2.5.1", "@eslint-react/eff": "2.5.7",
"@typescript-eslint/types": "^8.51.0", "@typescript-eslint/types": "^8.53.0",
"@typescript-eslint/typescript-estree": "^8.51.0", "@typescript-eslint/typescript-estree": "^8.53.0",
"@typescript-eslint/utils": "^8.51.0", "@typescript-eslint/utils": "^8.53.0",
"string-ts": "^2.3.1" "string-ts": "^2.3.1"
}, },
"engines": { "engines": {
@@ -1078,19 +1078,19 @@
} }
}, },
"node_modules/@eslint-react/core": { "node_modules/@eslint-react/core": {
"version": "2.5.1", "version": "2.5.7",
"resolved": "https://registry.npmjs.org/@eslint-react/core/-/core-2.5.1.tgz", "resolved": "https://registry.npmjs.org/@eslint-react/core/-/core-2.5.7.tgz",
"integrity": "sha512-48YQYhjHga0TAv7FI0uGGT+X3SITec89fJLKSTqIEa7jTLpXTV56B+B/V/IcfZ0s8I0lNVEmtHFTXaibAxmfyw==", "integrity": "sha512-LqY4YV4Z/uiymdy2OLXkjsEVnoDjoFsxJZI4GWsZ9aBgGWYAsuoKKUG0bdMLp1U9jwMaY0OY9bYgBB5R1ANkoQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-react/ast": "2.5.1", "@eslint-react/ast": "2.5.7",
"@eslint-react/eff": "2.5.1", "@eslint-react/eff": "2.5.7",
"@eslint-react/shared": "2.5.1", "@eslint-react/shared": "2.5.7",
"@eslint-react/var": "2.5.1", "@eslint-react/var": "2.5.7",
"@typescript-eslint/scope-manager": "^8.51.0", "@typescript-eslint/scope-manager": "^8.53.0",
"@typescript-eslint/types": "^8.51.0", "@typescript-eslint/types": "^8.53.0",
"@typescript-eslint/utils": "^8.51.0", "@typescript-eslint/utils": "^8.53.0",
"birecord": "^0.1.1", "birecord": "^0.1.1",
"ts-pattern": "^5.9.0" "ts-pattern": "^5.9.0"
}, },
@@ -1103,9 +1103,9 @@
} }
}, },
"node_modules/@eslint-react/eff": { "node_modules/@eslint-react/eff": {
"version": "2.5.1", "version": "2.5.7",
"resolved": "https://registry.npmjs.org/@eslint-react/eff/-/eff-2.5.1.tgz", "resolved": "https://registry.npmjs.org/@eslint-react/eff/-/eff-2.5.7.tgz",
"integrity": "sha512-henDz7sTB67Q+YPb+Kiy0UpD7eHLvDtB2nOrRtHLTatS/LFr7wVr4J2muQPkPzY9KFF8pv811nrwW58FuDphPg==", "integrity": "sha512-oxd8eP+dykyOUmQUEItnOZhitlKyo9yFq8Ne0k9+CJeV0Pk1db1+lNcDWswWYoeb1IFEloqHo6y63fVxQXvJew==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -1113,14 +1113,14 @@
} }
}, },
"node_modules/@eslint-react/shared": { "node_modules/@eslint-react/shared": {
"version": "2.5.1", "version": "2.5.7",
"resolved": "https://registry.npmjs.org/@eslint-react/shared/-/shared-2.5.1.tgz", "resolved": "https://registry.npmjs.org/@eslint-react/shared/-/shared-2.5.7.tgz",
"integrity": "sha512-JSTnpZdBu57ZvrJkBwUZXgkBW8iPiQfSiQMYHEjVFR2NHWFgcOd9qYJLX7mJLrdcw/j+GgRQeOAGok0a0xcxGA==", "integrity": "sha512-ZjKO3rNDoLN2ZTn3PxmMcgoASPNiL3umK1BHQvYwCXqqnXlLEouu7sxK6htOvDu1NC+Dbwsqpu4JGUY7t0XQTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-react/eff": "2.5.1", "@eslint-react/eff": "2.5.7",
"@typescript-eslint/utils": "^8.51.0", "@typescript-eslint/utils": "^8.53.0",
"ts-pattern": "^5.9.0", "ts-pattern": "^5.9.0",
"zod": "^4.3.5" "zod": "^4.3.5"
}, },
@@ -1133,17 +1133,17 @@
} }
}, },
"node_modules/@eslint-react/var": { "node_modules/@eslint-react/var": {
"version": "2.5.1", "version": "2.5.7",
"resolved": "https://registry.npmjs.org/@eslint-react/var/-/var-2.5.1.tgz", "resolved": "https://registry.npmjs.org/@eslint-react/var/-/var-2.5.7.tgz",
"integrity": "sha512-O3rbdK8pHKZ4NYPD39Zq7zhWu76dvhyeroqG8nQzf667F/FdHGpGhsCPJOeIU5L/ooYO/3/Qz2vJD1pB5JeROQ==", "integrity": "sha512-VWoyZENbK+OV70s/sE9E6T4HD0WO6lsg2CnYjjpKBKBDOBW67SRdIlM7dUPKzqQKsj6p4yNh59eQ5iKaH82LLg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-react/ast": "2.5.1", "@eslint-react/ast": "2.5.7",
"@eslint-react/eff": "2.5.1", "@eslint-react/eff": "2.5.7",
"@typescript-eslint/scope-manager": "^8.51.0", "@typescript-eslint/scope-manager": "^8.53.0",
"@typescript-eslint/types": "^8.51.0", "@typescript-eslint/types": "^8.53.0",
"@typescript-eslint/utils": "^8.51.0", "@typescript-eslint/utils": "^8.53.0",
"ts-pattern": "^5.9.0" "ts-pattern": "^5.9.0"
}, },
"engines": { "engines": {
@@ -3458,9 +3458,9 @@
} }
}, },
"node_modules/@trivago/prettier-plugin-sort-imports": { "node_modules/@trivago/prettier-plugin-sort-imports": {
"version": "6.0.1", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-6.0.1.tgz", "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-6.0.2.tgz",
"integrity": "sha512-6B13DCWDfAfh4AEJ43gRgeCSAQmlKG5LHqHzHc0lbUwgBy0rX7o41US+46Fd4XiXBx+JDGEz3NBadCbUls0dUQ==", "integrity": "sha512-3DgfkukFyC/sE/VuYjaUUWoFfuVjPK55vOFDsxD56XXynFMCZDYFogH2l/hDfOsQAm1myoU/1xByJ3tWqtulXA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -3556,9 +3556,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "25.0.3", "version": "25.0.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.7.tgz",
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "integrity": "sha512-C/er7DlIZgRJO7WtTdYovjIFzGsz0I95UlMyR9anTb4aCpBSRWe5Jc1/RvLKUfzmOxHPGjSE5+63HgLtndxU4w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3566,9 +3566,9 @@
} }
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.7", "version": "19.2.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3586,17 +3586,17 @@
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.52.0", "version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.52.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz",
"integrity": "sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q==", "integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/scope-manager": "8.53.0",
"@typescript-eslint/type-utils": "8.52.0", "@typescript-eslint/type-utils": "8.53.0",
"@typescript-eslint/utils": "8.52.0", "@typescript-eslint/utils": "8.53.0",
"@typescript-eslint/visitor-keys": "8.52.0", "@typescript-eslint/visitor-keys": "8.53.0",
"ignore": "^7.0.5", "ignore": "^7.0.5",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
"ts-api-utils": "^2.4.0" "ts-api-utils": "^2.4.0"
@@ -3609,7 +3609,7 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/parser": "^8.52.0", "@typescript-eslint/parser": "^8.53.0",
"eslint": "^8.57.0 || ^9.0.0", "eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0" "typescript": ">=4.8.4 <6.0.0"
} }
@@ -3625,16 +3625,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.52.0", "version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.52.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.0.tgz",
"integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/scope-manager": "8.53.0",
"@typescript-eslint/types": "8.52.0", "@typescript-eslint/types": "8.53.0",
"@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/typescript-estree": "8.53.0",
"@typescript-eslint/visitor-keys": "8.52.0", "@typescript-eslint/visitor-keys": "8.53.0",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@@ -3650,14 +3650,14 @@
} }
}, },
"node_modules/@typescript-eslint/project-service": { "node_modules/@typescript-eslint/project-service": {
"version": "8.52.0", "version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.52.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.0.tgz",
"integrity": "sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw==", "integrity": "sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.52.0", "@typescript-eslint/tsconfig-utils": "^8.53.0",
"@typescript-eslint/types": "^8.52.0", "@typescript-eslint/types": "^8.53.0",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@@ -3672,14 +3672,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.52.0", "version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.52.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.0.tgz",
"integrity": "sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA==", "integrity": "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.52.0", "@typescript-eslint/types": "8.53.0",
"@typescript-eslint/visitor-keys": "8.52.0" "@typescript-eslint/visitor-keys": "8.53.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3690,9 +3690,9 @@
} }
}, },
"node_modules/@typescript-eslint/tsconfig-utils": { "node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.52.0", "version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.52.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.0.tgz",
"integrity": "sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg==", "integrity": "sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3707,15 +3707,15 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.52.0", "version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.52.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.0.tgz",
"integrity": "sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ==", "integrity": "sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.52.0", "@typescript-eslint/types": "8.53.0",
"@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/typescript-estree": "8.53.0",
"@typescript-eslint/utils": "8.52.0", "@typescript-eslint/utils": "8.53.0",
"debug": "^4.4.3", "debug": "^4.4.3",
"ts-api-utils": "^2.4.0" "ts-api-utils": "^2.4.0"
}, },
@@ -3732,9 +3732,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.52.0", "version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.52.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.0.tgz",
"integrity": "sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg==", "integrity": "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3746,16 +3746,16 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.52.0", "version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.52.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.0.tgz",
"integrity": "sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ==", "integrity": "sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/project-service": "8.52.0", "@typescript-eslint/project-service": "8.53.0",
"@typescript-eslint/tsconfig-utils": "8.52.0", "@typescript-eslint/tsconfig-utils": "8.53.0",
"@typescript-eslint/types": "8.52.0", "@typescript-eslint/types": "8.53.0",
"@typescript-eslint/visitor-keys": "8.52.0", "@typescript-eslint/visitor-keys": "8.53.0",
"debug": "^4.4.3", "debug": "^4.4.3",
"minimatch": "^9.0.5", "minimatch": "^9.0.5",
"semver": "^7.7.3", "semver": "^7.7.3",
@@ -3800,16 +3800,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.52.0", "version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.52.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.0.tgz",
"integrity": "sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ==", "integrity": "sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.9.1", "@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/scope-manager": "8.53.0",
"@typescript-eslint/types": "8.52.0", "@typescript-eslint/types": "8.53.0",
"@typescript-eslint/typescript-estree": "8.52.0" "@typescript-eslint/typescript-estree": "8.53.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3824,13 +3824,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.52.0", "version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.52.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.0.tgz",
"integrity": "sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ==", "integrity": "sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.52.0", "@typescript-eslint/types": "8.53.0",
"eslint-visitor-keys": "^4.2.1" "eslint-visitor-keys": "^4.2.1"
}, },
"engines": { "engines": {
@@ -3911,9 +3911,9 @@
} }
}, },
"node_modules/ai-motion": { "node_modules/ai-motion": {
"version": "0.4.7", "version": "0.4.8",
"resolved": "https://registry.npmjs.org/ai-motion/-/ai-motion-0.4.7.tgz", "resolved": "https://registry.npmjs.org/ai-motion/-/ai-motion-0.4.8.tgz",
"integrity": "sha512-yuFtVgWeaxKyvfSFocA01Mr4UbRvwwy7HWZ6Mq9NM/yccAkqp3UCGdWX6AflNB8cU27PgwTBiMRfx6C9di7xkQ==", "integrity": "sha512-9nQVjf8lrYQWSW0sZdb9bES4x8Ei1w9C0S62po2KvRbMjslmjm0WMeszyW771SEauDnu4YGWqurLZ/gCkCTosQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/ajv": { "node_modules/ajv": {
@@ -4860,20 +4860,20 @@
} }
}, },
"node_modules/eslint-plugin-react-dom": { "node_modules/eslint-plugin-react-dom": {
"version": "2.5.1", "version": "2.5.7",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-dom/-/eslint-plugin-react-dom-2.5.1.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-react-dom/-/eslint-plugin-react-dom-2.5.7.tgz",
"integrity": "sha512-7x3k0bQwtTOSxjReH11buYL6aSMopBzSaWr2bU/LPOuy1dDxvqE6HC2Y6QPEyZjLJ1ppUSKwRmcX85uiF0jHAA==", "integrity": "sha512-KHCU7KRXeLYlrrXKtInpCqHzt+nYhIGpYIuMM4Q0FyGBOcswRS2Gv9PhN+yQqDI9PRBhnjCuRf+q2Y3ZrYWjsw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-react/ast": "2.5.1", "@eslint-react/ast": "2.5.7",
"@eslint-react/core": "2.5.1", "@eslint-react/core": "2.5.7",
"@eslint-react/eff": "2.5.1", "@eslint-react/eff": "2.5.7",
"@eslint-react/shared": "2.5.1", "@eslint-react/shared": "2.5.7",
"@eslint-react/var": "2.5.1", "@eslint-react/var": "2.5.7",
"@typescript-eslint/scope-manager": "^8.51.0", "@typescript-eslint/scope-manager": "^8.53.0",
"@typescript-eslint/types": "^8.51.0", "@typescript-eslint/types": "^8.53.0",
"@typescript-eslint/utils": "^8.51.0", "@typescript-eslint/utils": "^8.53.0",
"compare-versions": "^6.1.1", "compare-versions": "^6.1.1",
"string-ts": "^2.3.1", "string-ts": "^2.3.1",
"ts-pattern": "^5.9.0" "ts-pattern": "^5.9.0"
@@ -4917,21 +4917,21 @@
} }
}, },
"node_modules/eslint-plugin-react-x": { "node_modules/eslint-plugin-react-x": {
"version": "2.5.1", "version": "2.5.7",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-x/-/eslint-plugin-react-x-2.5.1.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-react-x/-/eslint-plugin-react-x-2.5.7.tgz",
"integrity": "sha512-w3KtEG8M7sFRh/psUmep3H+i2O/5Nn9wI8GXjlqzwp8zLb4ax66Ue+1oecLTzY/0j/M1xRfB3hzk5oio54RwLA==", "integrity": "sha512-EU+h7eUHNg9ikLaGOa0Tjsc/o4FO2KdyT3M97TkQmaKc9tzpcy8nlPQp/czBMbUuxAgbj2495wzOvCZ0fEK4mw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-react/ast": "2.5.1", "@eslint-react/ast": "2.5.7",
"@eslint-react/core": "2.5.1", "@eslint-react/core": "2.5.7",
"@eslint-react/eff": "2.5.1", "@eslint-react/eff": "2.5.7",
"@eslint-react/shared": "2.5.1", "@eslint-react/shared": "2.5.7",
"@eslint-react/var": "2.5.1", "@eslint-react/var": "2.5.7",
"@typescript-eslint/scope-manager": "^8.51.0", "@typescript-eslint/scope-manager": "^8.53.0",
"@typescript-eslint/type-utils": "^8.51.0", "@typescript-eslint/type-utils": "^8.53.0",
"@typescript-eslint/types": "^8.51.0", "@typescript-eslint/types": "^8.53.0",
"@typescript-eslint/utils": "^8.51.0", "@typescript-eslint/utils": "^8.53.0",
"compare-versions": "^6.1.1", "compare-versions": "^6.1.1",
"is-immutable-type": "^5.0.1", "is-immutable-type": "^5.0.1",
"string-ts": "^2.3.1", "string-ts": "^2.3.1",
@@ -5310,13 +5310,13 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/framer-motion": { "node_modules/framer-motion": {
"version": "12.24.0", "version": "12.26.1",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.24.0.tgz", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.26.1.tgz",
"integrity": "sha512-ggTMRkIDPc76lHmM+dRT1MmVfFV6t/y+jkWjWuzR7FG5xRvnAAl/5wFPjzSkLE8Nu5E5uIQRCNxmIXtWJVo6XQ==", "integrity": "sha512-Uzc8wGldU4FpmGotthjjcj0SZhigcODjqvKT7lzVZHsmYkzQMFfMIv0vHQoXCeoe/Ahxqp4by4A6QbzFA/lblw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"motion-dom": "^12.24.0", "motion-dom": "^12.24.11",
"motion-utils": "^12.23.28", "motion-utils": "^12.24.10",
"tslib": "^2.4.0" "tslib": "^2.4.0"
}, },
"peerDependencies": { "peerDependencies": {
@@ -5457,9 +5457,9 @@
} }
}, },
"node_modules/globals": { "node_modules/globals": {
"version": "16.5.0", "version": "17.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-17.0.0.tgz",
"integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", "integrity": "sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -5543,9 +5543,9 @@
} }
}, },
"node_modules/i18next": { "node_modules/i18next": {
"version": "25.7.3", "version": "25.7.4",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.3.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.4.tgz",
"integrity": "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA==", "integrity": "sha512-hRkpEblXXcXSNbw8mBNq9042OEetgyB/ahc/X17uV/khPwzV+uB8RHceHh3qavyrkPJvmXFKXME2Sy1E0KjAfw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -6523,12 +6523,12 @@
} }
}, },
"node_modules/motion": { "node_modules/motion": {
"version": "12.24.0", "version": "12.26.1",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.24.0.tgz", "resolved": "https://registry.npmjs.org/motion/-/motion-12.26.1.tgz",
"integrity": "sha512-FAnpl/DhCFct3p+T2hCjAY95w+3EggUk3b8Ql4jQ6mmdRbEaGD1000goqWeEYVoD7mqx9H0As9ORmRADD4LQAw==", "integrity": "sha512-IVhzx9HOQTiJ9ykthMOlZPnLwrkXziN5Q/yebsqBYlFJb2rHP8yhmKc8O/YUT9byPJlxOeqkzfNYCrVKZx8vqg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"framer-motion": "^12.24.0", "framer-motion": "^12.26.1",
"tslib": "^2.4.0" "tslib": "^2.4.0"
}, },
"peerDependencies": { "peerDependencies": {
@@ -6549,18 +6549,18 @@
} }
}, },
"node_modules/motion-dom": { "node_modules/motion-dom": {
"version": "12.24.0", "version": "12.24.11",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.24.0.tgz", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.24.11.tgz",
"integrity": "sha512-RD2kZkFd/GH4fITI8IJvypGgn0vIu5vkrJaXIAkYqORGs5P0CKDHKNvswmoY1H+tbUAOPSh6VtUqoAmc/3Gvig==", "integrity": "sha512-DlWOmsXMJrV8lzZyd+LKjG2CXULUs++bkq8GZ2Sr0R0RRhs30K2wtY+LKiTjhmJU3W61HK+rB0GLz6XmPvTA1A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"motion-utils": "^12.23.28" "motion-utils": "^12.24.10"
} }
}, },
"node_modules/motion-utils": { "node_modules/motion-utils": {
"version": "12.23.28", "version": "12.24.10",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.28.tgz", "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.24.10.tgz",
"integrity": "sha512-0W6cWd5Okoyf8jmessVK3spOmbyE0yTdNKujHctHH9XdAE4QDuZ1/LjSXC68rrhsJU+TkzXURC5OdSWh9ibOwQ==", "integrity": "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/ms": { "node_modules/ms": {
@@ -6935,9 +6935,9 @@
} }
}, },
"node_modules/react-i18next": { "node_modules/react-i18next": {
"version": "16.5.1", "version": "16.5.2",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.1.tgz", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.2.tgz",
"integrity": "sha512-Hks6UIRZWW4c+qDAnx1csVsCGYeIR4MoBGQgJ+NUoNnO6qLxXuf8zu0xdcinyXUORgGzCdRsexxO1Xzv3sTdnw==", "integrity": "sha512-GG/SBVxx9dvrO1uCs8VYdKfOP8NEBUhNP+2VDQLCifRJ8DL1qPq296k2ACNGyZMDe7iyIlz/LMJTQOs8HXSRvw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -7174,9 +7174,9 @@
} }
}, },
"node_modules/simple-icons": { "node_modules/simple-icons": {
"version": "16.4.0", "version": "16.5.0",
"resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.4.0.tgz", "resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.5.0.tgz",
"integrity": "sha512-8CKtCvx1Zq3L0CBsR4RR1MjGCXkXbzdspwl2yCxs8oWkstbzj2+DatRKDee/tuj3Ffd/2CDzwEky9RgG2yggew==", "integrity": "sha512-72nn0oHADKx6Hknu7q6M0vfL8LiCUMKABOHane2+4xdqaFBSHfNNBjuZioihiqVQMz7IvVle4NKAM0IlXvl/9A==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -7577,16 +7577,16 @@
} }
}, },
"node_modules/typescript-eslint": { "node_modules/typescript-eslint": {
"version": "8.52.0", "version": "8.53.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.52.0.tgz", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.53.0.tgz",
"integrity": "sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA==", "integrity": "sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "8.52.0", "@typescript-eslint/eslint-plugin": "8.53.0",
"@typescript-eslint/parser": "8.52.0", "@typescript-eslint/parser": "8.53.0",
"@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/typescript-estree": "8.53.0",
"@typescript-eslint/utils": "8.52.0" "@typescript-eslint/utils": "8.53.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -7772,9 +7772,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.0", "version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -8142,7 +8142,7 @@
}, },
"packages/llms": { "packages/llms": {
"name": "@page-agent/llms", "name": "@page-agent/llms",
"version": "0.0.19", "version": "0.0.21",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"chalk": "^5.6.2", "chalk": "^5.6.2",
@@ -8150,32 +8150,32 @@
} }
}, },
"packages/page-agent": { "packages/page-agent": {
"version": "0.0.19", "version": "0.0.21",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@page-agent/llms": "0.0.19", "@page-agent/llms": "0.0.21",
"@page-agent/page-controller": "0.0.19", "@page-agent/page-controller": "0.0.21",
"@page-agent/ui": "0.0.19", "@page-agent/ui": "0.0.21",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"zod": "^4.3.5" "zod": "^4.3.5"
} }
}, },
"packages/page-controller": { "packages/page-controller": {
"name": "@page-agent/page-controller", "name": "@page-agent/page-controller",
"version": "0.0.19", "version": "0.0.21",
"license": "MIT" "license": "MIT"
}, },
"packages/ui": { "packages/ui": {
"name": "@page-agent/ui", "name": "@page-agent/ui",
"version": "0.0.19", "version": "0.0.21",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ai-motion": "^0.4.7" "ai-motion": "^0.4.8"
} }
}, },
"packages/website": { "packages/website": {
"name": "@page-agent/website", "name": "@page-agent/website",
"version": "0.0.19", "version": "0.0.21",
"dependencies": { "dependencies": {
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
@@ -8185,23 +8185,23 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"motion": "^12.24.0", "motion": "^12.26.1",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"rough-notation": "^0.5.1", "rough-notation": "^0.5.1",
"simple-icons": "^16.4.0", "simple-icons": "^16.5.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@types/react": "^19.2.2", "@types/react": "^19.2.8",
"@types/react-dom": "^19.2.1", "@types/react-dom": "^19.2.1",
"@vitejs/plugin-react-swc": "^4.1.0", "@vitejs/plugin-react-swc": "^4.1.0",
"i18next": "^25.7.3", "i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-i18next": "^16.5.1", "react-i18next": "^16.5.2",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"wouter": "^3.9.0" "wouter": "^3.9.0"
+10 -10
View File
@@ -1,7 +1,7 @@
{ {
"name": "root", "name": "root",
"private": true, "private": true,
"version": "0.0.19", "version": "0.0.21",
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
"packages/page-controller", "packages/page-controller",
@@ -34,28 +34,28 @@
"prepare": "husky" "prepare": "husky"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^20.3.0", "@commitlint/cli": "^20.3.1",
"@commitlint/config-conventional": "^20.3.0", "@commitlint/config-conventional": "^20.3.1",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@microsoft/api-extractor": "^7.55.2", "@microsoft/api-extractor": "^7.55.2",
"@trivago/prettier-plugin-sort-imports": "^6.0.1", "@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^25.0.3", "@types/node": "^25.0.7",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "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-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26", "eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-react-x": "^2.5.1", "eslint-plugin-react-x": "^2.5.7",
"globals": "^16.4.0", "globals": "^17.0.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.2.4", "lint-staged": "^16.2.4",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.52.0", "typescript-eslint": "^8.53.0",
"unplugin-dts": "^1.0.0-beta.6", "unplugin-dts": "^1.0.0-beta.6",
"vite": "^7.3.0", "vite": "^7.3.1",
"vite-plugin-css-injected-by-js": "^3.5.2" "vite-plugin-css-injected-by-js": "^3.5.2"
}, },
"lint-staged": { "lint-staged": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/llms", "name": "@page-agent/llms",
"version": "0.0.19", "version": "0.0.21",
"type": "module", "type": "module",
"main": "./dist/lib/page-agent-llms.js", "main": "./dist/lib/page-agent-llms.js",
"module": "./dist/lib/page-agent-llms.js", "module": "./dist/lib/page-agent-llms.js",
+88 -74
View File
@@ -1,60 +1,58 @@
/** /**
* OpenAI Client implementation * 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 { 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' import { modelPatch, zodToOpenAITool } from './utils'
/** /**
* @deprecated Use OpenAILenientClient instead. * Client for OpenAI compatible APIs
*/ */
export class OpenAIClient implements LLMClient { 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.config = config
this.fetch = config.customFetch
} }
async invoke( async invoke(
messages: Message[], messages: Message[],
tools: Record<string, Tool>, tools: Record<string, Tool>,
abortSignal?: AbortSignal abortSignal?: AbortSignal,
options?: InvokeOptions
): Promise<InvokeResult> { ): Promise<InvokeResult> {
// 1. Convert tools to OpenAI format // 1. Convert tools to OpenAI format
const openaiTools = Object.entries(tools).map(([name, tool]) => zodToOpenAITool(name, tool)) const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t))
// Build request body
const requestBody: Record<string, unknown> = {
model: this.config.model,
temperature: this.config.temperature,
messages,
tools: openaiTools,
parallel_tool_calls: false,
// Require tool call: specific tool if provided, otherwise any tool
tool_choice: options?.toolChoiceName
? { type: 'function', function: { name: options.toolChoiceName } }
: 'required',
}
// 2. Call API // 2. Call API
let response: Response let response: Response
try { try {
response = await fetch(`${this.config.baseURL}/chat/completions`, { response = await this.fetch(`${this.config.baseURL}/chat/completions`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${this.config.apiKey}`, Authorization: `Bearer ${this.config.apiKey}`,
}, },
body: JSON.stringify( body: JSON.stringify(modelPatch(requestBody)),
modelPatch({
model: this.config.model,
temperature: this.config.temperature,
messages,
tools: openaiTools,
// tool_choice: 'required',
tool_choice: { type: 'function', function: { name: 'AgentOutput' } },
// model specific params
// reasoning_effort: 'minimal',
// verbosity: 'low',
parallel_tool_calls: false,
})
),
signal: abortSignal, signal: abortSignal,
}) })
} catch (error: unknown) { } catch (error: unknown) {
// Network error console.error(error)
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, 'Network request failed', error) throw new InvokeError(InvokeErrorType.NETWORK_ERROR, 'Network request failed', error)
} }
@@ -92,77 +90,94 @@ export class OpenAIClient implements LLMClient {
) )
} }
// 4. Parse and validate response
const data = await response.json() const data = await response.json()
// 4. Check finish_reason
const choice = data.choices?.[0] const choice = data.choices?.[0]
if (!choice) { if (!choice) {
throw new InvokeError(InvokeErrorType.UNKNOWN, 'No choices in response', data) throw new InvokeError(InvokeErrorType.UNKNOWN, 'No choices in response', data)
} }
// Check finish_reason
switch (choice.finish_reason) { switch (choice.finish_reason) {
case 'tool_calls': case 'tool_calls':
// ✅ Normal case 'function_call': // gemini
case 'stop': // some models use this even with tool calls
break break
case 'length': case 'length':
// ⚠️ Token limit reached
throw new InvokeError( throw new InvokeError(
InvokeErrorType.CONTEXT_LENGTH, InvokeErrorType.CONTEXT_LENGTH,
'Response truncated: max tokens reached', 'Response truncated: max tokens reached'
data
) )
case 'content_filter': case 'content_filter':
// ❌ Content filtered throw new InvokeError(InvokeErrorType.CONTENT_FILTER, 'Content filtered by safety system')
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)
default: default:
throw new InvokeError( throw new InvokeError(
InvokeErrorType.UNKNOWN, InvokeErrorType.UNKNOWN,
`Unexpected finish_reason: ${choice.finish_reason}`, `Unexpected finish_reason: ${choice.finish_reason}`
data
) )
} }
// 5. Parse tool call // Apply normalizeResponse if provided (for fixing format issues automatically)
const toolCall = choice.message?.tool_calls?.[0] const normalizedData = options?.normalizeResponse ? options.normalizeResponse(data) : data
if (!toolCall) { const normalizedChoice = (normalizedData as any).choices?.[0]
throw new InvokeError(InvokeErrorType.NO_TOOL_CALL, 'No tool call found in response', data)
}
const toolName = toolCall.function.name // Get tool name from response
const tool = tools[toolName] const toolCallName = normalizedChoice?.message?.tool_calls?.[0]?.function?.name
if (!tool) { if (!toolCallName) {
throw new InvokeError(InvokeErrorType.UNKNOWN, `Tool ${toolName} not found`, data)
}
// 6. Parse and validate arguments
let toolArgs: unknown
try {
toolArgs = JSON.parse(toolCall.function.arguments)
} catch (e) {
throw new InvokeError(InvokeErrorType.INVALID_TOOL_ARGS, 'Invalid JSON in tool arguments', e)
}
// Validate against zod schema
const validation = tool.inputSchema.safeParse(toolArgs)
if (!validation.success) {
throw new InvokeError( throw new InvokeError(
InvokeErrorType.INVALID_TOOL_ARGS, InvokeErrorType.NO_TOOL_CALL,
`Tool arguments validation failed: ${validation.error.message}`, 'No tool call found in response',
validation.error 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 let toolResult: unknown
try { try {
toolResult = await tool.execute(validation.data) toolResult = await tool.execute(toolInput)
} catch (e) { } catch (e) {
throw new InvokeError( throw new InvokeError(
InvokeErrorType.TOOL_EXECUTION_ERROR, InvokeErrorType.TOOL_EXECUTION_ERROR,
@@ -171,12 +186,11 @@ export class OpenAIClient implements LLMClient {
) )
} }
// 8. Return result (including cache tokens) // Return result
return { return {
toolCall: { toolCall: {
// id: toolCall.id, name: toolCallName,
name: toolName, args: toolInput,
args: validation.data as Record<string, unknown>,
}, },
toolResult, toolResult,
usage: { usage: {
-124
View File
@@ -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,
}
}
}
+6 -56
View File
@@ -1,37 +1,4 @@
/** import { OpenAIClient } from './OpenAIClient'
* @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 { import {
DEFAULT_API_KEY, DEFAULT_API_KEY,
DEFAULT_BASE_URL, DEFAULT_BASE_URL,
@@ -40,27 +7,9 @@ import {
LLM_MAX_RETRIES, LLM_MAX_RETRIES,
} from './constants' } from './constants'
import { InvokeError } from './errors' import { InvokeError } from './errors'
import type { import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
AgentBrain,
InvokeResult,
LLMClient,
LLMConfig,
MacroToolInput,
MacroToolResult,
Message,
Tool,
} from './types'
export type { export type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool }
AgentBrain,
InvokeResult,
LLMClient,
LLMConfig,
MacroToolInput,
MacroToolResult,
Message,
Tool,
}
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> { export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
return { return {
@@ -93,11 +42,12 @@ export class LLM extends EventTarget {
async invoke( async invoke(
messages: Message[], messages: Message[],
tools: Record<string, Tool>, tools: Record<string, Tool>,
abortSignal: AbortSignal abortSignal: AbortSignal,
options?: InvokeOptions
): Promise<InvokeResult> { ): Promise<InvokeResult> {
return await withRetry( return await withRetry(
async () => { async () => {
const result = await this.client.invoke(messages, tools, abortSignal) const result = await this.client.invoke(messages, tools, abortSignal, options)
return result return result
}, },
+20 -34
View File
@@ -32,6 +32,24 @@ export interface Tool<TParams = any, TResult = any> {
execute: (args: TParams) => Promise<TResult> execute: (args: TParams) => Promise<TResult>
} }
/**
* Invoke options for LLM call
*/
export interface InvokeOptions {
/**
* Force LLM to call a specific tool by name.
* If provided: tool_choice = { type: 'function', function: { name: toolChoiceName } }
* If not provided: tool_choice = 'required' (must call some tool, but model chooses which)
*/
toolChoiceName?: string
/**
* Response normalization function.
* Called before parsing the response.
* Used to fix various response format errors from the model.
*/
normalizeResponse?: (response: any) => any
}
/** /**
* LLM Client interface * LLM Client interface
* Note: Does not use generics because each tool in the tools array has different types * Note: Does not use generics because each tool in the tools array has different types
@@ -40,7 +58,8 @@ export interface LLMClient {
invoke( invoke(
messages: Message[], messages: Message[],
tools: Record<string, Tool>, tools: Record<string, Tool>,
abortSignal?: AbortSignal abortSignal?: AbortSignal,
options?: InvokeOptions
): Promise<InvokeResult> ): Promise<InvokeResult>
} }
@@ -82,36 +101,3 @@ export interface LLMConfig {
*/ */
customFetch?: typeof globalThis.fetch 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
View File
@@ -4,8 +4,7 @@
import chalk from 'chalk' import chalk from 'chalk'
import { z } from 'zod' import { z } from 'zod'
import { InvokeError, InvokeErrorType } from './errors' import type { Tool } from './types'
import type { MacroToolInput, Tool } from './types'
function debug(message: string) { function debug(message: string) {
console.debug(chalk.gray('[LLM]'), message) 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 * Patch model specific parameters
*/ */
@@ -205,11 +34,25 @@ export function modelPatch(body: Record<string, any>) {
const modelName = normalizeModelName(model) 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')) { if (modelName.startsWith('claude')) {
debug('Applying Claude patch: change tool_choice and disable thinking') debug('Applying Claude patch: disable thinking')
body.tool_choice = { type: 'tool', name: 'AgentOutput' }
body.thinking = { type: 'disabled' } 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')) { if (modelName.startsWith('grok')) {
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "page-agent", "name": "page-agent",
"private": false, "private": false,
"version": "0.0.19", "version": "0.0.21",
"type": "module", "type": "module",
"main": "./dist/esm/page-agent.js", "main": "./dist/esm/page-agent.js",
"module": "./dist/esm/page-agent.js", "module": "./dist/esm/page-agent.js",
@@ -46,8 +46,8 @@
"dependencies": { "dependencies": {
"chalk": "^5.6.2", "chalk": "^5.6.2",
"zod": "^4.3.5", "zod": "^4.3.5",
"@page-agent/llms": "0.0.19", "@page-agent/llms": "0.0.21",
"@page-agent/page-controller": "0.0.19", "@page-agent/page-controller": "0.0.21",
"@page-agent/ui": "0.0.19" "@page-agent/ui": "0.0.21"
} }
} }
+46 -15
View File
@@ -2,13 +2,7 @@
* Copyright (C) 2025 Alibaba Group Holding Limited * Copyright (C) 2025 Alibaba Group Holding Limited
* All rights reserved. * All rights reserved.
*/ */
import { import { LLM, type Tool } from '@page-agent/llms'
type AgentBrain,
LLM,
type MacroToolInput,
type MacroToolResult,
type Tool,
} from '@page-agent/llms'
import { PageController } from '@page-agent/page-controller' import { PageController } from '@page-agent/page-controller'
import { Panel, SimulatorMask } from '@page-agent/ui' import { Panel, SimulatorMask } from '@page-agent/ui'
import chalk from 'chalk' import chalk from 'chalk'
@@ -18,15 +12,46 @@ import type { PageAgentConfig } from './config'
import { MAX_STEPS } from './config/constants' import { MAX_STEPS } from './config/constants'
import SYSTEM_PROMPT from './prompts/system_prompt.md?raw' import SYSTEM_PROMPT from './prompts/system_prompt.md?raw'
import { tools } from './tools' import { tools } from './tools'
import { trimLines, uid, waitUntil } from './utils' import { normalizeResponse, trimLines, uid, waitUntil } from './utils'
import { assert } from './utils/assert' import { assert } from './utils/assert'
/**
* 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 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 type { PageAgentConfig }
export { tool, type PageAgentTool } from './tools' export { tool, type PageAgentTool } from './tools'
export type { AgentBrain, MacroToolInput, MacroToolResult }
export interface AgentHistory { export interface AgentHistory {
brain: AgentBrain brain: Partial<AgentReflection>
action: { action: {
name: string name: string
input: any input: any
@@ -124,9 +149,6 @@ export class PageAgent extends EventTarget {
window.addEventListener('beforeunload', this.#beforeUnloadListener) window.addEventListener('beforeunload', this.#beforeUnloadListener)
} }
/**
* @todo maybe return something?
*/
async execute(task: string): Promise<ExecutionResult> { async execute(task: string): Promise<ExecutionResult> {
if (!task) throw new Error('Task is required') if (!task) throw new Error('Task is required')
this.task = task this.task = task
@@ -183,7 +205,11 @@ export class PageAgent extends EventTarget {
}, },
], ],
{ AgentOutput: this.#packMacroTool() }, { AgentOutput: this.#packMacroTool() },
this.#abortController.signal this.#abortController.signal,
{
toolChoiceName: 'AgentOutput',
normalizeResponse,
}
) )
const macroResult = result.toolResult as MacroToolResult const macroResult = result.toolResult as MacroToolResult
@@ -279,6 +305,7 @@ export class PageAgent extends EventTarget {
}) })
return { return {
description: 'You MUST call this tool every step. Outputs your reflections and next action.',
inputSchema: macroToolSchema as zod.ZodType<MacroToolInput>, inputSchema: macroToolSchema as zod.ZodType<MacroToolInput>,
execute: async (input: MacroToolInput): Promise<MacroToolResult> => { execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
// abort // abort
@@ -473,7 +500,11 @@ export class PageAgent extends EventTarget {
await this.pageController.updateTree() await this.pageController.updateTree()
this.mask.wrapper.style.pointerEvents = 'auto' this.mask.wrapper.style.pointerEvents = 'auto'
const simplifiedHTML = await this.pageController.getSimplifiedHTML() let simplifiedHTML = await this.pageController.getSimplifiedHTML()
if (this.config.transformPageContent) {
simplifiedHTML = await this.config.transformPageContent(simplifiedHTML)
}
let prompt = trimLines(`<browser_state> let prompt = trimLines(`<browser_state>
Current Page: [${pageTitle}](${pageUrl}) Current Page: [${pageTitle}](${pageUrl})
+19
View File
@@ -62,6 +62,7 @@ export interface AgentConfig {
// lifecycle hooks // lifecycle hooks
// @todo: use event instead of 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 onBeforeStep?: (this: PageAgent, stepCnt: number) => Promise<void> | void
onAfterStep?: (this: PageAgent, stepCnt: number, history: AgentHistory[]) => Promise<void> | void onAfterStep?: (this: PageAgent, stepCnt: number, history: AgentHistory[]) => Promise<void> | void
@@ -71,6 +72,7 @@ export interface AgentConfig {
/** /**
* @note this hook can block the disposal process * @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. * @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 onDispose?: (this: PageAgent, reason?: string) => void
@@ -84,10 +86,27 @@ export interface AgentConfig {
*/ */
experimentalScriptExecutionTool?: boolean 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 * TODO: @unimplemented
* hook when action causes a new page to be opened * 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. * @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
+157
View File
@@ -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'
}
+2
View File
@@ -1,3 +1,5 @@
export { normalizeResponse } from './autoFixer'
/** /**
* Wait until condition becomes true * Wait until condition becomes true
* @returns Returns when condition becomes true, throws otherwise * @returns Returns when condition becomes true, throws otherwise
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/page-controller", "name": "@page-agent/page-controller",
"version": "0.0.19", "version": "0.0.21",
"type": "module", "type": "module",
"main": "./dist/lib/page-controller.js", "main": "./dist/lib/page-controller.js",
"module": "./dist/lib/page-controller.js", "module": "./dist/lib/page-controller.js",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/ui", "name": "@page-agent/ui",
"version": "0.0.19", "version": "0.0.21",
"type": "module", "type": "module",
"main": "./dist/lib/page-agent-ui.js", "main": "./dist/lib/page-agent-ui.js",
"module": "./dist/lib/page-agent-ui.js", "module": "./dist/lib/page-agent-ui.js",
@@ -36,6 +36,6 @@
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\"" "postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
}, },
"dependencies": { "dependencies": {
"ai-motion": "^0.4.7" "ai-motion": "^0.4.8"
} }
} }
+16 -21
View File
@@ -37,14 +37,17 @@ Available Magic UI components: https://magicui.design/docs/components
Located in `src/components/ui/`: Located in `src/components/ui/`:
**From shadcn/ui:** **From shadcn/ui:**
- `alert`, `badge`, `button`, `separator`, `sonner`, `switch`, `tooltip` - `alert`, `badge`, `button`, `separator`, `sonner`, `switch`, `tooltip`
**From Magic UI:** **From Magic UI:**
- `animated-gradient-text`, `animated-shiny-text`, `aurora-text` - `animated-gradient-text`, `animated-shiny-text`, `aurora-text`
- `hyper-text`, `magic-card`, `neon-gradient-card`, `particles` - `hyper-text`, `magic-card`, `neon-gradient-card`, `particles`
- `sparkles-text`, `text-animate`, `typing-animation` - `sparkles-text`, `text-animate`, `typing-animation`
**Custom:** **Custom:**
- `highlighter`, `kbd`, `spinner` - `highlighter`, `kbd`, `spinner`
### Styling Rules ### Styling Rules
@@ -58,17 +61,17 @@ Located in `src/components/ui/`:
``` ```
src/ src/
├── pages/
│ ├── Home.tsx # Homepage
│ └── docs/
│ ├── Layout.tsx # Documentation sidebar
│ └── [section]/[topic]/page.tsx
├── components/ ├── components/
│ ├── ui/ # shadcn/ui + Magic UI components │ ├── ui/ # shadcn/ui + Magic UI components
│ ├── Header.tsx # Site header │ ├── Header.tsx # Site header
── Footer.tsx # Site footer ── Footer.tsx # Site footer
│ └── DocsLayout.tsx # Documentation sidebar
├── docs/ # Documentation pages
│ └── [section]/[topic]/page.tsx
├── test-pages/ # Library integration tests
├── i18n/ # Internationalization ├── i18n/ # Internationalization
├── router.tsx # Central routing ├── router.tsx # Central routing
├── page.tsx # Homepage
└── main.tsx # App entry └── main.tsx # App entry
``` ```
@@ -76,14 +79,9 @@ src/
### Documentation Page ### 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 2. Add route to `src/router.tsx` with `<Header /> + <DocsLayout>` wrapper
3. Add navigation item to `DocsLayout.tsx` 3. Add navigation item to `pages/docs/Layout.tsx`
### Test Page
1. Create `src/test-pages/<name>.tsx`
2. Add route to `src/test-pages/router.tsx`
## Routing ## Routing
@@ -93,18 +91,16 @@ Uses hash-based routing for static hosting:
import { Router } from 'wouter' import { Router } from 'wouter'
import { useHashLocation } from 'wouter/use-hash-location' import { useHashLocation } from 'wouter/use-hash-location'
<Router hook={useHashLocation}> ;<Router hook={useHashLocation}>{/* routes */}</Router>
{/* routes */}
</Router>
``` ```
## Configuration Files ## Configuration Files
| File | Purpose | | File | Purpose |
|------|---------| | ----------------- | ----------------------- |
| `components.json` | shadcn/ui configuration | | `components.json` | shadcn/ui configuration |
| `vite.config.js` | Vite build settings | | `vite.config.js` | Vite build settings |
| `tsconfig.json` | TypeScript config | | `tsconfig.json` | TypeScript config |
## Commands ## Commands
@@ -112,4 +108,3 @@ import { useHashLocation } from 'wouter/use-hash-location'
npm start # Dev server (from root) npm start # Dev server (from root)
npm run build:website # Build website (from root) npm run build:website # Build website (from root)
``` ```
+6 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/website", "name": "@page-agent/website",
"private": true, "private": true,
"version": "0.0.19", "version": "0.0.21",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -11,14 +11,14 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@types/react": "^19.2.2", "@types/react": "^19.2.8",
"@types/react-dom": "^19.2.1", "@types/react-dom": "^19.2.1",
"@vitejs/plugin-react-swc": "^4.1.0", "@vitejs/plugin-react-swc": "^4.1.0",
"i18next": "^25.7.3", "i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-i18next": "^16.5.1", "react-i18next": "^16.5.2",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"wouter": "^3.9.0" "wouter": "^3.9.0"
@@ -32,10 +32,10 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"motion": "^12.24.0", "motion": "^12.26.1",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"rough-notation": "^0.5.1", "rough-notation": "^0.5.1",
"simple-icons": "^16.4.0", "simple-icons": "^16.5.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0"
} }
@@ -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>
)
}
@@ -27,14 +27,14 @@ export default {
overview: 'Overview', overview: 'Overview',
quick_start: 'Quick Start', quick_start: 'Quick Start',
limitations: 'Limitations', limitations: 'Limitations',
model_integration: 'Model Integration', models: 'Models',
custom_tools: 'Custom Tools', custom_tools: 'Custom Tools',
knowledge_injection: 'Instructions', knowledge_injection: 'Instructions',
security_permissions: 'Security & Permissions',
data_masking: 'Data Masking', data_masking: 'Data Masking',
cdn_setup: 'CDN Setup', cdn_setup: 'CDN Setup',
configuration: 'Configuration', configuration: 'Configuration',
best_practices: 'Best Practices', best_practices: 'Best Practices',
third_party_agent: 'Third-party Agent', 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.', '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: 'What is page-agent?',
what_is_desc: 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', features_title: 'Core Features',
feature_dom: { feature_dom: {
title: '🧠 Smart DOM Analysis', title: '🧠 Smart DOM Analysis',
@@ -112,8 +112,8 @@ export default {
future_3: '• Basic visual understanding', future_3: '• Basic visual understanding',
future_4: '• Smarter error recovery', future_4: '• Smarter error recovery',
}, },
model_integration: { models: {
title: 'Model Integration', title: 'Models',
subtitle: subtitle:
'Supports models that comply with OpenAI API specification and support tool calls, including public cloud services and private deployments.', '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.', recommendation_logic: 'Recommended: Lightweight models with strong ToolCall capabilities.',
@@ -26,14 +26,14 @@ export default {
overview: '概览', overview: '概览',
quick_start: '快速开始', quick_start: '快速开始',
limitations: '使用限制', limitations: '使用限制',
model_integration: '模型接入', models: '模型',
custom_tools: '自定义工具', custom_tools: '自定义工具',
knowledge_injection: '知识注入', knowledge_injection: '知识注入',
security_permissions: '安全与权限',
data_masking: '数据脱敏', data_masking: '数据脱敏',
cdn_setup: 'CDN 引入', cdn_setup: 'CDN 引入',
configuration: '配置选项', configuration: '配置选项',
best_practices: '最佳实践', best_practices: '最佳实践',
third_party_agent: '接入第三方 Agent', third_party_agent: '接入第三方 Agent',
security_permissions: '安全与权限',
}, },
} }
@@ -111,8 +111,8 @@ export default {
future_3: '• 基础的视觉理解能力', future_3: '• 基础的视觉理解能力',
future_4: '• 更智能的错误恢复机制', future_4: '• 更智能的错误恢复机制',
}, },
model_integration: { models: {
title: '模型接入', title: '模型',
subtitle: '当前支持符合 OpenAI 接口规范且支持 tool call 的模型,包括公有云服务和私有部署方案。', subtitle: '当前支持符合 OpenAI 接口规范且支持 tool call 的模型,包括公有云服务和私有部署方案。',
recommendation_logic: '推荐使用 ToolCall 能力强的轻量级模型。', recommendation_logic: '推荐使用 ToolCall 能力强的轻量级模型。',
recommended: '推荐模型', recommended: '推荐模型',
+2 -6
View File
@@ -1,19 +1,15 @@
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { Route, Router, Switch } from 'wouter' import { Router } from 'wouter'
import { useHashLocation } from 'wouter/use-hash-location' import { useHashLocation } from 'wouter/use-hash-location'
import './i18n/config' import './i18n/config'
import './i18n/types' import './i18n/types'
import { default as PagesRouter } from './router' import { default as PagesRouter } from './router'
import { default as TestPagesRouter } from './test-pages/router'
import './index.css' import './index.css'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<Router hook={useHashLocation}> <Router hook={useHashLocation}>
<Switch> <PagesRouter />
<Route path="/test-pages" component={TestPagesRouter} nest />
<Route path="/" component={PagesRouter} nest />
</Switch>
</Router> </Router>
) )
@@ -5,14 +5,14 @@ import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Link, useSearchParams } from 'wouter' import { Link, useSearchParams } from 'wouter'
import Footer from './components/Footer' import Footer from '../components/Footer'
import Header from './components/Header' import Header from '../components/Header'
import { AnimatedGradientText } from './components/ui/animated-gradient-text' import { AnimatedGradientText } from '../components/ui/animated-gradient-text'
import { Highlighter } from './components/ui/highlighter' import { Highlighter } from '../components/ui/highlighter'
import { NeonGradientCard } from './components/ui/neon-gradient-card' import { NeonGradientCard } from '../components/ui/neon-gradient-card'
import { Particles } from './components/ui/particles' import { Particles } from '../components/ui/particles'
import { SparklesText } from './components/ui/sparkles-text' import { SparklesText } from '../components/ui/sparkles-text'
import { CDN_CN_URL, CDN_URL } from './constants' import { CDN_CN_URL, CDN_URL } from '../constants'
function getInjection(useCN?: boolean) { function getInjection(useCN?: boolean) {
const cdn = useCN ? CDN_CN_URL : CDN_URL const cdn = useCN ? CDN_CN_URL : CDN_URL
@@ -32,20 +32,23 @@ export default function DocsLayout({ children }: DocsLayoutProps) {
{ {
title: t('nav.features'), title: t('nav.features'),
items: [ items: [
{ title: t('nav.model_integration'), path: '/docs/features/model-integration' }, { title: t('nav.models'), path: '/docs/features/models' },
{ title: t('nav.custom_tools'), path: '/docs/features/custom-tools' }, { title: t('nav.custom_tools'), path: '/docs/features/custom-tools' },
{ title: t('nav.knowledge_injection'), path: '/docs/features/custom-instructions' }, { 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.data_masking'), path: '/docs/features/data-masking' },
], ],
}, },
{ {
title: t('nav.integration'), title: t('nav.integration'),
items: [ items: [
{ title: t('nav.cdn_setup'), path: '/docs/integration/cdn-setup' },
{ title: t('nav.configuration'), path: '/docs/integration/configuration' }, { 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.third_party_agent'), path: '/docs/integration/third-party-agent' },
{ title: '🚧 ' + t('nav.cdn_setup'), path: '/docs/integration/cdn-setup' },
{
title: '🚧 ' + t('nav.security_permissions'),
path: '/docs/integration/security-permissions',
},
{ title: '🚧 ' + t('nav.best_practices'), path: '/docs/integration/best-practices' },
], ],
}, },
] ]
@@ -55,7 +58,7 @@ export default function DocsLayout({ children }: DocsLayoutProps) {
<div className="flex gap-8 min-w-[900px]"> <div className="flex gap-8 min-w-[900px]">
{/* Sidebar */} {/* Sidebar */}
<aside className="w-64 shrink-0" role="complementary" aria-label="文档导航"> <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="文档章节"> <nav className="space-y-8" role="navigation" aria-label="文档章节">
{navigationSections.map((section) => ( {navigationSections.map((section) => (
<section key={section.title}> <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>
)
}
@@ -4,13 +4,19 @@ import CodeEditor from '@/components/CodeEditor'
// Recommended models: lightweight with excellent tool call capabilities // Recommended models: lightweight with excellent tool call capabilities
const MODELS = { const MODELS = {
recommended: ['gpt-4.1-mini', 'claude-haiku-4.5', 'gemini-3-flash', 'deepseek-3.2', 'gpt-5.2'], recommended: [
verified: [ 'gpt-4.1-mini',
'claude-haiku-4.5',
'gemini-3-flash',
'deepseek-3.2',
'gpt-5.2',
'qwen-3-max', 'qwen-3-max',
],
verified: [
'qwen-3-plus',
'gpt-4.1', 'gpt-4.1',
'gpt-5', 'gpt-5',
'gpt-5-mini', 'gpt-5-mini',
'gpt-5.1',
'grok-4', 'grok-4',
'grok-code-fast', 'grok-code-fast',
'claude-sonnet-3.5', 'claude-sonnet-3.5',
@@ -21,22 +27,20 @@ const MODELS = {
], ],
} }
export default function ModelIntegration() { export default function Models() {
const { t } = useTranslation('docs') const { t } = useTranslation('docs')
const allModels = [...MODELS.recommended, ...MODELS.verified] const allModels = [...MODELS.recommended, ...MODELS.verified]
return ( return (
<div className="max-w-4xl"> <div className="max-w-4xl">
<h1 className="text-4xl font-bold mb-4">{t('model_integration.title')}</h1> <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"> <p className="text-lg text-gray-600 dark:text-gray-400 mb-8">{t('models.subtitle')}</p>
{t('model_integration.subtitle')}
</p>
{/* Models Section */} {/* Models Section */}
<section className="mb-10"> <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"> <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
{t('model_integration.recommendation_logic')} {t('models.recommendation_logic')}
</p> </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="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"> <div className="flex flex-wrap gap-2">
@@ -63,41 +67,41 @@ export default function ModelIntegration() {
{/* Tips Section */} {/* Tips Section */}
<section className="mb-10"> <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"> <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"> <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('models.tip_2')}</li>
<li>{t('model_integration.tip_3')}</li> <li>{t('models.tip_3')}</li>
</ul> </ul>
</div> </div>
</section> </section>
{/* Security Section */} {/* Security Section */}
<section className="mb-10"> <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"> <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"> <p className="text-sm font-semibold text-yellow-900 dark:text-yellow-200">
{t('model_integration.security_warning')} {t('models.security_warning')}
</p> </p>
</div> </div>
<div className="bg-gray-50 dark:bg-gray-900/30 rounded-lg p-5 border border-gray-200 dark:border-gray-800"> <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"> <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> </h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3"> <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> </p>
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1"> <ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
<li>{t('model_integration.security_method_1')}</li> <li>{t('models.security_method_1')}</li>
<li>{t('model_integration.security_method_2')}</li> <li>{t('models.security_method_2')}</li>
<li>{t('model_integration.security_method_3')}</li> <li>{t('models.security_method_3')}</li>
</ul> </ul>
</div> </div>
</section> </section>
{/* Configuration Section */} {/* Configuration Section */}
<section className="mb-10"> <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 <CodeEditor
code={`// OpenAI-compatible services (e.g., Alibaba Bailian) code={`// OpenAI-compatible services (e.g., Alibaba Bailian)
const pageAgent = new PageAgent({ const pageAgent = new PageAgent({
@@ -0,0 +1,82 @@
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 CdnSetup() {
const { i18n } = useTranslation()
const isZh = i18n.language === 'zh-CN'
return (
<div>
<BetaNotice />
<h1 className="text-4xl font-bold mb-6">{isZh ? 'CDN 引入' : 'CDN Setup'}</h1>
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
{isZh
? '通过 CDN 快速集成 page-agent,无需复杂的构建配置。默认使用演示模型。'
: 'Fastest way to integrate page-agent via CDN. No complex build setup needed. Demo model will be used by default.'}
</p>
<section className="mb-8">
<h2 className="text-2xl font-bold mb-4">{isZh ? 'CDN 地址' : 'CDN URLs'}</h2>
<div className="overflow-x-auto mb-6">
<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-3 px-4 font-semibold">{isZh ? '位置' : 'Location'}</th>
<th className="text-left py-3 px-4 font-semibold">URL</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-gray-100 dark:border-gray-800">
<td className="py-3 px-4">{isZh ? '全球' : 'Global'}</td>
<td className="py-3 px-4 font-mono text-xs break-all">{CDN_URL}</td>
</tr>
<tr className="border-b border-gray-100 dark:border-gray-800">
<td className="py-3 px-4">{isZh ? '中国镜像' : 'China Mirror'}</td>
<td className="py-3 px-4 font-mono text-xs break-all">{CDN_CN_URL}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section className="mb-8">
<h2 className="text-2xl font-bold mb-4">{isZh ? '快速开始' : 'Quick Start'}</h2>
<CodeEditor
className="mb-6"
code={`<script
src="${CDN_URL}"
crossorigin="true"
type="text/javascript"
></script>`}
/>
</section>
<section className="mb-8">
<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">
{isZh ? '注意事项' : 'Notes'}
</h3>
<ul className="text-gray-600 dark:text-gray-300 space-y-1">
<li>
{isZh ? '生产环境建议使用固定版本号' : 'Use fixed version number in production'}
</li>
<li> {isZh ? '确保 HTTPS 环境下使用' : 'Ensure HTTPS environment'}</li>
<li>
{' '}
{isZh
? '配置 CSP 策略允许脚本执行'
: 'Configure CSP policy to allow script execution'}
</li>
</ul>
</div>
</section>
</div>
)
}
@@ -0,0 +1,138 @@
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.
*/
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
onBeforeStep?: (stepCnt: number) => Promise<void> | void
onAfterStep?: (stepCnt: number, history: AgentHistory[]) => Promise<void> | void
onBeforeTask?: () => Promise<void> | void
onAfterTask?: (result: ExecutionResult) => Promise<void> | void
onDispose?: (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 PageControllerConfig {
/** 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
/** Viewport expansion in pixels (-1 for full page) */
viewportExpansion?: number
}`}
/>
</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>
)
}
@@ -74,19 +74,6 @@ const pageAgentTool = {
</div> </div>
</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"> <div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6">
<h3 className="text-lg font-semibold text-yellow-900 dark:text-yellow-100 mb-2"> <h3 className="text-lg font-semibold text-yellow-900 dark:text-yellow-100 mb-2">
@@ -97,19 +84,6 @@ const pageAgentTool = {
<li> </li> <li> </li>
</ul> </ul>
</div> </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> </div>
) )
} }
@@ -6,13 +6,13 @@ export default function Overview() {
return ( return (
<article> <article>
{/* 头图 */} {/* 头图 */}
<figure className="mb-8 rounded-xl overflow-hidden"> {/* <figure className="mb-8 rounded-xl overflow-hidden">
<img <img
src="https://img.alicdn.com/imgextra/i3/O1CN014xLeic1gnF2iWdzjJ_!!6000000004186-49-tps-1280-353.webp" src="https://img.alicdn.com/imgextra/i3/O1CN014xLeic1gnF2iWdzjJ_!!6000000004186-49-tps-1280-353.webp"
alt="page-agent" alt="page-agent"
className="w-full h-64 object-cover" className="w-full h-64 object-cover"
/> />
</figure> </figure> */}
<div className="mb-8"> <div className="mb-8">
<h1 className="text-4xl font-bold mb-4">{t('overview.title')}</h1> <h1 className="text-4xl font-bold mb-4">{t('overview.title')}</h1>
+25 -25
View File
@@ -1,23 +1,23 @@
import { Route, Switch } from 'wouter' import { Route, Switch } from 'wouter'
import DocsLayout from './components/DocsLayout'
import Header from './components/Header' import Header from './components/Header'
import Instructions from './docs/features/custom-instructions/page' import HomePage from './pages/Home'
import DocsLayout from './pages/docs/Layout'
// Features pages // Features pages
import CustomTools from './docs/features/custom-tools/page' import Instructions from './pages/docs/features/custom-instructions/page'
import DataMasking from './docs/features/data-masking/page' import CustomTools from './pages/docs/features/custom-tools/page'
import ModelIntegration from './docs/features/model-integration/page' import DataMasking from './pages/docs/features/data-masking/page'
import SecurityPermissions from './docs/features/security-permissions/page' import Models from './pages/docs/features/models/page'
import BestPractices from './docs/integration/best-practices/page'
// Integration pages // Integration pages
import CdnSetup from './docs/integration/cdn-setup/page' import BestPractices from './pages/docs/integration/best-practices/page'
import Configuration from './docs/integration/configuration/page' import CdnSetup from './pages/docs/integration/cdn-setup/page'
import ThirdPartyAgent from './docs/integration/third-party-agent/page' import Configuration from './pages/docs/integration/configuration/page'
import Limitations from './docs/introduction/limitations/page' import SecurityPermissions from './pages/docs/integration/security-permissions/page'
import ThirdPartyAgent from './pages/docs/integration/third-party-agent/page'
// Introduction pages // Introduction pages
import Overview from './docs/introduction/overview/page' import Limitations from './pages/docs/introduction/limitations/page'
import QuickStart from './docs/introduction/quick-start/page' import Overview from './pages/docs/introduction/overview/page'
import HomePage from './page' import QuickStart from './pages/docs/introduction/quick-start/page'
export default function Router() { export default function Router() {
return ( return (
@@ -53,15 +53,6 @@ export default function Router() {
</div> </div>
</Route> </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"> <Route path="/docs/features/custom-tools">
<div className="min-h-screen bg-white dark:bg-gray-900"> <div className="min-h-screen bg-white dark:bg-gray-900">
<Header /> <Header />
@@ -89,11 +80,11 @@ export default function Router() {
</div> </div>
</Route> </Route>
<Route path="/docs/features/model-integration"> <Route path="/docs/features/models">
<div className="min-h-screen bg-white dark:bg-gray-900"> <div className="min-h-screen bg-white dark:bg-gray-900">
<Header /> <Header />
<DocsLayout> <DocsLayout>
<ModelIntegration /> <Models />
</DocsLayout> </DocsLayout>
</div> </div>
</Route> </Route>
@@ -107,6 +98,15 @@ export default function Router() {
</div> </div>
</Route> </Route>
<Route path="/docs/integration/security-permissions">
<div className="min-h-screen bg-white dark:bg-gray-900">
<Header />
<DocsLayout>
<SecurityPermissions />
</DocsLayout>
</div>
</Route>
<Route path="/docs/integration/configuration"> <Route path="/docs/integration/configuration">
<div className="min-h-screen bg-white dark:bg-gray-900"> <div className="min-h-screen bg-white dark:bg-gray-900">
<Header /> <Header />
-209
View File
@@ -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">
(5MBPDF)
</label>
<input
type="file"
onChange={(e) =>
setFormData((prev) => ({ ...prev, file: e.target.files?.[0] || null }))
}
accept="image/*,.pdf"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
</div>
{formData.file && (
<div className="text-sm text-gray-600 dark:text-gray-300">
: {formData.file.name} ({(formData.file.size / 1024 / 1024).toFixed(2)}{' '}
MB)
</div>
)}
<button
onClick={handleFileUpload}
disabled={isLoading || !formData.file}
className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white rounded-md transition-colors"
>
{isLoading ? '上传中...' : '上传文件'}
</button>
</div>
</div>
{/* 权限测试说明 */}
<div className="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 rounded-lg p-4">
<h4 className="text-sm font-medium text-yellow-800 dark:text-yellow-200 mb-2">
💡
</h4>
<p className="text-sm text-yellow-700 dark:text-yellow-300">
"admin""触发错误"
</p>
</div>
</div>
</div>
{/* 加载状态指示器 */}
{isLoading && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 flex items-center space-x-4">
<svg
className="animate-spin h-8 w-8 text-blue-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span className="text-gray-900 dark:text-white">...</span>
</div>
</div>
)}
{/* 返回链接 */}
<div className="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
<Link href="/" className="text-blue-600 hover:text-blue-500 dark:text-blue-400">
</Link>
</div>
</div>
</div>
)
}
@@ -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>
)
}
-106
View File
@@ -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>
</>
)
}