Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fc88c0080 | |||
| 02f97ef2fa | |||
| 0853ffbbc6 | |||
| 60ac6da09f | |||
| 11a9fb1181 | |||
| 3762d0f1e0 | |||
| ebbe92f7ca | |||
| 9f239b2de3 | |||
| 2410c27b55 | |||
| 6d6c82648c | |||
| f41119c1ad | |||
| 9e6e1b996c | |||
| 1ab23e181f | |||
| a1b485691f | |||
| 694e05b800 | |||
| b761795c1a | |||
| 8cc54983aa | |||
| 59a599285e | |||
| 2f8c2b395c | |||
| 28f836674c | |||
| 6f01632231 | |||
| 41123ac561 | |||
| c8bf0dd20f | |||
| ec2b744bab | |||
| eab9cf64e8 | |||
| 42130d6f1d | |||
| c0ce6e7d14 | |||
| 8f5f98f889 | |||
| ee4719cdcf | |||
| b4c82b7833 | |||
| aefc3cfb89 | |||
| 7f2b8c2766 | |||
| 62e24a3050 | |||
| 4f6249a252 | |||
| 62519416bb | |||
| a1e70e663d | |||
| 338e317be1 | |||
| 9a66d5205f | |||
| 7d45bf6598 | |||
| 78ee72eea4 | |||
| 3b71c535b1 | |||
| 6615f7b300 | |||
| 38caae5e23 | |||
| 1751ba325d | |||
| 066b045822 | |||
| 3b21c42cdb | |||
| 8b1e91d665 | |||
| aa5909264a | |||
| b6232d4e21 | |||
| 0d3cef5578 | |||
| 1e737f3a71 | |||
| 2f2bdb4697 | |||
| 3fa7c8b85a | |||
| 7efce91a91 | |||
| a11d370f2b | |||
| d7bfa42f85 | |||
| 551ba49280 | |||
| adab24bb22 | |||
| 64aea7e84c | |||
| 67f6bd619e | |||
| 35d1fd1166 | |||
| d5b8019fb1 | |||
| 526cb4dbb3 | |||
| 6dc56c57c6 | |||
| a3263fdd3a | |||
| 14974c0257 | |||
| e70ae40096 | |||
| 3e04a131f1 | |||
| b487a7900b | |||
| 78b49c8b7a | |||
| 949e511dcf | |||
| d9faca5d01 | |||
| 398c6d0869 | |||
| 54d478df1e | |||
| 43c80f5418 | |||
| b186ffcaa6 | |||
| 778bbda4b8 | |||
| f5900bd096 | |||
| 09772d79bd | |||
| bfa6a5347e | |||
| da5cf44012 | |||
| 1666c08e58 | |||
| 0c0c76fdd8 | |||
| 6b0b1218ac | |||
| d70f652285 | |||
| c3c01b63fb | |||
| 463d010c24 | |||
| 4e52f17b9f | |||
| 5237edfcca | |||
| 79dcf9d9e4 | |||
| 09544205ed | |||
| 2c74c3170a | |||
| 9cee8f4174 | |||
| d05b43cf50 | |||
| 3fe6ae6862 | |||
| 66be1e56e3 | |||
| 0fd4eaf9af | |||
| a0712baa91 | |||
| 49af3a9c58 | |||
| 20ad3d04d2 | |||
| c7e0538836 | |||
| 59fcfaa503 | |||
| 17befe4bab | |||
| 7911bac052 | |||
| 87a8da5783 | |||
| 0aec35aee3 | |||
| 35fe51427c | |||
| 9e0b5bec8b | |||
| 70d33a9756 | |||
| 1c99d21c19 | |||
| e3c612c338 | |||
| 87596469b6 | |||
| 566b2d7022 | |||
| f521bd9beb | |||
| 35db15460e | |||
| a34d86ba75 | |||
| 3144e8d44d | |||
| 32be34bcb1 | |||
| a774bd94dc | |||
| 7063b8199d | |||
| 64f1a8c443 | |||
| 635416f964 | |||
| 7c2d000e29 |
Vendored
+12
-1
@@ -1,6 +1,17 @@
|
||||
{
|
||||
"editor.fontLigatures": true,
|
||||
"cSpell.words": ["HITL", "innerhtml", "opensource", "retryable", "wouter"],
|
||||
"cSpell.words": [
|
||||
"deepseek",
|
||||
"HITL",
|
||||
"innerhtml",
|
||||
"llms",
|
||||
"onwarn",
|
||||
"opensource",
|
||||
"qwen",
|
||||
"retryable",
|
||||
"shadcn",
|
||||
"wouter"
|
||||
],
|
||||
"markdownlint.config": {
|
||||
// "comment": "Relaxed rules",
|
||||
"default": true,
|
||||
|
||||
@@ -1,91 +1,61 @@
|
||||
# Instructions for coding assistants
|
||||
# Instructions for Coding Assistants
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a **monorepo** with npm workspaces containing **two main packages**:
|
||||
This is a **monorepo** with npm workspaces:
|
||||
|
||||
1. **Core Library** (`packages/page-agent/`) - Pure JavaScript/TypeScript AI agent library for browser DOM automation, published as `page-agent` on npm
|
||||
2. **Website** (`packages/website/`) - React documentation and landing page. Also as demo and test page for the core lib. private package `@page-agent/website`
|
||||
- **Core Library** (`packages/page-agent/`) - AI agent for browser DOM automation, published as `page-agent` on npm
|
||||
- **Website** (`packages/website/`) - React docs and landing page. **When working on website, follow `packages/website/AGENTS.md`**
|
||||
|
||||
And other internal packages:
|
||||
Internal packages:
|
||||
|
||||
- **Page Controller** (`packages/page-controller/`) - DOM operations and element interactions. Independent of LLM.
|
||||
- **UI** (`packages/ui/`) - Panel, SimulatorMask, and i18n. Decoupled from PageAgent.
|
||||
- **CDN** (`packages/cdn/`) - IIFE builds for script tag usage (npm: `@page-agent/cdn`)
|
||||
- **LLMs** (`packages/llms/`) - LLM client with reflection-before-action mental model
|
||||
- **Page Controller** (`packages/page-controller/`) - DOM operations and visual feedback (SimulatorMask), independent of LLM
|
||||
- **UI** (`packages/ui/`) - Panel and i18n. Decoupled from PageAgent
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Core Commands
|
||||
|
||||
```bash
|
||||
npm start # Start website dev server
|
||||
npm run build # Build all packages
|
||||
npm run build:libs # Build all libraries
|
||||
npm run build:website # Build the website
|
||||
npm run lint # ESLint with TypeScript strict rules
|
||||
```
|
||||
|
||||
## Architecture & Critical Patterns
|
||||
## Architecture
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
We adopt a very simple monorepo solution: ts reference + vite alias.
|
||||
Simple monorepo solution: TypeScript references + Vite aliases. Update tsconfig and vite config when adding/removing packages.
|
||||
|
||||
You must update tsconfig and vite config if you add/remove/rename a package.
|
||||
|
||||
```bash
|
||||
```
|
||||
packages/
|
||||
├── page-agent/ # npm: "page-agent" ⭐ MAIN
|
||||
│ ├── src/
|
||||
│ │ ├── PageAgent.ts # Main AI agent class
|
||||
│ │ ├── tools/ # LLM tool definitions
|
||||
│ │ └── llms/ # LLM integration
|
||||
│ ├── vite.config.js # Library build (ES + UMD)
|
||||
│ └── package.json
|
||||
├── website/ # npm: "@page-agent/website" (private) ⭐ MAIN
|
||||
│ ├── src/ # Website source
|
||||
│ └── index.html # Entry of vite webpage
|
||||
│
|
||||
│ # ...internal packages below...
|
||||
│
|
||||
├── page-controller/ # npm: "@page-agent/page-controller"
|
||||
│ └── src/ # DOM operations
|
||||
│ ├── PageController.ts
|
||||
│ ├── actions.ts
|
||||
│ └── dom/
|
||||
└── ui/ # npm: "@page-agent/ui"
|
||||
└── src/ # Panel and Mask Effects
|
||||
├── Panel.ts
|
||||
├── SimulatorMask.ts
|
||||
└── i18n/
|
||||
├── cdn/ # npm: "@page-agent/cdn" (IIFE builds)
|
||||
├── website/ # @page-agent/website (private)
|
||||
├── llms/ # @page-agent/llms
|
||||
├── page-controller/ # @page-agent/page-controller
|
||||
└── ui/ # @page-agent/ui
|
||||
```
|
||||
|
||||
`workspaces` must be written in topological order to guarantee build order.
|
||||
`workspaces` in `package.json` must be in topological order.
|
||||
|
||||
```json
|
||||
"workspaces": [
|
||||
// internal deps (topological order)
|
||||
"packages/page-controller",
|
||||
"packages/ui",
|
||||
"packages/page-agent",
|
||||
"packages/website"
|
||||
],
|
||||
```
|
||||
### Module Boundaries
|
||||
|
||||
### Module Boundaries (Critical)
|
||||
|
||||
- **Website** (`packages/website/`): CAN import from `page-agent` for demos. Alias `@/` → `website/src/`
|
||||
- **Page Agent** (`packages/page-agent/`): The core lib. Imports from `@page-agent/page-controller` and `@page-agent/ui`.
|
||||
- **UI** (`packages/ui/`): Panel, Mask, i18n. No dependency on page-agent.
|
||||
- **Page Controller** (`packages/page-controller/`): Pure DOM operations. No LLM or UI dependency.
|
||||
- **Page Agent**: Core lib. Imports from `@page-agent/llms`, `@page-agent/page-controller`, `@page-agent/ui`
|
||||
- **LLMs**: LLM client with MacroToolInput contract. No dependency on page-agent
|
||||
- **UI**: Panel and i18n. No dependency on page-agent
|
||||
- **Page Controller**: DOM operations with optional visual feedback (SimulatorMask). No LLM dependency. Enable mask via `enableMask: true` config
|
||||
|
||||
### PageController ↔ PageAgent Communication
|
||||
|
||||
All communication between PageAgent and PageController is async and isolated:
|
||||
All communication is async and isolated:
|
||||
|
||||
```typescript
|
||||
// PageAgent delegates DOM operations to PageController
|
||||
await this.pageController.updateTree() // Refresh DOM state
|
||||
await this.pageController.clickElement(index) // Click by index
|
||||
await this.pageController.updateTree()
|
||||
await this.pageController.clickElement(index)
|
||||
await this.pageController.inputText(index, text)
|
||||
await this.pageController.scroll({ down: true, numPages: 1 })
|
||||
|
||||
@@ -94,119 +64,68 @@ const simplifiedHTML = await this.pageController.getSimplifiedHTML()
|
||||
const pageInfo = await this.pageController.getPageInfo()
|
||||
```
|
||||
|
||||
DOM element references and internal state (selectorMap, elementTextMap) are encapsulated in PageController.
|
||||
|
||||
### DOM Pipeline
|
||||
|
||||
1. **DOM Extraction**: Convert live DOM to `FlatDomTree` via `page-controller/src/dom/dom_tree/`
|
||||
2. **Dehydration**: DOM tree → simplified text for LLM processing
|
||||
3. **LLM Processing**: AI model returns action plans (in page-agent)
|
||||
4. **Indexed Operations**: PageAgent calls PageController methods by element index
|
||||
1. **DOM Extraction**: Live DOM → `FlatDomTree` via `page-controller/src/dom/dom_tree/`
|
||||
2. **Dehydration**: DOM tree → simplified text for LLM
|
||||
3. **LLM Processing**: AI returns action plans (page-agent)
|
||||
4. **Indexed Operations**: PageAgent calls PageController by element index
|
||||
|
||||
### Hash Routing Requirement
|
||||
### CDN Builds (`packages/cdn/`)
|
||||
|
||||
Uses wouter with `useHashLocation` for static hosting:
|
||||
Two IIFE builds for script tag usage:
|
||||
|
||||
```tsx
|
||||
<Router hook={useHashLocation}> // Always hash-based routes
|
||||
```
|
||||
| Build | File | Description |
|
||||
| ----- | -------------------- | -------------------------------- |
|
||||
| Demo | `page-agent.demo.js` | Auto-init with built-in test API |
|
||||
| Full | `page-agent.js` | Exposes `PageAgent` class only |
|
||||
|
||||
### CDN Auto-Injection Pattern
|
||||
|
||||
Library auto-initializes when injected via script tag:
|
||||
|
||||
```html
|
||||
<script src="page-agent.js?model=gpt-4"></script>
|
||||
```
|
||||
|
||||
Query params configure `PageAgentConfig` automatically in `src/entry.ts`.
|
||||
Demo build supports query params (e.g., `?model=gpt-4&lang=en-US`).
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
### Page Agent (`packages/page-agent/`)
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `src/PageAgent.ts` | ⭐ Main AI agent class orchestrating tools and LLM |
|
||||
| `src/umd.ts` | CDN/UMD entry point with auto-initialization |
|
||||
| `src/tools/` | Tool definitions that call PageController methods |
|
||||
| `src/llms/` | LLM integration and communication layer |
|
||||
| `vite.config.js` | Library build configuration (ES + UMD) |
|
||||
| File | Description |
|
||||
| ------------------ | --------------------------------------- |
|
||||
| `src/PageAgent.ts` | ⭐ Main AI agent class |
|
||||
| `src/umd.ts` | CDN/UMD entry with auto-init |
|
||||
| `src/tools/` | Tool definitions calling PageController |
|
||||
|
||||
### LLMs (`packages/llms/`)
|
||||
|
||||
| File | Description |
|
||||
| ---------------------------- | ------------------------------------- |
|
||||
| `src/index.ts` | ⭐ LLM class with retry logic |
|
||||
| `src/types.ts` | MacroToolInput, AgentBrain, LLMConfig |
|
||||
| `src/OpenAILenientClient.ts` | OpenAI-compatible client |
|
||||
|
||||
### Page Controller (`packages/page-controller/`)
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `src/PageController.ts` | ⭐ Main controller class managing DOM state and actions |
|
||||
| `src/actions.ts` | Element interaction implementations (click, input, scroll) |
|
||||
| `src/dom/dom_tree/index.js` | Core DOM extraction engine (ported from browser-use) |
|
||||
| `src/dom/getPageInfo.ts` | Page scroll/size information |
|
||||
| `src/patches/` | Framework-specific optimizations (React, Antd) |
|
||||
| `src/types.ts` | TypeScript interfaces for controller |
|
||||
|
||||
### Website (`packages/website/`)
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `src/router.tsx` | ⭐ Central routing (manual registration required) |
|
||||
| `src/components/DocsLayout.tsx` | Navigation structure (hardcoded nav items) |
|
||||
| `src/main.tsx` | Site entry with hash routing setup |
|
||||
| `src/docs/[section]/[topic]/page.tsx` | Documentation pages |
|
||||
| `src/test-pages/` | Library integration test pages |
|
||||
| `vite.config.js` | Website build configuration |
|
||||
| File | Description |
|
||||
| --------------------------- | ---------------------------------------------------------- |
|
||||
| `src/PageController.ts` | ⭐ Main controller class with optional mask support |
|
||||
| `src/SimulatorMask.ts` | Visual overlay blocking user interaction during automation |
|
||||
| `src/actions.ts` | Element interactions (click, input, scroll) |
|
||||
| `src/dom/dom_tree/index.js` | Core DOM extraction engine |
|
||||
|
||||
## Adding New Features
|
||||
|
||||
### New Documentation Page
|
||||
|
||||
1. Create `packages/website/src/docs/<section>/<slug>/page.tsx`
|
||||
2. Add route to `packages/website/src/router.tsx` with `<Header /> + <DocsLayout>` wrapper
|
||||
3. Add navigation item to `DocsLayout.tsx`
|
||||
|
||||
### New Agent Tool
|
||||
|
||||
1. Implement tool in `packages/page-agent/src/tools/index.ts`
|
||||
2. If tool needs DOM operations, add method to PageController first
|
||||
1. Implement in `packages/page-agent/src/tools/index.ts`
|
||||
2. If tool needs DOM ops, add method to PageController first
|
||||
3. Tool calls `this.pageController.methodName()` for DOM interactions
|
||||
|
||||
### New PageController Action
|
||||
|
||||
1. Add action implementation in `packages/page-controller/src/actions.ts`
|
||||
1. Add implementation in `packages/page-controller/src/actions.ts`
|
||||
2. Expose via async method in `PageController.ts`
|
||||
3. Export from `packages/page-controller/src/index.ts`
|
||||
|
||||
## Code Standards
|
||||
|
||||
### TypeScript
|
||||
|
||||
- Explicit typing for exported/public APIs
|
||||
- ESLint relaxes some unsafe rules for rapid iteration
|
||||
|
||||
### CSS & Styling
|
||||
|
||||
- **Prefer Tailwind CSS over custom CSS**
|
||||
- Custom CSS variables for theme gradients in `src/index.css`
|
||||
- Dark mode support via `dark:` classes
|
||||
- CSS modules for component-specific styles
|
||||
|
||||
### Import Organization
|
||||
|
||||
- External libraries first
|
||||
- Internal modules (`@/`, `@pages/`)
|
||||
- Relative imports last
|
||||
- Blank lines between groups
|
||||
|
||||
## Debugging Common Issues
|
||||
|
||||
### Blank Documentation Pages
|
||||
|
||||
1. Verify route exists in `packages/website/src/router.tsx`
|
||||
2. Check component import path
|
||||
3. Verify CSS isn't hiding content (check dark mode classes)
|
||||
4. Test with minimal component first
|
||||
|
||||
### Library Integration Issues
|
||||
|
||||
1. Check `packages/page-agent/dist/lib/page-agent.umd.js` builds correctly
|
||||
2. Test CDN injection with query params
|
||||
4. Use `packages/website/src/test-pages/` for isolated testing
|
||||
- Every change you make should not only implement the desired functionality but also improve the quality of the codebase
|
||||
- All code and comments must be in English.
|
||||
|
||||
+37
-27
@@ -14,7 +14,7 @@ Thank you for your interest in contributing to Page-Agent! We welcome contributi
|
||||
2. **Setup**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm ci
|
||||
npm start # Start demo and documentation site
|
||||
```
|
||||
|
||||
@@ -25,11 +25,11 @@ This is a **monorepo** with npm workspaces containing **two main packages**:
|
||||
1. **Core Library** (`packages/page-agent/`) - Pure JavaScript/TypeScript AI agent library for browser DOM automation, published as `page-agent` on npm
|
||||
2. **Website** (`packages/website/`) - React documentation and landing page. Also as demo and test page for the core lib. private package `@page-agent/website`
|
||||
|
||||
We use a simplified monorepo solution with `native npm-workspace + ts reference + vite alias`. No fancy tooling. Hoisting is required.
|
||||
We use a simplified monorepo solution with `native npm-workspace + ts reference + vite alias`. No fancy tooling. Hoisting is required.
|
||||
|
||||
- When developing. Use alias so that we don't have to pre-build.
|
||||
- When bundling. Use external and disable ts `paths` alias to leave deps out.
|
||||
- When bundling `UMD` and `Website`. Bundle everything including local packages.
|
||||
- When bundling `IIFE` and `Website`. Bundle everything including local packages.
|
||||
|
||||
## 🤝 How to Contribute
|
||||
|
||||
@@ -61,10 +61,9 @@ We use a simplified monorepo solution with `native npm-workspace + ts reference
|
||||
- Update documentation as needed
|
||||
|
||||
4. **Test Your Changes**
|
||||
|
||||
```bash
|
||||
# TODO
|
||||
```
|
||||
- Test in our demo website
|
||||
- Test it on other websites if applicable
|
||||
- `@TODO: test suite`
|
||||
|
||||
5. **Commit and Push**
|
||||
|
||||
@@ -94,41 +93,52 @@ We use a simplified monorepo solution with `native npm-workspace + ts reference
|
||||
- It's **recommended** to heavily rely on AI (aka "vibe coding") when maintaining **demo pages and tests**.
|
||||
- BUT **NOT the core lib!!!** Be very careful if AI ever touched the core lib!!!
|
||||
- Review anything AI wrote before make a commit. You are the author of anything you commit. NOT AI.
|
||||
- Update the AI instructions when structure changed.
|
||||
- Cursor and Cline: `./.cursor/rules`
|
||||
- Github Copilot: `./.github/copilot-instructions.md`
|
||||
- Claude Code: `./CLAUDE.md`
|
||||
- Update the [AI instructions](AGENTS.md) when structure changed.
|
||||
|
||||
## 🔧 Development Workflows
|
||||
|
||||
### Test With Your Own LLM API
|
||||
|
||||
- Create a `.env` file in the repo root with your LLM API config
|
||||
|
||||
```env
|
||||
LLM_MODEL_NAME=gpt-5.2
|
||||
LLM_API_KEY=your-api-key
|
||||
LLM_BASE_URL=https://api.your-llm-provider.com/v1
|
||||
```
|
||||
|
||||
- Restart the dev server to load new env vars
|
||||
- If not provided, the demo will the free testing proxy by default
|
||||
|
||||
### Website Development
|
||||
|
||||
```bash
|
||||
npm start # React development server
|
||||
npm start
|
||||
```
|
||||
|
||||
### Core Lib Development and Testing
|
||||
### Testing on Other Websites
|
||||
|
||||
> @TODO this part is outdated. Update this.
|
||||
- Start and serve a local `iife` script
|
||||
|
||||
- Config your LLM API
|
||||
- Start and serve a local umd script
|
||||
```bash
|
||||
npm run dev:iife # Serving IIFE with auto rebuild at http://localhost:5174/page-agent.js
|
||||
```
|
||||
|
||||
```bash
|
||||
npm run dev:umd # Serving UMD with auto rebuild at http://localhost:5173/page-agent.umd.cjs
|
||||
```
|
||||
- Add a new bookmark
|
||||
|
||||
- Add a new bookmark enable it on other website
|
||||
```javascript
|
||||
javascript:(function(){var s=document.createElement('script');s.src=`http://localhost:5174/page-agent.js?t=${Math.random()}`;s.onload=()=>console.log(%27PageAgent ready!%27);document.head.appendChild(s);})();
|
||||
```
|
||||
|
||||
```
|
||||
javascript:(function(){var s=document.createElement('script');s.src=`http://localhost:5173/page-agent.umd.cjs?t=${Math.random()}`;s.onload=()=>console.log(%27PageAgent ready!%27);document.head.appendChild(s);})();
|
||||
```
|
||||
- Click the bookmark on any page to load Page-Agent
|
||||
|
||||
> Warning: AK in your local `.env` will be inlined in the iife script.
|
||||
|
||||
### Adding Documentation
|
||||
|
||||
1. Create `website/src/docs/section/page-name/page.tsx`
|
||||
2. Add route to `website/src/router.tsx`
|
||||
3. Add navigation link to `website/src/components/DocsLayout.tsx`
|
||||
Ask an AI to help you add documentation to the `website/` package. Follow the existing style.
|
||||
|
||||
> Our AGENTS.md file and guardrails are designed for this purpose. But please be careful and review anything AI generated.
|
||||
|
||||
## 🎯 Contribution Areas
|
||||
|
||||
@@ -143,7 +153,7 @@ We especially welcome contributions in:
|
||||
|
||||
## 🚫 What We Don't Accept
|
||||
|
||||
- Changes that break existing API compatibility
|
||||
- Changes that break existing API compatibility (Discuss first)
|
||||
- Heavy dependencies to core library
|
||||
- Contributions without proper testing
|
||||
- Code that doesn't follow project conventions
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Alibaba
|
||||
Copyright (c) 2026 Alibaba
|
||||
Copyright (c) 2026 Simon
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
+41
-40
@@ -1,16 +1,19 @@
|
||||
# PageAgent 🤖🪄
|
||||
|
||||

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

|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://img.alicdn.com/imgextra/i4/O1CN01qKig1P1FnhpFKNdi6_!!6000000000532-2-tps-1280-256.png">
|
||||
<img alt="Page Agent Banner" src="https://img.alicdn.com/imgextra/i1/O1CN01NCMKXj1Gn4tkFTsxf_!!6000000000666-2-tps-1280-256.png">
|
||||
</picture>
|
||||
|
||||
[](https://badge.fury.io/js/page-agent) [](https://opensource.org/licenses/MIT) [](http://www.typescriptlang.org/) [](https://www.npmjs.com/package/page-agent) [](https://bundlephobia.com/package/page-agent) [](https://github.com/alibaba/page-agent)
|
||||
[](https://opensource.org/licenses/MIT) [](http://www.typescriptlang.org/) [](https://www.npmjs.com/package/page-agent) [](https://bundlephobia.com/package/page-agent) [](https://github.com/alibaba/page-agent)
|
||||
|
||||
The GUI Agent Living in Your Webpage. Control web interfaces with natural language.
|
||||
|
||||
🌐 **English** | [中文](./README-zh.md)
|
||||
|
||||
👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/#/docs/introduction/overview" target="_blank"><b>📖 Documentation</b></a>
|
||||
👉 <a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/#/docs/introduction/overview" target="_blank"><b>📖 Documentation</b></a>
|
||||
|
||||
<video id="demo-video" src="https://github.com/user-attachments/assets/de8d1964-8bde-494f-a52f-2975469557a5" width="640" crossorigin muted autoplay loop></video>
|
||||
<video id="demo-video" src="https://github.com/user-attachments/assets/34d8444d-cbfb-44a3-a24e-fd5c167bb0bf" controls crossorigin muted></video>
|
||||
|
||||
---
|
||||
|
||||
@@ -24,22 +27,28 @@ The GUI Agent Living in Your Webpage. Control web interfaces with natural langua
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
👉 [**Roadmap**](./ROADMAP.md)
|
||||
👉 [**Roadmap**](https://github.com/alibaba/page-agent/issues/96)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### CDN Integration
|
||||
### Quick Try (With Testing LLM)
|
||||
|
||||
Fastest way to try PageAgent:
|
||||
|
||||
```html
|
||||
// CDN - https://cdn.jsdelivr.net/npm/page-agent@latest/dist/umd/page-agent.js
|
||||
// Mirror(CN) - https://registry.npmmirror.com/page-agent/latest/files/dist/umd/page-agent.js
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/page-agent@latest/dist/umd/page-agent.js"
|
||||
crossorigin="true"
|
||||
type="text/javascript"
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.demo.js"
|
||||
crossorigin="true"
|
||||
></script>
|
||||
```
|
||||
|
||||
> ⚠️ **For technical evaluation only.** Demo model has rate limits and usage restrictions. Use NPM for production.
|
||||
|
||||
| Mirrors | URL |
|
||||
| ------- | ----------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.demo.js |
|
||||
|
||||
### NPM Installation
|
||||
|
||||
```bash
|
||||
@@ -49,23 +58,18 @@ npm install page-agent
|
||||
```javascript
|
||||
import { PageAgent } from 'page-agent'
|
||||
|
||||
// test server
|
||||
// @note: rate limit. prompt limit. Origin limit. May change anytime. Use your own llm!
|
||||
// @note Using official DeepSeek-chat(3.2). Go to DeepSeek website for privacy policy.
|
||||
const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
const DEMO_BASE_URL = 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
|
||||
const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
|
||||
const agent = new PageAgent({
|
||||
modelName: DEMO_MODEL,
|
||||
baseURL: DEMO_BASE_URL,
|
||||
apiKey: DEMO_API_KEY,
|
||||
model: 'deepseek-chat',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
language: 'en-US',
|
||||
})
|
||||
|
||||
await agent.execute('Click the login button')
|
||||
```
|
||||
|
||||
For environments where NPM is not available. We do offer a IIFE build via CDN. [@see CDN Usage](https://alibaba.github.io/page-agent/#/docs/integration/cdn-setup)
|
||||
|
||||
## 🏗️ Structure
|
||||
|
||||
PageAgent adopts a simplified monorepo structure:
|
||||
@@ -73,8 +77,10 @@ PageAgent adopts a simplified monorepo structure:
|
||||
```
|
||||
packages/
|
||||
├── page-agent/ # AI agent (npm: page-agent)
|
||||
├── page-controller/ # DOM operations (npm: @page-agent/page-controller)
|
||||
├── ui/ # Panel & Mask & Mouse Animation (npm: @page-agent/ui)
|
||||
├── llms/ # LLM client (npm: @page-agent/llms)
|
||||
├── page-controller/ # DOM operations & Visual Mask (npm: @page-agent/page-controller)
|
||||
├── ui/ # Panel & i18n (npm: @page-agent/ui)
|
||||
├── cdn/ # CDN IIFE builds (npm: @page-agent/cdn)
|
||||
└── website/ # Demo & Documentation site
|
||||
```
|
||||
|
||||
@@ -82,28 +88,19 @@ packages/
|
||||
|
||||
We welcome contributions from the community! Here's how to get started:
|
||||
|
||||
### Setup
|
||||
1. Fork and clone. `git clone https://github.com/alibaba/page-agent.git && cd page-agent`
|
||||
2. Install dependencies: `npm install`
|
||||
3. Start development: `npm start`
|
||||
|
||||
1. Fork the repository
|
||||
2. Clone your fork: `git clone https://github.com/alibaba/page-agent.git && cd page-agent`
|
||||
3. Install dependencies: `npm install`
|
||||
4. Start development: `npm start`
|
||||
More details in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
### Contributing Guidelines
|
||||
|
||||
Please read our [Code of Conduct](CODE_OF_CONDUCT.md) and [Contributing Guide](CONTRIBUTING.md) before contributing.
|
||||
Please read our [Code of Conduct](CODE_OF_CONDUCT.md) before contributing.
|
||||
|
||||
## 👏 Acknowledgments
|
||||
|
||||
This project builds upon the excellent work of:
|
||||
This project builds upon the excellent work of **[`browser-use`](https://github.com/browser-use/browser-use)**.
|
||||
|
||||
- **[browser-use](https://github.com/browser-use/browser-use)**
|
||||
|
||||
PageAgent is designed for **client-side web enhancement**, not server-side automation.
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
`PageAgent` is designed for **client-side web enhancement**, not server-side automation.
|
||||
|
||||
```
|
||||
DOM processing components and prompt are derived from browser-use:
|
||||
@@ -122,6 +119,10 @@ Third-party dependencies and their licenses can be found in the package.json
|
||||
file and in the node_modules directory after installation.
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
|
||||
[MIT License](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
**⭐ Star this repo if you find PageAgent helpful!**
|
||||
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
# 🗺️ PageAgent Roadmap
|
||||
|
||||
The development progress and future plans for PageAgent.
|
||||
|
||||
## 🚀 Current Works
|
||||
|
||||
- [x] **MVP**
|
||||
- Core functionality implemented.
|
||||
- [x] **SPA interaction**
|
||||
- [x] **Reasoning and (short) memory**
|
||||
- [x] **Multi model provider integration and testing**
|
||||
- [x] **UI with HITL**
|
||||
- Human-in-the-loop user interface. Agent can ask user questions.
|
||||
- [x] **Landing and doc pages**
|
||||
- [x] **Remove `ai-sdk`**
|
||||
- Only one function of AI-ADK is being used.
|
||||
- Our agent memory and thinking mechanism does not suite ai-sdk.
|
||||
- [x] **Robust LLM output**
|
||||
- Auto-fix incomplete output format of DeepSeek and QWen.
|
||||
- [x] **Working homepage with live LLM API**
|
||||
- [x] **~~free~~ CDN**
|
||||
- [x] **Free evaluation plan**
|
||||
- [x] **Custom actions and HITL**
|
||||
- [ ] **Hooks and Events**
|
||||
- [x] **lifecycle hooks**
|
||||
- [ ] **lifecycle events**
|
||||
- [ ] **User takeover**
|
||||
- [#64](https://github.com/alibaba/page-agent/issues/64)
|
||||
- [ ] **❗Hijack `page_open/page_change/page_unload` behavior**
|
||||
- [ ] **Custom knowledge base and instructions**
|
||||
- [#45](https://github.com/alibaba/page-agent/issues/45)
|
||||
- [ ] **Black/white-list safeguard**
|
||||
- [ ] **Data-masking**
|
||||
- [#44](https://github.com/alibaba/page-agent/issues/44)
|
||||
- [ ] **Improve Memory**
|
||||
- [#66](https://github.com/alibaba/page-agent/issues/66)
|
||||
- [#65](https://github.com/alibaba/page-agent/issues/65)
|
||||
- [ ] **Optimize for popular UI frameworks**
|
||||
- [x] **i18n of the website**
|
||||
- [x] Chinese version
|
||||
- [x] English version
|
||||
- [x] **Refactor: Separate `Agent` and `PageController`**
|
||||
- [ ] **Chrome-ext wrapper for multi-page tasks**
|
||||
|
||||
♻️ Following browser-use's update and contribute back.
|
||||
|
||||
## 📋 Pending Features
|
||||
|
||||
- [ ] **Tools for more complex tasks**
|
||||
- todo list
|
||||
- file sys
|
||||
- [ ] **Support custom llm fetch**
|
||||
- [ ] **Testing suits**
|
||||
- [ ] **Same-origin multi-page-app rally**
|
||||
- [ ] **Local MCP proxy**
|
||||
|
||||
## 🤔 To Be Decided
|
||||
|
||||
- [ ] **Cross-origin multi-page?**
|
||||
- Tricky
|
||||
- Need some kind of "memory rally"
|
||||
+1
-1
@@ -8,7 +8,7 @@ import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['**/dist', '**/test-pages', '**/node_modules']),
|
||||
globalIgnores(['**/dist', '**/node_modules', 'packages/website/src/components/ui']),
|
||||
{
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
|
||||
Generated
+1286
-303
File diff suppressed because it is too large
Load Diff
+18
-11
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"version": "0.0.13",
|
||||
"version": "0.2.3",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/page-controller",
|
||||
"packages/ui",
|
||||
"packages/llms",
|
||||
"packages/page-agent",
|
||||
"packages/cdn",
|
||||
"packages/website"
|
||||
],
|
||||
"description": "AI-powered UI agent for web applications",
|
||||
@@ -22,39 +24,44 @@
|
||||
"npm": ">=10.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"cleanup": "rm -rf packages/*/dist",
|
||||
"start": "npm run dev --workspace=@page-agent/website",
|
||||
"build:website": "npm run build:website --workspace=@page-agent/website",
|
||||
"build:libs": "npm run build --workspaces --if-present",
|
||||
"build": "npm run build:libs && npm run build:website",
|
||||
"dev:umd": "npm run dev:umd --workspace=page-agent",
|
||||
"dev:iife": "npm run dev:iife --workspace=page-agent",
|
||||
"version": "node scripts/sync-version.js",
|
||||
"lint": "eslint .",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.2.0",
|
||||
"@commitlint/config-conventional": "^20.2.0",
|
||||
"@commitlint/cli": "^20.3.1",
|
||||
"@commitlint/config-conventional": "^20.3.1",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@microsoft/api-extractor": "^7.55.2",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/node": "^25.0.7",
|
||||
"concurrently": "^9.2.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-dom": "^2.3.13",
|
||||
"eslint-plugin-react-dom": "^2.5.7",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.25",
|
||||
"eslint-plugin-react-x": "^2.3.13",
|
||||
"globals": "^16.4.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"eslint-plugin-react-x": "^2.5.7",
|
||||
"globals": "^17.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.4",
|
||||
"prettier": "^3.7.4",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.50.0",
|
||||
"typescript-eslint": "^8.53.0",
|
||||
"unplugin-dts": "^1.0.0-beta.6",
|
||||
"vite": "^7.3.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-css-injected-by-js": "^3.5.2"
|
||||
},
|
||||
"overrides": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,cjs,cts,mjs,mts}": [
|
||||
"npx prettier --write --ignore-unknown",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@page-agent/cdn",
|
||||
"private": false,
|
||||
"version": "0.2.3",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"description": "CDN builds for page-agent - IIFE bundles for script tag usage",
|
||||
"author": "Simon<gaomeng1900>",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/alibaba/page-agent.git"
|
||||
},
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"scripts": {
|
||||
"build": "vite build && vite build --mode demo"
|
||||
},
|
||||
"dependencies": {
|
||||
"page-agent": "0.2.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Demo CDN build for page-agent
|
||||
* Auto-initializes with built-in demo API for testing
|
||||
*
|
||||
* Usage:
|
||||
* <script src="https://unpkg.com/@page-agent/cdn/dist/page-agent.demo.js"></script>
|
||||
*/
|
||||
import { PageAgent } from 'page-agent'
|
||||
|
||||
// Clean up existing instances to prevent multiple injections from bookmarklet
|
||||
if (window.pageAgent) {
|
||||
window.pageAgent.dispose()
|
||||
}
|
||||
|
||||
// Mount to global window object
|
||||
window.PageAgent = PageAgent
|
||||
|
||||
// Export for IIFE
|
||||
export { PageAgent }
|
||||
|
||||
console.log('🚀 page-agent.js loaded!')
|
||||
|
||||
const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
const DEMO_BASE_URL = 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
|
||||
const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
|
||||
// Demo warning
|
||||
console.log(
|
||||
'%c⚠️ DEMO MODE %c\n' +
|
||||
'This build uses a shared testing LLM with rate limits.\n' +
|
||||
'For production, use page-agent.js with your own API key:\n' +
|
||||
'https://alibaba.github.io/page-agent/#/docs/features/models',
|
||||
'background: #f59e0b; color: #000; font-size: 14px; font-weight: bold; padding: 4px 8px; border-radius: 4px;',
|
||||
'color: #f59e0b; font-size: 12px;'
|
||||
)
|
||||
|
||||
// in case document.x is not ready yet
|
||||
setTimeout(() => {
|
||||
const currentScript = document.currentScript
|
||||
|
||||
if (currentScript) {
|
||||
console.log('🚀 page-agent.js detected current script:', currentScript.src)
|
||||
const url = new URL(currentScript.src)
|
||||
const model = url.searchParams.get('model') || DEMO_MODEL
|
||||
const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL
|
||||
const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY
|
||||
const language = url.searchParams.get('lang') || 'zh-CN'
|
||||
const config = { model, baseURL, apiKey, language }
|
||||
window.pageAgent = new PageAgent(config)
|
||||
} else {
|
||||
console.log('🚀 page-agent.js no current script detected, using default demo config')
|
||||
const config = {
|
||||
model: DEMO_MODEL,
|
||||
baseURL: DEMO_BASE_URL,
|
||||
apiKey: DEMO_API_KEY,
|
||||
}
|
||||
window.pageAgent = new PageAgent(config)
|
||||
}
|
||||
|
||||
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
|
||||
|
||||
window.pageAgent.panel.show()
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Full CDN build for page-agent
|
||||
* Exposes PageAgent class for manual instantiation and configuration
|
||||
*
|
||||
* Usage:
|
||||
* <script src="https://unpkg.com/@page-agent/cdn/dist/page-agent.js"></script>
|
||||
* <script>
|
||||
* const agent = new PageAgent({ model: 'gpt-4o', apiKey: 'your-key' })
|
||||
* agent.panel.show()
|
||||
* </script>
|
||||
*/
|
||||
import { PageAgent } from 'page-agent'
|
||||
|
||||
export { PageAgent }
|
||||
@@ -0,0 +1,49 @@
|
||||
// @ts-nocheck
|
||||
import { dirname, resolve } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig } from 'vite'
|
||||
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
/**
|
||||
* CDN IIFE builds for page-agent
|
||||
*
|
||||
* Build targets (via --mode):
|
||||
* - demo: page-agent.demo.js with auto-init and built-in API
|
||||
* - full (default): page-agent.js exposes PageAgent class only
|
||||
*/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const isDemo = mode === 'demo'
|
||||
const entry = isDemo ? resolve(__dirname, 'src/demo.js') : resolve(__dirname, 'src/full.js')
|
||||
const fileName = isDemo ? 'page-agent.demo' : 'page-agent'
|
||||
|
||||
return {
|
||||
plugins: [cssInjectedByJsPlugin({ relativeCSSInjection: true })],
|
||||
publicDir: false,
|
||||
esbuild: {
|
||||
keepNames: true,
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry,
|
||||
name: 'PageAgent',
|
||||
fileName: () => `${fileName}.js`,
|
||||
formats: ['iife'],
|
||||
},
|
||||
outDir: resolve(__dirname, 'dist'),
|
||||
emptyOutDir: !isDemo, // only empty on first build (full)
|
||||
minify: false,
|
||||
cssCodeSplit: true,
|
||||
rollupOptions: {
|
||||
onwarn: function (message, handler) {
|
||||
if (message.code === 'EVAL') return
|
||||
handler(message)
|
||||
},
|
||||
},
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@page-agent/llms",
|
||||
"version": "0.2.3",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-agent-llms.js",
|
||||
"module": "./dist/lib/page-agent-llms.js",
|
||||
"types": "./dist/lib/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/lib/index.d.ts",
|
||||
"import": "./dist/lib/page-agent-llms.js",
|
||||
"default": "./dist/lib/page-agent-llms.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"description": "LLM client with reflection-before-action mental model for page-agent",
|
||||
"keywords": [
|
||||
"page-agent",
|
||||
"llm",
|
||||
"openai",
|
||||
"tool-calling",
|
||||
"agent"
|
||||
],
|
||||
"author": "Simon<gaomeng1900>",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/alibaba/page-agent.git",
|
||||
"directory": "packages/llms"
|
||||
},
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
||||
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* OpenAI Client implementation
|
||||
*/
|
||||
import { InvokeError, InvokeErrorType } from './errors'
|
||||
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
|
||||
import { modelPatch, zodToOpenAITool } from './utils'
|
||||
|
||||
/**
|
||||
* Client for OpenAI compatible APIs
|
||||
*/
|
||||
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: Record<string, Tool>,
|
||||
abortSignal?: AbortSignal,
|
||||
options?: InvokeOptions
|
||||
): Promise<InvokeResult> {
|
||||
// 1. Convert tools to OpenAI format
|
||||
const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t))
|
||||
|
||||
// Build request body
|
||||
const requestBody: Record<string, unknown> = {
|
||||
model: this.config.model,
|
||||
temperature: this.config.temperature,
|
||||
messages,
|
||||
tools: openaiTools,
|
||||
parallel_tool_calls: false,
|
||||
// Require tool call: specific tool if provided, otherwise any tool
|
||||
tool_choice: options?.toolChoiceName
|
||||
? { type: 'function', function: { name: options.toolChoiceName } }
|
||||
: 'required',
|
||||
}
|
||||
|
||||
// 2. Call API
|
||||
let response: Response
|
||||
try {
|
||||
response = await this.fetch(`${this.config.baseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(modelPatch(requestBody)),
|
||||
signal: abortSignal,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Parse and validate response
|
||||
const data = await response.json()
|
||||
|
||||
const choice = data.choices?.[0]
|
||||
if (!choice) {
|
||||
throw new InvokeError(InvokeErrorType.UNKNOWN, 'No choices in response', data)
|
||||
}
|
||||
|
||||
// Check finish_reason
|
||||
switch (choice.finish_reason) {
|
||||
case 'tool_calls':
|
||||
case 'function_call': // gemini
|
||||
case 'stop': // some models use this even with tool calls
|
||||
break
|
||||
case 'length':
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.CONTEXT_LENGTH,
|
||||
'Response truncated: max tokens reached'
|
||||
)
|
||||
case 'content_filter':
|
||||
throw new InvokeError(InvokeErrorType.CONTENT_FILTER, 'Content filtered by safety system')
|
||||
default:
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.UNKNOWN,
|
||||
`Unexpected finish_reason: ${choice.finish_reason}`
|
||||
)
|
||||
}
|
||||
|
||||
// Apply normalizeResponse if provided (for fixing format issues automatically)
|
||||
const normalizedData = options?.normalizeResponse ? options.normalizeResponse(data) : data
|
||||
const normalizedChoice = (normalizedData as any).choices?.[0]
|
||||
|
||||
// Get tool name from response
|
||||
const toolCallName = normalizedChoice?.message?.tool_calls?.[0]?.function?.name
|
||||
if (!toolCallName) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.NO_TOOL_CALL,
|
||||
'No tool call found in response',
|
||||
normalizedData
|
||||
)
|
||||
}
|
||||
|
||||
const tool = tools[toolCallName]
|
||||
if (!tool) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.UNKNOWN,
|
||||
`Tool "${toolCallName}" not found in tools`,
|
||||
normalizedData
|
||||
)
|
||||
}
|
||||
|
||||
// Extract and parse tool arguments
|
||||
const argString = normalizedChoice.message?.tool_calls?.[0]?.function?.arguments
|
||||
if (!argString) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
'No tool call arguments found',
|
||||
normalizedData
|
||||
)
|
||||
}
|
||||
|
||||
let parsedArgs: unknown
|
||||
try {
|
||||
parsedArgs = JSON.parse(argString)
|
||||
} catch (error) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
'Failed to parse tool arguments as JSON',
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
// Validate with schema
|
||||
const validation = tool.inputSchema.safeParse(parsedArgs)
|
||||
if (!validation.success) {
|
||||
console.error(validation.error)
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
'Tool arguments validation failed',
|
||||
validation.error
|
||||
)
|
||||
}
|
||||
const toolInput = validation.data
|
||||
|
||||
// 5. Execute tool
|
||||
let toolResult: unknown
|
||||
try {
|
||||
toolResult = await tool.execute(toolInput)
|
||||
} catch (e) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.TOOL_EXECUTION_ERROR,
|
||||
`Tool execution failed: ${(e as Error).message}`,
|
||||
e
|
||||
)
|
||||
}
|
||||
|
||||
// Return result
|
||||
return {
|
||||
toolCall: {
|
||||
name: toolCallName,
|
||||
args: toolInput,
|
||||
},
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Internal constants
|
||||
export const LLM_MAX_RETRIES = 2
|
||||
export const DEFAULT_TEMPERATURE = 0.7 // higher randomness helps auto-recovery
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,108 @@
|
||||
import { OpenAIClient } from './OpenAIClient'
|
||||
import { DEFAULT_TEMPERATURE, LLM_MAX_RETRIES } from './constants'
|
||||
import { InvokeError } from './errors'
|
||||
import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types'
|
||||
|
||||
export type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool }
|
||||
|
||||
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
|
||||
// Runtime validation as defensive programming (types already guarantee these)
|
||||
if (!config.baseURL || !config.apiKey || !config.model) {
|
||||
throw new Error(
|
||||
'[PageAgent] LLM configuration required. Please provide: baseURL, apiKey, model. ' +
|
||||
'See: https://alibaba.github.io/page-agent/#/docs/features/models'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
baseURL: config.baseURL,
|
||||
apiKey: config.apiKey,
|
||||
model: config.model,
|
||||
temperature: config.temperature ?? DEFAULT_TEMPERATURE,
|
||||
maxRetries: config.maxRetries ?? LLM_MAX_RETRIES,
|
||||
customFetch: (config.customFetch ?? fetch).bind(globalThis), // fetch will be illegal unless bound
|
||||
}
|
||||
}
|
||||
|
||||
export class LLM extends EventTarget {
|
||||
config: Required<LLMConfig>
|
||||
client: LLMClient
|
||||
|
||||
constructor(config: LLMConfig) {
|
||||
super()
|
||||
this.config = parseLLMConfig(config)
|
||||
|
||||
// Default to OpenAI client
|
||||
this.client = new OpenAIClient(this.config)
|
||||
}
|
||||
|
||||
/**
|
||||
* - call llm api *once*
|
||||
* - invoke tool call *once*
|
||||
* - return the result of the tool
|
||||
*/
|
||||
async invoke(
|
||||
messages: Message[],
|
||||
tools: Record<string, Tool>,
|
||||
abortSignal: AbortSignal,
|
||||
options?: InvokeOptions
|
||||
): Promise<InvokeResult> {
|
||||
return await withRetry(
|
||||
async () => {
|
||||
const result = await this.client.invoke(messages, tools, abortSignal, options)
|
||||
|
||||
return result
|
||||
},
|
||||
// retry settings
|
||||
{
|
||||
maxRetries: this.config.maxRetries,
|
||||
onRetry: (current: number) => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('retry', { detail: { current, max: this.config.maxRetries } })
|
||||
)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
this.dispatchEvent(new CustomEvent('error', { detail: { error } }))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
settings: {
|
||||
maxRetries: number
|
||||
onRetry: (retries: number) => void
|
||||
onError: (error: Error) => void
|
||||
}
|
||||
): Promise<T> {
|
||||
let retries = 0
|
||||
let lastError: Error | null = null
|
||||
while (retries <= settings.maxRetries) {
|
||||
if (retries > 0) {
|
||||
settings.onRetry(retries)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error: unknown) {
|
||||
console.error(error)
|
||||
settings.onError(error as Error)
|
||||
|
||||
// do not retry if aborted by user
|
||||
if ((error as { name?: string })?.name === 'AbortError') throw error
|
||||
|
||||
// do not retry if error is not retryable (InvokeError)
|
||||
if (error instanceof InvokeError && !error.retryable) throw error
|
||||
|
||||
lastError = error as Error
|
||||
retries++
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError!
|
||||
}
|
||||
@@ -32,6 +32,24 @@ export interface Tool<TParams = any, TResult = any> {
|
||||
execute: (args: TParams) => Promise<TResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke options for LLM call
|
||||
*/
|
||||
export interface InvokeOptions {
|
||||
/**
|
||||
* Force LLM to call a specific tool by name.
|
||||
* If provided: tool_choice = { type: 'function', function: { name: toolChoiceName } }
|
||||
* If not provided: tool_choice = 'required' (must call some tool, but model chooses which)
|
||||
*/
|
||||
toolChoiceName?: string
|
||||
/**
|
||||
* Response normalization function.
|
||||
* Called before parsing the response.
|
||||
* Used to fix various response format errors from the model.
|
||||
*/
|
||||
normalizeResponse?: (response: any) => any
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM Client interface
|
||||
* Note: Does not use generics because each tool in the tools array has different types
|
||||
@@ -40,7 +58,8 @@ export interface LLMClient {
|
||||
invoke(
|
||||
messages: Message[],
|
||||
tools: Record<string, Tool>,
|
||||
abortSignal?: AbortSignal
|
||||
abortSignal?: AbortSignal,
|
||||
options?: InvokeOptions
|
||||
): Promise<InvokeResult>
|
||||
}
|
||||
|
||||
@@ -65,13 +84,20 @@ export interface InvokeResult<TResult = unknown> {
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI Client config
|
||||
* LLM configuration
|
||||
*/
|
||||
export interface OpenAIClientConfig {
|
||||
model: string
|
||||
apiKey: string
|
||||
export 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
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Utility functions for LLM integration
|
||||
*/
|
||||
import chalk from 'chalk'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { Tool } from './types'
|
||||
|
||||
function debug(message: string) {
|
||||
console.debug(chalk.gray('[LLM]'), message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Zod schema to OpenAI tool format
|
||||
* Uses Zod 4 native z.toJSONSchema()
|
||||
*/
|
||||
export function zodToOpenAITool(name: string, tool: Tool) {
|
||||
return {
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name,
|
||||
description: tool.description,
|
||||
parameters: z.toJSONSchema(tool.inputSchema, { target: 'openapi-3.0' }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch model specific parameters
|
||||
*/
|
||||
export function modelPatch(body: Record<string, any>) {
|
||||
const model: string = body.model || ''
|
||||
if (!model) return body
|
||||
|
||||
const modelName = normalizeModelName(model)
|
||||
|
||||
if (modelName.startsWith('qwen')) {
|
||||
debug('Applying Qwen patch: use higher temperature for auto fixing')
|
||||
body.temperature = Math.max(body.temperature || 0, 1.0)
|
||||
}
|
||||
|
||||
if (modelName.startsWith('claude')) {
|
||||
debug('Applying Claude patch: disable thinking')
|
||||
body.thinking = { type: 'disabled' }
|
||||
|
||||
// Convert tool_choice to Claude format
|
||||
if (body.tool_choice === 'required') {
|
||||
// 'required' -> { type: 'any' } (must call some tool)
|
||||
debug('Applying Claude patch: convert tool_choice "required" to { type: "any" }')
|
||||
body.tool_choice = { type: 'any' }
|
||||
} else if (body.tool_choice?.function?.name) {
|
||||
// { type: 'function', function: { name: '...' } } -> { type: 'tool', name: '...' }
|
||||
debug('Applying Claude patch: convert tool_choice format')
|
||||
body.tool_choice = { type: 'tool', name: body.tool_choice.function.name }
|
||||
}
|
||||
}
|
||||
|
||||
if (modelName.startsWith('grok')) {
|
||||
debug('Applying Grok patch: removing tool_choice')
|
||||
delete body.tool_choice
|
||||
debug('Applying Grok patch: disable reasoning and thinking')
|
||||
body.thinking = { type: 'disabled', effort: 'minimal' }
|
||||
body.reasoning = { enabled: false, effort: 'low' }
|
||||
}
|
||||
|
||||
if (modelName.startsWith('gpt')) {
|
||||
debug('Applying GPT patch: set verbosity to low')
|
||||
body.verbosity = 'low'
|
||||
|
||||
if (modelName.startsWith('gpt-52')) {
|
||||
debug('Applying GPT-52 patch: disable reasoning')
|
||||
body.reasoning_effort = 'none'
|
||||
} else if (modelName.startsWith('gpt-51')) {
|
||||
debug('Applying GPT-51 patch: disable reasoning')
|
||||
body.reasoning_effort = 'none'
|
||||
} else if (modelName.startsWith('gpt-5')) {
|
||||
debug('Applying GPT-5 patch: set reasoning effort to low')
|
||||
body.reasoning_effort = 'low'
|
||||
}
|
||||
}
|
||||
|
||||
if (modelName.startsWith('gemini')) {
|
||||
debug('Applying Gemini patch: set reasoning effort to minimal')
|
||||
body.reasoning_effort = 'minimal'
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
/**
|
||||
* check if a given model ID fits a specific model name
|
||||
*
|
||||
* @note
|
||||
* Different model providers may use different model IDs for the same model.
|
||||
* For example, openai's `gpt-5.2` may called:
|
||||
*
|
||||
* - `gpt-5.2-version`
|
||||
* - `gpt-5_2-date`
|
||||
* - `GPT-52-version-date`
|
||||
* - `openai/gpt-5.2-chat`
|
||||
*
|
||||
* They should be treated as the same model.
|
||||
* Normalize them to `gpt-52`
|
||||
*/
|
||||
function normalizeModelName(modelName: string): string {
|
||||
let normalizedName = modelName.toLowerCase()
|
||||
|
||||
// remove prefix before '/'
|
||||
if (normalizedName.includes('/')) {
|
||||
normalizedName = normalizedName.split('/')[1]
|
||||
}
|
||||
|
||||
// remove '_'
|
||||
normalizedName = normalizedName.replace(/_/g, '')
|
||||
|
||||
// remove '.'
|
||||
normalizedName = normalizedName.replace(/\./g, '')
|
||||
|
||||
return normalizedName
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// @workaround DTS bug
|
||||
// dts do not work with monorepo path mapping
|
||||
// disable path mapping for it
|
||||
"paths": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||
"noEmit": false,
|
||||
"allowImportingTsExtensions": false,
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// @ts-check
|
||||
import chalk from 'chalk'
|
||||
import { dirname, resolve } from 'path'
|
||||
import dts from 'unplugin-dts/vite'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
console.log(chalk.cyan(`📦 Building @page-agent/llms`))
|
||||
|
||||
export default defineConfig({
|
||||
clearScreen: false,
|
||||
plugins: [dts({ tsconfigPath: './tsconfig.dts.json', bundleTypes: true })],
|
||||
publicDir: false,
|
||||
esbuild: {
|
||||
keepNames: true,
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/index.ts'),
|
||||
name: 'PageAgentLLMs',
|
||||
fileName: 'page-agent-llms',
|
||||
formats: ['es'],
|
||||
},
|
||||
outDir: resolve(__dirname, 'dist', 'lib'),
|
||||
rollupOptions: {
|
||||
external: ['chalk', 'zod'],
|
||||
},
|
||||
minify: false,
|
||||
sourcemap: true,
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
},
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "page-agent",
|
||||
"private": false,
|
||||
"version": "0.0.13",
|
||||
"version": "0.2.3",
|
||||
"type": "module",
|
||||
"main": "./dist/esm/page-agent.js",
|
||||
"module": "./dist/esm/page-agent.js",
|
||||
@@ -37,16 +37,16 @@
|
||||
},
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"scripts": {
|
||||
"build": "vite build && vite build --config vite.umd.config.js",
|
||||
"serve": "npx serve dist/umd -p 5173",
|
||||
"dev:umd": "concurrently \"vite build --config vite.umd.config.js --watch\" \"npm run serve\"",
|
||||
"build": "vite build",
|
||||
"dev:iife": "concurrently \"vite build --config vite.iife.config.js --watch\" \"npx serve dist/iife -p 5174\"",
|
||||
"prepublishOnly": "node -e \"const fs=require('fs');['README.md','LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
||||
"postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"zod": "^4.2.0",
|
||||
"@page-agent/page-controller": "0.0.13",
|
||||
"@page-agent/ui": "0.0.13"
|
||||
"zod": "^4.3.5",
|
||||
"@page-agent/llms": "0.2.3",
|
||||
"@page-agent/page-controller": "0.2.3",
|
||||
"@page-agent/ui": "0.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,24 +2,28 @@
|
||||
* Copyright (C) 2025 Alibaba Group Holding Limited
|
||||
* All rights reserved.
|
||||
*/
|
||||
import { LLM, type Tool } from '@page-agent/llms'
|
||||
import { PageController } from '@page-agent/page-controller'
|
||||
import { Panel, SimulatorMask } from '@page-agent/ui'
|
||||
import { Panel } from '@page-agent/ui'
|
||||
import chalk from 'chalk'
|
||||
import zod from 'zod'
|
||||
|
||||
import type { PageAgentConfig } from './config'
|
||||
import { MAX_STEPS } from './config/constants'
|
||||
import { LLM, type Tool } from './llms'
|
||||
import SYSTEM_PROMPT from './prompts/system_prompt.md?raw'
|
||||
import { tools } from './tools'
|
||||
import { trimLines, uid, waitUntil } from './utils'
|
||||
import { normalizeResponse, trimLines, uid } from './utils'
|
||||
import { assert } from './utils/assert'
|
||||
|
||||
export type { PageAgentConfig }
|
||||
export { tool, type PageAgentTool } from './tools'
|
||||
|
||||
export interface AgentBrain {
|
||||
// thinking?: string
|
||||
/**
|
||||
* 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
|
||||
@@ -27,11 +31,11 @@ export interface AgentBrain {
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
evaluation_previous_goal?: string
|
||||
memory?: string
|
||||
next_goal?: string
|
||||
export interface MacroToolInput extends Partial<AgentReflection> {
|
||||
action: Record<string, any>
|
||||
}
|
||||
|
||||
@@ -43,8 +47,15 @@ export interface MacroToolResult {
|
||||
output: string
|
||||
}
|
||||
|
||||
export interface AgentHistory {
|
||||
brain: AgentBrain
|
||||
export type { PageAgentConfig }
|
||||
export { tool, type PageAgentTool } from './tools'
|
||||
|
||||
/**
|
||||
* A single agent step with reflection and action
|
||||
*/
|
||||
export interface AgentStep {
|
||||
type: 'step'
|
||||
reflection: Partial<AgentReflection>
|
||||
action: {
|
||||
name: string
|
||||
input: any
|
||||
@@ -59,10 +70,30 @@ export interface AgentHistory {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent observation event (stays in memory)
|
||||
*/
|
||||
export interface ObservationEvent {
|
||||
type: 'observation'
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* User takeover event
|
||||
*/
|
||||
export interface UserTakeoverEvent {
|
||||
type: 'user_takeover'
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all history events
|
||||
*/
|
||||
export type HistoryEvent = AgentStep | ObservationEvent | UserTakeoverEvent
|
||||
|
||||
export interface ExecutionResult {
|
||||
success: boolean
|
||||
data: string
|
||||
history: AgentHistory[]
|
||||
history: HistoryEvent[]
|
||||
}
|
||||
|
||||
export class PageAgent extends EventTarget {
|
||||
@@ -70,13 +101,11 @@ export class PageAgent extends EventTarget {
|
||||
id = uid()
|
||||
panel: Panel
|
||||
tools: typeof tools
|
||||
paused = false
|
||||
disposed = false
|
||||
task = ''
|
||||
taskId = ''
|
||||
|
||||
#llm: LLM
|
||||
#totalWaitTime = 0
|
||||
#abortController = new AbortController()
|
||||
#llmRetryListener: ((e: Event) => void) | null = null
|
||||
#llmErrorListener: ((e: Event) => void) | null = null
|
||||
@@ -85,12 +114,18 @@ export class PageAgent extends EventTarget {
|
||||
/** PageController for DOM operations */
|
||||
pageController: PageController
|
||||
|
||||
/** Fullscreen mask */
|
||||
mask = new SimulatorMask()
|
||||
/** History records */
|
||||
history: AgentHistory[] = []
|
||||
/** Runtime states for tracking across steps */
|
||||
states = {
|
||||
/** Accumulated wait time in seconds, used by wait tool */
|
||||
totalWaitTime: 0,
|
||||
/** Last known URL for detecting navigation */
|
||||
lastURL: '',
|
||||
}
|
||||
|
||||
constructor(config: PageAgentConfig = {}) {
|
||||
/** History event stream */
|
||||
history: HistoryEvent[] = []
|
||||
|
||||
constructor(config: PageAgentConfig) {
|
||||
super()
|
||||
|
||||
this.config = config
|
||||
@@ -99,16 +134,14 @@ export class PageAgent extends EventTarget {
|
||||
language: this.config.language,
|
||||
onExecuteTask: (task) => this.execute(task),
|
||||
onStop: () => this.dispose(),
|
||||
onPauseToggle: () => {
|
||||
this.paused = !this.paused
|
||||
return this.paused
|
||||
},
|
||||
getPaused: () => this.paused,
|
||||
})
|
||||
this.tools = new Map(tools)
|
||||
|
||||
// Initialize PageController with config
|
||||
this.pageController = new PageController(this.config)
|
||||
// Initialize PageController with config (mask enabled by default)
|
||||
this.pageController = new PageController({
|
||||
...this.config,
|
||||
enableMask: this.config.enableMask ?? true,
|
||||
})
|
||||
|
||||
// Listen to LLM events
|
||||
this.#llmRetryListener = (e) => {
|
||||
@@ -143,8 +176,14 @@ export class PageAgent extends EventTarget {
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo maybe return something?
|
||||
* Push a persistent observation to the history event stream.
|
||||
* This will be visible in <agent_history> and remain in memory across steps.
|
||||
*/
|
||||
pushObservation(content: string): void {
|
||||
this.history.push({ type: 'observation', content })
|
||||
this.panel.update({ type: 'observation', content })
|
||||
}
|
||||
|
||||
async execute(task: string): Promise<ExecutionResult> {
|
||||
if (!task) throw new Error('Task is required')
|
||||
this.task = task
|
||||
@@ -158,7 +197,7 @@ export class PageAgent extends EventTarget {
|
||||
await onBeforeTask.call(this)
|
||||
|
||||
// Show mask and panel
|
||||
this.mask.show()
|
||||
this.pageController.showMask()
|
||||
|
||||
this.panel.show()
|
||||
this.panel.reset()
|
||||
@@ -172,18 +211,24 @@ export class PageAgent extends EventTarget {
|
||||
|
||||
this.history = []
|
||||
|
||||
// Reset states
|
||||
this.states = {
|
||||
totalWaitTime: 0,
|
||||
lastURL: '',
|
||||
}
|
||||
|
||||
try {
|
||||
let step = 0
|
||||
|
||||
while (true) {
|
||||
await this.#generateObservations(step)
|
||||
|
||||
await onBeforeStep.call(this, step)
|
||||
|
||||
console.group(`step: ${step}`)
|
||||
|
||||
// abort
|
||||
if (this.#abortController.signal.aborted) throw new Error('AbortError')
|
||||
// pause
|
||||
await waitUntil(() => !this.paused)
|
||||
|
||||
// Update status to thinking
|
||||
console.log(chalk.blue('Thinking...'))
|
||||
@@ -201,29 +246,34 @@ export class PageAgent extends EventTarget {
|
||||
},
|
||||
],
|
||||
{ AgentOutput: this.#packMacroTool() },
|
||||
this.#abortController.signal
|
||||
this.#abortController.signal,
|
||||
{
|
||||
toolChoiceName: 'AgentOutput',
|
||||
normalizeResponse,
|
||||
}
|
||||
)
|
||||
|
||||
const macroResult = result.toolResult as MacroToolResult
|
||||
const input = macroResult.input
|
||||
const output = macroResult.output
|
||||
const brain = {
|
||||
evaluation_previous_goal: input.evaluation_previous_goal || '',
|
||||
memory: input.memory || '',
|
||||
next_goal: input.next_goal || '',
|
||||
const reflection: Partial<AgentReflection> = {
|
||||
evaluation_previous_goal: input.evaluation_previous_goal,
|
||||
memory: input.memory,
|
||||
next_goal: input.next_goal,
|
||||
}
|
||||
const actionName = Object.keys(input.action)[0]
|
||||
const action = {
|
||||
const action: AgentStep['action'] = {
|
||||
name: actionName,
|
||||
input: input.action[actionName],
|
||||
output: output,
|
||||
}
|
||||
|
||||
this.history.push({
|
||||
brain,
|
||||
type: 'step',
|
||||
reflection,
|
||||
action,
|
||||
usage: result.usage,
|
||||
})
|
||||
} as AgentStep)
|
||||
|
||||
console.log(chalk.green('Step finished:'), actionName)
|
||||
console.groupEnd()
|
||||
@@ -281,9 +331,7 @@ export class PageAgent extends EventTarget {
|
||||
const tools = this.tools
|
||||
|
||||
const actionSchemas = Array.from(tools.entries()).map(([toolName, tool]) => {
|
||||
return zod.object({
|
||||
[toolName]: tool.inputSchema,
|
||||
})
|
||||
return zod.object({ [toolName]: tool.inputSchema }).describe(tool.description)
|
||||
})
|
||||
|
||||
const actionSchema = zod.union(
|
||||
@@ -299,25 +347,31 @@ export class PageAgent extends EventTarget {
|
||||
})
|
||||
|
||||
return {
|
||||
description: 'You MUST call this tool every step. Outputs your reflections and next action.',
|
||||
inputSchema: macroToolSchema as zod.ZodType<MacroToolInput>,
|
||||
execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
|
||||
// abort
|
||||
if (this.#abortController.signal.aborted) throw new Error('AbortError')
|
||||
// pause
|
||||
await waitUntil(() => !this.paused)
|
||||
|
||||
console.log(chalk.blue.bold('MacroTool execute'), input)
|
||||
const action = input.action
|
||||
|
||||
const toolName = Object.keys(action)[0]
|
||||
const toolInput = action[toolName]
|
||||
const brain = trimLines(`✅: ${input.evaluation_previous_goal}
|
||||
💾: ${input.memory}
|
||||
🎯: ${input.next_goal}
|
||||
`)
|
||||
|
||||
console.log(brain)
|
||||
this.panel.update({ type: 'thinking', text: brain })
|
||||
// Build reflection text, only include non-empty fields
|
||||
const reflectionLines: string[] = []
|
||||
if (input.evaluation_previous_goal)
|
||||
reflectionLines.push(`✅: ${input.evaluation_previous_goal}`)
|
||||
if (input.memory) reflectionLines.push(`💾: ${input.memory}`)
|
||||
if (input.next_goal) reflectionLines.push(`🎯: ${input.next_goal}`)
|
||||
|
||||
const reflectionText = reflectionLines.length > 0 ? reflectionLines.join('\n') : ''
|
||||
|
||||
if (reflectionText) {
|
||||
console.log(reflectionText)
|
||||
this.panel.update({ type: 'thinking', text: reflectionText })
|
||||
}
|
||||
|
||||
// Find the corresponding tool
|
||||
const tool = tools.get(toolName)
|
||||
@@ -329,20 +383,14 @@ export class PageAgent extends EventTarget {
|
||||
const startTime = Date.now()
|
||||
|
||||
// Execute tool, bind `this` to PageAgent
|
||||
let result = await tool.execute.bind(this)(toolInput)
|
||||
const result = await tool.execute.bind(this)(toolInput)
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result)
|
||||
|
||||
if (toolName === 'wait') {
|
||||
this.#totalWaitTime += Math.round(toolInput.seconds + duration / 1000)
|
||||
result += `\n<sys> You have waited ${this.#totalWaitTime} seconds accumulatively.`
|
||||
if (this.#totalWaitTime >= 3)
|
||||
result += '\nDo NOT wait any longer unless you have a good reason.\n'
|
||||
result += '</sys>'
|
||||
} else {
|
||||
// For other tools, reset wait time
|
||||
this.#totalWaitTime = 0
|
||||
// Reset wait time for non-wait tools
|
||||
if (toolName !== 'wait') {
|
||||
this.states.totalWaitTime = 0
|
||||
}
|
||||
|
||||
// Briefly display execution result
|
||||
@@ -381,42 +429,122 @@ export class PageAgent extends EventTarget {
|
||||
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> {
|
||||
let prompt = ''
|
||||
|
||||
// <agent_history>
|
||||
// - <step_>
|
||||
|
||||
prompt += '<agent_history>\n'
|
||||
|
||||
this.history.forEach((history, index) => {
|
||||
prompt += `<step_${index + 1}>
|
||||
Evaluation of Previous Step: ${history.brain.evaluation_previous_goal}
|
||||
Memory: ${history.brain.memory}
|
||||
Next Goal: ${history.brain.next_goal}
|
||||
Action Results: ${history.action.output}
|
||||
</step_${index + 1}>
|
||||
`
|
||||
})
|
||||
|
||||
prompt += '</agent_history>\n\n'
|
||||
// <instructions> (optional)
|
||||
prompt += await this.#getInstructions()
|
||||
|
||||
// <agent_state>
|
||||
// - <user_request>
|
||||
// - <step_info>
|
||||
// <agent_state>
|
||||
|
||||
const stepCount = this.history.filter((e) => e.type === 'step').length
|
||||
|
||||
prompt += `<agent_state>
|
||||
<user_request>
|
||||
${this.task}
|
||||
</user_request>
|
||||
<step_info>
|
||||
Step ${this.history.length + 1} of ${MAX_STEPS} max possible steps
|
||||
Step ${stepCount + 1} of ${MAX_STEPS} max possible steps
|
||||
Current date and time: ${new Date().toISOString()}
|
||||
</step_info>
|
||||
</agent_state>
|
||||
`
|
||||
|
||||
// <agent_history>
|
||||
// - <step_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>
|
||||
|
||||
prompt += await this.#getBrowserState()
|
||||
@@ -437,55 +565,28 @@ export class PageAgent extends EventTarget {
|
||||
// Task completed
|
||||
this.panel.update({ type: 'completed' })
|
||||
|
||||
this.mask.hide()
|
||||
this.pageController.hideMask()
|
||||
|
||||
this.#abortController.abort()
|
||||
}
|
||||
|
||||
async #getBrowserState(): Promise<string> {
|
||||
const pageUrl = await this.pageController.getCurrentUrl()
|
||||
const pageTitle = await this.pageController.getPageTitle()
|
||||
const pi = await this.pageController.getPageInfo()
|
||||
const viewportExpansion = await this.pageController.getViewportExpansion()
|
||||
const state = await this.pageController.getBrowserState()
|
||||
|
||||
this.mask.wrapper.style.pointerEvents = 'none'
|
||||
await this.pageController.updateTree()
|
||||
this.mask.wrapper.style.pointerEvents = 'auto'
|
||||
let content = state.content
|
||||
if (this.config.transformPageContent) {
|
||||
content = await this.config.transformPageContent(content)
|
||||
}
|
||||
|
||||
const simplifiedHTML = await this.pageController.getSimplifiedHTML()
|
||||
return trimLines(`<browser_state>
|
||||
Current Page: [${state.title}](${state.url})
|
||||
|
||||
let prompt = trimLines(`<browser_state>
|
||||
Current Page: [${pageTitle}](${pageUrl})
|
||||
|
||||
Page info: ${pi.viewport_width}x${pi.viewport_height}px viewport, ${pi.page_width}x${pi.page_height}px total page size, ${pi.pages_above.toFixed(1)} pages above, ${pi.pages_below.toFixed(1)} pages below, ${pi.total_pages.toFixed(1)} total pages, at ${(pi.current_page_position * 100).toFixed(0)}% of page
|
||||
|
||||
${viewportExpansion === -1 ? 'Interactive elements from top layer of the current page (full page):' : 'Interactive elements from top layer of the current page inside the viewport:'}
|
||||
${state.header}
|
||||
${content}
|
||||
${state.footer}
|
||||
|
||||
</browser_state>
|
||||
`)
|
||||
|
||||
// Page header info
|
||||
const has_content_above = pi.pixels_above > 4
|
||||
if (has_content_above && viewportExpansion !== -1) {
|
||||
prompt += `... ${pi.pixels_above} pixels above (${pi.pages_above.toFixed(1)} pages) - scroll to see more ...\n`
|
||||
} else {
|
||||
prompt += `[Start of page]\n`
|
||||
}
|
||||
|
||||
// Current viewport info
|
||||
prompt += simplifiedHTML
|
||||
prompt += `\n`
|
||||
|
||||
// Page footer info
|
||||
const has_content_below = pi.pixels_below > 4
|
||||
if (has_content_below && viewportExpansion !== -1) {
|
||||
prompt += `... ${pi.pixels_below} pixels below (${pi.pages_below.toFixed(1)} pages) - scroll to see more ...\n`
|
||||
} else {
|
||||
prompt += `[End of page]\n`
|
||||
}
|
||||
|
||||
prompt += `</browser_state>\n`
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
dispose(reason?: string) {
|
||||
@@ -493,7 +594,6 @@ export class PageAgent extends EventTarget {
|
||||
this.disposed = true
|
||||
this.pageController.dispose()
|
||||
this.panel.dispose()
|
||||
this.mask.dispose()
|
||||
this.history = []
|
||||
this.#abortController.abort(reason ?? 'PageAgent disposed')
|
||||
|
||||
|
||||
@@ -1,22 +1,2 @@
|
||||
// Dev environment: use .env config if available, otherwise fallback to testing api
|
||||
export const DEFAULT_MODEL_NAME: string =
|
||||
import.meta.env.DEV && import.meta.env.LLM_MODEL_NAME
|
||||
? import.meta.env.LLM_MODEL_NAME
|
||||
: 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
|
||||
export const DEFAULT_API_KEY: string =
|
||||
import.meta.env.DEV && import.meta.env.LLM_API_KEY
|
||||
? import.meta.env.LLM_API_KEY
|
||||
: 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
|
||||
export const DEFAULT_BASE_URL: string =
|
||||
import.meta.env.DEV && import.meta.env.LLM_BASE_URL
|
||||
? import.meta.env.LLM_BASE_URL
|
||||
: 'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
|
||||
|
||||
// internal
|
||||
|
||||
export const LLM_MAX_RETRIES = 2
|
||||
// Agent-specific constants (LLM constants moved to @page-agent/llms)
|
||||
export const MAX_STEPS = 20
|
||||
export const DEFAULT_TEMPERATURE = 0.7 // higher randomness helps auto-recovery
|
||||
export const DEFAULT_MAX_TOKENS = 4096
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
import type { LLMConfig } from '@page-agent/llms'
|
||||
import type { PageControllerConfig } from '@page-agent/page-controller'
|
||||
import type { SupportedLanguage } from '@page-agent/ui'
|
||||
|
||||
import type { AgentHistory, ExecutionResult, PageAgent } from '../PageAgent'
|
||||
import type { ExecutionResult, HistoryEvent, PageAgent } from '../PageAgent'
|
||||
import type { PageAgentTool } from '../tools'
|
||||
import {
|
||||
DEFAULT_API_KEY,
|
||||
DEFAULT_BASE_URL,
|
||||
DEFAULT_MAX_TOKENS,
|
||||
DEFAULT_MODEL_NAME,
|
||||
DEFAULT_TEMPERATURE,
|
||||
LLM_MAX_RETRIES,
|
||||
} from './constants'
|
||||
|
||||
export interface LLMConfig {
|
||||
baseURL?: string
|
||||
apiKey?: string
|
||||
model?: string
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
maxRetries?: number
|
||||
}
|
||||
export type { LLMConfig }
|
||||
|
||||
export interface AgentConfig {
|
||||
// theme?: 'light' | 'dark'
|
||||
@@ -56,17 +42,37 @@ export interface AgentConfig {
|
||||
*/
|
||||
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
|
||||
// @todo: use event instead of hooks
|
||||
// @todo: remove `this` binding, pass agent as explicit parameter instead
|
||||
|
||||
onBeforeStep?: (this: PageAgent, stepCnt: number) => Promise<void> | void
|
||||
onAfterStep?: (this: PageAgent, stepCnt: number, history: AgentHistory[]) => Promise<void> | void
|
||||
onAfterStep?: (this: PageAgent, stepCnt: number, history: HistoryEvent[]) => Promise<void> | void
|
||||
onBeforeTask?: (this: PageAgent) => Promise<void> | void
|
||||
onAfterTask?: (this: PageAgent, result: ExecutionResult) => Promise<void> | void
|
||||
|
||||
/**
|
||||
* @note this hook can block the disposal process
|
||||
* @note when dispose caused by page unload, reason will be 'PAGE_UNLOADING'. this method CANNOT block unloading. async operations may be cut.
|
||||
* @todo remove `this` binding, pass agent as explicit parameter instead
|
||||
*/
|
||||
onDispose?: (this: PageAgent, reason?: string) => void
|
||||
|
||||
@@ -80,30 +86,36 @@ export interface AgentConfig {
|
||||
*/
|
||||
experimentalScriptExecutionTool?: boolean
|
||||
|
||||
/**
|
||||
* Transform page content before sending to LLM.
|
||||
* Called after DOM extraction and simplification, before LLM invocation.
|
||||
* Use cases: inspect extraction results, modify page info, mask sensitive data.
|
||||
*
|
||||
* @param content - Simplified page content that will be sent to LLM
|
||||
* @returns Transformed content
|
||||
*
|
||||
* @example
|
||||
* // Mask phone numbers
|
||||
* transformPageContent: async (content) => {
|
||||
* return content.replace(/1[3-9]\d{9}/g, '***********')
|
||||
* }
|
||||
*/
|
||||
transformPageContent?: (content: string) => Promise<string> | string
|
||||
|
||||
/**
|
||||
* TODO: @unimplemented
|
||||
* hook when action causes a new page to be opened
|
||||
* @note PageAgent will try to detect new pages and decide if it's caused by an action. But not very reliable.
|
||||
* @todo remove `this` binding, pass agent as explicit parameter instead
|
||||
*/
|
||||
onNewPageOpen?: (this: PageAgent, url: string) => Promise<void> | void
|
||||
// onNewPageOpen?: (this: PageAgent, url: string) => Promise<void> | void
|
||||
|
||||
/**
|
||||
* TODO: @unimplemented
|
||||
* try to navigate to a new page instead of opening a new tab/window.
|
||||
* @note will unload the current page when a action tries to open a new page. so that things keep in the same tab/window.
|
||||
*/
|
||||
experimentalPreventNewPage?: boolean
|
||||
// experimentalPreventNewPage?: boolean
|
||||
}
|
||||
|
||||
export type PageAgentConfig = LLMConfig & AgentConfig & PageControllerConfig
|
||||
|
||||
export function parseLLMConfig(config: LLMConfig): Required<LLMConfig> {
|
||||
return {
|
||||
baseURL: config.baseURL ?? DEFAULT_BASE_URL,
|
||||
apiKey: config.apiKey ?? DEFAULT_API_KEY,
|
||||
model: config.model ?? DEFAULT_MODEL_NAME,
|
||||
temperature: config.temperature ?? DEFAULT_TEMPERATURE,
|
||||
maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
|
||||
maxRetries: config.maxRetries ?? LLM_MAX_RETRIES,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,16 @@ setTimeout(() => {
|
||||
window.pageAgent = new PageAgent(config)
|
||||
} else {
|
||||
console.log('🚀 page-agent.js no current script detected, using default demo config')
|
||||
window.pageAgent = new PageAgent()
|
||||
const config: PageAgentConfig = {
|
||||
// model: DEMO_MODEL,
|
||||
// baseURL: DEMO_BASE_URL,
|
||||
// apiKey: DEMO_API_KEY,
|
||||
|
||||
model: import.meta.env.LLM_MODEL_NAME ? import.meta.env.LLM_MODEL_NAME : DEMO_MODEL,
|
||||
baseURL: import.meta.env.LLM_BASE_URL ? import.meta.env.LLM_BASE_URL : DEMO_BASE_URL,
|
||||
apiKey: import.meta.env.LLM_API_KEY ? import.meta.env.LLM_API_KEY : DEMO_API_KEY,
|
||||
}
|
||||
window.pageAgent = new PageAgent(config)
|
||||
}
|
||||
|
||||
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* OpenAI Client implementation
|
||||
*/
|
||||
import { InvokeError, InvokeErrorType } from './errors'
|
||||
import type { InvokeResult, LLMClient, Message, OpenAIClientConfig, Tool } from './types'
|
||||
import { modelPatch, zodToOpenAITool } from './utils'
|
||||
|
||||
export class OpenAIClient implements LLMClient {
|
||||
config: OpenAIClientConfig
|
||||
|
||||
constructor(config: OpenAIClientConfig) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
async invoke(
|
||||
messages: Message[],
|
||||
tools: Record<string, Tool>,
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<InvokeResult> {
|
||||
// 1. Convert tools to OpenAI format
|
||||
const openaiTools = Object.entries(tools).map(([name, tool]) => zodToOpenAITool(name, tool))
|
||||
|
||||
// 2. Call API
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`${this.config.baseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(
|
||||
modelPatch({
|
||||
model: this.config.model,
|
||||
temperature: this.config.temperature,
|
||||
max_tokens: this.config.maxTokens,
|
||||
messages,
|
||||
|
||||
tools: openaiTools,
|
||||
// tool_choice: 'required',
|
||||
tool_choice: { type: 'function', function: { name: 'AgentOutput' } },
|
||||
|
||||
// model specific params
|
||||
|
||||
// reasoning_effort: 'minimal',
|
||||
// verbosity: 'low',
|
||||
parallel_tool_calls: false,
|
||||
})
|
||||
),
|
||||
signal: abortSignal,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
// Network error
|
||||
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, 'Network request failed', error)
|
||||
}
|
||||
|
||||
// 3. Handle HTTP errors
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch()
|
||||
const errorMessage =
|
||||
(errorData as { error?: { message?: string } }).error?.message || response.statusText
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.AUTH_ERROR,
|
||||
`Authentication failed: ${errorMessage}`,
|
||||
errorData
|
||||
)
|
||||
}
|
||||
if (response.status === 429) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.RATE_LIMIT,
|
||||
`Rate limit exceeded: ${errorMessage}`,
|
||||
errorData
|
||||
)
|
||||
}
|
||||
if (response.status >= 500) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.SERVER_ERROR,
|
||||
`Server error: ${errorMessage}`,
|
||||
errorData
|
||||
)
|
||||
}
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.UNKNOWN,
|
||||
`HTTP ${response.status}: ${errorMessage}`,
|
||||
errorData
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// 4. Check finish_reason
|
||||
const choice = data.choices?.[0]
|
||||
if (!choice) {
|
||||
throw new InvokeError(InvokeErrorType.UNKNOWN, 'No choices in response', data)
|
||||
}
|
||||
|
||||
switch (choice.finish_reason) {
|
||||
case 'tool_calls':
|
||||
// ✅ Normal
|
||||
break
|
||||
case 'length':
|
||||
// ⚠️ Token limit reached
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.CONTEXT_LENGTH,
|
||||
'Response truncated: max tokens reached',
|
||||
data
|
||||
)
|
||||
case 'content_filter':
|
||||
// ❌ Content filtered
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.CONTENT_FILTER,
|
||||
'Content filtered by safety system',
|
||||
data
|
||||
)
|
||||
case 'stop':
|
||||
// ❌ Did not call tool (we require tool call)
|
||||
throw new InvokeError(InvokeErrorType.NO_TOOL_CALL, 'Model did not call any tool', data)
|
||||
default:
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.UNKNOWN,
|
||||
`Unexpected finish_reason: ${choice.finish_reason}`,
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
// 5. Parse tool call
|
||||
const toolCall = choice.message?.tool_calls?.[0]
|
||||
if (!toolCall) {
|
||||
throw new InvokeError(InvokeErrorType.NO_TOOL_CALL, 'No tool call found in response', data)
|
||||
}
|
||||
|
||||
const toolName = toolCall.function.name
|
||||
const tool = tools[toolName]
|
||||
if (!tool) {
|
||||
throw new InvokeError(InvokeErrorType.UNKNOWN, `Tool ${toolName} not found`, data)
|
||||
}
|
||||
|
||||
// 6. Parse and validate arguments
|
||||
let toolArgs: unknown
|
||||
try {
|
||||
toolArgs = JSON.parse(toolCall.function.arguments)
|
||||
} catch (e) {
|
||||
throw new InvokeError(InvokeErrorType.INVALID_TOOL_ARGS, 'Invalid JSON in tool arguments', e)
|
||||
}
|
||||
|
||||
// Validate against zod schema
|
||||
const validation = tool.inputSchema.safeParse(toolArgs)
|
||||
if (!validation.success) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
`Tool arguments validation failed: ${validation.error.message}`,
|
||||
validation.error
|
||||
)
|
||||
}
|
||||
|
||||
// 7. Execute tool
|
||||
let toolResult: unknown
|
||||
try {
|
||||
toolResult = await tool.execute(validation.data)
|
||||
} catch (e) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.TOOL_EXECUTION_ERROR,
|
||||
`Tool execution failed: ${(e as Error).message}`,
|
||||
e
|
||||
)
|
||||
}
|
||||
|
||||
// 8. Return result (including cache tokens)
|
||||
return {
|
||||
toolCall: {
|
||||
// id: toolCall.id,
|
||||
name: toolName,
|
||||
args: validation.data as Record<string, unknown>,
|
||||
},
|
||||
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,128 +0,0 @@
|
||||
/**
|
||||
* OpenAI Client implementation
|
||||
*/
|
||||
import type { MacroToolInput } from '../PageAgent'
|
||||
import { InvokeError, InvokeErrorType } from './errors'
|
||||
import type { InvokeResult, LLMClient, Message, OpenAIClientConfig, Tool } from './types'
|
||||
import { lenientParseMacroToolCall, modelPatch, zodToOpenAITool } from './utils'
|
||||
|
||||
export class OpenAIClient implements LLMClient {
|
||||
config: OpenAIClientConfig
|
||||
|
||||
constructor(config: OpenAIClientConfig) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
async invoke(
|
||||
messages: Message[],
|
||||
tools: { AgentOutput: Tool<MacroToolInput> },
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<InvokeResult> {
|
||||
// 1. Convert tools to OpenAI format
|
||||
const openaiTools = Object.entries(tools).map(([name, tool]) => zodToOpenAITool(name, tool))
|
||||
|
||||
// 2. Call API
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`${this.config.baseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(
|
||||
modelPatch({
|
||||
model: this.config.model,
|
||||
temperature: this.config.temperature,
|
||||
max_tokens: this.config.maxTokens,
|
||||
messages,
|
||||
|
||||
tools: openaiTools,
|
||||
// tool_choice: 'required',
|
||||
tool_choice: { type: 'function', function: { name: 'AgentOutput' } },
|
||||
|
||||
// model specific params
|
||||
|
||||
// reasoning_effort: 'minimal',
|
||||
// verbosity: 'low',
|
||||
parallel_tool_calls: false,
|
||||
})
|
||||
),
|
||||
signal: abortSignal,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
// Network error
|
||||
throw new InvokeError(InvokeErrorType.NETWORK_ERROR, 'Network request failed', error)
|
||||
}
|
||||
|
||||
// 3. Handle HTTP errors
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch()
|
||||
const errorMessage =
|
||||
(errorData as { error?: { message?: string } }).error?.message || response.statusText
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.AUTH_ERROR,
|
||||
`Authentication failed: ${errorMessage}`,
|
||||
errorData
|
||||
)
|
||||
}
|
||||
if (response.status === 429) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.RATE_LIMIT,
|
||||
`Rate limit exceeded: ${errorMessage}`,
|
||||
errorData
|
||||
)
|
||||
}
|
||||
if (response.status >= 500) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.SERVER_ERROR,
|
||||
`Server error: ${errorMessage}`,
|
||||
errorData
|
||||
)
|
||||
}
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.UNKNOWN,
|
||||
`HTTP ${response.status}: ${errorMessage}`,
|
||||
errorData
|
||||
)
|
||||
}
|
||||
|
||||
// parse response
|
||||
|
||||
const data = await response.json()
|
||||
const tool = tools.AgentOutput
|
||||
const macroToolInput = lenientParseMacroToolCall(data, tool.inputSchema as any)
|
||||
|
||||
// Execute tool
|
||||
let toolResult: unknown
|
||||
try {
|
||||
toolResult = await tool.execute(macroToolInput)
|
||||
} catch (e) {
|
||||
throw new InvokeError(
|
||||
InvokeErrorType.TOOL_EXECUTION_ERROR,
|
||||
`Tool execution failed: ${(e as Error).message}`,
|
||||
e
|
||||
)
|
||||
}
|
||||
|
||||
// Return result (including cache tokens)
|
||||
return {
|
||||
toolCall: {
|
||||
// id: toolCall.id,
|
||||
name: 'AgentOutput',
|
||||
args: macroToolInput,
|
||||
},
|
||||
toolResult,
|
||||
usage: {
|
||||
promptTokens: data.usage?.prompt_tokens ?? 0,
|
||||
completionTokens: data.usage?.completion_tokens ?? 0,
|
||||
totalTokens: data.usage?.total_tokens ?? 0,
|
||||
cachedTokens: data.usage?.prompt_tokens_details?.cached_tokens,
|
||||
reasoningTokens: data.usage?.completion_tokens_details?.reasoning_tokens,
|
||||
},
|
||||
rawResponse: data,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
/**
|
||||
* @topic LLM 与主流程的隔离
|
||||
* @reasoning
|
||||
* 将 llm 的调用和主流程分开是复杂的,
|
||||
* 因为 agent 的 tool call 通常集成在 llm 模块中,而而先得到 llm 返回,然后处理工具调用
|
||||
* tools 和 llm 调用的逻辑不可避免地耦合在一起,tool 的执行又和主流程耦合在一起
|
||||
* 而 history 的维护和更新逻辑,又必须嵌入多轮 tool call 中
|
||||
* @reasoning
|
||||
* - 放弃框架提供的自动的多轮调用,每轮调用都由主流程发起
|
||||
* - 理想情况下,llm 调用应该获得 structured output,然后由额外的模块触发 tool call,目前模型和框架都无法实现
|
||||
* - 当前只能将 llm api 和 本地 tool call 耦合在一起,不关心其中的衔接方式
|
||||
* @conclusion
|
||||
* - @llm responsibility boundary:
|
||||
* - call llm api with given messages and tools
|
||||
* - invoke tool call and get the result of the tool
|
||||
* - return the result to main loop
|
||||
* - @main_loop responsibility boundary:
|
||||
* - maintain all behaviors of an **agent**
|
||||
* @conclusion
|
||||
* - 这里的 llm 模块不是 agent,只负责一轮 llm 调用和工具调用,无状态
|
||||
*/
|
||||
/**
|
||||
* @topic 结构化输出
|
||||
* @facts
|
||||
* - 几乎所有模型都支持 tool call schema
|
||||
* - 几乎所有模型都支持返回 json
|
||||
* - 只有 openAI/grok/gemini 支持 schema 并保证格式
|
||||
* - 主流模型都支持 tool_choice: required
|
||||
* - 除了 qwen 必须指定一个函数名 (9月上新后支持)
|
||||
* @conclusion
|
||||
* - 永远使用 tool call 来返回结构化数据,禁止模型直接返回(视为出错)
|
||||
* - 不能假设 tool 参数合法,必须有修复机制,而且修复也应该使用 tool call 返回
|
||||
*/
|
||||
import type { LLMConfig } from '../config'
|
||||
import { parseLLMConfig } from '../config'
|
||||
import { OpenAIClient } from './OpenAILenientClient'
|
||||
import { InvokeError } from './errors'
|
||||
import type { InvokeResult, LLMClient, Message, Tool } from './types'
|
||||
|
||||
export type { Message, Tool, InvokeResult, LLMClient }
|
||||
|
||||
export class LLM extends EventTarget {
|
||||
config: Required<LLMConfig>
|
||||
client: LLMClient
|
||||
|
||||
constructor(config: LLMConfig) {
|
||||
super()
|
||||
this.config = parseLLMConfig(config)
|
||||
|
||||
// Default to OpenAI client
|
||||
this.client = new OpenAIClient({
|
||||
model: this.config.model,
|
||||
apiKey: this.config.apiKey,
|
||||
baseURL: this.config.baseURL,
|
||||
temperature: this.config.temperature,
|
||||
maxTokens: this.config.maxTokens,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* - call llm api *once*
|
||||
* - invoke tool call *once*
|
||||
* - return the result of the tool
|
||||
*/
|
||||
async invoke(
|
||||
messages: Message[],
|
||||
tools: Record<string, Tool>,
|
||||
abortSignal: AbortSignal
|
||||
): Promise<InvokeResult> {
|
||||
return await withRetry(
|
||||
async () => {
|
||||
const result = await this.client.invoke(messages, tools, abortSignal)
|
||||
|
||||
return result
|
||||
},
|
||||
// retry settings
|
||||
{
|
||||
maxRetries: this.config.maxRetries,
|
||||
onRetry: (current: number) => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('retry', { detail: { current, max: this.config.maxRetries } })
|
||||
)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
this.dispatchEvent(new CustomEvent('error', { detail: { error } }))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
settings: {
|
||||
maxRetries: number
|
||||
onRetry: (retries: number) => void
|
||||
onError: (error: Error) => void
|
||||
}
|
||||
): Promise<T> {
|
||||
let retries = 0
|
||||
let lastError: Error | null = null
|
||||
while (retries <= settings.maxRetries) {
|
||||
if (retries > 0) {
|
||||
settings.onRetry(retries)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error: unknown) {
|
||||
console.error(error)
|
||||
settings.onError(error as Error)
|
||||
|
||||
// do not retry if aborted by user
|
||||
if ((error as { name?: string })?.name === 'AbortError') throw error
|
||||
|
||||
// do not retry if error is not retryable (InvokeError)
|
||||
if (error instanceof InvokeError && !error.retryable) throw error
|
||||
|
||||
lastError = error as Error
|
||||
retries++
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError!
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
/**
|
||||
* Utility functions for LLM integration
|
||||
*/
|
||||
import chalk from 'chalk'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { MacroToolInput } from '../PageAgent'
|
||||
import { InvokeError, InvokeErrorType } from './errors'
|
||||
import type { Tool } from './types'
|
||||
|
||||
/**
|
||||
* Convert Zod schema to OpenAI tool format
|
||||
* Uses Zod 4 native z.toJSONSchema()
|
||||
*/
|
||||
export function zodToOpenAITool(name: string, tool: Tool) {
|
||||
return {
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name,
|
||||
description: tool.description,
|
||||
parameters: z.toJSONSchema(tool.inputSchema, { target: 'openapi-3.0' }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function modelPatch(body: Record<string, any>) {
|
||||
const model: string = body.model || ''
|
||||
|
||||
if (model.toLowerCase().startsWith('claude')) {
|
||||
body.tool_choice = { type: 'tool', name: 'AgentOutput' }
|
||||
body.thinking = { type: 'disabled' }
|
||||
// body.reasoning = { enabled: 'disabled' }
|
||||
}
|
||||
|
||||
if (model.toLowerCase().includes('grok')) {
|
||||
console.log('Applying Grok patch: removing tool_choice')
|
||||
delete body.tool_choice
|
||||
console.log('Applying Grok patch: disable reasoning and thinking')
|
||||
body.thinking = { type: 'disabled', effort: 'minimal' }
|
||||
body.reasoning = { enabled: false, effort: 'low' }
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
@@ -57,6 +57,15 @@ tools.set(
|
||||
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000)
|
||||
console.log(`actualWaitTime: ${actualWaitTime} seconds`)
|
||||
await waitFor(actualWaitTime)
|
||||
|
||||
this.states.totalWaitTime += input.seconds
|
||||
|
||||
if (this.states.totalWaitTime >= 3) {
|
||||
this.pushObservation(
|
||||
`You have waited ${this.states.totalWaitTime} seconds accumulatively. Do NOT wait any longer unless you have a good reason.`
|
||||
)
|
||||
}
|
||||
|
||||
return `✅ Waited for ${input.seconds} seconds.`
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import chalk from 'chalk'
|
||||
|
||||
/**
|
||||
* Normalize LLM response and fix common format issues.
|
||||
*
|
||||
* Handles:
|
||||
* - No tool_calls but JSON in message.content (fallback)
|
||||
* - Model returns action name as tool call instead of AgentOutput
|
||||
* - Arguments wrapped as double JSON string
|
||||
* - Nested function call format
|
||||
* - Missing action field (fallback to wait)
|
||||
* - etc.
|
||||
*/
|
||||
export function normalizeResponse(response: any): any {
|
||||
let resolvedArguments = null as any
|
||||
|
||||
const choice = (response as { choices?: Choice[] }).choices?.[0]
|
||||
if (!choice) throw new Error('No choices in response')
|
||||
|
||||
const message = choice.message
|
||||
if (!message) throw new Error('No message in choice')
|
||||
|
||||
const toolCall = message.tool_calls?.[0]
|
||||
|
||||
// fix level and location of arguments
|
||||
|
||||
if (toolCall?.function?.arguments) {
|
||||
resolvedArguments = safeJsonParse(toolCall.function.arguments)
|
||||
|
||||
// case: sometimes the model only returns the action level
|
||||
if (toolCall.function.name && toolCall.function.name !== 'AgentOutput') {
|
||||
console.log(chalk.yellow(`[normalizeResponse] #1: fixing tool_call`))
|
||||
resolvedArguments = { action: safeJsonParse(resolvedArguments) }
|
||||
}
|
||||
} else {
|
||||
// case: sometimes the model returns json in content instead of tool_calls
|
||||
if (message.content) {
|
||||
const content = message.content.trim()
|
||||
const jsonInContent = retrieveJsonFromString(content)
|
||||
if (jsonInContent) {
|
||||
resolvedArguments = safeJsonParse(jsonInContent)
|
||||
|
||||
// case: sometimes the content json includes upper level wrapper
|
||||
if (resolvedArguments?.name === 'AgentOutput') {
|
||||
console.log(chalk.yellow(`[normalizeResponse] #2: fixing tool_call`))
|
||||
resolvedArguments = safeJsonParse(resolvedArguments.arguments)
|
||||
}
|
||||
|
||||
// case: sometimes even 2-levels of wrapping
|
||||
if (resolvedArguments?.type === 'function') {
|
||||
console.log(chalk.yellow(`[normalizeResponse] #3: fixing tool_call`))
|
||||
resolvedArguments = safeJsonParse(resolvedArguments.function.arguments)
|
||||
}
|
||||
|
||||
// case: and sometimes action level only
|
||||
// todo: needs better detection logic
|
||||
if (
|
||||
!resolvedArguments?.action &&
|
||||
!resolvedArguments?.evaluation_previous_goal &&
|
||||
!resolvedArguments?.memory &&
|
||||
!resolvedArguments?.next_goal &&
|
||||
!resolvedArguments?.thinking
|
||||
) {
|
||||
console.log(chalk.yellow(`[normalizeResponse] #4: fixing tool_call`))
|
||||
resolvedArguments = { action: safeJsonParse(resolvedArguments) }
|
||||
}
|
||||
} else {
|
||||
throw new Error('No tool_call and the message content does not contain valid JSON')
|
||||
}
|
||||
} else {
|
||||
throw new Error('No tool_call nor message content is present')
|
||||
}
|
||||
}
|
||||
|
||||
// fix double stringified arguments
|
||||
resolvedArguments = safeJsonParse(resolvedArguments)
|
||||
if (resolvedArguments.action) {
|
||||
resolvedArguments.action = safeJsonParse(resolvedArguments.action)
|
||||
}
|
||||
|
||||
// fix incomplete formats
|
||||
if (!resolvedArguments.action) {
|
||||
console.log(chalk.yellow(`[normalizeResponse] #5: fixing tool_call`))
|
||||
resolvedArguments.action = { name: 'wait', input: { seconds: 1 } }
|
||||
}
|
||||
|
||||
// pack back to standard format
|
||||
return {
|
||||
...response,
|
||||
choices: [
|
||||
{
|
||||
...choice,
|
||||
message: {
|
||||
...message,
|
||||
tool_calls: [
|
||||
{
|
||||
...(toolCall || {}),
|
||||
function: {
|
||||
...(toolCall?.function || {}),
|
||||
name: 'AgentOutput',
|
||||
arguments: JSON.stringify(resolvedArguments),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse JSON, return original input if not json.
|
||||
*/
|
||||
function safeJsonParse(input: any): any {
|
||||
if (typeof input === 'string') {
|
||||
try {
|
||||
return JSON.parse(input.trim())
|
||||
} catch {
|
||||
return input
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and parse JSON from a string.
|
||||
* - Treat content between the first `{` and the last `}` as JSON.
|
||||
* - Try to parse that content as JSON and return the parsed value (object/array/primitive) if successful, otherwise return null.
|
||||
*/
|
||||
function retrieveJsonFromString(str: string): any {
|
||||
try {
|
||||
const json = /({[\s\S]*})/.exec(str) ?? []
|
||||
if (json.length === 0) {
|
||||
return null
|
||||
}
|
||||
return JSON.parse(json[0]!)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface Choice {
|
||||
message?: {
|
||||
role?: 'assistant'
|
||||
content?: string
|
||||
tool_calls?: {
|
||||
id?: string
|
||||
type?: 'function'
|
||||
function?: {
|
||||
name?: string
|
||||
arguments?: string
|
||||
}
|
||||
}[]
|
||||
}
|
||||
index?: 0
|
||||
finish_reason?: 'tool_calls'
|
||||
}
|
||||
@@ -6,8 +6,6 @@ export interface PageAgentEventMap {
|
||||
// PageAgent status events
|
||||
// 'agent:execute': { params: { task: string } }
|
||||
// 'agent:done': { params: { text: string; success: boolean } }
|
||||
// 'agent:paused': { params: undefined }
|
||||
// 'agent:resumed': { params: undefined }
|
||||
// 'agent:disposed': { params: undefined }
|
||||
// 'agent:error': { params: { error: string | Error } }
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export { normalizeResponse } from './autoFixer'
|
||||
|
||||
/**
|
||||
* Wait until condition becomes true
|
||||
* @returns Returns when condition becomes true, throws otherwise
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"outDir": "dist",
|
||||
"paths": {
|
||||
//
|
||||
"@page-agent/llms": ["../llms/src/index.ts"],
|
||||
"@page-agent/page-controller": ["../page-controller/src/PageController.ts"],
|
||||
"@page-agent/ui": ["../ui/src/index.ts"]
|
||||
}
|
||||
@@ -16,6 +17,7 @@
|
||||
"exclude": ["dist", "node_modules"],
|
||||
"references": [
|
||||
//
|
||||
{ "path": "../llms" },
|
||||
{ "path": "../page-controller" },
|
||||
{ "path": "../ui" }
|
||||
]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @ts-check
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import { dirname, resolve } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig } from 'vite'
|
||||
@@ -6,11 +7,14 @@ import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// Load .env from repo root
|
||||
dotenvConfig({ path: resolve(__dirname, '../../.env') })
|
||||
|
||||
// UMD Bundle for CDN
|
||||
// - alias all local packages so that they can be build in
|
||||
// - no external
|
||||
// - no d.ts. dts does not work with monorepo aliasing
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => ({
|
||||
plugins: [cssInjectedByJsPlugin({ relativeCSSInjection: true })],
|
||||
publicDir: false,
|
||||
esbuild: {
|
||||
@@ -19,26 +23,34 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@page-agent/page-controller': resolve(__dirname, '../page-controller/src/PageController.ts'),
|
||||
'@page-agent/llms': resolve(__dirname, '../llms/src/index.ts'),
|
||||
'@page-agent/ui': resolve(__dirname, '../ui/src/index.ts'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/umd.ts'),
|
||||
entry: resolve(__dirname, 'src/iife.ts'),
|
||||
name: 'PageAgent',
|
||||
fileName: 'page-agent',
|
||||
formats: ['umd'],
|
||||
formats: ['iife'],
|
||||
},
|
||||
outDir: resolve(__dirname, 'dist', 'umd'),
|
||||
outDir: resolve(__dirname, 'dist', 'iife'),
|
||||
cssCodeSplit: true,
|
||||
minify: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// force use .js as extension
|
||||
entryFileNames: 'page-agent.js',
|
||||
},
|
||||
onwarn: function (message, handler) {
|
||||
if (message.code === 'EVAL') return
|
||||
handler(message)
|
||||
},
|
||||
},
|
||||
cssCodeSplit: true,
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
'import.meta.env.LLM_MODEL_NAME': JSON.stringify(process.env.LLM_MODEL_NAME),
|
||||
'import.meta.env.LLM_API_KEY': JSON.stringify(process.env.LLM_API_KEY),
|
||||
'import.meta.env.LLM_BASE_URL': JSON.stringify(process.env.LLM_BASE_URL),
|
||||
},
|
||||
})
|
||||
}))
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@page-agent/page-controller",
|
||||
"version": "0.0.13",
|
||||
"version": "0.2.3",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-controller.js",
|
||||
"module": "./dist/lib/page-controller.js",
|
||||
@@ -34,5 +34,8 @@
|
||||
"build": "vite build",
|
||||
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
||||
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||
},
|
||||
"dependencies": {
|
||||
"ai-motion": "^0.4.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,13 +18,32 @@ import { VIEWPORT_EXPANSION } from './constants'
|
||||
import * as dom from './dom'
|
||||
import type { FlatDomTree, InteractiveElementDomNode } from './dom/dom_tree/type'
|
||||
import { getPageInfo } from './dom/getPageInfo'
|
||||
import { SimulatorMask } from './mask/SimulatorMask'
|
||||
import { patchReact } from './patches/react'
|
||||
|
||||
export { SimulatorMask }
|
||||
|
||||
/**
|
||||
* Configuration for PageController
|
||||
*/
|
||||
export interface PageControllerConfig extends dom.DomConfig {
|
||||
viewportExpansion?: number
|
||||
/** Enable visual mask overlay during operations (default: false) */
|
||||
enableMask?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured browser state for LLM consumption
|
||||
*/
|
||||
export interface BrowserState {
|
||||
url: string
|
||||
title: string
|
||||
/** Page info + scroll position hint (e.g. "Page info: 1920x1080px...\n[Start of page]") */
|
||||
header: string
|
||||
/** Simplified HTML of interactive elements */
|
||||
content: string
|
||||
/** Page footer hint (e.g. "... 300 pixels below ..." or "[End of page]") */
|
||||
footer: string
|
||||
}
|
||||
|
||||
interface ActionResult {
|
||||
@@ -64,12 +83,19 @@ export class PageController extends EventTarget {
|
||||
/** last time the tree was updated */
|
||||
private lastTimeUpdate = 0
|
||||
|
||||
/** Visual mask overlay for blocking user interaction during automation */
|
||||
private mask: SimulatorMask | null = null
|
||||
|
||||
constructor(config: PageControllerConfig = {}) {
|
||||
super()
|
||||
|
||||
this.config = config
|
||||
|
||||
patchReact(this)
|
||||
|
||||
if (config.enableMask) {
|
||||
this.mask = new SimulatorMask()
|
||||
}
|
||||
}
|
||||
|
||||
// ======= State Queries =======
|
||||
@@ -81,42 +107,6 @@ export class PageController extends EventTarget {
|
||||
return window.location.href
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current page title
|
||||
*/
|
||||
async getPageTitle(): Promise<string> {
|
||||
return document.title
|
||||
}
|
||||
|
||||
/**
|
||||
* Get page scroll and size info
|
||||
*/
|
||||
async getPageInfo() {
|
||||
return getPageInfo()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the simplified HTML representation of the page.
|
||||
* This is used by LLM to understand the page structure.
|
||||
*/
|
||||
async getSimplifiedHTML(): Promise<string> {
|
||||
return this.simplifiedHTML
|
||||
}
|
||||
|
||||
/**
|
||||
* Get text description for an element by index
|
||||
*/
|
||||
async getElementText(index: number): Promise<string | undefined> {
|
||||
return this.elementTextMap.get(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total number of indexed interactive elements
|
||||
*/
|
||||
async getElementCount(): Promise<number> {
|
||||
return this.selectorMap.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last tree update timestamp
|
||||
*/
|
||||
@@ -125,10 +115,43 @@ export class PageController extends EventTarget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the viewport expansion setting
|
||||
* Get structured browser state for LLM consumption.
|
||||
* Automatically calls updateTree() to refresh the DOM state.
|
||||
*/
|
||||
async getViewportExpansion(): Promise<number> {
|
||||
return this.config.viewportExpansion ?? VIEWPORT_EXPANSION
|
||||
async getBrowserState(): Promise<BrowserState> {
|
||||
const url = window.location.href
|
||||
const title = document.title
|
||||
const pi = getPageInfo()
|
||||
const viewportExpansion = this.config.viewportExpansion ?? VIEWPORT_EXPANSION
|
||||
|
||||
await this.updateTree()
|
||||
|
||||
const content = this.simplifiedHTML
|
||||
|
||||
// Build header: page info + scroll position hint
|
||||
const pageInfoLine = `Page info: ${pi.viewport_width}x${pi.viewport_height}px viewport, ${pi.page_width}x${pi.page_height}px total page size, ${pi.pages_above.toFixed(1)} pages above, ${pi.pages_below.toFixed(1)} pages below, ${pi.total_pages.toFixed(1)} total pages, at ${(pi.current_page_position * 100).toFixed(0)}% of page`
|
||||
|
||||
const elementsLabel =
|
||||
viewportExpansion === -1
|
||||
? 'Interactive elements from top layer of the current page (full page):'
|
||||
: 'Interactive elements from top layer of the current page inside the viewport:'
|
||||
|
||||
const hasContentAbove = pi.pixels_above > 4
|
||||
const scrollHintAbove =
|
||||
hasContentAbove && viewportExpansion !== -1
|
||||
? `... ${pi.pixels_above} pixels above (${pi.pages_above.toFixed(1)} pages) - scroll to see more ...`
|
||||
: '[Start of page]'
|
||||
|
||||
const header = `${pageInfoLine}\n\n${elementsLabel}\n\n${scrollHintAbove}`
|
||||
|
||||
// Build footer: scroll position hint
|
||||
const hasContentBelow = pi.pixels_below > 4
|
||||
const footer =
|
||||
hasContentBelow && viewportExpansion !== -1
|
||||
? `... ${pi.pixels_below} pixels below (${pi.pages_below.toFixed(1)} pages) - scroll to see more ...`
|
||||
: '[End of page]'
|
||||
|
||||
return { url, title, header, content, footer }
|
||||
}
|
||||
|
||||
// ======= DOM Tree Operations =======
|
||||
@@ -136,12 +159,18 @@ export class PageController extends EventTarget {
|
||||
/**
|
||||
* Update DOM tree, returns simplified HTML for LLM.
|
||||
* This is the main method to refresh the page state.
|
||||
* Automatically bypasses mask during DOM extraction if enabled.
|
||||
*/
|
||||
async updateTree(): Promise<string> {
|
||||
this.dispatchEvent(new Event('beforeUpdate'))
|
||||
|
||||
this.lastTimeUpdate = Date.now()
|
||||
|
||||
// Temporarily bypass mask to allow DOM extraction
|
||||
if (this.mask) {
|
||||
this.mask.wrapper.style.pointerEvents = 'none'
|
||||
}
|
||||
|
||||
dom.cleanUpHighlights()
|
||||
|
||||
const blacklist = [
|
||||
@@ -162,6 +191,11 @@ export class PageController extends EventTarget {
|
||||
this.elementTextMap.clear()
|
||||
this.elementTextMap = dom.getElementTextMap(this.simplifiedHTML)
|
||||
|
||||
// Restore mask blocking
|
||||
if (this.mask) {
|
||||
this.mask.wrapper.style.pointerEvents = 'auto'
|
||||
}
|
||||
|
||||
this.dispatchEvent(new Event('afterUpdate'))
|
||||
|
||||
return this.simplifiedHTML
|
||||
@@ -326,6 +360,24 @@ export class PageController extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
// ======= Mask Operations =======
|
||||
|
||||
/**
|
||||
* Show the visual mask overlay.
|
||||
* Only works if enableMask was set to true in config.
|
||||
*/
|
||||
showMask(): void {
|
||||
this.mask?.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the visual mask overlay.
|
||||
* Only works if enableMask was set to true in config.
|
||||
*/
|
||||
hideMask(): void {
|
||||
this.mask?.hide()
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose and clean up resources
|
||||
*/
|
||||
@@ -335,5 +387,7 @@ export class PageController extends EventTarget {
|
||||
this.selectorMap.clear()
|
||||
this.elementTextMap.clear()
|
||||
this.simplifiedHTML = '<EMPTY>'
|
||||
this.mask?.dispose()
|
||||
this.mask = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,12 +287,52 @@ export async function scrollVertically(
|
||||
(document.documentElement as HTMLElement)
|
||||
|
||||
if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
|
||||
// Page-level scroll
|
||||
const scrollBefore = window.scrollY
|
||||
const scrollMax = document.documentElement.scrollHeight - window.innerHeight
|
||||
|
||||
window.scrollBy(0, dy)
|
||||
return `✅ Scrolled page by ${dy}px.`
|
||||
|
||||
const scrollAfter = window.scrollY
|
||||
const scrolled = scrollAfter - scrollBefore
|
||||
|
||||
if (Math.abs(scrolled) < 1) {
|
||||
return dy > 0
|
||||
? `⚠️ Already at the bottom of the page, cannot scroll down further.`
|
||||
: `⚠️ Already at the top of the page, cannot scroll up further.`
|
||||
}
|
||||
|
||||
const reachedBottom = dy > 0 && scrollAfter >= scrollMax - 1
|
||||
const reachedTop = dy < 0 && scrollAfter <= 1
|
||||
|
||||
if (reachedBottom) return `✅ Scrolled page by ${scrolled}px. Reached the bottom of the page.`
|
||||
if (reachedTop) return `✅ Scrolled page by ${scrolled}px. Reached the top of the page.`
|
||||
return `✅ Scrolled page by ${scrolled}px.`
|
||||
} else {
|
||||
// Container scroll
|
||||
const scrollBefore = el!.scrollTop
|
||||
const scrollMax = el!.scrollHeight - el!.clientHeight
|
||||
|
||||
el!.scrollBy({ top: dy, behavior: 'smooth' })
|
||||
await waitFor(0.1) // Animation playback
|
||||
return `✅ Scrolled container (${el!.tagName}) by ${dy}px.`
|
||||
await waitFor(0.1)
|
||||
|
||||
const scrollAfter = el!.scrollTop
|
||||
const scrolled = scrollAfter - scrollBefore
|
||||
|
||||
if (Math.abs(scrolled) < 1) {
|
||||
return dy > 0
|
||||
? `⚠️ Already at the bottom of container (${el!.tagName}), cannot scroll down further.`
|
||||
: `⚠️ Already at the top of container (${el!.tagName}), cannot scroll up further.`
|
||||
}
|
||||
|
||||
const reachedBottom = dy > 0 && scrollAfter >= scrollMax - 1
|
||||
const reachedTop = dy < 0 && scrollAfter <= 1
|
||||
|
||||
if (reachedBottom)
|
||||
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the bottom.`
|
||||
if (reachedTop)
|
||||
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the top.`
|
||||
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px.`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,11 +450,52 @@ export async function scrollHorizontally(
|
||||
(document.documentElement as HTMLElement)
|
||||
|
||||
if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
|
||||
// Page-level scroll
|
||||
const scrollBefore = window.scrollX
|
||||
const scrollMax = document.documentElement.scrollWidth - window.innerWidth
|
||||
|
||||
window.scrollBy(dx, 0)
|
||||
return `✅ Scrolled page horizontally by ${dx}px`
|
||||
|
||||
const scrollAfter = window.scrollX
|
||||
const scrolled = scrollAfter - scrollBefore
|
||||
|
||||
if (Math.abs(scrolled) < 1) {
|
||||
return dx > 0
|
||||
? `⚠️ Already at the right edge of the page, cannot scroll right further.`
|
||||
: `⚠️ Already at the left edge of the page, cannot scroll left further.`
|
||||
}
|
||||
|
||||
const reachedRight = dx > 0 && scrollAfter >= scrollMax - 1
|
||||
const reachedLeft = dx < 0 && scrollAfter <= 1
|
||||
|
||||
if (reachedRight)
|
||||
return `✅ Scrolled page by ${scrolled}px. Reached the right edge of the page.`
|
||||
if (reachedLeft) return `✅ Scrolled page by ${scrolled}px. Reached the left edge of the page.`
|
||||
return `✅ Scrolled page horizontally by ${scrolled}px.`
|
||||
} else {
|
||||
// Container scroll
|
||||
const scrollBefore = el!.scrollLeft
|
||||
const scrollMax = el!.scrollWidth - el!.clientWidth
|
||||
|
||||
el!.scrollBy({ left: dx, behavior: 'smooth' })
|
||||
await waitFor(0.1) // Animation playback
|
||||
return `✅ Scrolled container (${el!.tagName}) horizontally by ${dx}px`
|
||||
await waitFor(0.1)
|
||||
|
||||
const scrollAfter = el!.scrollLeft
|
||||
const scrolled = scrollAfter - scrollBefore
|
||||
|
||||
if (Math.abs(scrolled) < 1) {
|
||||
return dx > 0
|
||||
? `⚠️ Already at the right edge of container (${el!.tagName}), cannot scroll right further.`
|
||||
: `⚠️ Already at the left edge of container (${el!.tagName}), cannot scroll left further.`
|
||||
}
|
||||
|
||||
const reachedRight = dx > 0 && scrollAfter >= scrollMax - 1
|
||||
const reachedLeft = dx < 0 && scrollAfter <= 1
|
||||
|
||||
if (reachedRight)
|
||||
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the right edge.`
|
||||
if (reachedLeft)
|
||||
return `✅ Scrolled container (${el!.tagName}) by ${scrolled}px. Reached the left edge.`
|
||||
return `✅ Scrolled container (${el!.tagName}) horizontally by ${scrolled}px.`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* @edit scrollable element detection
|
||||
* @edit add `data-browser-use-ignore` attribute
|
||||
* @edit improve `sampleRect`, filter out rects with 0 area
|
||||
* @edit exclude aria-hidden elements
|
||||
*/
|
||||
|
||||
export default (
|
||||
@@ -1446,7 +1447,14 @@ export default (
|
||||
* @edit add `data-browser-use-ignore` attribute
|
||||
*/
|
||||
if (node.dataset?.browserUseIgnore === 'true') {
|
||||
return true // Skip this node and its children
|
||||
return null // Skip this node and its children
|
||||
}
|
||||
|
||||
/**
|
||||
* @edit exclude aria-hidden elements
|
||||
*/
|
||||
if (node.getAttribute && node.getAttribute('aria-hidden') === 'true') {
|
||||
return null // Skip this node and its children
|
||||
}
|
||||
|
||||
// Special handling for root node (body)
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
@@ -29,7 +29,11 @@ export default defineConfig({
|
||||
},
|
||||
outDir: resolve(__dirname, 'dist', 'lib'),
|
||||
rollupOptions: {
|
||||
external: ['@page-agent/*'],
|
||||
external: ['@page-agent/*', 'ai-motion'],
|
||||
onwarn: function (message, handler) {
|
||||
if (message.code === 'EVAL') return
|
||||
handler(message)
|
||||
},
|
||||
},
|
||||
minify: false,
|
||||
sourcemap: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@page-agent/ui",
|
||||
"version": "0.0.13",
|
||||
"version": "0.2.3",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-agent-ui.js",
|
||||
"module": "./dist/lib/page-agent-ui.js",
|
||||
@@ -15,7 +15,7 @@
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"description": "UI components for page-agent - Panel, SimulatorMask, and i18n",
|
||||
"description": "UI components for page-agent - Panel and i18n",
|
||||
"keywords": [
|
||||
"page-agent",
|
||||
"ui",
|
||||
@@ -34,8 +34,5 @@
|
||||
"build": "vite build",
|
||||
"prepublishOnly": "node -e \"const fs=require('fs');['LICENSE'].forEach(f=>fs.copyFileSync('../../'+f,f))\"",
|
||||
"postpublish": "node -e \"['LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||
},
|
||||
"dependencies": {
|
||||
"ai-motion": "^0.4.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,18 +209,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.pauseButton {
|
||||
font-weight: 600;
|
||||
&.paused {
|
||||
background: rgba(34, 197, 94, 0.2); /* 绿色背景表示可以继续 */
|
||||
color: rgb(34, 197, 94);
|
||||
|
||||
&:hover {
|
||||
background: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stopButton {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: rgb(255, 41, 41);
|
||||
@@ -364,6 +352,11 @@
|
||||
background: linear-gradient(135deg, rgba(255, 214, 0, 0.1), rgba(255, 214, 0, 0.05));
|
||||
}
|
||||
|
||||
&.observation {
|
||||
border-left-color: rgb(147, 51, 234);
|
||||
background: linear-gradient(135deg, rgba(147, 51, 234, 0.1), rgba(147, 51, 234, 0.05));
|
||||
}
|
||||
|
||||
/* 突出显示 done 成功结果 */
|
||||
&.doneSuccess {
|
||||
background: linear-gradient(
|
||||
|
||||
+12
-60
@@ -1,6 +1,6 @@
|
||||
import { type Step, UIState } from './UIState'
|
||||
import { I18n, type SupportedLanguage } from './i18n'
|
||||
import { truncate } from './utils'
|
||||
import { escapeHtml, truncate } from './utils'
|
||||
|
||||
import styles from './Panel.module.css'
|
||||
|
||||
@@ -11,8 +11,6 @@ export interface PanelConfig {
|
||||
language?: SupportedLanguage
|
||||
onExecuteTask: (task: string) => void
|
||||
onStop: () => void
|
||||
onPauseToggle: () => boolean // returns new paused state
|
||||
getPaused: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,6 +27,7 @@ export type PanelUpdate =
|
||||
| { type: 'completed' }
|
||||
| { type: 'toolExecuting'; toolName: string; args: any }
|
||||
| { type: 'toolCompleted'; toolName: string; args: any; result?: string; duration?: number }
|
||||
| { type: 'observation'; content: string }
|
||||
|
||||
/**
|
||||
* Agent control panel
|
||||
@@ -39,7 +38,6 @@ export class Panel {
|
||||
#statusText: HTMLElement
|
||||
#historySection: HTMLElement
|
||||
#expandButton: HTMLElement
|
||||
#pauseButton: HTMLElement
|
||||
#stopButton: HTMLElement
|
||||
#inputSection: HTMLElement
|
||||
#taskInput: HTMLInputElement
|
||||
@@ -66,7 +64,6 @@ export class Panel {
|
||||
this.#statusText = this.#wrapper.querySelector(`.${styles.statusText}`)!
|
||||
this.#historySection = this.#wrapper.querySelector(`.${styles.historySection}`)!
|
||||
this.#expandButton = this.#wrapper.querySelector(`.${styles.expandButton}`)!
|
||||
this.#pauseButton = this.#wrapper.querySelector(`.${styles.pauseButton}`)!
|
||||
this.#stopButton = this.#wrapper.querySelector(`.${styles.stopButton}`)!
|
||||
this.#inputSection = this.#wrapper.querySelector(`.${styles.inputSectionWrapper}`)!
|
||||
this.#taskInput = this.#wrapper.querySelector(`.${styles.taskInput}`)!
|
||||
@@ -75,6 +72,8 @@ export class Panel {
|
||||
this.#startHeaderUpdateLoop()
|
||||
|
||||
this.#showInputArea()
|
||||
|
||||
this.hide() // Start hidden
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,11 +119,6 @@ export class Panel {
|
||||
this.#updateStatusIndicator('thinking')
|
||||
this.#updateHistory()
|
||||
this.#collapse()
|
||||
// Reset pause state via callback
|
||||
if (this.#config.getPaused()) {
|
||||
this.#config.onPauseToggle()
|
||||
}
|
||||
this.#updatePauseButton()
|
||||
// Reset user input state
|
||||
this.#isWaitingForUserAnswer = false
|
||||
this.#userAnswerResolver = null
|
||||
@@ -205,6 +199,8 @@ export class Panel {
|
||||
duration: data.duration,
|
||||
}
|
||||
}
|
||||
case 'observation':
|
||||
return { type: 'observation', displayText: data.content }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,39 +273,6 @@ export class Panel {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle pause state
|
||||
*/
|
||||
#togglePause(): void {
|
||||
const paused = this.#config.onPauseToggle()
|
||||
this.#updatePauseButton()
|
||||
|
||||
// Update status display
|
||||
if (paused) {
|
||||
this.#statusText.textContent = this.#i18n.t('ui.panel.paused')
|
||||
this.#updateStatusIndicator('thinking')
|
||||
} else {
|
||||
this.#statusText.textContent = this.#i18n.t('ui.panel.continueExecution')
|
||||
this.#updateStatusIndicator('tool_executing')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update pause button state
|
||||
*/
|
||||
#updatePauseButton(): void {
|
||||
const paused = this.#config.getPaused()
|
||||
if (paused) {
|
||||
this.#pauseButton.textContent = '▶'
|
||||
this.#pauseButton.title = this.#i18n.t('ui.panel.continue')
|
||||
this.#pauseButton.classList.add(styles.paused)
|
||||
} else {
|
||||
this.#pauseButton.textContent = '⏸︎'
|
||||
this.#pauseButton.title = this.#i18n.t('ui.panel.pause')
|
||||
this.#pauseButton.classList.remove(styles.paused)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop Agent
|
||||
*/
|
||||
@@ -401,7 +364,7 @@ export class Panel {
|
||||
#createWrapper(): HTMLElement {
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.id = 'page-agent-runtime_agent-panel'
|
||||
wrapper.className = `${styles.wrapper} ${styles.collapsed}`
|
||||
wrapper.className = styles.wrapper
|
||||
wrapper.setAttribute('data-browser-use-ignore', 'true')
|
||||
|
||||
wrapper.innerHTML = `
|
||||
@@ -426,9 +389,6 @@ export class Panel {
|
||||
<button class="${styles.controlButton} ${styles.expandButton}" title="${this.#i18n.t('ui.panel.expand')}">
|
||||
▼
|
||||
</button>
|
||||
<button class="${styles.controlButton} ${styles.pauseButton}" title="${this.#i18n.t('ui.panel.pause')}">
|
||||
⏸︎
|
||||
</button>
|
||||
<button class="${styles.controlButton} ${styles.stopButton}" title="${this.#i18n.t('ui.panel.stop')}">
|
||||
X
|
||||
</button>
|
||||
@@ -466,12 +426,6 @@ export class Panel {
|
||||
this.#toggle()
|
||||
})
|
||||
|
||||
// Pause/continue button
|
||||
this.#pauseButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
this.#togglePause()
|
||||
})
|
||||
|
||||
// Stop button
|
||||
this.#stopButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
@@ -503,7 +457,6 @@ export class Panel {
|
||||
|
||||
#expand(): void {
|
||||
this.#isExpanded = true
|
||||
this.wrapper.classList.remove(styles.collapsed)
|
||||
this.wrapper.classList.add(styles.expanded)
|
||||
this.#expandButton.textContent = '▲'
|
||||
}
|
||||
@@ -511,7 +464,6 @@ export class Panel {
|
||||
#collapse(): void {
|
||||
this.#isExpanded = false
|
||||
this.wrapper.classList.remove(styles.expanded)
|
||||
this.wrapper.classList.add(styles.collapsed)
|
||||
this.#expandButton.textContent = '▼'
|
||||
}
|
||||
|
||||
@@ -590,10 +542,7 @@ export class Panel {
|
||||
|
||||
#updateHistory(): void {
|
||||
const steps = this.#state.getAllSteps()
|
||||
|
||||
this.#historySection.innerHTML = steps.map((step) => this.#createHistoryItem(step)).join('')
|
||||
|
||||
// Scroll to bottom to show latest records
|
||||
this.#scrollToBottom()
|
||||
}
|
||||
|
||||
@@ -635,7 +584,7 @@ export class Panel {
|
||||
typeClass = styles.error
|
||||
statusIcon = '❌'
|
||||
} else if (step.type === 'tool_executing') {
|
||||
statusIcon = '⚙️'
|
||||
statusIcon = '🔨'
|
||||
} else if (step.type === 'output') {
|
||||
typeClass = styles.output
|
||||
statusIcon = '🤖'
|
||||
@@ -645,6 +594,9 @@ export class Panel {
|
||||
} else if (step.type === 'retry') {
|
||||
typeClass = styles.retry
|
||||
statusIcon = '🔄'
|
||||
} else if (step.type === 'observation') {
|
||||
typeClass = styles.observation
|
||||
statusIcon = '👁️'
|
||||
} else {
|
||||
statusIcon = '🧠'
|
||||
}
|
||||
@@ -660,7 +612,7 @@ export class Panel {
|
||||
<div class="${styles.historyItem} ${typeClass}">
|
||||
<div class="${styles.historyContent}">
|
||||
<span class="${styles.statusIcon}">${statusIcon}</span>
|
||||
<span>${step.displayText}</span>
|
||||
<span>${escapeHtml(step.displayText)}</span>
|
||||
</div>
|
||||
<div class="${styles.historyMeta}">
|
||||
${stepLabel}
|
||||
|
||||
@@ -6,7 +6,15 @@ export interface Step {
|
||||
id: string
|
||||
stepNumber: number
|
||||
timestamp: Date
|
||||
type: 'thinking' | 'tool_executing' | 'completed' | 'error' | 'output' | 'input' | 'retry'
|
||||
type:
|
||||
| 'thinking'
|
||||
| 'tool_executing'
|
||||
| 'completed'
|
||||
| 'error'
|
||||
| 'output'
|
||||
| 'input'
|
||||
| 'retry'
|
||||
| 'observation'
|
||||
|
||||
// Tool execution related
|
||||
toolName?: string
|
||||
@@ -18,7 +26,7 @@ export interface Step {
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export type AgentStatus = 'idle' | 'running' | 'paused' | 'completed' | 'error'
|
||||
export type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
|
||||
|
||||
export class UIState {
|
||||
private steps: Step[] = []
|
||||
|
||||
@@ -12,7 +12,7 @@ export class I18n {
|
||||
|
||||
constructor(language: SupportedLanguage = 'en-US') {
|
||||
this.language = language in locales ? language : 'en-US'
|
||||
this.translations = locales[language]
|
||||
this.translations = locales[this.language]
|
||||
}
|
||||
|
||||
// 类型安全的翻译方法
|
||||
|
||||
@@ -4,17 +4,13 @@ const enUS = {
|
||||
panel: {
|
||||
ready: 'Ready',
|
||||
thinking: 'Thinking...',
|
||||
paused: 'Paused',
|
||||
taskInput: 'Enter new task, describe steps in detail, press Enter to submit',
|
||||
userAnswerPrompt: 'Please answer the question above, press Enter to submit',
|
||||
taskTerminated: 'Task terminated',
|
||||
taskCompleted: 'Task completed',
|
||||
continueExecution: 'Continue execution',
|
||||
userAnswer: 'User answer: {{input}}',
|
||||
question: 'Question: {{question}}',
|
||||
waitingPlaceholder: 'Waiting for task to start...',
|
||||
pause: 'Pause',
|
||||
continue: 'Continue',
|
||||
stop: 'Stop',
|
||||
expand: 'Expand history',
|
||||
collapse: 'Collapse history',
|
||||
@@ -54,17 +50,13 @@ const zhCN = {
|
||||
panel: {
|
||||
ready: '准备就绪',
|
||||
thinking: '正在思考...',
|
||||
paused: '暂停中,稍后',
|
||||
taskInput: '输入新任务,详细描述步骤,回车提交',
|
||||
userAnswerPrompt: '请回答上面问题,回车提交',
|
||||
taskTerminated: '任务已终止',
|
||||
taskCompleted: '任务结束',
|
||||
continueExecution: '继续执行',
|
||||
userAnswer: '用户回答: {{input}}',
|
||||
question: '询问: {{question}}',
|
||||
waitingPlaceholder: '等待任务开始...',
|
||||
pause: '暂停',
|
||||
continue: '继续',
|
||||
stop: '终止',
|
||||
expand: '展开历史',
|
||||
collapse: '收起历史',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { Panel, type PanelConfig, type PanelUpdate } from './Panel'
|
||||
export { SimulatorMask } from './SimulatorMask'
|
||||
export { UIState, type Step, type AgentStatus } from './UIState'
|
||||
export { I18n, type SupportedLanguage, type TranslationKey } from './i18n'
|
||||
|
||||
@@ -4,3 +4,15 @@ export function truncate(text: string, maxLength: number): string {
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters to prevent XSS and rendering issues
|
||||
*/
|
||||
export function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export default defineConfig({
|
||||
},
|
||||
outDir: resolve(__dirname, 'dist', 'lib'),
|
||||
rollupOptions: {
|
||||
external: ['ai-motion'],
|
||||
external: [],
|
||||
},
|
||||
minify: false,
|
||||
sourcemap: true,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# Website Package - Instructions for Coding Assistants
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **React** with TypeScript
|
||||
- **Vite** for dev server and build
|
||||
- **Tailwind CSS** for styling
|
||||
- **shadcn/ui** (new-york style) for UI components
|
||||
- **Magic UI** for animations and effects
|
||||
- **wouter** with hash routing for static hosting
|
||||
- **lucide-react** for icons
|
||||
|
||||
## Component Guidelines
|
||||
|
||||
### Use shadcn/ui Components First
|
||||
|
||||
**ALWAYS prefer shadcn/ui components over custom implementations.**
|
||||
|
||||
Before creating any UI component, check if shadcn already provides it:
|
||||
|
||||
```bash
|
||||
# IMPORTANT: Run from packages/website/, NOT from repo root
|
||||
cd packages/website
|
||||
|
||||
# Add a new shadcn component
|
||||
npx shadcn@latest add <component-name>
|
||||
|
||||
# Add a Magic UI component
|
||||
npx shadcn@latest add "@magicui/<component-name>"
|
||||
```
|
||||
|
||||
Available shadcn components: https://ui.shadcn.com/docs/components
|
||||
Available Magic UI components: https://magicui.design/docs/components
|
||||
|
||||
### Current UI Components
|
||||
|
||||
Located in `src/components/ui/`:
|
||||
|
||||
**From shadcn/ui:**
|
||||
|
||||
- `alert`, `badge`, `button`, `separator`, `sonner`, `switch`, `tooltip`
|
||||
|
||||
**From Magic UI:**
|
||||
|
||||
- `animated-gradient-text`, `animated-shiny-text`, `aurora-text`
|
||||
- `hyper-text`, `magic-card`, `neon-gradient-card`, `particles`
|
||||
- `sparkles-text`, `text-animate`, `typing-animation`
|
||||
|
||||
**Custom:**
|
||||
|
||||
- `highlighter`, `kbd`, `spinner`
|
||||
|
||||
### Styling Rules
|
||||
|
||||
1. **Prefer Tailwind classes** over custom CSS
|
||||
2. Use CSS modules only for complex component-specific styles
|
||||
3. Support dark mode via `dark:` classes
|
||||
4. Use CSS variables from `src/index.css` for theme colors
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── pages/
|
||||
│ ├── Home.tsx # Homepage
|
||||
│ └── docs/
|
||||
│ ├── Layout.tsx # Documentation sidebar
|
||||
│ └── [section]/[topic]/page.tsx
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui + Magic UI components
|
||||
│ ├── Header.tsx # Site header
|
||||
│ └── Footer.tsx # Site footer
|
||||
├── i18n/ # Internationalization
|
||||
├── router.tsx # Central routing
|
||||
└── main.tsx # App entry
|
||||
```
|
||||
|
||||
## Adding New Pages
|
||||
|
||||
### Documentation Page
|
||||
|
||||
1. Create `src/pages/docs/<section>/<slug>/page.tsx`
|
||||
2. Add route to `src/router.tsx` with `<Header /> + <DocsLayout>` wrapper
|
||||
3. Add navigation item to `pages/docs/Layout.tsx`
|
||||
|
||||
## Routing
|
||||
|
||||
Uses hash-based routing for static hosting:
|
||||
|
||||
```tsx
|
||||
import { Router } from 'wouter'
|
||||
import { useHashLocation } from 'wouter/use-hash-location'
|
||||
|
||||
;<Router hook={useHashLocation}>{/* routes */}</Router>
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------- | ----------------------- |
|
||||
| `components.json` | shadcn/ui configuration |
|
||||
| `vite.config.js` | Vite build settings |
|
||||
| `tsconfig.json` | TypeScript config |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm start # Dev server (from root)
|
||||
npm run build:website # Build website (from root)
|
||||
```
|
||||
@@ -1 +0,0 @@
|
||||
# Landing Page & Docs
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {
|
||||
"@magicui": "https://magicui.design/r/{name}.json"
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,42 @@
|
||||
{
|
||||
"name": "@page-agent/website",
|
||||
"private": true,
|
||||
"version": "0.0.13",
|
||||
"version": "0.2.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build:website": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||
"i18next": "^25.7.3",
|
||||
"i18next": "^25.7.4",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-i18next": "^16.5.0",
|
||||
"react-i18next": "^16.5.2",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"wouter": "^3.8.1"
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"wouter": "^3.9.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.26.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"rough-notation": "^0.5.1",
|
||||
"simple-icons": "^16.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { siGithub } from 'simple-icons'
|
||||
|
||||
export default function Footer() {
|
||||
const { t } = useTranslation('common')
|
||||
@@ -19,8 +20,13 @@ export default function Footer() {
|
||||
className="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors duration-200"
|
||||
aria-label={t('footer.github_label')}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-5 h-5 fill-current"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={siGithub.path} />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { BookOpen, Menu, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { siGithub } from 'simple-icons'
|
||||
import { Link } from 'wouter'
|
||||
|
||||
import LanguageSwitcher from './LanguageSwitcher'
|
||||
import ThemeSwitcher from './ThemeSwitcher'
|
||||
import { BookIcon, CloseIcon, GithubIcon, MenuIcon } from './icons'
|
||||
import { HyperText } from './ui/hyper-text'
|
||||
|
||||
export default function Header() {
|
||||
const { t } = useTranslation('common')
|
||||
@@ -20,7 +22,7 @@ export default function Header() {
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href="/"
|
||||
href="~/"
|
||||
className="flex items-center gap-2 sm:gap-3 group shrink-0"
|
||||
aria-label={t('header.logo_alt')}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
@@ -34,9 +36,15 @@ export default function Header() {
|
||||
<span className="text-base sm:text-xl font-bold text-gray-900 dark:text-white block leading-tight">
|
||||
page-agent
|
||||
</span>
|
||||
<p className="hidden sm:block text-xs text-gray-600 dark:text-gray-300">
|
||||
<HyperText
|
||||
as="p"
|
||||
className="hidden sm:block text-xs text-gray-600 dark:text-gray-300 py-0 font-normal overflow-visible"
|
||||
duration={600}
|
||||
animateOnHover={true}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{t('header.slogan')}
|
||||
</p>
|
||||
</HyperText>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -47,11 +55,11 @@ export default function Header() {
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
<Link
|
||||
href="/docs/introduction/overview"
|
||||
href="~/docs/introduction/overview"
|
||||
className="p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 shrink-0"
|
||||
aria-label={t('header.nav_docs')}
|
||||
>
|
||||
<BookIcon className="w-5 h-5" />
|
||||
<BookOpen className="w-5 h-5" />
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent"
|
||||
@@ -60,7 +68,14 @@ export default function Header() {
|
||||
className="p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 shrink-0"
|
||||
aria-label={t('header.nav_source')}
|
||||
>
|
||||
<GithubIcon className="w-5 h-5" />
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-5 h-5 fill-current"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={siGithub.path} />
|
||||
</svg>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
@@ -70,11 +85,14 @@ export default function Header() {
|
||||
role="navigation"
|
||||
aria-label={t('header.nav_docs')}
|
||||
>
|
||||
<span className="text-xs font-mono text-gray-400 dark:text-gray-500 tabular-nums before:content-['v']">
|
||||
{import.meta.env.VERSION}
|
||||
</span>
|
||||
<Link
|
||||
href="/docs/introduction/overview"
|
||||
href="~/docs/introduction/overview"
|
||||
className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
||||
>
|
||||
<BookIcon />
|
||||
<BookOpen className="w-4 h-4" />
|
||||
{t('header.nav_docs')}
|
||||
</Link>
|
||||
<a
|
||||
@@ -84,7 +102,14 @@ export default function Header() {
|
||||
className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
||||
aria-label={t('header.nav_source')}
|
||||
>
|
||||
<GithubIcon />
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-4 h-4 fill-current"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={siGithub.path} />
|
||||
</svg>
|
||||
{t('header.nav_source')}
|
||||
</a>
|
||||
<ThemeSwitcher />
|
||||
@@ -100,7 +125,7 @@ export default function Header() {
|
||||
aria-controls="mobile-menu"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
|
||||
{mobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -112,11 +137,11 @@ export default function Header() {
|
||||
role="navigation"
|
||||
>
|
||||
<Link
|
||||
href="/docs/introduction/overview"
|
||||
href="~/docs/introduction/overview"
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<BookIcon className="w-5 h-5" />
|
||||
<BookOpen className="w-5 h-5" />
|
||||
{t('header.nav_docs')}
|
||||
</Link>
|
||||
<a
|
||||
@@ -126,7 +151,14 @@ export default function Header() {
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
||||
aria-label={t('header.nav_source')}
|
||||
>
|
||||
<GithubIcon className="w-5 h-5" />
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-5 h-5 fill-current"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={siGithub.path} />
|
||||
</svg>
|
||||
{t('header.nav_source')}
|
||||
</a>
|
||||
<div className="flex items-center gap-3 px-3 py-2">
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
// SVG图标组件集合,用于Header等地方复用
|
||||
|
||||
interface IconProps {
|
||||
className?: string
|
||||
'aria-hidden'?: boolean
|
||||
}
|
||||
|
||||
export function BookIcon({ className = 'w-4 h-4', ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GithubIcon({ className = 'w-4 h-4', ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function MenuIcon({ className = 'w-6 h-6', ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function CloseIcon({ className = 'w-6 h-6', ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-card text-card-foreground',
|
||||
destructive:
|
||||
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface AnimatedGradientTextProps extends ComponentPropsWithoutRef<'div'> {
|
||||
speed?: number
|
||||
colorFrom?: string
|
||||
colorTo?: string
|
||||
}
|
||||
|
||||
export function AnimatedGradientText({
|
||||
children,
|
||||
className,
|
||||
speed = 1,
|
||||
colorFrom = '#ffaa40',
|
||||
colorTo = '#9c40ff',
|
||||
...props
|
||||
}: AnimatedGradientTextProps) {
|
||||
return (
|
||||
<span
|
||||
style={
|
||||
{
|
||||
'--bg-size': `${speed * 300}%`,
|
||||
'--color-from': colorFrom,
|
||||
'--color-to': colorTo,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
`animate-gradient inline bg-gradient-to-r from-[var(--color-from)] via-[var(--color-to)] to-[var(--color-from)] bg-[length:var(--bg-size)_100%] bg-clip-text text-transparent`,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { CSSProperties, ComponentPropsWithoutRef, FC } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface AnimatedShinyTextProps extends ComponentPropsWithoutRef<'span'> {
|
||||
shimmerWidth?: number
|
||||
}
|
||||
|
||||
export const AnimatedShinyText: FC<AnimatedShinyTextProps> = ({
|
||||
children,
|
||||
className,
|
||||
shimmerWidth = 100,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<span
|
||||
style={
|
||||
{
|
||||
'--shiny-width': `${shimmerWidth}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
'mx-auto max-w-md text-neutral-600/70 dark:text-neutral-400/70',
|
||||
|
||||
// Shine effect
|
||||
'animate-shiny-text [background-size:var(--shiny-width)_100%] bg-clip-text [background-position:0_0] bg-no-repeat [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]',
|
||||
|
||||
// Shine gradient
|
||||
'bg-gradient-to-r from-transparent via-black/80 via-50% to-transparent dark:via-white/80',
|
||||
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React, { memo } from 'react'
|
||||
|
||||
interface AuroraTextProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
colors?: string[]
|
||||
speed?: number
|
||||
}
|
||||
|
||||
export const AuroraText = memo(
|
||||
({
|
||||
children,
|
||||
className = '',
|
||||
colors = ['#FF0080', '#7928CA', '#0070F3', '#38bdf8'],
|
||||
speed = 1,
|
||||
}: AuroraTextProps) => {
|
||||
const gradientStyle = {
|
||||
backgroundImage: `linear-gradient(135deg, ${colors.join(', ')}, ${colors[0]})`,
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
animationDuration: `${10 / speed}s`,
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`relative inline-block ${className}`}>
|
||||
<span className="sr-only">{children}</span>
|
||||
<span
|
||||
className="animate-aurora relative bg-[length:200%_auto] bg-clip-text text-transparent"
|
||||
style={gradientStyle}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
AuroraText.displayName = 'AuroraText'
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'span'
|
||||
|
||||
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ArrowRightIcon } from '@radix-ui/react-icons'
|
||||
import { ComponentPropsWithoutRef, ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface BentoGridProps extends ComponentPropsWithoutRef<'div'> {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface BentoCardProps extends ComponentPropsWithoutRef<'div'> {
|
||||
name: string
|
||||
className: string
|
||||
background: ReactNode
|
||||
Icon: React.ElementType
|
||||
description: string
|
||||
href: string
|
||||
cta: string
|
||||
}
|
||||
|
||||
const BentoGrid = ({ children, className, ...props }: BentoGridProps) => {
|
||||
return (
|
||||
<div className={cn('grid w-full auto-rows-[22rem] grid-cols-3 gap-4', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const BentoCard = ({
|
||||
name,
|
||||
className,
|
||||
background,
|
||||
Icon,
|
||||
description,
|
||||
href,
|
||||
cta,
|
||||
...props
|
||||
}: BentoCardProps) => (
|
||||
<div
|
||||
key={name}
|
||||
className={cn(
|
||||
'group relative col-span-3 flex flex-col justify-between overflow-hidden rounded-xl',
|
||||
// light styles
|
||||
'bg-background [box-shadow:0_0_0_1px_rgba(0,0,0,.03),0_2px_4px_rgba(0,0,0,.05),0_12px_24px_rgba(0,0,0,.05)]',
|
||||
// dark styles
|
||||
'dark:bg-background transform-gpu dark:[box-shadow:0_-20px_80px_-20px_#ffffff1f_inset] dark:[border:1px_solid_rgba(255,255,255,.1)]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div>{background}</div>
|
||||
<div className="p-4">
|
||||
<div className="pointer-events-none z-10 flex transform-gpu flex-col gap-1 transition-all duration-300 lg:group-hover:-translate-y-10">
|
||||
<Icon className="h-12 w-12 origin-left transform-gpu text-neutral-700 transition-all duration-300 ease-in-out group-hover:scale-75" />
|
||||
<h3 className="text-xl font-semibold text-neutral-700 dark:text-neutral-300">{name}</h3>
|
||||
<p className="max-w-lg text-neutral-400">{description}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none flex w-full translate-y-0 transform-gpu flex-row items-center transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100 lg:hidden'
|
||||
)}
|
||||
>
|
||||
<Button variant="link" asChild size="sm" className="pointer-events-auto p-0">
|
||||
<a href={href}>
|
||||
{cta}
|
||||
<ArrowRightIcon className="ms-2 h-4 w-4 rtl:rotate-180" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute bottom-0 hidden w-full translate-y-10 transform-gpu flex-row items-center p-4 opacity-0 transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100 lg:flex'
|
||||
)}
|
||||
>
|
||||
<Button variant="link" asChild size="sm" className="pointer-events-auto p-0">
|
||||
<a href={href}>
|
||||
{cta}
|
||||
<ArrowRightIcon className="ms-2 h-4 w-4 rtl:rotate-180" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 transform-gpu transition-all duration-300 group-hover:bg-black/[.03] group-hover:dark:bg-neutral-800/10" />
|
||||
</div>
|
||||
)
|
||||
|
||||
export { BentoCard, BentoGrid }
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
AnimatePresence,
|
||||
MotionProps,
|
||||
UseInViewOptions,
|
||||
Variants,
|
||||
motion,
|
||||
useInView,
|
||||
} from 'motion/react'
|
||||
import { useRef } from 'react'
|
||||
|
||||
type MarginType = UseInViewOptions['margin']
|
||||
|
||||
interface BlurFadeProps extends MotionProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
variant?: {
|
||||
hidden: { y: number }
|
||||
visible: { y: number }
|
||||
}
|
||||
duration?: number
|
||||
delay?: number
|
||||
offset?: number
|
||||
direction?: 'up' | 'down' | 'left' | 'right'
|
||||
inView?: boolean
|
||||
inViewMargin?: MarginType
|
||||
blur?: string
|
||||
}
|
||||
|
||||
export function BlurFade({
|
||||
children,
|
||||
className,
|
||||
variant,
|
||||
duration = 0.4,
|
||||
delay = 0,
|
||||
offset = 6,
|
||||
direction = 'down',
|
||||
inView = false,
|
||||
inViewMargin = '-50px',
|
||||
blur = '6px',
|
||||
...props
|
||||
}: BlurFadeProps) {
|
||||
const ref = useRef(null)
|
||||
const inViewResult = useInView(ref, { once: true, margin: inViewMargin })
|
||||
const isInView = !inView || inViewResult
|
||||
const defaultVariants: Variants = {
|
||||
hidden: {
|
||||
[direction === 'left' || direction === 'right' ? 'x' : 'y']:
|
||||
direction === 'right' || direction === 'down' ? -offset : offset,
|
||||
opacity: 0,
|
||||
filter: `blur(${blur})`,
|
||||
},
|
||||
visible: {
|
||||
[direction === 'left' || direction === 'right' ? 'x' : 'y']: 0,
|
||||
opacity: 1,
|
||||
filter: `blur(0px)`,
|
||||
},
|
||||
}
|
||||
const combinedVariants = variant || defaultVariants
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
exit="hidden"
|
||||
variants={combinedVariants}
|
||||
transition={{
|
||||
delay: 0.04 + delay,
|
||||
duration,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useInView } from 'motion/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type React from 'react'
|
||||
import { annotate } from 'rough-notation'
|
||||
import { type RoughAnnotation } from 'rough-notation/lib/model'
|
||||
|
||||
type AnnotationAction =
|
||||
| 'highlight'
|
||||
| 'underline'
|
||||
| 'box'
|
||||
| 'circle'
|
||||
| 'strike-through'
|
||||
| 'crossed-off'
|
||||
| 'bracket'
|
||||
|
||||
interface HighlighterProps {
|
||||
children: React.ReactNode
|
||||
action?: AnnotationAction
|
||||
color?: string
|
||||
strokeWidth?: number
|
||||
animationDuration?: number
|
||||
iterations?: number
|
||||
padding?: number
|
||||
multiline?: boolean
|
||||
isView?: boolean
|
||||
}
|
||||
|
||||
export function Highlighter({
|
||||
children,
|
||||
action = 'highlight',
|
||||
color = '#ffd1dc',
|
||||
strokeWidth = 1.5,
|
||||
animationDuration = 600,
|
||||
iterations = 2,
|
||||
padding = 2,
|
||||
multiline = true,
|
||||
isView = false,
|
||||
}: HighlighterProps) {
|
||||
const elementRef = useRef<HTMLSpanElement>(null)
|
||||
const annotationRef = useRef<RoughAnnotation | null>(null)
|
||||
|
||||
const isInView = useInView(elementRef, {
|
||||
once: true,
|
||||
margin: '-10%',
|
||||
})
|
||||
|
||||
// If isView is false, always show. If isView is true, wait for inView
|
||||
const shouldShow = !isView || isInView
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShow) return
|
||||
|
||||
const element = elementRef.current
|
||||
if (!element) return
|
||||
|
||||
const annotationConfig = {
|
||||
type: action,
|
||||
color,
|
||||
strokeWidth,
|
||||
animationDuration,
|
||||
iterations,
|
||||
padding,
|
||||
multiline,
|
||||
}
|
||||
|
||||
const annotation = annotate(element, annotationConfig)
|
||||
|
||||
annotationRef.current = annotation
|
||||
annotationRef.current.show()
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
annotation.hide()
|
||||
annotation.show()
|
||||
})
|
||||
|
||||
resizeObserver.observe(element)
|
||||
resizeObserver.observe(document.body)
|
||||
|
||||
return () => {
|
||||
if (element) {
|
||||
annotate(element, { type: action }).remove()
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}
|
||||
}, [shouldShow, action, color, strokeWidth, animationDuration, iterations, padding, multiline])
|
||||
|
||||
return (
|
||||
<span ref={elementRef} className="relative inline-block bg-transparent">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { AnimatePresence, MotionProps, motion } from 'motion/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type CharacterSet = string[] | readonly string[]
|
||||
|
||||
interface HyperTextProps extends MotionProps {
|
||||
/** The text content to be animated */
|
||||
children: string
|
||||
/** Optional className for styling */
|
||||
className?: string
|
||||
/** Duration of the animation in milliseconds */
|
||||
duration?: number
|
||||
/** Delay before animation starts in milliseconds */
|
||||
delay?: number
|
||||
/** Component to render as - defaults to div */
|
||||
as?: React.ElementType
|
||||
/** Whether to start animation when element comes into view */
|
||||
startOnView?: boolean
|
||||
/** Whether to trigger animation on hover */
|
||||
animateOnHover?: boolean
|
||||
/** Custom character set for scramble effect. Defaults to uppercase alphabet */
|
||||
characterSet?: CharacterSet
|
||||
}
|
||||
|
||||
const DEFAULT_CHARACTER_SET = Object.freeze(
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
||||
) as readonly string[]
|
||||
|
||||
const getRandomInt = (max: number): number => Math.floor(Math.random() * max)
|
||||
|
||||
export function HyperText({
|
||||
children,
|
||||
className,
|
||||
duration = 800,
|
||||
delay = 0,
|
||||
as: Component = 'div',
|
||||
startOnView = false,
|
||||
animateOnHover = true,
|
||||
characterSet = DEFAULT_CHARACTER_SET,
|
||||
...props
|
||||
}: HyperTextProps) {
|
||||
const MotionComponent = motion.create(Component, {
|
||||
forwardMotionProps: true,
|
||||
})
|
||||
|
||||
const [displayText, setDisplayText] = useState<string[]>(() => children.split(''))
|
||||
const [isAnimating, setIsAnimating] = useState(false)
|
||||
const iterationCount = useRef(0)
|
||||
const elementRef = useRef<HTMLElement>(null)
|
||||
|
||||
const handleAnimationTrigger = () => {
|
||||
if (animateOnHover && !isAnimating) {
|
||||
iterationCount.current = 0
|
||||
setIsAnimating(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle animation start based on view or delay
|
||||
useEffect(() => {
|
||||
if (!startOnView) {
|
||||
const startTimeout = setTimeout(() => {
|
||||
setIsAnimating(true)
|
||||
}, delay)
|
||||
return () => clearTimeout(startTimeout)
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setTimeout(() => {
|
||||
setIsAnimating(true)
|
||||
}, delay)
|
||||
observer.disconnect()
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: '-30% 0px -30% 0px' }
|
||||
)
|
||||
|
||||
if (elementRef.current) {
|
||||
observer.observe(elementRef.current)
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [delay, startOnView])
|
||||
|
||||
// Handle scramble animation
|
||||
useEffect(() => {
|
||||
if (!isAnimating) return
|
||||
|
||||
const maxIterations = children.length
|
||||
const startTime = performance.now()
|
||||
let animationFrameId: number
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
|
||||
iterationCount.current = progress * maxIterations
|
||||
|
||||
setDisplayText((currentText) =>
|
||||
currentText.map((letter, index) =>
|
||||
letter === ' '
|
||||
? letter
|
||||
: index <= iterationCount.current
|
||||
? children[index]
|
||||
: characterSet[getRandomInt(characterSet.length)]
|
||||
)
|
||||
)
|
||||
|
||||
if (progress < 1) {
|
||||
animationFrameId = requestAnimationFrame(animate)
|
||||
} else {
|
||||
setIsAnimating(false)
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(animate)
|
||||
|
||||
return () => cancelAnimationFrame(animationFrameId)
|
||||
}, [children, duration, isAnimating, characterSet])
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
ref={elementRef}
|
||||
className={cn('overflow-hidden py-2 text-4xl font-bold', className)}
|
||||
onMouseEnter={handleAnimationTrigger}
|
||||
{...props}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{displayText.map((letter, index) => (
|
||||
<motion.span key={index} className={cn('font-mono', letter === ' ' ? 'w-3' : '')}>
|
||||
{letter.toUpperCase()}
|
||||
</motion.span>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</MotionComponent>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={cn(
|
||||
'bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none',
|
||||
"[&_svg:not([class*='size-'])]:size-3",
|
||||
'[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd-group"
|
||||
className={cn('inline-flex items-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup }
|
||||
@@ -0,0 +1,101 @@
|
||||
import { motion, useMotionTemplate, useMotionValue } from 'motion/react'
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MagicCardProps {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
gradientSize?: number
|
||||
gradientColor?: string
|
||||
gradientOpacity?: number
|
||||
gradientFrom?: string
|
||||
gradientTo?: string
|
||||
}
|
||||
|
||||
export function MagicCard({
|
||||
children,
|
||||
className,
|
||||
gradientSize = 200,
|
||||
gradientColor = '#262626',
|
||||
gradientOpacity = 0.8,
|
||||
gradientFrom = '#9E7AFF',
|
||||
gradientTo = '#FE8BBB',
|
||||
}: MagicCardProps) {
|
||||
const mouseX = useMotionValue(-gradientSize)
|
||||
const mouseY = useMotionValue(-gradientSize)
|
||||
const reset = useCallback(() => {
|
||||
mouseX.set(-gradientSize)
|
||||
mouseY.set(-gradientSize)
|
||||
}, [gradientSize, mouseX, mouseY])
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
mouseX.set(e.clientX - rect.left)
|
||||
mouseY.set(e.clientY - rect.top)
|
||||
},
|
||||
[mouseX, mouseY]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
reset()
|
||||
}, [reset])
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalPointerOut = (e: PointerEvent) => {
|
||||
if (!e.relatedTarget) {
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('pointerout', handleGlobalPointerOut)
|
||||
window.addEventListener('blur', reset)
|
||||
document.addEventListener('visibilitychange', handleVisibility)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointerout', handleGlobalPointerOut)
|
||||
window.removeEventListener('blur', reset)
|
||||
document.removeEventListener('visibilitychange', handleVisibility)
|
||||
}
|
||||
}, [reset])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('group relative rounded-[inherit]', className)}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={reset}
|
||||
onPointerEnter={reset}
|
||||
>
|
||||
<motion.div
|
||||
className="bg-border pointer-events-none absolute inset-0 rounded-[inherit] duration-300 group-hover:opacity-100"
|
||||
style={{
|
||||
background: useMotionTemplate`
|
||||
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px,
|
||||
${gradientFrom},
|
||||
${gradientTo},
|
||||
var(--border) 100%
|
||||
)
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<div className="bg-background absolute inset-px rounded-[inherit]" />
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-px rounded-[inherit] opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
style={{
|
||||
background: useMotionTemplate`
|
||||
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px, ${gradientColor}, transparent 100%)
|
||||
`,
|
||||
opacity: gradientOpacity,
|
||||
}}
|
||||
/>
|
||||
<div className="relative">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { CSSProperties, ReactElement, ReactNode, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface NeonColorsProps {
|
||||
firstColor: string
|
||||
secondColor: string
|
||||
}
|
||||
|
||||
interface NeonGradientCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* @default <div />
|
||||
* @type ReactElement
|
||||
* @description
|
||||
* The component to be rendered as the card
|
||||
* */
|
||||
as?: ReactElement
|
||||
/**
|
||||
* @default ""
|
||||
* @type string
|
||||
* @description
|
||||
* The className of the card
|
||||
*/
|
||||
className?: string
|
||||
|
||||
/**
|
||||
* @default ""
|
||||
* @type ReactNode
|
||||
* @description
|
||||
* The children of the card
|
||||
* */
|
||||
children?: ReactNode
|
||||
|
||||
/**
|
||||
* @default 5
|
||||
* @type number
|
||||
* @description
|
||||
* The size of the border in pixels
|
||||
* */
|
||||
borderSize?: number
|
||||
|
||||
/**
|
||||
* @default 20
|
||||
* @type number
|
||||
* @description
|
||||
* The size of the radius in pixels
|
||||
* */
|
||||
borderRadius?: number
|
||||
|
||||
/**
|
||||
* @default "{ firstColor: '#ff00aa', secondColor: '#00FFF1' }"
|
||||
* @type string
|
||||
* @description
|
||||
* The colors of the neon gradient
|
||||
* */
|
||||
neonColors?: NeonColorsProps
|
||||
}
|
||||
|
||||
export const NeonGradientCard: React.FC<NeonGradientCardProps> = ({
|
||||
className,
|
||||
children,
|
||||
borderSize = 2,
|
||||
borderRadius = 20,
|
||||
neonColors = {
|
||||
firstColor: '#ff00aa',
|
||||
secondColor: '#00FFF1',
|
||||
},
|
||||
...props
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
const updateDimensions = () => {
|
||||
if (containerRef.current) {
|
||||
const { offsetWidth, offsetHeight } = containerRef.current
|
||||
setDimensions({ width: offsetWidth, height: offsetHeight })
|
||||
}
|
||||
}
|
||||
|
||||
updateDimensions()
|
||||
window.addEventListener('resize', updateDimensions)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateDimensions)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const { offsetWidth, offsetHeight } = containerRef.current
|
||||
setDimensions({ width: offsetWidth, height: offsetHeight })
|
||||
}
|
||||
}, [children])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={
|
||||
{
|
||||
'--border-size': `${borderSize}px`,
|
||||
'--border-radius': `${borderRadius}px`,
|
||||
'--neon-first-color': neonColors.firstColor,
|
||||
'--neon-second-color': neonColors.secondColor,
|
||||
'--card-width': `${dimensions.width}px`,
|
||||
'--card-height': `${dimensions.height}px`,
|
||||
'--card-content-radius': `${borderRadius - borderSize}px`,
|
||||
'--pseudo-element-background-image': `linear-gradient(0deg, ${neonColors.firstColor}, ${neonColors.secondColor})`,
|
||||
'--pseudo-element-width': `${dimensions.width + borderSize * 2}px`,
|
||||
'--pseudo-element-height': `${dimensions.height + borderSize * 2}px`,
|
||||
'--after-blur': `${dimensions.width / 6}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
className={cn('relative z-10 size-full rounded-[var(--border-radius)]', className)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative size-full min-h-[inherit] rounded-[var(--card-content-radius)] bg-gray-100',
|
||||
'before:absolute before:-top-[var(--border-size)] before:-left-[var(--border-size)] before:-z-10 before:block',
|
||||
"before:h-[var(--pseudo-element-height)] before:w-[var(--pseudo-element-width)] before:rounded-[var(--border-radius)] before:content-['']",
|
||||
'before:bg-[linear-gradient(0deg,var(--neon-first-color),var(--neon-second-color))] before:bg-[length:100%_200%]',
|
||||
'before:animate-background-position-spin',
|
||||
'after:absolute after:-top-[var(--border-size)] after:-left-[var(--border-size)] after:-z-10 after:block',
|
||||
"after:h-[var(--pseudo-element-height)] after:w-[var(--pseudo-element-width)] after:rounded-[var(--border-radius)] after:blur-[var(--after-blur)] after:content-['']",
|
||||
'after:bg-[linear-gradient(0deg,var(--neon-first-color),var(--neon-second-color))] after:bg-[length:100%_200%] after:opacity-80',
|
||||
'after:animate-background-position-spin',
|
||||
'dark:bg-neutral-900',
|
||||
'break-words'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import React, { ComponentPropsWithoutRef, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MousePosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
function MousePosition(): MousePosition {
|
||||
const [mousePosition, setMousePosition] = useState<MousePosition>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
setMousePosition({ x: event.clientX, y: event.clientY })
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return mousePosition
|
||||
}
|
||||
|
||||
interface ParticlesProps extends ComponentPropsWithoutRef<'div'> {
|
||||
className?: string
|
||||
quantity?: number
|
||||
staticity?: number
|
||||
ease?: number
|
||||
size?: number
|
||||
refresh?: boolean
|
||||
color?: string
|
||||
vx?: number
|
||||
vy?: number
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): number[] {
|
||||
hex = hex.replace('#', '')
|
||||
|
||||
if (hex.length === 3) {
|
||||
hex = hex
|
||||
.split('')
|
||||
.map((char) => char + char)
|
||||
.join('')
|
||||
}
|
||||
|
||||
const hexInt = parseInt(hex, 16)
|
||||
const red = (hexInt >> 16) & 255
|
||||
const green = (hexInt >> 8) & 255
|
||||
const blue = hexInt & 255
|
||||
return [red, green, blue]
|
||||
}
|
||||
|
||||
type Circle = {
|
||||
x: number
|
||||
y: number
|
||||
translateX: number
|
||||
translateY: number
|
||||
size: number
|
||||
alpha: number
|
||||
targetAlpha: number
|
||||
dx: number
|
||||
dy: number
|
||||
magnetism: number
|
||||
}
|
||||
|
||||
export const Particles: React.FC<ParticlesProps> = ({
|
||||
className = '',
|
||||
quantity = 100,
|
||||
staticity = 50,
|
||||
ease = 50,
|
||||
size = 0.4,
|
||||
refresh = false,
|
||||
color = '#ffffff',
|
||||
vx = 0,
|
||||
vy = 0,
|
||||
...props
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const canvasContainerRef = useRef<HTMLDivElement>(null)
|
||||
const context = useRef<CanvasRenderingContext2D | null>(null)
|
||||
const circles = useRef<Circle[]>([])
|
||||
const mousePosition = MousePosition()
|
||||
const mouse = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
|
||||
const canvasSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 })
|
||||
const dpr = typeof window !== 'undefined' ? window.devicePixelRatio : 1
|
||||
const rafID = useRef<number | null>(null)
|
||||
const resizeTimeout = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (canvasRef.current) {
|
||||
context.current = canvasRef.current.getContext('2d')
|
||||
}
|
||||
initCanvas()
|
||||
animate()
|
||||
|
||||
const handleResize = () => {
|
||||
if (resizeTimeout.current) {
|
||||
clearTimeout(resizeTimeout.current)
|
||||
}
|
||||
resizeTimeout.current = setTimeout(() => {
|
||||
initCanvas()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => {
|
||||
if (rafID.current != null) {
|
||||
window.cancelAnimationFrame(rafID.current)
|
||||
}
|
||||
if (resizeTimeout.current) {
|
||||
clearTimeout(resizeTimeout.current)
|
||||
}
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [color])
|
||||
|
||||
useEffect(() => {
|
||||
onMouseMove()
|
||||
}, [mousePosition.x, mousePosition.y])
|
||||
|
||||
useEffect(() => {
|
||||
initCanvas()
|
||||
}, [refresh])
|
||||
|
||||
const initCanvas = () => {
|
||||
resizeCanvas()
|
||||
drawParticles()
|
||||
}
|
||||
|
||||
const onMouseMove = () => {
|
||||
if (canvasRef.current) {
|
||||
const rect = canvasRef.current.getBoundingClientRect()
|
||||
const { w, h } = canvasSize.current
|
||||
const x = mousePosition.x - rect.left - w / 2
|
||||
const y = mousePosition.y - rect.top - h / 2
|
||||
const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2
|
||||
if (inside) {
|
||||
mouse.current.x = x
|
||||
mouse.current.y = y
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resizeCanvas = () => {
|
||||
if (canvasContainerRef.current && canvasRef.current && context.current) {
|
||||
canvasSize.current.w = canvasContainerRef.current.offsetWidth
|
||||
canvasSize.current.h = canvasContainerRef.current.offsetHeight
|
||||
|
||||
canvasRef.current.width = canvasSize.current.w * dpr
|
||||
canvasRef.current.height = canvasSize.current.h * dpr
|
||||
canvasRef.current.style.width = `${canvasSize.current.w}px`
|
||||
canvasRef.current.style.height = `${canvasSize.current.h}px`
|
||||
context.current.scale(dpr, dpr)
|
||||
|
||||
// Clear existing particles and create new ones with exact quantity
|
||||
circles.current = []
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
const circle = circleParams()
|
||||
drawCircle(circle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const circleParams = (): Circle => {
|
||||
const x = Math.floor(Math.random() * canvasSize.current.w)
|
||||
const y = Math.floor(Math.random() * canvasSize.current.h)
|
||||
const translateX = 0
|
||||
const translateY = 0
|
||||
const pSize = Math.floor(Math.random() * 2) + size
|
||||
const alpha = 0
|
||||
const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1))
|
||||
const dx = (Math.random() - 0.5) * 0.1
|
||||
const dy = (Math.random() - 0.5) * 0.1
|
||||
const magnetism = 0.1 + Math.random() * 4
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
translateX,
|
||||
translateY,
|
||||
size: pSize,
|
||||
alpha,
|
||||
targetAlpha,
|
||||
dx,
|
||||
dy,
|
||||
magnetism,
|
||||
}
|
||||
}
|
||||
|
||||
const rgb = hexToRgb(color)
|
||||
|
||||
const drawCircle = (circle: Circle, update = false) => {
|
||||
if (context.current) {
|
||||
const { x, y, translateX, translateY, size, alpha } = circle
|
||||
context.current.translate(translateX, translateY)
|
||||
context.current.beginPath()
|
||||
context.current.arc(x, y, size, 0, 2 * Math.PI)
|
||||
context.current.fillStyle = `rgba(${rgb.join(', ')}, ${alpha})`
|
||||
context.current.fill()
|
||||
context.current.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
|
||||
if (!update) {
|
||||
circles.current.push(circle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clearContext = () => {
|
||||
if (context.current) {
|
||||
context.current.clearRect(0, 0, canvasSize.current.w, canvasSize.current.h)
|
||||
}
|
||||
}
|
||||
|
||||
const drawParticles = () => {
|
||||
clearContext()
|
||||
const particleCount = quantity
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const circle = circleParams()
|
||||
drawCircle(circle)
|
||||
}
|
||||
}
|
||||
|
||||
const remapValue = (
|
||||
value: number,
|
||||
start1: number,
|
||||
end1: number,
|
||||
start2: number,
|
||||
end2: number
|
||||
): number => {
|
||||
const remapped = ((value - start1) * (end2 - start2)) / (end1 - start1) + start2
|
||||
return remapped > 0 ? remapped : 0
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
clearContext()
|
||||
circles.current.forEach((circle: Circle, i: number) => {
|
||||
// Handle the alpha value
|
||||
const edge = [
|
||||
circle.x + circle.translateX - circle.size, // distance from left edge
|
||||
canvasSize.current.w - circle.x - circle.translateX - circle.size, // distance from right edge
|
||||
circle.y + circle.translateY - circle.size, // distance from top edge
|
||||
canvasSize.current.h - circle.y - circle.translateY - circle.size, // distance from bottom edge
|
||||
]
|
||||
const closestEdge = edge.reduce((a, b) => Math.min(a, b))
|
||||
const remapClosestEdge = parseFloat(remapValue(closestEdge, 0, 20, 0, 1).toFixed(2))
|
||||
if (remapClosestEdge > 1) {
|
||||
circle.alpha += 0.02
|
||||
if (circle.alpha > circle.targetAlpha) {
|
||||
circle.alpha = circle.targetAlpha
|
||||
}
|
||||
} else {
|
||||
circle.alpha = circle.targetAlpha * remapClosestEdge
|
||||
}
|
||||
circle.x += circle.dx + vx
|
||||
circle.y += circle.dy + vy
|
||||
circle.translateX +=
|
||||
(mouse.current.x / (staticity / circle.magnetism) - circle.translateX) / ease
|
||||
circle.translateY +=
|
||||
(mouse.current.y / (staticity / circle.magnetism) - circle.translateY) / ease
|
||||
|
||||
drawCircle(circle, true)
|
||||
|
||||
// circle gets out of the canvas
|
||||
if (
|
||||
circle.x < -circle.size ||
|
||||
circle.x > canvasSize.current.w + circle.size ||
|
||||
circle.y < -circle.size ||
|
||||
circle.y > canvasSize.current.h + circle.size
|
||||
) {
|
||||
// remove the circle from the array
|
||||
circles.current.splice(i, 1)
|
||||
// create a new circle
|
||||
const newCircle = circleParams()
|
||||
drawCircle(newCircle)
|
||||
}
|
||||
})
|
||||
rafID.current = window.requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('pointer-events-none', className)}
|
||||
ref={canvasContainerRef}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<canvas ref={canvasRef} className="size-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { Toaster as Sonner, type ToasterProps } from 'sonner'
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = 'system' } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps['theme']}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
'--border-radius': 'var(--radius)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,150 @@
|
||||
import { motion } from 'motion/react'
|
||||
import { CSSProperties, ReactElement, useEffect, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Sparkle {
|
||||
id: string
|
||||
x: string
|
||||
y: string
|
||||
color: string
|
||||
delay: number
|
||||
scale: number
|
||||
lifespan: number
|
||||
}
|
||||
|
||||
const Sparkle: React.FC<Sparkle> = ({ id, x, y, color, delay, scale }) => {
|
||||
return (
|
||||
<motion.svg
|
||||
key={id}
|
||||
className="pointer-events-none absolute z-20"
|
||||
initial={{ opacity: 0, left: x, top: y }}
|
||||
animate={{
|
||||
opacity: [0, 1, 0],
|
||||
scale: [0, scale, 0],
|
||||
rotate: [75, 120, 150],
|
||||
}}
|
||||
transition={{ duration: 0.8, repeat: Infinity, delay }}
|
||||
width="21"
|
||||
height="21"
|
||||
viewBox="0 0 21 21"
|
||||
>
|
||||
<path
|
||||
d="M9.82531 0.843845C10.0553 0.215178 10.9446 0.215178 11.1746 0.843845L11.8618 2.72026C12.4006 4.19229 12.3916 6.39157 13.5 7.5C14.6084 8.60843 16.8077 8.59935 18.2797 9.13822L20.1561 9.82534C20.7858 10.0553 20.7858 10.9447 20.1561 11.1747L18.2797 11.8618C16.8077 12.4007 14.6084 12.3916 13.5 13.5C12.3916 14.6084 12.4006 16.8077 11.8618 18.2798L11.1746 20.1562C10.9446 20.7858 10.0553 20.7858 9.82531 20.1562L9.13819 18.2798C8.59932 16.8077 8.60843 14.6084 7.5 13.5C6.39157 12.3916 4.19225 12.4007 2.72023 11.8618L0.843814 11.1747C0.215148 10.9447 0.215148 10.0553 0.843814 9.82534L2.72023 9.13822C4.19225 8.59935 6.39157 8.60843 7.5 7.5C8.60843 6.39157 8.59932 4.19229 9.13819 2.72026L9.82531 0.843845Z"
|
||||
fill={color}
|
||||
/>
|
||||
</motion.svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface SparklesTextProps {
|
||||
/**
|
||||
* @default <div />
|
||||
* @type ReactElement
|
||||
* @description
|
||||
* The component to be rendered as the text
|
||||
* */
|
||||
as?: ReactElement
|
||||
|
||||
/**
|
||||
* @default ""
|
||||
* @type string
|
||||
* @description
|
||||
* The className of the text
|
||||
*/
|
||||
className?: string
|
||||
|
||||
/**
|
||||
* @required
|
||||
* @type ReactNode
|
||||
* @description
|
||||
* The content to be displayed
|
||||
* */
|
||||
children: React.ReactNode
|
||||
|
||||
/**
|
||||
* @default 10
|
||||
* @type number
|
||||
* @description
|
||||
* The count of sparkles
|
||||
* */
|
||||
sparklesCount?: number
|
||||
|
||||
/**
|
||||
* @default "{first: '#9E7AFF', second: '#FE8BBB'}"
|
||||
* @type string
|
||||
* @description
|
||||
* The colors of the sparkles
|
||||
* */
|
||||
colors?: {
|
||||
first: string
|
||||
second: string
|
||||
}
|
||||
}
|
||||
|
||||
export const SparklesText: React.FC<SparklesTextProps> = ({
|
||||
children,
|
||||
colors = { first: '#9E7AFF', second: '#FE8BBB' },
|
||||
className,
|
||||
sparklesCount = 10,
|
||||
...props
|
||||
}) => {
|
||||
const [sparkles, setSparkles] = useState<Sparkle[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const generateStar = (): Sparkle => {
|
||||
const starX = `${Math.random() * 100}%`
|
||||
const starY = `${Math.random() * 100}%`
|
||||
const color = Math.random() > 0.5 ? colors.first : colors.second
|
||||
const delay = Math.random() * 2
|
||||
const scale = Math.random() * 1 + 0.3
|
||||
const lifespan = Math.random() * 10 + 5
|
||||
const id = `${starX}-${starY}-${Date.now()}`
|
||||
return { id, x: starX, y: starY, color, delay, scale, lifespan }
|
||||
}
|
||||
|
||||
const initializeStars = () => {
|
||||
const newSparkles = Array.from({ length: sparklesCount }, generateStar)
|
||||
setSparkles(newSparkles)
|
||||
}
|
||||
|
||||
const updateStars = () => {
|
||||
setSparkles((currentSparkles) =>
|
||||
currentSparkles.map((star) => {
|
||||
if (star.lifespan <= 0) {
|
||||
return generateStar()
|
||||
} else {
|
||||
return { ...star, lifespan: star.lifespan - 0.1 }
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
initializeStars()
|
||||
const interval = setInterval(updateStars, 100)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [colors.first, colors.second, sparklesCount])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('text-6xl font-bold', className)}
|
||||
{...props}
|
||||
style={
|
||||
{
|
||||
'--sparkles-first-color': `${colors.first}`,
|
||||
'--sparkles-second-color': `${colors.second}`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<span className="relative inline-block">
|
||||
{sparkles.map((sparkle) => (
|
||||
<Sparkle key={sparkle.id} {...sparkle} />
|
||||
))}
|
||||
<strong className="bg-linear-to-r from-[var(--sparkles-first-color)] to-[var(--sparkles-second-color)] bg-clip-text text-transparent">
|
||||
{children}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Loader2Icon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<Loader2Icon
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn('size-4 animate-spin', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,417 @@
|
||||
import { AnimatePresence, MotionProps, Variants, motion } from 'motion/react'
|
||||
import { ElementType, memo } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type AnimationType = 'text' | 'word' | 'character' | 'line'
|
||||
type AnimationVariant =
|
||||
| 'fadeIn'
|
||||
| 'blurIn'
|
||||
| 'blurInUp'
|
||||
| 'blurInDown'
|
||||
| 'slideUp'
|
||||
| 'slideDown'
|
||||
| 'slideLeft'
|
||||
| 'slideRight'
|
||||
| 'scaleUp'
|
||||
| 'scaleDown'
|
||||
|
||||
interface TextAnimateProps extends MotionProps {
|
||||
/**
|
||||
* The text content to animate
|
||||
*/
|
||||
children: string
|
||||
/**
|
||||
* The class name to be applied to the component
|
||||
*/
|
||||
className?: string
|
||||
/**
|
||||
* The class name to be applied to each segment
|
||||
*/
|
||||
segmentClassName?: string
|
||||
/**
|
||||
* The delay before the animation starts
|
||||
*/
|
||||
delay?: number
|
||||
/**
|
||||
* The duration of the animation
|
||||
*/
|
||||
duration?: number
|
||||
/**
|
||||
* Custom motion variants for the animation
|
||||
*/
|
||||
variants?: Variants
|
||||
/**
|
||||
* The element type to render
|
||||
*/
|
||||
as?: ElementType
|
||||
/**
|
||||
* How to split the text ("text", "word", "character")
|
||||
*/
|
||||
by?: AnimationType
|
||||
/**
|
||||
* Whether to start animation when component enters viewport
|
||||
*/
|
||||
startOnView?: boolean
|
||||
/**
|
||||
* Whether to animate only once
|
||||
*/
|
||||
once?: boolean
|
||||
/**
|
||||
* The animation preset to use
|
||||
*/
|
||||
animation?: AnimationVariant
|
||||
/**
|
||||
* Whether to enable accessibility features (default: true)
|
||||
*/
|
||||
accessible?: boolean
|
||||
}
|
||||
|
||||
const staggerTimings: Record<AnimationType, number> = {
|
||||
text: 0.06,
|
||||
word: 0.05,
|
||||
character: 0.03,
|
||||
line: 0.06,
|
||||
}
|
||||
|
||||
const defaultContainerVariants = {
|
||||
hidden: { opacity: 1 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
delayChildren: 0,
|
||||
staggerChildren: 0.05,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
staggerChildren: 0.05,
|
||||
staggerDirection: -1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const defaultItemVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
},
|
||||
}
|
||||
|
||||
const defaultItemAnimationVariants: Record<
|
||||
AnimationVariant,
|
||||
{ container: Variants; item: Variants }
|
||||
> = {
|
||||
fadeIn: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: 20,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
blurIn: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { opacity: 0, filter: 'blur(10px)' },
|
||||
show: {
|
||||
opacity: 1,
|
||||
filter: 'blur(0px)',
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
filter: 'blur(10px)',
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
blurInUp: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { opacity: 0, filter: 'blur(10px)', y: 20 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
filter: 'blur(0px)',
|
||||
y: 0,
|
||||
transition: {
|
||||
y: { duration: 0.3 },
|
||||
opacity: { duration: 0.4 },
|
||||
filter: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
filter: 'blur(10px)',
|
||||
y: 20,
|
||||
transition: {
|
||||
y: { duration: 0.3 },
|
||||
opacity: { duration: 0.4 },
|
||||
filter: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
blurInDown: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { opacity: 0, filter: 'blur(10px)', y: -20 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
filter: 'blur(0px)',
|
||||
y: 0,
|
||||
transition: {
|
||||
y: { duration: 0.3 },
|
||||
opacity: { duration: 0.4 },
|
||||
filter: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
slideUp: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
show: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
y: -20,
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
slideDown: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { y: -20, opacity: 0 },
|
||||
show: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
exit: {
|
||||
y: 20,
|
||||
opacity: 0,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
slideLeft: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { x: 20, opacity: 0 },
|
||||
show: {
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
exit: {
|
||||
x: -20,
|
||||
opacity: 0,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
slideRight: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { x: -20, opacity: 0 },
|
||||
show: {
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
exit: {
|
||||
x: 20,
|
||||
opacity: 0,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
scaleUp: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { scale: 0.5, opacity: 0 },
|
||||
show: {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
scale: {
|
||||
type: 'spring',
|
||||
damping: 15,
|
||||
stiffness: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
scale: 0.5,
|
||||
opacity: 0,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
scaleDown: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { scale: 1.5, opacity: 0 },
|
||||
show: {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
scale: {
|
||||
type: 'spring',
|
||||
damping: 15,
|
||||
stiffness: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
scale: 1.5,
|
||||
opacity: 0,
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const TextAnimateBase = ({
|
||||
children,
|
||||
delay = 0,
|
||||
duration = 0.3,
|
||||
variants,
|
||||
className,
|
||||
segmentClassName,
|
||||
as: Component = 'p',
|
||||
startOnView = true,
|
||||
once = false,
|
||||
by = 'word',
|
||||
animation = 'fadeIn',
|
||||
accessible = true,
|
||||
...props
|
||||
}: TextAnimateProps) => {
|
||||
const MotionComponent = motion.create(Component)
|
||||
|
||||
let segments: string[] = []
|
||||
switch (by) {
|
||||
case 'word':
|
||||
segments = children.split(/(\s+)/)
|
||||
break
|
||||
case 'character':
|
||||
segments = children.split('')
|
||||
break
|
||||
case 'line':
|
||||
segments = children.split('\n')
|
||||
break
|
||||
case 'text':
|
||||
default:
|
||||
segments = [children]
|
||||
break
|
||||
}
|
||||
|
||||
const finalVariants = variants
|
||||
? {
|
||||
container: {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
opacity: { duration: 0.01, delay },
|
||||
delayChildren: delay,
|
||||
staggerChildren: duration / segments.length,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
staggerChildren: duration / segments.length,
|
||||
staggerDirection: -1,
|
||||
},
|
||||
},
|
||||
},
|
||||
item: variants,
|
||||
}
|
||||
: animation
|
||||
? {
|
||||
container: {
|
||||
...defaultItemAnimationVariants[animation].container,
|
||||
show: {
|
||||
...defaultItemAnimationVariants[animation].container.show,
|
||||
transition: {
|
||||
delayChildren: delay,
|
||||
staggerChildren: duration / segments.length,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
...defaultItemAnimationVariants[animation].container.exit,
|
||||
transition: {
|
||||
staggerChildren: duration / segments.length,
|
||||
staggerDirection: -1,
|
||||
},
|
||||
},
|
||||
},
|
||||
item: defaultItemAnimationVariants[animation].item,
|
||||
}
|
||||
: { container: defaultContainerVariants, item: defaultItemVariants }
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="popLayout">
|
||||
<MotionComponent
|
||||
variants={finalVariants.container as Variants}
|
||||
initial="hidden"
|
||||
whileInView={startOnView ? 'show' : undefined}
|
||||
animate={startOnView ? undefined : 'show'}
|
||||
exit="exit"
|
||||
className={cn('whitespace-pre-wrap', className)}
|
||||
viewport={{ once }}
|
||||
aria-label={accessible ? children : undefined}
|
||||
{...props}
|
||||
>
|
||||
{accessible && <span className="sr-only">{children}</span>}
|
||||
{segments.map((segment, i) => (
|
||||
<motion.span
|
||||
key={`${by}-${segment}-${i}`}
|
||||
variants={finalVariants.item}
|
||||
custom={i * staggerTimings[by]}
|
||||
className={cn(
|
||||
by === 'line' ? 'block' : 'inline-block whitespace-pre',
|
||||
by === 'character' && '',
|
||||
segmentClassName
|
||||
)}
|
||||
aria-hidden={accessible ? true : undefined}
|
||||
>
|
||||
{segment}
|
||||
</motion.span>
|
||||
))}
|
||||
</MotionComponent>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
// Export the memoized version
|
||||
export const TextAnimate = memo(TextAnimateBase)
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -0,0 +1,164 @@
|
||||
import { MotionProps, motion, useInView } from 'motion/react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TypingAnimationProps extends MotionProps {
|
||||
children?: string
|
||||
words?: string[]
|
||||
className?: string
|
||||
duration?: number
|
||||
typeSpeed?: number
|
||||
deleteSpeed?: number
|
||||
delay?: number
|
||||
pauseDelay?: number
|
||||
loop?: boolean
|
||||
as?: React.ElementType
|
||||
startOnView?: boolean
|
||||
showCursor?: boolean
|
||||
blinkCursor?: boolean
|
||||
cursorStyle?: 'line' | 'block' | 'underscore'
|
||||
}
|
||||
|
||||
export function TypingAnimation({
|
||||
children,
|
||||
words,
|
||||
className,
|
||||
duration = 100,
|
||||
typeSpeed,
|
||||
deleteSpeed,
|
||||
delay = 0,
|
||||
pauseDelay = 1000,
|
||||
loop = false,
|
||||
as: Component = 'span',
|
||||
startOnView = true,
|
||||
showCursor = true,
|
||||
blinkCursor = true,
|
||||
cursorStyle = 'line',
|
||||
...props
|
||||
}: TypingAnimationProps) {
|
||||
const MotionComponent = motion.create(Component, {
|
||||
forwardMotionProps: true,
|
||||
})
|
||||
|
||||
const [displayedText, setDisplayedText] = useState<string>('')
|
||||
const [currentWordIndex, setCurrentWordIndex] = useState(0)
|
||||
const [currentCharIndex, setCurrentCharIndex] = useState(0)
|
||||
const [phase, setPhase] = useState<'typing' | 'pause' | 'deleting'>('typing')
|
||||
const elementRef = useRef<HTMLElement | null>(null)
|
||||
const isInView = useInView(elementRef as React.RefObject<Element>, {
|
||||
amount: 0.3,
|
||||
once: true,
|
||||
})
|
||||
|
||||
const wordsToAnimate = useMemo(() => words || (children ? [children] : []), [words, children])
|
||||
const hasMultipleWords = wordsToAnimate.length > 1
|
||||
|
||||
const typingSpeed = typeSpeed || duration
|
||||
const deletingSpeed = deleteSpeed || typingSpeed / 2
|
||||
|
||||
const shouldStart = startOnView ? isInView : true
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldStart || wordsToAnimate.length === 0) return
|
||||
|
||||
const timeoutDelay =
|
||||
delay > 0 && displayedText === ''
|
||||
? delay
|
||||
: phase === 'typing'
|
||||
? typingSpeed
|
||||
: phase === 'deleting'
|
||||
? deletingSpeed
|
||||
: pauseDelay
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
const currentWord = wordsToAnimate[currentWordIndex] || ''
|
||||
const graphemes = Array.from(currentWord)
|
||||
|
||||
switch (phase) {
|
||||
case 'typing':
|
||||
if (currentCharIndex < graphemes.length) {
|
||||
setDisplayedText(graphemes.slice(0, currentCharIndex + 1).join(''))
|
||||
setCurrentCharIndex(currentCharIndex + 1)
|
||||
} else {
|
||||
if (hasMultipleWords || loop) {
|
||||
const isLastWord = currentWordIndex === wordsToAnimate.length - 1
|
||||
if (!isLastWord || loop) {
|
||||
setPhase('pause')
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'pause':
|
||||
setPhase('deleting')
|
||||
break
|
||||
|
||||
case 'deleting':
|
||||
if (currentCharIndex > 0) {
|
||||
setDisplayedText(graphemes.slice(0, currentCharIndex - 1).join(''))
|
||||
setCurrentCharIndex(currentCharIndex - 1)
|
||||
} else {
|
||||
const nextIndex = (currentWordIndex + 1) % wordsToAnimate.length
|
||||
setCurrentWordIndex(nextIndex)
|
||||
setPhase('typing')
|
||||
}
|
||||
break
|
||||
}
|
||||
}, timeoutDelay)
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
}, [
|
||||
shouldStart,
|
||||
phase,
|
||||
currentCharIndex,
|
||||
currentWordIndex,
|
||||
displayedText,
|
||||
wordsToAnimate,
|
||||
hasMultipleWords,
|
||||
loop,
|
||||
typingSpeed,
|
||||
deletingSpeed,
|
||||
pauseDelay,
|
||||
delay,
|
||||
])
|
||||
|
||||
const currentWordGraphemes = Array.from(wordsToAnimate[currentWordIndex] || '')
|
||||
const isComplete =
|
||||
!loop &&
|
||||
currentWordIndex === wordsToAnimate.length - 1 &&
|
||||
currentCharIndex >= currentWordGraphemes.length &&
|
||||
phase !== 'deleting'
|
||||
|
||||
const shouldShowCursor =
|
||||
showCursor &&
|
||||
!isComplete &&
|
||||
(hasMultipleWords || loop || currentCharIndex < currentWordGraphemes.length)
|
||||
|
||||
const getCursorChar = () => {
|
||||
switch (cursorStyle) {
|
||||
case 'block':
|
||||
return '▌'
|
||||
case 'underscore':
|
||||
return '_'
|
||||
case 'line':
|
||||
default:
|
||||
return '|'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
ref={elementRef}
|
||||
className={cn('leading-[5rem] tracking-[-0.02em]', className)}
|
||||
{...props}
|
||||
>
|
||||
{displayedText}
|
||||
{shouldShowCursor && (
|
||||
<span className={cn('inline-block', blinkCursor && 'animate-blink-cursor')}>
|
||||
{getCursorChar()}
|
||||
</span>
|
||||
)}
|
||||
</MotionComponent>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,15 @@
|
||||
export const CDN_URL = 'https://cdn.jsdelivr.net/npm/page-agent@latest/dist/umd/page-agent.js'
|
||||
export const CDN_CN_URL =
|
||||
'https://registry.npmmirror.com/page-agent/latest/files/dist/umd/page-agent.js'
|
||||
// Demo build (auto-init with demo LLM, for quick testing)
|
||||
export const CDN_DEMO_URL = 'https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.demo.js'
|
||||
export const CDN_DEMO_CN_URL =
|
||||
'https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.demo.js'
|
||||
|
||||
// Full build (exports PageAgent class, usage identical to npm)
|
||||
export const CDN_FULL_URL = 'https://cdn.jsdelivr.net/npm/@page-agent/cdn/dist/page-agent.js'
|
||||
export const CDN_FULL_CN_URL =
|
||||
'https://registry.npmmirror.com/@page-agent/cdn/latest/files/dist/page-agent.js'
|
||||
|
||||
// Demo LLM for website testing
|
||||
export const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
export const DEMO_BASE_URL =
|
||||
'https://hwcxiuzfylggtcktqgij.supabase.co/functions/v1/llm-testing-proxy'
|
||||
export const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import BetaNotice from '@/components/BetaNotice'
|
||||
import CodeEditor from '@/components/CodeEditor'
|
||||
|
||||
export default function DataMasking() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-6">数据脱敏</h1>
|
||||
|
||||
<BetaNotice />
|
||||
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
|
||||
保护敏感数据,确保 AI 处理过程中的数据安全。
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-3">脱敏策略</h2>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-300">
|
||||
🔒 自动脱敏
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
自动识别并脱敏手机号、身份证号、银行卡号等敏感信息。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2 text-purple-900 dark:text-purple-300">
|
||||
⚙️ 自定义规则
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
支持自定义脱敏规则,适应不同业务场景的数据保护需求。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CodeEditor
|
||||
code={`// 数据脱敏配置
|
||||
// @todo
|
||||
const rules = [
|
||||
{ pattern: /\\d{11}/, replacement: '***-****-****' },
|
||||
{ pattern: /\\d{4}-\\d{4}-\\d{4}-\\d{4}/, replacement: '****-****-****-****' }
|
||||
]
|
||||
pageAgent.maskData(rules)`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,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,164 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import BetaNotice from '@/components/BetaNotice'
|
||||
import CodeEditor from '@/components/CodeEditor'
|
||||
|
||||
export default function ModelIntegration() {
|
||||
const { t } = useTranslation('docs')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-6">{t('model_integration.title')}</h1>
|
||||
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
|
||||
{t('model_integration.subtitle')}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-3">{t('model_integration.recommended')}</h2>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2 text-green-900 dark:text-green-300">
|
||||
{t('model_integration.model_gpt4_title')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-2">
|
||||
{t('model_integration.model_gpt4_badge')}
|
||||
</p>
|
||||
<ul className="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<li>{t('model_integration.model_gpt4_1')}</li>
|
||||
<li>{t('model_integration.model_gpt4_2')}</li>
|
||||
<li>{t('model_integration.model_gpt4_3')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2 text-purple-900 dark:text-purple-300">
|
||||
{t('model_integration.model_deepseek_title')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-2">
|
||||
{t('model_integration.model_deepseek_badge')}
|
||||
</p>
|
||||
<ul className="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<li>{t('model_integration.model_deepseek_1')}</li>
|
||||
<li>{t('model_integration.model_deepseek_2')}</li>
|
||||
<li>{t('model_integration.model_deepseek_3')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2 text-orange-900 dark:text-orange-300">
|
||||
{t('model_integration.model_qwen_title')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-2">
|
||||
{t('model_integration.model_qwen_badge')}
|
||||
</p>
|
||||
<ul className="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<li>{t('model_integration.model_qwen_1')}</li>
|
||||
<li>{t('model_integration.model_qwen_2')}</li>
|
||||
<li>{t('model_integration.model_qwen_3')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2 text-orange-900 dark:text-orange-300">
|
||||
{t('model_integration.model_gemini_title')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-2">
|
||||
{t('model_integration.model_gemini_badge')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-3">{t('model_integration.available')}</h2>
|
||||
|
||||
<div className="p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg mb-6">
|
||||
<h3 className="text-lg font-semibold mb-3 text-emerald-900 dark:text-emerald-300">
|
||||
{t('model_integration.available_verified')}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-900 dark:text-emerald-200 px-3 py-1 text-sm">
|
||||
gpt-4.1-mini/4.1/5
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-900 dark:text-emerald-200 px-3 py-1 text-sm">
|
||||
grok-4/grok-code-fast
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-900 dark:text-emerald-200 px-3 py-1 text-sm">
|
||||
qwen3
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-900 dark:text-emerald-200 px-3 py-1 text-sm">
|
||||
deepseek-v3.1/3.2
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-900 dark:text-emerald-200 px-3 py-1 text-sm">
|
||||
claude-sonnet-4/4.5/haiku-4.5
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-900 dark:text-emerald-200 px-3 py-1 text-sm">
|
||||
gemini-2.5
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-3">{t('model_integration.tips')}</h2>
|
||||
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg mb-6">
|
||||
<ul className="text-sm text-gray-600 dark:text-gray-300 space-y-1 list-disc pl-5">
|
||||
<li>{t('model_integration.tip_1')}</li>
|
||||
<li>{t('model_integration.tip_2')}</li>
|
||||
<li>{t('model_integration.tip_3')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-3">{t('model_integration.security')}</h2>
|
||||
|
||||
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-500 mb-4">
|
||||
<p className="text-sm font-semibold text-yellow-900 dark:text-yellow-200">
|
||||
{t('model_integration.security_warning')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
{t('model_integration.security_desc')}
|
||||
</p>
|
||||
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg mb-6">
|
||||
<h3 className="text-lg font-semibold mb-3 text-blue-900 dark:text-blue-300">
|
||||
{t('model_integration.security_backend_proxy')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
|
||||
{t('model_integration.security_backend_desc')}
|
||||
</p>
|
||||
<ul className="text-sm text-gray-600 dark:text-gray-300 space-y-1 list-none pl-0">
|
||||
<li>{t('model_integration.security_method_1')}</li>
|
||||
<li>{t('model_integration.security_method_2')}</li>
|
||||
<li>{t('model_integration.security_method_3')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-3">{t('model_integration.configuration')}</h2>
|
||||
|
||||
<CodeEditor
|
||||
code={`
|
||||
// 百炼等其他兼容服务
|
||||
const pageAgent = new PageAgent({
|
||||
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
apiKey: 'your-api-key',
|
||||
model: 'qwen-plus'
|
||||
});
|
||||
|
||||
// 私有部署模型
|
||||
const pageAgent = new PageAgent({
|
||||
baseURL: 'http://localhost:11434/v1',
|
||||
apiKey: 'N/A', // Ollama 通常使用任意值
|
||||
model: 'qwen3:latest'
|
||||
});
|
||||
|
||||
// 测试接口
|
||||
// @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'
|
||||
`}
|
||||
/>
|
||||
</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,109 +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
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
Vendored
+4
@@ -1,5 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VERSION: string
|
||||
}
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
|
||||
@@ -27,14 +27,14 @@ export default {
|
||||
overview: 'Overview',
|
||||
quick_start: 'Quick Start',
|
||||
limitations: 'Limitations',
|
||||
model_integration: 'Model Integration',
|
||||
models: 'Models',
|
||||
custom_tools: 'Custom Tools',
|
||||
knowledge_injection: 'Knowledge Injection',
|
||||
security_permissions: 'Security & Permissions',
|
||||
knowledge_injection: 'Instructions',
|
||||
data_masking: 'Data Masking',
|
||||
cdn_setup: 'CDN Setup',
|
||||
configuration: 'Configuration',
|
||||
best_practices: 'Best Practices',
|
||||
third_party_agent: 'Third-party Agent',
|
||||
security_permissions: 'Security & Permissions',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export default {
|
||||
'page-agent is a purely web-based GUI Agent. Gives your website an AI operator in simple steps.',
|
||||
what_is: 'What is page-agent?',
|
||||
what_is_desc:
|
||||
'page-agent is an embedded GUI Agent. Unlike traditional browser automation tools, page-agent targets web developers, not scrapers or agent clients builders. Integrate it into your site to let users interact with pages through natural language.',
|
||||
'page-agent is an embedded GUI Agent. Unlike traditional browser automation tools, page-agent is built for web developers and web applications first. Integrate it into your site to let users interact with pages through natural language.',
|
||||
features_title: 'Core Features',
|
||||
feature_dom: {
|
||||
title: '🧠 Smart DOM Analysis',
|
||||
@@ -112,37 +112,22 @@ export default {
|
||||
future_3: '• Basic visual understanding',
|
||||
future_4: '• Smarter error recovery',
|
||||
},
|
||||
model_integration: {
|
||||
title: 'Model Integration',
|
||||
models: {
|
||||
title: 'Models',
|
||||
subtitle:
|
||||
'Supports OpenAI-compatible models with tool call support, 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.',
|
||||
recommended: 'Recommended Models',
|
||||
model_gpt4_title: '⚡ gpt-4.1-mini',
|
||||
model_gpt4_badge: 'Evaluation Baseline ✅',
|
||||
model_gpt4_1: '• Cost-effective',
|
||||
model_gpt4_2: '• Fast',
|
||||
model_gpt4_3: '• High success rate',
|
||||
model_deepseek_title: '💰 DeepSeek-3.2',
|
||||
model_deepseek_badge: 'Economical',
|
||||
model_deepseek_1: '• Much cheaper than similar models',
|
||||
model_deepseek_2: '• ToolCall may error but usually auto-recovers',
|
||||
model_deepseek_3: "• This site's free demo uses DeepSeek",
|
||||
model_qwen_title: '🛡️ qwen3',
|
||||
model_qwen_badge: 'Secure & Compliant',
|
||||
model_qwen_1: '• Controllable, decent results, reasonable price',
|
||||
model_qwen_2: '• ToolCall may error but usually auto-recovers',
|
||||
model_qwen_3: '• Best for scenarios with detailed steps',
|
||||
model_gemini_title: '⚡ gemini-2.5-flash',
|
||||
model_gemini_badge: 'Highly efficient, high success rate, reasonable price',
|
||||
available: 'Available Models',
|
||||
available_verified: '✅ Verified Working',
|
||||
available: 'Tested Models',
|
||||
available_verified: 'Verified Working Models',
|
||||
tips: 'Tips',
|
||||
tip_1: 'Reasoning models (like GPT-5) are slower with no advantage',
|
||||
tip_2:
|
||||
"Non-OpenAI models don't guarantee JSON schema compliance—tool call may error but usually recovers. Higher temperature recommended",
|
||||
tip_3: 'Small/nano models perform poorly',
|
||||
'Models with weaker ToolCall capabilities may return incorrect formats. Common errors usually auto-recover. Higher temperature recommended',
|
||||
tip_3:
|
||||
'Small models or those unable to handle complex tool definitions typically perform poorly',
|
||||
security: '🔐 Production Authentication',
|
||||
security_warning: '⚠️ Never commit real LLM API Keys to your frontend codebase',
|
||||
security_warning: '⚠️ Never commit real LLM API Keys to your frontend code',
|
||||
security_desc:
|
||||
'In production environments, to hide the real LLM API Keys, we recommend the following architecture:',
|
||||
security_backend_proxy: 'Backend Proxy Pattern',
|
||||
|
||||
@@ -3,7 +3,7 @@ export default {
|
||||
badge: 'GUI Agent in your webpage',
|
||||
title_line1: 'The AI Operator',
|
||||
title_line2: 'Living in Your Web App',
|
||||
subtitle_emoji: '🪄 One line of CDN',
|
||||
subtitle_emoji: '🪄One line of code',
|
||||
subtitle_main: ' adds intelligent GUI Agents to your website.',
|
||||
subtitle_detail: 'Users give natural language commands, AI handles the rest.',
|
||||
tab_try: '🚀 Try It Now',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user