Compare commits

...

68 Commits

Author SHA1 Message Date
Simon c0ce6e7d14 chore(version): bump version to 0.2.0 2026-01-15 20:47:39 +08:00
Simon 8f5f98f889 feat(agent)!: rename brain to reflection; fix undefined lines in panel 2026-01-15 20:45:40 +08:00
Simon ee4719cdcf feat: observation 2026-01-15 20:28:39 +08:00
Simon b4c82b7833 refactor: mv getBrowserState to Controller; simplify Agent 2026-01-15 20:17:50 +08:00
Simon aefc3cfb89 Merge pull request #111 from alibaba/feat/extend-agent-history-for-observation
feat: extend history type
2026-01-15 19:57:41 +08:00
Simon 7f2b8c2766 feat: extend history type 2026-01-15 19:18:25 +08:00
Simon 62e24a3050 chore(version): bump version to 0.1.0 2026-01-14 18:50:06 +08:00
Simon 4f6249a252 feat!: require user to provide LLM api. do not fallback to demo LLM
BREAKING CHANGE: LLM API will be required for agent constructor
2026-01-14 18:49:23 +08:00
Simon 62519416bb chore(version): bump version to 0.0.24 2026-01-14 18:34:55 +08:00
Simon a1e70e663d feat(PageController): exclude aria-hidden elements 2026-01-14 18:04:31 +08:00
Simon 338e317be1 chore(website): fix homepage overflow 2026-01-14 17:45:49 +08:00
Simon 9a66d5205f feat(website): display version in header and load from page-agent package 2026-01-14 17:42:18 +08:00
Simon 7d45bf6598 feat(website): update CDN links and usage 2026-01-14 17:27:35 +08:00
Simon 78ee72eea4 chore(version): bump version to 0.0.23 2026-01-14 15:46:10 +08:00
Simon 3b71c535b1 fix: clean up debug code 2026-01-14 15:23:02 +08:00
Simon 6615f7b300 Merge pull request #110 from alibaba/fix/panel-text-uncompleted
feat: add HTML escaping to Panel
2026-01-14 15:17:43 +08:00
Simon 38caae5e23 feat: add HTML escaping to Panel 2026-01-14 15:15:51 +08:00
Simon 1751ba325d chore(version): bump version to 0.0.22 2026-01-13 21:01:16 +08:00
Simon 066b045822 Merge pull request #108 from alibaba/feat/dual-umd
feat: add `cdn` packages for iife builds
2026-01-13 20:58:37 +08:00
Simon 3b21c42cdb feat: add cdn packages for iife builds 2026-01-13 20:52:41 +08:00
Simon 8b1e91d665 Merge pull request #107 from alibaba/refactor/mv-simulator-mask-to-page-controller
Refactor: move simulator mask to page controller
2026-01-13 17:38:37 +08:00
Simon aa5909264a chore: mv mask code to folder 2026-01-13 17:35:24 +08:00
Simon b6232d4e21 refactor: mv SimulatorMask from ui to page-controller 2026-01-13 17:33:38 +08:00
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
Simon f5900bd096 chore(version): bump version to 0.0.19 2026-01-10 23:35:02 +08:00
Simon 09772d79bd fix: panel language not found 2026-01-10 23:33:36 +08:00
Simon bfa6a5347e fix: rm maxTokens from api 2026-01-10 23:27:08 +08:00
Simon da5cf44012 Merge pull request #97 from alibaba/feat/instructions
feat: custom instructions
2026-01-10 19:27:27 +08:00
Simon 1666c08e58 refactor: rename instructions to custom-instructions 2026-01-10 19:25:56 +08:00
Simon 0c0c76fdd8 feat: wrap user callback in try catch
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-10 19:23:02 +08:00
Simon 6b0b1218ac chore: cleanup 2026-01-10 19:23:01 +08:00
Simon d70f652285 chore(website): add instructions for PageAgent demo page 2026-01-10 19:08:50 +08:00
Simon c3c01b63fb docs: upgrade docs for custom instructions 2026-01-10 19:02:18 +08:00
Simon 463d010c24 feat(agent): add instructions api 2026-01-10 18:44:09 +08:00
Simon 4e52f17b9f feat(agent): mv <agent_state> in front of <agent_history> 2026-01-10 17:44:08 +08:00
Simon 5237edfcca Merge branch 'main' of https://github.com/alibaba/page-agent 2026-01-07 19:58:56 +08:00
Simon 79dcf9d9e4 docs: fix api doc, should be model instead of modelName 2026-01-07 19:58:43 +08:00
Simon 09544205ed Merge pull request #91 from alibaba/dependabot/npm_and_yarn/production-dependencies-d50d67c463
chore(deps): bump the production-dependencies group across 1 directory with 3 updates
2026-01-07 19:28:54 +08:00
Simon 2c74c3170a Merge pull request #92 from alibaba/dependabot/npm_and_yarn/development-dependencies-f82d5f2ef0
chore(deps-dev): bump the development-dependencies group across 1 directory with 7 updates
2026-01-07 19:28:40 +08:00
dependabot[bot] 9cee8f4174 chore(deps-dev): bump the development-dependencies group across 1 directory with 7 updates
Bumps the development-dependencies group with 7 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@commitlint/cli](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/cli) | `20.2.0` | `20.3.0` |
| [@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/config-conventional) | `20.2.0` | `20.3.0` |
| [@trivago/prettier-plugin-sort-imports](https://github.com/trivago/prettier-plugin-sort-imports) | `6.0.0` | `6.0.1` |
| [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/HEAD/packages/plugins/eslint-plugin-react-dom) | `2.3.13` | `2.5.1` |
| [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/HEAD/packages/plugins/eslint-plugin-react-x) | `2.3.13` | `2.5.1` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.50.1` | `8.52.0` |
| [react-i18next](https://github.com/i18next/react-i18next) | `16.5.0` | `16.5.1` |



Updates `@commitlint/cli` from 20.2.0 to 20.3.0
- [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.0/@commitlint/cli)

Updates `@commitlint/config-conventional` from 20.2.0 to 20.3.0
- [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.0/@commitlint/config-conventional)

Updates `@trivago/prettier-plugin-sort-imports` from 6.0.0 to 6.0.1
- [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.0...v6.0.1)

Updates `eslint-plugin-react-dom` from 2.3.13 to 2.5.1
- [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.1/packages/plugins/eslint-plugin-react-dom)

Updates `eslint-plugin-react-x` from 2.3.13 to 2.5.1
- [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.1/packages/plugins/eslint-plugin-react-x)

Updates `typescript-eslint` from 8.50.1 to 8.52.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.52.0/packages/typescript-eslint)

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

---
updated-dependencies:
- dependency-name: "@commitlint/cli"
  dependency-version: 20.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: "@commitlint/config-conventional"
  dependency-version: 20.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: "@trivago/prettier-plugin-sort-imports"
  dependency-version: 6.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
- dependency-name: eslint-plugin-react-dom
  dependency-version: 2.5.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: eslint-plugin-react-x
  dependency-version: 2.5.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.52.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: react-i18next
  dependency-version: 16.5.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-05 19:38:18 +00:00
dependabot[bot] d05b43cf50 chore(deps): bump the production-dependencies group across 1 directory with 3 updates
Bumps the production-dependencies group with 3 updates in the / directory: [zod](https://github.com/colinhacks/zod), [motion](https://github.com/motiondivision/motion) and [simple-icons](https://github.com/simple-icons/simple-icons).


Updates `zod` from 4.2.1 to 4.3.5
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.2.1...v4.3.5)

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

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

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.3.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: motion
  dependency-version: 12.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: simple-icons
  dependency-version: 16.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-05 19:37:33 +00:00
81 changed files with 2110 additions and 5259 deletions
+30 -25
View File
@@ -9,9 +9,10 @@ This is a **monorepo** with npm workspaces:
Internal packages: Internal packages:
- **CDN** (`packages/cdn/`) - IIFE builds for script tag usage (npm: `@page-agent/cdn`)
- **LLMs** (`packages/llms/`) - LLM client with reflection-before-action mental model - **LLMs** (`packages/llms/`) - LLM client with reflection-before-action mental model
- **Page Controller** (`packages/page-controller/`) - DOM operations, independent of LLM - **Page Controller** (`packages/page-controller/`) - DOM operations and visual feedback (SimulatorMask), independent of LLM
- **UI** (`packages/ui/`) - Panel, SimulatorMask, i18n. Decoupled from PageAgent - **UI** (`packages/ui/`) - Panel and i18n. Decoupled from PageAgent
## Development Commands ## Development Commands
@@ -31,6 +32,7 @@ Simple monorepo solution: TypeScript references + Vite aliases. Update tsconfig
``` ```
packages/ packages/
├── page-agent/ # npm: "page-agent" ⭐ MAIN ├── page-agent/ # npm: "page-agent" ⭐ MAIN
├── cdn/ # npm: "@page-agent/cdn" (IIFE builds)
├── website/ # @page-agent/website (private) ├── website/ # @page-agent/website (private)
├── llms/ # @page-agent/llms ├── llms/ # @page-agent/llms
├── page-controller/ # @page-agent/page-controller ├── page-controller/ # @page-agent/page-controller
@@ -43,8 +45,8 @@ packages/
- **Page Agent**: Core lib. Imports from `@page-agent/llms`, `@page-agent/page-controller`, `@page-agent/ui` - **Page Agent**: Core lib. Imports from `@page-agent/llms`, `@page-agent/page-controller`, `@page-agent/ui`
- **LLMs**: LLM client with MacroToolInput contract. No dependency on page-agent - **LLMs**: LLM client with MacroToolInput contract. No dependency on page-agent
- **UI**: Panel, Mask, i18n. No dependency on page-agent - **UI**: Panel and i18n. No dependency on page-agent
- **Page Controller**: Pure DOM operations. No LLM or UI dependency - **Page Controller**: DOM operations with optional visual feedback (SimulatorMask). No LLM dependency. Enable mask via `enableMask: true` config
### PageController ↔ PageAgent Communication ### PageController ↔ PageAgent Communication
@@ -69,41 +71,43 @@ const pageInfo = await this.pageController.getPageInfo()
3. **LLM Processing**: AI returns action plans (page-agent) 3. **LLM Processing**: AI returns action plans (page-agent)
4. **Indexed Operations**: PageAgent calls PageController by element index 4. **Indexed Operations**: PageAgent calls PageController by element index
### CDN Auto-Injection ### CDN Builds (`packages/cdn/`)
Library auto-initializes via script tag: Two IIFE builds for script tag usage:
```html | Build | File | Description |
<script src="page-agent.js?model=gpt-4"></script> | ----- | -------------------- | -------------------------------- |
``` | Demo | `page-agent.demo.js` | Auto-init with built-in test API |
| Full | `page-agent.js` | Exposes `PageAgent` class only |
Query params configure `PageAgentConfig` in `src/umd.ts`. Demo build supports query params (e.g., `?model=gpt-4&lang=en-US`).
## Key Files Reference ## Key Files Reference
### Page Agent (`packages/page-agent/`) ### Page Agent (`packages/page-agent/`)
| File | Description | | File | Description |
|------|-------------| | ------------------ | --------------------------------------- |
| `src/PageAgent.ts` | ⭐ Main AI agent class | | `src/PageAgent.ts` | ⭐ Main AI agent class |
| `src/umd.ts` | CDN/UMD entry with auto-init | | `src/umd.ts` | CDN/UMD entry with auto-init |
| `src/tools/` | Tool definitions calling PageController | | `src/tools/` | Tool definitions calling PageController |
### LLMs (`packages/llms/`) ### LLMs (`packages/llms/`)
| File | Description | | File | Description |
|------|-------------| | ---------------------------- | ------------------------------------- |
| `src/index.ts` | ⭐ LLM class with retry logic | | `src/index.ts` | ⭐ LLM class with retry logic |
| `src/types.ts` | MacroToolInput, AgentBrain, LLMConfig | | `src/types.ts` | MacroToolInput, AgentBrain, LLMConfig |
| `src/OpenAILenientClient.ts` | OpenAI-compatible client | | `src/OpenAILenientClient.ts` | OpenAI-compatible client |
### Page Controller (`packages/page-controller/`) ### Page Controller (`packages/page-controller/`)
| File | Description | | File | Description |
|------|-------------| | --------------------------- | ---------------------------------------------------------- |
| `src/PageController.ts` | ⭐ Main controller class | | `src/PageController.ts` | ⭐ Main controller class with optional mask support |
| `src/actions.ts` | Element interactions (click, input, scroll) | | `src/SimulatorMask.ts` | Visual overlay blocking user interaction during automation |
| `src/dom/dom_tree/index.js` | Core DOM extraction engine | | `src/actions.ts` | Element interactions (click, input, scroll) |
| `src/dom/dom_tree/index.js` | Core DOM extraction engine |
## Adding New Features ## Adding New Features
@@ -123,4 +127,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.
+41 -23
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,23 +24,26 @@
## 🗺️ Roadmap ## 🗺️ Roadmap
👉 [**Roadmap**](./ROADMAP.md) 👉 [**Roadmap**](https://github.com/alibaba/page-agent/issues/96)
## 🚀 快速开始 ## 🚀 快速开始
### CDN 集成 ### 快速体验 (Demo CDN)
最快的体验方式:
```html ```html
// CDN - https://cdn.jsdelivr.net/npm/page-agent@latest/dist/umd/page-agent.js <script src="https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.demo.js" crossorigin="true"></script>
// 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 安装 > ⚠️ **仅用于技术评估。** Demo 模型有速率限制和使用限制,生产环境请使用 NPM 方式。
| 位置 | URL |
| ------ | --------------------------------------------------------------------------------------- |
| 全球 | https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.demo.js |
| 中国 | https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.demo.js |
### NPM 安装(推荐)
```bash ```bash
npm install page-agent npm install page-agent
@@ -49,23 +52,37 @@ npm install page-agent
```javascript ```javascript
import { PageAgent } from 'page-agent' import { PageAgent } from 'page-agent'
// 测试接口
// @note: 限流,限制 prompt 内容,限制来源,随时变更,请替换成你自己的
// @note: 使用 DeepSeek-chat(3.2) 官方版本,使用协议和隐私策略见 DeepSeek 网站
const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
const DEMO_BASE_URL = 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
const agent = new PageAgent({ const agent = new PageAgent({
modelName: DEMO_MODEL, model: 'deepseek-chat',
baseURL: DEMO_BASE_URL, baseURL: 'https://api.deepseek.com',
apiKey: DEMO_API_KEY, apiKey: 'YOUR_API_KEY',
language: 'zh-CN', language: 'zh-CN',
}) })
await agent.execute('点击登录按钮') await agent.execute('点击登录按钮')
``` ```
### CDN 引入
适用于无法使用 NPM 的环境,用法与 NPM 一致:
```html
<script src="https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.js" crossorigin="true"></script>
<script>
const agent = new PageAgent({
model: 'deepseek-chat',
baseURL: 'https://api.deepseek.com',
apiKey: 'YOUR_API_KEY',
})
await agent.execute('点击登录按钮')
</script>
```
| 位置 | URL |
| ------ | ----------------------------------------------------------------------------------- |
| 全球 | https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.js |
| 中国 | https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.js |
## 🏗️ 架构设计 ## 🏗️ 架构设计
PageAgent adopts a simplified monorepo structure: PageAgent adopts a simplified monorepo structure:
@@ -74,8 +91,9 @@ PageAgent adopts a simplified monorepo structure:
packages/ packages/
├── page-agent/ # AI agent (npm: page-agent) ├── page-agent/ # AI agent (npm: page-agent)
├── llms/ # LLM 客户端 (npm: @page-agent/llms) ├── llms/ # LLM 客户端 (npm: @page-agent/llms)
├── page-controller/ # DOM 操作 (npm: @page-agent/page-controller) ├── page-controller/ # DOM 操作 & 蒙层 & 模拟鼠标 (npm: @page-agent/page-controller)
├── ui/ # 面板 & 蒙层 & 模拟鼠标 (npm: @page-agent/ui) ├── ui/ # 面板 & i18n (npm: @page-agent/ui)
├── cdn/ # CDN IIFE builds (npm: @page-agent/cdn)
└── website/ # 文档站点 └── website/ # 文档站点
``` ```
+41 -23
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,23 +24,26 @@ 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 ### Quick Try (Demo CDN)
Fastest way to try PageAgent:
```html ```html
// CDN - https://cdn.jsdelivr.net/npm/page-agent@latest/dist/umd/page-agent.js <script src="https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.demo.js" crossorigin="true"></script>
// 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 > ⚠️ **For technical evaluation only.** Demo model has rate limits and usage restrictions. Use NPM for production.
| Location | URL |
| -------- | --------------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.demo.js |
| China | https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.demo.js |
### NPM Installation (Recommended)
```bash ```bash
npm install page-agent npm install page-agent
@@ -49,23 +52,37 @@ npm install page-agent
```javascript ```javascript
import { PageAgent } from 'page-agent' import { PageAgent } from 'page-agent'
// test server
// @note: rate limit. prompt limit. Origin limit. May change anytime. Use your own llm!
// @note Using official DeepSeek-chat(3.2). Go to DeepSeek website for privacy policy.
const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
const DEMO_BASE_URL = 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
const agent = new PageAgent({ const agent = new PageAgent({
modelName: DEMO_MODEL, model: 'deepseek-chat',
baseURL: DEMO_BASE_URL, baseURL: 'https://api.deepseek.com',
apiKey: DEMO_API_KEY, apiKey: 'YOUR_API_KEY',
language: 'en-US', language: 'en-US',
}) })
await agent.execute('Click the login button') await agent.execute('Click the login button')
``` ```
### CDN Build
For environments where NPM is not available. Usage is identical to NPM:
```html
<script src="https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.js" crossorigin="true"></script>
<script>
const agent = new PageAgent({
model: 'deepseek-chat',
baseURL: 'https://api.deepseek.com',
apiKey: 'YOUR_API_KEY',
})
await agent.execute('Click the login button')
</script>
```
| Location | URL |
| -------- | ----------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.js |
| China | https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.js |
## 🏗️ Structure ## 🏗️ Structure
PageAgent adopts a simplified monorepo structure: PageAgent adopts a simplified monorepo structure:
@@ -74,8 +91,9 @@ PageAgent adopts a simplified monorepo structure:
packages/ packages/
├── page-agent/ # AI agent (npm: page-agent) ├── page-agent/ # AI agent (npm: page-agent)
├── llms/ # LLM client (npm: @page-agent/llms) ├── llms/ # LLM client (npm: @page-agent/llms)
├── page-controller/ # DOM operations (npm: @page-agent/page-controller) ├── page-controller/ # DOM operations & Visual Mask (npm: @page-agent/page-controller)
├── ui/ # Panel & Mask & Mouse Animation (npm: @page-agent/ui) ├── ui/ # Panel & i18n (npm: @page-agent/ui)
├── cdn/ # CDN IIFE builds (npm: @page-agent/cdn)
└── website/ # Demo & Documentation site └── website/ # Demo & Documentation site
``` ```
-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,
+290 -297
View File
File diff suppressed because it is too large Load Diff
+11 -10
View File
@@ -1,13 +1,14 @@
{ {
"name": "root", "name": "root",
"private": true, "private": true,
"version": "0.0.18", "version": "0.2.0",
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
"packages/page-controller", "packages/page-controller",
"packages/ui", "packages/ui",
"packages/llms", "packages/llms",
"packages/page-agent", "packages/page-agent",
"packages/cdn",
"packages/website" "packages/website"
], ],
"description": "AI-powered UI agent for web applications", "description": "AI-powered UI agent for web applications",
@@ -34,28 +35,28 @@
"prepare": "husky" "prepare": "husky"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^20.2.0", "@commitlint/cli": "^20.3.1",
"@commitlint/config-conventional": "^20.2.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.0", "@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.3.13", "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.3.13", "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.50.1", "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": {
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@page-agent/cdn",
"private": false,
"version": "0.2.0",
"type": "module",
"files": [
"dist/"
],
"description": "CDN builds for page-agent - IIFE bundles for script tag usage",
"author": "Simon<gaomeng1900>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/alibaba/page-agent.git"
},
"homepage": "https://alibaba.github.io/page-agent/",
"scripts": {
"build": "vite build && vite build --mode demo"
},
"dependencies": {
"page-agent": "0.2.0"
}
}
+64
View File
@@ -0,0 +1,64 @@
// @ts-nocheck
/**
* Demo CDN build for page-agent
* Auto-initializes with built-in demo API for testing
*
* Usage:
* <script src="https://unpkg.com/@page-agent/cdn/dist/page-agent.demo.js"></script>
*/
import { PageAgent } from 'page-agent'
// Clean up existing instances to prevent multiple injections from bookmarklet
if (window.pageAgent) {
window.pageAgent.dispose()
}
// Mount to global window object
window.PageAgent = PageAgent
// Export for IIFE
export { PageAgent }
console.log('🚀 page-agent.js loaded!')
const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
const DEMO_BASE_URL = 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
// Demo warning
console.log(
'%c⚠️ DEMO MODE %c\n' +
'This build uses a shared testing LLM with rate limits.\n' +
'For production, use page-agent.js with your own API key:\n' +
'https://alibaba.github.io/page-agent/#/docs/features/models',
'background: #f59e0b; color: #000; font-size: 14px; font-weight: bold; padding: 4px 8px; border-radius: 4px;',
'color: #f59e0b; font-size: 12px;'
)
// in case document.x is not ready yet
setTimeout(() => {
const currentScript = document.currentScript
if (currentScript) {
console.log('🚀 page-agent.js detected current script:', currentScript.src)
const url = new URL(currentScript.src)
const model = url.searchParams.get('model') || DEMO_MODEL
const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL
const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY
const language = url.searchParams.get('lang') || 'zh-CN'
const config = { model, baseURL, apiKey, language }
window.pageAgent = new PageAgent(config)
} else {
console.log('🚀 page-agent.js no current script detected, using default demo config')
const config = {
model: DEMO_MODEL,
baseURL: DEMO_BASE_URL,
apiKey: DEMO_API_KEY,
}
window.pageAgent = new PageAgent(config)
}
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
window.pageAgent.panel.show()
})
+15
View File
@@ -0,0 +1,15 @@
// @ts-nocheck
/**
* Full CDN build for page-agent
* Exposes PageAgent class for manual instantiation and configuration
*
* Usage:
* <script src="https://unpkg.com/@page-agent/cdn/dist/page-agent.js"></script>
* <script>
* const agent = new PageAgent({ model: 'gpt-4o', apiKey: 'your-key' })
* agent.panel.show()
* </script>
*/
import { PageAgent } from 'page-agent'
export { PageAgent }
+42
View File
@@ -0,0 +1,42 @@
// @ts-nocheck
import { dirname, resolve } from 'path'
import { fileURLToPath } from 'url'
import { defineConfig } from 'vite'
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
const __dirname = dirname(fileURLToPath(import.meta.url))
/**
* CDN IIFE builds for page-agent
*
* Build targets (via --mode):
* - demo: page-agent.demo.js with auto-init and built-in API
* - full (default): page-agent.js exposes PageAgent class only
*/
export default defineConfig(({ mode }) => {
const isDemo = mode === 'demo'
const entry = isDemo ? resolve(__dirname, 'src/demo.js') : resolve(__dirname, 'src/full.js')
const fileName = isDemo ? 'page-agent.demo' : 'page-agent'
return {
plugins: [cssInjectedByJsPlugin({ relativeCSSInjection: true })],
publicDir: false,
esbuild: {
keepNames: true,
},
build: {
lib: {
entry,
name: 'PageAgent',
fileName: () => `${fileName}.js`,
formats: ['iife'],
},
outDir: resolve(__dirname, 'dist'),
emptyOutDir: !isDemo, // only empty on first build (full)
minify: false,
},
define: {
'process.env.NODE_ENV': '"production"',
},
}
})
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/llms", "name": "@page-agent/llms",
"version": "0.0.18", "version": "0.2.0",
"type": "module", "type": "module",
"main": "./dist/lib/page-agent-llms.js", "main": "./dist/lib/page-agent-llms.js",
"module": "./dist/lib/page-agent-llms.js", "module": "./dist/lib/page-agent-llms.js",
@@ -38,6 +38,6 @@
}, },
"dependencies": { "dependencies": {
"chalk": "^5.6.2", "chalk": "^5.6.2",
"zod": "^4.2.1" "zod": "^4.3.5"
} }
} }
+88 -75
View File
@@ -1,61 +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,
max_tokens: this.config.maxTokens,
messages,
tools: openaiTools,
// tool_choice: 'required',
tool_choice: { type: 'function', function: { name: 'AgentOutput' } },
// model specific params
// reasoning_effort: 'minimal',
// verbosity: 'low',
parallel_tool_calls: false,
})
),
signal: abortSignal, signal: abortSignal,
}) })
} catch (error: unknown) { } catch (error: unknown) {
// Network error console.error(error)
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, 'Network request failed', error) throw new InvokeError(InvokeErrorType.NETWORK_ERROR, 'Network request failed', error)
} }
@@ -93,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,
@@ -172,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: {
-125
View File
@@ -1,125 +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,
max_tokens: this.config.maxTokens,
messages,
tools: openaiTools,
// tool_choice: 'required',
tool_choice: { type: 'function', function: { name: 'AgentOutput' } },
parallel_tool_calls: false,
})
),
signal: abortSignal,
})
} catch (error: unknown) {
// Network error
console.error(error)
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, 'Network request failed', error)
}
// 3. Handle HTTP errors
if (!response.ok) {
const errorData = await response.json().catch()
const errorMessage =
(errorData as { error?: { message?: string } }).error?.message || response.statusText
if (response.status === 401 || response.status === 403) {
throw new InvokeError(
InvokeErrorType.AUTH_ERROR,
`Authentication failed: ${errorMessage}`,
errorData
)
}
if (response.status === 429) {
throw new InvokeError(
InvokeErrorType.RATE_LIMIT,
`Rate limit exceeded: ${errorMessage}`,
errorData
)
}
if (response.status >= 500) {
throw new InvokeError(
InvokeErrorType.SERVER_ERROR,
`Server error: ${errorMessage}`,
errorData
)
}
throw new InvokeError(
InvokeErrorType.UNKNOWN,
`HTTP ${response.status}: ${errorMessage}`,
errorData
)
}
// parse response
const data = await response.json()
const tool = tools.AgentOutput
const macroToolInput = lenientParseMacroToolCall(data, tool.inputSchema as any)
// Execute tool
let toolResult: unknown
try {
toolResult = await tool.execute(macroToolInput)
} catch (e) {
throw new InvokeError(
InvokeErrorType.TOOL_EXECUTION_ERROR,
`Tool execution failed: ${(e as Error).message}`,
e
)
}
// Return result (including cache tokens)
return {
toolCall: {
// id: toolCall.id,
name: 'AgentOutput',
args: macroToolInput,
},
toolResult,
usage: {
promptTokens: data.usage?.prompt_tokens ?? 0,
completionTokens: data.usage?.completion_tokens ?? 0,
totalTokens: data.usage?.total_tokens ?? 0,
cachedTokens: data.usage?.prompt_tokens_details?.cached_tokens,
reasoningTokens: data.usage?.completion_tokens_details?.reasoning_tokens,
},
rawResponse: data,
}
}
}
+1 -19
View File
@@ -1,21 +1,3 @@
// Dev environment: use .env config if available, otherwise fallback to testing api // Internal constants
export const DEFAULT_MODEL_NAME: string =
import.meta.env.DEV && import.meta.env.LLM_MODEL_NAME
? import.meta.env.LLM_MODEL_NAME
: 'PAGE-AGENT-FREE-TESTING-RANDOM'
export const DEFAULT_API_KEY: string =
import.meta.env.DEV && import.meta.env.LLM_API_KEY
? import.meta.env.LLM_API_KEY
: 'PAGE-AGENT-FREE-TESTING-RANDOM'
export const DEFAULT_BASE_URL: string =
import.meta.env.DEV && import.meta.env.LLM_BASE_URL
? import.meta.env.LLM_BASE_URL
: 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
// internal
export const LLM_MAX_RETRIES = 2 export const LLM_MAX_RETRIES = 2
export const DEFAULT_TEMPERATURE = 0.7 // higher randomness helps auto-recovery export const DEFAULT_TEMPERATURE = 0.7 // higher randomness helps auto-recovery
export const DEFAULT_MAX_TOKENS = 16000
+18 -68
View File
@@ -1,75 +1,24 @@
/** import { OpenAIClient } from './OpenAIClient'
* @topic LLM 与主流程的隔离 import { DEFAULT_TEMPERATURE, LLM_MAX_RETRIES } from './constants'
* @reasoning
* 将 llm 的调用和主流程分开是复杂的,
* 因为 agent 的 tool call 通常集成在 llm 模块中,而而先得到 llm 返回,然后处理工具调用
* tools 和 llm 调用的逻辑不可避免地耦合在一起,tool 的执行又和主流程耦合在一起
* 而 history 的维护和更新逻辑,又必须嵌入多轮 tool call 中
* @reasoning
* - 放弃框架提供的自动的多轮调用,每轮调用都由主流程发起
* - 理想情况下,llm 调用应该获得 structured output,然后由额外的模块触发 tool call,目前模型和框架都无法实现
* - 当前只能将 llm api 和 本地 tool call 耦合在一起,不关心其中的衔接方式
* @conclusion
* - @llm responsibility boundary:
* - call llm api with given messages and tools
* - invoke tool call and get the result of the tool
* - return the result to main loop
* - @main_loop responsibility boundary:
* - maintain all behaviors of an **agent**
* @conclusion
* - 这里的 llm 模块不是 agent,只负责一轮 llm 调用和工具调用,无状态
*/
/**
* @topic 结构化输出
* @facts
* - 几乎所有模型都支持 tool call schema
* - 几乎所有模型都支持返回 json
* - 只有 openAI/grok/gemini 支持 schema 并保证格式
* - 主流模型都支持 tool_choice: required
* - 除了 qwen 必须指定一个函数名 (9月上新后支持)
* @conclusion
* - 永远使用 tool call 来返回结构化数据,禁止模型直接返回(视为出错)
* - 不能假设 tool 参数合法,必须有修复机制,而且修复也应该使用 tool call 返回
*/
import { OpenAIClient } from './OpenAILenientClient'
import {
DEFAULT_API_KEY,
DEFAULT_BASE_URL,
DEFAULT_MAX_TOKENS,
DEFAULT_MODEL_NAME,
DEFAULT_TEMPERATURE,
LLM_MAX_RETRIES,
} from './constants'
import { InvokeError } from './errors' import { InvokeError } from './errors'
import type { import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
AgentBrain,
InvokeResult,
LLMClient,
LLMConfig,
MacroToolInput,
MacroToolResult,
Message,
Tool,
} from './types'
export type { export type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool }
AgentBrain,
InvokeResult,
LLMClient,
LLMConfig,
MacroToolInput,
MacroToolResult,
Message,
Tool,
}
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> { export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
// Runtime validation as defensive programming (types already guarantee these)
if (!config.baseURL || !config.apiKey || !config.model) {
throw new Error(
'[PageAgent] LLM configuration required. Please provide: baseURL, apiKey, model. ' +
'See: https://alibaba.github.io/page-agent/#/docs/features/models'
)
}
return { return {
baseURL: config.baseURL ?? DEFAULT_BASE_URL, baseURL: config.baseURL,
apiKey: config.apiKey ?? DEFAULT_API_KEY, apiKey: config.apiKey,
model: config.model ?? DEFAULT_MODEL_NAME, model: config.model,
temperature: config.temperature ?? DEFAULT_TEMPERATURE, temperature: config.temperature ?? DEFAULT_TEMPERATURE,
maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
maxRetries: config.maxRetries ?? LLM_MAX_RETRIES, maxRetries: config.maxRetries ?? LLM_MAX_RETRIES,
customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound
} }
@@ -95,11 +44,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
}, },
+23 -38
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>
} }
@@ -68,12 +87,11 @@ export interface InvokeResult<TResult = unknown> {
* LLM configuration * LLM configuration
*/ */
export interface LLMConfig { export interface LLMConfig {
baseURL?: string baseURL: string
apiKey?: string apiKey: string
model?: string model: string
temperature?: number temperature?: number
maxTokens?: number
maxRetries?: number maxRetries?: number
/** /**
@@ -83,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')) {
+5 -5
View File
@@ -1,7 +1,7 @@
{ {
"name": "page-agent", "name": "page-agent",
"private": false, "private": false,
"version": "0.0.18", "version": "0.2.0",
"type": "module", "type": "module",
"main": "./dist/esm/page-agent.js", "main": "./dist/esm/page-agent.js",
"module": "./dist/esm/page-agent.js", "module": "./dist/esm/page-agent.js",
@@ -45,9 +45,9 @@
}, },
"dependencies": { "dependencies": {
"chalk": "^5.6.2", "chalk": "^5.6.2",
"zod": "^4.2.1", "zod": "^4.3.5",
"@page-agent/llms": "0.0.18", "@page-agent/llms": "0.2.0",
"@page-agent/page-controller": "0.0.18", "@page-agent/page-controller": "0.2.0",
"@page-agent/ui": "0.0.18" "@page-agent/ui": "0.2.0"
} }
} }
+236 -104
View File
@@ -2,15 +2,9 @@
* 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 } from '@page-agent/ui'
import chalk from 'chalk' import chalk from 'chalk'
import zod from 'zod' import zod from 'zod'
@@ -18,15 +12,50 @@ 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 reflection state - the reflection-before-action model
*
* Every tool call must first reflect on:
* - evaluation_previous_goal: How well did the previous action achieve its goal?
* - memory: Key information to remember for future steps
* - next_goal: What should be accomplished in the next action?
*/
export interface AgentReflection {
evaluation_previous_goal: string
memory: string
next_goal: string
}
/**
* MacroTool input structure
*
* This is the core abstraction that enforces the "reflection-before-action" mental model.
* Before executing any action, the LLM must output its reasoning state.
*/
export interface MacroToolInput extends Partial<AgentReflection> {
action: Record<string, any>
}
/**
* MacroTool output structure
*/
export interface MacroToolResult {
input: MacroToolInput
output: string
}
export type { PageAgentConfig } export type { PageAgentConfig }
export { tool, type PageAgentTool } from './tools' export { tool, type PageAgentTool } from './tools'
export type { AgentBrain, MacroToolInput, MacroToolResult }
export interface AgentHistory { /**
brain: AgentBrain * A single agent step with reflection and action
*/
export interface AgentStep {
type: 'step'
reflection: Partial<AgentReflection>
action: { action: {
name: string name: string
input: any input: any
@@ -41,10 +70,33 @@ export interface AgentHistory {
} }
} }
/**
* Persistent observation event (stays in memory)
*/
export interface ObservationEvent {
type: 'observation'
content: string
}
/**
* User takeover event
*/
export interface UserTakeoverEvent {
type: 'user_takeover'
}
/**
* Union type for all history events
*/
export type HistoryEvent = AgentStep | ObservationEvent | UserTakeoverEvent
/** @deprecated Use AgentStep instead */
export type AgentHistory = AgentStep
export interface ExecutionResult { export interface ExecutionResult {
success: boolean success: boolean
data: string data: string
history: AgentHistory[] history: HistoryEvent[]
} }
export class PageAgent extends EventTarget { export class PageAgent extends EventTarget {
@@ -58,7 +110,6 @@ export class PageAgent extends EventTarget {
taskId = '' taskId = ''
#llm: LLM #llm: LLM
#totalWaitTime = 0
#abortController = new AbortController() #abortController = new AbortController()
#llmRetryListener: ((e: Event) => void) | null = null #llmRetryListener: ((e: Event) => void) | null = null
#llmErrorListener: ((e: Event) => void) | null = null #llmErrorListener: ((e: Event) => void) | null = null
@@ -67,12 +118,18 @@ export class PageAgent extends EventTarget {
/** PageController for DOM operations */ /** PageController for DOM operations */
pageController: PageController pageController: PageController
/** Fullscreen mask */ /** Runtime states for tracking across steps */
mask = new SimulatorMask() states = {
/** History records */ /** Accumulated wait time in seconds, used by wait tool */
history: AgentHistory[] = [] totalWaitTime: 0,
/** Last known URL for detecting navigation */
lastURL: '',
}
constructor(config: PageAgentConfig = {}) { /** History event stream */
history: HistoryEvent[] = []
constructor(config: PageAgentConfig) {
super() super()
this.config = config this.config = config
@@ -89,8 +146,11 @@ export class PageAgent extends EventTarget {
}) })
this.tools = new Map(tools) this.tools = new Map(tools)
// Initialize PageController with config // Initialize PageController with config (mask enabled by default)
this.pageController = new PageController(this.config) this.pageController = new PageController({
...this.config,
enableMask: this.config.enableMask ?? true,
})
// Listen to LLM events // Listen to LLM events
this.#llmRetryListener = (e) => { this.#llmRetryListener = (e) => {
@@ -125,8 +185,13 @@ export class PageAgent extends EventTarget {
} }
/** /**
* @todo maybe return something? * Push a persistent observation to the history event stream.
* This will be visible in <agent_history> and remain in memory across steps.
*/ */
pushObservation(content: string): void {
this.history.push({ type: 'observation', content })
}
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
@@ -140,7 +205,7 @@ export class PageAgent extends EventTarget {
await onBeforeTask.call(this) await onBeforeTask.call(this)
// Show mask and panel // Show mask and panel
this.mask.show() this.pageController.showMask()
this.panel.show() this.panel.show()
this.panel.reset() this.panel.reset()
@@ -154,10 +219,18 @@ export class PageAgent extends EventTarget {
this.history = [] this.history = []
// Reset states
this.states = {
totalWaitTime: 0,
lastURL: '',
}
try { try {
let step = 0 let step = 0
while (true) { while (true) {
await this.#generateObservations(step)
await onBeforeStep.call(this, step) await onBeforeStep.call(this, step)
console.group(`step: ${step}`) console.group(`step: ${step}`)
@@ -183,29 +256,34 @@ 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
const input = macroResult.input const input = macroResult.input
const output = macroResult.output const output = macroResult.output
const brain = { const reflection: Partial<AgentReflection> = {
evaluation_previous_goal: input.evaluation_previous_goal || '', evaluation_previous_goal: input.evaluation_previous_goal,
memory: input.memory || '', memory: input.memory,
next_goal: input.next_goal || '', next_goal: input.next_goal,
} }
const actionName = Object.keys(input.action)[0] const actionName = Object.keys(input.action)[0]
const action = { const action: AgentStep['action'] = {
name: actionName, name: actionName,
input: input.action[actionName], input: input.action[actionName],
output: output, output: output,
} }
this.history.push({ this.history.push({
brain, type: 'step',
reflection,
action, action,
usage: result.usage, usage: result.usage,
}) } as AgentStep)
console.log(chalk.green('Step finished:'), actionName) console.log(chalk.green('Step finished:'), actionName)
console.groupEnd() console.groupEnd()
@@ -279,6 +357,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
@@ -291,13 +370,20 @@ export class PageAgent extends EventTarget {
const toolName = Object.keys(action)[0] const toolName = Object.keys(action)[0]
const toolInput = action[toolName] const toolInput = action[toolName]
const brain = trimLines(`✅: ${input.evaluation_previous_goal}
💾: ${input.memory}
🎯: ${input.next_goal}
`)
console.log(brain) // Build reflection text, only include non-empty fields
this.panel.update({ type: 'thinking', text: brain }) const reflectionLines: string[] = []
if (input.evaluation_previous_goal)
reflectionLines.push(`✅: ${input.evaluation_previous_goal}`)
if (input.memory) reflectionLines.push(`💾: ${input.memory}`)
if (input.next_goal) reflectionLines.push(`🎯: ${input.next_goal}`)
const reflectionText = reflectionLines.length > 0 ? reflectionLines.join('\n') : ''
if (reflectionText) {
console.log(reflectionText)
this.panel.update({ type: 'thinking', text: reflectionText })
}
// Find the corresponding tool // Find the corresponding tool
const tool = tools.get(toolName) const tool = tools.get(toolName)
@@ -309,20 +395,14 @@ export class PageAgent extends EventTarget {
const startTime = Date.now() const startTime = Date.now()
// Execute tool, bind `this` to PageAgent // Execute tool, bind `this` to PageAgent
let result = await tool.execute.bind(this)(toolInput) const result = await tool.execute.bind(this)(toolInput)
const duration = Date.now() - startTime const duration = Date.now() - startTime
console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result) console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result)
if (toolName === 'wait') { // Reset wait time for non-wait tools
this.#totalWaitTime += Math.round(toolInput.seconds + duration / 1000) if (toolName !== 'wait') {
result += `\n<sys> You have waited ${this.#totalWaitTime} seconds accumulatively.` this.states.totalWaitTime = 0
if (this.#totalWaitTime >= 3)
result += '\nDo NOT wait any longer unless you have a good reason.\n'
result += '</sys>'
} else {
// For other tools, reset wait time
this.#totalWaitTime = 0
} }
// Briefly display execution result // Briefly display execution result
@@ -361,42 +441,122 @@ export class PageAgent extends EventTarget {
return systemPrompt return systemPrompt
} }
/**
* Get instructions from config and format as XML block
*/
async #getInstructions(): Promise<string> {
const { instructions } = this.config
if (!instructions) return ''
const systemInstructions = instructions.system?.trim()
const url = await this.pageController.getCurrentUrl()
let pageInstructions: string | undefined
if (instructions.getPageInstructions) {
try {
pageInstructions = instructions.getPageInstructions(url)?.trim()
} catch (error) {
console.error(
chalk.red('[PageAgent] Failed to execute getPageInstructions callback:'),
error
)
}
}
if (!systemInstructions && !pageInstructions) return ''
let result = '<instructions>\n'
if (systemInstructions) {
result += `<system_instructions>\n${systemInstructions}\n</system_instructions>\n`
}
if (pageInstructions) {
result += `<page_instructions>\n${pageInstructions}\n</page_instructions>\n`
}
result += '</instructions>\n\n'
return result
}
/**
* Generate observations before each step
* - URL change detection
* - Too many steps warning
* @todo loop detection
* @todo console error
*/
async #generateObservations(stepCount: number): Promise<void> {
// Detect URL change
const currentURL = await this.pageController.getCurrentUrl()
if (currentURL !== this.states.lastURL) {
this.pushObservation(`Page navigated to → ${currentURL}`)
this.states.lastURL = currentURL
}
// Warn about remaining steps
const remaining = MAX_STEPS - stepCount
if (remaining === 5) {
this.pushObservation(
`⚠️ Only ${remaining} steps remaining. Consider wrapping up or calling done with partial results.`
)
} else if (remaining === 2) {
this.pushObservation(
`⚠️ Critical: Only ${remaining} steps left! You must finish the task or call done immediately.`
)
}
}
async #assembleUserPrompt(): Promise<string> { async #assembleUserPrompt(): Promise<string> {
let prompt = '' let prompt = ''
// <agent_history> // <instructions> (optional)
// - <step_> prompt += await this.#getInstructions()
prompt += '<agent_history>\n'
this.history.forEach((history, index) => {
prompt += `<step_${index + 1}>
Evaluation of Previous Step: ${history.brain.evaluation_previous_goal}
Memory: ${history.brain.memory}
Next Goal: ${history.brain.next_goal}
Action Results: ${history.action.output}
</step_${index + 1}>
`
})
prompt += '</agent_history>\n\n'
// <agent_state> // <agent_state>
// - <user_request> // - <user_request>
// - <step_info> // - <step_info>
// <agent_state> // <agent_state>
const stepCount = this.history.filter((e) => e.type === 'step').length
prompt += `<agent_state> prompt += `<agent_state>
<user_request> <user_request>
${this.task} ${this.task}
</user_request> </user_request>
<step_info> <step_info>
Step ${this.history.length + 1} of ${MAX_STEPS} max possible steps Step ${stepCount + 1} of ${MAX_STEPS} max possible steps
Current date and time: ${new Date().toISOString()} Current date and time: ${new Date().toISOString()}
</step_info> </step_info>
</agent_state> </agent_state>
` `
// <agent_history>
// - <step_N> for steps
// - <sys> for observations and system messages
prompt += '\n<agent_history>\n'
let stepIndex = 0
for (const event of this.history) {
if (event.type === 'step') {
stepIndex++
prompt += `<step_${stepIndex}>
Evaluation of Previous Step: ${event.reflection.evaluation_previous_goal}
Memory: ${event.reflection.memory}
Next Goal: ${event.reflection.next_goal}
Action Results: ${event.action.output}
</step_${stepIndex}>
`
} else if (event.type === 'observation') {
prompt += `<sys>${event.content}</sys>\n`
} else if (event.type === 'user_takeover') {
prompt += `<sys>User took over control and made changes to the page.</sys>\n`
}
}
prompt += '</agent_history>\n\n'
// <browser_state> // <browser_state>
prompt += await this.#getBrowserState() prompt += await this.#getBrowserState()
@@ -417,55 +577,28 @@ export class PageAgent extends EventTarget {
// Task completed // Task completed
this.panel.update({ type: 'completed' }) this.panel.update({ type: 'completed' })
this.mask.hide() this.pageController.hideMask()
this.#abortController.abort() this.#abortController.abort()
} }
async #getBrowserState(): Promise<string> { async #getBrowserState(): Promise<string> {
const pageUrl = await this.pageController.getCurrentUrl() const state = await this.pageController.getBrowserState()
const pageTitle = await this.pageController.getPageTitle()
const pi = await this.pageController.getPageInfo()
const viewportExpansion = await this.pageController.getViewportExpansion()
this.mask.wrapper.style.pointerEvents = 'none' let content = state.content
await this.pageController.updateTree() if (this.config.transformPageContent) {
this.mask.wrapper.style.pointerEvents = 'auto' content = await this.config.transformPageContent(content)
}
const simplifiedHTML = await this.pageController.getSimplifiedHTML() return trimLines(`<browser_state>
Current Page: [${state.title}](${state.url})
let prompt = trimLines(`<browser_state> ${state.header}
Current Page: [${pageTitle}](${pageUrl}) ${content}
${state.footer}
Page info: ${pi.viewport_width}x${pi.viewport_height}px viewport, ${pi.page_width}x${pi.page_height}px total page size, ${pi.pages_above.toFixed(1)} pages above, ${pi.pages_below.toFixed(1)} pages below, ${pi.total_pages.toFixed(1)} total pages, at ${(pi.current_page_position * 100).toFixed(0)}% of page
${viewportExpansion === -1 ? 'Interactive elements from top layer of the current page (full page):' : 'Interactive elements from top layer of the current page inside the viewport:'}
</browser_state>
`) `)
// Page header info
const has_content_above = pi.pixels_above > 4
if (has_content_above && viewportExpansion !== -1) {
prompt += `... ${pi.pixels_above} pixels above (${pi.pages_above.toFixed(1)} pages) - scroll to see more ...\n`
} else {
prompt += `[Start of page]\n`
}
// Current viewport info
prompt += simplifiedHTML
prompt += `\n`
// Page footer info
const has_content_below = pi.pixels_below > 4
if (has_content_below && viewportExpansion !== -1) {
prompt += `... ${pi.pixels_below} pixels below (${pi.pages_below.toFixed(1)} pages) - scroll to see more ...\n`
} else {
prompt += `[End of page]\n`
}
prompt += `</browser_state>\n`
return prompt
} }
dispose(reason?: string) { dispose(reason?: string) {
@@ -473,7 +606,6 @@ export class PageAgent extends EventTarget {
this.disposed = true this.disposed = true
this.pageController.dispose() this.pageController.dispose()
this.panel.dispose() this.panel.dispose()
this.mask.dispose()
this.history = [] this.history = []
this.#abortController.abort(reason ?? 'PageAgent disposed') this.#abortController.abort(reason ?? 'PageAgent disposed')
+39 -2
View File
@@ -2,7 +2,7 @@ import type { LLMConfig } from '@page-agent/llms'
import type { PageControllerConfig } from '@page-agent/page-controller' import type { PageControllerConfig } from '@page-agent/page-controller'
import type { SupportedLanguage } from '@page-agent/ui' import type { SupportedLanguage } from '@page-agent/ui'
import type { AgentHistory, ExecutionResult, PageAgent } from '../PageAgent' import type { ExecutionResult, HistoryEvent, PageAgent } from '../PageAgent'
import type { PageAgentTool } from '../tools' import type { PageAgentTool } from '../tools'
export type { LLMConfig } export type { LLMConfig }
@@ -42,17 +42,37 @@ export interface AgentConfig {
*/ */
customTools?: Record<string, PageAgentTool | null> customTools?: Record<string, PageAgentTool | null>
/**
* Instructions to guide the agent's behavior
*/
instructions?: {
/**
* Global system-level instructions, applied to all tasks
*/
system?: string
/**
* Dynamic page-level instructions callback
* Called before each step to get instructions for the current page
* @param url - Current page URL (window.location.href)
* @returns Instructions string, or undefined/null to skip
*/
getPageInstructions?: (url: string) => string | undefined | null
}
// lifecycle hooks // 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: HistoryEvent[]) => Promise<void> | void
onBeforeTask?: (this: PageAgent) => Promise<void> | void onBeforeTask?: (this: PageAgent) => Promise<void> | void
onAfterTask?: (this: PageAgent, result: ExecutionResult) => Promise<void> | void onAfterTask?: (this: PageAgent, result: ExecutionResult) => Promise<void> | void
/** /**
* @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
@@ -66,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
+9
View File
@@ -57,6 +57,15 @@ tools.set(
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000) const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000)
console.log(`actualWaitTime: ${actualWaitTime} seconds`) console.log(`actualWaitTime: ${actualWaitTime} seconds`)
await waitFor(actualWaitTime) await waitFor(actualWaitTime)
this.states.totalWaitTime += input.seconds
if (this.states.totalWaitTime >= 3) {
this.pushObservation(
`You have waited ${this.states.totalWaitTime} seconds accumulatively. Do NOT wait any longer unless you have a good reason.`
)
}
return `✅ Waited for ${input.seconds} seconds.` return `✅ Waited for ${input.seconds} seconds.`
}, },
}) })
+6 -1
View File
@@ -35,7 +35,12 @@ setTimeout(() => {
window.pageAgent = new PageAgent(config) window.pageAgent = new PageAgent(config)
} else { } else {
console.log('🚀 page-agent.js no current script detected, using default demo config') console.log('🚀 page-agent.js no current script detected, using default demo config')
window.pageAgent = new PageAgent() const config: PageAgentConfig = {
model: DEMO_MODEL,
baseURL: DEMO_BASE_URL,
apiKey: DEMO_API_KEY,
}
window.pageAgent = new PageAgent(config)
} }
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config) console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
+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
+4 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/page-controller", "name": "@page-agent/page-controller",
"version": "0.0.18", "version": "0.2.0",
"type": "module", "type": "module",
"main": "./dist/lib/page-controller.js", "main": "./dist/lib/page-controller.js",
"module": "./dist/lib/page-controller.js", "module": "./dist/lib/page-controller.js",
@@ -34,5 +34,8 @@
"build": "vite build", "build": "vite build",
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"", "prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\"" "postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
},
"dependencies": {
"ai-motion": "^0.4.8"
} }
} }
+93 -39
View File
@@ -18,13 +18,32 @@ import { VIEWPORT_EXPANSION } from './constants'
import * as dom from './dom' import * as dom from './dom'
import type { FlatDomTree, InteractiveElementDomNode } from './dom/dom_tree/type' import type { FlatDomTree, InteractiveElementDomNode } from './dom/dom_tree/type'
import { getPageInfo } from './dom/getPageInfo' import { getPageInfo } from './dom/getPageInfo'
import { SimulatorMask } from './mask/SimulatorMask'
import { patchReact } from './patches/react' import { patchReact } from './patches/react'
export { SimulatorMask }
/** /**
* Configuration for PageController * Configuration for PageController
*/ */
export interface PageControllerConfig extends dom.DomConfig { export interface PageControllerConfig extends dom.DomConfig {
viewportExpansion?: number viewportExpansion?: number
/** Enable visual mask overlay during operations (default: false) */
enableMask?: boolean
}
/**
* Structured browser state for LLM consumption
*/
export interface BrowserState {
url: string
title: string
/** Page info + scroll position hint (e.g. "Page info: 1920x1080px...\n[Start of page]") */
header: string
/** Simplified HTML of interactive elements */
content: string
/** Page footer hint (e.g. "... 300 pixels below ..." or "[End of page]") */
footer: string
} }
interface ActionResult { interface ActionResult {
@@ -64,12 +83,19 @@ export class PageController extends EventTarget {
/** last time the tree was updated */ /** last time the tree was updated */
private lastTimeUpdate = 0 private lastTimeUpdate = 0
/** Visual mask overlay for blocking user interaction during automation */
private mask: SimulatorMask | null = null
constructor(config: PageControllerConfig = {}) { constructor(config: PageControllerConfig = {}) {
super() super()
this.config = config this.config = config
patchReact(this) patchReact(this)
if (config.enableMask) {
this.mask = new SimulatorMask()
}
} }
// ======= State Queries ======= // ======= State Queries =======
@@ -81,42 +107,6 @@ export class PageController extends EventTarget {
return window.location.href return window.location.href
} }
/**
* Get current page title
*/
async getPageTitle(): Promise<string> {
return document.title
}
/**
* Get page scroll and size info
*/
async getPageInfo() {
return getPageInfo()
}
/**
* Get the simplified HTML representation of the page.
* This is used by LLM to understand the page structure.
*/
async getSimplifiedHTML(): Promise<string> {
return this.simplifiedHTML
}
/**
* Get text description for an element by index
*/
async getElementText(index: number): Promise<string | undefined> {
return this.elementTextMap.get(index)
}
/**
* Get total number of indexed interactive elements
*/
async getElementCount(): Promise<number> {
return this.selectorMap.size
}
/** /**
* Get last tree update timestamp * Get last tree update timestamp
*/ */
@@ -125,10 +115,43 @@ export class PageController extends EventTarget {
} }
/** /**
* Get the viewport expansion setting * Get structured browser state for LLM consumption.
* Automatically calls updateTree() to refresh the DOM state.
*/ */
async getViewportExpansion(): Promise<number> { async getBrowserState(): Promise<BrowserState> {
return this.config.viewportExpansion ?? VIEWPORT_EXPANSION const url = window.location.href
const title = document.title
const pi = getPageInfo()
const viewportExpansion = this.config.viewportExpansion ?? VIEWPORT_EXPANSION
await this.updateTree()
const content = this.simplifiedHTML
// Build header: page info + scroll position hint
const pageInfoLine = `Page info: ${pi.viewport_width}x${pi.viewport_height}px viewport, ${pi.page_width}x${pi.page_height}px total page size, ${pi.pages_above.toFixed(1)} pages above, ${pi.pages_below.toFixed(1)} pages below, ${pi.total_pages.toFixed(1)} total pages, at ${(pi.current_page_position * 100).toFixed(0)}% of page`
const elementsLabel =
viewportExpansion === -1
? 'Interactive elements from top layer of the current page (full page):'
: 'Interactive elements from top layer of the current page inside the viewport:'
const hasContentAbove = pi.pixels_above > 4
const scrollHintAbove =
hasContentAbove && viewportExpansion !== -1
? `... ${pi.pixels_above} pixels above (${pi.pages_above.toFixed(1)} pages) - scroll to see more ...`
: '[Start of page]'
const header = `${pageInfoLine}\n\n${elementsLabel}\n\n${scrollHintAbove}`
// Build footer: scroll position hint
const hasContentBelow = pi.pixels_below > 4
const footer =
hasContentBelow && viewportExpansion !== -1
? `... ${pi.pixels_below} pixels below (${pi.pages_below.toFixed(1)} pages) - scroll to see more ...`
: '[End of page]'
return { url, title, header, content, footer }
} }
// ======= DOM Tree Operations ======= // ======= DOM Tree Operations =======
@@ -136,12 +159,18 @@ export class PageController extends EventTarget {
/** /**
* Update DOM tree, returns simplified HTML for LLM. * Update DOM tree, returns simplified HTML for LLM.
* This is the main method to refresh the page state. * This is the main method to refresh the page state.
* Automatically bypasses mask during DOM extraction if enabled.
*/ */
async updateTree(): Promise<string> { async updateTree(): Promise<string> {
this.dispatchEvent(new Event('beforeUpdate')) this.dispatchEvent(new Event('beforeUpdate'))
this.lastTimeUpdate = Date.now() this.lastTimeUpdate = Date.now()
// Temporarily bypass mask to allow DOM extraction
if (this.mask) {
this.mask.wrapper.style.pointerEvents = 'none'
}
dom.cleanUpHighlights() dom.cleanUpHighlights()
const blacklist = [ const blacklist = [
@@ -162,6 +191,11 @@ export class PageController extends EventTarget {
this.elementTextMap.clear() this.elementTextMap.clear()
this.elementTextMap = dom.getElementTextMap(this.simplifiedHTML) this.elementTextMap = dom.getElementTextMap(this.simplifiedHTML)
// Restore mask blocking
if (this.mask) {
this.mask.wrapper.style.pointerEvents = 'auto'
}
this.dispatchEvent(new Event('afterUpdate')) this.dispatchEvent(new Event('afterUpdate'))
return this.simplifiedHTML return this.simplifiedHTML
@@ -326,6 +360,24 @@ export class PageController extends EventTarget {
} }
} }
// ======= Mask Operations =======
/**
* Show the visual mask overlay.
* Only works if enableMask was set to true in config.
*/
showMask(): void {
this.mask?.show()
}
/**
* Hide the visual mask overlay.
* Only works if enableMask was set to true in config.
*/
hideMask(): void {
this.mask?.hide()
}
/** /**
* Dispose and clean up resources * Dispose and clean up resources
*/ */
@@ -335,5 +387,7 @@ export class PageController extends EventTarget {
this.selectorMap.clear() this.selectorMap.clear()
this.elementTextMap.clear() this.elementTextMap.clear()
this.simplifiedHTML = '<EMPTY>' this.simplifiedHTML = '<EMPTY>'
this.mask?.dispose()
this.mask = null
} }
} }
@@ -16,6 +16,7 @@
* @edit scrollable element detection * @edit scrollable element detection
* @edit add `data-browser-use-ignore` attribute * @edit add `data-browser-use-ignore` attribute
* @edit improve `sampleRect`, filter out rects with 0 area * @edit improve `sampleRect`, filter out rects with 0 area
* @edit exclude aria-hidden elements
*/ */
export default ( export default (
@@ -1446,7 +1447,14 @@ export default (
* @edit add `data-browser-use-ignore` attribute * @edit add `data-browser-use-ignore` attribute
*/ */
if (node.dataset?.browserUseIgnore === 'true') { if (node.dataset?.browserUseIgnore === 'true') {
return true // Skip this node and its children return null // Skip this node and its children
}
/**
* @edit exclude aria-hidden elements
*/
if (node.getAttribute && node.getAttribute('aria-hidden') === 'true') {
return null // Skip this node and its children
} }
// Special handling for root node (body) // Special handling for root node (body)
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
+1 -1
View File
@@ -29,7 +29,7 @@ export default defineConfig({
}, },
outDir: resolve(__dirname, 'dist', 'lib'), outDir: resolve(__dirname, 'dist', 'lib'),
rollupOptions: { rollupOptions: {
external: ['@page-agent/*'], external: ['@page-agent/*', 'ai-motion'],
}, },
minify: false, minify: false,
sourcemap: true, sourcemap: true,
+2 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/ui", "name": "@page-agent/ui",
"version": "0.0.18", "version": "0.2.0",
"type": "module", "type": "module",
"main": "./dist/lib/page-agent-ui.js", "main": "./dist/lib/page-agent-ui.js",
"module": "./dist/lib/page-agent-ui.js", "module": "./dist/lib/page-agent-ui.js",
@@ -15,7 +15,7 @@
"files": [ "files": [
"dist/" "dist/"
], ],
"description": "UI components for page-agent - Panel, SimulatorMask, and i18n", "description": "UI components for page-agent - Panel and i18n",
"keywords": [ "keywords": [
"page-agent", "page-agent",
"ui", "ui",
@@ -34,8 +34,5 @@
"build": "vite build", "build": "vite build",
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"", "prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\"" "postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
},
"dependencies": {
"ai-motion": "^0.4.7"
} }
} }
+2 -5
View File
@@ -1,6 +1,6 @@
import { type Step, UIState } from './UIState' import { type Step, UIState } from './UIState'
import { I18n, type SupportedLanguage } from './i18n' import { I18n, type SupportedLanguage } from './i18n'
import { truncate } from './utils' import { escapeHtml, truncate } from './utils'
import styles from './Panel.module.css' import styles from './Panel.module.css'
@@ -590,10 +590,7 @@ export class Panel {
#updateHistory(): void { #updateHistory(): void {
const steps = this.#state.getAllSteps() const steps = this.#state.getAllSteps()
this.#historySection.innerHTML = steps.map((step) => this.#createHistoryItem(step)).join('') this.#historySection.innerHTML = steps.map((step) => this.#createHistoryItem(step)).join('')
// Scroll to bottom to show latest records
this.#scrollToBottom() this.#scrollToBottom()
} }
@@ -660,7 +657,7 @@ export class Panel {
<div class="${styles.historyItem} ${typeClass}"> <div class="${styles.historyItem} ${typeClass}">
<div class="${styles.historyContent}"> <div class="${styles.historyContent}">
<span class="${styles.statusIcon}">${statusIcon}</span> <span class="${styles.statusIcon}">${statusIcon}</span>
<span>${step.displayText}</span> <span>${escapeHtml(step.displayText)}</span>
</div> </div>
<div class="${styles.historyMeta}"> <div class="${styles.historyMeta}">
${stepLabel} ${stepLabel}
+1 -1
View File
@@ -12,7 +12,7 @@ export class I18n {
constructor(language: SupportedLanguage = 'en-US') { constructor(language: SupportedLanguage = 'en-US') {
this.language = language in locales ? language : 'en-US' this.language = language in locales ? language : 'en-US'
this.translations = locales[language] this.translations = locales[this.language]
} }
// 类型安全的翻译方法 // 类型安全的翻译方法
-1
View File
@@ -1,4 +1,3 @@
export { Panel, type PanelConfig, type PanelUpdate } from './Panel' export { Panel, type PanelConfig, type PanelUpdate } from './Panel'
export { SimulatorMask } from './SimulatorMask'
export { UIState, type Step, type AgentStatus } from './UIState' export { UIState, type Step, type AgentStatus } from './UIState'
export { I18n, type SupportedLanguage, type TranslationKey } from './i18n' export { I18n, type SupportedLanguage, type TranslationKey } from './i18n'
+12
View File
@@ -4,3 +4,15 @@ export function truncate(text: string, maxLength: number): string {
} }
return text return text
} }
/**
* Escape HTML special characters to prevent XSS and rendering issues
*/
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
+1 -1
View File
@@ -29,7 +29,7 @@ export default defineConfig({
}, },
outDir: resolve(__dirname, 'dist', 'lib'), outDir: resolve(__dirname, 'dist', 'lib'),
rollupOptions: { rollupOptions: {
external: ['ai-motion'], external: [],
}, },
minify: false, minify: false,
sourcemap: true, sourcemap: true,
+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)
``` ```
+7 -7
View File
@@ -1,24 +1,24 @@
{ {
"name": "@page-agent/website", "name": "@page-agent/website",
"private": true, "private": true,
"version": "0.0.18", "version": "0.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --host 0.0.0.0",
"build:website": "vite build", "build:website": "vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@types/react": "^19.2.2", "@types/react": "^19.2.8",
"@types/react-dom": "^19.2.1", "@types/react-dom": "^19.2.1",
"@vitejs/plugin-react-swc": "^4.1.0", "@vitejs/plugin-react-swc": "^4.1.0",
"i18next": "^25.7.3", "i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-i18next": "^16.5.0", "react-i18next": "^16.5.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.23.26", "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.2.0", "simple-icons": "^16.5.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0"
} }
@@ -41,6 +41,7 @@ export default function Header() {
className="hidden sm:block text-xs text-gray-600 dark:text-gray-300 py-0 font-normal overflow-visible" className="hidden sm:block text-xs text-gray-600 dark:text-gray-300 py-0 font-normal overflow-visible"
duration={600} duration={600}
animateOnHover={true} animateOnHover={true}
aria-hidden="true"
> >
{t('header.slogan')} {t('header.slogan')}
</HyperText> </HyperText>
@@ -84,6 +85,9 @@ export default function Header() {
role="navigation" role="navigation"
aria-label={t('header.nav_docs')} aria-label={t('header.nav_docs')}
> >
<span className="text-xs font-mono text-gray-400 dark:text-gray-500 tabular-nums before:content-['v']">
{import.meta.env.VERSION}
</span>
<Link <Link
href="/docs/introduction/overview" href="/docs/introduction/overview"
className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200" className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
+15 -3
View File
@@ -1,3 +1,15 @@
export const CDN_URL = 'https://cdn.jsdelivr.net/npm/page-agent@latest/dist/umd/page-agent.js' // Demo build (auto-init with demo LLM, for quick testing)
export const CDN_CN_URL = export const CDN_DEMO_URL = 'https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.demo.js'
'https://registry.npmmirror.com/page-agent/latest/files/dist/umd/page-agent.js' export const CDN_DEMO_CN_URL =
'https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.demo.js'
// Full build (exports PageAgent class, usage identical to npm)
export const CDN_FULL_URL = 'https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.js'
export const CDN_FULL_CN_URL =
'https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.js'
// Demo LLM for website testing
export const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
export const DEMO_BASE_URL =
'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
export const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
@@ -1,48 +0,0 @@
import BetaNotice from '@/components/BetaNotice'
import CodeEditor from '@/components/CodeEditor'
export default function DataMasking() {
return (
<div>
<h1 className="text-4xl font-bold mb-6"></h1>
<BetaNotice />
<p className="text-xl text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
AI
</p>
<h2 className="text-2xl font-bold mb-3"></h2>
<div className="space-y-4 mb-6">
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-300">
🔒
</h3>
<p className="text-gray-600 dark:text-gray-300">
</p>
</div>
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-purple-900 dark:text-purple-300">
</h3>
<p className="text-gray-600 dark:text-gray-300">
</p>
</div>
</div>
<CodeEditor
code={`// 数据脱敏配置
// @todo
const rules = [
{ pattern: /\\d{11}/, replacement: '***-****-****' },
{ pattern: /\\d{4}-\\d{4}-\\d{4}-\\d{4}/, replacement: '****-****-****-****' }
]
pageAgent.maskData(rules)`}
/>
</div>
)
}
@@ -1,162 +0,0 @@
import BetaNotice from '@/components/BetaNotice'
import CodeEditor from '@/components/CodeEditor'
export default function KnowledgeInjection() {
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">
AI
</p>
{/* Custom Instruction */}
<section className="mb-12">
<h2 className="text-3xl font-bold mb-6">Instruction - </h2>
<div className="p-6 bg-purple-50 dark:bg-purple-900/20 rounded-lg mb-6">
<h3 className="text-xl font-semibold mb-3 text-purple-900 dark:text-purple-300">
🎯
</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4">
AI
</p>
<ul className="list-disc list-inside space-y-2 text-gray-500 dark:text-gray-400">
<li> AI </li>
<li></li>
<li></li>
<li></li>
</ul>
</div>
<CodeEditor
className="mb-6"
code={`// 构造函数中设置系统指令
const pageAgent = new PageAgent({
instruction: \`
# 角色定义
你是专业的电商运营助手。
# 工作风格
- 谨慎:操作前确认
- 准确:确保正确性
- 高效:优化流程
# 错误处理
遇到错误时暂停并报告。
\`
});`}
/>
</section>
{/* App Knowledge */}
<section className="mb-12">
<h2 className="text-3xl font-bold mb-6">App Knowledge - </h2>
<div className="p-6 bg-blue-50 dark:bg-blue-900/20 rounded-lg mb-6">
<h3 className="text-xl font-semibold mb-3 text-blue-900 dark:text-blue-300">
</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4">
AI
</p>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<h4 className="font-semibold text-blue-800 dark:text-blue-200"></h4>
<ul className="list-disc list-inside text-sm text-gray-500 dark:text-gray-400 space-y-1">
<li></li>
<li></li>
<li></li>
</ul>
</div>
<div className="space-y-2">
<h4 className="font-semibold text-blue-800 dark:text-blue-200"></h4>
<ul className="list-disc list-inside text-sm text-gray-500 dark:text-gray-400 space-y-1">
<li></li>
<li></li>
<li></li>
</ul>
</div>
</div>
</div>
<CodeEditor
className="mb-6"
code={`// 应用知识
pageAgent.knowledge.setAppKnowledge(\`
# 产品介绍
电商管理系统:面向中小企业的一站式解决方案。
# 操作流程
## 商品上架
1. 进入商品管理页面 2. 点击新增商品 3. 填写基本信息 4. 设置库存 5. 提交审核
# 术语解释
- SKU:库存量单位
- SPU:标准产品单位
- 运费模板:物流费用计算规则
# 业务规则
- 库存为0时自动下架
- VIP会员享9.5折
\`);`}
/>
</section>
{/* Page Knowledge */}
<section className="mb-12">
<h2 className="text-3xl font-bold mb-6">Page Knowledge - </h2>
<div className="p-6 bg-green-50 dark:bg-green-900/20 rounded-lg mb-6">
<h3 className="text-xl font-semibold mb-3 text-green-900 dark:text-green-300">
📄
</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4">
AI
</p>
<div className="grid md:grid-cols-3 gap-4">
<div className="space-y-2">
<h4 className="font-semibold text-green-800 dark:text-green-200"></h4>
<p className="text-sm text-gray-500 dark:text-gray-400"></p>
</div>
<div className="space-y-2">
<h4 className="font-semibold text-green-800 dark:text-green-200"></h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
</p>
</div>
<div className="space-y-2">
<h4 className="font-semibold text-green-800 dark:text-green-200"></h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
</p>
</div>
</div>
</div>
<CodeEditor
className="mb-6"
code={`// 页面知识库
// 添加页面知识
pageAgent.knowledge.addPageKnowledge("/products", \`
商品列表页面,包含搜索、筛选、批量操作功能。
#add-product-btn:新增商品按钮
.product-item:商品列表项
#search-input:搜索框,最少2个字符
\`);
pageAgent.knowledge.addPageKnowledge("/orders/*", \`
订单详情页面。
.order-status:订单状态标签
#update-status-btn:状态更新按钮
\`);
// 移除页面知识
pageAgent.knowledge.removePageKnowledge("/products");`}
/>
</section>
</div>
)
}
@@ -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,116 +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
maxTokens?: number
maxRetries?: number
/**
* Custom fetch function for LLM API requests.
* Use this to customize headers, credentials, proxy, etc.
* The response should follow OpenAI API format.
*/
customFetch?: typeof globalThis.fetch
}
interface AgentConfig {
language?: "en-US" | "zh-CN"
/**
* Custom tools to extend PageAgent capabilities
* @experimental
* @note You can also override or remove internal tools by using the same name.
* @see [tools](../tools/index.ts)
*
* @example
* // override internal tool
* import { tool } from 'page-agent'
* const customTools = {
* ask_user: tool({
* description:
* 'Ask the user or parent model a question and wait for their answer. Use this if you need more information or clarification.',
* inputSchema: zod.object({
* question: zod.string(),
* }),
* execute: async function (this: PageAgent, input) {
* const answer = await do_some_thing(input.question)
* return "✅ Received user answer: " + answer
* },
* })
* }
*
* @example
* // remove internal tool
* const customTools = {
* ask_user: null // never ask user questions
* }
*/
customTools?: Record<string, PageAgentTool | null>
// lifecycle hooks
// @todo: use event instead of hooks
onBeforeStep?: (this: PageAgent, stepCnt: number) => Promise<void> | void
onAfterStep?: (this: PageAgent, stepCnt: number, history: AgentHistory[]) => Promise<void> | void
onBeforeTask?: (this: PageAgent) => Promise<void> | void
onAfterTask?: (this: PageAgent, result: ExecutionResult) => Promise<void> | void
/**
* @note this hook can block the disposal process
* @note when dispose caused by page unload, "reason" will be 'PAGE_UNLOADING'. this method CANNOT block unloading. async operations may be cut.
*/
onDispose?: (this: PageAgent, reason?: string) => void
// page behavior hooks
/**
* @experimental
* Enable the experimental script execution tool that allows executing generated JavaScript code on the page.
* @note Can cause unpredictable side effects.
* @note May bypass some safe guards and data-masking mechanisms.
*/
experimentalScriptExecutionTool?: boolean
/**
* TODO: @unimplemented
* hook when action causes a new page to be opened
* @note PageAgent will try to detect new pages and decide if it's caused by an action. But not very reliable.
*/
onNewPageOpen?: (this: PageAgent, url: string) => Promise<void> | void
/**
* TODO: @unimplemented
* try to navigate to a new page instead of opening a new tab/window.
* @note will unload the current page when a action tries to open a new page. so that things keep in the same tab/window.
*/
experimentalPreventNewPage?: boolean
}
interface PageControllerConfig {
interactiveBlacklist?: (Element | (() => Element))[]
interactiveWhitelist?: (Element | (() => Element))[]
include_attributes?: string[]
highlightOpacity?: number
highlightLabelOpacity?: number
viewportExpansion?: number
}
`}
/>
</div>
)
}
@@ -1,84 +0,0 @@
import { useTranslation } from 'react-i18next'
import BetaNotice from '@/components/BetaNotice'
import CodeEditor from '@/components/CodeEditor'
import { CDN_CN_URL, CDN_URL } from '@/constants'
export default function QuickStart() {
const { t } = useTranslation('docs')
return (
<div>
<h1 className="text-4xl font-bold mb-6">{t('quick_start.title')}</h1>
<p className=" mb-6 leading-relaxed">{t('quick_start.subtitle')}</p>
<h2 className="text-2xl font-bold mb-3">{t('quick_start.installation')}</h2>
<div className="space-y-4 mb-6">
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-300">
{t('quick_start.step1_title')}
</h3>
<div className="space-y-3">
<div>
<p className="text-sm font-medium mb-2">{t('quick_start.step1_cdn')}</p>
<CodeEditor
code={`// CDN: \t${CDN_URL}
// Mirror: \t${CDN_CN_URL}
<script src="${CDN_URL}" crossorigin="true" type="text/javascript"></script>`}
language="html"
/>
</div>
<div>
<p className="text-sm font-medium mb-2">{t('quick_start.step1_npm')}</p>
<CodeEditor
code={`// npm install page-agent
import PageAgent from 'page-agent'`}
language="bash"
/>
</div>
</div>
</div>
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-green-900 dark:text-green-300">
{t('quick_start.step2_title')}
</h3>
<CodeEditor
code={`// 仅供测试使用,生产环境需要配置 LLM 接入点,本工具不提供 LLM 服务
// 测试接口
// @note: 限流,限制 prompt 内容,限制来源,随时变更,请替换成你自己的
// @note: 使用 DeepSeek-chat(3.2) 官方版本,使用协议和隐私策略见 DeepSeek 网站
const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
const DEMO_BASE_URL = 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
const agent = new PageAgent({
modelName: DEMO_MODEL,
baseURL: DEMO_BASE_URL,
apiKey: DEMO_API_KEY,
language: 'zh-CN'
})`}
language="javascript"
/>
</div>
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-purple-900 dark:text-purple-300">
{t('quick_start.step3_title')}
</h3>
<CodeEditor
code={`// 程序化执行自然语言指令
await pageAgent.execute('点击提交按钮,然后填写用户名为张三');
// 或者显示对话框让用户输入指令
pageAgent.panel.show()
`}
language="javascript"
/>
</div>
</div>
</div>
)
}
+4
View File
@@ -1,5 +1,9 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VERSION: string
}
declare module '*.module.css' { declare module '*.module.css' {
const classes: Record<string, string> const classes: Record<string, string>
export default classes export default classes
@@ -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: 'Knowledge Injection', 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,17 +5,23 @@ 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_DEMO_CN_URL,
CDN_DEMO_URL,
DEMO_API_KEY,
DEMO_BASE_URL,
DEMO_MODEL,
} from '../constants'
function getInjection(useCN?: boolean) { function getInjection(useCN?: boolean) {
const cdn = useCN ? CDN_CN_URL : CDN_URL const cdn = useCN ? CDN_DEMO_CN_URL : CDN_DEMO_URL
const injection = encodeURI( const injection = encodeURI(
`javascript:(function(){var s=document.createElement('script');s.src=\`${cdn}?t=\${Math.random()}\`;s.setAttribute('crossorigin', true);s.type="text/javascript";s.onload=()=>console.log('PageAgent script loaded!');document.body.appendChild(s);})();` `javascript:(function(){var s=document.createElement('script');s.src=\`${cdn}?t=\${Math.random()}\`;s.setAttribute('crossorigin', true);s.type="text/javascript";s.onload=()=>console.log('PageAgent script loaded!');document.body.appendChild(s);})();`
@@ -64,13 +70,27 @@ export default function HomePage() {
interactiveBlacklist: [document.getElementById('root')!], interactiveBlacklist: [document.getElementById('root')!],
language: i18n.language as any, language: i18n.language as any,
// experimentalScriptExecutionTool: true, instructions: {
system: 'You are a helpful assistant on PageAgent website.',
getPageInstructions: (url) => {
const hint = url.includes('page-agent') ? 'This is PageAgent demo page.' : undefined
console.log('[instructions] getPageInstructions:', url, '->', hint)
return hint
},
},
// testing server model:
// @note: rate limit. prompt limit. import.meta.env.DEV && import.meta.env.LLM_MODEL_NAME
// model: DEMO_MODEL, ? import.meta.env.LLM_MODEL_NAME
// baseURL: DEMO_BASE_URL, : DEMO_MODEL,
// apiKey: DEMO_API_KEY, baseURL:
import.meta.env.DEV && import.meta.env.LLM_BASE_URL
? import.meta.env.LLM_BASE_URL
: DEMO_BASE_URL,
apiKey:
import.meta.env.DEV && import.meta.env.LLM_API_KEY
? import.meta.env.LLM_API_KEY
: DEMO_API_KEY,
}) })
win.pageAgent = pageAgent win.pageAgent = pageAgent
} }
@@ -86,7 +106,10 @@ export default function HomePage() {
{/* Hero Section */} {/* Hero Section */}
<main id="main-content"> <main id="main-content">
<section className="relative px-6 py-22 lg:py-28" aria-labelledby="hero-heading"> <section
className="relative px-6 py-22 lg:py-28 overflow-hidden"
aria-labelledby="hero-heading"
>
<div className="max-w-7xl mx-auto text-center"> <div className="max-w-7xl mx-auto text-center">
{/* Background Pattern + Particles */} {/* Background Pattern + Particles */}
<div className="absolute inset-0 opacity-30" aria-hidden="true"> <div className="absolute inset-0 opacity-30" aria-hidden="true">
@@ -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/knowledge-injection' }, { 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,135 @@
import { useTranslation } from 'react-i18next'
import CodeEditor from '@/components/CodeEditor'
export default function Instructions() {
const { i18n } = useTranslation()
const isZh = i18n.language === 'zh-CN'
return (
<div>
<h1 className="text-4xl font-bold mb-6">{isZh ? '知识注入' : 'Instructions'}</h1>
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
{isZh
? '通过 instructions 配置,为 AI 注入系统级指导和页面级上下文,让它更好地理解你的业务场景。'
: 'Use the instructions config to inject system-level directives and page-specific context, helping the AI better understand your application.'}
</p>
{/* System Instructions */}
<section className="mb-12">
<h2 className="text-3xl font-bold mb-6">
{isZh ? '系统级指导 (System Instructions)' : 'System Instructions'}
</h2>
<p className="text-gray-600 dark:text-gray-300 mb-6">
{isZh
? '全局提示词,应用于所有任务。定义 AI 的角色、工作风格和行为边界。'
: "Global directives applied to all tasks. Define the AI's role, working style, and behavioral boundaries."}
</p>
<CodeEditor
className="mb-6"
code={`const agent = new PageAgent({
// ...other config
instructions: {
system: \`
You are a professional e-commerce assistant.
Guidelines:
- Always confirm before submitting orders
- Double-check prices and quantities
- Report errors immediately instead of retrying blindly
\`
}
})`}
/>
</section>
{/* Page Instructions */}
<section className="mb-12">
<h2 className="text-3xl font-bold mb-6">
{isZh ? '页面级指导 (Page Instructions)' : 'Page Instructions'}
</h2>
<p className="text-gray-600 dark:text-gray-300 mb-6">
{isZh
? '动态回调函数,在每个 step 执行前调用,根据当前页面 URL 返回特定提示词。适用于为不同页面提供针对性的操作引导。'
: 'A dynamic callback invoked before each step. Returns page-specific instructions based on the current URL. Useful for providing targeted guidance on different pages.'}
</p>
<CodeEditor
className="mb-6"
code={`const agent = new PageAgent({
// ...other config
instructions: {
system: 'You are an order management assistant.',
getPageInstructions: (url) => {
if (url.includes('/checkout')) {
return \`
This is the checkout page.
- Verify shipping address before proceeding
- Check if any discounts are applied
- Confirm the total amount with the user
\`
}
if (url.includes('/products')) {
return \`
This is the product listing page.
- Use filters to narrow down search results
- Check stock availability before adding to cart
\`
}
return undefined // No special instructions for other pages
}
}
})`}
/>
</section>
{/* How It Works */}
<section className="mb-12">
<h2 className="text-3xl font-bold mb-6">{isZh ? '工作原理' : 'How It Works'}</h2>
<p className="text-gray-600 dark:text-gray-300 mb-4">
{isZh
? '在每个执行步骤之前,page-agent 会将 instructions 拼接到用户提示词中:'
: 'Before each execution step, page-agent prepends the instructions to the user prompt:'}
</p>
<CodeEditor
language="xml"
className="mb-6"
code={`<instructions>
<system_instructions>
You are a professional e-commerce assistant.
...
</system_instructions>
<page_instructions>
This is the checkout page.
...
</page_instructions>
</instructions>
<!-- followed by agent state, history, and browser state -->`}
/>
<ul className="list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400">
<li>
{isZh
? '如果 system 为空,则不输出 <system_instructions> 标签'
: 'If system is empty, the <system_instructions> tag is omitted'}
</li>
<li>
{isZh
? '如果 getPageInstructions 返回空值,则不输出 <page_instructions> 标签'
: 'If getPageInstructions returns empty, the <page_instructions> tag is omitted'}
</li>
</ul>
</section>
</div>
)
}
@@ -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,125 @@
import { useTranslation } from 'react-i18next'
import CodeEditor from '@/components/CodeEditor'
import { CDN_DEMO_CN_URL, CDN_DEMO_URL, CDN_FULL_CN_URL, CDN_FULL_URL } from '@/constants'
export default function CdnSetup() {
const { i18n } = useTranslation()
const isZh = i18n.language === 'zh-CN'
return (
<div className="space-y-10">
<header>
<h1 className="text-4xl font-bold mb-4">{isZh ? 'CDN 引入' : 'CDN Setup'}</h1>
<p className="text-lg text-gray-600 dark:text-gray-300 leading-relaxed">
{isZh
? 'CDN 提供两种构建版本:Demo 版用于快速体验,标准版用法与 NPM 一致。'
: 'CDN provides two builds: Demo for quick testing, standard build with usage identical to NPM.'}
</p>
</header>
{/* Demo Build Section */}
<section>
<h2 className="text-2xl font-bold mb-3">{isZh ? '🚀 快速体验' : '🚀 Quick Try'}</h2>
<p className="text-gray-600 dark:text-gray-300 mb-3">
{isZh
? '自动初始化并使用内置 Demo LLM,无需任何配置:'
: 'Auto-initializes with built-in demo LLM, no configuration needed:'}
</p>
<CodeEditor
className="mb-3"
code={`<script src="${CDN_DEMO_URL}" crossorigin="true"></script>`}
/>
<div className="bg-yellow-50 dark:bg-yellow-900/20 px-4 py-3 rounded-lg mb-4 text-sm">
<span className="text-yellow-800 dark:text-yellow-200">
{' '}
{isZh
? '仅用于技术评估,Demo 模型有速率限制。'
: 'For evaluation only. Demo model has rate limits.'}
</span>
</div>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="text-left py-2 px-3 font-semibold w-28">
{isZh ? '位置' : 'Location'}
</th>
<th className="text-left py-2 px-3 font-semibold">URL</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-gray-100 dark:border-gray-800">
<td className="py-2 px-3">{isZh ? '全球' : 'Global'}</td>
<td className="py-2 px-3 font-mono text-xs break-all">{CDN_DEMO_URL}</td>
</tr>
<tr>
<td className="py-2 px-3">{isZh ? '中国' : 'China'}</td>
<td className="py-2 px-3 font-mono text-xs break-all">{CDN_DEMO_CN_URL}</td>
</tr>
</tbody>
</table>
</section>
{/* CDN Build Section */}
<section>
<h2 className="text-2xl font-bold mb-3">{isZh ? '📦 标准版' : '📦 Standard Build'}</h2>
<p className="text-gray-600 dark:text-gray-300 mb-3">
{isZh
? '将 PageAgent 类暴露到 globalThis,用法与 NPM 一致:'
: 'Exposes PageAgent class to globalThis, usage identical to NPM:'}
</p>
<CodeEditor
className="mb-4"
code={`<script src="${CDN_FULL_URL}" crossorigin="true"></script>
<script>
const agent = new PageAgent({
model: 'deepseek-chat',
baseURL: 'https://api.deepseek.com',
apiKey: 'YOUR_API_KEY',
})
await agent.execute('Click the submit button')
</script>`}
/>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="text-left py-2 px-3 font-semibold w-28">
{isZh ? '位置' : 'Location'}
</th>
<th className="text-left py-2 px-3 font-semibold">URL</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-gray-100 dark:border-gray-800">
<td className="py-2 px-3">{isZh ? '全球' : 'Global'}</td>
<td className="py-2 px-3 font-mono text-xs break-all">{CDN_FULL_URL}</td>
</tr>
<tr>
<td className="py-2 px-3">{isZh ? '中国' : 'China'}</td>
<td className="py-2 px-3 font-mono text-xs break-all">{CDN_FULL_CN_URL}</td>
</tr>
</tbody>
</table>
</section>
{/* Tips */}
<section className="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg">
<h3 className="text-base font-semibold mb-2 text-blue-900 dark:text-blue-300">
💡 {isZh ? '提示' : 'Tips'}
</h3>
<ul className="text-gray-600 dark:text-gray-300 text-sm space-y-1">
<li> {isZh ? '生产环境推荐使用 NPM' : 'NPM is recommended for production'}</li>
<li> {isZh ? '生产环境建议锁定版本号' : 'Lock version number in production'}</li>
<li> {isZh ? '确保 HTTPS 环境' : 'Ensure HTTPS environment'}</li>
</ul>
</section>
</div>
)
}
@@ -0,0 +1,145 @@
import { useTranslation } from 'react-i18next'
import CodeEditor from '@/components/CodeEditor'
export default function Configuration() {
const { i18n } = useTranslation()
const isZh = i18n.language === 'zh-CN'
return (
<div>
<h1 className="text-4xl font-bold mb-6">{isZh ? '配置选项' : 'Configuration'}</h1>
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
{isZh
? 'PageAgent 的完整配置接口定义。'
: 'Complete configuration interface for PageAgent.'}
</p>
{/* LLM Configuration */}
<section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">{isZh ? 'LLM 配置' : 'LLM Configuration'}</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{isZh
? '配置与大语言模型的连接参数。'
: 'Configure connection parameters for the language model.'}
</p>
<CodeEditor
className="mb-4"
language="typescript"
code={`interface LLMConfig {
baseURL?: string
apiKey?: string
model?: string
temperature?: number
maxRetries?: number
/**
* Custom fetch function for LLM API requests.
* Use this to customize headers, credentials, proxy, etc.
*/
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
/**
* Enable visual mask overlay during automation.
* Blocks user interaction while agent is running.
* Default: false for PageController, true for PageAgent.
*/
enableMask?: boolean
}`}
/>
</section>
{/* Complete Type */}
<section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">{isZh ? '完整类型' : 'Complete Type'}</h2>
<CodeEditor
language="typescript"
code={`type PageAgentConfig = LLMConfig & AgentConfig & PageControllerConfig`}
/>
</section>
</div>
)
}
@@ -0,0 +1,90 @@
import { useTranslation } from 'react-i18next'
import BetaNotice from '@/components/BetaNotice'
export default function SecurityPermissions() {
const { i18n } = useTranslation()
const isZh = i18n.language === 'zh-CN'
return (
<div>
<BetaNotice />
<h1 className="text-4xl font-bold mb-6">{isZh ? '安全与权限' : 'Security & Permissions'}</h1>
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
{isZh
? 'page-agent 提供多种安全机制,确保 AI 操作在可控范围内进行。'
: 'page-agent provides multiple security mechanisms to ensure AI operations stay within controlled boundaries.'}
</p>
<div className="space-y-6">
<section>
<h2 className="text-2xl font-bold mb-3">
{isZh ? '元素操作黑白名单' : 'Element Interaction Allowlist/Blocklist'}
</h2>
<div className="space-y-3">
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg">
<h3 className="text-lg font-semibold text-red-900 dark:text-red-300">
🚫 {isZh ? '操作黑名单' : 'Blocklist'}
</h3>
<p className="text-gray-600 dark:text-gray-300">
{isZh
? '禁止 AI 操作敏感元素,如删除按钮、支付按钮等。'
: 'Prevent AI from interacting with sensitive elements like delete buttons, payment buttons, etc.'}
</p>
</div>
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<h3 className="text-lg font-semibold text-green-900 dark:text-green-300">
{isZh ? '操作白名单' : 'Allowlist'}
</h3>
<p className="text-gray-600 dark:text-gray-300">
{isZh
? '明确定义 AI 可以操作的元素范围。'
: 'Explicitly define which elements AI can interact with.'}
</p>
</div>
</div>
</section>
<section>
<h2 className="text-2xl font-bold mb-3">
{isZh ? 'Instruction 安全约束' : 'Instruction Safety Constraints'}
</h2>
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-yellow-900 dark:text-yellow-300">
{isZh ? '高危操作控制' : 'High-Risk Operation Control'}
</h3>
<p className="text-gray-600 dark:text-gray-300 mb-3">
{isZh
? '在 AI 指令中明确列举高危操作,通过两种策略进行控制:'
: 'Define high-risk operations in AI instructions and control them through two strategies:'}
</p>
<div className="space-y-2">
<div className="pl-3 border-l-2 border-red-400">
<p className="font-medium text-red-700 dark:text-red-300">
{isZh ? '完全禁止操作' : 'Completely Forbidden'}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{isZh
? '对极高风险操作明确禁止执行'
: 'Explicitly prohibit execution of extremely high-risk operations'}
</p>
</div>
<div className="pl-3 border-l-2 border-orange-400">
<p className="font-medium text-orange-700 dark:text-orange-300">
{isZh ? '需用户确认操作' : 'Requires User Confirmation'}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{isZh
? '对中等风险操作要求用户明确同意'
: 'Require explicit user consent for medium-risk operations'}
</p>
</div>
</div>
</div>
</section>
</div>
</div>
)
}
@@ -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>
@@ -0,0 +1,83 @@
import { useTranslation } from 'react-i18next'
import BetaNotice from '@/components/BetaNotice'
import CodeEditor from '@/components/CodeEditor'
import { CDN_DEMO_CN_URL, CDN_DEMO_URL } from '@/constants'
export default function QuickStart() {
const { t, i18n } = useTranslation('docs')
const isZh = i18n.language === 'zh-CN'
return (
<div>
<h1 className="text-4xl font-bold mb-6">{t('quick_start.title')}</h1>
<p className=" mb-6 leading-relaxed">{t('quick_start.subtitle')}</p>
<h2 className="text-2xl font-bold mb-3">{t('quick_start.installation')}</h2>
<div className="space-y-4 mb-6">
{/* Demo CDN - One Line */}
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-300">
{isZh ? '🚀 快速体验(Demo CDN' : '🚀 Quick Try (Demo CDN)'}
</h3>
<div className="bg-yellow-50 dark:bg-yellow-900/20 p-2 rounded mb-3 text-sm">
<span className="text-yellow-800 dark:text-yellow-200">
{isZh ? '仅用于技术评估' : 'For evaluation only'}
</span>
</div>
<CodeEditor
code={`// Global: ${CDN_DEMO_URL}
// China: ${CDN_DEMO_CN_URL}
<script src="${isZh ? CDN_DEMO_CN_URL : CDN_DEMO_URL}" crossorigin="true"></script>`}
language="html"
/>
</div>
{/* NPM - Recommended */}
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-green-900 dark:text-green-300">
{isZh ? '📦 NPM 安装(推荐)' : '📦 NPM Install (Recommended)'}
</h3>
<CodeEditor
code={`npm install page-agent
import { PageAgent } from 'page-agent'`}
language="bash"
/>
</div>
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-purple-900 dark:text-purple-300">
{t('quick_start.step2_title')}
</h3>
<CodeEditor
code={`const agent = new PageAgent({
model: 'deepseek-chat',
baseURL: 'https://api.deepseek.com',
apiKey: 'YOUR_API_KEY',
language: '${isZh ? 'zh-CN' : 'en-US'}'
})`}
language="javascript"
/>
</div>
<div className="p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
<h3 className="text-lg font-semibold mb-2 text-orange-900 dark:text-orange-300">
{t('quick_start.step3_title')}
</h3>
<CodeEditor
code={`// ${isZh ? '程序化执行自然语言指令' : 'Execute natural language instructions programmatically'}
await agent.execute('${isZh ? '点击提交按钮,然后填写用户名为张三' : 'Click submit button, then fill username as John'}');
// ${isZh ? '或者显示对话框让用户输入指令' : 'Or show panel for user to input instructions'}
agent.panel.show()
`}
language="javascript"
/>
</div>
</div>
</div>
)
}
+27 -27
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 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 KnowledgeInjection from './docs/features/knowledge-injection/page' import DataMasking from './pages/docs/features/data-masking/page'
import ModelIntegration from './docs/features/model-integration/page' import Models from './pages/docs/features/models/page'
import SecurityPermissions from './docs/features/security-permissions/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 />
@@ -80,20 +71,20 @@ export default function Router() {
</div> </div>
</Route> </Route>
<Route path="/docs/features/knowledge-injection"> <Route path="/docs/features/custom-instructions">
<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>
<KnowledgeInjection /> <Instructions />
</DocsLayout> </DocsLayout>
</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>
</>
)
}
+5
View File
@@ -1,12 +1,16 @@
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react-swc' import react from '@vitejs/plugin-react-swc'
import { config as dotenvConfig } from 'dotenv' import { config as dotenvConfig } from 'dotenv'
import { readFileSync } from 'node:fs'
import process from 'node:process' import process from 'node:process'
import { dirname, resolve } from 'path' import { dirname, resolve } from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
const pageAgentPkg = JSON.parse(
readFileSync(resolve(__dirname, '../page-agent/package.json'), 'utf-8')
)
// Load .env from repo root // Load .env from repo root
dotenvConfig({ path: resolve(__dirname, '../../.env') }) dotenvConfig({ path: resolve(__dirname, '../../.env') })
@@ -32,5 +36,6 @@ export default defineConfig({
'import.meta.env.LLM_MODEL_NAME': JSON.stringify(process.env.LLM_MODEL_NAME), 'import.meta.env.LLM_MODEL_NAME': JSON.stringify(process.env.LLM_MODEL_NAME),
'import.meta.env.LLM_API_KEY': JSON.stringify(process.env.LLM_API_KEY), 'import.meta.env.LLM_API_KEY': JSON.stringify(process.env.LLM_API_KEY),
'import.meta.env.LLM_BASE_URL': JSON.stringify(process.env.LLM_BASE_URL), 'import.meta.env.LLM_BASE_URL': JSON.stringify(process.env.LLM_BASE_URL),
'import.meta.env.VERSION': JSON.stringify(pageAgentPkg.version),
}, },
}) })