refactor: monorepo
This commit is contained in:
20
packages/page-agent/env.d.ts
vendored
Normal file
20
packages/page-agent/env.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
/// <reference types="vite/client" />
|
||||
import type { PageAgent } from './src/PageAgent'
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.md?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
pageAgent?: PageAgent
|
||||
PageAgent: typeof PageAgent
|
||||
__PAGE_AGENT_IDS__: string[]
|
||||
}
|
||||
}
|
||||
57
packages/page-agent/package.json
Normal file
57
packages/page-agent/package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "page-agent",
|
||||
"private": false,
|
||||
"version": "0.0.4",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-agent.js",
|
||||
"module": "./dist/lib/page-agent.js",
|
||||
"types": "./dist/lib/PageAgent.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/lib/PageAgent.d.ts",
|
||||
"import": "./dist/lib/page-agent.js",
|
||||
"default": "./dist/lib/page-agent.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
"NOTICE"
|
||||
],
|
||||
"description": "AI-powered UI agent for web applications - add intelligent automation to any webpage with a single script tag",
|
||||
"keywords": [
|
||||
"ai",
|
||||
"automation",
|
||||
"ui-agent",
|
||||
"browser-automation",
|
||||
"web-agent",
|
||||
"llm",
|
||||
"dom-interaction",
|
||||
"intelligent-ui"
|
||||
],
|
||||
"author": "Simon<gaomeng1900>",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/alibaba/page-agent.git",
|
||||
"directory": "packages/page-agent"
|
||||
},
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"scripts": {
|
||||
"build": "MODE=lib vite build && MODE=umd vite build",
|
||||
"build:lib": "MODE=lib vite build",
|
||||
"build:umd": "MODE=umd vite build",
|
||||
"build:watch": "MODE=lib vite build --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"ai-motion": "^0.4.7",
|
||||
"chalk": "^5.6.2",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@microsoft/api-extractor": "^7.55.1",
|
||||
"unplugin-dts": "^1.0.0-beta.6",
|
||||
"vite-plugin-css-injected-by-js": "^3.5.2"
|
||||
}
|
||||
}
|
||||
537
packages/page-agent/src/PageAgent.ts
Normal file
537
packages/page-agent/src/PageAgent.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* Copyright (C) 2025 Alibaba Group Holding Limited
|
||||
* All rights reserved.
|
||||
*/
|
||||
import chalk from 'chalk'
|
||||
import zod from 'zod'
|
||||
|
||||
import type { PageAgentConfig } from './config'
|
||||
import { MAX_STEPS, VIEWPORT_EXPANSION } from './config/constants'
|
||||
import * as dom from './dom'
|
||||
import { FlatDomTree, InteractiveElementDomNode } from './dom/dom_tree/type'
|
||||
import { getPageInfo } from './dom/getPageInfo'
|
||||
import { I18n } from './i18n'
|
||||
import { LLM, type Tool } from './llms'
|
||||
import { patchReact } from './patches/react'
|
||||
import SYSTEM_PROMPT from './prompts/system_prompt.md?raw'
|
||||
import { tools } from './tools'
|
||||
import { Panel, getToolCompletedText, getToolExecutingText } from './ui/Panel'
|
||||
import { SimulatorMask } from './ui/SimulatorMask'
|
||||
import { trimLines, uid, waitUntil } from './utils'
|
||||
import { assert } from './utils/assert'
|
||||
import { getEventBus } from './utils/bus'
|
||||
|
||||
export type { PageAgentConfig }
|
||||
export { tool, type PageAgentTool } from './tools'
|
||||
|
||||
export interface AgentBrain {
|
||||
// thinking?: string
|
||||
evaluation_previous_goal: string
|
||||
memory: string
|
||||
next_goal: string
|
||||
}
|
||||
|
||||
/**
|
||||
* MacroTool input structure
|
||||
*/
|
||||
export interface MacroToolInput {
|
||||
evaluation_previous_goal?: string
|
||||
memory?: string
|
||||
next_goal?: string
|
||||
action: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* MacroTool output structure
|
||||
*/
|
||||
export interface MacroToolResult {
|
||||
input: MacroToolInput
|
||||
output: string
|
||||
}
|
||||
|
||||
export interface AgentHistory {
|
||||
brain: AgentBrain
|
||||
action: {
|
||||
name: string
|
||||
input: any
|
||||
output: string
|
||||
}
|
||||
usage: {
|
||||
promptTokens: number
|
||||
completionTokens: number
|
||||
totalTokens: number
|
||||
cachedTokens?: number
|
||||
reasoningTokens?: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExecutionResult {
|
||||
success: boolean
|
||||
data: string
|
||||
history: AgentHistory[]
|
||||
}
|
||||
|
||||
export class PageAgent extends EventTarget {
|
||||
config: PageAgentConfig
|
||||
id = uid()
|
||||
bus = getEventBus(this.id)
|
||||
i18n: I18n
|
||||
panel: Panel
|
||||
tools: typeof tools
|
||||
paused = false
|
||||
disposed = false
|
||||
task = ''
|
||||
taskId = ''
|
||||
|
||||
#llm: LLM
|
||||
#totalWaitTime = 0
|
||||
#abortController = new AbortController()
|
||||
|
||||
/** Corresponds to eval_page in browser-use */
|
||||
flatTree: FlatDomTree | null = null
|
||||
/**
|
||||
* All highlighted index-mapped interactive elements
|
||||
* Corresponds to DOMState.selector_map in browser-use
|
||||
*/
|
||||
selectorMap = new Map<number, InteractiveElementDomNode>()
|
||||
/** highlight index -> element text */
|
||||
elementTextMap = new Map<number, string>()
|
||||
/** Corresponds to clickable_elements_to_string in browser-use */
|
||||
simplifiedHTML = '<EMPTY>'
|
||||
/** last time the tree was updated */
|
||||
lastTimeUpdate = 0
|
||||
|
||||
/** Fullscreen mask */
|
||||
mask = new SimulatorMask()
|
||||
/** History records */
|
||||
history: AgentHistory[] = []
|
||||
|
||||
constructor(config: PageAgentConfig = {}) {
|
||||
super()
|
||||
|
||||
this.config = config
|
||||
this.#llm = new LLM(this.config, this.id)
|
||||
this.i18n = new I18n(this.config.language)
|
||||
this.panel = new Panel(this)
|
||||
this.tools = new Map(tools)
|
||||
|
||||
if (this.config.customTools) {
|
||||
for (const [name, tool] of Object.entries(this.config.customTools)) {
|
||||
if (tool === null) {
|
||||
this.tools.delete(name)
|
||||
continue
|
||||
}
|
||||
this.tools.set(name, tool)
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.config.experimentalScriptExecutionTool) {
|
||||
this.tools.delete('execute_javascript')
|
||||
}
|
||||
|
||||
patchReact(this)
|
||||
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
if (!this.disposed) this.dispose('PAGE_UNLOADING')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo maybe return something?
|
||||
*/
|
||||
async execute(task: string): Promise<ExecutionResult> {
|
||||
if (!task) throw new Error('Task is required')
|
||||
this.task = task
|
||||
this.taskId = uid()
|
||||
|
||||
const onBeforeStep = this.config.onBeforeStep || (() => void 0)
|
||||
const onAfterStep = this.config.onAfterStep || (() => void 0)
|
||||
const onBeforeTask = this.config.onBeforeTask || (() => void 0)
|
||||
const onAfterTask = this.config.onAfterTask || (() => void 0)
|
||||
|
||||
await onBeforeTask.call(this)
|
||||
|
||||
// Show mask and panel
|
||||
this.mask.show()
|
||||
|
||||
this.bus.emit('panel:show')
|
||||
this.bus.emit('panel:reset')
|
||||
|
||||
this.bus.emit('panel:update', {
|
||||
type: 'input',
|
||||
displayText: this.task,
|
||||
})
|
||||
|
||||
if (this.#abortController) {
|
||||
this.#abortController.abort()
|
||||
this.#abortController = new AbortController()
|
||||
}
|
||||
|
||||
this.history = []
|
||||
|
||||
try {
|
||||
let step = 0
|
||||
|
||||
while (true) {
|
||||
await onBeforeStep.call(this, step)
|
||||
|
||||
console.group(`step: ${step + 1}`)
|
||||
|
||||
// abort
|
||||
if (this.#abortController.signal.aborted) throw new Error('AbortError')
|
||||
// pause
|
||||
await waitUntil(() => !this.paused)
|
||||
|
||||
// Update status to thinking
|
||||
console.log(chalk.blue('Thinking...'))
|
||||
this.bus.emit('panel:update', {
|
||||
type: 'thinking',
|
||||
displayText: this.i18n.t('ui.panel.thinking'),
|
||||
})
|
||||
|
||||
const result = await this.#llm.invoke(
|
||||
[
|
||||
{
|
||||
role: 'system',
|
||||
content: this.#getSystemPrompt(),
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: this.#assembleUserPrompt(),
|
||||
},
|
||||
],
|
||||
{ AgentOutput: this.#packMacroTool() },
|
||||
this.#abortController.signal
|
||||
)
|
||||
|
||||
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 actionName = Object.keys(input.action)[0]
|
||||
const action = {
|
||||
name: actionName,
|
||||
input: input.action[actionName],
|
||||
output: output,
|
||||
}
|
||||
|
||||
this.history.push({
|
||||
brain,
|
||||
action,
|
||||
usage: result.usage,
|
||||
})
|
||||
|
||||
console.log(chalk.green('Step finished:'), actionName)
|
||||
console.groupEnd()
|
||||
|
||||
await onAfterStep.call(this, step, this.history)
|
||||
|
||||
step++
|
||||
if (step > MAX_STEPS) {
|
||||
this.#onDone('Step count exceeded maximum limit', false)
|
||||
const result: ExecutionResult = {
|
||||
success: false,
|
||||
data: 'Step count exceeded maximum limit',
|
||||
history: this.history,
|
||||
}
|
||||
await onAfterTask.call(this, result)
|
||||
return result
|
||||
}
|
||||
if (actionName === 'done') {
|
||||
const success = action.input?.success ?? false
|
||||
const text = action.input?.text || 'no text provided'
|
||||
console.log(chalk.green.bold('Task completed'), success, text)
|
||||
this.#onDone(text, success)
|
||||
const result: ExecutionResult = {
|
||||
success,
|
||||
data: text,
|
||||
history: this.history,
|
||||
}
|
||||
await onAfterTask.call(this, result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error('Task failed', error)
|
||||
this.#onDone(String(error), false)
|
||||
const result: ExecutionResult = {
|
||||
success: false,
|
||||
data: String(error),
|
||||
history: this.history,
|
||||
}
|
||||
await onAfterTask.call(this, result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all tools into a single MacroTool with the following input:
|
||||
* - thinking: string
|
||||
* - evaluation_previous_goal: string
|
||||
* - memory: string
|
||||
* - next_goal: string
|
||||
* - action: { toolName: toolInput }
|
||||
* where action must be selected from tools defined in this.tools
|
||||
*/
|
||||
#packMacroTool(): Tool<MacroToolInput, MacroToolResult> {
|
||||
const tools = this.tools
|
||||
|
||||
const actionSchemas = Array.from(tools.entries()).map(([toolName, tool]) => {
|
||||
return zod.object({
|
||||
[toolName]: tool.inputSchema,
|
||||
})
|
||||
})
|
||||
|
||||
const actionSchema = zod.union(
|
||||
actionSchemas as unknown as [zod.ZodType, zod.ZodType, ...zod.ZodType[]]
|
||||
)
|
||||
|
||||
const macroToolSchema = zod.object({
|
||||
// thinking: zod.string().optional(),
|
||||
evaluation_previous_goal: zod.string().optional(),
|
||||
memory: zod.string().optional(),
|
||||
next_goal: zod.string().optional(),
|
||||
action: actionSchema,
|
||||
})
|
||||
|
||||
return {
|
||||
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.bus.emit('panel:update', {
|
||||
type: 'thinking',
|
||||
displayText: brain,
|
||||
})
|
||||
|
||||
// Find the corresponding tool
|
||||
const tool = tools.get(toolName)
|
||||
assert(tool, `Tool ${toolName} not found. (@note should have been caught before this!!!)`)
|
||||
|
||||
console.log(chalk.blue.bold(`Executing tool: ${toolName}`), toolInput)
|
||||
this.bus.emit('panel:update', {
|
||||
type: 'tool_executing',
|
||||
toolName,
|
||||
toolArgs: toolInput,
|
||||
displayText: getToolExecutingText(toolName, toolInput, this.i18n),
|
||||
})
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
// Execute tool, bind `this` to PageAgent
|
||||
let 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
|
||||
}
|
||||
|
||||
// Briefly display execution result
|
||||
const displayResult = getToolCompletedText(toolName, toolInput, this.i18n)
|
||||
if (displayResult)
|
||||
this.bus.emit('panel:update', {
|
||||
type: 'tool_executing',
|
||||
toolName,
|
||||
toolArgs: toolInput,
|
||||
toolResult: result,
|
||||
displayText: displayResult,
|
||||
duration,
|
||||
})
|
||||
|
||||
// Wait a moment to let user see the result
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Return structured result
|
||||
return {
|
||||
input,
|
||||
output: result,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system prompt, dynamically replace language settings based on configured language
|
||||
*/
|
||||
#getSystemPrompt(): string {
|
||||
let systemPrompt = SYSTEM_PROMPT
|
||||
|
||||
const targetLanguage = this.config.language === 'zh-CN' ? '中文' : 'English'
|
||||
systemPrompt = systemPrompt.replace(
|
||||
/Default working language: \*\*.*?\*\*/,
|
||||
`Default working language: **${targetLanguage}**`
|
||||
)
|
||||
|
||||
return systemPrompt
|
||||
}
|
||||
|
||||
#assembleUserPrompt(): string {
|
||||
let prompt = ''
|
||||
|
||||
// <agent_history>
|
||||
// - <step_>
|
||||
|
||||
prompt += '<agent_history>\n'
|
||||
|
||||
this.history.forEach((history, index) => {
|
||||
prompt += `<step_${index + 1}>
|
||||
Evaluation of Previous Step: ${history.brain.evaluation_previous_goal}
|
||||
Memory: ${history.brain.memory}
|
||||
Next Goal: ${history.brain.next_goal}
|
||||
Action Results: ${history.action.output}
|
||||
</step_${index + 1}>
|
||||
`
|
||||
})
|
||||
|
||||
prompt += '</agent_history>\n\n'
|
||||
|
||||
// <agent_state>
|
||||
// - <user_request>
|
||||
// - <step_info>
|
||||
// <agent_state>
|
||||
|
||||
prompt += `<agent_state>
|
||||
<user_request>
|
||||
${this.task}
|
||||
</user_request>
|
||||
<step_info>
|
||||
Step ${this.history.length + 1} of ${MAX_STEPS} max possible steps
|
||||
Current date and time: ${new Date().toISOString()}
|
||||
</step_info>
|
||||
</agent_state>
|
||||
`
|
||||
|
||||
// <browser_state>
|
||||
|
||||
prompt += this.#getBrowserState()
|
||||
|
||||
return trimLines(prompt)
|
||||
}
|
||||
|
||||
#onDone(text: string, success = true) {
|
||||
dom.cleanUpHighlights()
|
||||
|
||||
// Update panel status
|
||||
this.bus.emit('panel:update', {
|
||||
type: success ? 'output' : 'error',
|
||||
displayText: text,
|
||||
})
|
||||
|
||||
// Task completed
|
||||
this.bus.emit('panel:update', {
|
||||
type: 'completed',
|
||||
displayText: this.i18n.t('ui.panel.taskCompleted'),
|
||||
})
|
||||
|
||||
this.mask.hide()
|
||||
|
||||
this.#abortController.abort()
|
||||
}
|
||||
|
||||
#getBrowserState(): string {
|
||||
const pageUrl = window.location.href
|
||||
const pageTitle = document.title
|
||||
const pi = getPageInfo()
|
||||
|
||||
this.#updateTree()
|
||||
|
||||
let prompt = trimLines(`<browser_state>
|
||||
Current Page: [${pageTitle}](${pageUrl})
|
||||
|
||||
Page info: ${pi.viewport_width}x${pi.viewport_height}px viewport, ${pi.page_width}x${pi.page_height}px total page size, ${pi.pages_above.toFixed(1)} pages above, ${pi.pages_below.toFixed(1)} pages below, ${pi.total_pages.toFixed(1)} total pages, at ${(pi.current_page_position * 100).toFixed(0)}% of page
|
||||
|
||||
${VIEWPORT_EXPANSION === -1 ? 'Interactive elements from top layer of the current page (full page):' : 'Interactive elements from top layer of the current page inside the viewport:'}
|
||||
|
||||
`)
|
||||
|
||||
// Page header info
|
||||
const has_content_above = pi.pixels_above > 4
|
||||
if (has_content_above && VIEWPORT_EXPANSION !== -1) {
|
||||
prompt += `... ${pi.pixels_above} pixels above (${pi.pages_above.toFixed(1)} pages) - scroll to see more ...\n`
|
||||
} else {
|
||||
prompt += `[Start of page]\n`
|
||||
}
|
||||
|
||||
// Current viewport info
|
||||
prompt += this.simplifiedHTML
|
||||
prompt += `\n`
|
||||
|
||||
// Page footer info
|
||||
const has_content_below = pi.pixels_below > 4
|
||||
if (has_content_below && VIEWPORT_EXPANSION !== -1) {
|
||||
prompt += `... ${pi.pixels_below} pixels below (${pi.pages_below.toFixed(1)} pages) - scroll to see more ...\n`
|
||||
} else {
|
||||
prompt += `[End of page]\n`
|
||||
}
|
||||
|
||||
prompt += `</browser_state>\n`
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
/**
|
||||
* Update document tree
|
||||
*/
|
||||
#updateTree() {
|
||||
this.dispatchEvent(new Event('beforeUpdate'))
|
||||
this.lastTimeUpdate = Date.now()
|
||||
dom.cleanUpHighlights()
|
||||
this.mask.wrapper.style.pointerEvents = 'none'
|
||||
this.flatTree = dom.getFlatTree({
|
||||
...this.config,
|
||||
interactiveBlacklist: [
|
||||
...(this.config.interactiveBlacklist || []),
|
||||
...document.querySelectorAll('[data-page-agent-not-interactive]').values(),
|
||||
],
|
||||
})
|
||||
this.mask.wrapper.style.pointerEvents = 'auto'
|
||||
this.simplifiedHTML = dom.flatTreeToString(this.flatTree, this.config.include_attributes)
|
||||
this.selectorMap.clear()
|
||||
this.selectorMap = dom.getSelectorMap(this.flatTree)
|
||||
this.elementTextMap.clear()
|
||||
this.elementTextMap = dom.getElementTextMap(this.simplifiedHTML)
|
||||
this.dispatchEvent(new Event('afterUpdate'))
|
||||
}
|
||||
|
||||
dispose(reason?: string) {
|
||||
console.log('Disposing PageAgent...')
|
||||
this.disposed = true
|
||||
dom.cleanUpHighlights()
|
||||
this.flatTree = null
|
||||
this.selectorMap.clear()
|
||||
this.elementTextMap.clear()
|
||||
this.panel.dispose()
|
||||
this.mask.dispose()
|
||||
this.history = []
|
||||
this.#abortController.abort(reason ?? 'PageAgent disposed')
|
||||
|
||||
this.config.onDispose?.call(this, reason)
|
||||
}
|
||||
}
|
||||
29
packages/page-agent/src/config/constants.ts
Normal file
29
packages/page-agent/src/config/constants.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @note Since isTopElement depends on elementFromPoint,
|
||||
* it returns null when out of viewport, this feature has no practical use, only differ between -1 and 0
|
||||
*/
|
||||
// export const VIEWPORT_EXPANSION = 100
|
||||
export const VIEWPORT_EXPANSION = -1
|
||||
|
||||
// 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
|
||||
export const MAX_STEPS = 20
|
||||
export const DEFAULT_TEMPERATURE = 0.7 // higher randomness helps auto-recovery
|
||||
export const DEFAULT_MAX_TOKENS = 4096
|
||||
108
packages/page-agent/src/config/index.ts
Normal file
108
packages/page-agent/src/config/index.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { AgentHistory, ExecutionResult, PageAgent } from '../PageAgent'
|
||||
import type { DomConfig } from '../dom'
|
||||
import type { SupportedLanguage } from '../i18n'
|
||||
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 interface AgentConfig {
|
||||
// theme?: 'light' | 'dark'
|
||||
language?: SupportedLanguage
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
export type PageAgentConfig = LLMConfig & AgentConfig & DomConfig
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
1685
packages/page-agent/src/dom/dom_tree/index.js
Normal file
1685
packages/page-agent/src/dom/dom_tree/index.js
Normal file
File diff suppressed because it is too large
Load Diff
51
packages/page-agent/src/dom/dom_tree/type.ts
Normal file
51
packages/page-agent/src/dom/dom_tree/type.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// FlatDomTree: 扁平化 DOM 树结构,适用于高效存储和遍历页面结构。
|
||||
// 每个节点通过 map 索引,支持文本节点和元素节点,字段区分 undefined 和 false。
|
||||
|
||||
export interface FlatDomTree {
|
||||
rootId: string
|
||||
map: Record<string, DomNode>
|
||||
}
|
||||
|
||||
export type DomNode = TextDomNode | ElementDomNode | InteractiveElementDomNode
|
||||
|
||||
export interface TextDomNode {
|
||||
type: 'TEXT_NODE'
|
||||
text: string
|
||||
isVisible: boolean
|
||||
// 其他可选字段
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface ElementDomNode {
|
||||
tagName: string
|
||||
attributes?: Record<string, string>
|
||||
xpath?: string
|
||||
children?: string[]
|
||||
isVisible?: boolean
|
||||
isTopElement?: boolean
|
||||
isInViewport?: boolean
|
||||
isNew?: boolean
|
||||
isInteractive?: false
|
||||
highlightIndex?: number
|
||||
extra?: Record<string, any>
|
||||
// 其他可选字段
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface InteractiveElementDomNode {
|
||||
tagName: string
|
||||
attributes?: Record<string, string>
|
||||
xpath?: string
|
||||
children?: string[]
|
||||
isVisible?: boolean
|
||||
isTopElement?: boolean
|
||||
isInViewport?: boolean
|
||||
isInteractive: true
|
||||
highlightIndex: number
|
||||
/**
|
||||
* 可交互元素的 dom 引用
|
||||
*/
|
||||
ref: HTMLElement
|
||||
// 其他可选字段
|
||||
[key: string]: unknown
|
||||
}
|
||||
42
packages/page-agent/src/dom/getPageInfo.ts
Normal file
42
packages/page-agent/src/dom/getPageInfo.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export function getPageInfo() {
|
||||
const viewport_width = window.innerWidth
|
||||
const viewport_height = window.innerHeight
|
||||
|
||||
const page_width = Math.max(document.documentElement.scrollWidth, document.body.scrollWidth || 0)
|
||||
const page_height = Math.max(
|
||||
document.documentElement.scrollHeight,
|
||||
document.body.scrollHeight || 0
|
||||
)
|
||||
|
||||
const scroll_x = window.scrollX || window.pageXOffset || document.documentElement.scrollLeft || 0
|
||||
const scroll_y = window.scrollY || window.pageYOffset || document.documentElement.scrollTop || 0
|
||||
|
||||
const pixels_below = Math.max(0, page_height - (window.innerHeight + scroll_y))
|
||||
const pixels_right = Math.max(0, page_width - (window.innerWidth + scroll_x))
|
||||
|
||||
return {
|
||||
// Current viewport dimensions
|
||||
viewport_width,
|
||||
viewport_height,
|
||||
|
||||
// Total page dimensions
|
||||
page_width,
|
||||
page_height,
|
||||
|
||||
// Current scroll position
|
||||
scroll_x,
|
||||
scroll_y,
|
||||
|
||||
pixels_above: scroll_y,
|
||||
pixels_below,
|
||||
|
||||
pages_above: viewport_height > 0 ? scroll_y / viewport_height : 0,
|
||||
pages_below: viewport_height > 0 ? pixels_below / viewport_height : 0,
|
||||
total_pages: viewport_height > 0 ? page_height / viewport_height : 0,
|
||||
|
||||
current_page_position: scroll_y / Math.max(1, page_height - viewport_height),
|
||||
|
||||
pixels_left: scroll_x,
|
||||
pixels_right,
|
||||
}
|
||||
}
|
||||
475
packages/page-agent/src/dom/index.ts
Normal file
475
packages/page-agent/src/dom/index.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
import { VIEWPORT_EXPANSION } from '../config/constants'
|
||||
import domTree from './dom_tree/index'
|
||||
import {
|
||||
ElementDomNode,
|
||||
FlatDomTree,
|
||||
InteractiveElementDomNode,
|
||||
TextDomNode,
|
||||
} from './dom_tree/type'
|
||||
|
||||
export interface DomConfig {
|
||||
interactiveBlacklist?: (Element | (() => Element))[]
|
||||
interactiveWhitelist?: (Element | (() => Element))[]
|
||||
include_attributes?: string[]
|
||||
highlightOpacity?: number
|
||||
highlightLabelOpacity?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 用于检测可交互元素是否是新出现的。
|
||||
*/
|
||||
const newElementsCache = new WeakMap<HTMLElement, string>()
|
||||
|
||||
export function getFlatTree(config: DomConfig): FlatDomTree {
|
||||
const interactiveBlacklist = [] as Element[]
|
||||
for (const item of config.interactiveBlacklist || []) {
|
||||
if (typeof item === 'function') {
|
||||
interactiveBlacklist.push(item())
|
||||
} else {
|
||||
interactiveBlacklist.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
const interactiveWhitelist = [] as Element[]
|
||||
for (const item of config.interactiveWhitelist || []) {
|
||||
if (typeof item === 'function') {
|
||||
interactiveWhitelist.push(item())
|
||||
} else {
|
||||
interactiveWhitelist.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
const elements = domTree({
|
||||
doHighlightElements: true,
|
||||
debugMode: true,
|
||||
focusHighlightIndex: -1,
|
||||
viewportExpansion: VIEWPORT_EXPANSION,
|
||||
interactiveBlacklist,
|
||||
interactiveWhitelist,
|
||||
highlightOpacity: config.highlightOpacity ?? 0.0,
|
||||
highlightLabelOpacity: config.highlightLabelOpacity ?? 0.1,
|
||||
}) as FlatDomTree
|
||||
|
||||
const currentUrl = window.location.href
|
||||
|
||||
/**
|
||||
* 标记新出现的元素
|
||||
* @todo browser-use 使用 hash(位置,属性等信息) 来判断是否同一个元素,
|
||||
* 能够解决 1. 元素被删除后重新添加 2. 页面卸载 等问题。
|
||||
* 这里先简单做.
|
||||
*/
|
||||
for (const nodeId in elements.map) {
|
||||
const node = elements.map[nodeId]
|
||||
if (node.isInteractive && node.ref) {
|
||||
const ref = node.ref as HTMLElement
|
||||
// @note 这样太严格,元素是可以跨页面存在的
|
||||
// if (newElementsCache.get(ref) !== currentUrl) {
|
||||
if (!newElementsCache.has(ref)) {
|
||||
newElementsCache.set(ref, currentUrl)
|
||||
node.isNew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return elements
|
||||
}
|
||||
|
||||
/**
|
||||
* elementsToString 内部使用的类型
|
||||
*/
|
||||
interface TreeNode {
|
||||
type: 'text' | 'element'
|
||||
parent: TreeNode | null
|
||||
children: TreeNode[]
|
||||
isVisible: boolean
|
||||
// Text node properties
|
||||
text?: string
|
||||
// Element node properties
|
||||
tagName?: string
|
||||
attributes?: Record<string, string>
|
||||
isInteractive?: boolean
|
||||
isTopElement?: boolean
|
||||
isNew?: boolean
|
||||
highlightIndex?: number
|
||||
extra?: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* 对应 python 中的 views::clickable_elements_to_string,
|
||||
* 将 dom 信息处理成适合 llm 阅读的文本格式
|
||||
* @形如
|
||||
* ``` text
|
||||
* [0]<a aria-label=page-agent.js 首页 />
|
||||
* [1]<div >P />
|
||||
* [2]<div >page-agent.js
|
||||
* UI Agent in your webpage />
|
||||
* [3]<a >文档 />
|
||||
* [4]<a aria-label=查看源码(在新窗口打开)>源码 />
|
||||
* UI Agent in your webpage
|
||||
* 用户输入需求,AI 理解页面并自动操作。
|
||||
* [5]<a role=button>快速开始 />
|
||||
* [6]<a role=button>查看文档 />
|
||||
* 无需后端
|
||||
* ```
|
||||
* 其中可交互元素用序号标出,提示llm可以用序号操作。
|
||||
* 缩进代表父子关系。
|
||||
* 普通文本则直接列出来。
|
||||
*
|
||||
* @todo 数据脱敏过滤器
|
||||
*/
|
||||
export function flatTreeToString(flatTree: FlatDomTree, include_attributes?: string[]): string {
|
||||
const DEFAULT_INCLUDE_ATTRIBUTES = [
|
||||
'title',
|
||||
'type',
|
||||
'checked',
|
||||
'name',
|
||||
'role',
|
||||
'value',
|
||||
'placeholder',
|
||||
'data-date-format',
|
||||
'alt',
|
||||
'aria-label',
|
||||
'aria-expanded',
|
||||
'data-state',
|
||||
'aria-checked',
|
||||
|
||||
// @edit added for better form handling
|
||||
'id',
|
||||
'for',
|
||||
|
||||
// for jump check
|
||||
'target',
|
||||
|
||||
// absolute 定位的下拉菜单
|
||||
'aria-haspopup',
|
||||
'aria-controls',
|
||||
'aria-owns',
|
||||
]
|
||||
|
||||
const includeAttrs = [...(include_attributes || []), ...DEFAULT_INCLUDE_ATTRIBUTES]
|
||||
|
||||
// Helper function to cap text length
|
||||
const capTextLength = (text: string, maxLength: number): string => {
|
||||
if (text.length > maxLength) {
|
||||
return text.substring(0, maxLength) + '...'
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// Build tree structure from flat map
|
||||
const buildTreeNode = (nodeId: string): TreeNode | null => {
|
||||
const node = flatTree.map[nodeId]
|
||||
if (!node) return null
|
||||
|
||||
if (node.type === 'TEXT_NODE') {
|
||||
const textNode = node as TextDomNode
|
||||
return {
|
||||
type: 'text',
|
||||
text: textNode.text,
|
||||
isVisible: textNode.isVisible,
|
||||
parent: null,
|
||||
children: [],
|
||||
}
|
||||
} else {
|
||||
const elementNode = node as ElementDomNode
|
||||
const children: TreeNode[] = []
|
||||
|
||||
if (elementNode.children) {
|
||||
for (const childId of elementNode.children) {
|
||||
const child = buildTreeNode(childId)
|
||||
if (child) {
|
||||
child.parent = null // Will be set later
|
||||
children.push(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'element',
|
||||
tagName: elementNode.tagName,
|
||||
attributes: elementNode.attributes ?? {},
|
||||
isVisible: elementNode.isVisible ?? false,
|
||||
isInteractive: elementNode.isInteractive ?? false,
|
||||
isTopElement: elementNode.isTopElement ?? false,
|
||||
isNew: elementNode.isNew ?? false,
|
||||
highlightIndex: elementNode.highlightIndex,
|
||||
parent: null,
|
||||
children,
|
||||
extra: elementNode.extra ?? {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set parent references
|
||||
const setParentReferences = (node: TreeNode, parent: TreeNode | null = null) => {
|
||||
node.parent = parent
|
||||
for (const child of node.children) {
|
||||
setParentReferences(child, node)
|
||||
}
|
||||
}
|
||||
|
||||
// Build root node
|
||||
const rootNode = buildTreeNode(flatTree.rootId)
|
||||
if (!rootNode) return ''
|
||||
|
||||
setParentReferences(rootNode)
|
||||
|
||||
// Helper to check if text node has parent with highlight index
|
||||
const hasParentWithHighlightIndex = (node: TreeNode): boolean => {
|
||||
let current = node.parent
|
||||
while (current) {
|
||||
if (current.type === 'element' && current.highlightIndex !== undefined) {
|
||||
return true
|
||||
}
|
||||
current = current.parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Helper to check if parent is top element
|
||||
// const isParentTopElement = (node: TreeNode): boolean => {
|
||||
// return node.parent?.type === 'element' && node.parent.isTopElement === true
|
||||
// }
|
||||
|
||||
// Main processing function
|
||||
const processNode = (node: TreeNode, depth: number, result: string[]): void => {
|
||||
let nextDepth = depth
|
||||
const depthStr = '\t'.repeat(depth)
|
||||
|
||||
if (node.type === 'element') {
|
||||
// Add element with highlight_index
|
||||
if (node.highlightIndex !== undefined) {
|
||||
nextDepth += 1
|
||||
|
||||
const text = getAllTextTillNextClickableElement(node)
|
||||
let attributesHtmlStr = ''
|
||||
|
||||
if (includeAttrs.length > 0 && node.attributes) {
|
||||
const attributesToInclude: Record<string, string> = {}
|
||||
|
||||
// Filter attributes
|
||||
for (const key of includeAttrs) {
|
||||
const value = node.attributes[key]
|
||||
if (value && value.trim() !== '') {
|
||||
attributesToInclude[key] = value.trim()
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicate values (for attributes longer than 5 chars)
|
||||
const orderedKeys = includeAttrs.filter((key) => key in attributesToInclude)
|
||||
if (orderedKeys.length > 1) {
|
||||
const keysToRemove = new Set<string>()
|
||||
const seenValues: Record<string, string> = {}
|
||||
|
||||
for (const key of orderedKeys) {
|
||||
const value = attributesToInclude[key]
|
||||
if (value.length > 5) {
|
||||
if (value in seenValues) {
|
||||
keysToRemove.add(key)
|
||||
} else {
|
||||
seenValues[value] = key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of keysToRemove) {
|
||||
delete attributesToInclude[key]
|
||||
}
|
||||
}
|
||||
|
||||
// Remove role if it matches tagName
|
||||
if (attributesToInclude.role === node.tagName) {
|
||||
delete attributesToInclude.role
|
||||
}
|
||||
|
||||
// Remove attributes that duplicate text content
|
||||
const attrsToRemoveIfTextMatches = ['aria-label', 'placeholder', 'title']
|
||||
for (const attr of attrsToRemoveIfTextMatches) {
|
||||
if (
|
||||
attributesToInclude[attr] &&
|
||||
attributesToInclude[attr].toLowerCase().trim() === text.toLowerCase().trim()
|
||||
) {
|
||||
delete attributesToInclude[attr]
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(attributesToInclude).length > 0) {
|
||||
attributesHtmlStr = Object.entries(attributesToInclude)
|
||||
.map(([key, value]) => `${key}=${capTextLength(value, 20)}`)
|
||||
.join(' ')
|
||||
}
|
||||
}
|
||||
|
||||
// Build the line
|
||||
const highlightIndicator = node.isNew
|
||||
? `*[${node.highlightIndex}]`
|
||||
: `[${node.highlightIndex}]`
|
||||
let line = `${depthStr}${highlightIndicator}<${node.tagName ?? ''}`
|
||||
|
||||
if (attributesHtmlStr) {
|
||||
line += ` ${attributesHtmlStr}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @edit scrollable 数据
|
||||
*/
|
||||
if (node.extra) {
|
||||
if (node.extra.scrollable) {
|
||||
let scrollDataText = ''
|
||||
if (node.extra.scrollData?.left)
|
||||
scrollDataText += `left=${node.extra.scrollData.left}, `
|
||||
if (node.extra.scrollData?.top) scrollDataText += `top=${node.extra.scrollData.top}, `
|
||||
if (node.extra.scrollData?.right)
|
||||
scrollDataText += `right=${node.extra.scrollData.right}, `
|
||||
if (node.extra.scrollData?.bottom)
|
||||
scrollDataText += `bottom=${node.extra.scrollData.bottom}`
|
||||
|
||||
line += ` data-scrollable="${scrollDataText}"`
|
||||
}
|
||||
}
|
||||
|
||||
if (text) {
|
||||
const trimmedText = text.trim()
|
||||
if (!attributesHtmlStr) {
|
||||
line += ' '
|
||||
}
|
||||
line += `>${trimmedText}`
|
||||
} else if (!attributesHtmlStr) {
|
||||
line += ' '
|
||||
}
|
||||
|
||||
line += ' />'
|
||||
result.push(line)
|
||||
}
|
||||
|
||||
// Process children regardless
|
||||
for (const child of node.children) {
|
||||
processNode(child, nextDepth, result)
|
||||
}
|
||||
} else if (node.type === 'text') {
|
||||
// Add text only if it doesn't have a highlighted parent
|
||||
if (hasParentWithHighlightIndex(node)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
node.parent &&
|
||||
node.parent.type === 'element' &&
|
||||
node.parent.isVisible &&
|
||||
node.parent.isTopElement
|
||||
) {
|
||||
result.push(`${depthStr}${node.text ?? ''}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result: string[] = []
|
||||
processNode(rootNode, 0, result)
|
||||
return result.join('\n')
|
||||
}
|
||||
|
||||
// Get all text until next clickable element
|
||||
export const getAllTextTillNextClickableElement = (node: TreeNode, maxDepth = -1): string => {
|
||||
const textParts: string[] = []
|
||||
|
||||
const collectText = (currentNode: TreeNode, currentDepth: number) => {
|
||||
if (maxDepth !== -1 && currentDepth > maxDepth) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip this branch if we hit a highlighted element (except for the current node)
|
||||
if (
|
||||
currentNode.type === 'element' &&
|
||||
currentNode !== node &&
|
||||
currentNode.highlightIndex !== undefined
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentNode.type === 'text' && currentNode.text) {
|
||||
textParts.push(currentNode.text)
|
||||
} else if (currentNode.type === 'element') {
|
||||
for (const child of currentNode.children) {
|
||||
collectText(child, currentDepth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collectText(node, 0)
|
||||
return textParts.join('\n').trim()
|
||||
}
|
||||
|
||||
export function getSelectorMap(flatTree: FlatDomTree): Map<number, InteractiveElementDomNode> {
|
||||
const selectorMap = new Map<number, InteractiveElementDomNode>()
|
||||
|
||||
const keys = Object.keys(flatTree.map)
|
||||
for (const key of keys) {
|
||||
const node = flatTree.map[key]
|
||||
if (node.isInteractive && typeof node.highlightIndex === 'number') {
|
||||
selectorMap.set(node.highlightIndex, node as InteractiveElementDomNode)
|
||||
}
|
||||
}
|
||||
|
||||
return selectorMap
|
||||
}
|
||||
|
||||
export function getElementTextMap(simplifiedHTML: string) {
|
||||
const lines = simplifiedHTML
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
const elementTextMap = new Map<number, string>()
|
||||
for (const line of lines) {
|
||||
const regex = /^\[(\d+)\]<[^>]+>([^<]*)/
|
||||
const match = regex.exec(line)
|
||||
if (match) {
|
||||
const index = parseInt(match[1], 10)
|
||||
elementTextMap.set(index, line)
|
||||
}
|
||||
}
|
||||
|
||||
return elementTextMap
|
||||
}
|
||||
|
||||
export function cleanUpHighlights() {
|
||||
const cleanupFunctions = (window as any)._highlightCleanupFunctions || []
|
||||
for (const cleanup of cleanupFunctions) {
|
||||
if (typeof cleanup === 'function') {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
;(window as any)._highlightCleanupFunctions = []
|
||||
}
|
||||
|
||||
// 监听 URL 的任何变化,立刻清空 highLights
|
||||
window.addEventListener('popstate', () => {
|
||||
// console.log('URL changed (popstate), highlights cleaned up.')
|
||||
cleanUpHighlights()
|
||||
})
|
||||
window.addEventListener('hashchange', () => {
|
||||
// console.log('URL changed (hashchange), highlights cleaned up.')
|
||||
cleanUpHighlights()
|
||||
})
|
||||
window.addEventListener('beforeunload', () => {
|
||||
// console.log('Page is unloading, highlights cleaned up.')
|
||||
cleanUpHighlights()
|
||||
})
|
||||
|
||||
const navigation = (window as any).navigation
|
||||
if (navigation && typeof navigation.addEventListener === 'function') {
|
||||
navigation.addEventListener('navigate', () => {
|
||||
// console.log('Navigation event detected, highlights cleaned up.')
|
||||
cleanUpHighlights()
|
||||
})
|
||||
} else {
|
||||
// 定时器
|
||||
let currentUrl = window.location.href
|
||||
setInterval(() => {
|
||||
if (window.location.href !== currentUrl) {
|
||||
currentUrl = window.location.href
|
||||
// console.log('URL changed (interval), highlights cleaned up.')
|
||||
cleanUpHighlights()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
40
packages/page-agent/src/entry.ts
Normal file
40
packages/page-agent/src/entry.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Auto-run entry for page-agent.js. Insert this script into your page to get page-agent functionality.
|
||||
*/
|
||||
import { PageAgent, type PageAgentConfig } from './PageAgent'
|
||||
|
||||
// Clean up existing instances to prevent multiple injections from bookmarklet
|
||||
if (window.pageAgent) {
|
||||
window.pageAgent.dispose()
|
||||
}
|
||||
|
||||
// Mount to global window object
|
||||
window.PageAgent = PageAgent
|
||||
|
||||
// Export for ES module usage
|
||||
// export { PageAgent }
|
||||
|
||||
console.log('🚀 page-agent.js loaded!')
|
||||
|
||||
const 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 currentScript = document.currentScript as HTMLScriptElement | null
|
||||
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') as 'zh-CN' | 'en-US') || 'zh-CN'
|
||||
const config: PageAgentConfig = { model, baseURL, apiKey, language }
|
||||
window.pageAgent = new PageAgent(config)
|
||||
} else {
|
||||
console.log('🚀 page-agent.js no current script detected, using default demo config')
|
||||
window.pageAgent = new PageAgent()
|
||||
}
|
||||
|
||||
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
|
||||
|
||||
window.pageAgent.bus.emit('panel:show') // Show panel
|
||||
50
packages/page-agent/src/i18n/index.ts
Normal file
50
packages/page-agent/src/i18n/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
type SupportedLanguage,
|
||||
type TranslationKey,
|
||||
type TranslationParams,
|
||||
type TranslationSchema,
|
||||
locales,
|
||||
} from './locales'
|
||||
|
||||
export class I18n {
|
||||
private language: SupportedLanguage
|
||||
private translations: TranslationSchema
|
||||
|
||||
constructor(language: SupportedLanguage = 'en-US') {
|
||||
this.language = language in locales ? language : 'en-US'
|
||||
this.translations = locales[language]
|
||||
}
|
||||
|
||||
// 类型安全的翻译方法
|
||||
t(key: TranslationKey, params?: TranslationParams): string {
|
||||
const value = this.getNestedValue(this.translations, key)
|
||||
if (!value) {
|
||||
console.warn(`Translation key "${key}" not found for language "${this.language}"`)
|
||||
return key
|
||||
}
|
||||
|
||||
if (params) {
|
||||
return this.interpolate(value, params)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private getNestedValue(obj: any, path: string): string | undefined {
|
||||
return path.split('.').reduce((current, key) => current?.[key], obj)
|
||||
}
|
||||
|
||||
private interpolate(template: string, params: TranslationParams): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||
// Use != null to check for both null and undefined, allow empty strings
|
||||
return params[key] != null ? params[key].toString() : match
|
||||
})
|
||||
}
|
||||
|
||||
getLanguage(): SupportedLanguage {
|
||||
return this.language
|
||||
}
|
||||
}
|
||||
|
||||
// 导出类型和实例创建函数
|
||||
export type { TranslationKey, SupportedLanguage, TranslationParams }
|
||||
export { locales }
|
||||
126
packages/page-agent/src/i18n/locales.ts
Normal file
126
packages/page-agent/src/i18n/locales.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
// English translations (base/reference language)
|
||||
const enUS = {
|
||||
ui: {
|
||||
panel: {
|
||||
ready: 'Ready',
|
||||
thinking: 'Thinking...',
|
||||
paused: 'Paused',
|
||||
taskInput: 'Enter new task, describe steps in detail, press Enter to submit',
|
||||
userAnswerPrompt: 'Please answer the question above, press Enter to submit',
|
||||
taskTerminated: 'Task terminated',
|
||||
taskCompleted: 'Task completed',
|
||||
continueExecution: 'Continue execution',
|
||||
userAnswer: 'User answer: {{input}}',
|
||||
question: 'Question: {{question}}',
|
||||
waitingPlaceholder: 'Waiting for task to start...',
|
||||
pause: 'Pause',
|
||||
continue: 'Continue',
|
||||
stop: 'Stop',
|
||||
expand: 'Expand history',
|
||||
collapse: 'Collapse history',
|
||||
step: 'Step {{number}} · {{time}}{{duration}}',
|
||||
},
|
||||
tools: {
|
||||
clicking: 'Clicking element [{{index}}]...',
|
||||
inputting: 'Inputting text to element [{{index}}]...',
|
||||
selecting: 'Selecting option "{{text}}"...',
|
||||
scrolling: 'Scrolling page...',
|
||||
waiting: 'Waiting {{seconds}} seconds...',
|
||||
done: 'Task done',
|
||||
clicked: '🖱️ Clicked element [{{index}}]',
|
||||
inputted: '⌨️ Inputted text "{{text}}"',
|
||||
selected: '☑️ Selected option "{{text}}"',
|
||||
scrolled: '🛞 Page scrolled',
|
||||
waited: '⌛️ Wait completed',
|
||||
executing: 'Executing {{toolName}}...',
|
||||
resultSuccess: 'success',
|
||||
resultFailure: 'failed',
|
||||
resultError: 'error',
|
||||
},
|
||||
errors: {
|
||||
elementNotFound: 'No interactive element found at index {{index}}',
|
||||
taskRequired: 'Task description is required',
|
||||
executionFailed: 'Task execution failed',
|
||||
notInputElement: 'Element is not an input or textarea',
|
||||
notSelectElement: 'Element is not a select element',
|
||||
optionNotFound: 'Option "{{text}}" not found',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
// Chinese translations (must match the structure of enUS)
|
||||
const zhCN = {
|
||||
ui: {
|
||||
panel: {
|
||||
ready: '准备就绪',
|
||||
thinking: '正在思考...',
|
||||
paused: '暂停中,稍后',
|
||||
taskInput: '输入新任务,详细描述步骤,回车提交',
|
||||
userAnswerPrompt: '请回答上面问题,回车提交',
|
||||
taskTerminated: '任务已终止',
|
||||
taskCompleted: '任务结束',
|
||||
continueExecution: '继续执行',
|
||||
userAnswer: '用户回答: {{input}}',
|
||||
question: '询问: {{question}}',
|
||||
waitingPlaceholder: '等待任务开始...',
|
||||
pause: '暂停',
|
||||
continue: '继续',
|
||||
stop: '终止',
|
||||
expand: '展开历史',
|
||||
collapse: '收起历史',
|
||||
step: '步骤 {{number}} · {{time}}{{duration}}',
|
||||
},
|
||||
tools: {
|
||||
clicking: '正在点击元素 [{{index}}]...',
|
||||
inputting: '正在输入文本到元素 [{{index}}]...',
|
||||
selecting: '正在选择选项 "{{text}}"...',
|
||||
scrolling: '正在滚动页面...',
|
||||
waiting: '等待 {{seconds}} 秒...',
|
||||
done: '结束任务',
|
||||
clicked: '🖱️ 已点击元素 [{{index}}]',
|
||||
inputted: '⌨️ 已输入文本 "{{text}}"',
|
||||
selected: '☑️ 已选择选项 "{{text}}"',
|
||||
scrolled: '🛞 页面滚动完成',
|
||||
waited: '⌛️ 等待完成',
|
||||
executing: '正在执行 {{toolName}}...',
|
||||
resultSuccess: '成功',
|
||||
resultFailure: '失败',
|
||||
resultError: '错误',
|
||||
},
|
||||
errors: {
|
||||
elementNotFound: '未找到索引为 {{index}} 的交互元素',
|
||||
taskRequired: '任务描述不能为空',
|
||||
executionFailed: '任务执行失败',
|
||||
notInputElement: '元素不是输入框或文本域',
|
||||
notSelectElement: '元素不是选择框',
|
||||
optionNotFound: '未找到选项 "{{text}}"',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
// Type definitions generated from English base structure (but with string values)
|
||||
type DeepStringify<T> = {
|
||||
[K in keyof T]: T[K] extends string ? string : T[K] extends object ? DeepStringify<T[K]> : T[K]
|
||||
}
|
||||
|
||||
export type TranslationSchema = DeepStringify<typeof enUS>
|
||||
|
||||
// Utility type: Extract all nested paths from translation object
|
||||
type NestedKeyOf<ObjectType extends object> = {
|
||||
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object
|
||||
? `${Key}` | `${Key}.${NestedKeyOf<ObjectType[Key]>}`
|
||||
: `${Key}`
|
||||
}[keyof ObjectType & (string | number)]
|
||||
|
||||
// Extract all possible key paths from translation structure
|
||||
export type TranslationKey = NestedKeyOf<TranslationSchema>
|
||||
|
||||
// Parameterized translation types
|
||||
export type TranslationParams = Record<string, string | number>
|
||||
|
||||
export const locales = {
|
||||
'en-US': enUS,
|
||||
'zh-CN': zhCN,
|
||||
} as const
|
||||
|
||||
export type SupportedLanguage = keyof typeof locales
|
||||
188
packages/page-agent/src/llms/OpenAIClient.ts
Normal file
188
packages/page-agent/src/llms/OpenAIClient.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
128
packages/page-agent/src/llms/OpenAILenientClient.ts
Normal file
128
packages/page-agent/src/llms/OpenAILenientClient.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
50
packages/page-agent/src/llms/errors.ts
Normal file
50
packages/page-agent/src/llms/errors.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Error types and error handling for LLM invocations
|
||||
*/
|
||||
|
||||
export const InvokeErrorType = {
|
||||
// Retryable
|
||||
NETWORK_ERROR: 'network_error', // Network error, retry
|
||||
RATE_LIMIT: 'rate_limit', // Rate limit, retry
|
||||
SERVER_ERROR: 'server_error', // 5xx, retry
|
||||
NO_TOOL_CALL: 'no_tool_call', // Model did not call tool
|
||||
INVALID_TOOL_ARGS: 'invalid_tool_args', // Tool args don't match schema
|
||||
TOOL_EXECUTION_ERROR: 'tool_execution_error', // Tool execution error
|
||||
|
||||
UNKNOWN: 'unknown',
|
||||
|
||||
// Non-retryable
|
||||
AUTH_ERROR: 'auth_error', // Authentication failed
|
||||
CONTEXT_LENGTH: 'context_length', // Prompt too long
|
||||
CONTENT_FILTER: 'content_filter', // Content filtered
|
||||
} as const
|
||||
|
||||
export type InvokeErrorType = (typeof InvokeErrorType)[keyof typeof InvokeErrorType]
|
||||
|
||||
export class InvokeError extends Error {
|
||||
type: InvokeErrorType
|
||||
retryable: boolean
|
||||
statusCode?: number
|
||||
rawError?: unknown
|
||||
|
||||
constructor(type: InvokeErrorType, message: string, rawError?: unknown) {
|
||||
super(message)
|
||||
this.name = 'InvokeError'
|
||||
this.type = type
|
||||
this.retryable = this.isRetryable(type)
|
||||
this.rawError = rawError
|
||||
}
|
||||
|
||||
private isRetryable(type: InvokeErrorType): boolean {
|
||||
const retryableTypes: InvokeErrorType[] = [
|
||||
InvokeErrorType.NETWORK_ERROR,
|
||||
InvokeErrorType.RATE_LIMIT,
|
||||
InvokeErrorType.SERVER_ERROR,
|
||||
InvokeErrorType.NO_TOOL_CALL,
|
||||
InvokeErrorType.INVALID_TOOL_ARGS,
|
||||
InvokeErrorType.TOOL_EXECUTION_ERROR,
|
||||
InvokeErrorType.UNKNOWN,
|
||||
]
|
||||
return retryableTypes.includes(type)
|
||||
}
|
||||
}
|
||||
137
packages/page-agent/src/llms/index.ts
Normal file
137
packages/page-agent/src/llms/index.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @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 { EventBus, getEventBus } from '../utils/bus'
|
||||
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 {
|
||||
config: Required<LLMConfig>
|
||||
id: string
|
||||
client: LLMClient
|
||||
#bus: EventBus
|
||||
|
||||
constructor(config: LLMConfig, id: string) {
|
||||
this.config = parseLLMConfig(config)
|
||||
this.id = id
|
||||
|
||||
this.#bus = getEventBus(id)
|
||||
|
||||
// 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: (retries: number) => {
|
||||
this.#bus.emit('panel:update', {
|
||||
type: 'retry',
|
||||
displayText: `retry-ing (${retries} / ${this.config.maxRetries})`,
|
||||
})
|
||||
},
|
||||
onError: (error: Error, withRetry: boolean) => {
|
||||
this.#bus.emit('panel:update', {
|
||||
type: 'error',
|
||||
displayText: `step failed: ${(error as Error).message}`,
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
settings: {
|
||||
maxRetries: number
|
||||
onRetry: (retries: number) => void
|
||||
onError: (error: Error, withRetry: boolean) => void
|
||||
}
|
||||
): Promise<T> {
|
||||
let retries = 0
|
||||
let lastError: Error | null = null
|
||||
while (retries <= settings.maxRetries) {
|
||||
if (retries > 0) {
|
||||
settings.onRetry(retries)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error: unknown) {
|
||||
console.error(error)
|
||||
settings.onError(error as Error, retries < settings.maxRetries)
|
||||
|
||||
// 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!
|
||||
}
|
||||
77
packages/page-agent/src/llms/types.ts
Normal file
77
packages/page-agent/src/llms/types.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Core types for LLM integration
|
||||
*/
|
||||
import type { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Message format - OpenAI standard (industry standard)
|
||||
*/
|
||||
export interface Message {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool'
|
||||
content?: string | null
|
||||
tool_calls?: {
|
||||
id: string
|
||||
type: 'function'
|
||||
function: {
|
||||
name: string
|
||||
arguments: string // JSON string
|
||||
}
|
||||
}[]
|
||||
tool_call_id?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool definition - uses Zod schema (LLM-agnostic)
|
||||
* Supports generics for type-safe parameters and return values
|
||||
*/
|
||||
export interface Tool<TParams = any, TResult = any> {
|
||||
// name: string
|
||||
description?: string
|
||||
inputSchema: z.ZodType<TParams>
|
||||
execute: (args: TParams) => Promise<TResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM Client interface
|
||||
* Note: Does not use generics because each tool in the tools array has different types
|
||||
*/
|
||||
export interface LLMClient {
|
||||
invoke(
|
||||
messages: Message[],
|
||||
tools: Record<string, Tool>,
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<InvokeResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke result (strict typing, supports generics)
|
||||
*/
|
||||
export interface InvokeResult<TResult = unknown> {
|
||||
toolCall: {
|
||||
// id?: string // OpenAI's tool_call_id
|
||||
name: string
|
||||
args: any
|
||||
}
|
||||
toolResult: TResult // Supports generics, but defaults to unknown
|
||||
usage: {
|
||||
promptTokens: number
|
||||
completionTokens: number
|
||||
totalTokens: number
|
||||
cachedTokens?: number // Prompt cache hits
|
||||
reasoningTokens?: number // OpenAI o1 series reasoning tokens
|
||||
}
|
||||
rawResponse?: unknown // Raw response for debugging
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI Client config
|
||||
*/
|
||||
export interface OpenAIClientConfig {
|
||||
model: string
|
||||
apiKey: string
|
||||
baseURL: string
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
maxRetries?: number
|
||||
}
|
||||
214
packages/page-agent/src/llms/utils.ts
Normal file
214
packages/page-agent/src/llms/utils.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
20
packages/page-agent/src/patches/antd.ts
Normal file
20
packages/page-agent/src/patches/antd.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { PageAgent } from '../PageAgent'
|
||||
|
||||
const clearFunctions = [] as (() => void)[]
|
||||
|
||||
/**
|
||||
* antd 的 select 是 div 包 input 的结构,所有信息都在 input 标签上,
|
||||
* 但是 input 不可见,也不会出现在清洗后的树里,因此这里把他提上来
|
||||
*/
|
||||
function fixAntdSelect() {
|
||||
const selects = [...document.querySelectorAll('input[role="combobox"]')]
|
||||
// for (const select of selects) {}
|
||||
}
|
||||
|
||||
export function patchAntd(pageAgent: PageAgent) {
|
||||
pageAgent.addEventListener('beforeUpdate', fixAntdSelect)
|
||||
pageAgent.addEventListener('afterUpdate', () => {
|
||||
for (const fn of clearFunctions) fn()
|
||||
clearFunctions.length = 0
|
||||
})
|
||||
}
|
||||
16
packages/page-agent/src/patches/react.ts
Normal file
16
packages/page-agent/src/patches/react.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { PageAgent } from '../PageAgent'
|
||||
|
||||
// Find common React root elements and add data-page-agent-not-interactive attribute
|
||||
export function patchReact(pageAgent: PageAgent) {
|
||||
const reactRootElements = document.querySelectorAll(
|
||||
'[data-reactroot], [data-reactid], [data-react-checksum], #root, #app, [id^="root-"], [id^="app-"], #adex-wrapper, #adex-root'
|
||||
)
|
||||
|
||||
for (const element of reactRootElements) {
|
||||
element.setAttribute('data-page-agent-not-interactive', 'true')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo (Heavy, might have false negatives) Interaction detection, if element width/height equals body offsetWidth/Height, consider it root element and non-interactive (React often attaches many events to root elements, causing false positives)
|
||||
*/
|
||||
156
packages/page-agent/src/prompts/system_prompt.md
Normal file
156
packages/page-agent/src/prompts/system_prompt.md
Normal file
@@ -0,0 +1,156 @@
|
||||
You are an AI agent designed to operate in an iterative loop to automate browser tasks. Your ultimate goal is accomplishing the task provided in <user_request>.
|
||||
|
||||
<intro>
|
||||
You excel at following tasks:
|
||||
1. Navigating complex websites and extracting precise information
|
||||
2. Automating form submissions and interactive web actions
|
||||
3. Gathering and saving information
|
||||
4. Operate effectively in an agent loop
|
||||
5. Efficiently performing diverse web tasks
|
||||
</intro>
|
||||
|
||||
<language_settings>
|
||||
- Default working language: **中文**
|
||||
- Use the language that user is using. Return in user's language.
|
||||
</language_settings>
|
||||
|
||||
<input>
|
||||
At every step, your input will consist of:
|
||||
1. <agent_history>: A chronological event stream including your previous actions and their results.
|
||||
2. <agent_state>: Current <user_request> and <step_info>.
|
||||
3. <browser_state>: Current URL, interactive elements indexed for actions, and visible page content.
|
||||
</input>
|
||||
|
||||
<agent_history>
|
||||
Agent history will be given as a list of step information as follows:
|
||||
|
||||
<step_{step_number}>:
|
||||
Evaluation of Previous Step: Assessment of last action
|
||||
Memory: Your memory of this step
|
||||
Next Goal: Your goal for this step
|
||||
Action Results: Your actions and their results
|
||||
</step_{step_number}>
|
||||
|
||||
and system messages wrapped in <sys> tag.
|
||||
</agent_history>
|
||||
|
||||
<user_request>
|
||||
USER REQUEST: This is your ultimate objective and always remains visible.
|
||||
- This has the highest priority. Make the user happy.
|
||||
- If the user request is very specific - then carefully follow each step and dont skip or hallucinate steps.
|
||||
- If the task is open ended you can plan yourself how to get it done.
|
||||
</user_request>
|
||||
|
||||
<browser_state>
|
||||
1. Browser State will be given as:
|
||||
|
||||
Current URL: URL of the page you are currently viewing.
|
||||
Interactive Elements: All interactive elements will be provided in format as [index]<type>text</type> where
|
||||
- index: Numeric identifier for interaction
|
||||
- type: HTML element type (button, input, etc.)
|
||||
- text: Element description
|
||||
|
||||
Examples:
|
||||
[33]<div>User form</div>
|
||||
\t*[35]<button aria-label='Submit form'>Submit</button>
|
||||
|
||||
Note that:
|
||||
- Only elements with numeric indexes in [] are interactive
|
||||
- (stacked) indentation (with \t) is important and means that the element is a (html) child of the element above (with a lower index)
|
||||
- Elements tagged with `*[` are the new clickable elements that appeared on the website since the last step - if url has not changed.
|
||||
- Pure text elements without [] are not interactive.
|
||||
</browser_state>
|
||||
|
||||
<browser_rules>
|
||||
Strictly follow these rules while using the browser and navigating the web:
|
||||
- Only interact with elements that have a numeric [index] assigned.
|
||||
- Only use indexes that are explicitly provided.
|
||||
- If the page changes after, for example, an input text action, analyze if you need to interact with new elements, e.g. selecting the right option from the list.
|
||||
- By default, only elements in the visible viewport are listed. Use scrolling actions if you suspect relevant content is offscreen which you need to interact with. Scroll ONLY if there are more pixels below or above the page.
|
||||
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
|
||||
- All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed.
|
||||
- If a captcha appears, tell user you can not solve captcha. finished the task and ask user to solve it.
|
||||
- If expected elements are missing, try scrolling, or navigating back.
|
||||
- If the page is not fully loaded, use the `wait` action.
|
||||
- Do not repeat one action for more than 3 times unless some conditions changed.
|
||||
- If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field.
|
||||
- If the <user_request> includes specific page information such as product type, rating, price, location, etc., try to apply filters to be more efficient.
|
||||
- The <user_request> is the ultimate goal. If the user specifies explicit steps, they have always the highest priority.
|
||||
- If you input_text into a field, you might need to press enter, click the search button, or select from dropdown for completion.
|
||||
- Don't login into a page if you don't have to. Don't login if you don't have the credentials.
|
||||
- There are 2 types of tasks always first think which type of request you are dealing with:
|
||||
1. Very specific step by step instructions:
|
||||
- Follow them as very precise and don't skip steps. Try to complete everything as requested.
|
||||
2. Open ended tasks. Plan yourself, be creative in achieving them.
|
||||
- If you get stuck e.g. with logins or captcha in open-ended tasks you can re-evaluate the task and try alternative ways, e.g. sometimes accidentally login pops up, even though there some part of the page is accessible or you get some information via web search.
|
||||
</browser_rules>
|
||||
|
||||
<capability>
|
||||
- You can only handle single page app. Do not jump out of current page.
|
||||
- Do not click on link if it will open in a new page (etc. <a target="_blank">)
|
||||
- It is ok to fail the task.
|
||||
- User can be wrong. If the request of user is not achievable, inappropriate or you do not have enough information or tools to achieve it. Tell user to make a better request.
|
||||
- Webpage can be broken. All webpages or apps have bugs. Some bug will make it hard for your job. It's encouraged to tell user the problem of current page. Your feedbacks (including failing) are valuable for user.
|
||||
- Trying to hard can be harmful. Repeating some action back and forth or pushing for a complex procedure with little knowledge can cause unwanted result and harmful side-effects. User would rather you to complete the task with a fail.
|
||||
- If you are not clear about the request or steps. `ask_user` to clarify it.
|
||||
- If you do not have knowledge for the current webpage or task. You must require user to give specific instructions and detailed steps.
|
||||
</capability>
|
||||
|
||||
<task_completion_rules>
|
||||
You must call the `done` action in one of three cases:
|
||||
- When you have fully completed the USER REQUEST.
|
||||
- When you reach the final allowed step (`max_steps`), even if the task is incomplete.
|
||||
- When you feel stuck or unable to solve user request. Or user request is not clear or contains inappropriate content.
|
||||
- If it is ABSOLUTELY IMPOSSIBLE to continue.
|
||||
|
||||
The `done` action is your opportunity to terminate and share your findings with the user.
|
||||
- Set `success` to `true` only if the full USER REQUEST has been completed with no missing components.
|
||||
- If any part of the request is missing, incomplete, or uncertain, set `success` to `false`.
|
||||
- You can use the `text` field of the `done` action to communicate your findings and to provide a coherent reply to the user and fulfill the USER REQUEST.
|
||||
- You are ONLY ALLOWED to call `done` as a single action. Don't call it together with other actions.
|
||||
- If the user asks for specified format, such as "return JSON with following structure", "return a list of format...", MAKE sure to use the right format in your answer.
|
||||
- If the user asks for a structured output, your `done` action's schema may be modified. Take this schema into account when solving the task!
|
||||
</task_completion_rules>
|
||||
|
||||
<reasoning_rules>
|
||||
Exhibit the following reasoning patterns to successfully achieve the <user_request>:
|
||||
|
||||
- Reason about <agent_history> to track progress and context toward <user_request>.
|
||||
- Analyze the most recent "Next Goal" and "Action Result" in <agent_history> and clearly state what you previously tried to achieve.
|
||||
- Analyze all relevant items in <agent_history> and <browser_state> to understand your state.
|
||||
- Explicitly judge success/failure/uncertainty of the last action. Never assume an action succeeded just because it appears to be executed in your last step in <agent_history>. If the expected change is missing, mark the last action as failed (or uncertain) and plan a recovery.
|
||||
- Analyze whether you are stuck, e.g. when you repeat the same actions multiple times without any progress. Then consider alternative approaches e.g. scrolling for more context or ask user for help.
|
||||
- `ask_user` for help if you have any difficulty. Users want to be kept in the loop.
|
||||
- If you see information relevant to <user_request>, plan saving the information to memory.
|
||||
- Always reason about the <user_request>. Make sure to carefully analyze the specific steps and information required. E.g. specific filters, specific form fields, specific information to search. Make sure to always compare the current trajectory with the user request and think carefully if thats how the user requested it.
|
||||
</reasoning_rules>
|
||||
|
||||
<examples>
|
||||
Here are examples of good output patterns. Use them as reference but never copy them directly.
|
||||
|
||||
<evaluation_examples>
|
||||
- Positive Examples:
|
||||
"evaluation_previous_goal": "Successfully navigated to the product page and found the target information. Verdict: Success"
|
||||
"evaluation_previous_goal": "Clicked the login button and user authentication form appeared. Verdict: Success"
|
||||
</evaluation_examples>
|
||||
|
||||
<memory_examples>
|
||||
"memory": "Found many pending reports that need to be analyzed in the main page. Successfully processed the first 2 reports on quarterly sales data and moving on to inventory analysis and customer feedback reports."
|
||||
</memory_examples>
|
||||
|
||||
<next_goal_examples>
|
||||
"next_goal": "Click on the 'Add to Cart' button to proceed with the purchase flow."
|
||||
"next_goal": "Extract details from the first item on the page."
|
||||
</next_goal_examples>
|
||||
</examples>
|
||||
|
||||
<output>
|
||||
You must ALWAYS respond with a valid JSON in this exact format:
|
||||
|
||||
{
|
||||
"evaluation_previous_goal": "Concise one-sentence analysis of your last action. Clearly state success, failure, or uncertain.",
|
||||
"memory": "1-3 concise sentences of specific memory of this step and overall progress. You should put here everything that will help you track progress in future steps. Like counting pages visited, items found, etc.",
|
||||
"next_goal": "State the next immediate goal and action to achieve it, in one clear sentence."
|
||||
"action":{"one_action_name": {// action-specific parameter}}
|
||||
}
|
||||
</output>
|
||||
430
packages/page-agent/src/tools/actions.ts
Normal file
430
packages/page-agent/src/tools/actions.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Copyright (C) 2025 Alibaba Group Holding Limited
|
||||
* All rights reserved.
|
||||
*/
|
||||
import type { PageAgent } from '../PageAgent'
|
||||
|
||||
// ======= general utils =======
|
||||
|
||||
export async function waitFor(seconds: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
|
||||
}
|
||||
|
||||
let currentUrl = window.location.href
|
||||
export async function getSystemInfo() {
|
||||
// If current URL is already up to date, no need to add message
|
||||
if (currentUrl === window.location.href) return ''
|
||||
|
||||
await waitFor(0.3) // Wait a bit longer for page to load
|
||||
|
||||
currentUrl = window.location.href
|
||||
|
||||
return `\n<sys> Current URL changed to: ${currentUrl} </sys>`
|
||||
}
|
||||
|
||||
// ======= dom utils =======
|
||||
|
||||
export async function movePointerToElement(element: HTMLElement) {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const x = rect.left + rect.width / 2
|
||||
const y = rect.top + rect.height / 2
|
||||
|
||||
window.dispatchEvent(new CustomEvent('PageAgent::MovePointerTo', { detail: { x, y } }))
|
||||
|
||||
await waitFor(0.3)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the HTMLElement by index from the selectorMap in PageAgent.
|
||||
*/
|
||||
export function getElementByIndex(pageAgent: PageAgent, index: number): HTMLElement {
|
||||
const interactiveNode = pageAgent.selectorMap.get(index)
|
||||
if (!interactiveNode) {
|
||||
throw new Error(`No interactive element found at index ${index}`)
|
||||
}
|
||||
|
||||
const element = interactiveNode.ref
|
||||
if (!element) {
|
||||
throw new Error(`Element at index ${index} does not have a reference`)
|
||||
}
|
||||
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error(`Element at index ${index} is not an HTMLElement`)
|
||||
}
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
let lastClickedElement: HTMLElement | null = null
|
||||
|
||||
function blurLastClickedElement() {
|
||||
if (lastClickedElement) {
|
||||
lastClickedElement.blur()
|
||||
lastClickedElement.dispatchEvent(
|
||||
new MouseEvent('mouseout', { bubbles: true, cancelable: true })
|
||||
)
|
||||
lastClickedElement = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a click on the element
|
||||
*/
|
||||
export async function clickElement(element: HTMLElement) {
|
||||
blurLastClickedElement()
|
||||
|
||||
lastClickedElement = element
|
||||
await scrollIntoViewIfNeeded(element)
|
||||
await movePointerToElement(element)
|
||||
window.dispatchEvent(new CustomEvent('PageAgent::ClickPointer'))
|
||||
await waitFor(0.1)
|
||||
|
||||
// hover it
|
||||
element.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true, cancelable: true }))
|
||||
element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true }))
|
||||
|
||||
// dispatch a sequence of events to ensure all listeners are triggered
|
||||
element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }))
|
||||
|
||||
// focus it to ensure it gets the click event
|
||||
element.focus()
|
||||
|
||||
element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }))
|
||||
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||
|
||||
// dispatch a click event
|
||||
// element.click()
|
||||
|
||||
await waitFor(0.1) // Wait to ensure click event processing completes
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value'
|
||||
)!.set!
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
'value'
|
||||
)!.set!
|
||||
|
||||
/**
|
||||
* create a synthetic keyboard event
|
||||
* with key keycode code
|
||||
*/
|
||||
export async function createSyntheticInputEvent(elem: HTMLElement, key: string) {
|
||||
elem.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key }))
|
||||
await waitFor(0.01)
|
||||
|
||||
if (elem instanceof HTMLInputElement || elem instanceof HTMLTextAreaElement) {
|
||||
elem.dispatchEvent(new Event('beforeinput', { bubbles: true }))
|
||||
await waitFor(0.01)
|
||||
elem.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await waitFor(0.01)
|
||||
}
|
||||
|
||||
elem.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: true, key }))
|
||||
}
|
||||
|
||||
export async function inputTextElement(element: HTMLElement, text: string) {
|
||||
if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) {
|
||||
throw new Error('Element is not an input or textarea')
|
||||
}
|
||||
|
||||
await clickElement(element)
|
||||
|
||||
if (element instanceof HTMLTextAreaElement) {
|
||||
nativeTextAreaValueSetter.call(element, text)
|
||||
} else {
|
||||
nativeInputValueSetter.call(element, text)
|
||||
}
|
||||
|
||||
const inputEvent = new Event('input', { bubbles: true })
|
||||
element.dispatchEvent(inputEvent)
|
||||
|
||||
await waitFor(0.1) // Wait to ensure input event processing completes
|
||||
|
||||
blurLastClickedElement()
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo browser-use version is very complex and supports menu tags, need to follow up
|
||||
*/
|
||||
export async function selectOptionElement(selectElement: HTMLSelectElement, optionText: string) {
|
||||
if (!(selectElement instanceof HTMLSelectElement)) {
|
||||
throw new Error('Element is not a select element')
|
||||
}
|
||||
|
||||
const options = Array.from(selectElement.options)
|
||||
const option = options.find((opt) => opt.textContent?.trim() === optionText.trim())
|
||||
|
||||
if (!option) {
|
||||
throw new Error(`Option with text "${optionText}" not found in select element`)
|
||||
}
|
||||
|
||||
selectElement.value = option.value
|
||||
selectElement.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
|
||||
await waitFor(0.1) // Wait to ensure change event processing completes
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
export async function scrollIntoViewIfNeeded(element: HTMLElement) {
|
||||
const el = element as any
|
||||
if (el.scrollIntoViewIfNeeded) {
|
||||
el.scrollIntoViewIfNeeded()
|
||||
// await waitFor(0.5) // Animation playback
|
||||
} else {
|
||||
// @todo visibility check
|
||||
el.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'nearest' })
|
||||
// await waitFor(0.5) // Animation playback
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrollVertically(
|
||||
down: boolean,
|
||||
scroll_amount: number,
|
||||
element?: HTMLElement | null
|
||||
) {
|
||||
// Element-specific scrolling if element is provided
|
||||
if (element) {
|
||||
const targetElement = element
|
||||
console.log(
|
||||
'[SCROLL DEBUG] Starting direct container scroll for element:',
|
||||
targetElement.tagName
|
||||
)
|
||||
|
||||
let currentElement = targetElement as HTMLElement | null
|
||||
let scrollSuccess = false
|
||||
let scrolledElement: HTMLElement | null = null
|
||||
let scrollDelta = 0
|
||||
let attempts = 0
|
||||
const dy = scroll_amount
|
||||
|
||||
while (currentElement && attempts < 10) {
|
||||
const computedStyle = window.getComputedStyle(currentElement)
|
||||
const hasScrollableY = /(auto|scroll|overlay)/.test(computedStyle.overflowY)
|
||||
const canScrollVertically = currentElement.scrollHeight > currentElement.clientHeight
|
||||
|
||||
console.log(
|
||||
'[SCROLL DEBUG] Checking element:',
|
||||
currentElement.tagName,
|
||||
'hasScrollableY:',
|
||||
hasScrollableY,
|
||||
'canScrollVertically:',
|
||||
canScrollVertically,
|
||||
'scrollHeight:',
|
||||
currentElement.scrollHeight,
|
||||
'clientHeight:',
|
||||
currentElement.clientHeight
|
||||
)
|
||||
|
||||
if (hasScrollableY && canScrollVertically) {
|
||||
const beforeScroll = currentElement.scrollTop
|
||||
const maxScroll = currentElement.scrollHeight - currentElement.clientHeight
|
||||
|
||||
let scrollAmount = dy / 3
|
||||
|
||||
if (scrollAmount > 0) {
|
||||
scrollAmount = Math.min(scrollAmount, maxScroll - beforeScroll)
|
||||
} else {
|
||||
scrollAmount = Math.max(scrollAmount, -beforeScroll)
|
||||
}
|
||||
|
||||
currentElement.scrollTop = beforeScroll + scrollAmount
|
||||
|
||||
const afterScroll = currentElement.scrollTop
|
||||
const actualScrollDelta = afterScroll - beforeScroll
|
||||
|
||||
console.log(
|
||||
'[SCROLL DEBUG] Scroll attempt:',
|
||||
currentElement.tagName,
|
||||
'before:',
|
||||
beforeScroll,
|
||||
'after:',
|
||||
afterScroll,
|
||||
'delta:',
|
||||
actualScrollDelta
|
||||
)
|
||||
|
||||
if (Math.abs(actualScrollDelta) > 0.5) {
|
||||
scrollSuccess = true
|
||||
scrolledElement = currentElement
|
||||
scrollDelta = actualScrollDelta
|
||||
console.log(
|
||||
'[SCROLL DEBUG] Successfully scrolled container:',
|
||||
currentElement.tagName,
|
||||
'delta:',
|
||||
actualScrollDelta
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (currentElement === document.body || currentElement === document.documentElement) {
|
||||
break
|
||||
}
|
||||
currentElement = currentElement.parentElement
|
||||
attempts++
|
||||
}
|
||||
|
||||
if (scrollSuccess) {
|
||||
return `Scrolled container (${scrolledElement?.tagName}) by ${scrollDelta}px`
|
||||
} else {
|
||||
return `No scrollable container found for element (${targetElement.tagName})`
|
||||
}
|
||||
}
|
||||
|
||||
// Page-level scrolling (default or fallback)
|
||||
|
||||
const dy = scroll_amount
|
||||
const bigEnough = (el: HTMLElement) => el.clientHeight >= window.innerHeight * 0.5
|
||||
const canScroll = (el: HTMLElement | null) =>
|
||||
el &&
|
||||
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowY) &&
|
||||
el.scrollHeight > el.clientHeight &&
|
||||
bigEnough(el)
|
||||
|
||||
let el: HTMLElement | null = document.activeElement as HTMLElement | null
|
||||
while (el && !canScroll(el) && el !== document.body) el = el.parentElement
|
||||
|
||||
el = canScroll(el)
|
||||
? el
|
||||
: Array.from(document.querySelectorAll<HTMLElement>('*')).find(canScroll) ||
|
||||
(document.scrollingElement as HTMLElement) ||
|
||||
(document.documentElement as HTMLElement)
|
||||
|
||||
if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
|
||||
window.scrollBy(0, dy)
|
||||
return `✅ Scrolled page by ${dy}px.`
|
||||
} else {
|
||||
el!.scrollBy({ top: dy, behavior: 'smooth' })
|
||||
await waitFor(0.1) // Animation playback
|
||||
return `✅ Scrolled container (${el!.tagName}) by ${dy}px.`
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrollHorizontally(
|
||||
right: boolean,
|
||||
scroll_amount: number,
|
||||
element?: HTMLElement | null
|
||||
) {
|
||||
// Element-specific scrolling if element is provided
|
||||
if (element) {
|
||||
const targetElement = element
|
||||
console.log(
|
||||
'[SCROLL DEBUG] Starting direct container scroll for element:',
|
||||
targetElement.tagName
|
||||
)
|
||||
|
||||
let currentElement = targetElement as HTMLElement | null
|
||||
let scrollSuccess = false
|
||||
let scrolledElement: HTMLElement | null = null
|
||||
let scrollDelta = 0
|
||||
let attempts = 0
|
||||
const dx = right ? scroll_amount : -scroll_amount
|
||||
|
||||
while (currentElement && attempts < 10) {
|
||||
const computedStyle = window.getComputedStyle(currentElement)
|
||||
const hasScrollableX = /(auto|scroll|overlay)/.test(computedStyle.overflowX)
|
||||
const canScrollHorizontally = currentElement.scrollWidth > currentElement.clientWidth
|
||||
|
||||
console.log(
|
||||
'[SCROLL DEBUG] Checking element:',
|
||||
currentElement.tagName,
|
||||
'hasScrollableX:',
|
||||
hasScrollableX,
|
||||
'canScrollHorizontally:',
|
||||
canScrollHorizontally,
|
||||
'scrollWidth:',
|
||||
currentElement.scrollWidth,
|
||||
'clientWidth:',
|
||||
currentElement.clientWidth
|
||||
)
|
||||
|
||||
if (hasScrollableX && canScrollHorizontally) {
|
||||
const beforeScroll = currentElement.scrollLeft
|
||||
const maxScroll = currentElement.scrollWidth - currentElement.clientWidth
|
||||
|
||||
let scrollAmount = dx / 3
|
||||
|
||||
if (scrollAmount > 0) {
|
||||
scrollAmount = Math.min(scrollAmount, maxScroll - beforeScroll)
|
||||
} else {
|
||||
scrollAmount = Math.max(scrollAmount, -beforeScroll)
|
||||
}
|
||||
|
||||
currentElement.scrollLeft = beforeScroll + scrollAmount
|
||||
|
||||
const afterScroll = currentElement.scrollLeft
|
||||
const actualScrollDelta = afterScroll - beforeScroll
|
||||
|
||||
console.log(
|
||||
'[SCROLL DEBUG] Scroll attempt:',
|
||||
currentElement.tagName,
|
||||
'before:',
|
||||
beforeScroll,
|
||||
'after:',
|
||||
afterScroll,
|
||||
'delta:',
|
||||
actualScrollDelta
|
||||
)
|
||||
|
||||
if (Math.abs(actualScrollDelta) > 0.5) {
|
||||
scrollSuccess = true
|
||||
scrolledElement = currentElement
|
||||
scrollDelta = actualScrollDelta
|
||||
console.log(
|
||||
'[SCROLL DEBUG] Successfully scrolled container:',
|
||||
currentElement.tagName,
|
||||
'delta:',
|
||||
actualScrollDelta
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (currentElement === document.body || currentElement === document.documentElement) {
|
||||
break
|
||||
}
|
||||
currentElement = currentElement.parentElement
|
||||
attempts++
|
||||
}
|
||||
|
||||
if (scrollSuccess) {
|
||||
return `Scrolled container (${scrolledElement?.tagName}) horizontally by ${scrollDelta}px`
|
||||
} else {
|
||||
return `No horizontally scrollable container found for element (${targetElement.tagName})`
|
||||
}
|
||||
}
|
||||
|
||||
// Page-level scrolling (default or fallback)
|
||||
|
||||
const dx = right ? scroll_amount : -scroll_amount
|
||||
const bigEnough = (el: HTMLElement) => el.clientWidth >= window.innerWidth * 0.5
|
||||
const canScroll = (el: HTMLElement | null) =>
|
||||
el &&
|
||||
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowX) &&
|
||||
el.scrollWidth > el.clientWidth &&
|
||||
bigEnough(el)
|
||||
|
||||
let el: HTMLElement | null = document.activeElement as HTMLElement | null
|
||||
while (el && !canScroll(el) && el !== document.body) el = el.parentElement
|
||||
|
||||
el = canScroll(el)
|
||||
? el
|
||||
: Array.from(document.querySelectorAll<HTMLElement>('*')).find(canScroll) ||
|
||||
(document.scrollingElement as HTMLElement) ||
|
||||
(document.documentElement as HTMLElement)
|
||||
|
||||
if (el === document.scrollingElement || el === document.documentElement || el === document.body) {
|
||||
window.scrollBy(dx, 0)
|
||||
return `✅ Scrolled page horizontally by ${dx}px`
|
||||
} else {
|
||||
el!.scrollBy({ left: dx, behavior: 'smooth' })
|
||||
await waitFor(0.1) // Animation playback
|
||||
return `✅ Scrolled container (${el!.tagName}) horizontally by ${dx}px`
|
||||
}
|
||||
}
|
||||
243
packages/page-agent/src/tools/index.ts
Normal file
243
packages/page-agent/src/tools/index.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Internal tools for PageAgent.
|
||||
* @note Adapted from browser-use
|
||||
*/
|
||||
import zod, { type z } from 'zod'
|
||||
|
||||
import type { PageAgent } from '../PageAgent'
|
||||
import {
|
||||
clickElement,
|
||||
getElementByIndex,
|
||||
getSystemInfo,
|
||||
inputTextElement,
|
||||
scrollHorizontally,
|
||||
scrollVertically,
|
||||
selectOptionElement,
|
||||
waitFor,
|
||||
} from './actions'
|
||||
// debug
|
||||
import * as utils from './actions'
|
||||
|
||||
// @ts-expect-error debug only
|
||||
window.utils = utils
|
||||
|
||||
/**
|
||||
* Internal tool definition that has access to PageAgent `this` context
|
||||
*/
|
||||
export interface PageAgentTool<TParams = any> {
|
||||
// name: string
|
||||
description: string
|
||||
inputSchema: z.ZodType<TParams>
|
||||
execute: (this: PageAgent, args: TParams) => Promise<string>
|
||||
}
|
||||
|
||||
export function tool<TParams>(options: PageAgentTool<TParams>): PageAgentTool<TParams> {
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal tools for PageAgent.
|
||||
* Note: Using any to allow different parameter types for each tool
|
||||
*/
|
||||
export const tools = new Map<string, PageAgentTool>()
|
||||
|
||||
// tools.set(
|
||||
// 'get_current_html',
|
||||
// tool({
|
||||
// description: 'Get the current (updated) simplified HTML of the page',
|
||||
// inputSchema: zod.object({}),
|
||||
// execute: function (this: PageAgent) {
|
||||
// this.updateTree()
|
||||
// return this.simplifiedHTML
|
||||
// },
|
||||
// })
|
||||
// )
|
||||
|
||||
tools.set(
|
||||
'done',
|
||||
tool({
|
||||
description:
|
||||
'Complete task - provide a summary of results for the user. Set success=True if task completed successfully, false otherwise. Text should be your response to the user summarizing results.',
|
||||
inputSchema: zod.object({
|
||||
text: zod.string(),
|
||||
success: zod.boolean().default(true),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
// @note main loop will handle this one
|
||||
// this.onDone(input.text, input.success)
|
||||
return Promise.resolve('Task completed')
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
tools.set(
|
||||
'wait',
|
||||
tool({
|
||||
description:
|
||||
'Wait for x seconds. default 1s (max 10 seconds, min 1 second). This can be used to wait until the page or data is fully loaded.',
|
||||
inputSchema: zod.object({
|
||||
seconds: zod.number().min(1).max(10).default(1),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
const lastTimeUpdate = this.lastTimeUpdate
|
||||
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000)
|
||||
console.log(`actualWaitTime: ${actualWaitTime} seconds`)
|
||||
await waitFor(actualWaitTime)
|
||||
return `✅ Waited for ${input.seconds} seconds.` + (await getSystemInfo())
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
tools.set(
|
||||
'ask_user',
|
||||
tool({
|
||||
description:
|
||||
'Ask the user a question and wait for their answer. Use this if you need more information or clarification.',
|
||||
inputSchema: zod.object({
|
||||
question: zod.string(),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
const answer = await this.panel.askUser(input.question)
|
||||
return `✅ Received user answer: ${answer}` + (await getSystemInfo())
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
tools.set(
|
||||
'click_element_by_index',
|
||||
tool({
|
||||
description: 'Click element by index',
|
||||
inputSchema: zod.object({
|
||||
index: zod.int().min(0),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
const element = getElementByIndex(this, input.index)
|
||||
const elemText = this.elementTextMap.get(input.index)
|
||||
await clickElement(element)
|
||||
|
||||
// @workaround: Handle links that open in new tabs
|
||||
if (element instanceof HTMLAnchorElement && element.target === '_blank') {
|
||||
return `⚠️ Clicked link that opens in a new tab (${elemText ? elemText : input.index}). You are not capable of reading new tabs.`
|
||||
}
|
||||
|
||||
return `✅ Clicked element (${elemText ? elemText : input.index}).` + (await getSystemInfo())
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
tools.set(
|
||||
'input_text',
|
||||
tool({
|
||||
description: 'Click and input text into a input interactive element',
|
||||
inputSchema: zod.object({
|
||||
index: zod.int().min(0),
|
||||
text: zod.string(),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
const element = getElementByIndex(this, input.index)
|
||||
const elemText = this.elementTextMap.get(input.index)
|
||||
await inputTextElement(element, input.text)
|
||||
return (
|
||||
`✅ Input text (${input.text}) into element (${elemText ? elemText : input.index}).` +
|
||||
(await getSystemInfo())
|
||||
)
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
tools.set(
|
||||
'select_dropdown_option',
|
||||
tool({
|
||||
description:
|
||||
'Select dropdown option for interactive element index by the text of the option you want to select',
|
||||
inputSchema: zod.object({
|
||||
index: zod.int().min(0),
|
||||
text: zod.string(),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
const element = getElementByIndex(this, input.index)
|
||||
const elemText = this.elementTextMap.get(input.index)
|
||||
await selectOptionElement(element as HTMLSelectElement, input.text)
|
||||
return (
|
||||
`✅ Selected option (${input.text}) in element (${elemText ? elemText : input.index}).` +
|
||||
(await getSystemInfo())
|
||||
)
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* @note Reference from browser-use
|
||||
*/
|
||||
tools.set(
|
||||
'scroll',
|
||||
tool({
|
||||
description:
|
||||
'Scroll the page by specified number of pages (set down=True to scroll down, down=False to scroll up, num_pages=number of pages to scroll like 0.5 for half page, 1.0 for one page, etc.). Optional index parameter to scroll within a specific element or its scroll container (works well for dropdowns and custom UI components). Optional pixels parameter to scroll by a specific number of pixels instead of pages.',
|
||||
inputSchema: zod.object({
|
||||
down: zod.boolean().default(true),
|
||||
num_pages: zod.number().min(0).max(10).optional().default(0.1),
|
||||
pixels: zod.number().int().min(0).optional(),
|
||||
index: zod.number().int().min(0).optional(),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
const { down, num_pages, index, pixels } = input
|
||||
|
||||
const scroll_amount = pixels ? pixels : num_pages * (down ? 1 : -1) * window.innerHeight
|
||||
|
||||
const element = index !== undefined ? getElementByIndex(this, index) : null
|
||||
|
||||
return (await scrollVertically(down, scroll_amount, element)) + (await getSystemInfo())
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
tools.set(
|
||||
'scroll_horizontally',
|
||||
tool({
|
||||
description:
|
||||
'Scroll the page or element horizontally (set right=True to scroll right, right=False to scroll left, pixels=number of pixels to scroll). Optional index parameter to scroll within a specific element or its scroll container (works well for wide tables).',
|
||||
inputSchema: zod.object({
|
||||
right: zod.boolean().default(true),
|
||||
pixels: zod.number().int().min(0),
|
||||
index: zod.number().int().min(0).optional(),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
const { right, pixels, index } = input
|
||||
|
||||
const scroll_amount = pixels * (right ? 1 : -1)
|
||||
|
||||
const element = index !== undefined ? getElementByIndex(this, index) : null
|
||||
|
||||
return (await scrollHorizontally(right, scroll_amount, element)) + (await getSystemInfo())
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
tools.set(
|
||||
'execute_javascript',
|
||||
tool({
|
||||
description:
|
||||
'Execute JavaScript code on the current page. Supports async/await syntax. Use with caution!',
|
||||
inputSchema: zod.object({
|
||||
script: zod.string(),
|
||||
}),
|
||||
execute: async function (this: PageAgent, input) {
|
||||
try {
|
||||
// Wrap script in async function to support await
|
||||
const asyncFunction = eval(`(async () => { ${input.script} })`)
|
||||
const result = await asyncFunction()
|
||||
return `✅ Executed JavaScript. Result: ${result}` + (await getSystemInfo())
|
||||
} catch (error) {
|
||||
return `❌ Error executing JavaScript: ${error}` + (await getSystemInfo())
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// @todo get_dropdown_options
|
||||
// @todo select_dropdown_option
|
||||
// @todo send_keys
|
||||
// @todo upload_file
|
||||
// @todo go_back
|
||||
// @todo extract_structured_data
|
||||
597
packages/page-agent/src/ui/Panel.module.css
Normal file
597
packages/page-agent/src/ui/Panel.module.css
Normal file
@@ -0,0 +1,597 @@
|
||||
.wrapper {
|
||||
position: fixed;
|
||||
bottom: 100px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
opacity: 0;
|
||||
z-index: 2147483642; /* 比 SimulatorMask 高一层 */
|
||||
box-sizing: border-box;
|
||||
|
||||
overflow: visible;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
--width: 360px;
|
||||
--height: 40px;
|
||||
--border-radius: 12px;
|
||||
|
||||
--side-space: 12px; /* 控制栏两侧的间距 */
|
||||
--history-width: calc(var(--width) - var(--side-space) * 2);
|
||||
|
||||
--color-1: rgb(57, 182, 255);
|
||||
--color-2: rgb(189, 69, 251);
|
||||
--color-3: rgb(255, 87, 51);
|
||||
--color-4: rgb(255, 214, 0);
|
||||
|
||||
width: var(--width);
|
||||
height: var(--height);
|
||||
|
||||
transition: all 0.3s ease-in-out;
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 480px) {
|
||||
width: calc(100vw - 40px);
|
||||
--width: calc(100vw - 40px);
|
||||
}
|
||||
|
||||
.background {
|
||||
position: absolute;
|
||||
inset: -2px -8px;
|
||||
border-radius: calc(var(--border-radius) + 4px);
|
||||
filter: blur(16px);
|
||||
overflow: hidden;
|
||||
/* mix-blend-mode: lighten; */
|
||||
/* display: none; */
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* left: -100%; */
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
background-image: linear-gradient(
|
||||
to bottom left,
|
||||
var(--color-1),
|
||||
var(--color-2),
|
||||
var(--color-1)
|
||||
);
|
||||
animation: mask-running 2s linear infinite;
|
||||
}
|
||||
&::after {
|
||||
content: '';
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
background-image: linear-gradient(
|
||||
to bottom left,
|
||||
var(--color-2),
|
||||
var(--color-1),
|
||||
var(--color-2)
|
||||
);
|
||||
animation: mask-running 2s linear infinite;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mask-running {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 控制栏 */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
user-select: none;
|
||||
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
cursor: pointer;
|
||||
flex-shrink: 0; /* 防止 header 被压缩 */
|
||||
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: var(--border-radius);
|
||||
background-clip: padding-box;
|
||||
|
||||
box-shadow:
|
||||
0 0 0px 2px rgba(255, 255, 255, 0.4),
|
||||
0 0 5px 1px rgba(255, 255, 255, 0.3);
|
||||
|
||||
.statusSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-height: 24px; /* 确保垂直居中 */
|
||||
|
||||
.indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
animation: none; /* 默认无动画 */
|
||||
|
||||
/* 运行状态 - 有动画 */
|
||||
&.thinking {
|
||||
background: rgb(57, 182, 255);
|
||||
animation: pulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&.tool_executing {
|
||||
background: rgb(189, 69, 251);
|
||||
animation: pulse 0.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&.retry {
|
||||
background: rgb(255, 214, 0);
|
||||
animation: retryPulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 静止状态 - 无动画 */
|
||||
&.completed,
|
||||
&.input,
|
||||
&.output {
|
||||
background: rgb(34, 197, 94);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: rgb(239, 68, 68);
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.statusText {
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease-in-out;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 24px; /* 确保垂直居中 */
|
||||
|
||||
&.fadeOut {
|
||||
animation: statusTextFadeOut 0.3s ease forwards;
|
||||
}
|
||||
|
||||
&.fadeIn {
|
||||
animation: statusTextFadeIn 0.3s ease forwards;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.controlButton {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.pauseButton {
|
||||
font-weight: 600;
|
||||
&.paused {
|
||||
background: rgba(34, 197, 94, 0.2); /* 绿色背景表示可以继续 */
|
||||
color: rgb(34, 197, 94);
|
||||
|
||||
&:hover {
|
||||
background: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stopButton {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: rgb(255, 41, 41);
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
background: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes statusTextFadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes statusTextFadeOut {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
.historySectionWrapper {
|
||||
position: absolute;
|
||||
width: var(--history-width);
|
||||
bottom: var(--height);
|
||||
left: var(--side-space);
|
||||
z-index: -2;
|
||||
|
||||
padding-top: 0px;
|
||||
visibility: collapse;
|
||||
overflow: hidden;
|
||||
|
||||
transition: all 0.2s;
|
||||
|
||||
background: rgba(2, 0, 20, 0.5);
|
||||
/* background: rgba(186, 186, 186, 0.2); */
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
text-shadow: 0 0 1px rgba(0, 0, 0, 0.2);
|
||||
|
||||
border-top-left-radius: calc(var(--border-radius) + 4px);
|
||||
border-top-right-radius: calc(var(--border-radius) + 4px);
|
||||
|
||||
/* border: 2px solid rgba(255, 255, 255, 0.8); */
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
|
||||
|
||||
/* @media (prefers-color-scheme: dark) {
|
||||
box-shadow:
|
||||
0 8px 32px 0 rgba(0, 0, 0, 0.85),
|
||||
0 2px 12px 0 rgba(57, 182, 255, 0.1);
|
||||
} */
|
||||
|
||||
.expanded & {
|
||||
padding-top: 8px;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.historySection {
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none;
|
||||
max-height: 0;
|
||||
padding-inline: 8px;
|
||||
|
||||
transition: max-height 0.2s;
|
||||
|
||||
.expanded & {
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.historyItem {
|
||||
/* backdrop-filter: blur(10px); */
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03));
|
||||
border-radius: 8px;
|
||||
border-left: 2px solid rgba(57, 182, 255, 0.5);
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
/* color: black; */
|
||||
line-height: 1.3;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
/* 微妙的内阴影 */
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1),
|
||||
0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.06));
|
||||
/* transform: translateY(-1px); */
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15),
|
||||
0 2px 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
&.completed,
|
||||
&.input,
|
||||
&.output {
|
||||
border-left-color: rgb(34, 197, 94);
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.1), rgba(34, 197, 94, 0.05));
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-left-color: rgb(239, 68, 68);
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1), rgba(239, 68, 68, 0.05));
|
||||
}
|
||||
|
||||
&.retry {
|
||||
border-left-color: rgb(255, 214, 0);
|
||||
background: linear-gradient(135deg, rgba(255, 214, 0, 0.1), rgba(255, 214, 0, 0.05));
|
||||
}
|
||||
|
||||
/* 突出显示 done 成功结果 */
|
||||
&.doneSuccess {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(34, 197, 94, 0.25),
|
||||
rgba(34, 197, 94, 0.15),
|
||||
rgba(34, 197, 94, 0.08)
|
||||
);
|
||||
border: none;
|
||||
border-left: 4px solid rgb(34, 197, 94);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(34, 197, 94, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2),
|
||||
0 0 20px rgba(34, 197, 94, 0.1);
|
||||
font-weight: 600;
|
||||
color: rgb(220, 252, 231);
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
background: linear-gradient(90deg, transparent, rgba(34, 197, 94, 0.4), transparent);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.historyContent {
|
||||
.statusIcon {
|
||||
font-size: 16px;
|
||||
animation: celebrate 0.8s ease-in-out;
|
||||
filter: drop-shadow(0 2px 4px rgba(34, 197, 94, 0.5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 突出显示 done 失败结果 */
|
||||
&.doneError {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(239, 68, 68, 0.25),
|
||||
rgba(239, 68, 68, 0.15),
|
||||
rgba(239, 68, 68, 0.08)
|
||||
);
|
||||
border: none;
|
||||
border-left: 4px solid rgb(239, 68, 68);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(239, 68, 68, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2),
|
||||
0 0 20px rgba(239, 68, 68, 0.1);
|
||||
font-weight: 600;
|
||||
color: rgb(254, 226, 226);
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
background: linear-gradient(90deg, transparent, rgba(239, 68, 68, 0.4), transparent);
|
||||
}
|
||||
|
||||
.historyContent {
|
||||
.statusIcon {
|
||||
font-size: 16px;
|
||||
filter: drop-shadow(0 2px 4px rgba(239, 68, 68, 0.5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.historyContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
|
||||
/* overflow-x: auto; */
|
||||
|
||||
.statusIcon {
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.historyMeta {
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
/* color: rgb(61, 61, 61); */
|
||||
margin-top: 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画关键帧 - 更快的闪烁 */
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
transform: scale(1.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* 重试动画 - 旋转脉冲 */
|
||||
@keyframes retryPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.2) rotate(90deg);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.1) rotate(180deg);
|
||||
}
|
||||
75% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.2) rotate(270deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 庆祝动画 */
|
||||
@keyframes celebrate {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
25% {
|
||||
transform: scale(1.2) rotate(-5deg);
|
||||
}
|
||||
75% {
|
||||
transform: scale(1.2) rotate(5deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* done 卡片的光泽效果 */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
left: -100%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 输入区域样式 */
|
||||
.inputSectionWrapper {
|
||||
position: absolute;
|
||||
width: var(--history-width);
|
||||
top: var(--height);
|
||||
left: var(--side-space);
|
||||
z-index: -1;
|
||||
|
||||
visibility: visible;
|
||||
overflow: hidden;
|
||||
|
||||
height: 48px;
|
||||
|
||||
transition: all 0.2s;
|
||||
|
||||
background: rgba(186, 186, 186, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
border-bottom-left-radius: calc(var(--border-radius) + 4px);
|
||||
border-bottom-right-radius: calc(var(--border-radius) + 4px);
|
||||
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 1px 16px rgba(0, 0, 0, 0.4);
|
||||
|
||||
&.hidden {
|
||||
visibility: collapse;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.inputSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 8px;
|
||||
|
||||
.taskInput {
|
||||
flex: 1;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 10px;
|
||||
padding-inline: 10px;
|
||||
color: rgb(20, 20, 20);
|
||||
font-size: 12px;
|
||||
height: 28px;
|
||||
line-height: 1;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
/* text-shadow: 0 0 2px rgba(255, 255, 255, 0.8); */
|
||||
|
||||
/* border-color: rgba(57, 182, 255, 0.3); */
|
||||
|
||||
&::placeholder {
|
||||
color: rgb(53, 53, 53);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-color: rgba(57, 182, 255, 0.6);
|
||||
box-shadow: 0 0 0 2px rgba(57, 182, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
596
packages/page-agent/src/ui/Panel.ts
Normal file
596
packages/page-agent/src/ui/Panel.ts
Normal file
@@ -0,0 +1,596 @@
|
||||
import type { PageAgent } from '../PageAgent'
|
||||
import type { I18n } from '../i18n'
|
||||
import { truncate } from '../utils'
|
||||
import type { EventBus } from '../utils/bus'
|
||||
import { type Step, UIState } from './UIState'
|
||||
|
||||
import styles from './Panel.module.css'
|
||||
|
||||
/**
|
||||
* Agent control panel
|
||||
*/
|
||||
export class Panel {
|
||||
#wrapper: HTMLElement
|
||||
#indicator: HTMLElement
|
||||
#statusText: HTMLElement
|
||||
#historySection: HTMLElement
|
||||
#expandButton: HTMLElement
|
||||
#pauseButton: HTMLElement
|
||||
#stopButton: HTMLElement
|
||||
#inputSection: HTMLElement
|
||||
#taskInput: HTMLInputElement
|
||||
#bus: EventBus
|
||||
|
||||
#state = new UIState()
|
||||
#isExpanded = false
|
||||
#pageAgent: PageAgent
|
||||
#userAnswerResolver: ((input: string) => void) | null = null
|
||||
#isWaitingForUserAnswer: boolean = false
|
||||
#headerUpdateTimer: ReturnType<typeof setInterval> | null = null
|
||||
#pendingHeaderText: string | null = null
|
||||
#isAnimating = false
|
||||
|
||||
get wrapper(): HTMLElement {
|
||||
return this.#wrapper
|
||||
}
|
||||
|
||||
constructor(pageAgent: PageAgent) {
|
||||
this.#pageAgent = pageAgent
|
||||
this.#bus = pageAgent.bus
|
||||
this.#wrapper = this.#createWrapper()
|
||||
this.#indicator = this.#wrapper.querySelector(`.${styles.indicator}`)!
|
||||
this.#statusText = this.#wrapper.querySelector(`.${styles.statusText}`)!
|
||||
this.#historySection = this.#wrapper.querySelector(`.${styles.historySection}`)!
|
||||
this.#expandButton = this.#wrapper.querySelector(`.${styles.expandButton}`)!
|
||||
this.#pauseButton = this.#wrapper.querySelector(`.${styles.pauseButton}`)!
|
||||
this.#stopButton = this.#wrapper.querySelector(`.${styles.stopButton}`)!
|
||||
this.#inputSection = this.#wrapper.querySelector(`.${styles.inputSectionWrapper}`)!
|
||||
this.#taskInput = this.#wrapper.querySelector(`.${styles.taskInput}`)!
|
||||
|
||||
this.#setupEventListeners()
|
||||
this.#startHeaderUpdateLoop()
|
||||
// this.#expand() // debug
|
||||
|
||||
this.#showInputArea()
|
||||
|
||||
this.#bus.on('panel:show', () => this.#show())
|
||||
this.#bus.on('panel:hide', () => this.#hide())
|
||||
this.#bus.on('panel:reset', () => this.#reset())
|
||||
this.#bus.on('panel:update', (stepData) => this.#update(stepData))
|
||||
this.#bus.on('panel:expand', () => this.#expand())
|
||||
this.#bus.on('panel:collapse', () => this.#collapse())
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for user input
|
||||
*/
|
||||
async askUser(question: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
// Set `waiting for user answer` state
|
||||
this.#isWaitingForUserAnswer = true
|
||||
this.#userAnswerResolver = resolve
|
||||
|
||||
// Update state to `running`
|
||||
this.#update({
|
||||
type: 'output',
|
||||
displayText: this.#pageAgent.i18n.t('ui.panel.question', { question }),
|
||||
}) // Expand history panel
|
||||
if (!this.#isExpanded) {
|
||||
this.#expand()
|
||||
}
|
||||
|
||||
this.#showInputArea(this.#pageAgent.i18n.t('ui.panel.userAnswerPrompt'))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose panel
|
||||
*/
|
||||
dispose(): void {
|
||||
this.#isWaitingForUserAnswer = false
|
||||
this.#stopHeaderUpdateLoop()
|
||||
this.wrapper.remove()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status
|
||||
*/
|
||||
#update(stepData: Omit<Step, 'id' | 'stepNumber' | 'timestamp'>): void {
|
||||
const step = this.#state.addStep(stepData)
|
||||
|
||||
// Queue header text update (will be processed by periodic check)
|
||||
const headerText = truncate(step.displayText, 20)
|
||||
this.#pendingHeaderText = headerText
|
||||
|
||||
this.#updateStatusIndicator(step.type)
|
||||
this.#updateHistory()
|
||||
|
||||
// Auto-expand history after task completion
|
||||
if (step.type === 'completed' || step.type === 'error') {
|
||||
if (!this.#isExpanded) {
|
||||
this.#expand()
|
||||
}
|
||||
}
|
||||
|
||||
// Control input area display based on status
|
||||
if (this.#shouldShowInputArea()) {
|
||||
this.#showInputArea()
|
||||
} else {
|
||||
this.#hideInputArea()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show panel
|
||||
*/
|
||||
#show(): void {
|
||||
this.wrapper.style.display = 'block'
|
||||
// Force reflow to trigger animation
|
||||
void this.wrapper.offsetHeight
|
||||
this.wrapper.style.opacity = '1'
|
||||
this.wrapper.style.transform = 'translateX(-50%) translateY(0)'
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide panel
|
||||
*/
|
||||
#hide(): void {
|
||||
this.wrapper.style.opacity = '0'
|
||||
this.wrapper.style.transform = 'translateX(-50%) translateY(20px)'
|
||||
this.wrapper.style.display = 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state
|
||||
*/
|
||||
#reset(): void {
|
||||
this.#state.reset()
|
||||
this.#statusText.textContent = this.#pageAgent.i18n.t('ui.panel.ready')
|
||||
this.#updateStatusIndicator('thinking')
|
||||
this.#updateHistory()
|
||||
this.#collapse()
|
||||
// Reset pause state
|
||||
this.#pageAgent.paused = false
|
||||
this.#updatePauseButton()
|
||||
// Reset user input state
|
||||
this.#isWaitingForUserAnswer = false
|
||||
this.#userAnswerResolver = null
|
||||
// Show input area
|
||||
this.#showInputArea()
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle pause state
|
||||
*/
|
||||
#togglePause(): void {
|
||||
this.#pageAgent.paused = !this.#pageAgent.paused
|
||||
this.#updatePauseButton()
|
||||
|
||||
// Update status display
|
||||
if (this.#pageAgent.paused) {
|
||||
this.#statusText.textContent = this.#pageAgent.i18n.t('ui.panel.paused')
|
||||
this.#updateStatusIndicator('thinking') // Use existing thinking state
|
||||
} else {
|
||||
this.#statusText.textContent = this.#pageAgent.i18n.t('ui.panel.continueExecution')
|
||||
this.#updateStatusIndicator('tool_executing') // Restore to execution state
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update pause button state
|
||||
*/
|
||||
#updatePauseButton(): void {
|
||||
if (this.#pageAgent.paused) {
|
||||
this.#pauseButton.textContent = '▶'
|
||||
this.#pauseButton.title = this.#pageAgent.i18n.t('ui.panel.continue')
|
||||
this.#pauseButton.classList.add(styles.paused)
|
||||
} else {
|
||||
this.#pauseButton.textContent = '⏸︎'
|
||||
this.#pauseButton.title = this.#pageAgent.i18n.t('ui.panel.pause')
|
||||
this.#pauseButton.classList.remove(styles.paused)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop Agent
|
||||
*/
|
||||
#stopAgent(): void {
|
||||
// Update status display
|
||||
this.#update({
|
||||
type: 'error',
|
||||
displayText: this.#pageAgent.i18n.t('ui.panel.taskTerminated'),
|
||||
})
|
||||
|
||||
this.#pageAgent.dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit task
|
||||
*/
|
||||
#submitTask() {
|
||||
const input = this.#taskInput.value.trim()
|
||||
if (!input) return
|
||||
|
||||
// Hide input area
|
||||
this.#hideInputArea()
|
||||
|
||||
if (this.#isWaitingForUserAnswer) {
|
||||
// Handle user input mode
|
||||
this.#handleUserAnswer(input)
|
||||
} else {
|
||||
this.#pageAgent.execute(input)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle user answer
|
||||
*/
|
||||
#handleUserAnswer(input: string): void {
|
||||
// Add user input to history
|
||||
this.#update({
|
||||
type: 'input',
|
||||
displayText: this.#pageAgent.i18n.t('ui.panel.userAnswer', { input }),
|
||||
})
|
||||
|
||||
// Reset state
|
||||
this.#isWaitingForUserAnswer = false
|
||||
|
||||
// Call resolver to return user input
|
||||
if (this.#userAnswerResolver) {
|
||||
this.#userAnswerResolver(input)
|
||||
this.#userAnswerResolver = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show input area
|
||||
*/
|
||||
#showInputArea(placeholder?: string): void {
|
||||
// Clear input field
|
||||
this.#taskInput.value = ''
|
||||
this.#taskInput.placeholder = placeholder || this.#pageAgent.i18n.t('ui.panel.taskInput')
|
||||
this.#inputSection.classList.remove(styles.hidden)
|
||||
// Focus on input field
|
||||
setTimeout(() => {
|
||||
this.#taskInput.focus()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide input area
|
||||
*/
|
||||
#hideInputArea(): void {
|
||||
this.#inputSection.classList.add(styles.hidden)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if input area should be shown
|
||||
*/
|
||||
#shouldShowInputArea(): boolean {
|
||||
// Always show input area if waiting for user input
|
||||
if (this.#isWaitingForUserAnswer) return true
|
||||
|
||||
const steps = this.#state.getAllSteps()
|
||||
if (steps.length === 0) {
|
||||
return true // Initial state
|
||||
}
|
||||
|
||||
const lastStep = steps[steps.length - 1]
|
||||
return lastStep.type === 'completed' || lastStep.type === 'error'
|
||||
}
|
||||
|
||||
#createWrapper(): HTMLElement {
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.id = 'page-agent-runtime_agent-panel'
|
||||
wrapper.className = `${styles.wrapper} ${styles.collapsed}`
|
||||
wrapper.setAttribute('data-browser-use-ignore', 'true')
|
||||
|
||||
wrapper.innerHTML = `
|
||||
<div class="${styles.background}"></div>
|
||||
<div class="${styles.historySectionWrapper}">
|
||||
<div class="${styles.historySection}">
|
||||
${this.#createHistoryItem({
|
||||
id: 'placeholder',
|
||||
stepNumber: 0,
|
||||
timestamp: new Date(),
|
||||
type: 'thinking',
|
||||
displayText: this.#pageAgent.i18n.t('ui.panel.waitingPlaceholder'),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div class="${styles.header}">
|
||||
<div class="${styles.statusSection}">
|
||||
<div class="${styles.indicator} ${styles.thinking}"></div>
|
||||
<div class="${styles.statusText}">${this.#pageAgent.i18n.t('ui.panel.ready')}</div>
|
||||
</div>
|
||||
<div class="${styles.controls}">
|
||||
<button class="${styles.controlButton} ${styles.expandButton}" title="${this.#pageAgent.i18n.t('ui.panel.expand')}">
|
||||
▼
|
||||
</button>
|
||||
<button class="${styles.controlButton} ${styles.pauseButton}" title="${this.#pageAgent.i18n.t('ui.panel.pause')}">
|
||||
⏸︎
|
||||
</button>
|
||||
<button class="${styles.controlButton} ${styles.stopButton}" title="${this.#pageAgent.i18n.t('ui.panel.stop')}">
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="${styles.inputSectionWrapper} ${styles.hidden}">
|
||||
<div class="${styles.inputSection}">
|
||||
<input
|
||||
type="text"
|
||||
class="${styles.taskInput}"
|
||||
maxlength="200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
document.body.appendChild(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
#setupEventListeners(): void {
|
||||
// Click header area to expand/collapse
|
||||
const header = this.wrapper.querySelector(`.${styles.header}`)!
|
||||
header.addEventListener('click', (e) => {
|
||||
// Don't trigger expand/collapse if clicking on buttons
|
||||
if ((e.target as HTMLElement).closest(`.${styles.controlButton}`)) {
|
||||
return
|
||||
}
|
||||
this.#toggle()
|
||||
})
|
||||
|
||||
// Expand button
|
||||
this.#expandButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
this.#toggle()
|
||||
})
|
||||
|
||||
// Pause/continue button
|
||||
this.#pauseButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
this.#togglePause()
|
||||
})
|
||||
|
||||
// Stop button
|
||||
this.#stopButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
this.#stopAgent()
|
||||
})
|
||||
|
||||
// Submit on Enter key in input field
|
||||
this.#taskInput.addEventListener('keydown', (e) => {
|
||||
if (e.isComposing) return // Ignore IME composition keys
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
this.#submitTask()
|
||||
}
|
||||
})
|
||||
|
||||
// Prevent input area click event bubbling
|
||||
this.#inputSection.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
})
|
||||
}
|
||||
|
||||
#toggle(): void {
|
||||
if (this.#isExpanded) {
|
||||
this.#collapse()
|
||||
} else {
|
||||
this.#expand()
|
||||
}
|
||||
}
|
||||
|
||||
#expand(): void {
|
||||
this.#isExpanded = true
|
||||
this.wrapper.classList.remove(styles.collapsed)
|
||||
this.wrapper.classList.add(styles.expanded)
|
||||
this.#expandButton.textContent = '▲'
|
||||
}
|
||||
|
||||
#collapse(): void {
|
||||
this.#isExpanded = false
|
||||
this.wrapper.classList.remove(styles.expanded)
|
||||
this.wrapper.classList.add(styles.collapsed)
|
||||
this.#expandButton.textContent = '▼'
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic header update loop
|
||||
*/
|
||||
#startHeaderUpdateLoop(): void {
|
||||
// Check every 450ms (same as total animation duration)
|
||||
this.#headerUpdateTimer = setInterval(() => {
|
||||
this.#checkAndUpdateHeader()
|
||||
}, 450)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic header update loop
|
||||
*/
|
||||
#stopHeaderUpdateLoop(): void {
|
||||
if (this.#headerUpdateTimer) {
|
||||
clearInterval(this.#headerUpdateTimer)
|
||||
this.#headerUpdateTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if header needs update and trigger animation if not currently animating
|
||||
*/
|
||||
#checkAndUpdateHeader(): void {
|
||||
// If no pending text or currently animating, skip
|
||||
if (!this.#pendingHeaderText || this.#isAnimating) {
|
||||
return
|
||||
}
|
||||
|
||||
// If text is already displayed, clear pending and skip
|
||||
if (this.#statusText.textContent === this.#pendingHeaderText) {
|
||||
this.#pendingHeaderText = null
|
||||
return
|
||||
}
|
||||
|
||||
// Start animation
|
||||
const textToShow = this.#pendingHeaderText
|
||||
this.#pendingHeaderText = null
|
||||
this.#animateTextChange(textToShow)
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate text change with fade out/in effect
|
||||
*/
|
||||
#animateTextChange(newText: string): void {
|
||||
this.#isAnimating = true
|
||||
|
||||
// Fade out current text
|
||||
this.#statusText.classList.add(styles.fadeOut)
|
||||
|
||||
setTimeout(() => {
|
||||
// Update text content
|
||||
this.#statusText.textContent = newText
|
||||
|
||||
// Fade in new text
|
||||
this.#statusText.classList.remove(styles.fadeOut)
|
||||
this.#statusText.classList.add(styles.fadeIn)
|
||||
|
||||
setTimeout(() => {
|
||||
this.#statusText.classList.remove(styles.fadeIn)
|
||||
this.#isAnimating = false
|
||||
}, 300)
|
||||
}, 150) // Half the duration of fade out animation
|
||||
}
|
||||
|
||||
#updateStatusIndicator(type: Step['type']): void {
|
||||
// Clear all status classes
|
||||
this.#indicator.className = styles.indicator
|
||||
|
||||
// Add corresponding status class
|
||||
this.#indicator.classList.add(styles[type])
|
||||
}
|
||||
|
||||
#updateHistory(): void {
|
||||
const steps = this.#state.getAllSteps()
|
||||
|
||||
this.#historySection.innerHTML = steps.map((step) => this.#createHistoryItem(step)).join('')
|
||||
|
||||
// Scroll to bottom to show latest records
|
||||
this.#scrollToBottom()
|
||||
}
|
||||
|
||||
#scrollToBottom(): void {
|
||||
// Execute in next event loop to ensure DOM update completion
|
||||
setTimeout(() => {
|
||||
this.#historySection.scrollTop = this.#historySection.scrollHeight
|
||||
}, 0)
|
||||
}
|
||||
|
||||
#createHistoryItem(step: Step): string {
|
||||
const time = step.timestamp.toLocaleTimeString('zh-CN', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
|
||||
let typeClass = ''
|
||||
let statusIcon = ''
|
||||
|
||||
// Set styles and icons based on step type
|
||||
if (step.type === 'completed') {
|
||||
// Check if this is a result from done tool
|
||||
if (step.toolName === 'done') {
|
||||
// Judge success or failure based on result
|
||||
const failureKeyword = this.#pageAgent.i18n.t('ui.tools.resultFailure')
|
||||
const errorKeyword = this.#pageAgent.i18n.t('ui.tools.resultError')
|
||||
const isSuccess =
|
||||
!step.toolResult ||
|
||||
(!step.toolResult.includes(failureKeyword) && !step.toolResult.includes(errorKeyword))
|
||||
typeClass = isSuccess ? styles.doneSuccess : styles.doneError
|
||||
statusIcon = isSuccess ? '🎉' : '❌'
|
||||
} else {
|
||||
typeClass = styles.completed
|
||||
statusIcon = '✅'
|
||||
}
|
||||
} else if (step.type === 'error') {
|
||||
typeClass = styles.error
|
||||
statusIcon = '❌'
|
||||
} else if (step.type === 'tool_executing') {
|
||||
statusIcon = '⚙️'
|
||||
} else if (step.type === 'output') {
|
||||
typeClass = styles.output
|
||||
statusIcon = '🤖'
|
||||
} else if (step.type === 'input') {
|
||||
typeClass = styles.input
|
||||
statusIcon = '🎯'
|
||||
} else if (step.type === 'retry') {
|
||||
typeClass = styles.retry
|
||||
statusIcon = '🔄'
|
||||
} else {
|
||||
statusIcon = '🧠'
|
||||
}
|
||||
|
||||
const durationText = step.duration ? ` · ${step.duration}ms` : ''
|
||||
const stepLabel = this.#pageAgent.i18n.t('ui.panel.step', {
|
||||
number: step.stepNumber.toString(),
|
||||
time,
|
||||
duration: durationText || '', // Explicitly pass empty string to replace template
|
||||
})
|
||||
|
||||
return `
|
||||
<div class="${styles.historyItem} ${typeClass}">
|
||||
<div class="${styles.historyContent}">
|
||||
<span class="${styles.statusIcon}">${statusIcon}</span>
|
||||
<span>${step.displayText}</span>
|
||||
</div>
|
||||
<div class="${styles.historyMeta}">
|
||||
${stepLabel}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display text for tool execution
|
||||
*/
|
||||
export function getToolExecutingText(toolName: string, args: any, i18n: I18n): string {
|
||||
switch (toolName) {
|
||||
case 'click_element_by_index':
|
||||
return i18n.t('ui.tools.clicking', { index: args.index })
|
||||
case 'input_text':
|
||||
return i18n.t('ui.tools.inputting', { index: args.index })
|
||||
case 'select_dropdown_option':
|
||||
return i18n.t('ui.tools.selecting', { text: args.text })
|
||||
case 'scroll':
|
||||
return i18n.t('ui.tools.scrolling')
|
||||
case 'wait':
|
||||
return i18n.t('ui.tools.waiting', { seconds: args.seconds })
|
||||
case 'done':
|
||||
return i18n.t('ui.tools.done')
|
||||
default:
|
||||
return i18n.t('ui.tools.executing', { toolName })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display text for tool completion
|
||||
*/
|
||||
export function getToolCompletedText(toolName: string, args: any, i18n: I18n): string | null {
|
||||
switch (toolName) {
|
||||
case 'click_element_by_index':
|
||||
return i18n.t('ui.tools.clicked', { index: args.index })
|
||||
case 'input_text':
|
||||
return i18n.t('ui.tools.inputted', { text: args.text })
|
||||
case 'select_dropdown_option':
|
||||
return i18n.t('ui.tools.selected', { text: args.text })
|
||||
case 'scroll':
|
||||
return i18n.t('ui.tools.scrolled')
|
||||
case 'wait':
|
||||
return i18n.t('ui.tools.waited')
|
||||
case 'done':
|
||||
return null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
10
packages/page-agent/src/ui/SimulatorMask.module.css
Normal file
10
packages/page-agent/src/ui/SimulatorMask.module.css
Normal file
@@ -0,0 +1,10 @@
|
||||
.wrapper {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483641; /* 确保在所有元素之上,除了 panel */
|
||||
/* pointer-events: none; */
|
||||
cursor: not-allowed;
|
||||
overflow: hidden;
|
||||
|
||||
display: none;
|
||||
}
|
||||
172
packages/page-agent/src/ui/SimulatorMask.ts
Normal file
172
packages/page-agent/src/ui/SimulatorMask.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { Motion } from 'ai-motion'
|
||||
|
||||
import { isPageDark } from '../utils/checkDarkMode'
|
||||
|
||||
import styles from './SimulatorMask.module.css'
|
||||
import cursorStyles from './cursor.module.css'
|
||||
|
||||
export class SimulatorMask {
|
||||
wrapper = document.createElement('div')
|
||||
motion = new Motion({
|
||||
mode: isPageDark() ? 'dark' : 'light',
|
||||
styles: {
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
},
|
||||
})
|
||||
|
||||
#cursor = document.createElement('div')
|
||||
|
||||
#currentCursorX = 0
|
||||
#currentCursorY = 0
|
||||
|
||||
#targetCursorX = 0
|
||||
#targetCursorY = 0
|
||||
|
||||
constructor() {
|
||||
this.wrapper.id = 'page-agent-runtime_simulator-mask'
|
||||
this.wrapper.className = styles.wrapper
|
||||
this.wrapper.setAttribute('data-browser-use-ignore', 'true')
|
||||
|
||||
this.wrapper.appendChild(this.motion.element)
|
||||
this.motion.autoResize(this.wrapper)
|
||||
|
||||
// Capture all mouse, keyboard, and wheel events
|
||||
this.wrapper.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
})
|
||||
this.wrapper.addEventListener('mousedown', (e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
})
|
||||
this.wrapper.addEventListener('mouseup', (e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
})
|
||||
this.wrapper.addEventListener('mousemove', (e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
})
|
||||
this.wrapper.addEventListener('wheel', (e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
})
|
||||
this.wrapper.addEventListener('keydown', (e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
})
|
||||
this.wrapper.addEventListener('keyup', (e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
})
|
||||
|
||||
// Create AI cursor
|
||||
this.#createCursor()
|
||||
// this.show()
|
||||
|
||||
document.body.appendChild(this.wrapper)
|
||||
|
||||
this.#moveCursorToTarget()
|
||||
|
||||
window.addEventListener('PageAgent::MovePointerTo', (event: Event) => {
|
||||
const { x, y } = (event as CustomEvent).detail
|
||||
this.setCursorPosition(x, y)
|
||||
})
|
||||
|
||||
window.addEventListener('PageAgent::ClickPointer', (event: Event) => {
|
||||
this.triggerClickAnimation()
|
||||
})
|
||||
}
|
||||
|
||||
#createCursor() {
|
||||
this.#cursor.className = cursorStyles.cursor
|
||||
|
||||
// Create ripple effect container
|
||||
const rippleContainer = document.createElement('div')
|
||||
rippleContainer.className = cursorStyles.cursorRipple
|
||||
this.#cursor.appendChild(rippleContainer)
|
||||
|
||||
// Create filling layer
|
||||
const fillingLayer = document.createElement('div')
|
||||
fillingLayer.className = cursorStyles.cursorFilling
|
||||
this.#cursor.appendChild(fillingLayer)
|
||||
|
||||
// Create border layer
|
||||
const borderLayer = document.createElement('div')
|
||||
borderLayer.className = cursorStyles.cursorBorder
|
||||
this.#cursor.appendChild(borderLayer)
|
||||
|
||||
this.wrapper.appendChild(this.#cursor)
|
||||
}
|
||||
|
||||
#moveCursorToTarget() {
|
||||
const newX = this.#currentCursorX + (this.#targetCursorX - this.#currentCursorX) * 0.2
|
||||
const newY = this.#currentCursorY + (this.#targetCursorY - this.#currentCursorY) * 0.2
|
||||
|
||||
const xDistance = Math.abs(newX - this.#targetCursorX)
|
||||
if (xDistance > 0) {
|
||||
if (xDistance < 2) {
|
||||
this.#currentCursorX = this.#targetCursorX
|
||||
} else {
|
||||
this.#currentCursorX = newX
|
||||
}
|
||||
this.#cursor.style.left = `${this.#currentCursorX}px`
|
||||
}
|
||||
|
||||
const yDistance = Math.abs(newY - this.#targetCursorY)
|
||||
if (yDistance > 0) {
|
||||
if (yDistance < 2) {
|
||||
this.#currentCursorY = this.#targetCursorY
|
||||
} else {
|
||||
this.#currentCursorY = newY
|
||||
}
|
||||
this.#cursor.style.top = `${this.#currentCursorY}px`
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => this.#moveCursorToTarget())
|
||||
}
|
||||
|
||||
setCursorPosition(x: number, y: number) {
|
||||
this.#targetCursorX = x
|
||||
this.#targetCursorY = y
|
||||
}
|
||||
|
||||
triggerClickAnimation() {
|
||||
this.#cursor.classList.remove(cursorStyles.clicking)
|
||||
// Force reflow to restart animation
|
||||
void this.#cursor.offsetHeight
|
||||
this.#cursor.classList.add(cursorStyles.clicking)
|
||||
}
|
||||
|
||||
show() {
|
||||
this.motion.start()
|
||||
this.motion.fadeIn()
|
||||
|
||||
this.wrapper.style.display = 'block'
|
||||
|
||||
// Initialize cursor position
|
||||
this.#currentCursorX = window.innerWidth / 2
|
||||
this.#currentCursorY = window.innerHeight / 2
|
||||
this.#targetCursorX = this.#currentCursorX
|
||||
this.#targetCursorY = this.#currentCursorY
|
||||
this.#cursor.style.left = `${this.#currentCursorX}px`
|
||||
this.#cursor.style.top = `${this.#currentCursorY}px`
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.motion.fadeOut()
|
||||
this.motion.pause()
|
||||
|
||||
this.#cursor.classList.remove(cursorStyles.clicking)
|
||||
|
||||
setTimeout(() => {
|
||||
this.wrapper.style.display = 'none'
|
||||
}, 800) // Match the animation duration
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.motion.dispose()
|
||||
this.wrapper.remove()
|
||||
}
|
||||
}
|
||||
93
packages/page-agent/src/ui/UIState.ts
Normal file
93
packages/page-agent/src/ui/UIState.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Agent execution state management
|
||||
*/
|
||||
|
||||
export interface Step {
|
||||
id: string
|
||||
stepNumber: number
|
||||
timestamp: Date
|
||||
type: 'thinking' | 'tool_executing' | 'completed' | 'error' | 'output' | 'input' | 'retry'
|
||||
|
||||
// Tool execution related
|
||||
toolName?: string
|
||||
toolArgs?: any
|
||||
toolResult?: any
|
||||
|
||||
// Display data
|
||||
displayText: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export type AgentStatus = 'idle' | 'running' | 'paused' | 'completed' | 'error'
|
||||
|
||||
export class UIState {
|
||||
private steps: Step[] = []
|
||||
private currentStep: Step | null = null
|
||||
private status: AgentStatus = 'idle'
|
||||
private stepCounter = 0
|
||||
|
||||
addStep(stepData: Omit<Step, 'id' | 'stepNumber' | 'timestamp'>): Step {
|
||||
const step: Step = {
|
||||
id: this.generateId(),
|
||||
stepNumber: ++this.stepCounter,
|
||||
timestamp: new Date(),
|
||||
...stepData,
|
||||
}
|
||||
|
||||
this.steps.push(step)
|
||||
this.currentStep = step
|
||||
|
||||
// Update overall status
|
||||
this.updateStatus(step.type)
|
||||
|
||||
return step
|
||||
}
|
||||
|
||||
updateCurrentStep(updates: Partial<Step>): Step | null {
|
||||
if (!this.currentStep) return null
|
||||
|
||||
Object.assign(this.currentStep, updates)
|
||||
return this.currentStep
|
||||
}
|
||||
|
||||
getCurrentStep(): Step | null {
|
||||
return this.currentStep
|
||||
}
|
||||
|
||||
getAllSteps(): Step[] {
|
||||
return [...this.steps]
|
||||
}
|
||||
|
||||
getStatus(): AgentStatus {
|
||||
return this.status
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.steps = []
|
||||
this.currentStep = null
|
||||
this.status = 'idle'
|
||||
this.stepCounter = 0
|
||||
}
|
||||
|
||||
private updateStatus(stepType: Step['type']): void {
|
||||
switch (stepType) {
|
||||
case 'thinking':
|
||||
case 'tool_executing':
|
||||
case 'output':
|
||||
case 'input':
|
||||
case 'retry':
|
||||
this.status = 'running'
|
||||
break
|
||||
case 'completed':
|
||||
this.status = 'completed'
|
||||
break
|
||||
case 'error':
|
||||
this.status = 'error'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
return `step_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`
|
||||
}
|
||||
}
|
||||
91
packages/page-agent/src/ui/cursor.module.css
Normal file
91
packages/page-agent/src/ui/cursor.module.css
Normal file
@@ -0,0 +1,91 @@
|
||||
/* AI 光标样式 */
|
||||
.cursor {
|
||||
position: absolute;
|
||||
width: var(--cursor-size, 75px);
|
||||
height: var(--cursor-size, 75px);
|
||||
pointer-events: none;
|
||||
z-index: 10000;
|
||||
transform: translate(-30%, -30%);
|
||||
|
||||
animation: cursor-enter 300ms ease-out forwards;
|
||||
}
|
||||
|
||||
.cursorBorder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(45deg, rgb(57, 182, 255), rgb(189, 69, 251));
|
||||
mask-image: url(https://img.alicdn.com/imgextra/i1/O1CN01YHLVYR1LvqWIyo5kH_!!6000000001362-2-tps-202-202.png);
|
||||
mask-size: 100% 100%;
|
||||
mask-repeat: no-repeat;
|
||||
animation: cursor-breathe 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.cursorFilling {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: url(https://img.alicdn.com/imgextra/i3/O1CN01JZOqOS1Tu1sIKbPLW_!!6000000002441-2-tps-202-202.png);
|
||||
background-size: 100% 100%;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.cursorRipple {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cursor.clicking .cursorRipple::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: -30%;
|
||||
top: -30%;
|
||||
border: 4px solid rgba(57, 182, 255, 1);
|
||||
border-radius: 50%;
|
||||
animation: cursor-ripple 300ms ease-out forwards;
|
||||
}
|
||||
|
||||
/* 光标动画关键帧 */
|
||||
@keyframes cursor-breathe {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cursor-rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cursor-enter {
|
||||
0% {
|
||||
transform: translate(-30%, -30%) scale(0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-30%, -30%) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cursor-ripple {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
64
packages/page-agent/src/ui/motion-css/createMotion.ts
Normal file
64
packages/page-agent/src/ui/motion-css/createMotion.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import styles from './motion.module.css'
|
||||
|
||||
export function createMotion() {
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.className = styles.wrapper
|
||||
|
||||
{
|
||||
const colorWrapper = document.createElement('div')
|
||||
colorWrapper.className = styles.colorWrapper
|
||||
wrapper.appendChild(colorWrapper)
|
||||
|
||||
const layerA = document.createElement('div')
|
||||
layerA.className = styles.colorLayer + ' ' + styles.layerA
|
||||
colorWrapper.appendChild(layerA)
|
||||
|
||||
const layerB = document.createElement('div')
|
||||
layerB.className = styles.colorLayer + ' ' + styles.layerB
|
||||
colorWrapper.appendChild(layerB)
|
||||
|
||||
const layerC = document.createElement('div')
|
||||
layerC.className = styles.colorLayer + ' ' + styles.layerC
|
||||
colorWrapper.appendChild(layerC)
|
||||
}
|
||||
|
||||
{
|
||||
const borderWrapper = document.createElement('div')
|
||||
borderWrapper.className = styles.borderWrapper
|
||||
wrapper.appendChild(borderWrapper)
|
||||
|
||||
const layerA = document.createElement('div')
|
||||
layerA.className = styles.borderLayer + ' ' + styles.layerA
|
||||
borderWrapper.appendChild(layerA)
|
||||
|
||||
const layerB = document.createElement('div')
|
||||
layerB.className = styles.borderLayer + ' ' + styles.layerB
|
||||
borderWrapper.appendChild(layerB)
|
||||
|
||||
const layerC = document.createElement('div')
|
||||
layerC.className = styles.borderLayer + ' ' + styles.layerC
|
||||
borderWrapper.appendChild(layerC)
|
||||
}
|
||||
|
||||
function show() {
|
||||
wrapper.classList.remove(styles.exit)
|
||||
wrapper.classList.remove(styles.entry)
|
||||
// Force reflow to restart animation
|
||||
void wrapper.offsetHeight
|
||||
wrapper.classList.add(styles.entry)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
wrapper.classList.remove(styles.entry)
|
||||
wrapper.classList.remove(styles.exit)
|
||||
// Force reflow to restart animation
|
||||
void wrapper.offsetHeight
|
||||
wrapper.classList.add(styles.exit)
|
||||
}
|
||||
|
||||
return {
|
||||
element: wrapper,
|
||||
show,
|
||||
hide,
|
||||
}
|
||||
}
|
||||
397
packages/page-agent/src/ui/motion-css/motion.module.css
Normal file
397
packages/page-agent/src/ui/motion-css/motion.module.css
Normal file
@@ -0,0 +1,397 @@
|
||||
.wrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
|
||||
transform-origin: center;
|
||||
|
||||
--color-1: rgb(57, 182, 255);
|
||||
--color-2: rgb(189, 69, 251);
|
||||
--color-3: rgb(255, 87, 51);
|
||||
--color-4: rgb(255, 214, 0);
|
||||
|
||||
--blend-mode: screen;
|
||||
}
|
||||
|
||||
.colorLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
/* 变亮混合模式 */
|
||||
/* mix-blend-mode: screen; */
|
||||
/* mix-blend-mode: overlay; */
|
||||
/* mix-blend-mode: multiply; */
|
||||
mix-blend-mode: add;
|
||||
|
||||
/* 边框遮罩 - 中间透明,边缘不透明 */
|
||||
mask-image: url(https://img.alicdn.com/imgextra/i2/O1CN01iW1wfX1C0ICvoPbTq_!!6000000000018-2-tps-512-512.png);
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: calc(100% + 10px) calc(100% + 10px);
|
||||
}
|
||||
|
||||
.borderWrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
/* filter: blur(10px); */
|
||||
}
|
||||
|
||||
.borderLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
/* 变亮混合模式 */
|
||||
/* mix-blend-mode: overlay; */
|
||||
mix-blend-mode: add;
|
||||
|
||||
mask-image:
|
||||
linear-gradient(
|
||||
to right,
|
||||
black 0px,
|
||||
black 2px,
|
||||
transparent 2px,
|
||||
transparent calc(100% - 2px),
|
||||
black calc(100% - 2px),
|
||||
black 100%
|
||||
),
|
||||
linear-gradient(
|
||||
to top,
|
||||
black 0px,
|
||||
black 2px,
|
||||
transparent 2px,
|
||||
transparent calc(100% - 2px),
|
||||
black calc(100% - 2px),
|
||||
black 100%
|
||||
);
|
||||
|
||||
mask-composite: add;
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: 100% 100%;
|
||||
|
||||
/* filter: blur(100px); */
|
||||
}
|
||||
|
||||
.blueLayer {
|
||||
&.colorLayer {
|
||||
mask-position: left -5px top -5px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
/* inset: 0; */
|
||||
width: calc(max(100vw, 100vh) * 1.5);
|
||||
height: 600px;
|
||||
top: calc(50% - 300px);
|
||||
left: 50%;
|
||||
filter: blur(100px);
|
||||
background: rgb(57, 182, 255);
|
||||
animation: rotate-clockwise 4s linear infinite;
|
||||
animation-delay: -3s;
|
||||
}
|
||||
}
|
||||
|
||||
.purpleLayer {
|
||||
&.colorLayer {
|
||||
mask-position: left -3px top -7px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
/* inset: 0; */
|
||||
width: calc(max(100vw, 100vh) * 1.5);
|
||||
height: 600px;
|
||||
top: calc(50% - 300px);
|
||||
left: 50%;
|
||||
filter: blur(100px);
|
||||
background: rgb(189, 69, 251);
|
||||
animation: rotate-clockwise 4s linear infinite;
|
||||
animation-delay: -2s;
|
||||
}
|
||||
}
|
||||
|
||||
.orangeLayer {
|
||||
/* opacity: 0.5; */
|
||||
|
||||
&.colorLayer {
|
||||
mask-position: left -7px top -2px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
/* inset: 0; */
|
||||
width: calc(max(100vw, 100vh) * 1.5);
|
||||
height: 600px;
|
||||
top: calc(50% - 300px);
|
||||
left: 50%;
|
||||
filter: blur(100px);
|
||||
background: rgb(255, 87, 51);
|
||||
animation: rotate-counter-clockwise 3s linear infinite;
|
||||
animation-delay: -2s;
|
||||
}
|
||||
}
|
||||
|
||||
.yellowLayer {
|
||||
/* opacity: 0.5; */
|
||||
|
||||
&.colorLayer {
|
||||
mask-position: left -6px top -4px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
/* inset: 0; */
|
||||
width: calc(max(100vw, 100vh) * 1.5);
|
||||
height: 600px;
|
||||
top: calc(50% - 300px);
|
||||
left: 50%;
|
||||
filter: blur(100px);
|
||||
background: rgb(255, 214, 0);
|
||||
animation: rotate-counter-clockwise 4s linear infinite;
|
||||
animation-delay: -1s;
|
||||
}
|
||||
}
|
||||
|
||||
/* 旋转动画 */
|
||||
@keyframes rotate-clockwise {
|
||||
0% {
|
||||
transform: translateX(-50%) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate-counter-clockwise {
|
||||
0% {
|
||||
transform: translateX(-50%) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%) rotate(-360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wrapper-entry {
|
||||
from {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
rgb(57, 182, 255)
|
||||
rgb(189, 69, 251)
|
||||
rgb(255, 87, 51)
|
||||
rgb(255, 214, 0)
|
||||
*/
|
||||
|
||||
@keyframes mask-running {
|
||||
from {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mask-running-reverse {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
}
|
||||
|
||||
.colorWrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
.colorLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
mix-blend-mode: var(--blend-mode);
|
||||
|
||||
/* 边框遮罩 - 中间透明,边缘不透明 */
|
||||
mask-image: url(https://img.alicdn.com/imgextra/i2/O1CN01iW1wfX1C0ICvoPbTq_!!6000000000018-2-tps-512-512.png);
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: 100% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.borderWrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
--blend-mode: lighten;
|
||||
|
||||
.borderLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
mix-blend-mode: var(--blend-mode);
|
||||
|
||||
mask-border: url(https://img.alicdn.com/imgextra/i3/O1CN01bFjRug1yssyWEUbKL_!!6000000006635-2-tps-256-256.png)
|
||||
25;
|
||||
-webkit-mask-box-image: url(https://img.alicdn.com/imgextra/i3/O1CN01bFjRug1yssyWEUbKL_!!6000000006635-2-tps-256-256.png)
|
||||
25;
|
||||
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: 100% 100%;
|
||||
|
||||
background-color: var(--color-2);
|
||||
}
|
||||
}
|
||||
|
||||
.entry .colorWrapper,
|
||||
.entry .borderWrapper {
|
||||
animation: wrapper-entry 0.8s ease-in-out forwards;
|
||||
}
|
||||
|
||||
.exit .colorWrapper,
|
||||
.exit .borderWrapper {
|
||||
animation: wrapper-entry 0.8s ease-in-out reverse forwards;
|
||||
}
|
||||
|
||||
.layerA {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
&::before {
|
||||
mix-blend-mode: var(--blend-mode);
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: -100%;
|
||||
top: 0;
|
||||
background-image: linear-gradient(
|
||||
to right bottom,
|
||||
transparent,
|
||||
var(--color-1),
|
||||
transparent,
|
||||
var(--color-1),
|
||||
transparent
|
||||
);
|
||||
animation: mask-running 2s linear infinite;
|
||||
}
|
||||
|
||||
&::after {
|
||||
mix-blend-mode: var(--blend-mode);
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
background-image: linear-gradient(
|
||||
to right bottom,
|
||||
transparent,
|
||||
var(--color-1),
|
||||
transparent,
|
||||
var(--color-1),
|
||||
transparent
|
||||
);
|
||||
animation: mask-running 2s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.layerB {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
&::before {
|
||||
mix-blend-mode: var(--blend-mode);
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: -100%;
|
||||
top: 0;
|
||||
background: linear-gradient(
|
||||
to right top,
|
||||
transparent,
|
||||
var(--color-2),
|
||||
transparent,
|
||||
var(--color-2),
|
||||
transparent
|
||||
);
|
||||
animation: mask-running-reverse 3s linear infinite;
|
||||
}
|
||||
|
||||
&::after {
|
||||
mix-blend-mode: var(--blend-mode);
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
background: linear-gradient(
|
||||
to right top,
|
||||
transparent,
|
||||
var(--color-2),
|
||||
transparent,
|
||||
var(--color-2),
|
||||
transparent
|
||||
);
|
||||
animation: mask-running-reverse 3s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.layerC {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
opacity: 0.5;
|
||||
|
||||
&::before {
|
||||
mix-blend-mode: var(--blend-mode);
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: -100%;
|
||||
top: 0;
|
||||
background: linear-gradient(
|
||||
to right top,
|
||||
transparent,
|
||||
var(--color-3),
|
||||
transparent,
|
||||
var(--color-3),
|
||||
transparent
|
||||
);
|
||||
animation: mask-running 1s linear infinite;
|
||||
}
|
||||
|
||||
&::after {
|
||||
mix-blend-mode: var(--blend-mode);
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
background: linear-gradient(
|
||||
to right top,
|
||||
transparent,
|
||||
var(--color-3),
|
||||
transparent,
|
||||
var(--color-3),
|
||||
transparent
|
||||
);
|
||||
animation: mask-running 1s linear infinite;
|
||||
}
|
||||
}
|
||||
5
packages/page-agent/src/ui/motion-css/readme
Normal file
5
packages/page-agent/src/ui/motion-css/readme
Normal file
@@ -0,0 +1,5 @@
|
||||
This is the CSS implementation of ai-motion.
|
||||
|
||||
Easy to use but Terrible performance. Causing full screen glitching in some browsers.
|
||||
|
||||
Use it only in a small area.
|
||||
17
packages/page-agent/src/utils/assert.ts
Normal file
17
packages/page-agent/src/utils/assert.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import chalk from 'chalk'
|
||||
|
||||
/**
|
||||
* Simple assertion function that throws an error if the condition is falsy
|
||||
* @param condition - The condition to assert
|
||||
* @param message - Optional error message
|
||||
* @throws Error if condition is falsy
|
||||
*/
|
||||
export function assert(condition: unknown, message?: string, silent?: boolean): asserts condition {
|
||||
if (!condition) {
|
||||
const errorMessage = message ?? 'Assertion failed'
|
||||
|
||||
if (!silent) console.error(chalk.red(`❌ assert: ${errorMessage}`))
|
||||
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
}
|
||||
122
packages/page-agent/src/utils/bus.ts
Normal file
122
packages/page-agent/src/utils/bus.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Type-safe event bus for decoupling PageAgent and Panel
|
||||
*/
|
||||
import type { Step } from '../ui/UIState'
|
||||
|
||||
/**
|
||||
* Event mapping definitions
|
||||
* @note Event bus callbacks must be repeatable without errors
|
||||
*/
|
||||
export interface PageAgentEventMap {
|
||||
// Panel control events
|
||||
// call panel.show()
|
||||
'panel:show': { params: undefined }
|
||||
// call panel.hide()
|
||||
'panel:hide': { params: undefined }
|
||||
// call panel.reset()
|
||||
'panel:reset': { params: undefined }
|
||||
// call panel.update()
|
||||
'panel:update': { params: Omit<Step, 'id' | 'stepNumber' | 'timestamp'> }
|
||||
// call panel.expand()
|
||||
'panel:expand': { params: undefined }
|
||||
// call panel.collapse()
|
||||
'panel:collapse': { params: undefined }
|
||||
|
||||
// PageAgent status events
|
||||
// 'agent:beforeUpdate': { params: undefined }
|
||||
// 'agent:afterUpdate': { params: undefined }
|
||||
// 'agent:execute': { params: { task: string } }
|
||||
// 'agent:done': { params: { text: string; success: boolean } }
|
||||
// 'agent:paused': { params: undefined }
|
||||
// 'agent:resumed': { params: undefined }
|
||||
// 'agent:disposed': { params: undefined }
|
||||
// 'agent:error': { params: { error: string | Error } }
|
||||
|
||||
// Task status change events
|
||||
// 'task:start': { params: { task: string } }
|
||||
// 'task:step': { params: Omit<AgentStep, 'id' | 'stepNumber' | 'timestamp'> }
|
||||
// 'task:complete': { params: { text: string; success: boolean } }
|
||||
// 'task:error': { params: { error: string | Error } }
|
||||
|
||||
// Index signature for dynamic event names
|
||||
// [key: string]: { params: any }
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handler type definitions
|
||||
*/
|
||||
export type EventHandler<T extends keyof PageAgentEventMap> =
|
||||
PageAgentEventMap[T]['params'] extends undefined
|
||||
? () => void
|
||||
: (params: PageAgentEventMap[T]['params']) => void
|
||||
|
||||
/**
|
||||
* Async event handler type definitions
|
||||
*/
|
||||
export type AsyncEventHandler<T extends keyof PageAgentEventMap> =
|
||||
PageAgentEventMap[T]['params'] extends undefined
|
||||
? () => Promise<void>
|
||||
: (params: PageAgentEventMap[T]['params']) => Promise<void>
|
||||
|
||||
/**
|
||||
* Type-safe event bus
|
||||
* @note Mainly used to decouple logic and UI
|
||||
* @note All modules of a PageAgent instance share the same EventBus instance for communication
|
||||
* @note Use with caution if delivery guarantee is needed for logic communication
|
||||
* @note `on` `once` `emit` methods handle built-in events with type protection, use `addEventListener` for other events
|
||||
*/
|
||||
class EventBus extends EventTarget {
|
||||
/**
|
||||
* Listen to built-in events
|
||||
*/
|
||||
on<T extends keyof PageAgentEventMap>(event: T, handler: EventHandler<T>): void {
|
||||
const wrappedHandler = (e: Event) => {
|
||||
const customEvent = e as CustomEvent
|
||||
const params = customEvent.detail?.[0]
|
||||
return handler(params)
|
||||
}
|
||||
this.addEventListener(event, wrappedHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to built-in events (one-time)
|
||||
*/
|
||||
once<T extends keyof PageAgentEventMap>(event: T, handler: EventHandler<T>): void {
|
||||
const wrappedHandler = (e: Event) => {
|
||||
const customEvent = e as CustomEvent
|
||||
const params = customEvent.detail?.[0]
|
||||
return handler(params)
|
||||
}
|
||||
this.addEventListener(event, wrappedHandler, { once: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit built-in events
|
||||
*/
|
||||
emit<T extends keyof PageAgentEventMap>(
|
||||
event: T,
|
||||
...args: PageAgentEventMap[T]['params'] extends undefined
|
||||
? []
|
||||
: [PageAgentEventMap[T]['params']]
|
||||
): void {
|
||||
const customEvent = new CustomEvent(event, { detail: args })
|
||||
this.dispatchEvent(customEvent)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const buses = new Map<string, EventBus>()
|
||||
|
||||
/**
|
||||
* Get the event bus for a given channel
|
||||
*/
|
||||
export function getEventBus(channel: string) {
|
||||
if (buses.has(channel)) {
|
||||
return buses.get(channel)!
|
||||
}
|
||||
const bus = new EventBus()
|
||||
buses.set(channel, bus)
|
||||
return bus
|
||||
}
|
||||
|
||||
export type { EventBus }
|
||||
110
packages/page-agent/src/utils/checkDarkMode.ts
Normal file
110
packages/page-agent/src/utils/checkDarkMode.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Checks for common dark mode CSS classes on the html or body elements.
|
||||
* @returns {boolean} - True if a common dark mode class is found.
|
||||
*/
|
||||
function hasDarkModeClass() {
|
||||
const DFEAULT_DARK_MODE_CLASSES = ['dark', 'dark-mode', 'theme-dark', 'night', 'night-mode']
|
||||
|
||||
const htmlElement = document.documentElement
|
||||
const bodyElement = document.body
|
||||
|
||||
// Check class names on <html> and <body>
|
||||
for (const className of DFEAULT_DARK_MODE_CLASSES) {
|
||||
if (htmlElement.classList.contains(className) || bodyElement.classList.contains(className)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Some sites use data attributes
|
||||
const darkThemeAttribute = htmlElement.getAttribute('data-theme')
|
||||
if (darkThemeAttribute?.toLowerCase().includes('dark')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an RGB or RGBA color string and returns an object with r, g, b properties.
|
||||
* @param {string} colorString - e.g., "rgb(34, 34, 34)" or "rgba(0, 0, 0, 0.5)"
|
||||
* @returns {{r: number, g: number, b: number}|null}
|
||||
*/
|
||||
function parseRgbColor(colorString: string) {
|
||||
const rgbMatch = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(colorString)
|
||||
if (!rgbMatch) {
|
||||
return null // Not a valid rgb/rgba string
|
||||
}
|
||||
return {
|
||||
r: parseInt(rgbMatch[1]),
|
||||
g: parseInt(rgbMatch[2]),
|
||||
b: parseInt(rgbMatch[3]),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a color is "dark" based on its calculated luminance.
|
||||
* @param {string} colorString - The CSS color string (e.g., "rgb(50, 50, 50)").
|
||||
* @param {number} threshold - A value between 0 and 255. Colors with luminance below this will be considered dark. Default is 128.
|
||||
* @returns {boolean} - True if the color is considered dark.
|
||||
*/
|
||||
function isColorDark(colorString: string, threshold = 128) {
|
||||
if (!colorString || colorString === 'transparent' || colorString.startsWith('rgba(0, 0, 0, 0)')) {
|
||||
return false // Transparent is not dark
|
||||
}
|
||||
|
||||
const rgb = parseRgbColor(colorString)
|
||||
if (!rgb) {
|
||||
return false // Could not parse color
|
||||
}
|
||||
|
||||
// Calculate perceived luminance using the standard formula
|
||||
const luminance = 0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b
|
||||
|
||||
return luminance < threshold
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the background color of the body element to determine if the page is dark.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isBackgroundDark() {
|
||||
// We check both <html> and <body> because some pages set the color on <html>
|
||||
const htmlStyle = window.getComputedStyle(document.documentElement)
|
||||
const bodyStyle = window.getComputedStyle(document.body)
|
||||
|
||||
// Get background colors
|
||||
const htmlBgColor = htmlStyle.backgroundColor
|
||||
const bodyBgColor = bodyStyle.backgroundColor
|
||||
|
||||
// The body's background might be transparent, in which case we should
|
||||
// fall back to the html element's background.
|
||||
if (isColorDark(bodyBgColor)) {
|
||||
return true
|
||||
} else if (bodyBgColor === 'transparent' || bodyBgColor.startsWith('rgba(0, 0, 0, 0)')) {
|
||||
return isColorDark(htmlBgColor)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* A comprehensive function to determine if the page is currently in a dark theme.
|
||||
* It combines class checking and background color analysis.
|
||||
* @returns {boolean} - True if the page is likely dark.
|
||||
*/
|
||||
export function isPageDark() {
|
||||
// Strategy 1: Check for common dark mode classes
|
||||
if (hasDarkModeClass()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Strategy 2: Analyze the computed background color
|
||||
if (isBackgroundDark()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// @TODO add more checks here, e.g., analyzing text color,
|
||||
// or checking the background of major layout elements like <main> or #app.
|
||||
|
||||
return false
|
||||
}
|
||||
80
packages/page-agent/src/utils/index.ts
Normal file
80
packages/page-agent/src/utils/index.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Wait until condition becomes true
|
||||
* @returns Returns when condition becomes true, throws otherwise
|
||||
* @param timeout Timeout in milliseconds, default 0 means no timeout, throws error on timeout
|
||||
*/
|
||||
export async function waitUntil(check: () => boolean, timeout = 60 * 60_1000): Promise<boolean> {
|
||||
if (check()) return true
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now()
|
||||
const interval = setInterval(() => {
|
||||
if (check()) {
|
||||
clearInterval(interval)
|
||||
resolve(true)
|
||||
} else if (Date.now() - start > timeout) {
|
||||
clearInterval(interval)
|
||||
reject(new Error('Timeout waiting for condition to become true'))
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
export function truncate(text: string, maxLength: number): string {
|
||||
if (text.length > maxLength) {
|
||||
return text.substring(0, maxLength) + '...'
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
export function trimLines(text: string): string {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
export function randomID(existingIDs?: string[]): string {
|
||||
let id = Math.random().toString(36).substring(2, 11)
|
||||
|
||||
if (!existingIDs) {
|
||||
return id
|
||||
}
|
||||
|
||||
const MAX_TRY = 1000
|
||||
let tryCount = 0
|
||||
|
||||
while (existingIDs.includes(id)) {
|
||||
id = Math.random().toString(36).substring(2, 11)
|
||||
tryCount++
|
||||
if (tryCount > MAX_TRY) {
|
||||
throw new Error('randomID: too many try')
|
||||
}
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
if (!window.__PAGE_AGENT_IDS__) {
|
||||
window.__PAGE_AGENT_IDS__ = []
|
||||
}
|
||||
|
||||
const ids = window.__PAGE_AGENT_IDS__
|
||||
|
||||
/**
|
||||
* Generate a random ID.
|
||||
* @note Unique within this window.
|
||||
*/
|
||||
export function uid() {
|
||||
const id = randomID(ids)
|
||||
ids.push(id)
|
||||
return id
|
||||
}
|
||||
10
packages/page-agent/tsconfig.json
Normal file
10
packages/page-agent/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["src", "env.d.ts"]
|
||||
}
|
||||
85
packages/page-agent/vite.config.js
Normal file
85
packages/page-agent/vite.config.js
Normal file
@@ -0,0 +1,85 @@
|
||||
// @ts-check
|
||||
import chalk from 'chalk'
|
||||
import 'dotenv/config'
|
||||
import process from 'node:process'
|
||||
import { dirname, resolve } from 'path'
|
||||
import dts from 'unplugin-dts/vite'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig } from 'vite'
|
||||
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// ============================================================================
|
||||
// Library Config (ES Module for NPM Package)
|
||||
// ============================================================================
|
||||
/** @type {import('vite').UserConfig} */
|
||||
const libConfig = {
|
||||
clearScreen: false,
|
||||
plugins: [
|
||||
dts({ tsconfigPath: './tsconfig.json', bundleTypes: true }),
|
||||
cssInjectedByJsPlugin({ relativeCSSInjection: true }),
|
||||
],
|
||||
publicDir: false,
|
||||
esbuild: {
|
||||
keepNames: true,
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/PageAgent.ts'),
|
||||
name: 'PageAgent',
|
||||
fileName: 'page-agent',
|
||||
formats: ['es'],
|
||||
},
|
||||
outDir: resolve(__dirname, 'dist', 'lib'),
|
||||
rollupOptions: {
|
||||
external: ['ai', 'ai-motion', 'chalk', 'zod'],
|
||||
},
|
||||
minify: false,
|
||||
sourcemap: true,
|
||||
cssCodeSplit: true,
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UMD Config (Browser Bundle for CDN)
|
||||
// ============================================================================
|
||||
/** @type {import('vite').UserConfig} */
|
||||
const umdConfig = {
|
||||
plugins: [cssInjectedByJsPlugin({ relativeCSSInjection: true })],
|
||||
publicDir: false,
|
||||
esbuild: {
|
||||
keepNames: true,
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/entry.ts'),
|
||||
name: 'PageAgent',
|
||||
fileName: 'page-agent',
|
||||
formats: ['umd'],
|
||||
},
|
||||
outDir: resolve(__dirname, 'dist', 'umd'),
|
||||
cssCodeSplit: true,
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
const MODE = process.env.MODE
|
||||
|
||||
console.log(chalk.cyan(`📦 Build mode: ${chalk.bold(MODE || 'lib')}`))
|
||||
|
||||
let config
|
||||
if (MODE === 'umd') {
|
||||
config = umdConfig
|
||||
} else {
|
||||
config = libConfig
|
||||
}
|
||||
|
||||
export default defineConfig(config)
|
||||
1
packages/website/README.md
Normal file
1
packages/website/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# Landing Page & Docs
|
||||
6
packages/website/env.d.ts
vendored
Normal file
6
packages/website/env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
60
packages/website/index.html
Normal file
60
packages/website/index.html
Normal file
@@ -0,0 +1,60 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="https://img.alicdn.com/imgextra/i2/O1CN012eGDRI1X6nnMt9clU_!!6000000002875-49-tps-64-64.webp"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PageAgent - The GUI Agent Living in Your Webpage</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="PageAgent.js: Intelligent GUI Agent for any website. Modern web AI automation with minimal integration."
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="PageAgent, AI Agent, GUI Agent, Web Automation, GUI Automation, Frontend, CDN, JavaScript, React, Vite, LLM"
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://img.alicdn.com/imgextra/i3/O1CN01JPT4Fj1FJTfmHfNxO_!!6000000000466-49-tps-512-512.webp"
|
||||
/>
|
||||
<meta property="og:url" content="https://alibaba.github.io/page-agent" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="theme-color" content="#58c0fc" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="author" content="PageAgent.js Team" />
|
||||
<meta property="og:title" content="PageAgent.js - AI-powered GUI Agent" />
|
||||
<meta property="og:description" content="The GUI Agent living in your website." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:locale:alternate" content="zh_CN" />
|
||||
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-HCGRJTN3HM"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || []
|
||||
function gtag() {
|
||||
dataLayer.push(arguments)
|
||||
}
|
||||
gtag('js', new Date())
|
||||
|
||||
gtag('config', 'G-HCGRJTN3HM')
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
<script>
|
||||
// Dynamically update html lang attribute based on i18n detection
|
||||
const updateHtmlLang = () => {
|
||||
const lang = localStorage.getItem('i18nextLng') || navigator.language || 'zh-CN'
|
||||
document.documentElement.lang = lang
|
||||
}
|
||||
updateHtmlLang()
|
||||
window.addEventListener('storage', updateHtmlLang)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
28
packages/website/package.json
Normal file
28
packages/website/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@page-agent/website",
|
||||
"private": true,
|
||||
"version": "0.0.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"page-agent": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||
"i18next": "^25.6.0",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-i18next": "^16.1.4",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"wouter": "^3.7.1"
|
||||
}
|
||||
}
|
||||
21
packages/website/src/components/BetaNotice.tsx
Normal file
21
packages/website/src/components/BetaNotice.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function BetaNotice() {
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
return (
|
||||
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4 mb-8">
|
||||
<div className="flex items-start">
|
||||
<div className="shrink-0">
|
||||
<span className="text-xl">🚧</span>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-orange-800 dark:text-orange-200 mb-1">
|
||||
{t('beta_notice.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-orange-700 dark:text-orange-300">{t('beta_notice.content')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
129
packages/website/src/components/CodeEditor.tsx
Normal file
129
packages/website/src/components/CodeEditor.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 代码编辑器组件,模拟现代代码编辑器的外观
|
||||
*/
|
||||
import React from 'react'
|
||||
|
||||
import HighlightSyntax from './HighlightSyntax'
|
||||
|
||||
interface CodeEditorProps {
|
||||
code: string
|
||||
language?: string
|
||||
title?: string
|
||||
showLineNumbers?: boolean
|
||||
showHeader?: boolean
|
||||
showFooter?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const CodeEditor: React.FC<CodeEditorProps> = ({
|
||||
code,
|
||||
language = 'javascript',
|
||||
title,
|
||||
showLineNumbers = false,
|
||||
showHeader = false,
|
||||
showFooter = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const lines = code.split('\n')
|
||||
|
||||
// 使用 Tailwind 的 dark: 前缀实现自动主题切换
|
||||
const containerClasses =
|
||||
'bg-linear-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 border-gray-300 dark:border-gray-700'
|
||||
const headerClasses = 'bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-700'
|
||||
const headerTextClasses = 'text-gray-700 dark:text-gray-300'
|
||||
const languageTextClasses = 'text-gray-600 dark:text-gray-400'
|
||||
const lineNumbersClasses =
|
||||
'bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-500'
|
||||
const codeAreaClasses = 'bg-white dark:bg-gray-900'
|
||||
const footerClasses =
|
||||
'bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-400'
|
||||
const copyButtonClasses =
|
||||
'bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-600 hover:text-gray-800 dark:text-gray-300 dark:hover:text-white'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative ${containerClasses} rounded-xl border shadow-2xl overflow-hidden ${className}`}
|
||||
>
|
||||
{/* 编辑器顶部栏 */}
|
||||
{showHeader && (
|
||||
<div className={`flex items-center justify-between px-4 py-3 ${headerClasses} border-b`}>
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* 窗口控制按钮 */}
|
||||
<div className="flex space-x-2">
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||
</div>
|
||||
{title && (
|
||||
<span className={`text-sm ${headerTextClasses} font-medium ml-2`}>{title}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={`text-xs ${languageTextClasses} uppercase tracking-wide`}>
|
||||
{language}
|
||||
</span>
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 代码内容区域 */}
|
||||
<div className="relative">
|
||||
<div className="flex">
|
||||
{/* 行号 */}
|
||||
{showLineNumbers && (
|
||||
<div className={`shrink-0 px-4 py-4 ${lineNumbersClasses} border-r select-none`}>
|
||||
<div className="text-xs font-mono leading-6">
|
||||
{lines.map((line, lineIdx) => {
|
||||
const lineNum = lineIdx + 1
|
||||
return (
|
||||
<div key={`${lineNum}-${line.substring(0, 20)}`} className="text-right">
|
||||
{lineNum}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 代码内容 */}
|
||||
<div className={`flex-1 px-4 py-4 ${codeAreaClasses} overflow-x-auto`}>
|
||||
<div className="text-sm font-mono leading-6">
|
||||
<HighlightSyntax code={code} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 复制按钮 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(code).catch(console.error)
|
||||
}}
|
||||
className={`absolute top-3 right-3 p-2 ${copyButtonClasses} rounded-lg transition-all duration-200 opacity-0 group-hover:opacity-100`}
|
||||
title="复制代码"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 底部状态栏 */}
|
||||
{showFooter && (
|
||||
<div className={`px-4 py-2 ${footerClasses} border-t`}>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span>{lines.length} lines</span>
|
||||
<span>UTF-8</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CodeEditor
|
||||
98
packages/website/src/components/DocsLayout.tsx
Normal file
98
packages/website/src/components/DocsLayout.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, useLocation } from 'wouter'
|
||||
|
||||
interface DocsLayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
title: string
|
||||
path: string
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
title: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
export default function DocsLayout({ children }: DocsLayoutProps) {
|
||||
const { t } = useTranslation('common')
|
||||
const [location] = useLocation()
|
||||
|
||||
const navigationSections: NavSection[] = [
|
||||
{
|
||||
title: t('nav.introduction'),
|
||||
items: [
|
||||
{ title: t('nav.overview'), path: '/docs/introduction/overview' },
|
||||
{ title: t('nav.quick_start'), path: '/docs/introduction/quick-start' },
|
||||
{ title: t('nav.limitations'), path: '/docs/introduction/limitations' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('nav.features'),
|
||||
items: [
|
||||
{ title: t('nav.model_integration'), path: '/docs/features/model-integration' },
|
||||
{ title: t('nav.custom_tools'), path: '/docs/features/custom-tools' },
|
||||
{ title: t('nav.knowledge_injection'), path: '/docs/features/knowledge-injection' },
|
||||
{ title: t('nav.security_permissions'), path: '/docs/features/security-permissions' },
|
||||
{ title: t('nav.data_masking'), path: '/docs/features/data-masking' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('nav.integration'),
|
||||
items: [
|
||||
{ title: t('nav.cdn_setup'), path: '/docs/integration/cdn-setup' },
|
||||
{ title: t('nav.configuration'), path: '/docs/integration/configuration' },
|
||||
{ title: t('nav.best_practices'), path: '/docs/integration/best-practices' },
|
||||
{ title: t('nav.third_party_agent'), path: '/docs/integration/third-party-agent' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 py-8 overflow-x-auto">
|
||||
<div className="flex gap-8 min-w-[900px]">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 shrink-0" role="complementary" aria-label="文档导航">
|
||||
<div className="sticky top-8">
|
||||
<nav className="space-y-8" role="navigation" aria-label="文档章节">
|
||||
{navigationSections.map((section) => (
|
||||
<section key={section.title}>
|
||||
<h3 className="font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
{section.title}
|
||||
</h3>
|
||||
<ul className="space-y-2" role="list">
|
||||
{section.items.map((item) => {
|
||||
const isActive = location === item.path
|
||||
return (
|
||||
<li key={item.path}>
|
||||
<Link
|
||||
href={item.path}
|
||||
className={`block px-3 py-2 rounded-lg transition-colors duration-200 ${
|
||||
isActive
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-medium'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 min-w-0" id="main-content" role="main">
|
||||
<div className="prose prose-lg dark:prose-invert max-w-none">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
packages/website/src/components/Footer.tsx
Normal file
31
packages/website/src/components/Footer.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Footer() {
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
return (
|
||||
<footer
|
||||
className="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700"
|
||||
role="contentinfo"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-6 py-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm">{t('footer.copyright')}</p>
|
||||
<div className="flex items-center space-x-6">
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
142
packages/website/src/components/Header.tsx
Normal file
142
packages/website/src/components/Header.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'wouter'
|
||||
|
||||
import LanguageSwitcher from './LanguageSwitcher'
|
||||
import ThemeSwitcher from './ThemeSwitcher'
|
||||
import { BookIcon, CloseIcon, GithubIcon, MenuIcon } from './icons'
|
||||
|
||||
export default function Header() {
|
||||
const { t } = useTranslation('common')
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className="relative z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-gray-200 dark:border-gray-700"
|
||||
role="banner"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 sm:gap-3 group shrink-0"
|
||||
aria-label={t('header.logo_alt')}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<img
|
||||
src="https://img.alicdn.com/imgextra/i2/O1CN01HB8ylu1uozANEMZw2_!!6000000006085-49-tps-128-128.webp"
|
||||
alt="PageAgent Logo"
|
||||
className="w-10 h-10 rounded-xl group-hover:scale-110 transition-transform duration-200"
|
||||
/>
|
||||
<div>
|
||||
<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">
|
||||
{t('header.slogan')}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Mobile Icon Navigation (横向滚动) */}
|
||||
<nav
|
||||
className="md:hidden flex items-center gap-1 overflow-x-auto scrollbar-hide flex-1"
|
||||
role="navigation"
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
<Link
|
||||
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" />
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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" />
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav
|
||||
className="hidden md:flex items-center space-x-6"
|
||||
role="navigation"
|
||||
aria-label={t('header.nav_docs')}
|
||||
>
|
||||
<Link
|
||||
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 />
|
||||
{t('header.nav_docs')}
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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 />
|
||||
{t('header.nav_source')}
|
||||
</a>
|
||||
<ThemeSwitcher />
|
||||
<LanguageSwitcher />
|
||||
</nav>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
type="button"
|
||||
className="md:hidden p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-200 shrink-0"
|
||||
aria-label={t('header.mobile_menu')}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
{mobileMenuOpen && (
|
||||
<nav
|
||||
id="mobile-menu"
|
||||
className="md:hidden pt-4 pb-2 space-y-3 border-t border-gray-200 dark:border-gray-700 mt-4"
|
||||
role="navigation"
|
||||
>
|
||||
<Link
|
||||
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" />
|
||||
{t('header.nav_docs')}
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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" />
|
||||
{t('header.nav_source')}
|
||||
</a>
|
||||
<div className="flex items-center gap-3 px-3 py-2">
|
||||
<ThemeSwitcher />
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
)
|
||||
}
|
||||
126
packages/website/src/components/HighlightSyntax.module.css
Normal file
126
packages/website/src/components/HighlightSyntax.module.css
Normal file
@@ -0,0 +1,126 @@
|
||||
.syntax {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
font-family: monospace;
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
:global(.dark) .syntax {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* JavaScript/TypeScript 关键字 */
|
||||
.keyword {
|
||||
color: #d73a49;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.dark) .keyword {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* TypeScript 特定关键字 (interface, type, enum, etc.) */
|
||||
.tsKeyword {
|
||||
color: #af00db;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.dark) .tsKeyword {
|
||||
color: #c792ea;
|
||||
}
|
||||
|
||||
/* TypeScript 内置类型 */
|
||||
.type {
|
||||
color: #267f99;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:global(.dark) .type {
|
||||
color: #4ec9b0;
|
||||
}
|
||||
|
||||
/* 字符串 */
|
||||
.string {
|
||||
color: #1d6eca;
|
||||
}
|
||||
|
||||
:global(.dark) .string {
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
/* 数字 */
|
||||
.number {
|
||||
color: #00c583;
|
||||
}
|
||||
|
||||
:global(.dark) .number {
|
||||
color: #66bb6a;
|
||||
}
|
||||
|
||||
/* 布尔值和字面量 (true, false, null, undefined) */
|
||||
.literal {
|
||||
color: #0000ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:global(.dark) .literal {
|
||||
color: #569cd6;
|
||||
}
|
||||
|
||||
/* 注释 */
|
||||
.comment {
|
||||
color: #6a737d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
:global(.dark) .comment {
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
/* 装饰器 (@decorator) */
|
||||
.decorator {
|
||||
color: #e0aa00;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:global(.dark) .decorator {
|
||||
color: #dcdcaa;
|
||||
}
|
||||
|
||||
/* 箭头函数 (=>) */
|
||||
.arrow {
|
||||
color: #d73a49;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
:global(.dark) .arrow {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* 标识符(变量名、函数名等) */
|
||||
.identifier {
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
:global(.dark) .identifier {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* 属性访问 (.property) */
|
||||
.property {
|
||||
color: #0550ae;
|
||||
}
|
||||
|
||||
:global(.dark) .property {
|
||||
color: #9cdcfe;
|
||||
}
|
||||
|
||||
/* 运算符 */
|
||||
.operator {
|
||||
color: #5a5a5a;
|
||||
}
|
||||
|
||||
:global(.dark) .operator {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
176
packages/website/src/components/HighlightSyntax.tsx
Normal file
176
packages/website/src/components/HighlightSyntax.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* js 语法高亮组件,适合在文章中演示代码片段
|
||||
*/
|
||||
import React from 'react'
|
||||
|
||||
import styles from './HighlightSyntax.module.css'
|
||||
|
||||
interface HighlightSyntaxProps {
|
||||
code: string
|
||||
}
|
||||
|
||||
// JavaScript/TypeScript 关键字
|
||||
const keywords =
|
||||
'async|await|function|const|let|var|if|else|for|while|return|try|catch|finally|class|extends|from|import|export|default|undefined|throw|break|continue|switch|case|do|with|yield|delete|typeof|void|static|get|set|super|debugger'
|
||||
|
||||
// TypeScript 特定关键字
|
||||
const tsKeywords =
|
||||
'interface|type|enum|namespace|module|declare|abstract|implements|public|private|protected|readonly|as|satisfies|infer|keyof|is'
|
||||
|
||||
// 布尔值和空值
|
||||
const literals = 'true|false|null|undefined|NaN|Infinity'
|
||||
|
||||
// TypeScript 内置类型
|
||||
const tsTypes =
|
||||
'string|number|boolean|any|unknown|never|void|object|symbol|bigint|Array|Promise|Record|Partial|Required|Readonly|Pick|Omit|Exclude|Extract|NonNullable|ReturnType|Parameters|ConstructorParameters|InstanceType|ThisType|Uppercase|Lowercase|Capitalize|Uncapitalize'
|
||||
|
||||
// 辅助函数:转义 HTML 特殊字符
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
// 语法高亮函数,先提取 token 再转义和高亮
|
||||
function highlightSyntax(code: string): string {
|
||||
// 构建正则模式,包含更多 token 类型(在原始文本上匹配)
|
||||
const pattern = new RegExp(
|
||||
'(' +
|
||||
// 1. 字符串(双引号、单引号、模板字符串)
|
||||
'"([^"\\\\]|\\\\.)*"|' +
|
||||
"'([^'\\\\]|\\\\.)*'|" +
|
||||
'`([^`\\\\]|\\\\.)*`|' +
|
||||
// 2. 注释(单行和多行)
|
||||
'//[^\\n]*|' +
|
||||
'/\\*[\\s\\S]*?\\*/|' +
|
||||
// 3. 装饰器
|
||||
'@[a-zA-Z_$][\\w$]*|' +
|
||||
// 4. 数字(包括小数、十六进制、科学计数法)
|
||||
'\\b0[xX][0-9a-fA-F]+\\b|' +
|
||||
'\\b\\d+\\.?\\d*(?:[eE][+-]?\\d+)?\\b|' +
|
||||
// 5. TypeScript/JavaScript 关键字
|
||||
'\\b(?:' +
|
||||
keywords +
|
||||
'|' +
|
||||
tsKeywords +
|
||||
'|' +
|
||||
literals +
|
||||
')\\b|' +
|
||||
// 6. TypeScript 内置类型
|
||||
'\\b(?:' +
|
||||
tsTypes +
|
||||
')\\b|' +
|
||||
// 7. 箭头函数
|
||||
'=>|' +
|
||||
// 8. 函数调用(函数名后跟括号)
|
||||
'\\b[a-zA-Z_$][\\w$]*(?=\\()|' +
|
||||
// 9. 属性访问
|
||||
'\\.[a-zA-Z_$][\\w$]*|' +
|
||||
// 10. 运算符和特殊符号
|
||||
'[+\\-*/%&|^!~<>=?:]+|' +
|
||||
'[{}\\[\\]();,.]' +
|
||||
')',
|
||||
'g'
|
||||
)
|
||||
|
||||
const tokens: string[] = []
|
||||
let lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = pattern.exec(code)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
const gap = code.slice(lastIndex, match.index)
|
||||
// 将间隙按空白符分割,保留空白符
|
||||
tokens.push(...gap.split(/(\s+)/))
|
||||
}
|
||||
tokens.push(match[0])
|
||||
lastIndex = pattern.lastIndex
|
||||
}
|
||||
if (lastIndex < code.length) {
|
||||
tokens.push(...code.slice(lastIndex).split(/(\s+)/))
|
||||
}
|
||||
|
||||
const highlighted = tokens
|
||||
.map((token) => {
|
||||
// 空白符直接返回
|
||||
if (/^\s+$/.test(token)) {
|
||||
return token
|
||||
}
|
||||
|
||||
// 1. 注释(单行和多行)
|
||||
if (/^\/\/.*$/.test(token) || /^\/\*[\s\S]*?\*\/$/.test(token)) {
|
||||
return `<span class="${styles.comment}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 2. 字符串
|
||||
if (
|
||||
/^"([^"\\]|\\.)*"$/.test(token) ||
|
||||
/^'([^'\\]|\\.)*'$/.test(token) ||
|
||||
/^`([^`\\]|\\.)*`$/.test(token)
|
||||
) {
|
||||
return `<span class="${styles.string}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 3. 数字
|
||||
if (/^(0[xX][0-9a-fA-F]+|\d+\.?\d*(?:[eE][+-]?\d+)?)$/.test(token)) {
|
||||
return `<span class="${styles.number}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 4. 布尔值和特殊字面量
|
||||
if (new RegExp(`^(?:${literals})$`).test(token)) {
|
||||
return `<span class="${styles.literal}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 5. JavaScript/TypeScript 关键字
|
||||
if (new RegExp(`^(?:${keywords})$`).test(token)) {
|
||||
return `<span class="${styles.keyword}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 6. TypeScript 特定关键字
|
||||
if (new RegExp(`^(?:${tsKeywords})$`).test(token)) {
|
||||
return `<span class="${styles.tsKeyword}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 7. TypeScript 内置类型
|
||||
if (new RegExp(`^(?:${tsTypes})$`).test(token)) {
|
||||
return `<span class="${styles.type}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 8. 装饰器
|
||||
if (/^@[a-zA-Z_$][\w$]*$/.test(token)) {
|
||||
return `<span class="${styles.decorator}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 9. 箭头函数
|
||||
if (token === '=>') {
|
||||
return `<span class="${styles.arrow}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 10. 函数调用和标识符
|
||||
if (/^[a-zA-Z_$][\w$]*$/.test(token)) {
|
||||
return `<span class="${styles.identifier}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 11. 属性访问
|
||||
if (/^\.[a-zA-Z_$][\w$]*$/.test(token)) {
|
||||
return `<span class="${styles.property}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 12. 运算符
|
||||
if (/^[+\-*/%&|^!~<>=?:]+$/.test(token)) {
|
||||
return `<span class="${styles.operator}">${escapeHtml(token)}</span>`
|
||||
}
|
||||
|
||||
// 13. 其他符号,需要转义
|
||||
return escapeHtml(token)
|
||||
})
|
||||
.join('')
|
||||
|
||||
return highlighted
|
||||
}
|
||||
|
||||
const HighlightSyntaxClient: React.FC<HighlightSyntaxProps> = ({ code }) => {
|
||||
const htmlContent = highlightSyntax(code)
|
||||
|
||||
// eslint-disable-next-line react-dom/no-dangerously-set-innerhtml
|
||||
return <code className={styles.syntax} dangerouslySetInnerHTML={{ __html: htmlContent }} />
|
||||
}
|
||||
|
||||
export default HighlightSyntaxClient
|
||||
164
packages/website/src/components/JSConsole.module.css
Normal file
164
packages/website/src/components/JSConsole.module.css
Normal file
@@ -0,0 +1,164 @@
|
||||
.console {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
scroll-behavior: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.historyArea {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
background-color: #fafafa;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
scroll-behavior: contain;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: #d0d0d0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #b0b0b0;
|
||||
}
|
||||
|
||||
.historyItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: #ccdeeebd 1px solid;
|
||||
margin-bottom: 6px;
|
||||
|
||||
flex: 0 0 auto;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.input {
|
||||
}
|
||||
&.output {
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
flex: 1;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
/* 错误样式 */
|
||||
&.error .content {
|
||||
color: #dc2626;
|
||||
background-color: #fef2f2;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #dc2626;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.prompt {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
align-items: flex-start;
|
||||
width: 12px;
|
||||
color: #666;
|
||||
margin-right: 8px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.executing {
|
||||
color: #f59e0b;
|
||||
font-style: italic;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.inputArea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
padding: 12px;
|
||||
background-color: #ffffff;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
|
||||
.prompt {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: auto;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: #333;
|
||||
resize: none;
|
||||
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.console {
|
||||
font-size: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.historyArea,
|
||||
.inputLine {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
369
packages/website/src/components/JSConsole.tsx
Normal file
369
packages/website/src/components/JSConsole.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* JS 调试台,适合在文档中直接让用户运行代码,并且实时查看运行结果
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/no-base-to-string */
|
||||
import { KeyboardEvent, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
||||
|
||||
import HighlightSyntax from './HighlightSyntax'
|
||||
|
||||
import styles from './JSConsole.module.css'
|
||||
|
||||
// 全局console拦截管理器
|
||||
class ConsoleInterceptor {
|
||||
private static instance: ConsoleInterceptor
|
||||
private subscribers = new Set<(type: string, args: unknown[]) => void>()
|
||||
private originalConsole: {
|
||||
log: typeof console.log
|
||||
warn: typeof console.warn
|
||||
error: typeof console.error
|
||||
}
|
||||
private isIntercepting = false
|
||||
|
||||
private constructor() {
|
||||
this.originalConsole = {
|
||||
log: console.log.bind(console),
|
||||
warn: console.warn.bind(console),
|
||||
error: console.error.bind(console),
|
||||
}
|
||||
}
|
||||
|
||||
static getInstance() {
|
||||
if (!ConsoleInterceptor.instance) {
|
||||
ConsoleInterceptor.instance = new ConsoleInterceptor()
|
||||
}
|
||||
return ConsoleInterceptor.instance
|
||||
}
|
||||
|
||||
subscribe(callback: (type: string, args: unknown[]) => void) {
|
||||
this.subscribers.add(callback)
|
||||
this.startIntercepting()
|
||||
}
|
||||
|
||||
unsubscribe(callback: (type: string, args: unknown[]) => void) {
|
||||
this.subscribers.delete(callback)
|
||||
if (this.subscribers.size === 0) {
|
||||
this.stopIntercepting()
|
||||
}
|
||||
}
|
||||
|
||||
private startIntercepting() {
|
||||
if (this.isIntercepting) return
|
||||
|
||||
this.isIntercepting = true
|
||||
|
||||
console.log = (...args: unknown[]) => {
|
||||
this.originalConsole.log(...args)
|
||||
this.notifySubscribers('log', args)
|
||||
}
|
||||
|
||||
console.warn = (...args: unknown[]) => {
|
||||
this.originalConsole.warn(...args)
|
||||
this.notifySubscribers('warn', args)
|
||||
}
|
||||
|
||||
console.error = (...args: unknown[]) => {
|
||||
this.originalConsole.error(...args)
|
||||
this.notifySubscribers('error', args)
|
||||
}
|
||||
}
|
||||
|
||||
private stopIntercepting() {
|
||||
if (!this.isIntercepting) return
|
||||
|
||||
this.isIntercepting = false
|
||||
console.log = this.originalConsole.log
|
||||
console.warn = this.originalConsole.warn
|
||||
console.error = this.originalConsole.error
|
||||
}
|
||||
|
||||
private notifySubscribers(type: string, args: unknown[]) {
|
||||
this.subscribers.forEach((callback) => {
|
||||
callback(type, args)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
interface JSConsoleProps {
|
||||
context?: Record<string, unknown>
|
||||
height?: string
|
||||
onExecute?: (code: string, result: unknown) => void
|
||||
placeholder?: string
|
||||
ref?: React.Ref<JSConsoleRef>
|
||||
}
|
||||
|
||||
export interface JSConsoleRef {
|
||||
executeCode: (code: string) => Promise<unknown>
|
||||
clear: () => void
|
||||
appendOutput: (content: string) => void
|
||||
}
|
||||
|
||||
interface OutputItem {
|
||||
type: 'input' | 'output' | 'error' | 'log'
|
||||
content: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const DEFAULT_CONTEXT = {}
|
||||
|
||||
function JSConsole({
|
||||
context = DEFAULT_CONTEXT,
|
||||
height = '400px',
|
||||
onExecute,
|
||||
placeholder = 'Enter JavaScript code...',
|
||||
ref,
|
||||
}: JSConsoleProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const [outputs, setOutputs] = useState<OutputItem[]>([])
|
||||
const [isExecuting, setIsExecuting] = useState(false)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const outputRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 持久的执行上下文,用于多轮对话共享作用域
|
||||
const executionContextRef = useRef<Record<string, unknown>>({})
|
||||
|
||||
// 格式化结果
|
||||
const formatResult = (value: unknown): string => {
|
||||
if (value === null) return 'null'
|
||||
if (value === undefined) return 'undefined'
|
||||
if (typeof value === 'string') return `"${value}"`
|
||||
if (typeof value === 'function') return `[Function: ${value.name || 'anonymous'}]`
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return value.toString()
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// 全局console拦截处理
|
||||
useEffect(() => {
|
||||
const interceptor = ConsoleInterceptor.getInstance()
|
||||
|
||||
const handleGlobalConsole = (type: string, args: unknown[]) => {
|
||||
const content = args.map((arg) => formatResult(arg)).join(' ')
|
||||
|
||||
const outputItem: OutputItem = {
|
||||
type: type as any,
|
||||
content: content,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
setOutputs((prev) => [...prev, outputItem])
|
||||
|
||||
// 自动滚动到底部
|
||||
setTimeout(() => {
|
||||
if (outputRef.current) {
|
||||
outputRef.current.scrollTop = outputRef.current.scrollHeight
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
interceptor.subscribe(handleGlobalConsole)
|
||||
|
||||
return () => {
|
||||
interceptor.unsubscribe(handleGlobalConsole)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 执行代码
|
||||
const executeCode = async (code: string): Promise<unknown> => {
|
||||
if (!code.trim()) return
|
||||
|
||||
setIsExecuting(true)
|
||||
|
||||
// 添加输入到输出
|
||||
const inputItem: OutputItem = {
|
||||
type: 'input',
|
||||
content: code,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
setOutputs((prev) => [...prev, inputItem])
|
||||
|
||||
try {
|
||||
// 创建异步函数以支持 await
|
||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
|
||||
|
||||
// 合并外部上下文和持久执行上下文
|
||||
const allContext = { ...context, ...executionContextRef.current }
|
||||
const contextKeys = Object.keys(allContext)
|
||||
const contextValues = Object.values(allContext)
|
||||
|
||||
// 注入 console.log 重定向
|
||||
const logs: string[] = []
|
||||
const mockConsole = {
|
||||
log: (...args: unknown[]) => {
|
||||
logs.push(args.map((arg) => formatResult(arg)).join(' '))
|
||||
},
|
||||
error: (...args: unknown[]) => {
|
||||
logs.push('ERROR: ' + args.map((arg) => formatResult(arg)).join(' '))
|
||||
},
|
||||
warn: (...args: unknown[]) => {
|
||||
logs.push('WARN: ' + args.map((arg) => formatResult(arg)).join(' '))
|
||||
},
|
||||
}
|
||||
|
||||
// 检测代码是否是表达式还是语句
|
||||
const trimmedCode = code.trim()
|
||||
const isExpression =
|
||||
!trimmedCode.includes(';') &&
|
||||
!trimmedCode.startsWith('const ') &&
|
||||
!trimmedCode.startsWith('let ') &&
|
||||
!trimmedCode.startsWith('var ') &&
|
||||
!trimmedCode.startsWith('function ') &&
|
||||
!trimmedCode.startsWith('class ') &&
|
||||
!trimmedCode.startsWith('if ') &&
|
||||
!trimmedCode.startsWith('for ') &&
|
||||
!trimmedCode.startsWith('while ') &&
|
||||
!trimmedCode.startsWith('try ') &&
|
||||
!trimmedCode.startsWith('{') &&
|
||||
!trimmedCode.includes('\n')
|
||||
|
||||
// 如果是表达式,自动添加 return
|
||||
const codeToExecute = isExpression ? `return ${code}` : code
|
||||
|
||||
const wrappedCode = `
|
||||
return (async function() {
|
||||
${codeToExecute}
|
||||
})();
|
||||
`
|
||||
|
||||
// 执行代码
|
||||
const func = new AsyncFunction('console', ...contextKeys, wrappedCode)
|
||||
const result = await func(mockConsole, ...contextValues)
|
||||
|
||||
// 添加 console.log 输出
|
||||
if (logs.length > 0) {
|
||||
const logItem: OutputItem = {
|
||||
type: 'log',
|
||||
content: logs.join('\n'),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
setOutputs((prev) => [...prev, logItem])
|
||||
}
|
||||
|
||||
// 总是添加执行结果输出(包括 undefined)
|
||||
const outputItem: OutputItem = {
|
||||
type: 'output',
|
||||
content: formatResult(result),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
setOutputs((prev) => [...prev, outputItem])
|
||||
|
||||
onExecute?.(code, result)
|
||||
return result
|
||||
} catch (error) {
|
||||
const errorItem: OutputItem = {
|
||||
type: 'error',
|
||||
content: error instanceof Error ? error.message : String(error),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
setOutputs((prev) => [...prev, errorItem])
|
||||
throw error
|
||||
} finally {
|
||||
setIsExecuting(false)
|
||||
// 滚动到底部
|
||||
setTimeout(() => {
|
||||
if (outputRef.current) {
|
||||
outputRef.current.scrollTop = outputRef.current.scrollHeight
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// 清空控制台
|
||||
const clear = () => {
|
||||
setOutputs([])
|
||||
// 同时清空执行上下文
|
||||
executionContextRef.current = {}
|
||||
}
|
||||
|
||||
// 添加输出
|
||||
const appendOutput = (content: string) => {
|
||||
const outputItem: OutputItem = {
|
||||
type: 'output',
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
setOutputs((prev) => [...prev, outputItem])
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
executeCode,
|
||||
clear,
|
||||
appendOutput,
|
||||
}))
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.shiftKey) {
|
||||
// Shift+Enter 换行
|
||||
return
|
||||
} else {
|
||||
// Enter 执行
|
||||
e.preventDefault()
|
||||
if (!isExecuting && input.trim()) {
|
||||
executeCode(input)
|
||||
setInput('')
|
||||
setTimeout(() => inputRef.current?.focus(), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPrompt(type: string) {
|
||||
let prompt = ' '
|
||||
if (type === 'input') {
|
||||
prompt = '>'
|
||||
} else if (type === 'output') {
|
||||
prompt = '<'
|
||||
}
|
||||
return prompt
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.console} style={{ height }}>
|
||||
{/* 历史记录和输入区域 */}
|
||||
<div className={styles.historyArea} ref={outputRef}>
|
||||
{outputs.map((item) => (
|
||||
<div key={item.timestamp} className={`${styles.historyItem} ${styles[item.type]}`}>
|
||||
<span className={styles.prompt}>{getPrompt(item.type)}</span>
|
||||
<pre className={styles.content}>
|
||||
<HighlightSyntax code={item.content} />
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
{isExecuting && (
|
||||
<div className={styles.historyItem}>
|
||||
<span className={styles.prompt}>{'> '}</span>
|
||||
<span className={styles.executing}>Executing...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 当前输入行 */}
|
||||
<div className={styles.inputArea}>
|
||||
<span className={styles.prompt}>{'> '}</span>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className={styles.input}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={isExecuting}
|
||||
rows={1}
|
||||
style={{
|
||||
height: Math.min(Math.max(20, input.split('\n').length * 20), 120),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default JSConsole
|
||||
115
packages/website/src/components/LanguageSwitcher.tsx
Normal file
115
packages/website/src/components/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function LanguageSwitcher() {
|
||||
const { i18n, t } = useTranslation('common')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const currentLang = i18n.language
|
||||
|
||||
const languages = [
|
||||
{ code: 'zh-CN', label: '中文' },
|
||||
{ code: 'en-US', label: 'English' },
|
||||
]
|
||||
|
||||
const currentLanguage = languages.find((lang) => lang.code === currentLang) || languages[0]
|
||||
|
||||
const handleLanguageChange = (langCode: string) => {
|
||||
i18n.changeLanguage(langCode)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors text-sm font-medium border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300"
|
||||
aria-label={t('language.switch_label')}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"
|
||||
/>
|
||||
</svg>
|
||||
<span>{currentLanguage.label}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="absolute right-0 mt-2 w-40 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => handleLanguageChange(lang.code)}
|
||||
className={`w-full flex items-center gap-2 px-4 py-2 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
|
||||
currentLang === lang.code
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
role="menuitem"
|
||||
>
|
||||
<span>{lang.label}</span>
|
||||
{currentLang === lang.code && (
|
||||
<svg
|
||||
className="w-4 h-4 ml-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
133
packages/website/src/components/ThemeSwitcher.tsx
Normal file
133
packages/website/src/components/ThemeSwitcher.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
export default function ThemeSwitcher() {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
// 初始化时读取保存的主题
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedTheme = localStorage.getItem('theme') as Theme | null
|
||||
if (savedTheme === 'light' || savedTheme === 'dark') {
|
||||
return savedTheme
|
||||
}
|
||||
// 默认跟随系统
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
return 'light'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// 应用主题
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
document.documentElement.style.colorScheme = 'dark'
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
document.documentElement.style.colorScheme = 'light'
|
||||
}
|
||||
// 保存到 localStorage
|
||||
localStorage.setItem('theme', theme)
|
||||
}, [theme])
|
||||
|
||||
// 监听系统主题变化
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
const handleSystemThemeChange = (e: MediaQueryListEvent) => {
|
||||
// 只有在用户未手动设置时才自动跟随系统
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
if (!savedTheme) {
|
||||
setTheme(e.matches ? 'dark' : 'light')
|
||||
}
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener('change', handleSystemThemeChange)
|
||||
return () => mediaQuery.removeEventListener('change', handleSystemThemeChange)
|
||||
}, [])
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'))
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="relative inline-flex items-center h-8 w-16 rounded-full transition-colors duration-300 ease-in-out focus:outline-none"
|
||||
style={{
|
||||
backgroundColor: theme === 'dark' ? '#1e293b' : '#e0f2fe',
|
||||
}}
|
||||
aria-label={theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'}
|
||||
role="switch"
|
||||
aria-checked={theme === 'dark'}
|
||||
>
|
||||
{/* 滑块 */}
|
||||
<span
|
||||
className="inline-block h-6 w-6 transform rounded-full transition-all duration-300 ease-in-out shadow-md"
|
||||
style={{
|
||||
backgroundColor: theme === 'dark' ? '#475569' : '#fbbf24',
|
||||
transform: theme === 'dark' ? 'translateX(2.25rem)' : 'translateX(0.25rem)',
|
||||
}}
|
||||
>
|
||||
{/* 图标 */}
|
||||
<span className="flex items-center justify-center h-full w-full">
|
||||
{theme === 'light' ? (
|
||||
// 太阳图标
|
||||
<svg
|
||||
className="w-4 h-4 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
// 月亮图标
|
||||
<svg
|
||||
className="w-4 h-4 text-slate-200"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* 背景装饰 */}
|
||||
<span
|
||||
className="absolute inset-0 flex items-center justify-between px-2 pointer-events-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* 左侧太阳(浅色模式时显示) */}
|
||||
<span
|
||||
className={`transition-opacity duration-300 ${
|
||||
theme === 'light' ? 'opacity-0' : 'opacity-40'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4 text-sky-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{/* 右侧月亮(深色模式时显示) */}
|
||||
<span
|
||||
className={`transition-opacity duration-300 ${
|
||||
theme === 'dark' ? 'opacity-0' : 'opacity-40'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4 text-slate-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
75
packages/website/src/components/icons.tsx
Normal file
75
packages/website/src/components/icons.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
104
packages/website/src/docs/features/custom-tools/page.tsx
Normal file
104
packages/website/src/docs/features/custom-tools/page.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import BetaNotice from '@/components/BetaNotice'
|
||||
import CodeEditor from '@/components/CodeEditor'
|
||||
|
||||
export default function CustomTools() {
|
||||
const { t } = useTranslation('docs')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-6">{t('custom_tools.title')}</h1>
|
||||
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
|
||||
{t('custom_tools.subtitle')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold mb-4">{t('custom_tools.registration')}</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
{t('custom_tools.registration_desc')}
|
||||
</p>
|
||||
|
||||
<CodeEditor
|
||||
code={`import zod from 'zod'
|
||||
import { PageAgent, tool } from 'page-agent'
|
||||
|
||||
// override internal tool
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// remove internal tool
|
||||
const customTools = {
|
||||
ask_user: null // never ask user questions
|
||||
}
|
||||
|
||||
const pageAgent = new PageAgent({customTools})
|
||||
`}
|
||||
language="javascript"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold mb-4">{t('custom_tools.page_filter')}</h2>
|
||||
|
||||
<BetaNotice />
|
||||
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
{t('custom_tools.page_filter_desc')}
|
||||
</p>
|
||||
|
||||
<CodeEditor
|
||||
code={`pageAgent.registerTool({
|
||||
name: 'approveOrder',
|
||||
description: '审批订单',
|
||||
input: z.object({
|
||||
orderId: z.string(),
|
||||
approved: z.boolean()
|
||||
}),
|
||||
execute: async (params) => {
|
||||
// 审批逻辑
|
||||
},
|
||||
// 可选:页面过滤器
|
||||
pageFilter: {
|
||||
// 只在订单管理页面显示
|
||||
include: ['/admin/orders', '/admin/orders/*'],
|
||||
// 排除特定页面
|
||||
exclude: ['/admin/orders/archived']
|
||||
}
|
||||
})`}
|
||||
language="javascript"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold mb-4">{t('custom_tools.best_practices')}</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
|
||||
<h3 className="text-lg font-semibold text-yellow-900 dark:text-yellow-300 mb-2">
|
||||
{t('custom_tools.bp_performance')}
|
||||
</h3>
|
||||
<ul className="text-gray-600 dark:text-gray-300 space-y-1 text-sm">
|
||||
<li>{t('custom_tools.bp_1')}</li>
|
||||
<li>{t('custom_tools.bp_2')}</li>
|
||||
<li>{t('custom_tools.bp_3')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
packages/website/src/docs/features/data-masking/page.tsx
Normal file
48
packages/website/src/docs/features/data-masking/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
162
packages/website/src/docs/features/knowledge-injection/page.tsx
Normal file
162
packages/website/src/docs/features/knowledge-injection/page.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
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">
|
||||
<EFBFBD> 业务领域知识
|
||||
</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>
|
||||
)
|
||||
}
|
||||
164
packages/website/src/docs/features/model-integration/page.tsx
Normal file
164
packages/website/src/docs/features/model-integration/page.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import BetaNotice from '@/components/BetaNotice'
|
||||
import CodeEditor from '@/components/CodeEditor'
|
||||
|
||||
export default function BestPractices() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-6">最佳实践</h1>
|
||||
|
||||
<BetaNotice />
|
||||
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
|
||||
使用 page-agent 的最佳实践和常见问题解决方案。
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-3">性能优化</h2>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2 text-green-900 dark:text-green-300">
|
||||
⚡ 减少 API 调用
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-3">
|
||||
合并多个操作指令,减少与 AI 模型的交互次数。
|
||||
</p>
|
||||
|
||||
<CodeEditor
|
||||
code={`// 推荐:合并操作
|
||||
await pageAgent.execute('填写表单:姓名张三,邮箱test@example.com,然后提交');
|
||||
|
||||
// 不推荐:分别操作
|
||||
await pageAgent.execute('填写姓名张三');
|
||||
await pageAgent.execute('填写邮箱test@example.com');
|
||||
await pageAgent.execute('点击提交按钮');`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-300">
|
||||
🎯 精确的元素描述
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
使用具体、明确的元素描述,提高操作成功率。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-3">安全建议</h2>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border-l-4 border-red-500">
|
||||
<h3 className="font-semibold mb-1 text-red-900 dark:text-red-300">重要操作保护</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">对删除、支付等敏感操作设置黑名单保护。</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border-l-4 border-yellow-500">
|
||||
<h3 className="font-semibold mb-1 text-yellow-900 dark:text-yellow-300">数据脱敏</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">启用数据脱敏功能,保护用户隐私信息。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-3">调试技巧</h2>
|
||||
|
||||
<CodeEditor code={`// TODO`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
packages/website/src/docs/integration/cdn-setup/page.tsx
Normal file
38
packages/website/src/docs/integration/cdn-setup/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import BetaNotice from '@/components/BetaNotice'
|
||||
import CodeEditor from '@/components/CodeEditor'
|
||||
|
||||
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 待定
|
||||
<script src="https://hwcxiuzfylggtcktqgij.supabase.co/storage/v1/object/public/demo-public/v0.0.4/page-agent.js" crossorigin="true" type="text/javascript"></script>
|
||||
|
||||
<script>
|
||||
window.pageAgent.panel.show()
|
||||
</script>`}
|
||||
/>
|
||||
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 p-4 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2 text-yellow-900 dark:text-yellow-300">
|
||||
⚠️ 注意事项
|
||||
</h3>
|
||||
<ul className="text-gray-600 dark:text-gray-300 space-y-1">
|
||||
<li>• 生产环境建议使用固定版本号</li>
|
||||
<li>• 确保 HTTPS 环境下使用</li>
|
||||
<li>• 配置 CSP 策略允许脚本执行</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
packages/website/src/docs/integration/configuration/page.tsx
Normal file
108
packages/website/src/docs/integration/configuration/page.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
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 & DomConfig
|
||||
|
||||
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 DomConfig {
|
||||
interactiveBlacklist?: (Element | (() => Element))[]
|
||||
interactiveWhitelist?: (Element | (() => Element))[]
|
||||
include_attributes?: string[]
|
||||
highlightOpacity?: number
|
||||
highlightLabelOpacity?: number
|
||||
}
|
||||
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
packages/website/src/docs/integration/third-party-agent/page.tsx
Normal file
115
packages/website/src/docs/integration/third-party-agent/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import CodeEditor from '@/components/CodeEditor'
|
||||
|
||||
export default function ThirdPartyAgentPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-6">接入第三方 Agent</h1>
|
||||
<p className="mb-6 leading-relaxed text-gray-600 dark:text-gray-300">
|
||||
将 pageAgent 作为工具接入你的答疑助手或 Agent 系统,成为你 Agent 的眼和手。
|
||||
</p>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 mb-6">
|
||||
<h3 className="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-300">💡 核心价值</h3>
|
||||
<p className="text-blue-800 dark:text-blue-200">
|
||||
让你的答疑机器人不再只是"嘴巴",而是拥有"眼睛"和"手"的完整智能体。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-4">集成方式</h2>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2 text-green-900 dark:text-green-300">
|
||||
1. Function Calling
|
||||
</h3>
|
||||
<CodeEditor
|
||||
code={`// 定义工具
|
||||
const pageAgentTool = {
|
||||
name: "page_agent",
|
||||
description: "执行网页操作",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
instruction: { type: "string", description: "操作指令" }
|
||||
},
|
||||
required: ["instruction"]
|
||||
},
|
||||
execute: async (params) => {
|
||||
const result = await pageAgent.execute(params.instruction)
|
||||
return { success: result.success, message: result.message }
|
||||
}
|
||||
}
|
||||
|
||||
// 注册到你的 agent 中`}
|
||||
language="javascript"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-4">应用场景</h2>
|
||||
<div className="grid md:grid-cols-2 gap-4 mb-6">
|
||||
<div className="bg-linear-to-br from-blue-50 to-purple-50 dark:from-gray-800 dark:to-gray-700 p-4 rounded-lg">
|
||||
<h4 className="font-semibold mb-2 text-gray-900 dark:text-white">🤖 智能客服系统</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
客服机器人帮用户直接操作系统,如"帮我提交工单"
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-linear-to-br from-green-50 to-blue-50 dark:from-gray-800 dark:to-gray-700 p-4 rounded-lg">
|
||||
<h4 className="font-semibold mb-2 text-gray-900 dark:text-white">📋 业务流程助手</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
引导新员工完成复杂流程,如"完成客户入职"
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-linear-to-br from-purple-50 to-pink-50 dark:from-gray-800 dark:to-gray-700 p-4 rounded-lg">
|
||||
<h4 className="font-semibold mb-2 text-gray-900 dark:text-white">🎯 个人效率助手</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
跨网站帮你完成任务,如"预订会议室"
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-linear-to-br from-orange-50 to-red-50 dark:from-gray-800 dark:to-gray-700 p-4 rounded-lg">
|
||||
<h4 className="font-semibold mb-2 text-gray-900 dark:text-white">🔧 运维自动化</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
通过自然语言操作管理后台,如"重启服务器"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-4">最佳实践</h2>
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2 text-gray-900 dark:text-white">错误处理</h3>
|
||||
<CodeEditor code={`// @TODO`} language="javascript" />
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2 text-gray-900 dark:text-white">权限控制</h3>
|
||||
<CodeEditor code={`// @TODO`} language="javascript" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6">
|
||||
<h3 className="text-lg font-semibold text-yellow-900 dark:text-yellow-100 mb-2">
|
||||
⚠️ 注意事项
|
||||
</h3>
|
||||
<ul className="text-yellow-800 dark:text-yellow-200 space-y-1 text-sm">
|
||||
<li>• 确保目标网站允许自动化操作</li>
|
||||
<li>• 实现适当的频率限制</li>
|
||||
<li>• 敏感操作建议要求人工确认</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-linear-to-r from-green-50 to-blue-50 dark:from-green-900/20 dark:to-blue-900/20 p-4 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2 text-gray-900 dark:text-white">🎉 开始集成</h3>
|
||||
<p className="mb-3 text-gray-700 dark:text-gray-300">
|
||||
通过这种方式,你的 Agent 系统就能真正成为用户的智能助手。
|
||||
</p>
|
||||
<a
|
||||
href="/docs/integration/configuration"
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors duration-200"
|
||||
>
|
||||
查看配置选项 →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
144
packages/website/src/docs/introduction/limitations/page.tsx
Normal file
144
packages/website/src/docs/introduction/limitations/page.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function LimitationsPage() {
|
||||
const { t } = useTranslation('docs')
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold mb-4 text-gray-900 dark:text-white">
|
||||
{t('limitations.title')}
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300">{t('limitations.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="prose prose-lg dark:prose-invert max-w-none">
|
||||
<h2 className="text-2xl font-bold mb-3">{t('limitations.page_support')}</h2>
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-4 border-blue-400 p-4 mb-6">
|
||||
<h3 className="font-semibold text-blue-800 dark:text-blue-200 mb-2">
|
||||
{t('limitations.spa_limit_title')}
|
||||
</h3>
|
||||
<ul className="text-blue-700 dark:text-blue-300 space-y-2">
|
||||
<li>{t('limitations.spa_limit_1')}</li>
|
||||
<li>{t('limitations.spa_limit_2')}</li>
|
||||
<li>{t('limitations.spa_limit_3')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-3">{t('limitations.interaction_limits')}</h2>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-6 mb-6">
|
||||
<h3 className="font-semibold mb-4">{t('limitations.supported_ops')}</h3>
|
||||
<div className="grid md:grid-cols-2 gap-4 mb-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center text-green-600 dark:text-green-400">
|
||||
<span className="mr-2">✅</span>
|
||||
<span>{t('limitations.op_click')}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-green-600 dark:text-green-400">
|
||||
<span className="mr-2">✅</span>
|
||||
<span>{t('limitations.op_input')}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-green-600 dark:text-green-400">
|
||||
<span className="mr-2">✅</span>
|
||||
<span>{t('limitations.op_scroll')}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-green-600 dark:text-green-400">
|
||||
<span className="mr-2">✅</span>
|
||||
<span>{t('limitations.op_submit')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center text-green-600 dark:text-green-400">
|
||||
<span className="mr-2">✅</span>
|
||||
<span>{t('limitations.op_select')}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-green-600 dark:text-green-400">
|
||||
<span className="mr-2">✅</span>
|
||||
<span>{t('limitations.op_focus')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold mb-4">{t('limitations.unsupported_ops')}</h3>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center text-red-600 dark:text-red-400">
|
||||
<span className="mr-2">❌</span>
|
||||
<span>{t('limitations.op_hover')}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-red-600 dark:text-red-400">
|
||||
<span className="mr-2">❌</span>
|
||||
<span>{t('limitations.op_drag')}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-red-600 dark:text-red-400">
|
||||
<span className="mr-2">❌</span>
|
||||
<span>{t('limitations.op_context')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center text-red-600 dark:text-red-400">
|
||||
<span className="mr-2">❌</span>
|
||||
<span>{t('limitations.op_draw')}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-red-600 dark:text-red-400">
|
||||
<span className="mr-2">❌</span>
|
||||
<span>{t('limitations.op_keyboard')}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-red-600 dark:text-red-400">
|
||||
<span className="mr-2">❌</span>
|
||||
<span>{t('limitations.op_position')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-3">{t('limitations.understanding_limits')}</h2>
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border-l-4 border-red-400 p-4 mb-6">
|
||||
<h3 className="font-semibold text-red-800 dark:text-red-200 mb-2">
|
||||
{t('limitations.no_vision_title')}
|
||||
</h3>
|
||||
<p className="text-red-700 dark:text-red-300 mb-3">{t('limitations.no_vision_desc')}</p>
|
||||
<ul className="text-red-700 dark:text-red-300 space-y-1">
|
||||
<li>{t('limitations.no_vision_1')}</li>
|
||||
<li>{t('limitations.no_vision_2')}</li>
|
||||
<li>{t('limitations.no_vision_3')}</li>
|
||||
<li>{t('limitations.no_vision_4')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-3">{t('limitations.website_requirements')}</h2>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-6 mb-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">{t('limitations.req_semantic_title')}</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{t('limitations.req_semantic_desc')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">{t('limitations.req_ux_title')}</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">{t('limitations.req_ux_desc')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">{t('limitations.req_env_title')}</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">{t('limitations.req_env_desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>{t('limitations.future')}</h2>
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border-l-4 border-green-400 p-4">
|
||||
<h3 className="font-semibold text-green-800 dark:text-green-200 mb-2">
|
||||
{t('limitations.future_title')}
|
||||
</h3>
|
||||
<ul className="text-green-700 dark:text-green-300 space-y-1">
|
||||
<li>{t('limitations.future_1')}</li>
|
||||
<li>{t('limitations.future_2')}</li>
|
||||
<li>{t('limitations.future_3')}</li>
|
||||
<li>{t('limitations.future_4')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
212
packages/website/src/docs/introduction/overview/page.tsx
Normal file
212
packages/website/src/docs/introduction/overview/page.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Overview() {
|
||||
const { t } = useTranslation('docs')
|
||||
|
||||
return (
|
||||
<article>
|
||||
{/* 头图 */}
|
||||
<figure className="mb-8 rounded-xl overflow-hidden">
|
||||
<img
|
||||
src="https://img.alicdn.com/imgextra/i1/O1CN01RY0Wvh26ATVeDIX7v_!!6000000007621-0-tps-1672-512.jpg"
|
||||
alt="page-agent"
|
||||
className="w-full h-64 object-cover"
|
||||
/>
|
||||
</figure>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold mb-4">{t('overview.title')}</h1>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 mb-4 leading-relaxed">
|
||||
{t('overview.subtitle')}
|
||||
</p>
|
||||
|
||||
{/* Status Badges */}
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<a
|
||||
href="https://www.npmjs.com/package/page-agent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img src="https://badge.fury.io/js/page-agent.svg" alt="npm version" />
|
||||
</a>
|
||||
<a href="https://opensource.org/licenses/MIT" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="MIT License" />
|
||||
</a>
|
||||
<a href="http://www.typescriptlang.org/" target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src="https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg"
|
||||
alt="TypeScript"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.npmjs.com/package/page-agent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img src="https://img.shields.io/npm/dt/page-agent.svg" alt="Downloads" />
|
||||
</a>
|
||||
<a
|
||||
href="https://bundlephobia.com/package/page-agent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img src="https://img.shields.io/bundlephobia/minzip/page-agent" alt="Bundle Size" />
|
||||
</a>
|
||||
<a href="https://github.com/alibaba/page-agent" target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src="https://img.shields.io/github/stars/alibaba/page-agent.svg"
|
||||
alt="GitHub stars"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold mb-4">{t('overview.what_is')}</h2>
|
||||
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-8 leading-relaxed ">
|
||||
{t('overview.what_is_desc')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold mb-3">{t('overview.features_title')}</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4 mb-8" role="list">
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-300">
|
||||
{t('overview.feature_dom.title')}
|
||||
</h3>
|
||||
<p className="text-gray-700 dark:text-gray-300">{t('overview.feature_dom.desc')}</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">
|
||||
{t('overview.feature_secure.title')}
|
||||
</h3>
|
||||
<p className="text-gray-700 dark:text-gray-300">{t('overview.feature_secure.desc')}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2 text-green-900 dark:text-green-300">
|
||||
{t('overview.feature_backend.title')}
|
||||
</h3>
|
||||
<p className="text-gray-700 dark:text-gray-300">{t('overview.feature_backend.desc')}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2 text-orange-900 dark:text-orange-300">
|
||||
{t('overview.feature_accessible.title')}
|
||||
</h3>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
{t('overview.feature_accessible.desc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-4">{t('overview.vs_browser_use')}</h2>
|
||||
|
||||
<div className="overflow-x-auto mb-8">
|
||||
<table className="w-full border-collapse border border-gray-300 dark:border-gray-600">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-800">
|
||||
<th className="border border-gray-300 dark:border-gray-600 px-4 py-3 text-left">
|
||||
{t('overview.table_feature')}
|
||||
</th>
|
||||
<th className="border border-gray-300 dark:border-gray-600 px-4 py-3 text-left">
|
||||
page-agent
|
||||
</th>
|
||||
<th className="border border-gray-300 dark:border-gray-600 px-4 py-3 text-left">
|
||||
browser-use
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3 font-medium">
|
||||
{t('overview.table_deployment')}
|
||||
</td>
|
||||
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3">
|
||||
{t('overview.table_deployment_pa')}
|
||||
</td>
|
||||
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3">
|
||||
{t('overview.table_deployment_bu')}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3 font-medium">
|
||||
{t('overview.table_scope')}
|
||||
</td>
|
||||
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3">
|
||||
{t('overview.table_scope_pa')}
|
||||
</td>
|
||||
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3">
|
||||
{t('overview.table_scope_bu')}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3 font-medium">
|
||||
{t('overview.table_user')}
|
||||
</td>
|
||||
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3">
|
||||
{t('overview.table_user_pa')}
|
||||
</td>
|
||||
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3">
|
||||
{t('overview.table_user_bu')}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3 font-medium">
|
||||
{t('overview.table_scenario')}
|
||||
</td>
|
||||
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3">
|
||||
{t('overview.table_scenario_pa')}
|
||||
</td>
|
||||
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3">
|
||||
{t('overview.table_scenario_bu')}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-4">{t('overview.use_cases_title')}</h2>
|
||||
|
||||
<ul className="space-y-4 mb-8">
|
||||
<li className="flex items-start space-x-3">
|
||||
<span className="w-6 h-6 min-w-6 bg-blue-500 text-white rounded-full flex items-center justify-center font-bold text-sm mt-0.5 shrink-0">
|
||||
1
|
||||
</span>
|
||||
<div className="text-gray-700 dark:text-gray-300">
|
||||
<strong>{t('overview.use_case1_title')}</strong> {t('overview.use_case1_desc')}
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start space-x-3">
|
||||
<span className="w-6 h-6 min-w-6 bg-green-500 text-white rounded-full flex items-center justify-center font-bold text-sm mt-0.5 shrink-0">
|
||||
2
|
||||
</span>
|
||||
<div className="text-gray-700 dark:text-gray-300">
|
||||
<strong>{t('overview.use_case2_title')}</strong> {t('overview.use_case2_desc')}
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start space-x-3">
|
||||
<span className="w-6 h-6 min-w-6 bg-purple-500 text-white rounded-full flex items-center justify-center font-bold text-sm mt-0.5 shrink-0">
|
||||
3
|
||||
</span>
|
||||
<div className="text-gray-700 dark:text-gray-300">
|
||||
<strong>{t('overview.use_case3_title')}</strong> {t('overview.use_case3_desc')}
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start space-x-3">
|
||||
<span className="w-6 h-6 min-w-6 bg-orange-500 text-white rounded-full flex items-center justify-center font-bold text-sm mt-0.5 shrink-0">
|
||||
4
|
||||
</span>
|
||||
<div className="text-gray-700 dark:text-gray-300">
|
||||
<strong>{t('overview.use_case4_title')}</strong> {t('overview.use_case4_desc')}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
82
packages/website/src/docs/introduction/quick-start/page.tsx
Normal file
82
packages/website/src/docs/introduction/quick-start/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import BetaNotice from '@/components/BetaNotice'
|
||||
import CodeEditor from '@/components/CodeEditor'
|
||||
|
||||
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={`// 仅供测试使用
|
||||
<script src="https://hwcxiuzfylggtcktqgij.supabase.co/storage/v1/object/public/demo-public/v0.0.4/page-agent.js" 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>
|
||||
)
|
||||
}
|
||||
74
packages/website/src/i18n/README.md
Normal file
74
packages/website/src/i18n/README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# 国际化配置说明
|
||||
|
||||
本项目使用 `react-i18next` 实现国际化支持。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
pages/i18n/
|
||||
├── config.ts # i18next 配置和初始化
|
||||
├── types.ts # TypeScript 类型声明
|
||||
├── locales/
|
||||
│ ├── zh-CN/ # 中文翻译
|
||||
│ │ ├── common.json # 通用组件(Header, Footer等)
|
||||
│ │ ├── home.json # 首页
|
||||
│ │ └── docs.json # 文档页(待完善)
|
||||
│ └── en-US/ # 英文翻译
|
||||
│ ├── common.json
|
||||
│ ├── home.json
|
||||
│ └── docs.json
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 在组件中使用
|
||||
|
||||
```tsx
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function MyComponent() {
|
||||
const { t } = useTranslation('common') // 指定命名空间
|
||||
|
||||
return <h1>{t('header.nav_docs')}</h1>
|
||||
}
|
||||
```
|
||||
|
||||
### 使用多个命名空间
|
||||
|
||||
```tsx
|
||||
const { t } = useTranslation(['home', 'common'])
|
||||
|
||||
// 使用时指定命名空间
|
||||
t('home:hero.title')
|
||||
t('common:header.nav_docs')
|
||||
```
|
||||
|
||||
## 语言切换
|
||||
|
||||
用户可以通过以下方式切换语言:
|
||||
|
||||
1. **自动检测**:首次访问根据浏览器语言自动选择
|
||||
2. **手动切换**:点击页面右上角的语言切换按钮
|
||||
3. **持久化**:语言选择保存在 `localStorage` 中,刷新后保持
|
||||
|
||||
## 添加新翻译
|
||||
|
||||
1. 在对应的 JSON 文件中添加翻译条目(如 `zh-CN/home.json`)
|
||||
2. 在对应的英文文件中添加翻译(如 `en-US/home.json`)
|
||||
3. 在组件中使用 `t('namespace:key')` 获取翻译
|
||||
|
||||
## TypeScript 支持
|
||||
|
||||
`types.ts` 文件提供了类型声明,使得翻译 key 具有:
|
||||
|
||||
- 自动补全
|
||||
- 编译期类型检查
|
||||
- 防止拼写错误
|
||||
|
||||
## 待完成
|
||||
|
||||
- [ ] 文档页翻译(`docs.json`)
|
||||
- [ ] DocsLayout 导航结构国际化
|
||||
- [ ] 404 页面国际化
|
||||
|
||||
45
packages/website/src/i18n/config.ts
Normal file
45
packages/website/src/i18n/config.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import i18n from 'i18next'
|
||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
|
||||
import commonEn from './locales/en-US/common'
|
||||
import docsEn from './locales/en-US/docs'
|
||||
import homeEn from './locales/en-US/home'
|
||||
import commonZh from './locales/zh-CN/common'
|
||||
import docsZh from './locales/zh-CN/docs'
|
||||
import homeZh from './locales/zh-CN/home'
|
||||
|
||||
const resources = {
|
||||
'zh-CN': {
|
||||
common: commonZh,
|
||||
home: homeZh,
|
||||
docs: docsZh,
|
||||
},
|
||||
'en-US': {
|
||||
common: commonEn,
|
||||
home: homeEn,
|
||||
docs: docsEn,
|
||||
},
|
||||
}
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: 'en-US',
|
||||
defaultNS: 'common',
|
||||
|
||||
// 语言检测配置
|
||||
detection: {
|
||||
// localStorage 优先(用户手动选择),其次检测浏览器语言
|
||||
order: ['localStorage', 'navigator'],
|
||||
caches: ['localStorage'],
|
||||
},
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false, // React 已经做了 XSS 防护
|
||||
},
|
||||
})
|
||||
|
||||
export default i18n
|
||||
40
packages/website/src/i18n/locales/en-US/common.ts
Normal file
40
packages/website/src/i18n/locales/en-US/common.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export default {
|
||||
header: {
|
||||
logo_alt: 'page-agent home',
|
||||
slogan: 'GUI Agent in your webpage',
|
||||
nav_docs: 'Docs',
|
||||
nav_source: 'GitHub',
|
||||
mobile_menu: 'Open navigation',
|
||||
},
|
||||
footer: {
|
||||
copyright: '© 2025 page-agent. All rights reserved.',
|
||||
github_label: 'Visit GitHub repository',
|
||||
},
|
||||
beta_notice: {
|
||||
title: 'Beta Stage',
|
||||
content:
|
||||
'Current features are incomplete and the API may change at any time. Please do not use in production environments before the official release.',
|
||||
},
|
||||
language: {
|
||||
zh: '中文',
|
||||
en: 'English',
|
||||
switch_label: 'Switch language',
|
||||
},
|
||||
nav: {
|
||||
introduction: 'Introduction',
|
||||
features: 'Features',
|
||||
integration: 'Integration',
|
||||
overview: 'Overview',
|
||||
quick_start: 'Quick Start',
|
||||
limitations: 'Limitations',
|
||||
model_integration: 'Model Integration',
|
||||
custom_tools: 'Custom Tools',
|
||||
knowledge_injection: 'Knowledge Injection',
|
||||
security_permissions: 'Security & Permissions',
|
||||
data_masking: 'Data Masking',
|
||||
cdn_setup: 'CDN Setup',
|
||||
configuration: 'Configuration',
|
||||
best_practices: 'Best Practices',
|
||||
third_party_agent: 'Third-party Agent',
|
||||
},
|
||||
}
|
||||
172
packages/website/src/i18n/locales/en-US/docs.ts
Normal file
172
packages/website/src/i18n/locales/en-US/docs.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
export default {
|
||||
overview: {
|
||||
title: 'Overview',
|
||||
subtitle:
|
||||
'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.',
|
||||
features_title: 'Core Features',
|
||||
feature_dom: {
|
||||
title: '🧠 Smart DOM Analysis',
|
||||
desc: 'DOM-based analysis with high-intensity dehydration. No visual recognition needed. Pure text for fast and precise operations.',
|
||||
},
|
||||
feature_secure: {
|
||||
title: '🔒 Secure & Controllable',
|
||||
desc: 'Supports operation allowlists, data masking protection. Inject custom knowledge to make AI work by your rules.',
|
||||
},
|
||||
feature_backend: {
|
||||
title: '⚡ Zero Backend',
|
||||
desc: 'CDN or NPM import with custom LLM endpoints.',
|
||||
},
|
||||
feature_accessible: {
|
||||
title: '♿ Accessible Intelligence',
|
||||
desc: 'Provides natural language interface for complex B2B systems and admin panels. Makes software easy for everyone.',
|
||||
},
|
||||
vs_browser_use: 'vs. browser-use',
|
||||
table_feature: 'Feature',
|
||||
table_deployment: 'Deployment',
|
||||
table_deployment_pa: 'Embedded component',
|
||||
table_deployment_bu: 'External tool',
|
||||
table_scope: 'Scope',
|
||||
table_scope_pa: 'Current page',
|
||||
table_scope_bu: 'Entire browser',
|
||||
table_user: 'Target Users',
|
||||
table_user_pa: 'Web developers',
|
||||
table_user_bu: 'Scraper/Agent developers',
|
||||
table_scenario: 'Use Case',
|
||||
table_scenario_pa: 'UX enhancement',
|
||||
table_scenario_bu: 'Automation tasks',
|
||||
use_cases_title: 'Use Cases',
|
||||
use_case1_title: 'Connect Support Bots:',
|
||||
use_case1_desc:
|
||||
"Turn your support assistant into a full agent. Customer service bots no longer just say 'Please click the settings button then click...'—they operate for users directly.",
|
||||
use_case2_title: 'Modernize Legacy Apps:',
|
||||
use_case2_desc:
|
||||
'One line of code transforms old apps into agents. Product experts help users navigate complex B2B software. Reduce support costs and improve satisfaction.',
|
||||
use_case3_title: 'Interactive Training:',
|
||||
use_case3_desc:
|
||||
"Demonstrate workflows in real-time. Let AI show the complete process of 'how to submit an expense report.'",
|
||||
use_case4_title: 'Accessibility:',
|
||||
use_case4_desc:
|
||||
'Provide natural language interaction for visually impaired and elderly users. Connect screen readers or voice assistants to make software accessible to everyone.',
|
||||
get_started_title: '🚀 Get Started',
|
||||
get_started_desc:
|
||||
'Ready to add an AI operator to your website? Check our quick start guide for integration in minutes.',
|
||||
get_started_button: 'Quick Start →',
|
||||
},
|
||||
quick_start: {
|
||||
title: 'Quick Start',
|
||||
subtitle: 'Integrate page-agent in minutes.',
|
||||
installation: 'Installation Steps',
|
||||
step1_title: '1. Import Options',
|
||||
step1_cdn: 'CDN Import',
|
||||
step1_npm: 'NPM Install',
|
||||
step2_title: '2. Initialize Configuration',
|
||||
step3_title: '3. Start Using',
|
||||
},
|
||||
limitations: {
|
||||
title: 'Limitations',
|
||||
subtitle: "Understand page-agent's current capabilities and technical constraints",
|
||||
page_support: 'Page Support Limitations',
|
||||
spa_limit_title: 'Single Page Application Limits',
|
||||
spa_limit_1: '• SPA only: Currently operates within a single page',
|
||||
spa_limit_2: '• Multi-page relay in design: Cannot execute continuous tasks across pages yet',
|
||||
spa_limit_3: '• Requires integration: Cannot operate on sites without page-agent',
|
||||
interaction_limits: 'Interaction Limitations',
|
||||
supported_ops: 'Supported Operations',
|
||||
op_click: 'Click',
|
||||
op_input: 'Text input',
|
||||
op_scroll: 'Scroll',
|
||||
op_submit: 'Form submit',
|
||||
op_select: 'Select',
|
||||
op_focus: 'Focus',
|
||||
unsupported_ops: 'Unsupported Operations',
|
||||
op_hover: 'Mouse hover',
|
||||
op_drag: 'Drag & drop',
|
||||
op_context: 'Right-click menu',
|
||||
op_draw: 'Drawing',
|
||||
op_keyboard: 'Keyboard shortcuts',
|
||||
op_position: 'Position-based control',
|
||||
understanding_limits: 'Understanding Limitations',
|
||||
no_vision_title: 'No Visual Recognition',
|
||||
no_vision_desc:
|
||||
'page-agent operates based on DOM structure with no visual recognition. Cannot understand:',
|
||||
no_vision_1: '• Image content: Cannot recognize text, icons, or visual elements in images',
|
||||
no_vision_2: '• Canvas: Cannot understand graphics drawn on Canvas',
|
||||
no_vision_3: '• WebGL 3D: Cannot operate elements in 3D scenes',
|
||||
no_vision_4: '• SVG graphics: Cannot understand visual content and paths in SVG',
|
||||
website_requirements: 'Website Requirements',
|
||||
req_semantic_title: 'Semantic & Usability',
|
||||
req_semantic_desc:
|
||||
'All operations rely on semantic tags and attributes. Poor semantic structure or lack of accessibility features may affect AI understanding accuracy.',
|
||||
req_ux_title: 'UI/UX',
|
||||
req_ux_desc:
|
||||
'Counter-intuitive interaction rules, visual-only operation hints, complex mouse interactions, or rapidly appearing/disappearing elements can affect AI understanding and operation.',
|
||||
req_env_title: 'Environment',
|
||||
req_env_desc: 'modern browser',
|
||||
future: 'Future Plans',
|
||||
future_title: 'Coming Soon',
|
||||
future_1: '• Multi-page relay capabilities',
|
||||
future_2: '• Richer mouse interaction support',
|
||||
future_3: '• Basic visual understanding',
|
||||
future_4: '• Smarter error recovery',
|
||||
},
|
||||
model_integration: {
|
||||
title: 'Model Integration',
|
||||
subtitle:
|
||||
'Supports OpenAI-compatible models with tool call support, including public cloud services and private deployments.',
|
||||
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',
|
||||
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',
|
||||
security: '🔐 Production Authentication',
|
||||
security_warning: '⚠️ Never commit real LLM API Keys to your frontend codebase',
|
||||
security_desc:
|
||||
'In production environments, to hide the real LLM API Keys, we recommend the following architecture:',
|
||||
security_backend_proxy: 'Backend Proxy Pattern',
|
||||
security_backend_desc:
|
||||
'Set up a backend LLM proxy endpoint that uses the same authentication method as other APIs in your website, such as:',
|
||||
security_method_1: '• Session/Cookie-based authentication',
|
||||
security_method_2: '• OIDC (OpenID Connect) single sign-on',
|
||||
security_method_3: '• Temporary Access Key or JWT Token',
|
||||
configuration: 'Configuration',
|
||||
},
|
||||
custom_tools: {
|
||||
title: 'Custom Tools',
|
||||
subtitle:
|
||||
'Extend AI Agent capabilities by registering custom tools. Use Zod to define strict input schemas for safe business logic calls.',
|
||||
registration: 'Tool Registration',
|
||||
registration_desc:
|
||||
'Each custom tool requires four core properties: name, description, input schema, and execute function.',
|
||||
page_filter: 'Page Filter',
|
||||
page_filter_desc:
|
||||
'Control tool visibility on specific pages via the pageFilter property to enhance security and UX.',
|
||||
best_practices: 'Best Practices',
|
||||
bp_performance: '⚡ Performance Optimization',
|
||||
bp_1: '• Use pageFilter to reduce unnecessary tool loading',
|
||||
bp_2: '• Implement appropriate caching in execute functions',
|
||||
bp_3: '• Avoid long-running sync operations in tools',
|
||||
},
|
||||
}
|
||||
79
packages/website/src/i18n/locales/en-US/home.ts
Normal file
79
packages/website/src/i18n/locales/en-US/home.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export default {
|
||||
hero: {
|
||||
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_main: ' adds intelligent GUI Agents to your website.',
|
||||
subtitle_detail: 'Users give natural language commands, AI handles the rest.',
|
||||
tab_try: '🚀 Try It Now',
|
||||
tab_other: '🌐 Try on Other Sites',
|
||||
input_placeholder: 'Describe what you want AI to do...',
|
||||
execute_button: 'Run',
|
||||
default_task:
|
||||
'Goto docs in navigation bar, find Quick-Start section, and summarize in markdown',
|
||||
},
|
||||
try_other: {
|
||||
step1_title: 'Step 1:',
|
||||
step1_content: 'Show your bookmarks bar',
|
||||
step2_title: 'Step 2:',
|
||||
step2_content: 'Drag this button to your bookmarks',
|
||||
step3_title: 'Step 3:',
|
||||
step3_content: 'Click the bookmark on any site to activate',
|
||||
notice_title: '⚠️ Heads Up',
|
||||
notice_items: {
|
||||
item1: 'Demo only—link may expire without notice',
|
||||
item2: 'This free demo uses DeepSeek API (see their terms and privacy policy)',
|
||||
item3: 'Some sites block script injection (CSP policies)',
|
||||
item4: 'Works on single-page apps only—reload required after navigation',
|
||||
item5: 'Text-only understanding—no image recognition or drag-and-drop',
|
||||
item6_prefix: 'Full limitations in',
|
||||
item6_link: 'Docs',
|
||||
},
|
||||
},
|
||||
benefits: {
|
||||
no_backend: 'Pure Front-end Solution',
|
||||
private_model: 'Your Own Models',
|
||||
data_masking: 'Built-in Privacy',
|
||||
open_source: 'MIT Open Source',
|
||||
},
|
||||
features: {
|
||||
section_title: 'Why PageAgent',
|
||||
in_page: {
|
||||
title: 'In-page Solution',
|
||||
desc: 'Runs entirely within your page. No browser extensions, no headless browsers, and no backend required.',
|
||||
},
|
||||
secure_integration: {
|
||||
title: 'Secure by Design',
|
||||
desc: 'Control what AI can access with allowlists, data masking, and custom knowledge injection. Your rules, your data.',
|
||||
},
|
||||
zero_backend: {
|
||||
title: 'Zero Backend Setup',
|
||||
desc: 'Just drop in a script. Works with any LLM provider—OpenAI, Anthropic, or your own models.',
|
||||
},
|
||||
accessible: {
|
||||
title: 'Natural Language UI',
|
||||
desc: 'Transform complex admin panels into chat interfaces. Make powerful tools accessible to everyone, not just experts.',
|
||||
},
|
||||
},
|
||||
use_cases: {
|
||||
section_title: 'Where It Shines',
|
||||
section_subtitle: 'From simple forms to complex workflows, AI understands and executes',
|
||||
case1: {
|
||||
title: 'Supercharge Support Bots',
|
||||
desc: 'Stop telling users where to click—let AI do it for them. Turn your chatbot from a guide into an operator that actually completes tasks.',
|
||||
},
|
||||
case2: {
|
||||
title: 'Modernize Legacy Apps',
|
||||
desc: 'Add AI superpowers to old software without rebuilding. One script tag transforms complex enterprise tools into chat-driven interfaces.',
|
||||
},
|
||||
case3: {
|
||||
title: 'Interactive Walkthroughs',
|
||||
desc: "Show, don't tell. Let AI demonstrate workflows in real-time—perfect for onboarding or training new users on complex systems.",
|
||||
},
|
||||
case4: {
|
||||
title: 'Accessibility First',
|
||||
desc: 'Make web apps accessible through natural language. Perfect for screen readers, voice control, or users who find traditional interfaces challenging.',
|
||||
},
|
||||
},
|
||||
}
|
||||
39
packages/website/src/i18n/locales/zh-CN/common.ts
Normal file
39
packages/website/src/i18n/locales/zh-CN/common.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export default {
|
||||
header: {
|
||||
logo_alt: 'page-agent 首页',
|
||||
slogan: 'GUI Agent in your webpage',
|
||||
nav_docs: '文档',
|
||||
nav_source: 'GitHub',
|
||||
mobile_menu: '打开导航栏',
|
||||
},
|
||||
footer: {
|
||||
copyright: '© 2025 page-agent. All rights reserved.',
|
||||
github_label: '访问 GitHub 仓库',
|
||||
},
|
||||
beta_notice: {
|
||||
title: 'Beta 阶段',
|
||||
content: '当前功能未完成,接口可能随时变更。正式版本发布前请勿用于生产环境。',
|
||||
},
|
||||
language: {
|
||||
zh: '中文',
|
||||
en: 'English',
|
||||
switch_label: '切换语言',
|
||||
},
|
||||
nav: {
|
||||
introduction: '介绍',
|
||||
features: '功能特性',
|
||||
integration: '集成指南',
|
||||
overview: '概览',
|
||||
quick_start: '快速开始',
|
||||
limitations: '使用限制',
|
||||
model_integration: '模型接入',
|
||||
custom_tools: '自定义工具',
|
||||
knowledge_injection: '知识库注入',
|
||||
security_permissions: '安全与权限',
|
||||
data_masking: '数据脱敏',
|
||||
cdn_setup: 'CDN 引入',
|
||||
configuration: '配置选项',
|
||||
best_practices: '最佳实践',
|
||||
third_party_agent: '接入第三方 Agent',
|
||||
},
|
||||
}
|
||||
168
packages/website/src/i18n/locales/zh-CN/docs.ts
Normal file
168
packages/website/src/i18n/locales/zh-CN/docs.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
export default {
|
||||
overview: {
|
||||
title: 'Overview',
|
||||
subtitle:
|
||||
'page-agent 是一个完全基于 Web 技术的 GUI Agent,简单几步,让你的网站拥有 AI 操作员。',
|
||||
what_is: '什么是 page-agent?',
|
||||
what_is_desc:
|
||||
'page-agent 是一个页面内嵌式 GUI Agent。与传统的浏览器自动化工具不同,page-agent 面向网站开发者,而非爬虫或Agent客户端开发者;将 Agent 集成到你的网站中,让用户可以通过自然语言与页面进行交互。',
|
||||
features_title: '核心特性',
|
||||
feature_dom: {
|
||||
title: '🧠 智能 DOM 理解',
|
||||
desc: '基于 DOM 分析,高强度脱水。无需视觉识别,纯文本实现精准操作。',
|
||||
},
|
||||
feature_secure: {
|
||||
title: '🔒 安全可控',
|
||||
desc: '支持操作黑白名单、数据脱敏保护。注入自定义知识库,让 AI 按你的规则工作。',
|
||||
},
|
||||
feature_backend: {
|
||||
title: '⚡ 零后端部署',
|
||||
desc: 'CDN 或 NPM 引入,自定义 LLM 接入点。',
|
||||
},
|
||||
feature_accessible: {
|
||||
title: '♿ 普惠智能',
|
||||
desc: '为复杂 B端系统、管理后台提供自然语言入口。让每个用户都能轻松上手。',
|
||||
},
|
||||
vs_browser_use: '与 browser-use 的区别',
|
||||
table_feature: '特性',
|
||||
table_deployment: '部署方式',
|
||||
table_deployment_pa: '页面内嵌组件',
|
||||
table_deployment_bu: '外部工具',
|
||||
table_scope: '操作范围',
|
||||
table_scope_pa: '当前页面',
|
||||
table_scope_bu: '整个浏览器',
|
||||
table_user: '目标用户',
|
||||
table_user_pa: '网站开发者',
|
||||
table_user_bu: '爬虫/Agent 开发者',
|
||||
table_scenario: '使用场景',
|
||||
table_scenario_pa: '用户体验增强',
|
||||
table_scenario_bu: '自动化任务',
|
||||
use_cases_title: '应用场景',
|
||||
use_case1_title: '对接答疑机器人:',
|
||||
use_case1_desc:
|
||||
'把你的答疑助手变成全能Agent。客服机器人不再只说「请先点击设置按钮然后点击...」,而是直接帮用户现场操作。',
|
||||
use_case2_title: '交互升级/智能化改造:',
|
||||
use_case2_desc:
|
||||
'一行代码,老应用变身Agent,产品专家帮用户操作复杂 B 端软件。降低人工支持成本,提高用户满意度。',
|
||||
use_case3_title: '产品教学:',
|
||||
use_case3_desc:
|
||||
'向用户演示交互过程,边做边教。例如让AI演示「如何提交报销申请」的完整操作流程。',
|
||||
use_case4_title: '无障碍支持:',
|
||||
use_case4_desc:
|
||||
'为视障用户、老年用户提供自然语言交互,对接屏幕阅读器或语音助理,让软件人人可用。',
|
||||
get_started_title: '🚀 开始使用',
|
||||
get_started_desc:
|
||||
'准备好为你的网站添加 AI 操作员了吗?查看我们的快速开始指南,几分钟内完成集成。',
|
||||
get_started_button: '快速开始 →',
|
||||
},
|
||||
quick_start: {
|
||||
title: 'Quick Start',
|
||||
subtitle: '几分钟内完成 page-agent 的集成。',
|
||||
installation: '安装步骤',
|
||||
step1_title: '1. 引入方式',
|
||||
step1_cdn: 'CDN 引入',
|
||||
step1_npm: 'NPM 安装',
|
||||
step2_title: '2. 初始化配置',
|
||||
step3_title: '3. 开始使用',
|
||||
},
|
||||
limitations: {
|
||||
title: '使用限制',
|
||||
subtitle: '了解 page-agent 当前的功能边界和技术限制',
|
||||
page_support: '页面支持限制',
|
||||
spa_limit_title: '单页应用限制',
|
||||
spa_limit_1: '• 仅支持单页应用(SPA):目前只能在单个页面内进行操作',
|
||||
spa_limit_2: '• 多页接力功能正在设计中:暂时无法跨页面执行连续任务',
|
||||
spa_limit_3: '• 无法操作未接入该能力的网站:需要目标网站主动集成 page-agent',
|
||||
interaction_limits: '交互行为限制',
|
||||
supported_ops: '支持的操作',
|
||||
op_click: '点击操作',
|
||||
op_input: '文本输入',
|
||||
op_scroll: '页面滚动',
|
||||
op_submit: '表单提交',
|
||||
op_select: '选择操作',
|
||||
op_focus: '焦点切换',
|
||||
unsupported_ops: '不支持的操作',
|
||||
op_hover: '鼠标悬停(hover)',
|
||||
op_drag: '拖拽操作',
|
||||
op_context: '右键菜单',
|
||||
op_draw: '图形绘制',
|
||||
op_keyboard: '键盘快捷键',
|
||||
op_position: '基于点击区域或鼠标位置的控制',
|
||||
understanding_limits: '网页理解限制',
|
||||
no_vision_title: '无视觉能力',
|
||||
no_vision_desc: 'page-agent 基于 DOM 结构进行理解和操作,没有视觉识别能力,无法理解以下内容:',
|
||||
no_vision_1: '• 图片内容:无法识别图片中的文字、图标或视觉元素',
|
||||
no_vision_2: '• Canvas 画布:无法理解 Canvas 中绘制的图形和内容',
|
||||
no_vision_3: '• WebGL 3D 内容:无法操作 3D 场景中的元素',
|
||||
no_vision_4: '• SVG 图形:无法理解 SVG 中的视觉内容和路径',
|
||||
website_requirements: '被操作网站要求',
|
||||
req_semantic_title: '语义化和易用性',
|
||||
req_semantic_desc:
|
||||
'所有操作都基于 DOM 元素的语义化标签和属性。如果页面结构不够语义化,或者没有任何 accessibility 特性,可能影响 AI 的理解准确性。',
|
||||
req_ux_title: 'UI/UX',
|
||||
req_ux_desc:
|
||||
'反常识的交互规则、基于视觉的操作提示、复杂的鼠标交互、快速出现快速消失的元素等,都会影响 AI 的理解和操作。',
|
||||
req_env_title: '环境要求',
|
||||
req_env_desc: 'modern browser',
|
||||
future: '未来规划',
|
||||
future_title: '即将支持',
|
||||
future_1: '• 多页面接力操作能力',
|
||||
future_2: '• 更丰富的鼠标交互支持',
|
||||
future_3: '• 基础的视觉理解能力',
|
||||
future_4: '• 更智能的错误恢复机制',
|
||||
},
|
||||
model_integration: {
|
||||
title: '模型接入',
|
||||
subtitle: '当前支持符合 OpenAI 接口规范且支持 tool call 的模型,包括公有云服务和私有部署方案。',
|
||||
recommended: '推荐模型',
|
||||
model_gpt4_title: '⚡ gpt-4.1-mini',
|
||||
model_gpt4_badge: '评估基准 ✅',
|
||||
model_gpt4_1: '• 性价比高',
|
||||
model_gpt4_2: '• 速度快',
|
||||
model_gpt4_3: '• 成功率高',
|
||||
model_deepseek_title: '💰 DeepSeek-3.2',
|
||||
model_deepseek_badge: '经济实惠',
|
||||
model_deepseek_1: '• 价格远低于同等级其他模型',
|
||||
model_deepseek_2: '• ToolCall 有出错率,通常能够自动修复',
|
||||
model_deepseek_3: '• 本网站提供的免费试用为 DeepSeek',
|
||||
model_qwen_title: '🛡️ qwen3',
|
||||
model_qwen_badge: '安全合规',
|
||||
model_qwen_1: '• 可控、效果尚可,价格合理',
|
||||
model_qwen_2: '• ToolCall 有出错率,通常能够自动修复',
|
||||
model_qwen_3: '• 适合能给出详细步骤的场景',
|
||||
model_gemini_title: '⚡ gemini-2.5-flash',
|
||||
model_gemini_badge: '极其高效,成功率高,价格合理',
|
||||
available: '可用模型',
|
||||
available_verified: '✅ 已验证可用',
|
||||
tips: '提示',
|
||||
tip_1: 'reasoning 模型(如 GPT-5),速度偏慢,没有必要',
|
||||
tip_2:
|
||||
'不保证 json schema 的模型(openAI 以外的几乎所有模型),tool call 有概率出错,通常能自动修复,建议 temperature 设置高一些',
|
||||
tip_3: '小模型、nano 模型,效果不佳',
|
||||
security: '🔐 生产环境鉴权建议',
|
||||
security_warning: '⚠️ 永远不要把真实的 LLM API Key 发布到前端代码库',
|
||||
security_desc: '在实际应用中,为了隐藏真实的 LLM API Key,建议采用以下架构:',
|
||||
security_backend_proxy: '后端代理转发',
|
||||
security_backend_desc:
|
||||
'在后端搭建一个 LLM 流量转发接口,该接口使用与你网站上其他接口相同的鉴权方式,例如:',
|
||||
security_method_1: '• Session/Cookie 会话认证',
|
||||
security_method_2: '• OIDC (OpenID Connect) 单点登录',
|
||||
security_method_3: '• 临时 Access Key 或 JWT Token',
|
||||
configuration: '配置方式',
|
||||
},
|
||||
custom_tools: {
|
||||
title: '自定义工具',
|
||||
subtitle:
|
||||
'通过注册自定义工具,扩展 AI Agent 的能力边界。使用 Zod 定义严格的输入接口,让 AI 安全调用你的业务逻辑。',
|
||||
registration: '工具注册',
|
||||
registration_desc:
|
||||
'每个自定义工具需要定义四个核心属性:name、description、input schema 和 execute 函数。',
|
||||
page_filter: '页面过滤器',
|
||||
page_filter_desc: '通过 pageFilter 属性控制工具在哪些页面可见,提升安全性和用户体验。',
|
||||
best_practices: '最佳实践',
|
||||
bp_performance: '⚡ 性能优化',
|
||||
bp_1: '• 使用 pageFilter 减少不必要的工具加载',
|
||||
bp_2: '• 在 execute 函数中实现适当的缓存机制',
|
||||
bp_3: '• 避免在工具中执行耗时的同步操作',
|
||||
},
|
||||
}
|
||||
78
packages/website/src/i18n/locales/zh-CN/home.ts
Normal file
78
packages/website/src/i18n/locales/zh-CN/home.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
export default {
|
||||
hero: {
|
||||
badge: 'GUI Agent in your webpage',
|
||||
title_line1: '让你的 Web 应用',
|
||||
title_line2: '拥有 AI 操作员',
|
||||
subtitle_emoji: '🪄 一行 CDN 引入',
|
||||
subtitle_main: ',为你的网站添加 GUI Agent。',
|
||||
subtitle_detail: '用户/答疑机器人给出文字指示,AI 帮你操作页面。',
|
||||
tab_try: '🚀 立即尝试',
|
||||
tab_other: '🌐 其他网页尝试',
|
||||
input_placeholder: '输入您想要 AI 执行的任务...',
|
||||
execute_button: '执行',
|
||||
default_task: '从导航栏中进入文档页,打开“快速开始”相关的文档,帮我总结成 markdown',
|
||||
},
|
||||
try_other: {
|
||||
step1_title: '步骤 1:',
|
||||
step1_content: '显示收藏夹栏',
|
||||
step2_title: '步骤 2:',
|
||||
step2_content: '拖拽下面按钮到收藏夹栏',
|
||||
step3_title: '步骤 3:',
|
||||
step3_content: '在其他网站点击收藏夹中的按钮即可使用',
|
||||
notice_title: '⚠️ 注意',
|
||||
notice_items: {
|
||||
item1: '仅做技术评估,链接定期失效',
|
||||
item2: '使用 DeepSeek 模型,参考 DeepSeek 用户协议和隐私政策',
|
||||
item3: '部分网站屏蔽了链接嵌入,将无反应',
|
||||
item4: '仅支持单页应用,页面跳转后需要重新注入',
|
||||
item5: '仅识别文本,不识别图像,不支持拖拽等复杂交互',
|
||||
item6_prefix: '详细使用限制参照',
|
||||
item6_link: '《文档》',
|
||||
},
|
||||
},
|
||||
benefits: {
|
||||
no_backend: '纯前端方案',
|
||||
private_model: '支持私有模型',
|
||||
data_masking: '无痛脱敏',
|
||||
open_source: 'MIT 开源',
|
||||
},
|
||||
features: {
|
||||
section_title: '核心特性',
|
||||
in_page: {
|
||||
title: '纯页面内方案',
|
||||
desc: '完全运行在你的页面内。不需要浏览器插件、不需要无头浏览器,不需要后端。',
|
||||
},
|
||||
secure_integration: {
|
||||
title: '安全可控集成',
|
||||
desc: '支持操作黑白名单、数据脱敏保护。注入自定义知识库,让 AI 按你的规则工作。',
|
||||
},
|
||||
zero_backend: {
|
||||
title: '零后端部署',
|
||||
desc: '前端脚本引入,自定义 LLM 接入点。从 OpenAI 到 qwen3,完全由你掌控。',
|
||||
},
|
||||
accessible: {
|
||||
title: '普惠智能交互',
|
||||
desc: '为复杂 B端系统、管理后台提供自然语言入口。让每个用户都能轻松上手。',
|
||||
},
|
||||
},
|
||||
use_cases: {
|
||||
section_title: '应用场景',
|
||||
section_subtitle: '从简单的表单填写到复杂的业务流程,AI 都能理解并执行',
|
||||
case1: {
|
||||
title: '对接答疑机器人',
|
||||
desc: '把你的答疑助手变成全能Agent。客服机器人不再只说「请先点击设置按钮然后点击...」,而是直接帮用户现场操作。',
|
||||
},
|
||||
case2: {
|
||||
title: '交互升级/智能化改造',
|
||||
desc: '一行代码,老应用变身Agent,产品专家帮用户操作复杂 B 端软件。降低人工支持成本,提高用户满意度。',
|
||||
},
|
||||
case3: {
|
||||
title: '产品教学',
|
||||
desc: '向用户演示交互过程,边做边教。例如让AI演示「如何提交报销申请」的完整操作流程。',
|
||||
},
|
||||
case4: {
|
||||
title: '无障碍支持',
|
||||
desc: '为视障用户、老年用户提供自然语言交互,对接屏幕阅读器或语音助理,让软件人人可用。',
|
||||
},
|
||||
},
|
||||
}
|
||||
16
packages/website/src/i18n/types.ts
Normal file
16
packages/website/src/i18n/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'react-i18next'
|
||||
|
||||
import type commonZh from './locales/zh-CN/common'
|
||||
import type docsZh from './locales/zh-CN/docs'
|
||||
import type homeZh from './locales/zh-CN/home'
|
||||
|
||||
declare module 'react-i18next' {
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: 'common'
|
||||
resources: {
|
||||
common: typeof commonZh
|
||||
home: typeof homeZh
|
||||
docs: typeof docsZh
|
||||
}
|
||||
}
|
||||
}
|
||||
204
packages/website/src/index.css
Normal file
204
packages/website/src/index.css
Normal file
@@ -0,0 +1,204 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* 启用 class-based dark mode for Tailwind v4 */
|
||||
@variant dark (.dark &);
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
/* 主题色渐变 */
|
||||
--theme-color-1: rgb(88, 192, 252);
|
||||
--theme-color-2: rgb(189, 69, 251);
|
||||
}
|
||||
|
||||
/* class-based dark mode - 应用到 html.dark */
|
||||
html.dark,
|
||||
:root.dark {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
|
||||
/* 同时支持系统偏好 */
|
||||
/* @media (prefers-color-scheme: dark) {
|
||||
html:not(.light),
|
||||
:root:not(.light) {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
} */
|
||||
|
||||
/* 添加 Tailwind 自定义颜色 */
|
||||
@theme {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* 确保文档页面标题在暗色模式下可见 - 只针对 prose 内的标题 */
|
||||
.prose h1,
|
||||
.prose h2,
|
||||
.prose h3,
|
||||
.prose h4,
|
||||
.prose h5,
|
||||
.prose h6 {
|
||||
color: rgba(23, 23, 23, 0.85);
|
||||
}
|
||||
|
||||
.dark .prose h1,
|
||||
.dark .prose h2,
|
||||
.dark .prose h3,
|
||||
.dark .prose h4,
|
||||
.dark .prose h5,
|
||||
.dark .prose h6 {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
table,
|
||||
th,
|
||||
td {
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
.dark table,
|
||||
.dark th,
|
||||
.dark td {
|
||||
color: #ededed;
|
||||
}
|
||||
|
||||
/* 文档页深色模式优化 */
|
||||
.dark .prose {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* @media (prefers-color-scheme: dark) {
|
||||
:root:not(.light) .prose {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
} */
|
||||
|
||||
.dark .dark\:prose-invert {
|
||||
--tw-prose-body: rgba(255, 255, 255, 0.7);
|
||||
--tw-prose-headings: rgba(255, 255, 255, 0.95);
|
||||
--tw-prose-lead: rgba(255, 255, 255, 0.7);
|
||||
--tw-prose-links: rgba(147, 197, 253, 0.9);
|
||||
--tw-prose-bold: rgba(255, 255, 255, 0.9);
|
||||
--tw-prose-counters: rgba(255, 255, 255, 0.6);
|
||||
--tw-prose-bullets: rgba(255, 255, 255, 0.5);
|
||||
--tw-prose-hr: rgba(255, 255, 255, 0.2);
|
||||
--tw-prose-quotes: rgba(255, 255, 255, 0.8);
|
||||
--tw-prose-quote-borders: rgba(255, 255, 255, 0.3);
|
||||
--tw-prose-captions: rgba(255, 255, 255, 0.6);
|
||||
--tw-prose-code: rgba(255, 255, 255, 0.9);
|
||||
--tw-prose-pre-code: rgba(255, 255, 255, 0.95);
|
||||
--tw-prose-pre-bg: rgba(0, 0, 0, 0.5);
|
||||
--tw-prose-th-borders: rgba(255, 255, 255, 0.3);
|
||||
--tw-prose-td-borders: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* @media (prefers-color-scheme: dark) {
|
||||
:root:not(.light) .dark\:prose-invert {
|
||||
--tw-prose-body: rgba(255, 255, 255, 0.7);
|
||||
--tw-prose-headings: rgba(255, 255, 255, 0.95);
|
||||
--tw-prose-lead: rgba(255, 255, 255, 0.7);
|
||||
--tw-prose-links: rgba(147, 197, 253, 0.9);
|
||||
--tw-prose-bold: rgba(255, 255, 255, 0.9);
|
||||
--tw-prose-counters: rgba(255, 255, 255, 0.6);
|
||||
--tw-prose-bullets: rgba(255, 255, 255, 0.5);
|
||||
--tw-prose-hr: rgba(255, 255, 255, 0.2);
|
||||
--tw-prose-quotes: rgba(255, 255, 255, 0.8);
|
||||
--tw-prose-quote-borders: rgba(255, 255, 255, 0.3);
|
||||
--tw-prose-captions: rgba(255, 255, 255, 0.6);
|
||||
--tw-prose-code: rgba(255, 255, 255, 0.9);
|
||||
--tw-prose-pre-code: rgba(255, 255, 255, 0.95);
|
||||
--tw-prose-pre-bg: rgba(0, 0, 0, 0.5);
|
||||
--tw-prose-th-borders: rgba(255, 255, 255, 0.3);
|
||||
--tw-prose-td-borders: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
} */
|
||||
|
||||
/* 标题更清晰 */
|
||||
.dark .prose h1,
|
||||
.dark .prose h2,
|
||||
.dark .prose h3,
|
||||
.dark .prose h4 {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
/* @media (prefers-color-scheme: dark) {
|
||||
:root:not(.light) .prose h1,
|
||||
:root:not(.light) .prose h2,
|
||||
:root:not(.light) .prose h3,
|
||||
:root:not(.light) .prose h4 {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
} */
|
||||
|
||||
/* 链接更清晰 */
|
||||
.dark .prose a {
|
||||
color: rgba(147, 197, 253, 0.9);
|
||||
}
|
||||
|
||||
/* @media (prefers-color-scheme: dark) {
|
||||
:root:not(.light) .prose a {
|
||||
color: rgba(147, 197, 253, 0.9);
|
||||
}
|
||||
} */
|
||||
|
||||
/* 代码块背景更黑 */
|
||||
.dark .prose pre {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* @media (prefers-color-scheme: dark) {
|
||||
:root:not(.light) .prose pre {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
} */
|
||||
|
||||
/* 表格样式 */
|
||||
.dark .prose table {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* @media (prefers-color-scheme: dark) {
|
||||
:root:not(.light) .prose table {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
} */
|
||||
|
||||
.dark .prose thead {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-bottom-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* @media (prefers-color-scheme: dark) {
|
||||
:root:not(.light) .prose thead {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-bottom-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
} */
|
||||
|
||||
.dark .prose tbody tr {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* @media (prefers-color-scheme: dark) {
|
||||
:root:not(.light) .prose tbody tr {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
} */
|
||||
|
||||
/* 隐藏滚动条,但保持滚动功能 */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari and Opera */
|
||||
}
|
||||
19
packages/website/src/main.tsx
Normal file
19
packages/website/src/main.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Route, Router, Switch } from 'wouter'
|
||||
import { useHashLocation } from 'wouter/use-hash-location'
|
||||
|
||||
import './i18n/config'
|
||||
import './i18n/types'
|
||||
import { default as PagesRouter } from './router.tsx'
|
||||
import { default as TestPagesRouter } from './test-pages/router.tsx'
|
||||
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<Router hook={useHashLocation}>
|
||||
<Switch>
|
||||
<Route path="/test-pages" component={TestPagesRouter} nest />
|
||||
<Route path="/" component={PagesRouter} nest />
|
||||
</Switch>
|
||||
</Router>
|
||||
)
|
||||
474
packages/website/src/page.tsx
Normal file
474
packages/website/src/page.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
/* eslint-disable react-dom/no-dangerously-set-innerhtml */
|
||||
import { PageAgent } from 'page-agent'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, useSearchParams } from 'wouter'
|
||||
|
||||
import Footer from './components/Footer'
|
||||
import Header from './components/Header'
|
||||
|
||||
const injection = encodeURI(
|
||||
"javascript:(function(){var s=document.createElement('script');s.src=`https://hwcxiuzfylggtcktqgij.supabase.co/storage/v1/object/public/demo-public/v0.0.4/page-agent.js?t=${Math.random()}`;s.setAttribute('crossorigin', true);s.type=`text/javascript`;s.onload=()=>console.log('PageAgent script loaded!');document.body.appendChild(s);})();"
|
||||
)
|
||||
|
||||
const injectionA = `
|
||||
<a
|
||||
href=${injection}
|
||||
class="inline-flex items-center text-xs px-3 py-2 bg-blue-500 text-white font-medium rounded-lg hover:shadow-md transform hover:scale-105 transition-all duration-200 cursor-move border-2 border-dashed border-green-300"
|
||||
draggable="true"
|
||||
onclick="return false;"
|
||||
title="Drag me to your bookmarks bar!"
|
||||
>
|
||||
✨PageAgent
|
||||
</a>
|
||||
|
||||
`
|
||||
|
||||
export default function HomePage() {
|
||||
const { t, i18n } = useTranslation(['home', 'common'])
|
||||
const [task, setTask] = useState(() => t('home:hero.default_task'))
|
||||
|
||||
// Update task when language changes
|
||||
const defaultTask = t('home:hero.default_task')
|
||||
useEffect(() => {
|
||||
setTask(defaultTask)
|
||||
}, [defaultTask])
|
||||
|
||||
const [params, setParams] = useSearchParams()
|
||||
const isOther = params.has('try_other')
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'try' | 'other'>(isOther ? 'other' : 'try')
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!task.trim()) return
|
||||
|
||||
let pageAgent: PageAgent
|
||||
const win = window as any
|
||||
|
||||
if (win.pageAgent && !win.pageAgent.disposed) {
|
||||
pageAgent = win.pageAgent
|
||||
} else {
|
||||
pageAgent = new PageAgent({
|
||||
// 把 react 根元素排除掉,挂了很多冒泡时间导致假阳
|
||||
interactiveBlacklist: [document.getElementById('root')!],
|
||||
language: i18n.language as any,
|
||||
|
||||
// experimentalScriptExecutionTool: true,
|
||||
|
||||
// testing server
|
||||
// @note: rate limit. prompt limit.
|
||||
// model: DEMO_MODEL,
|
||||
// baseURL: DEMO_BASE_URL,
|
||||
// apiKey: DEMO_API_KEY,
|
||||
})
|
||||
win.pageAgent = pageAgent
|
||||
}
|
||||
|
||||
const result = await pageAgent.execute(task)
|
||||
|
||||
console.log(result)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-linear-to-br from-blue-50 to-purple-50 dark:from-gray-900 dark:to-gray-800">
|
||||
<Header />
|
||||
|
||||
{/* Hero Section */}
|
||||
<main id="main-content">
|
||||
<section className="relative px-6 py-22 lg:py-28" aria-labelledby="hero-heading">
|
||||
<div className="max-w-7xl mx-auto text-center">
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 opacity-30" aria-hidden="true">
|
||||
<div className="absolute inset-0 bg-linear-to-r from-blue-400/20 to-purple-400/20 rounded-3xl transform rotate-1"></div>
|
||||
<div className="absolute inset-0 bg-linear-to-l from-purple-400/20 to-blue-400/20 rounded-3xl transform -rotate-1"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="inline-flex items-center px-4 py-2 mb-8 text-sm font-medium text-blue-700 bg-blue-100 rounded-full dark:text-blue-300 dark:bg-blue-900/30">
|
||||
<span
|
||||
className="w-2 h-2 bg-blue-500 rounded-full mr-2 animate-pulse"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{t('home:hero.badge')}
|
||||
</div>
|
||||
|
||||
<h1
|
||||
id="hero-heading"
|
||||
className="text-5xl lg:text-7xl font-bold mb-8 bg-linear-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent pb-1"
|
||||
>
|
||||
{t('home:hero.title_line1')}
|
||||
<br />
|
||||
{t('home:hero.title_line2')}
|
||||
</h1>
|
||||
|
||||
<p className="text-xl lg:text-2xl text-gray-600 dark:text-gray-300 mb-12 max-w-4xl mx-auto leading-relaxed">
|
||||
<span className="bg-linear-to-r from-blue-500 to-purple-500 bg-clip-text text-transparent font-bold">
|
||||
{t('home:hero.subtitle_emoji')}
|
||||
</span>
|
||||
{t('home:hero.subtitle_main')}
|
||||
<br />
|
||||
{t('home:hero.subtitle_detail')}
|
||||
</p>
|
||||
|
||||
{/* Try It Now Section - Tab Card */}
|
||||
<div className="mt-8 mb-6">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Tab Headers */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setActiveTab('try')}
|
||||
className={`flex-1 px-4 py-4 text-lg font-medium transition-colors duration-200 ${
|
||||
activeTab === 'try'
|
||||
? 'bg-linear-to-r from-blue-50 to-purple-50 dark:from-blue-900/30 dark:to-purple-900/30 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{t('home:hero.tab_try')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('other')}
|
||||
className={`flex-1 px-4 py-4 text-lg font-medium transition-colors duration-200 ${
|
||||
activeTab === 'other'
|
||||
? 'bg-linear-to-r from-green-50 to-blue-50 dark:from-green-900/30 dark:to-blue-900/30 text-green-700 dark:text-green-300 border-b-2 border-green-500'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{t('home:hero.tab_other')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-4">
|
||||
{activeTab === 'try' && (
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
value={task}
|
||||
onChange={(e) => setTask(e.target.value)}
|
||||
placeholder={t('home:hero.input_placeholder')}
|
||||
className="w-full px-4 py-3 pr-20 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none text-sm mb-0"
|
||||
data-page-agent-not-interactive
|
||||
/>
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
// disabled
|
||||
// disabled={!task.trim()}
|
||||
className="absolute right-2 top-2 px-5 py-1.5 bg-linear-to-r from-blue-600 to-purple-600 text-white font-medium rounded-md hover:shadow-md transform hover:scale-105 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none text-sm"
|
||||
data-page-agent-not-interactive
|
||||
>
|
||||
{t('home:hero.execute_button')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'other' && (
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* 左侧:操作步骤 */}
|
||||
<div className="space-y-4">
|
||||
{/* Keyboard Shortcut Hint */}
|
||||
<div className="bg-blue-50 dark:bg-gray-700 p-4 rounded-lg">
|
||||
<p className="text-gray-700 dark:text-gray-300 text-sm mb-3">
|
||||
<span className="font-semibold">
|
||||
{t('home:try_other.step1_title')}
|
||||
</span>{' '}
|
||||
{t('home:try_other.step1_content')}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<kbd className="px-2 py-1 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded text-xs font-mono">
|
||||
Ctrl + Shift + B
|
||||
</kbd>
|
||||
<span className="text-gray-500 dark:text-gray-400">或</span>
|
||||
<kbd className="px-2 py-1 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded text-xs font-mono">
|
||||
⌘ + Shift + B
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Draggable Bookmarklet */}
|
||||
<div className="bg-green-50 dark:bg-gray-700 p-4 rounded-lg">
|
||||
<p className="text-gray-700 dark:text-gray-300 text-sm mb-3">
|
||||
<span className="font-semibold">
|
||||
{t('home:try_other.step2_title')}
|
||||
</span>{' '}
|
||||
{t('home:try_other.step2_content')}
|
||||
</p>
|
||||
<div
|
||||
className="flex items-center justify-center gap-2 text-gray-500 dark:text-gray-400"
|
||||
dangerouslySetInnerHTML={{ __html: injectionA }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{/* Usage Instructions */}
|
||||
<div className="bg-purple-50 dark:bg-gray-700 p-4 rounded-lg">
|
||||
<p className="text-gray-700 dark:text-gray-300 text-sm">
|
||||
<span className="font-semibold">
|
||||
{t('home:try_other.step3_title')}
|
||||
</span>{' '}
|
||||
{t('home:try_other.step3_content')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:注意事项 */}
|
||||
<div className="bg-yellow-50 dark:bg-gray-700 p-4 rounded-lg">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white mb-3 text-sm">
|
||||
{t('home:try_other.notice_title')}
|
||||
</h4>
|
||||
<ul className="space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<li className="flex items-start text-left">
|
||||
<span className="w-1.5 h-1.5 bg-yellow-500 rounded-full mt-2 mr-2 shrink-0 "></span>
|
||||
{t('home:try_other.notice_items.item1')}
|
||||
</li>
|
||||
<li className="flex items-start text-left">
|
||||
<span className="w-1.5 h-1.5 bg-yellow-500 rounded-full mt-2 mr-2 shrink-0 "></span>
|
||||
{t('home:try_other.notice_items.item2')}
|
||||
</li>
|
||||
<li className="flex items-start text-left">
|
||||
<span className="w-1.5 h-1.5 bg-yellow-500 rounded-full mt-2 mr-2 shrink-0 "></span>
|
||||
{t('home:try_other.notice_items.item3')}
|
||||
</li>
|
||||
<li className="flex items-start text-left">
|
||||
<span className="w-1.5 h-1.5 bg-yellow-500 rounded-full mt-2 mr-2 shrink-0 "></span>
|
||||
{t('home:try_other.notice_items.item4')}
|
||||
</li>
|
||||
<li className="flex items-start text-left">
|
||||
<span className="w-1.5 h-1.5 bg-yellow-500 rounded-full mt-2 mr-2 shrink-0 "></span>
|
||||
{t('home:try_other.notice_items.item5')}
|
||||
</li>
|
||||
<li className="flex items-start text-left">
|
||||
<span className="w-1.5 h-1.5 bg-yellow-500 rounded-full mt-2 mr-2 shrink-0 "></span>
|
||||
{t('home:try_other.notice_items.item6_prefix')}{' '}
|
||||
<Link
|
||||
href="/docs/introduction/limitations"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{t('home:try_other.notice_items.item6_link')}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
className="flex flex-wrap justify-center gap-6 text-sm text-gray-500 dark:text-gray-400"
|
||||
role="list"
|
||||
>
|
||||
<li className="flex items-center">
|
||||
<span
|
||||
className="w-2 h-2 bg-green-500 rounded-full mr-2"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{t('home:benefits.no_backend')}
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span
|
||||
className="w-2 h-2 bg-green-500 rounded-full mr-2"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{t('home:benefits.private_model')}
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span
|
||||
className="w-2 h-2 bg-green-500 rounded-full mr-2"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{t('home:benefits.data_masking')}
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span
|
||||
className="w-2 h-2 bg-green-500 rounded-full mr-2"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{t('home:benefits.open_source')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section
|
||||
className="px-6 py-20 bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm"
|
||||
aria-labelledby="features-heading"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8" role="list">
|
||||
{/* Feature 1 */}
|
||||
<article
|
||||
className="group p-8 bg-linear-to-br from-blue-100 to-purple-100 dark:from-gray-700 dark:to-gray-800 rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 border border-gray-100 dark:border-gray-700"
|
||||
role="listitem"
|
||||
>
|
||||
<div
|
||||
className="w-12 h-12 bg-linear-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="text-white text-xl">📦</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">
|
||||
{t('home:features.in_page.title')}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{t('home:features.in_page.desc')}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
{/* Feature 2 */}
|
||||
<article
|
||||
className="group p-8 bg-linear-to-br from-purple-100 to-pink-100 dark:from-gray-700 dark:to-gray-800 rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 border border-gray-100 dark:border-gray-700"
|
||||
role="listitem"
|
||||
>
|
||||
<div
|
||||
className="w-12 h-12 bg-linear-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="text-white text-xl">⚡</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">
|
||||
{t('home:features.zero_backend.title')}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{t('home:features.zero_backend.desc')}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
{/* Feature 3 */}
|
||||
<article
|
||||
className="group p-8 bg-linear-to-br from-orange-100 to-red-100 dark:from-gray-700 dark:to-gray-800 rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 border border-gray-100 dark:border-gray-700"
|
||||
role="listitem"
|
||||
>
|
||||
<div
|
||||
className="w-12 h-12 bg-linear-to-br from-purple-500 to-purple-600 rounded-xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="text-white text-xl">🌈</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">
|
||||
{t('home:features.accessible.title')}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{t('home:features.accessible.desc')}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
{/* Feature 4 */}
|
||||
<article
|
||||
className="group p-8 bg-linear-to-br from-green-100 to-blue-100 dark:from-gray-700 dark:to-gray-800 rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 border border-gray-100 dark:border-gray-700"
|
||||
role="listitem"
|
||||
>
|
||||
<div
|
||||
className="w-12 h-12 bg-linear-to-br from-orange-500 to-orange-600 rounded-xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="text-white text-xl">🔒</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">
|
||||
{t('home:features.secure_integration.title')}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{t('home:features.secure_integration.desc')}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Use Cases Section */}
|
||||
<section className="px-6 py-20" aria-labelledby="use-cases-heading">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h2
|
||||
id="use-cases-heading"
|
||||
className="text-4xl lg:text-5xl font-bold mb-6 bg-linear-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent"
|
||||
>
|
||||
{t('home:use_cases.section_title')}
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto">
|
||||
{t('home:use_cases.section_subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-12" role="list">
|
||||
{/* Use Case 1 */}
|
||||
<div className="bg-linear-to-br from-blue-100 to-purple-100 dark:from-gray-700 dark:to-gray-800 p-8 rounded-2xl">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center shrink-0">
|
||||
<span className="text-white font-bold">1</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-white">
|
||||
{t('home:use_cases.case1.title')}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{t('home:use_cases.case1.desc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Use Case 2 */}
|
||||
<div className="bg-linear-to-br from-green-100 to-blue-100 dark:from-gray-700 dark:to-gray-800 p-8 rounded-2xl">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="w-10 h-10 bg-green-500 rounded-lg flex items-center justify-center shrink-0">
|
||||
<span className="text-white font-bold">2</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-white">
|
||||
{t('home:use_cases.case2.title')}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{t('home:use_cases.case2.desc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Use Case 3 */}
|
||||
<div className="bg-linear-to-br from-purple-100 to-pink-100 dark:from-gray-700 dark:to-gray-800 p-8 rounded-2xl">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="w-10 h-10 bg-purple-500 rounded-lg flex items-center justify-center shrink-0">
|
||||
<span className="text-white font-bold">3</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-white">
|
||||
{t('home:use_cases.case3.title')}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{t('home:use_cases.case3.desc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Use Case 4 */}
|
||||
<div className="bg-linear-to-br from-orange-100 to-red-100 dark:from-gray-700 dark:to-gray-800 p-8 rounded-2xl">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="w-10 h-10 bg-orange-500 rounded-lg flex items-center justify-center shrink-0">
|
||||
<span className="text-white font-bold">4</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-white">
|
||||
{t('home:use_cases.case4.title')}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{t('home:use_cases.case4.desc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
148
packages/website/src/router.tsx
Normal file
148
packages/website/src/router.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Route, Switch } from 'wouter'
|
||||
|
||||
import DocsLayout from './components/DocsLayout'
|
||||
import Header from './components/Header'
|
||||
// Features pages
|
||||
import CustomTools from './docs/features/custom-tools/page'
|
||||
import DataMasking from './docs/features/data-masking/page'
|
||||
import KnowledgeInjection from './docs/features/knowledge-injection/page'
|
||||
import ModelIntegration from './docs/features/model-integration/page'
|
||||
import SecurityPermissions from './docs/features/security-permissions/page'
|
||||
import BestPractices from './docs/integration/best-practices/page'
|
||||
// Integration pages
|
||||
import CdnSetup from './docs/integration/cdn-setup/page'
|
||||
import Configuration from './docs/integration/configuration/page'
|
||||
import ThirdPartyAgent from './docs/integration/third-party-agent/page'
|
||||
import Limitations from './docs/introduction/limitations/page'
|
||||
// Introduction pages
|
||||
import Overview from './docs/introduction/overview/page'
|
||||
import QuickStart from './docs/introduction/quick-start/page'
|
||||
import HomePage from './page'
|
||||
|
||||
export default function Router() {
|
||||
return (
|
||||
<Switch>
|
||||
{/* Home page */}
|
||||
<Route path="/" component={HomePage} />
|
||||
|
||||
{/* Documentation pages with layout */}
|
||||
<Route path="/docs/introduction/overview">
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
||||
<Header />
|
||||
<DocsLayout>
|
||||
<Overview />
|
||||
</DocsLayout>
|
||||
</div>
|
||||
</Route>
|
||||
|
||||
<Route path="/docs/introduction/quick-start">
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
||||
<Header />
|
||||
<DocsLayout>
|
||||
<QuickStart />
|
||||
</DocsLayout>
|
||||
</div>
|
||||
</Route>
|
||||
|
||||
<Route path="/docs/introduction/limitations">
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
||||
<Header />
|
||||
<DocsLayout>
|
||||
<Limitations />
|
||||
</DocsLayout>
|
||||
</div>
|
||||
</Route>
|
||||
|
||||
<Route path="/docs/features/security-permissions">
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
||||
<Header />
|
||||
<DocsLayout>
|
||||
<SecurityPermissions />
|
||||
</DocsLayout>
|
||||
</div>
|
||||
</Route>
|
||||
|
||||
<Route path="/docs/features/custom-tools">
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
||||
<Header />
|
||||
<DocsLayout>
|
||||
<CustomTools />
|
||||
</DocsLayout>
|
||||
</div>
|
||||
</Route>
|
||||
|
||||
<Route path="/docs/features/data-masking">
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
||||
<Header />
|
||||
<DocsLayout>
|
||||
<DataMasking />
|
||||
</DocsLayout>
|
||||
</div>
|
||||
</Route>
|
||||
|
||||
<Route path="/docs/features/knowledge-injection">
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
||||
<Header />
|
||||
<DocsLayout>
|
||||
<KnowledgeInjection />
|
||||
</DocsLayout>
|
||||
</div>
|
||||
</Route>
|
||||
|
||||
<Route path="/docs/features/model-integration">
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
||||
<Header />
|
||||
<DocsLayout>
|
||||
<ModelIntegration />
|
||||
</DocsLayout>
|
||||
</div>
|
||||
</Route>
|
||||
|
||||
<Route path="/docs/integration/cdn-setup">
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
||||
<Header />
|
||||
<DocsLayout>
|
||||
<CdnSetup />
|
||||
</DocsLayout>
|
||||
</div>
|
||||
</Route>
|
||||
|
||||
<Route path="/docs/integration/configuration">
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
||||
<Header />
|
||||
<DocsLayout>
|
||||
<Configuration />
|
||||
</DocsLayout>
|
||||
</div>
|
||||
</Route>
|
||||
|
||||
<Route path="/docs/integration/best-practices">
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
||||
<Header />
|
||||
<DocsLayout>
|
||||
<BestPractices />
|
||||
</DocsLayout>
|
||||
</div>
|
||||
</Route>
|
||||
|
||||
<Route path="/docs/integration/third-party-agent">
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
||||
<Header />
|
||||
<DocsLayout>
|
||||
<ThirdPartyAgent />
|
||||
</DocsLayout>
|
||||
</div>
|
||||
</Route>
|
||||
|
||||
{/* 404 page */}
|
||||
<Route>
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold mb-4 text-gray-900 dark:text-white">404</h1>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300">页面未找到</p>
|
||||
</div>
|
||||
</div>
|
||||
</Route>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
209
packages/website/src/test-pages/README.md
Normal file
209
packages/website/src/test-pages/README.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# Page Use Agent 测试页面
|
||||
|
||||
这个目录包含了一系列专门设计的测试页面,用于验证 Page Use Agent 的各种能力。每个页面都模拟了真实 Web 应用中的常见交互模式和边界情况。
|
||||
|
||||
## 测试页面列表
|
||||
|
||||
### 1. 表单测试页面 (`/test-pages/form`)
|
||||
**测试目标:** 表单填写、验证和提交能力
|
||||
|
||||
**包含功能:**
|
||||
- 各种输入类型(文本、邮箱、密码、数字、日期、电话、URL)
|
||||
- 下拉选择框和复选框
|
||||
- 实时表单验证
|
||||
- 异步提交和错误处理
|
||||
- 重置表单功能
|
||||
|
||||
**测试任务示例:**
|
||||
- 填写完整的用户注册表单并提交
|
||||
- 故意输入错误信息触发验证错误
|
||||
- 测试密码确认功能
|
||||
- 尝试提交空表单查看错误提示
|
||||
|
||||
### 2. 导航测试页面 (`/test-pages/navigation`)
|
||||
**测试目标:** 复杂导航和交互元素处理
|
||||
|
||||
**包含功能:**
|
||||
- 顶部导航栏和下拉菜单
|
||||
- 面包屑导航
|
||||
- 标签页切换
|
||||
- 模态框弹窗
|
||||
- 通知系统
|
||||
- 用户菜单
|
||||
|
||||
**测试任务示例:**
|
||||
- 点击产品下拉菜单选择不同选项
|
||||
- 切换不同的标签页查看内容
|
||||
- 打开和关闭模态框
|
||||
- 点击面包屑导航
|
||||
- 添加新通知并标记为已读
|
||||
|
||||
### 3. 列表测试页面 (`/test-pages/list`)
|
||||
**测试目标:** 列表操作、搜索、过滤和分页
|
||||
|
||||
**包含功能:**
|
||||
- 产品列表展示(网格和列表视图)
|
||||
- 搜索功能
|
||||
- 类别过滤
|
||||
- 排序功能
|
||||
- 分页导航
|
||||
- 加载状态和骨架屏
|
||||
|
||||
**测试任务示例:**
|
||||
- 搜索特定产品名称
|
||||
- 按价格排序产品列表
|
||||
- 切换网格和列表视图
|
||||
- 使用分页浏览不同页面
|
||||
- 按类别过滤产品
|
||||
|
||||
### 4. 复杂交互测试页面 (`/test-pages/complex`)
|
||||
**测试目标:** 多步骤流程和状态管理
|
||||
|
||||
**包含功能:**
|
||||
- 购物车管理(添加、删除、修改数量)
|
||||
- 多步骤向导流程
|
||||
- 步骤验证和导航
|
||||
- 订单确认流程
|
||||
- 异步提交处理
|
||||
|
||||
**测试任务示例:**
|
||||
- 完成完整的购买流程
|
||||
- 在向导中前进和后退
|
||||
- 修改购物车中的商品数量
|
||||
- 添加新商品到购物车
|
||||
- 提交订单并处理可能的错误
|
||||
|
||||
### 5. 错误处理测试页面 (`/test-pages/errors`)
|
||||
**测试目标:** 错误识别和重试机制
|
||||
|
||||
**包含功能:**
|
||||
- 网络连接错误模拟
|
||||
- 表单验证错误
|
||||
- 权限不足错误
|
||||
- 请求超时错误
|
||||
- 服务器内部错误
|
||||
- 文件上传错误处理
|
||||
|
||||
**测试任务示例:**
|
||||
- 触发网络错误并重试
|
||||
- 提交不完整表单查看验证错误
|
||||
- 测试权限验证(用户名需为"admin")
|
||||
- 上传超大文件触发错误
|
||||
- 处理各种错误场景的重试逻辑
|
||||
|
||||
### 6. 异步操作测试页面 (`/test-pages/async`)
|
||||
**测试目标:** 等待和异步操作处理
|
||||
|
||||
**包含功能:**
|
||||
- 文件上传进度条
|
||||
- 实时数据更新
|
||||
- 数据加载骨架屏
|
||||
- 长时间运行任务
|
||||
- 进度跟踪和日志显示
|
||||
|
||||
**测试任务示例:**
|
||||
- 启动文件上传并等待完成
|
||||
- 开启实时数据更新功能
|
||||
- 加载数据并等待所有项目完成
|
||||
- 执行长时间任务并监控进度
|
||||
- 处理上传失败的重试
|
||||
|
||||
## 测试任务集合
|
||||
|
||||
### 基础操作测试
|
||||
1. **导航测试**
|
||||
- 前往表单测试页面
|
||||
- 返回测试页面首页
|
||||
- 前往导航测试页面
|
||||
|
||||
2. **表单填写测试**
|
||||
- 填写用户注册表单的所有必填字段
|
||||
- 提交表单并等待结果
|
||||
- 重置表单并重新填写
|
||||
|
||||
3. **搜索和过滤测试**
|
||||
- 在列表页面搜索"Apple"
|
||||
- 按价格降序排列产品
|
||||
- 过滤显示"手机"类别的产品
|
||||
|
||||
### 中级交互测试
|
||||
4. **购物流程测试**
|
||||
- 前往复杂交互页面
|
||||
- 添加商品到购物车
|
||||
- 完成多步骤购买流程
|
||||
- 填写个人信息、地址和支付信息
|
||||
- 提交订单
|
||||
|
||||
5. **导航和菜单测试**
|
||||
- 点击产品下拉菜单选择"手机"
|
||||
- 切换到"订单管理"标签页
|
||||
- 打开模态框并关闭
|
||||
- 添加新的面包屑导航
|
||||
|
||||
6. **异步操作测试**
|
||||
- 启动文件上传
|
||||
- 开启实时数据更新
|
||||
- 执行长时间任务并等待完成
|
||||
|
||||
### 高级错误处理测试
|
||||
7. **错误恢复测试**
|
||||
- 触发网络连接错误
|
||||
- 重试失败的操作
|
||||
- 处理表单验证错误
|
||||
- 测试权限验证(用户名输入"admin")
|
||||
|
||||
8. **边界情况测试**
|
||||
- 提交空表单查看错误
|
||||
- 上传不支持的文件类型
|
||||
- 在向导中跳过必填步骤
|
||||
- 处理超时错误
|
||||
|
||||
### 综合场景测试
|
||||
9. **完整用户流程**
|
||||
- 浏览产品列表
|
||||
- 搜索并过滤产品
|
||||
- 添加产品到购物车
|
||||
- 完成购买流程
|
||||
- 处理可能出现的错误
|
||||
|
||||
10. **压力和边界测试**
|
||||
- 快速连续点击按钮
|
||||
- 在加载过程中尝试其他操作
|
||||
- 测试各种错误恢复场景
|
||||
- 验证所有异步操作的完成
|
||||
|
||||
## 使用说明
|
||||
|
||||
### 对于 Agent 开发者
|
||||
- 每个页面都包含了详细的状态指示器和反馈信息
|
||||
- 错误信息清晰明确,便于 Agent 理解和处理
|
||||
- 异步操作都有明确的完成标志
|
||||
- 所有交互元素都有适当的可访问性标记
|
||||
|
||||
### 对于测试人员
|
||||
- 可以按照测试任务逐一验证 Agent 的能力
|
||||
- 每个页面都是独立的,可以单独测试
|
||||
- 包含了各种真实场景的模拟
|
||||
- 错误场景是随机的,确保测试的真实性
|
||||
|
||||
### 技术特性
|
||||
- 使用 React + TypeScript 构建
|
||||
- 响应式设计,支持不同屏幕尺寸
|
||||
- 深色模式支持
|
||||
- 无需外部依赖,完全自包含
|
||||
- 模拟真实的网络延迟和错误
|
||||
|
||||
## 扩展建议
|
||||
|
||||
如需添加新的测试场景,建议考虑以下方面:
|
||||
- 特定行业的业务流程
|
||||
- 更复杂的数据可视化交互
|
||||
- 多媒体内容处理
|
||||
- 实时协作功能
|
||||
- 移动端特有的交互模式
|
||||
|
||||
每个新页面都应该:
|
||||
- 有明确的测试目标
|
||||
- 包含多种难度级别的任务
|
||||
- 提供清晰的状态反馈
|
||||
- 模拟真实的用户场景
|
||||
543
packages/website/src/test-pages/async-test.tsx
Normal file
543
packages/website/src/test-pages/async-test.tsx
Normal file
@@ -0,0 +1,543 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'wouter'
|
||||
|
||||
interface UploadProgress {
|
||||
id: string
|
||||
name: string
|
||||
progress: number
|
||||
status: 'uploading' | 'completed' | 'error'
|
||||
speed: string
|
||||
timeRemaining: string
|
||||
}
|
||||
|
||||
interface DataItem {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
timestamp: string
|
||||
status: 'loading' | 'loaded' | 'error'
|
||||
}
|
||||
|
||||
export default function AsyncTestPage() {
|
||||
const [uploads, setUploads] = useState<UploadProgress[]>([])
|
||||
const [dataItems, setDataItems] = useState<DataItem[]>([])
|
||||
const [isLoadingData, setIsLoadingData] = useState(false)
|
||||
const [realTimeData, setRealTimeData] = useState<string[]>([])
|
||||
const [isRealTimeActive, setIsRealTimeActive] = useState(false)
|
||||
const [longRunningTask, setLongRunningTask] = useState<{
|
||||
isRunning: boolean
|
||||
progress: number
|
||||
currentStep: string
|
||||
logs: string[]
|
||||
}>({
|
||||
isRunning: false,
|
||||
progress: 0,
|
||||
currentStep: '',
|
||||
logs: [],
|
||||
})
|
||||
|
||||
// 模拟实时数据更新
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout
|
||||
if (isRealTimeActive) {
|
||||
interval = setInterval(() => {
|
||||
const newData = `数据更新 ${new Date().toLocaleTimeString()}: ${Math.floor(Math.random() * 1000)}`
|
||||
setRealTimeData((prev) => [newData, ...prev.slice(0, 9)]) // 保持最新10条
|
||||
}, 2000)
|
||||
}
|
||||
return () => {
|
||||
if (interval) clearInterval(interval)
|
||||
}
|
||||
}, [isRealTimeActive])
|
||||
|
||||
// 模拟文件上传
|
||||
const simulateFileUpload = (fileName: string) => {
|
||||
const uploadId = Date.now().toString()
|
||||
const newUpload: UploadProgress = {
|
||||
id: uploadId,
|
||||
name: fileName,
|
||||
progress: 0,
|
||||
status: 'uploading',
|
||||
speed: '0 KB/s',
|
||||
timeRemaining: '计算中...',
|
||||
}
|
||||
|
||||
setUploads((prev) => [...prev, newUpload])
|
||||
|
||||
// 模拟上传进度
|
||||
const interval = setInterval(() => {
|
||||
setUploads((prev) =>
|
||||
prev.map((upload) => {
|
||||
if (upload.id === uploadId) {
|
||||
const newProgress = Math.min(upload.progress + Math.random() * 15, 100)
|
||||
const speed = `${(Math.random() * 500 + 100).toFixed(0)} KB/s`
|
||||
const timeRemaining =
|
||||
newProgress >= 100 ? '完成' : `${Math.ceil((100 - newProgress) / 10)}秒`
|
||||
|
||||
// 模拟随机失败
|
||||
if (newProgress > 50 && Math.random() < 0.1) {
|
||||
clearInterval(interval)
|
||||
return {
|
||||
...upload,
|
||||
status: 'error' as const,
|
||||
speed: '0 KB/s',
|
||||
timeRemaining: '失败',
|
||||
}
|
||||
}
|
||||
|
||||
if (newProgress >= 100) {
|
||||
clearInterval(interval)
|
||||
return {
|
||||
...upload,
|
||||
progress: 100,
|
||||
status: 'completed' as const,
|
||||
speed,
|
||||
timeRemaining,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...upload,
|
||||
progress: newProgress,
|
||||
speed,
|
||||
timeRemaining,
|
||||
}
|
||||
}
|
||||
return upload
|
||||
})
|
||||
)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 模拟数据加载
|
||||
const loadData = async () => {
|
||||
setIsLoadingData(true)
|
||||
setDataItems([])
|
||||
|
||||
// 创建骨架屏数据
|
||||
const skeletonItems: DataItem[] = Array.from({ length: 6 }, (_, i) => ({
|
||||
id: i,
|
||||
title: '',
|
||||
content: '',
|
||||
timestamp: '',
|
||||
status: 'loading',
|
||||
}))
|
||||
setDataItems(skeletonItems)
|
||||
|
||||
// 逐个加载数据项
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 800 + Math.random() * 1000))
|
||||
|
||||
setDataItems((prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.id === i) {
|
||||
// 模拟随机加载失败
|
||||
if (Math.random() < 0.15) {
|
||||
return {
|
||||
...item,
|
||||
status: 'error',
|
||||
title: '加载失败',
|
||||
content: '数据加载失败,请重试',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
status: 'loaded',
|
||||
title: `数据项 ${i + 1}`,
|
||||
content: `这是第 ${i + 1} 个数据项的内容,包含了一些示例文本用于展示加载效果。`,
|
||||
timestamp: new Date().toLocaleString(),
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
setIsLoadingData(false)
|
||||
}
|
||||
|
||||
// 模拟长时间运行的任务
|
||||
const startLongRunningTask = async () => {
|
||||
setLongRunningTask({
|
||||
isRunning: true,
|
||||
progress: 0,
|
||||
currentStep: '初始化任务...',
|
||||
logs: ['任务开始'],
|
||||
})
|
||||
|
||||
const steps = [
|
||||
{ name: '初始化任务...', duration: 2000 },
|
||||
{ name: '连接服务器...', duration: 1500 },
|
||||
{ name: '验证权限...', duration: 1000 },
|
||||
{ name: '下载数据...', duration: 3000 },
|
||||
{ name: '处理数据...', duration: 2500 },
|
||||
{ name: '生成报告...', duration: 2000 },
|
||||
{ name: '保存结果...', duration: 1000 },
|
||||
{ name: '清理资源...', duration: 500 },
|
||||
]
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i]
|
||||
|
||||
setLongRunningTask((prev) => ({
|
||||
...prev,
|
||||
currentStep: step.name,
|
||||
logs: [...prev.logs, `开始: ${step.name}`],
|
||||
}))
|
||||
|
||||
// 模拟步骤执行时间
|
||||
const startTime = Date.now()
|
||||
while (Date.now() - startTime < step.duration) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
const elapsed = Date.now() - startTime
|
||||
const stepProgress = Math.min((elapsed / step.duration) * 100, 100)
|
||||
const totalProgress = ((i + stepProgress / 100) / steps.length) * 100
|
||||
|
||||
setLongRunningTask((prev) => ({
|
||||
...prev,
|
||||
progress: totalProgress,
|
||||
}))
|
||||
}
|
||||
|
||||
setLongRunningTask((prev) => ({
|
||||
...prev,
|
||||
logs: [...prev.logs, `完成: ${step.name}`],
|
||||
}))
|
||||
|
||||
// 模拟随机失败
|
||||
if (i === 3 && Math.random() < 0.2) {
|
||||
setLongRunningTask((prev) => ({
|
||||
...prev,
|
||||
isRunning: false,
|
||||
currentStep: '任务失败',
|
||||
logs: [...prev.logs, '错误: 数据下载失败,请重试'],
|
||||
}))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setLongRunningTask((prev) => ({
|
||||
...prev,
|
||||
isRunning: false,
|
||||
progress: 100,
|
||||
currentStep: '任务完成',
|
||||
logs: [...prev.logs, '任务成功完成!'],
|
||||
}))
|
||||
}
|
||||
|
||||
const clearUploads = () => {
|
||||
setUploads([])
|
||||
}
|
||||
|
||||
const retryFailedUpload = (uploadId: string) => {
|
||||
const failedUpload = uploads.find((u) => u.id === uploadId)
|
||||
if (failedUpload) {
|
||||
setUploads((prev) => prev.filter((u) => u.id !== uploadId))
|
||||
simulateFileUpload(failedUpload.name)
|
||||
}
|
||||
}
|
||||
|
||||
const retryDataLoad = (itemId: number) => {
|
||||
setDataItems((prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.id === itemId) {
|
||||
return { ...item, status: 'loading', title: '', content: '', timestamp: '' }
|
||||
}
|
||||
return item
|
||||
})
|
||||
)
|
||||
|
||||
setTimeout(() => {
|
||||
setDataItems((prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.id === itemId) {
|
||||
return {
|
||||
...item,
|
||||
status: 'loaded',
|
||||
title: `数据项 ${itemId + 1}`,
|
||||
content: `这是重新加载的第 ${itemId + 1} 个数据项的内容。`,
|
||||
timestamp: new Date().toLocaleString(),
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">异步操作测试</h1>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
测试等待、加载状态识别和异步操作处理能力
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* 文件上传进度 */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
文件上传进度
|
||||
</h2>
|
||||
<div className="space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => simulateFileUpload(`文件_${Date.now()}.pdf`)}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors text-sm"
|
||||
>
|
||||
开始上传
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearUploads}
|
||||
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-md transition-colors text-sm"
|
||||
>
|
||||
清空列表
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{uploads.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
点击"开始上传"来模拟文件上传
|
||||
</div>
|
||||
) : (
|
||||
uploads.map((upload) => (
|
||||
<div
|
||||
key={upload.id}
|
||||
className="border border-gray-200 dark:border-gray-600 rounded-lg p-4"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{upload.name}
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
upload.status === 'completed'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: upload.status === 'error'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-blue-600 dark:text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{upload.status === 'completed'
|
||||
? '✓ 完成'
|
||||
: upload.status === 'error'
|
||||
? '✗ 失败'
|
||||
: '上传中...'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${
|
||||
upload.status === 'completed'
|
||||
? 'bg-green-500'
|
||||
: upload.status === 'error'
|
||||
? 'bg-red-500'
|
||||
: 'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${upload.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-300">
|
||||
<span>{upload.progress.toFixed(1)}%</span>
|
||||
<span>{upload.speed}</span>
|
||||
<span>{upload.timeRemaining}</span>
|
||||
</div>
|
||||
|
||||
{upload.status === 'error' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => retryFailedUpload(upload.id)}
|
||||
className="mt-2 px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-sm transition-colors"
|
||||
>
|
||||
重试上传
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 实时数据更新 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
实时数据更新
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsRealTimeActive(!isRealTimeActive)}
|
||||
className={`px-4 py-2 rounded-md transition-colors text-sm ${
|
||||
isRealTimeActive
|
||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: 'bg-green-600 hover:bg-green-700 text-white'
|
||||
}`}
|
||||
>
|
||||
{isRealTimeActive ? '停止更新' : '开始更新'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{realTimeData.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
点击"开始更新"来查看实时数据
|
||||
</div>
|
||||
) : (
|
||||
realTimeData.map((data) => (
|
||||
<div
|
||||
key={data}
|
||||
className={`p-3 rounded-lg border transition-all duration-300 ${
|
||||
data === realTimeData[0]
|
||||
? 'bg-blue-50 dark:bg-blue-900 border-blue-200 dark:border-blue-700'
|
||||
: 'bg-gray-50 dark:bg-gray-700 border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm text-gray-900 dark:text-white">{data}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数据加载和长时间任务 */}
|
||||
<div className="space-y-6">
|
||||
{/* 数据加载骨架屏 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
数据加载测试
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadData}
|
||||
disabled={isLoadingData}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-md transition-colors text-sm"
|
||||
>
|
||||
{isLoadingData ? '加载中...' : '加载数据'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{dataItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="border border-gray-200 dark:border-gray-600 rounded-lg p-4"
|
||||
>
|
||||
{item.status === 'loading' ? (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-3/4 mb-2"></div>
|
||||
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded w-full mb-1"></div>
|
||||
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded w-2/3"></div>
|
||||
</div>
|
||||
) : item.status === 'error' ? (
|
||||
<div>
|
||||
<h3 className="font-medium text-red-600 dark:text-red-400 mb-2">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-sm text-red-500 dark:text-red-400 mb-2">
|
||||
{item.content}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => retryDataLoad(item.id)}
|
||||
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-sm transition-colors"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-2">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-2">
|
||||
{item.content}
|
||||
</p>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{item.timestamp}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 长时间运行任务 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">长时间任务</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startLongRunningTask}
|
||||
disabled={longRunningTask.isRunning}
|
||||
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-400 text-white rounded-md transition-colors text-sm"
|
||||
>
|
||||
{longRunningTask.isRunning ? '执行中...' : '开始任务'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{longRunningTask.progress > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{longRunningTask.currentStep}
|
||||
</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{longRunningTask.progress.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-purple-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${longRunningTask.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{longRunningTask.logs.length > 0 && (
|
||||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 max-h-48 overflow-y-auto">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
执行日志:
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{longRunningTask.logs.map((log, logIdx) => {
|
||||
const logKey = `${logIdx + 1}-${log.substring(0, 30)}`
|
||||
return (
|
||||
<div
|
||||
key={logKey}
|
||||
className="text-sm text-gray-600 dark:text-gray-300 font-mono"
|
||||
>
|
||||
{log}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 返回链接 */}
|
||||
<div className="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
|
||||
<Link href="/" className="text-blue-600 hover:text-blue-500 dark:text-blue-400">
|
||||
← 返回测试页面列表
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
582
packages/website/src/test-pages/complex-test.tsx
Normal file
582
packages/website/src/test-pages/complex-test.tsx
Normal file
@@ -0,0 +1,582 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'wouter'
|
||||
|
||||
interface CartItem {
|
||||
id: number
|
||||
name: string
|
||||
price: number
|
||||
quantity: number
|
||||
image: string
|
||||
}
|
||||
|
||||
interface WizardStep {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
export default function ComplexTestPage() {
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
const [cartItems, setCartItems] = useState<CartItem[]>([
|
||||
{
|
||||
id: 1,
|
||||
name: 'iPhone 15 Pro',
|
||||
price: 7999,
|
||||
quantity: 1,
|
||||
image: 'https://picsum.photos/100/100?random=1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'MacBook Air',
|
||||
price: 8999,
|
||||
quantity: 1,
|
||||
image: 'https://picsum.photos/100/100?random=2',
|
||||
},
|
||||
])
|
||||
const [wizardData, setWizardData] = useState({
|
||||
personalInfo: { name: '', email: '', phone: '' },
|
||||
address: { street: '', city: '', zipCode: '' },
|
||||
payment: { cardNumber: '', expiryDate: '', cvv: '' },
|
||||
})
|
||||
const [wizardSteps, setWizardSteps] = useState<WizardStep[]>([
|
||||
{ id: 1, title: '个人信息', description: '填写基本信息', completed: false },
|
||||
{ id: 2, title: '收货地址', description: '填写收货地址', completed: false },
|
||||
{ id: 3, title: '支付方式', description: '选择支付方式', completed: false },
|
||||
{ id: 4, title: '确认订单', description: '确认订单信息', completed: false },
|
||||
])
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [orderComplete, setOrderComplete] = useState(false)
|
||||
|
||||
// 购物车操作
|
||||
const updateQuantity = (id: number, newQuantity: number) => {
|
||||
if (newQuantity <= 0) {
|
||||
removeItem(id)
|
||||
return
|
||||
}
|
||||
setCartItems((prev) =>
|
||||
prev.map((item) => (item.id === id ? { ...item, quantity: newQuantity } : item))
|
||||
)
|
||||
}
|
||||
|
||||
const removeItem = (id: number) => {
|
||||
setCartItems((prev) => prev.filter((item) => item.id !== id))
|
||||
}
|
||||
|
||||
const addItem = () => {
|
||||
const newItem: CartItem = {
|
||||
id: Date.now(),
|
||||
name: `新产品 ${cartItems.length + 1}`,
|
||||
price: Math.floor(Math.random() * 5000) + 1000,
|
||||
quantity: 1,
|
||||
image: `https://picsum.photos/100/100?random=${Date.now()}`,
|
||||
}
|
||||
setCartItems((prev) => [...prev, newItem])
|
||||
}
|
||||
|
||||
const getTotalPrice = () => {
|
||||
return cartItems.reduce((total, item) => total + item.price * item.quantity, 0)
|
||||
}
|
||||
|
||||
// 向导步骤验证
|
||||
const validateStep = (step: number): boolean => {
|
||||
switch (step) {
|
||||
case 1:
|
||||
return !!(
|
||||
wizardData.personalInfo.name &&
|
||||
wizardData.personalInfo.email &&
|
||||
wizardData.personalInfo.phone
|
||||
)
|
||||
case 2:
|
||||
return !!(
|
||||
wizardData.address.street &&
|
||||
wizardData.address.city &&
|
||||
wizardData.address.zipCode
|
||||
)
|
||||
case 3:
|
||||
return !!(
|
||||
wizardData.payment.cardNumber &&
|
||||
wizardData.payment.expiryDate &&
|
||||
wizardData.payment.cvv
|
||||
)
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const goToStep = (step: number) => {
|
||||
// 验证当前步骤
|
||||
if (step > currentStep && !validateStep(currentStep)) {
|
||||
alert('请完成当前步骤的必填信息')
|
||||
return
|
||||
}
|
||||
|
||||
// 更新步骤完成状态
|
||||
if (step > currentStep) {
|
||||
setWizardSteps((prev) =>
|
||||
prev.map((s) => (s.id === currentStep ? { ...s, completed: true } : s))
|
||||
)
|
||||
}
|
||||
|
||||
setCurrentStep(step)
|
||||
}
|
||||
|
||||
const handleInputChange = (section: string, field: string, value: string) => {
|
||||
setWizardData((prev) => ({
|
||||
...prev,
|
||||
[section]: {
|
||||
...prev[section as keyof typeof prev],
|
||||
[field]: value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmitOrder = async () => {
|
||||
setIsProcessing(true)
|
||||
|
||||
// 模拟处理时间
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
|
||||
// 模拟随机失败
|
||||
if (Math.random() < 0.2) {
|
||||
setIsProcessing(false)
|
||||
alert('订单提交失败,请重试')
|
||||
return
|
||||
}
|
||||
|
||||
setIsProcessing(false)
|
||||
setOrderComplete(true)
|
||||
setShowConfirmDialog(false)
|
||||
}
|
||||
|
||||
const resetWizard = () => {
|
||||
setCurrentStep(1)
|
||||
setWizardData({
|
||||
personalInfo: { name: '', email: '', phone: '' },
|
||||
address: { street: '', city: '', zipCode: '' },
|
||||
payment: { cardNumber: '', expiryDate: '', cvv: '' },
|
||||
})
|
||||
setWizardSteps((prev) => prev.map((s) => ({ ...s, completed: false })))
|
||||
setOrderComplete(false)
|
||||
setShowConfirmDialog(false)
|
||||
}
|
||||
|
||||
if (orderComplete) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="max-w-md mx-auto text-center">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
|
||||
<div className="text-6xl mb-4">🎉</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
订单提交成功!
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
您的订单已成功提交,我们将尽快为您处理。
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetWizard}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-md transition-colors"
|
||||
>
|
||||
重新开始
|
||||
</button>
|
||||
<Link
|
||||
href="/test-pages"
|
||||
className="block w-full bg-gray-600 hover:bg-gray-700 text-white py-2 px-4 rounded-md transition-colors text-center"
|
||||
>
|
||||
返回测试页面
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">复杂交互测试</h1>
|
||||
<p className="text-gray-600 dark:text-gray-300">测试多步骤操作、状态管理和复杂用户交互</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* 购物车区域 */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 sticky top-8">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
购物车 ({cartItems.length})
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
{cartItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center space-x-3 p-3 border border-gray-200 dark:border-gray-600 rounded-lg"
|
||||
>
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
className="w-12 h-12 object-cover rounded"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{item.name}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
¥{item.price.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, item.quantity - 1)}
|
||||
className="w-6 h-6 flex items-center justify-center bg-gray-200 dark:bg-gray-600 rounded text-sm hover:bg-gray-300 dark:hover:bg-gray-500"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="text-sm font-medium w-8 text-center">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, item.quantity + 1)}
|
||||
className="w-6 h-6 flex items-center justify-center bg-gray-200 dark:bg-gray-600 rounded text-sm hover:bg-gray-300 dark:hover:bg-gray-500"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeItem(item.id)}
|
||||
className="w-6 h-6 flex items-center justify-center bg-red-500 text-white rounded text-sm hover:bg-red-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={addItem}
|
||||
className="w-full mb-4 py-2 px-4 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg text-gray-500 dark:text-gray-400 hover:border-blue-500 hover:text-blue-500 transition-colors"
|
||||
>
|
||||
+ 添加商品
|
||||
</button>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-600 pt-4">
|
||||
<div className="flex justify-between items-center text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<span>总计:</span>
|
||||
<span>¥{getTotalPrice().toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 向导区域 */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
{/* 步骤指示器 */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-600">
|
||||
<div className="flex items-center justify-between">
|
||||
{wizardSteps.map((step, index) => (
|
||||
<div key={step.id} className="flex items-center">
|
||||
<button
|
||||
onClick={() => goToStep(step.id)}
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${
|
||||
step.completed
|
||||
? 'bg-green-500 text-white'
|
||||
: step.id === currentStep
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-600 text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{step.completed ? '✓' : step.id}
|
||||
</button>
|
||||
{index < wizardSteps.length - 1 && (
|
||||
<div
|
||||
className={`w-16 h-1 mx-2 ${
|
||||
step.completed ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-600'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{wizardSteps[currentStep - 1].title}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{wizardSteps[currentStep - 1].description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 步骤内容 */}
|
||||
<div className="p-6">
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
姓名 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={wizardData.personalInfo.name}
|
||||
onChange={(e) => handleInputChange('personalInfo', 'name', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="请输入您的姓名"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
邮箱 *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={wizardData.personalInfo.email}
|
||||
onChange={(e) => handleInputChange('personalInfo', 'email', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="请输入您的邮箱"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
手机号 *
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={wizardData.personalInfo.phone}
|
||||
onChange={(e) => handleInputChange('personalInfo', 'phone', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="请输入您的手机号"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
详细地址 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={wizardData.address.street}
|
||||
onChange={(e) => handleInputChange('address', 'street', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="请输入详细地址"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
城市 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={wizardData.address.city}
|
||||
onChange={(e) => handleInputChange('address', 'city', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="请输入城市"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
邮政编码 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={wizardData.address.zipCode}
|
||||
onChange={(e) => handleInputChange('address', 'zipCode', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="请输入邮政编码"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
银行卡号 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={wizardData.payment.cardNumber}
|
||||
onChange={(e) => handleInputChange('payment', 'cardNumber', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="请输入银行卡号"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
有效期 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={wizardData.payment.expiryDate}
|
||||
onChange={(e) =>
|
||||
handleInputChange('payment', 'expiryDate', e.target.value)
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="MM/YY"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
CVV *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={wizardData.payment.cvv}
|
||||
onChange={(e) => handleInputChange('payment', 'cvv', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="CVV"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 4 && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
订单确认
|
||||
</h4>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-700 p-4 rounded-lg">
|
||||
<h5 className="font-medium text-gray-900 dark:text-white mb-2">
|
||||
个人信息
|
||||
</h5>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{wizardData.personalInfo.name} | {wizardData.personalInfo.email} |{' '}
|
||||
{wizardData.personalInfo.phone}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-700 p-4 rounded-lg">
|
||||
<h5 className="font-medium text-gray-900 dark:text-white mb-2">
|
||||
收货地址
|
||||
</h5>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{wizardData.address.street}, {wizardData.address.city}{' '}
|
||||
{wizardData.address.zipCode}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-700 p-4 rounded-lg">
|
||||
<h5 className="font-medium text-gray-900 dark:text-white mb-2">
|
||||
支付方式
|
||||
</h5>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
**** **** **** {wizardData.payment.cardNumber.slice(-4)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 导航按钮 */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-600 flex justify-between">
|
||||
<button
|
||||
onClick={() => goToStep(currentStep - 1)}
|
||||
disabled={currentStep === 1}
|
||||
className="px-4 py-2 text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
上一步
|
||||
</button>
|
||||
|
||||
{currentStep < 4 ? (
|
||||
<button
|
||||
onClick={() => goToStep(currentStep + 1)}
|
||||
disabled={!validateStep(currentStep)}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-md transition-colors"
|
||||
>
|
||||
下一步
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowConfirmDialog(true)}
|
||||
disabled={cartItems.length === 0}
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white rounded-md transition-colors"
|
||||
>
|
||||
提交订单
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 确认对话框 */}
|
||||
{showConfirmDialog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
确认提交订单
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
您确定要提交这个订单吗?订单总金额为 ¥{getTotalPrice().toLocaleString()}
|
||||
</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => setShowConfirmDialog(false)}
|
||||
disabled={isProcessing}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmitOrder}
|
||||
disabled={isProcessing}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-md transition-colors flex items-center"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
处理中...
|
||||
</>
|
||||
) : (
|
||||
'确认提交'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 返回链接 */}
|
||||
<div className="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
|
||||
<Link href="/" className="text-blue-600 hover:text-blue-500 dark:text-blue-400">
|
||||
← 返回测试页面列表
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
464
packages/website/src/test-pages/error-test.tsx
Normal file
464
packages/website/src/test-pages/error-test.tsx
Normal file
@@ -0,0 +1,464 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'wouter'
|
||||
|
||||
interface ErrorScenario {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
type: 'network' | 'validation' | 'permission' | 'timeout' | 'server'
|
||||
}
|
||||
|
||||
export default function ErrorTestPage() {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [retryCount, setRetryCount] = useState(0)
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
email: '',
|
||||
file: null as File | null,
|
||||
})
|
||||
|
||||
const errorScenarios: ErrorScenario[] = [
|
||||
{
|
||||
id: 'network-error',
|
||||
title: '网络连接错误',
|
||||
description: '模拟网络连接失败,测试重试机制',
|
||||
type: 'network',
|
||||
},
|
||||
{
|
||||
id: 'validation-error',
|
||||
title: '表单验证错误',
|
||||
description: '模拟表单验证失败,测试错误提示',
|
||||
type: 'validation',
|
||||
},
|
||||
{
|
||||
id: 'permission-error',
|
||||
title: '权限不足错误',
|
||||
description: '模拟权限验证失败,测试权限处理',
|
||||
type: 'permission',
|
||||
},
|
||||
{
|
||||
id: 'timeout-error',
|
||||
title: '请求超时错误',
|
||||
description: '模拟请求超时,测试超时处理',
|
||||
type: 'timeout',
|
||||
},
|
||||
{
|
||||
id: 'server-error',
|
||||
title: '服务器内部错误',
|
||||
description: '模拟服务器500错误,测试错误恢复',
|
||||
type: 'server',
|
||||
},
|
||||
]
|
||||
|
||||
const simulateError = async (scenario: ErrorScenario): Promise<void> => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
// 模拟网络延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 + Math.random() * 2000))
|
||||
|
||||
switch (scenario.type) {
|
||||
case 'network':
|
||||
// 70% 概率失败
|
||||
if (Math.random() < 0.7) {
|
||||
throw new Error('网络连接失败:无法连接到服务器,请检查您的网络连接')
|
||||
}
|
||||
break
|
||||
|
||||
case 'validation':
|
||||
// 检查表单数据
|
||||
if (!formData.username || formData.username.length < 3) {
|
||||
throw new Error('用户名验证失败:用户名至少需要3个字符')
|
||||
}
|
||||
if (!formData.password || formData.password.length < 6) {
|
||||
throw new Error('密码验证失败:密码至少需要6个字符')
|
||||
}
|
||||
if (!formData.email?.includes('@')) {
|
||||
throw new Error('邮箱验证失败:请输入有效的邮箱地址')
|
||||
}
|
||||
break
|
||||
|
||||
case 'permission':
|
||||
// 模拟权限检查
|
||||
if (formData.username !== 'admin') {
|
||||
throw new Error('权限不足:您没有执行此操作的权限,请联系管理员')
|
||||
}
|
||||
break
|
||||
|
||||
case 'timeout':
|
||||
// 模拟超时
|
||||
await new Promise((resolve) => setTimeout(resolve, 8000))
|
||||
throw new Error('请求超时:服务器响应时间过长,请稍后重试')
|
||||
|
||||
case 'server':
|
||||
// 50% 概率服务器错误
|
||||
if (Math.random() < 0.5) {
|
||||
throw new Error('服务器内部错误:服务器遇到了一个错误,请稍后重试')
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
throw new Error('未知错误:发生了未预期的错误')
|
||||
}
|
||||
|
||||
// 成功情况
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const handleScenarioTest = async (scenario: ErrorScenario) => {
|
||||
try {
|
||||
await simulateError(scenario)
|
||||
setSuccess(`${scenario.title} 测试成功完成!`)
|
||||
setRetryCount(0)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '未知错误'
|
||||
setError(errorMessage)
|
||||
setRetryCount((prev) => prev + 1)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRetry = async (scenario: ErrorScenario) => {
|
||||
if (retryCount >= 3) {
|
||||
setError('重试次数已达上限,请稍后再试或联系技术支持')
|
||||
return
|
||||
}
|
||||
await handleScenarioTest(scenario)
|
||||
}
|
||||
|
||||
const handleFileUpload = async () => {
|
||||
if (!formData.file) {
|
||||
setError('请选择要上传的文件')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
try {
|
||||
// 模拟文件大小检查
|
||||
if (formData.file.size > 5 * 1024 * 1024) {
|
||||
throw new Error('文件上传失败:文件大小不能超过5MB')
|
||||
}
|
||||
|
||||
// 模拟文件类型检查
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf']
|
||||
if (!allowedTypes.includes(formData.file.type)) {
|
||||
throw new Error('文件上传失败:不支持的文件类型,请上传图片或PDF文件')
|
||||
}
|
||||
|
||||
// 模拟上传过程
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
|
||||
// 模拟随机失败
|
||||
if (Math.random() < 0.3) {
|
||||
throw new Error('文件上传失败:上传过程中发生错误,请重试')
|
||||
}
|
||||
|
||||
setSuccess('文件上传成功!')
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '文件上传失败'
|
||||
setError(errorMessage)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const clearMessages = () => {
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
setRetryCount(0)
|
||||
}
|
||||
|
||||
const getErrorIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'network':
|
||||
return '🌐'
|
||||
case 'validation':
|
||||
return '⚠️'
|
||||
case 'permission':
|
||||
return '🔒'
|
||||
case 'timeout':
|
||||
return '⏰'
|
||||
case 'server':
|
||||
return '🔧'
|
||||
default:
|
||||
return '❌'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">错误处理测试</h1>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
测试各种错误场景和重试机制,验证 Agent 的错误处理能力
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 全局消息显示 */}
|
||||
{(error || success) && (
|
||||
<div className="mb-8">
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-start">
|
||||
<div className="shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">操作失败</h3>
|
||||
<p className="mt-1 text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||
{retryCount > 0 && (
|
||||
<p className="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||
已重试 {retryCount} 次 {retryCount >= 3 && '(已达最大重试次数)'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={clearMessages}
|
||||
className="ml-3 text-red-400 hover:text-red-600 dark:hover:text-red-300"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-start">
|
||||
<div className="shrink-0">
|
||||
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<h3 className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
操作成功
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-green-700 dark:text-green-300">{success}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={clearMessages}
|
||||
className="ml-3 text-green-400 hover:text-green-600 dark:hover:text-green-300"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* 错误场景测试 */}
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">错误场景测试</h2>
|
||||
|
||||
{errorScenarios.map((scenario) => (
|
||||
<div key={scenario.id} className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="text-3xl">{getErrorIcon(scenario.type)}</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
{scenario.title}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm mb-4">
|
||||
{scenario.description}
|
||||
</p>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => handleScenarioTest(scenario)}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white rounded-md transition-colors text-sm"
|
||||
>
|
||||
{isLoading ? '测试中...' : '触发错误'}
|
||||
</button>
|
||||
{error && retryCount > 0 && retryCount < 3 && (
|
||||
<button
|
||||
onClick={() => handleRetry(scenario)}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-md transition-colors text-sm"
|
||||
>
|
||||
重试 ({retryCount}/3)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 表单验证测试 */}
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">表单验证测试</h2>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
用户信息表单
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
用户名 (至少3个字符)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, username: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
密码 (至少6个字符)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, password: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
邮箱地址
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, email: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="请输入邮箱地址"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleScenarioTest(errorScenarios.find((s) => s.type === 'validation')!)
|
||||
}
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-md transition-colors"
|
||||
>
|
||||
{isLoading ? '验证中...' : '提交表单'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文件上传测试 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
文件上传测试
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
选择文件 (最大5MB,支持图片和PDF)
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, file: e.target.files?.[0] || null }))
|
||||
}
|
||||
accept="image/*,.pdf"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
{formData.file && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
已选择: {formData.file.name} ({(formData.file.size / 1024 / 1024).toFixed(2)}{' '}
|
||||
MB)
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleFileUpload}
|
||||
disabled={isLoading || !formData.file}
|
||||
className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white rounded-md transition-colors"
|
||||
>
|
||||
{isLoading ? '上传中...' : '上传文件'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 权限测试说明 */}
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-yellow-800 dark:text-yellow-200 mb-2">
|
||||
💡 权限测试提示
|
||||
</h4>
|
||||
<p className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||
要通过权限测试,请在用户名字段输入 "admin",然后点击"触发错误"按钮测试权限验证。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 加载状态指示器 */}
|
||||
{isLoading && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 flex items-center space-x-4">
|
||||
<svg
|
||||
className="animate-spin h-8 w-8 text-blue-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span className="text-gray-900 dark:text-white">处理中,请稍候...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 返回链接 */}
|
||||
<div className="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
|
||||
<Link href="/" className="text-blue-600 hover:text-blue-500 dark:text-blue-400">
|
||||
← 返回测试页面列表
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
488
packages/website/src/test-pages/form-test.tsx
Normal file
488
packages/website/src/test-pages/form-test.tsx
Normal file
@@ -0,0 +1,488 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'wouter'
|
||||
|
||||
interface FormData {
|
||||
username: string
|
||||
email: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
age: string
|
||||
birthDate: string
|
||||
phone: string
|
||||
website: string
|
||||
bio: string
|
||||
country: string
|
||||
newsletter: boolean
|
||||
terms: boolean
|
||||
}
|
||||
|
||||
type FormErrors = Record<string, string>
|
||||
|
||||
export default function FormTestPage() {
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
age: '',
|
||||
birthDate: '',
|
||||
phone: '',
|
||||
website: '',
|
||||
bio: '',
|
||||
country: '',
|
||||
newsletter: false,
|
||||
terms: false,
|
||||
})
|
||||
|
||||
const [errors, setErrors] = useState<FormErrors>({})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [submitResult, setSubmitResult] = useState<'success' | 'error' | null>(null)
|
||||
const [submitMessage, setSubmitMessage] = useState('')
|
||||
|
||||
const validateField = (name: string, value: string | boolean): string => {
|
||||
switch (name) {
|
||||
case 'username':
|
||||
if (!value) return '用户名不能为空'
|
||||
if (typeof value === 'string' && value.length < 3) return '用户名至少需要3个字符'
|
||||
if (typeof value === 'string' && !/^[a-zA-Z0-9_]+$/.test(value))
|
||||
return '用户名只能包含字母、数字和下划线'
|
||||
return ''
|
||||
case 'email':
|
||||
if (!value) return '邮箱不能为空'
|
||||
if (typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
|
||||
return '请输入有效的邮箱地址'
|
||||
return ''
|
||||
case 'password':
|
||||
if (!value) return '密码不能为空'
|
||||
if (typeof value === 'string' && value.length < 6) return '密码至少需要6个字符'
|
||||
if (typeof value === 'string' && !/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value))
|
||||
return '密码必须包含大小写字母和数字'
|
||||
return ''
|
||||
case 'confirmPassword':
|
||||
if (!value) return '请确认密码'
|
||||
if (value !== formData.password) return '两次输入的密码不一致'
|
||||
return ''
|
||||
case 'age': {
|
||||
if (!value) return '年龄不能为空'
|
||||
const age = parseInt(value as string)
|
||||
if (isNaN(age) || age < 18 || age > 120) return '年龄必须在18-120之间'
|
||||
return ''
|
||||
}
|
||||
case 'phone':
|
||||
if (!value) return '手机号不能为空'
|
||||
if (typeof value === 'string' && !/^1[3-9]\d{9}$/.test(value)) return '请输入有效的手机号'
|
||||
return ''
|
||||
case 'terms':
|
||||
if (!value) return '请同意服务条款'
|
||||
return ''
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (name: string, value: string | boolean) => {
|
||||
console.log(`Input changed: ${name} = ${value}`)
|
||||
|
||||
setFormData((prev) => ({ ...prev, [name]: value }))
|
||||
|
||||
// 实时验证
|
||||
const error = validateField(name, value)
|
||||
setErrors((prev) => ({ ...prev, [name]: error }))
|
||||
}
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: FormErrors = {}
|
||||
let isValid = true
|
||||
|
||||
Object.keys(formData).forEach((key) => {
|
||||
const error = validateField(key, formData[key as keyof FormData])
|
||||
if (error) {
|
||||
newErrors[key] = error
|
||||
isValid = false
|
||||
}
|
||||
})
|
||||
|
||||
setErrors(newErrors)
|
||||
return isValid
|
||||
}
|
||||
|
||||
const simulateSubmit = async (): Promise<{ success: boolean; message: string }> => {
|
||||
// 模拟网络延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000 + Math.random() * 2000))
|
||||
|
||||
// 模拟随机失败
|
||||
if (Math.random() < 0.3) {
|
||||
throw new Error('网络错误:服务器暂时不可用,请稍后重试')
|
||||
}
|
||||
|
||||
// 模拟服务器验证错误
|
||||
if (formData.username.toLowerCase() === 'admin') {
|
||||
throw new Error('用户名 "admin" 已被占用,请选择其他用户名')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '注册成功!欢迎加入我们的平台。',
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!validateForm()) {
|
||||
setSubmitResult('error')
|
||||
setSubmitMessage('请修正表单中的错误')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setSubmitResult(null)
|
||||
setSubmitMessage('')
|
||||
|
||||
try {
|
||||
const result = await simulateSubmit()
|
||||
setSubmitResult('success')
|
||||
setSubmitMessage(result.message)
|
||||
} catch (error) {
|
||||
setSubmitResult('error')
|
||||
setSubmitMessage(error instanceof Error ? error.message : '提交失败,请重试')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
age: '',
|
||||
birthDate: '',
|
||||
phone: '',
|
||||
website: '',
|
||||
bio: '',
|
||||
country: '',
|
||||
newsletter: false,
|
||||
terms: false,
|
||||
})
|
||||
setErrors({})
|
||||
setSubmitResult(null)
|
||||
setSubmitMessage('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
||||
<div className="max-w-2xl mx-auto px-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">用户注册表单</h1>
|
||||
<p className="text-gray-600 dark:text-gray-300">测试各种表单输入、验证和提交功能</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 用户名 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
用户名 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => handleInputChange('username', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white ${
|
||||
errors.username ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
{errors.username && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.username}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 邮箱 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
邮箱地址 *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white ${
|
||||
errors.email ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
placeholder="请输入邮箱地址"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 密码 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
密码 *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleInputChange('password', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white ${
|
||||
errors.password ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
确认密码 *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white ${
|
||||
errors.confirmPassword
|
||||
? 'border-red-500'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
placeholder="请再次输入密码"
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
|
||||
{errors.confirmPassword}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 年龄和生日 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
年龄 *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.age}
|
||||
onChange={(e) => handleInputChange('age', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white ${
|
||||
errors.age ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
placeholder="请输入年龄"
|
||||
min="18"
|
||||
max="120"
|
||||
/>
|
||||
{errors.age && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.age}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
出生日期
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.birthDate}
|
||||
onChange={(e) => handleInputChange('birthDate', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 手机和网站 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
手机号 *
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleInputChange('phone', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white ${
|
||||
errors.phone ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
placeholder="请输入手机号"
|
||||
/>
|
||||
{errors.phone && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
个人网站
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.website}
|
||||
onChange={(e) => handleInputChange('website', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 国家选择 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
国家/地区
|
||||
</label>
|
||||
<select
|
||||
value={formData.country}
|
||||
onChange={(e) => handleInputChange('country', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="">请选择国家/地区</option>
|
||||
<option value="CN">中国</option>
|
||||
<option value="US">美国</option>
|
||||
<option value="JP">日本</option>
|
||||
<option value="KR">韩国</option>
|
||||
<option value="GB">英国</option>
|
||||
<option value="DE">德国</option>
|
||||
<option value="FR">法国</option>
|
||||
<option value="CA">加拿大</option>
|
||||
<option value="AU">澳大利亚</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 个人简介 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
个人简介
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.bio}
|
||||
onChange={(e) => handleInputChange('bio', e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="请简单介绍一下自己..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 复选框 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="newsletter"
|
||||
checked={formData.newsletter}
|
||||
onChange={(e) => handleInputChange('newsletter', e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label
|
||||
htmlFor="newsletter"
|
||||
className="ml-2 block text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
订阅我们的新闻通讯
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="terms"
|
||||
checked={formData.terms}
|
||||
onChange={(e) => handleInputChange('terms', e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label
|
||||
htmlFor="terms"
|
||||
className="ml-2 block text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
我同意{' '}
|
||||
<a href="#" className="text-blue-600 hover:text-blue-500">
|
||||
服务条款
|
||||
</a>{' '}
|
||||
和{' '}
|
||||
<a href="#" className="text-blue-600 hover:text-blue-500">
|
||||
隐私政策
|
||||
</a>{' '}
|
||||
*
|
||||
</label>
|
||||
</div>
|
||||
{errors.terms && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{errors.terms}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 提交结果 */}
|
||||
{submitResult && (
|
||||
<div
|
||||
className={`p-4 rounded-md ${
|
||||
submitResult === 'success'
|
||||
? 'bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700'
|
||||
: 'bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700'
|
||||
}`}
|
||||
>
|
||||
<p
|
||||
className={`text-sm ${
|
||||
submitResult === 'success'
|
||||
? 'text-green-800 dark:text-green-200'
|
||||
: 'text-red-800 dark:text-red-200'
|
||||
}`}
|
||||
>
|
||||
{submitMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 按钮组 */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
提交中...
|
||||
</span>
|
||||
) : (
|
||||
'注册账户'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
className="flex-1 bg-gray-600 hover:bg-gray-700 text-white font-medium py-2 px-4 rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
|
||||
>
|
||||
重置表单
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<Link href="/" className="text-blue-600 hover:text-blue-500 dark:text-blue-400">
|
||||
← 返回测试页面列表
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
106
packages/website/src/test-pages/index.tsx
Normal file
106
packages/website/src/test-pages/index.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Link } from 'wouter'
|
||||
|
||||
export default function IndexPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-linear-to-br from-blue-50 to-purple-50 dark:from-gray-900 dark:to-gray-800 p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Page Use Agent 测试页面
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-300">
|
||||
用于测试 AI Agent 网页操作能力的综合测试套件
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
<TestPageCard
|
||||
title="表单测试"
|
||||
description="测试输入、验证、提交等表单操作"
|
||||
path="/form"
|
||||
icon="📝"
|
||||
difficulty="简单"
|
||||
/>
|
||||
<TestPageCard
|
||||
title="导航测试"
|
||||
description="测试菜单、下拉框、弹窗等交互"
|
||||
path="/navigation"
|
||||
icon="🧭"
|
||||
difficulty="中等"
|
||||
/>
|
||||
<TestPageCard
|
||||
title="列表测试"
|
||||
description="测试滚动、分页、搜索、排序"
|
||||
path="/list"
|
||||
icon="📋"
|
||||
difficulty="中等"
|
||||
/>
|
||||
<TestPageCard
|
||||
title="复杂交互"
|
||||
description="测试多步骤操作和状态管理"
|
||||
path="/complex"
|
||||
icon="⚙️"
|
||||
difficulty="困难"
|
||||
/>
|
||||
<TestPageCard
|
||||
title="错误处理"
|
||||
description="测试错误识别和重试机制"
|
||||
path="/errors"
|
||||
icon="⚠️"
|
||||
difficulty="困难"
|
||||
/>
|
||||
<TestPageCard
|
||||
title="异步操作"
|
||||
description="测试等待、加载状态识别"
|
||||
path="/async"
|
||||
icon="⏳"
|
||||
difficulty="中等"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
← 回到 Page Use 首页
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TestPageCardProps {
|
||||
title: string
|
||||
description: string
|
||||
path: string
|
||||
icon: string
|
||||
difficulty: string
|
||||
}
|
||||
|
||||
function TestPageCard({ title, description, path, icon, difficulty }: TestPageCardProps) {
|
||||
const difficultyColors = {
|
||||
简单: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
中等: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
困难: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={path}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105 cursor-pointer border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-4xl mb-4">{icon}</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">{title}</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4 text-sm">{description}</p>
|
||||
<div className="flex justify-between items-center">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${difficultyColors[difficulty as keyof typeof difficultyColors]}`}
|
||||
>
|
||||
{difficulty}
|
||||
</span>
|
||||
<span className="text-blue-600 dark:text-blue-400 text-sm font-medium">开始测试 →</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
481
packages/website/src/test-pages/list-test.tsx
Normal file
481
packages/website/src/test-pages/list-test.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'wouter'
|
||||
|
||||
interface Product {
|
||||
id: number
|
||||
name: string
|
||||
category: string
|
||||
price: number
|
||||
stock: number
|
||||
rating: number
|
||||
image: string
|
||||
description: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
const generateProducts = (count: number): Product[] => {
|
||||
const categories = ['手机', '电脑', '平板', '耳机', '手表', '相机']
|
||||
const brands = ['Apple', 'Samsung', 'Huawei', 'Xiaomi', 'Sony', 'Dell']
|
||||
const adjectives = ['Pro', 'Max', 'Ultra', 'Plus', 'Air', 'Mini']
|
||||
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: `${brands[i % brands.length]} ${categories[i % categories.length]} ${adjectives[i % adjectives.length]}`,
|
||||
category: categories[i % categories.length],
|
||||
price: Math.floor(Math.random() * 10000) + 500,
|
||||
stock: Math.floor(Math.random() * 100),
|
||||
rating: Math.round((Math.random() * 2 + 3) * 10) / 10,
|
||||
image: `https://picsum.photos/200/200?random=${i}`,
|
||||
description: `这是一款优秀的${categories[i % categories.length]}产品,具有出色的性能和设计。`,
|
||||
tags: ['热销', '新品', '推荐'].slice(0, Math.floor(Math.random() * 3) + 1),
|
||||
}))
|
||||
}
|
||||
|
||||
// Loading skeleton component
|
||||
const LoadingSkeleton = () => (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{Array.from({ length: 12 }, (_, i) => `skeleton-item-${i}`).map((id) => (
|
||||
<div key={id} className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 animate-pulse">
|
||||
<div className="bg-gray-300 dark:bg-gray-600 h-48 rounded-lg mb-4"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-gray-300 dark:bg-gray-600 h-4 rounded w-3/4"></div>
|
||||
<div className="bg-gray-300 dark:bg-gray-600 h-4 rounded w-1/2"></div>
|
||||
<div className="bg-gray-300 dark:bg-gray-600 h-4 rounded w-1/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Product card component
|
||||
const ProductCard = ({ product }: { product: Product }) => (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-4">
|
||||
<div className="relative mb-4">
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
className="w-full h-48 object-cover rounded-lg"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute top-2 right-2 flex flex-wrap gap-1">
|
||||
{product.tags.map((tag) => (
|
||||
<span key={tag} className="bg-red-500 text-white text-xs px-2 py-1 rounded-full">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-2 line-clamp-2">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm mb-3 line-clamp-2">
|
||||
{product.description}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
¥{product.price.toLocaleString()}
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<span className="text-yellow-400">★</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300 ml-1">{product.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">库存: {product.stock}</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{product.category}</span>
|
||||
</div>
|
||||
<button className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-md transition-colors">
|
||||
加入购物车
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Product list item component
|
||||
const ProductListItem = ({ product }: { product: Product }) => (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 flex items-center space-x-4">
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
className="w-20 h-20 object-cover rounded-lg shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">{product.name}</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm mb-2 line-clamp-1">
|
||||
{product.description}
|
||||
</p>
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>{product.category}</span>
|
||||
<span>库存: {product.stock}</span>
|
||||
<div className="flex items-center">
|
||||
<span className="text-yellow-400">★</span>
|
||||
<span className="ml-1">{product.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-xl font-bold text-blue-600 dark:text-blue-400">
|
||||
¥{product.price.toLocaleString()}
|
||||
</span>
|
||||
<button className="bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-md transition-colors">
|
||||
加入购物车
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Pagination component
|
||||
interface PaginationProps {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
startIndex: number
|
||||
endIndex: number
|
||||
totalItems: number
|
||||
onPageChange: (page: number) => void
|
||||
}
|
||||
|
||||
const Pagination = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
startIndex,
|
||||
endIndex,
|
||||
totalItems,
|
||||
onPageChange,
|
||||
}: PaginationProps) => {
|
||||
const getPageNumbers = () => {
|
||||
const pages = []
|
||||
const maxVisible = 5
|
||||
let start = Math.max(1, currentPage - Math.floor(maxVisible / 2))
|
||||
const end = Math.min(totalPages, start + maxVisible - 1)
|
||||
|
||||
if (end - start + 1 < maxVisible) {
|
||||
start = Math.max(1, end - maxVisible + 1)
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mt-8">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
显示 {startIndex + 1}-{Math.min(endIndex, totalItems)} 条, 共 {totalItems} 条结果
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
{getPageNumbers().map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page)}
|
||||
className={`px-3 py-2 text-sm font-medium rounded-md ${
|
||||
page === currentPage
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-500 bg-white border border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ListTestPage() {
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState('全部')
|
||||
const [sortBy, setSortBy] = useState('name')
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [itemsPerPage, setItemsPerPage] = useState(12)
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
|
||||
const categories = ['全部', '手机', '电脑', '平板', '耳机', '手表', '相机']
|
||||
|
||||
// Helper to set filters and reset page
|
||||
const handleSearchChange = (term: string) => {
|
||||
setSearchTerm(term)
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
const handleCategoryChange = (category: string) => {
|
||||
setSelectedCategory(category)
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
const handleSortChange = (sort: string) => {
|
||||
setSortBy(sort)
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
const handleSortOrderChange = (order: 'asc' | 'desc') => {
|
||||
setSortOrder(order)
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
// 模拟数据加载
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
// 模拟网络延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
const data = generateProducts(150)
|
||||
setProducts(data)
|
||||
setLoading(false)
|
||||
}
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
// 搜索和过滤 - Use useMemo to compute filtered products
|
||||
const filteredProducts = useMemo(() => {
|
||||
let filtered = [...products]
|
||||
|
||||
// 按类别过滤
|
||||
if (selectedCategory !== '全部') {
|
||||
filtered = filtered.filter((product) => product.category === selectedCategory)
|
||||
}
|
||||
|
||||
// 按搜索词过滤
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(
|
||||
(product) =>
|
||||
product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
product.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
// 排序
|
||||
filtered.sort((a, b) => {
|
||||
let aValue: any = a[sortBy as keyof Product]
|
||||
let bValue: any = b[sortBy as keyof Product]
|
||||
|
||||
if (typeof aValue === 'string') {
|
||||
aValue = aValue.toLowerCase()
|
||||
bValue = bValue.toLowerCase()
|
||||
}
|
||||
|
||||
if (sortOrder === 'asc') {
|
||||
return aValue > bValue ? 1 : -1
|
||||
} else {
|
||||
return aValue < bValue ? 1 : -1
|
||||
}
|
||||
})
|
||||
|
||||
return filtered
|
||||
}, [products, searchTerm, selectedCategory, sortBy, sortOrder])
|
||||
|
||||
// 分页计算
|
||||
const totalPages = Math.ceil(filteredProducts.length / itemsPerPage)
|
||||
const startIndex = (currentPage - 1) * itemsPerPage
|
||||
const endIndex = startIndex + itemsPerPage
|
||||
const currentProducts = filteredProducts.slice(startIndex, endIndex)
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page)
|
||||
// 滚动到顶部
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">产品列表测试</h1>
|
||||
<p className="text-gray-600 dark:text-gray-300">测试搜索、过滤、排序、分页和滚动功能</p>
|
||||
</div>
|
||||
|
||||
{/* 搜索和过滤栏 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
{/* 搜索框 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
搜索产品
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder="输入产品名称或描述..."
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 类别过滤 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
产品类别
|
||||
</label>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => handleCategoryChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
{categories.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 排序方式 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
排序方式
|
||||
</label>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => handleSortChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="name">名称</option>
|
||||
<option value="price">价格</option>
|
||||
<option value="rating">评分</option>
|
||||
<option value="stock">库存</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 排序顺序 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
排序顺序
|
||||
</label>
|
||||
<select
|
||||
value={sortOrder}
|
||||
onChange={(e) => handleSortOrderChange(e.target.value as 'asc' | 'desc')}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="asc">升序</option>
|
||||
<option value="desc">降序</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 视图控制 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
每页显示:
|
||||
</span>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => setItemsPerPage(Number(e.target.value))}
|
||||
className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value={12}>12</option>
|
||||
<option value={24}>24</option>
|
||||
<option value={48}>48</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">视图:</span>
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-2 rounded-md ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-2 rounded-md ${
|
||||
viewMode === 'list'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 8a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 12a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 16a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 产品列表 */}
|
||||
{loading ? (
|
||||
<LoadingSkeleton />
|
||||
) : filteredProducts.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-6xl mb-4">🔍</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
没有找到匹配的产品
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">请尝试调整搜索条件或过滤器</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{currentProducts.map((product: Product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{currentProducts.map((product: Product) => (
|
||||
<ProductListItem key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
startIndex={startIndex}
|
||||
endIndex={endIndex}
|
||||
totalItems={filteredProducts.length}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 返回顶部按钮 */}
|
||||
<button
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
className="fixed bottom-8 right-8 bg-blue-600 hover:bg-blue-700 text-white p-3 rounded-full shadow-lg transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 返回链接 */}
|
||||
<div className="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
|
||||
<Link href="/" className="text-blue-600 hover:text-blue-500 dark:text-blue-400">
|
||||
← 返回测试页面列表
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
566
packages/website/src/test-pages/navigation-test.tsx
Normal file
566
packages/website/src/test-pages/navigation-test.tsx
Normal file
@@ -0,0 +1,566 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'wouter'
|
||||
|
||||
export default function NavigationTestPage() {
|
||||
const [activeTab, setActiveTab] = useState('home')
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false)
|
||||
const [breadcrumbs, setBreadcrumbs] = useState(['首页', '产品', '手机'])
|
||||
const [notifications, setNotifications] = useState([
|
||||
{ id: 1, title: '新消息', content: '您有一条新的私信', time: '2分钟前', unread: true },
|
||||
{ id: 2, title: '系统通知', content: '系统将于今晚维护', time: '1小时前', unread: true },
|
||||
{ id: 3, title: '订单更新', content: '您的订单已发货', time: '3小时前', unread: false },
|
||||
])
|
||||
|
||||
const handleBreadcrumbClick = (index: number) => {
|
||||
const newBreadcrumbs = breadcrumbs.slice(0, index + 1)
|
||||
setBreadcrumbs(newBreadcrumbs)
|
||||
}
|
||||
|
||||
const markNotificationAsRead = (id: number) => {
|
||||
setNotifications((prev) =>
|
||||
prev.map((notif) => (notif.id === id ? { ...notif, unread: false } : notif))
|
||||
)
|
||||
}
|
||||
|
||||
const unreadCount = notifications.filter((n) => n.unread).length
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
{/* 顶部导航栏 */}
|
||||
<nav className="bg-white dark:bg-gray-800 shadow-lg border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">TestNav</div>
|
||||
</div>
|
||||
|
||||
{/* 主导航菜单 */}
|
||||
<div className="hidden md:flex space-x-8">
|
||||
<a
|
||||
href="#"
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
首页
|
||||
</a>
|
||||
|
||||
{/* 产品下拉菜单 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 px-3 py-2 rounded-md text-sm font-medium transition-colors flex items-center"
|
||||
>
|
||||
产品
|
||||
<svg
|
||||
className="ml-1 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50">
|
||||
<div className="py-1">
|
||||
<a
|
||||
href="#"
|
||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
手机
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
电脑
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
平板
|
||||
</a>
|
||||
<div className="border-t border-gray-200 dark:border-gray-600 my-1"></div>
|
||||
<a
|
||||
href="#"
|
||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
配件
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
服务
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
支持
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* 右侧菜单 */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* 通知铃铛 */}
|
||||
<div className="relative">
|
||||
<button className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 p-2 rounded-full transition-colors">
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 17h5l-5 5v-5zM10.5 3.75a6 6 0 0 1 6 6v2.25l2.25 2.25v2.25H2.25V14.25L4.5 12V9.75a6 6 0 0 1 6-6z"
|
||||
/>
|
||||
</svg>
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 用户菜单 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
|
||||
className="flex items-center text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 p-2 rounded-full transition-colors"
|
||||
>
|
||||
<div className="h-8 w-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
|
||||
U
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isUserMenuOpen && (
|
||||
<div className="absolute top-full right-0 mt-1 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50">
|
||||
<div className="py-1">
|
||||
<div className="px-4 py-2 text-sm text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-600">
|
||||
user@example.com
|
||||
</div>
|
||||
<a
|
||||
href="#"
|
||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
个人资料
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
设置
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
帮助
|
||||
</a>
|
||||
<div className="border-t border-gray-200 dark:border-gray-600 my-1"></div>
|
||||
<a
|
||||
href="#"
|
||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
退出登录
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* 面包屑导航 */}
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="max-w-7xl mx-auto px-4 py-3">
|
||||
<nav className="flex" aria-label="Breadcrumb">
|
||||
<ol className="flex items-center space-x-2">
|
||||
{breadcrumbs.map((crumb, crumbIdx) => {
|
||||
const isLast = crumbIdx === breadcrumbs.length - 1
|
||||
const showSeparator = crumbIdx > 0
|
||||
return (
|
||||
<li key={`${crumb}-${crumbIdx + 1}`} className="flex items-center">
|
||||
{showSeparator && (
|
||||
<svg
|
||||
className="h-4 w-4 text-gray-400 mx-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleBreadcrumbClick(crumbIdx)}
|
||||
className={`text-sm font-medium transition-colors ${
|
||||
isLast
|
||||
? 'text-gray-500 dark:text-gray-400 cursor-default'
|
||||
: 'text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300'
|
||||
}`}
|
||||
>
|
||||
{crumb}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* 标签页导航 */}
|
||||
<div className="mb-8">
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{[
|
||||
{ id: 'home', label: '概览', icon: '🏠' },
|
||||
{ id: 'products', label: '产品列表', icon: '📱' },
|
||||
{ id: 'orders', label: '订单管理', icon: '📦' },
|
||||
{ id: 'analytics', label: '数据分析', icon: '📊' },
|
||||
{ id: 'settings', label: '设置', icon: '⚙️' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-2">{tab.icon}</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标签页内容 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
{activeTab === 'home' && (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">概览</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
欢迎来到导航测试页面!这里展示了各种常见的导航模式。
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-blue-50 dark:bg-blue-900 p-4 rounded-lg">
|
||||
<h3 className="font-semibold text-blue-900 dark:text-blue-100">总销售额</h3>
|
||||
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">¥123,456</p>
|
||||
</div>
|
||||
<div className="bg-green-50 dark:bg-green-900 p-4 rounded-lg">
|
||||
<h3 className="font-semibold text-green-900 dark:text-green-100">订单数量</h3>
|
||||
<p className="text-2xl font-bold text-green-600 dark:text-green-400">1,234</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 dark:bg-purple-900 p-4 rounded-lg">
|
||||
<h3 className="font-semibold text-purple-900 dark:text-purple-100">用户数量</h3>
|
||||
<p className="text-2xl font-bold text-purple-600 dark:text-purple-400">5,678</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'products' && (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">产品列表</h2>
|
||||
<div className="space-y-4">
|
||||
{['iPhone 15 Pro', 'MacBook Air', 'iPad Pro', 'Apple Watch'].map((product) => (
|
||||
<div
|
||||
key={product}
|
||||
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-600 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">{product}</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">产品描述...</p>
|
||||
</div>
|
||||
<button className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md transition-colors">
|
||||
查看详情
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'orders' && (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">订单管理</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-600">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
订单号
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
客户
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
金额
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-600">
|
||||
{[
|
||||
{ id: '#001', customer: '张三', status: '已发货', amount: '¥1,299' },
|
||||
{ id: '#002', customer: '李四', status: '处理中', amount: '¥2,599' },
|
||||
{ id: '#003', customer: '王五', status: '已完成', amount: '¥899' },
|
||||
].map((order) => (
|
||||
<tr key={order.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
{order.id}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
{order.customer}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
order.status === '已完成'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: order.status === '已发货'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
||||
}`}
|
||||
>
|
||||
{order.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
{order.amount}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'analytics' && (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">数据分析</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
销售趋势
|
||||
</h3>
|
||||
<div className="h-32 bg-linear-to-r from-blue-400 to-purple-500 rounded-lg flex items-center justify-center text-white">
|
||||
📈 图表占位符
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
用户分布
|
||||
</h3>
|
||||
<div className="h-32 bg-linear-to-r from-green-400 to-blue-500 rounded-lg flex items-center justify-center text-white">
|
||||
🗺️ 地图占位符
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">设置</h2>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
通知设置
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
defaultChecked
|
||||
/>
|
||||
<span className="ml-2 text-gray-700 dark:text-gray-300">邮件通知</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="ml-2 text-gray-700 dark:text-gray-300">短信通知</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
隐私设置
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
defaultChecked
|
||||
/>
|
||||
<span className="ml-2 text-gray-700 dark:text-gray-300">公开个人资料</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
defaultChecked
|
||||
/>
|
||||
<span className="ml-2 text-gray-700 dark:text-gray-300">允许搜索</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="mt-8 flex flex-wrap gap-4">
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md transition-colors"
|
||||
>
|
||||
打开模态框
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBreadcrumbs([...breadcrumbs, `新页面${breadcrumbs.length}`])}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-md transition-colors"
|
||||
>
|
||||
添加面包屑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newNotif = {
|
||||
id: Date.now(),
|
||||
title: '新通知',
|
||||
content: `这是第 ${notifications.length + 1} 条通知`,
|
||||
time: '刚刚',
|
||||
unread: true,
|
||||
}
|
||||
setNotifications((prev) => [newNotif, ...prev])
|
||||
}}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white px-6 py-2 rounded-md transition-colors"
|
||||
>
|
||||
添加通知
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 通知列表 */}
|
||||
{notifications.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">通知中心</h3>
|
||||
<div className="space-y-2">
|
||||
{notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`p-4 rounded-lg border cursor-pointer transition-colors ${
|
||||
notification.unread
|
||||
? 'bg-blue-50 dark:bg-blue-900 border-blue-200 dark:border-blue-700'
|
||||
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
onClick={() => markNotificationAsRead(notification.id)}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">
|
||||
{notification.title}
|
||||
</h4>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm">
|
||||
{notification.content}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{notification.time}
|
||||
</span>
|
||||
{notification.unread && (
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 模态框 */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">模态框标题</h3>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
这是一个模态框示例,用于测试弹窗交互。Agent 需要能够识别并操作这类覆盖层元素。
|
||||
</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors"
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 返回链接 */}
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
<Link href="/" className="text-blue-600 hover:text-blue-500 dark:text-blue-400">
|
||||
← 返回测试页面列表
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
25
packages/website/src/test-pages/router.tsx
Normal file
25
packages/website/src/test-pages/router.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Route, Switch } from 'wouter'
|
||||
|
||||
import AsyncTestPage from './async-test'
|
||||
import ComplexTestPage from './complex-test'
|
||||
import ErrorTestPage from './error-test'
|
||||
import FormTestPage from './form-test'
|
||||
import IndexPage from './index'
|
||||
import ListTestPage from './list-test'
|
||||
import NavigationTestPage from './navigation-test'
|
||||
|
||||
export default function Router() {
|
||||
return (
|
||||
<>
|
||||
<Switch>
|
||||
<Route path="/form" component={FormTestPage} />
|
||||
<Route path="/navigation" component={NavigationTestPage} />
|
||||
<Route path="/list" component={ListTestPage} />
|
||||
<Route path="/complex" component={ComplexTestPage} />
|
||||
<Route path="/errors" component={ErrorTestPage} />
|
||||
<Route path="/async" component={AsyncTestPage} />
|
||||
<Route path="" component={IndexPage} />
|
||||
</Switch>
|
||||
</>
|
||||
)
|
||||
}
|
||||
17
packages/website/tsconfig.json
Normal file
17
packages/website/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
// Self root
|
||||
"@/*": ["src/*"],
|
||||
|
||||
// Simplified monorepo solution (raw npm workspace with hoisting)
|
||||
"page-agent": ["../page-agent/src/PageAgent.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "env.d.ts"],
|
||||
"references": [{ "path": "../page-agent" }]
|
||||
}
|
||||
30
packages/website/vite.config.js
Normal file
30
packages/website/vite.config.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import 'dotenv/config'
|
||||
import process from 'node:process'
|
||||
import { dirname, resolve } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// Website Config (React Documentation Site)
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
clearScreen: false,
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
// Self root
|
||||
'@': resolve(__dirname, 'src'),
|
||||
|
||||
// Simplified monorepo solution (raw npm workspace with hoisting)
|
||||
'page-agent': resolve(__dirname, '../page-agent/src/PageAgent.ts'),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
'import.meta.env.LLM_MODEL_NAME': JSON.stringify(process.env.LLM_MODEL_NAME),
|
||||
'import.meta.env.LLM_API_KEY': JSON.stringify(process.env.LLM_API_KEY),
|
||||
'import.meta.env.LLM_BASE_URL': JSON.stringify(process.env.LLM_BASE_URL),
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user