Compare commits

...

7 Commits

Author SHA1 Message Date
Simon 67f6a56a87 chore(version): bump version to 1.1.2 2026-02-09 17:50:34 +08:00
Simon 6f0ff1fd33 refactor: zod tree-shaking; better error handling in agent steps 2026-02-09 17:49:10 +08:00
Simon 6fdb8d03f2 docs: trouble shooting 2026-02-09 16:32:58 +08:00
Simon d00a8dcc21 feat: improve prompt; update model list 2026-02-09 15:50:20 +08:00
Simon 851e9ef158 docs: model list 2026-02-06 18:49:06 +08:00
Simon 8ea8ce9acd fix(llms): gpt-5-mini temperature 2026-02-06 17:19:06 +08:00
Simon 9bd4a47d35 feat(ext): add includeInitialTab option; change main world API 2026-02-04 19:22:06 +08:00
33 changed files with 871 additions and 375 deletions
+3 -3
View File
@@ -40,7 +40,7 @@
```html ```html
<script <script
src="https://registry.npmmirror.com/page-agent/1.1.0/files/dist/iife/page-agent.demo.js" src="https://registry.npmmirror.com/page-agent/1.1.2/files/dist/iife/page-agent.demo.js"
crossorigin="true" crossorigin="true"
></script> ></script>
``` ```
@@ -50,8 +50,8 @@
| Mirrors | URL | | Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- | | ------- | ---------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.1.0/dist/iife/page-agent.demo.js | | Global | https://cdn.jsdelivr.net/npm/page-agent@1.1.2/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.1.0/files/dist/iife/page-agent.demo.js | | China | https://registry.npmmirror.com/page-agent/1.1.2/files/dist/iife/page-agent.demo.js |
### NPM 安装 ### NPM 安装
+3 -3
View File
@@ -40,7 +40,7 @@ Fastest way to try PageAgent with our free Demo LLM:
```html ```html
<script <script
src="https://cdn.jsdelivr.net/npm/page-agent@1.1.0/dist/iife/page-agent.demo.js" src="https://cdn.jsdelivr.net/npm/page-agent@1.1.2/dist/iife/page-agent.demo.js"
crossorigin="true" crossorigin="true"
></script> ></script>
``` ```
@@ -50,8 +50,8 @@ Fastest way to try PageAgent with our free Demo LLM:
| Mirrors | URL | | Mirrors | URL |
| ------- | ---------------------------------------------------------------------------------- | | ------- | ---------------------------------------------------------------------------------- |
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.1.0/dist/iife/page-agent.demo.js | | Global | https://cdn.jsdelivr.net/npm/page-agent@1.1.2/dist/iife/page-agent.demo.js |
| China | https://registry.npmmirror.com/page-agent/1.1.0/files/dist/iife/page-agent.demo.js | | China | https://registry.npmmirror.com/page-agent/1.1.2/files/dist/iife/page-agent.demo.js |
### NPM Installation ### NPM Installation
+33 -22
View File
@@ -1,12 +1,12 @@
{ {
"name": "root", "name": "root",
"version": "1.1.0", "version": "1.1.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "root", "name": "root",
"version": "1.1.0", "version": "1.1.2",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"packages/page-controller", "packages/page-controller",
@@ -41,6 +41,7 @@
"typescript-eslint": "^8.54.0", "typescript-eslint": "^8.54.0",
"unplugin-dts": "^1.0.0-beta.6", "unplugin-dts": "^1.0.0-beta.6",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-bundle-analyzer": "^1.3.6",
"vite-plugin-css-injected-by-js": "^3.5.2" "vite-plugin-css-injected-by-js": "^3.5.2"
}, },
"engines": { "engines": {
@@ -1639,9 +1640,9 @@
} }
}, },
"node_modules/@isaacs/brace-expansion": { "node_modules/@isaacs/brace-expansion": {
"version": "5.0.0", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -10411,6 +10412,16 @@
} }
} }
}, },
"node_modules/vite-bundle-analyzer": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/vite-bundle-analyzer/-/vite-bundle-analyzer-1.3.6.tgz",
"integrity": "sha512-elFXkF9oGy4CJEeOdP1DSEHRDKWfmaEDfT9/GuF4jmyfmUF6nC//iN+ythqMEVvrtchOEHGKLumZwnu1NjoI0w==",
"dev": true,
"license": "MIT",
"bin": {
"analyze": "dist/bin.js"
}
},
"node_modules/vite-node": { "node_modules/vite-node": {
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-5.3.0.tgz", "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-5.3.0.tgz",
@@ -11049,24 +11060,24 @@
}, },
"packages/core": { "packages/core": {
"name": "@page-agent/core", "name": "@page-agent/core",
"version": "1.1.0", "version": "1.1.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@page-agent/llms": "1.1.0", "@page-agent/llms": "1.1.2",
"@page-agent/page-controller": "1.1.0", "@page-agent/page-controller": "1.1.2",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"zod": "^4.3.5" "zod": "^4.3.5"
} }
}, },
"packages/extension": { "packages/extension": {
"name": "@page-agent/ext", "name": "@page-agent/ext",
"version": "0.1.2", "version": "0.1.3",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@page-agent/core": "1.1.0", "@page-agent/core": "1.1.2",
"@page-agent/llms": "1.1.0", "@page-agent/llms": "1.1.2",
"@page-agent/page-controller": "1.1.0", "@page-agent/page-controller": "1.1.2",
"@page-agent/ui": "1.1.0", "@page-agent/ui": "1.1.2",
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
@@ -11100,7 +11111,7 @@
}, },
"packages/llms": { "packages/llms": {
"name": "@page-agent/llms", "name": "@page-agent/llms",
"version": "1.1.0", "version": "1.1.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"chalk": "^5.6.2", "chalk": "^5.6.2",
@@ -11108,20 +11119,20 @@
} }
}, },
"packages/page-agent": { "packages/page-agent": {
"version": "1.1.0", "version": "1.1.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@page-agent/core": "1.1.0", "@page-agent/core": "1.1.2",
"@page-agent/llms": "1.1.0", "@page-agent/llms": "1.1.2",
"@page-agent/page-controller": "1.1.0", "@page-agent/page-controller": "1.1.2",
"@page-agent/ui": "1.1.0", "@page-agent/ui": "1.1.2",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"zod": "^4.3.5" "zod": "^4.3.5"
} }
}, },
"packages/page-controller": { "packages/page-controller": {
"name": "@page-agent/page-controller", "name": "@page-agent/page-controller",
"version": "1.1.0", "version": "1.1.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ai-motion": "^0.4.8" "ai-motion": "^0.4.8"
@@ -11129,12 +11140,12 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@page-agent/ui", "name": "@page-agent/ui",
"version": "1.1.0", "version": "1.1.2",
"license": "MIT" "license": "MIT"
}, },
"packages/website": { "packages/website": {
"name": "@page-agent/website", "name": "@page-agent/website",
"version": "1.1.0", "version": "1.1.2",
"dependencies": { "dependencies": {
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
+3 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "root", "name": "root",
"private": true, "private": true,
"version": "1.1.0", "version": "1.1.2",
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
"packages/page-controller", "packages/page-controller",
@@ -59,7 +59,8 @@
"typescript-eslint": "^8.54.0", "typescript-eslint": "^8.54.0",
"unplugin-dts": "^1.0.0-beta.6", "unplugin-dts": "^1.0.0-beta.6",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-plugin-css-injected-by-js": "^3.5.2" "vite-plugin-css-injected-by-js": "^3.5.2",
"vite-bundle-analyzer": "^1.3.6"
}, },
"overrides": { "overrides": {
"typescript": "^5.9.3" "typescript": "^5.9.3"
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/core", "name": "@page-agent/core",
"private": false, "private": false,
"version": "1.1.0", "version": "1.1.2",
"type": "module", "type": "module",
"main": "./dist/esm/page-agent-core.js", "main": "./dist/esm/page-agent-core.js",
"module": "./dist/esm/page-agent-core.js", "module": "./dist/esm/page-agent-core.js",
@@ -45,7 +45,7 @@
"dependencies": { "dependencies": {
"chalk": "^5.6.2", "chalk": "^5.6.2",
"zod": "^4.3.5", "zod": "^4.3.5",
"@page-agent/llms": "1.1.0", "@page-agent/llms": "1.1.2",
"@page-agent/page-controller": "1.1.0" "@page-agent/page-controller": "1.1.2"
} }
} }
+36 -33
View File
@@ -5,13 +5,13 @@
import { InvokeError, LLM, type Tool } from '@page-agent/llms' import { InvokeError, LLM, type Tool } from '@page-agent/llms'
import type { PageController } from '@page-agent/page-controller' import type { PageController } from '@page-agent/page-controller'
import chalk from 'chalk' import chalk from 'chalk'
import zod from 'zod' import * as zod from 'zod'
import { type PageAgentConfig } from './config' import { type PageAgentConfig } from './config'
import { DEFAULT_MAX_STEPS } from './config/constants' import { DEFAULT_MAX_STEPS } from './config/constants'
import SYSTEM_PROMPT from './prompts/system_prompt.md?raw' import SYSTEM_PROMPT from './prompts/system_prompt.md?raw'
import { tools } from './tools' import { tools } from './tools'
import { import type {
AgentActivity, AgentActivity,
AgentReflection, AgentReflection,
AgentStatus, AgentStatus,
@@ -21,8 +21,7 @@ import {
MacroToolInput, MacroToolInput,
MacroToolResult, MacroToolResult,
} from './types' } from './types'
import { normalizeResponse, trimLines, uid, waitFor } from './utils' import { assert, normalizeResponse, trimLines, uid, waitFor } from './utils'
import { assert } from './utils/assert'
export { type PageAgentConfig } export { type PageAgentConfig }
export { tool, type PageAgentTool } from './tools' export { tool, type PageAgentTool } from './tools'
@@ -209,20 +208,20 @@ export class PageAgentCore extends EventTarget {
lastURL: '', lastURL: '',
} }
try { let step = 0
let step = 0
while (true) {
try {
console.group(`step: ${step}`)
while (true) {
await this.#generateObservations(step) await this.#generateObservations(step)
await onBeforeStep?.(this, step) await onBeforeStep?.(this, step)
console.group(`step: ${step}`)
// abort // abort
if (this.#abortController.signal.aborted) throw new Error('AbortError') if (this.#abortController.signal.aborted) throw new Error('AbortError')
// Thinking // status
console.log(chalk.blue('Thinking...')) console.log(chalk.blue('Thinking...'))
this.#emitActivity({ type: 'thinking' }) this.#emitActivity({ type: 'thinking' })
@@ -270,22 +269,13 @@ export class PageAgentCore extends EventTarget {
// //
await onAfterStep?.(this, this.history)
console.log(chalk.green('Step finished:'), actionName) console.log(chalk.green('Step finished:'), actionName)
console.groupEnd() console.groupEnd()
await onAfterStep?.(this, this.history) // finish task if done
step++
if (step > this.config.maxSteps) {
this.#onDone(false)
const result: ExecutionResult = {
success: false,
data: 'Step count exceeded maximum limit',
history: this.history,
}
await onAfterTask?.(this, result)
return result
}
if (actionName === 'done') { if (actionName === 'done') {
const success = action.input?.success ?? false const success = action.input?.success ?? false
const text = action.input?.text || 'no text provided' const text = action.input?.text || 'no text provided'
@@ -299,19 +289,32 @@ export class PageAgentCore extends EventTarget {
await onAfterTask?.(this, result) await onAfterTask?.(this, result)
return result return result
} }
} catch (error: unknown) {
console.groupEnd() // to prevent nested groups
console.error('Task failed', error)
const errorMessage = String(error)
this.#emitActivity({ type: 'error', message: errorMessage })
this.#onDone(false)
const result: ExecutionResult = {
success: false,
data: errorMessage,
history: this.history,
}
await onAfterTask?.(this, result)
return result
} }
} catch (error: unknown) {
console.error('Task failed', error) step++
const errorMessage = String(error) if (step > this.config.maxSteps) {
this.#emitActivity({ type: 'error', message: errorMessage }) this.#onDone(false)
this.#onDone(false) const result: ExecutionResult = {
const result: ExecutionResult = { success: false,
success: false, data: 'Step count exceeded maximum limit',
data: errorMessage, history: this.history,
history: this.history, }
await onAfterTask?.(this, result)
return result
} }
await onAfterTask?.(this, result)
return result
} }
} }
+4 -4
View File
@@ -144,12 +144,12 @@ Here are examples of good output patterns. Use them as reference but never copy
</examples> </examples>
<output> <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.", "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.", "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." "next_goal": "State the next immediate goal and action to achieve it, in one clear sentence.",
"action":{"action": {// action-specific parameter}} "action":{
"Action name": {// Action parameters}
}
} }
</output> </output>
+2 -2
View File
@@ -2,7 +2,7 @@
* Internal tools for PageAgent. * Internal tools for PageAgent.
* @note Adapted from browser-use * @note Adapted from browser-use
*/ */
import zod, { type z } from 'zod' import * as zod from 'zod'
import type { PageAgentCore } from '../PageAgentCore' import type { PageAgentCore } from '../PageAgentCore'
import { waitFor } from '../utils' import { waitFor } from '../utils'
@@ -13,7 +13,7 @@ import { waitFor } from '../utils'
export interface PageAgentTool<TParams = any> { export interface PageAgentTool<TParams = any> {
// name: string // name: string
description: string description: string
inputSchema: z.ZodType<TParams> inputSchema: zod.ZodType<TParams>
execute: (this: PageAgentCore, args: TParams) => Promise<string> execute: (this: PageAgentCore, args: TParams) => Promise<string>
} }
-17
View File
@@ -1,17 +0,0 @@
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)
}
}
+18
View File
@@ -1,3 +1,5 @@
import chalk from 'chalk'
export { normalizeResponse } from './autoFixer' export { normalizeResponse } from './autoFixer'
/** /**
@@ -85,3 +87,19 @@ export function uid() {
ids.push(id) ids.push(id)
return id return id
} }
/**
* 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)
}
}
+79 -71
View File
@@ -33,7 +33,6 @@ import type {
AgentStatus, AgentStatus,
ExecutionResult, ExecutionResult,
HistoricalEvent, HistoricalEvent,
LLMConfig,
} from '@page-agent/core' } from '@page-agent/core'
// Wait for extension injection (up to 1 second) // Wait for extension injection (up to 1 second)
@@ -48,18 +47,13 @@ async function waitForExtension(timeout = 1000): Promise<boolean> {
// Usage // Usage
if (await waitForExtension()) { if (await waitForExtension()) {
const result = await window.PAGE_AGENT_EXT!.execute( const result = await window.PAGE_AGENT_EXT!.execute('Click the login button', {
'Click the login button', baseURL: 'https://api.openai.com/v1',
{ apiKey: 'your-api-key',
baseURL: 'https://api.openai.com/v1', model: 'gpt-5.2',
apiKey: 'your-api-key', onStatusChange: (status) => console.log('Status:', status),
model: 'gpt-5.2', onActivity: (activity) => console.log('Activity:', activity),
}, })
{
onStatusChange: (status) => console.log('Status:', status),
onActivity: (activity) => console.log('Activity:', activity),
}
)
console.log('Result:', result) console.log('Result:', result)
} }
``` ```
@@ -76,7 +70,7 @@ Extension version string (e.g., `"1.0.0"`). This is exposed separately to allow
Main API namespace object containing: Main API namespace object containing:
#### `PAGE_AGENT_EXT.execute(task, llmConfig, hooks?)` #### `PAGE_AGENT_EXT.execute(task, config)`
Execute an agent task. Execute an agent task.
@@ -85,8 +79,7 @@ Execute an agent task.
| Name | Type | Required | Description | | Name | Type | Required | Description |
|------|------|----------|-------------| |------|------|----------|-------------|
| `task` | `string` | Yes | Task description | | `task` | `string` | Yes | Task description |
| `llmConfig` | `LLMConfig` | Yes | LLM configuration | | `config` | `ExecuteConfig` | Yes | Execution configuration (LLM settings, options, and event callbacks) |
| `hooks` | `ExecuteHooks` | No | Event callbacks |
**Returns:** `Promise<ExecutionResult>` **Returns:** `Promise<ExecutionResult>`
@@ -104,21 +97,26 @@ import type {
AgentStatus, AgentStatus,
ExecutionResult, ExecutionResult,
HistoricalEvent, HistoricalEvent,
LLMConfig,
} from '@page-agent/core' } from '@page-agent/core'
export interface ExecuteHooks { export interface ExecuteConfig {
baseURL: string
apiKey: string
model: string
/**
* Whether to include the initial tab (that holds this main world script) in the task.
* @default true
*/
includeInitialTab?: boolean
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void onDispose?: () => void
} }
export type Execute = ( export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
task: string,
llmConfig: LLMConfig,
hooks?: ExecuteHooks
) => Promise<ExecutionResult>
``` ```
### AgentStatus ### AgentStatus
@@ -164,16 +162,6 @@ type HistoricalEvent =
| { type: 'error'; message: string; rawResponse?: unknown } | { type: 'error'; message: string; rawResponse?: unknown }
``` ```
### LLMConfig
```typescript
interface LLMConfig {
baseURL: string // e.g. 'https://api.openai.com/v1'
apiKey: string
model: string // e.g. 'gpt-5.2'
}
```
### ExecutionResult ### ExecutionResult
```typescript ```typescript
@@ -205,44 +193,63 @@ if (result.success) {
} }
``` ```
### With Event Hooks ### Exclude Initial Tab
By default, the agent includes the initial tab (where the script runs) in the task. Set `includeInitialTab: false` to exclude it:
```typescript ```typescript
await window.PAGE_AGENT_EXT!.execute( const result = await window.PAGE_AGENT_EXT!.execute(
'Navigate to the settings page', 'Open a new tab and search for page-agent on GitHub',
llmConfig,
{ {
onStatusChange: (status) => { baseURL: 'https://api.openai.com/v1',
updateUI({ agentStatus: status }) apiKey: process.env.OPENAI_API_KEY!,
}, model: 'gpt-5.2',
onActivity: (activity) => { includeInitialTab: false, // Agent will open new tabs only
switch (activity.type) {
case 'thinking':
showSpinner('Agent is thinking...')
break
case 'executing':
showSpinner(`Executing: ${activity.tool}`)
break
case 'executed':
log(`${activity.tool} completed in ${activity.duration}ms`)
break
case 'error':
showError(activity.message)
break
}
},
onHistoryUpdate: (history) => {
renderHistory(history)
},
} }
) )
``` ```
### With Event Callbacks
```typescript
await window.PAGE_AGENT_EXT!.execute('Navigate to the settings page', {
baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-5.2',
onStatusChange: (status) => {
updateUI({ agentStatus: status })
},
onActivity: (activity) => {
switch (activity.type) {
case 'thinking':
showSpinner('Agent is thinking...')
break
case 'executing':
showSpinner(`Executing: ${activity.tool}`)
break
case 'executed':
log(`${activity.tool} completed in ${activity.duration}ms`)
break
case 'error':
showError(activity.message)
break
}
},
onHistoryUpdate: (history) => {
renderHistory(history)
},
})
```
### Stop Execution ### Stop Execution
```typescript ```typescript
// Start a task // Start a task
window.PAGE_AGENT_EXT!.execute('Scroll through all pages', llmConfig) window.PAGE_AGENT_EXT!.execute('Scroll through all pages', {
baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-5.2',
})
// Later, stop it // Later, stop it
window.PAGE_AGENT_EXT!.dispose() window.PAGE_AGENT_EXT!.dispose()
@@ -258,24 +265,25 @@ import type {
AgentStatus, AgentStatus,
ExecutionResult, ExecutionResult,
HistoricalEvent, HistoricalEvent,
LLMConfig,
} from '@page-agent/core' } from '@page-agent/core'
interface ExecuteConfig {
baseURL: string
apiKey: string
model: string
includeInitialTab?: boolean
onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
}
declare global { declare global {
interface Window { interface Window {
PAGE_AGENT_EXT_VERSION?: string PAGE_AGENT_EXT_VERSION?: string
PAGE_AGENT_EXT?: { PAGE_AGENT_EXT?: {
version: string version: string
execute: ( execute: (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
task: string,
llmConfig: LLMConfig,
hooks?: {
onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
}
) => Promise<ExecutionResult>
dispose: () => void dispose: () => void
} }
} }
+81 -73
View File
@@ -33,7 +33,6 @@ import type {
AgentStatus, AgentStatus,
ExecutionResult, ExecutionResult,
HistoricalEvent, HistoricalEvent,
LLMConfig,
} from '@page-agent/core' } from '@page-agent/core'
// 等待插件注入(最多 1 秒) // 等待插件注入(最多 1 秒)
@@ -48,18 +47,13 @@ async function waitForExtension(timeout = 1000): Promise<boolean> {
// 使用 // 使用
if (await waitForExtension()) { if (await waitForExtension()) {
const result = await window.PAGE_AGENT_EXT!.execute( const result = await window.PAGE_AGENT_EXT!.execute('点击登录按钮', {
'点击登录按钮', baseURL: 'https://api.openai.com/v1',
{ apiKey: 'your-api-key',
baseURL: 'https://api.openai.com/v1', model: 'gpt-5.2',
apiKey: 'your-api-key', onStatusChange: (status) => console.log('状态:', status),
model: 'gpt-5.2', onActivity: (activity) => console.log('活动:', activity),
}, })
{
onStatusChange: (status) => console.log('状态:', status),
onActivity: (activity) => console.log('活动:', activity),
}
)
console.log('结果:', result) console.log('结果:', result)
} }
``` ```
@@ -76,7 +70,7 @@ if (await waitForExtension()) {
主 API 命名空间对象,包含: 主 API 命名空间对象,包含:
#### `PAGE_AGENT_EXT.execute(task, llmConfig, hooks?)` #### `PAGE_AGENT_EXT.execute(task, config)`
执行 Agent 任务。 执行 Agent 任务。
@@ -85,8 +79,7 @@ if (await waitForExtension()) {
| 名称 | 类型 | 必填 | 说明 | | 名称 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| `task` | `string` | 是 | 任务描述 | | `task` | `string` | 是 | 任务描述 |
| `llmConfig` | `LLMConfig` | 是 | LLM 配置 | | `config` | `ExecuteConfig` | 是 | 执行配置(LLM 设置、选项和事件回调) |
| `hooks` | `ExecuteHooks` | 否 | 事件回调 |
**返回:** `Promise<ExecutionResult>` **返回:** `Promise<ExecutionResult>`
@@ -104,21 +97,26 @@ import type {
AgentStatus, AgentStatus,
ExecutionResult, ExecutionResult,
HistoricalEvent, HistoricalEvent,
LLMConfig,
} from '@page-agent/core' } from '@page-agent/core'
export interface ExecuteHooks { export interface ExecuteConfig {
baseURL: string
apiKey: string
model: string
/**
* 是否将初始标签页(运行此脚本的页面)包含在任务中。
* @default true
*/
includeInitialTab?: boolean
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void onDispose?: () => void
} }
export type Execute = ( export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
task: string,
llmConfig: LLMConfig,
hooks?: ExecuteHooks
) => Promise<ExecutionResult>
``` ```
### AgentStatus ### AgentStatus
@@ -164,16 +162,6 @@ type HistoricalEvent =
| { type: 'error'; message: string; rawResponse?: unknown } | { type: 'error'; message: string; rawResponse?: unknown }
``` ```
### LLMConfig
```typescript
interface LLMConfig {
baseURL: string // 例如 'https://api.openai.com/v1'
apiKey: string
model: string // 例如 'gpt-5.2'
}
```
### ExecutionResult ### ExecutionResult
```typescript ```typescript
@@ -205,44 +193,63 @@ if (result.success) {
} }
``` ```
### 排除初始标签页
默认情况下,Agent 会将初始标签页(运行脚本的页面)包含在任务中。设置 `includeInitialTab: false` 可以排除它:
```typescript
const result = await window.PAGE_AGENT_EXT!.execute(
'打开新标签页并在 GitHub 上搜索 page-agent',
{
baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-5.2',
includeInitialTab: false, // Agent 只会打开新标签页
}
)
```
### 使用事件回调 ### 使用事件回调
```typescript ```typescript
await window.PAGE_AGENT_EXT!.execute( await window.PAGE_AGENT_EXT!.execute('导航到设置页面', {
'导航到设置页面', baseURL: 'https://api.openai.com/v1',
llmConfig, apiKey: process.env.OPENAI_API_KEY!,
{ model: 'gpt-5.2',
onStatusChange: (status) => { onStatusChange: (status) => {
updateUI({ agentStatus: status }) updateUI({ agentStatus: status })
}, },
onActivity: (activity) => { onActivity: (activity) => {
switch (activity.type) { switch (activity.type) {
case 'thinking': case 'thinking':
showSpinner('Agent 正在思考...') showSpinner('Agent 正在思考...')
break break
case 'executing': case 'executing':
showSpinner(`正在执行: ${activity.tool}`) showSpinner(`正在执行: ${activity.tool}`)
break break
case 'executed': case 'executed':
log(`${activity.tool} 完成,耗时 ${activity.duration}ms`) log(`${activity.tool} 完成,耗时 ${activity.duration}ms`)
break break
case 'error': case 'error':
showError(activity.message) showError(activity.message)
break break
} }
}, },
onHistoryUpdate: (history) => { onHistoryUpdate: (history) => {
renderHistory(history) renderHistory(history)
}, },
} })
)
``` ```
### 停止执行 ### 停止执行
```typescript ```typescript
// 启动任务 // 启动任务
window.PAGE_AGENT_EXT!.execute('滚动浏览所有页面', llmConfig) window.PAGE_AGENT_EXT!.execute('滚动浏览所有页面', {
baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-5.2',
})
// 稍后停止 // 稍后停止
window.PAGE_AGENT_EXT!.dispose() window.PAGE_AGENT_EXT!.dispose()
@@ -258,24 +265,25 @@ import type {
AgentStatus, AgentStatus,
ExecutionResult, ExecutionResult,
HistoricalEvent, HistoricalEvent,
LLMConfig,
} from '@page-agent/core' } from '@page-agent/core'
interface ExecuteConfig {
baseURL: string
apiKey: string
model: string
includeInitialTab?: boolean
onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
}
declare global { declare global {
interface Window { interface Window {
PAGE_AGENT_EXT_VERSION?: string PAGE_AGENT_EXT_VERSION?: string
PAGE_AGENT_EXT?: { PAGE_AGENT_EXT?: {
version: string version: string
execute: ( execute: (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
task: string,
llmConfig: LLMConfig,
hooks?: {
onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void
}
) => Promise<ExecutionResult>
dispose: () => void dispose: () => void
} }
} }
+5 -5
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/ext", "name": "@page-agent/ext",
"private": true, "private": true,
"version": "0.1.2", "version": "0.1.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "wxt", "dev": "wxt",
@@ -23,10 +23,10 @@
"wxt": "^0.20.13" "wxt": "^0.20.13"
}, },
"dependencies": { "dependencies": {
"@page-agent/core": "1.1.0", "@page-agent/core": "1.1.2",
"@page-agent/llms": "1.1.0", "@page-agent/llms": "1.1.2",
"@page-agent/page-controller": "1.1.0", "@page-agent/page-controller": "1.1.2",
"@page-agent/ui": "1.1.0", "@page-agent/ui": "1.1.2",
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
@@ -17,7 +17,7 @@ function detectLanguage(): 'en-US' | 'zh-CN' {
* - can be used from a side panel or a content script * - can be used from a side panel or a content script
*/ */
export class MultiPageAgent extends PageAgentCore { export class MultiPageAgent extends PageAgentCore {
constructor(config: Omit<PageAgentConfig, 'pageController'>) { constructor(config: Omit<PageAgentConfig, 'pageController'> & { includeInitialTab?: boolean }) {
// multi page controller // multi page controller
const tabsController = new TabsController() const tabsController = new TabsController()
const pageController = new RemotePageController(tabsController) const pageController = new RemotePageController(tabsController)
@@ -31,6 +31,9 @@ export class MultiPageAgent extends PageAgentCore {
`Default working language: **${targetLanguage}**` `Default working language: **${targetLanguage}**`
) )
// include initial tab for controlling
const includeInitialTab = config.includeInitialTab ?? true
/** /**
* When the agent is in side-panel and user closed the side-panel. * When the agent is in side-panel and user closed the side-panel.
* There is no chance for isAgentRunning to be set false. * There is no chance for isAgentRunning to be set false.
@@ -47,7 +50,7 @@ export class MultiPageAgent extends PageAgentCore {
customSystemPrompt: systemPrompt, customSystemPrompt: systemPrompt,
onBeforeTask: async (agent) => { onBeforeTask: async (agent) => {
await tabsController.init(agent.task) await tabsController.init(agent.task, includeInitialTab)
heartBeatInterval = window.setInterval(() => { heartBeatInterval = window.setInterval(() => {
chrome.storage.local.set({ chrome.storage.local.set({
+13 -7
View File
@@ -12,7 +12,7 @@ export class TabsController extends EventTarget {
private task: string = '' private task: string = ''
private windowId: number | null = null private windowId: number | null = null
async init(task: string) { async init(task: string, includeInitialTab: boolean = true) {
this.task = task this.task = task
this.tabs = [] this.tabs = []
this.currentTabId = null this.currentTabId = null
@@ -26,17 +26,19 @@ export class TabsController extends EventTarget {
}) })
this.initialTabId = result.tabId this.initialTabId = result.tabId
this.currentTabId = result.tabId
this.tabs.push({
id: result.tabId,
isInitial: true,
})
if (!this.initialTabId) { if (!this.initialTabId) {
throw new Error('Failed to get initial tab ID') throw new Error('Failed to get initial tab ID')
} }
if (includeInitialTab) {
this.currentTabId = this.initialTabId
this.tabs.push({
id: result.tabId,
isInitial: true,
})
}
await this.updateCurrentTabId(this.currentTabId) await this.updateCurrentTabId(this.currentTabId)
const tabChangeHandler = (message: any): void => { const tabChangeHandler = (message: any): void => {
@@ -230,6 +232,10 @@ export class TabsController extends EventTarget {
`| ${tab.id} | ${url} | ${title} | ${this.currentTabId === tab.id ? '✅' : ''} |` `| ${tab.id} | ${url} | ${title} | ${this.currentTabId === tab.id ? '✅' : ''} |`
) )
} }
if (!this.tabs.length) {
summaries.push('\nNo tabs available. Open a tab if needed.')
}
return summaries.join('\n') return summaries.join('\n')
} }
@@ -136,12 +136,12 @@ Here are examples of good output patterns. Use them as reference but never copy
</examples> </examples>
<output> <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.", "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.", "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." "next_goal": "State the next immediate goal and action to achieve it, in one clear sentence.",
"action":{"action_name": {// action-specific parameter}} "action":{
"Action name": {// Action parameters}
}
} }
</output> </output>
+1 -1
View File
@@ -6,7 +6,7 @@
* - switch_to_tab: Switch to an existing tab * - switch_to_tab: Switch to an existing tab
* - close_tab: Close a tab (optionally switch to another) * - close_tab: Close a tab (optionally switch to another)
*/ */
import zod from 'zod' import * as zod from 'zod'
import type { TabsController } from './TabsController' import type { TabsController } from './TabsController'
@@ -69,11 +69,11 @@ async function exposeAgentToPage() {
} }
try { try {
const { task, llmConfig } = payload const { task, config } = payload
// create when used // create when used
multiPageAgent = new MultiPageAgent(llmConfig) multiPageAgent = new MultiPageAgent(config)
// events // events
@@ -1,19 +1,24 @@
import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core' import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core'
import type { LLMConfig } from '@page-agent/llms'
export interface ExecuteHooks { export type Execute = (task: string, config: ExecuteConfig) => Promise<ExecutionResult>
export interface ExecuteConfig {
baseURL: string
apiKey: string
model: string
/**
* Whether to include the initial tab (that holds this main world script) in the task.
* @default true
*/
includeInitialTab?: boolean
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
onActivity?: (activity: AgentActivity) => void onActivity?: (activity: AgentActivity) => void
onHistoryUpdate?: (history: HistoricalEvent[]) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void
onDispose?: () => void onDispose?: () => void
} }
export type Execute = (
task: string,
llmConfig: LLMConfig,
hooks?: ExecuteHooks
) => Promise<ExecutionResult>
export default defineUnlistedScript(() => { export default defineUnlistedScript(() => {
let _lastId = 0 let _lastId = 0
function getId() { function getId() {
@@ -21,13 +26,13 @@ export default defineUnlistedScript(() => {
return _lastId return _lastId
} }
const execute: Execute = async (task, llmConfig, hooks) => { const execute: Execute = async (task, config) => {
if (typeof task !== 'string') throw new Error('Task must be a string') if (typeof task !== 'string') throw new Error('Task must be a string')
if (task.trim().length === 0) throw new Error('Task cannot be empty') if (task.trim().length === 0) throw new Error('Task cannot be empty')
if (!llmConfig) throw new Error('LLM config is required') if (!config) throw new Error('Config is required')
if (!llmConfig.baseURL) throw new Error('LLM config must have a baseURL') if (!config.baseURL) throw new Error('Config must have a baseURL')
if (!llmConfig.apiKey) throw new Error('LLM config must have an apiKey') if (!config.apiKey) throw new Error('Config must have an apiKey')
if (!llmConfig.model) throw new Error('LLM config must have a model') if (!config.model) throw new Error('Config must have a model')
const id = getId() const id = getId()
@@ -40,30 +45,31 @@ export default defineUnlistedScript(() => {
// events // events
if (data.action === 'status_change_event' && hooks?.onStatusChange) { if (data.action === 'status_change_event' && config.onStatusChange) {
hooks.onStatusChange(data.payload) config.onStatusChange(data.payload)
return return
} }
if (data.action === 'activity_event' && hooks?.onActivity) { if (data.action === 'activity_event' && config.onActivity) {
hooks.onActivity(data.payload) config.onActivity(data.payload)
return return
} }
if (data.action === 'history_change_event' && hooks?.onHistoryUpdate) { if (data.action === 'history_change_event' && config.onHistoryUpdate) {
hooks.onHistoryUpdate(data.payload) config.onHistoryUpdate(data.payload)
return return
} }
if (data.action === 'dispose_event' && hooks?.onDispose) { if (data.action === 'dispose_event' && config.onDispose) {
hooks.onDispose() config.onDispose()
window.removeEventListener('message', handleMessage)
return return
} }
// result
if (data.action !== 'execute_result') return if (data.action !== 'execute_result') return
// execute_result
window.removeEventListener('message', handleMessage) window.removeEventListener('message', handleMessage)
if (data.error) { if (data.error) {
@@ -73,6 +79,7 @@ export default defineUnlistedScript(() => {
} }
} }
// @note will be removed on dispose or result
window.addEventListener('message', handleMessage) window.addEventListener('message', handleMessage)
}) })
@@ -81,7 +88,15 @@ export default defineUnlistedScript(() => {
channel: 'PAGE_AGENT_EXT_REQUEST', channel: 'PAGE_AGENT_EXT_REQUEST',
id, id,
action: 'execute', action: 'execute',
payload: { task, llmConfig }, payload: {
task,
config: {
baseURL: config.baseURL,
apiKey: config.apiKey,
model: config.model,
includeInitialTab: config.includeInitialTab,
},
},
}, },
'*' '*'
) )
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/llms", "name": "@page-agent/llms",
"version": "1.1.0", "version": "1.1.2",
"type": "module", "type": "module",
"main": "./dist/lib/page-agent-llms.js", "main": "./dist/lib/page-agent-llms.js",
"module": "./dist/lib/page-agent-llms.js", "module": "./dist/lib/page-agent-llms.js",
+5 -1
View File
@@ -2,7 +2,7 @@
* Utility functions for LLM integration * Utility functions for LLM integration
*/ */
import chalk from 'chalk' import chalk from 'chalk'
import { z } from 'zod' import * as z from 'zod'
import type { Tool } from './types' import type { Tool } from './types'
@@ -74,6 +74,10 @@ export function modelPatch(body: Record<string, any>) {
} else if (modelName.startsWith('gpt-51')) { } else if (modelName.startsWith('gpt-51')) {
debug('Applying GPT-51 patch: disable reasoning') debug('Applying GPT-51 patch: disable reasoning')
body.reasoning_effort = 'none' body.reasoning_effort = 'none'
} else if (modelName.startsWith('gpt-5-mini')) {
debug('Applying GPT-5-mini patch: set reasoning effort to low, temperature to 1')
body.reasoning_effort = 'low'
body.temperature = 1
} else if (modelName.startsWith('gpt-5')) { } else if (modelName.startsWith('gpt-5')) {
debug('Applying GPT-5 patch: set reasoning effort to low') debug('Applying GPT-5 patch: set reasoning effort to low')
body.reasoning_effort = 'low' body.reasoning_effort = 'low'
+6 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "page-agent", "name": "page-agent",
"private": false, "private": false,
"version": "1.1.0", "version": "1.1.2",
"type": "module", "type": "module",
"main": "./dist/esm/page-agent.js", "main": "./dist/esm/page-agent.js",
"module": "./dist/esm/page-agent.js", "module": "./dist/esm/page-agent.js",
@@ -44,11 +44,11 @@
"postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\"" "postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
}, },
"dependencies": { "dependencies": {
"@page-agent/core": "1.1.2",
"@page-agent/llms": "1.1.2",
"@page-agent/page-controller": "1.1.2",
"@page-agent/ui": "1.1.2",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"zod": "^4.3.5", "zod": "^4.3.5"
"@page-agent/llms": "1.1.0",
"@page-agent/page-controller": "1.1.0",
"@page-agent/core": "1.1.0",
"@page-agent/ui": "1.1.0"
} }
} }
+6 -2
View File
@@ -3,6 +3,7 @@ import { config as dotenvConfig } from 'dotenv'
import { dirname, resolve } from 'path' import { dirname, resolve } from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
// import { analyzer } from 'vite-bundle-analyzer'
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js' import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -14,8 +15,11 @@ dotenvConfig({ path: resolve(__dirname, '../../.env') })
// - alias all local packages so that they can be build in // - alias all local packages so that they can be build in
// - no external // - no external
// - no d.ts. dts does not work with monorepo aliasing // - no d.ts. dts does not work with monorepo aliasing
export default defineConfig(({ mode }) => ({ export default defineConfig(() => ({
plugins: [cssInjectedByJsPlugin({ relativeCSSInjection: true })], plugins: [
cssInjectedByJsPlugin({ relativeCSSInjection: true }),
// analyzer()
],
publicDir: false, publicDir: false,
esbuild: { esbuild: {
keepNames: true, keepNames: true,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/page-controller", "name": "@page-agent/page-controller",
"version": "1.1.0", "version": "1.1.2",
"type": "module", "type": "module",
"main": "./dist/lib/page-controller.js", "main": "./dist/lib/page-controller.js",
"module": "./dist/lib/page-controller.js", "module": "./dist/lib/page-controller.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@page-agent/ui", "name": "@page-agent/ui",
"version": "1.1.0", "version": "1.1.2",
"type": "module", "type": "module",
"main": "./dist/lib/page-agent-ui.js", "main": "./dist/lib/page-agent-ui.js",
"module": "./dist/lib/page-agent-ui.js", "module": "./dist/lib/page-agent-ui.js",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@page-agent/website", "name": "@page-agent/website",
"private": true, "private": true,
"version": "1.1.0", "version": "1.1.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,8 +1,8 @@
// Demo build (auto-init with demo LLM, for quick testing) // Demo build (auto-init with demo LLM, for quick testing)
export const CDN_DEMO_URL = export const CDN_DEMO_URL =
'https://cdn.jsdelivr.net/npm/page-agent@1.1.0/dist/iife/page-agent.demo.js' 'https://cdn.jsdelivr.net/npm/page-agent@1.1.2/dist/iife/page-agent.demo.js'
export const CDN_DEMO_CN_URL = export const CDN_DEMO_CN_URL =
'https://registry.npmmirror.com/page-agent/1.1.0/files/dist/iife/page-agent.demo.js' 'https://registry.npmmirror.com/page-agent/1.1.2/files/dist/iife/page-agent.demo.js'
// Demo LLM for website testing // Demo LLM for website testing
export const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM' export const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
@@ -30,6 +30,10 @@ export default function DocsLayout({ children }: DocsLayoutProps) {
{ title: isZh ? '概览' : 'Overview', path: '/introduction/overview' }, { title: isZh ? '概览' : 'Overview', path: '/introduction/overview' },
{ title: isZh ? '快速开始' : 'Quick Start', path: '/introduction/quick-start' }, { title: isZh ? '快速开始' : 'Quick Start', path: '/introduction/quick-start' },
{ title: isZh ? '使用限制' : 'Limitations', path: '/introduction/limitations' }, { title: isZh ? '使用限制' : 'Limitations', path: '/introduction/limitations' },
{
title: isZh ? '故障排查' : 'Troubleshooting',
path: '/introduction/troubleshooting',
},
{ {
title: '🚧 ' + (isZh ? '最佳实践' : 'Best Practices'), title: '🚧 ' + (isZh ? '最佳实践' : 'Best Practices'),
path: '/integration/best-practices', path: '/integration/best-practices',
@@ -152,27 +152,24 @@ localStorage.setItem('PageAgentExtUserAuthToken', '<your-token-from-extension>')
</div> </div>
</section> </section>
<h3 className="text-xl font-semibold my-3"> <h3 className="text-xl font-semibold my-3">PAGE_AGENT_EXT.execute(task, config)</h3>
PAGE_AGENT_EXT.execute(task, llmConfig, hooks?)
</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4"> <p className="text-gray-600 dark:text-gray-300 mb-4">
{isZh {isZh
? '使用 LLM 配置执行任务。返回一个 Promise,在任务完成时 resolve。可选的 hooks 参数用于监听任务执行过程中的事件。' ? '使用配置执行任务。返回一个 Promise,在任务完成时 resolve。config 参数包含 LLM 设置、选项和事件回调。'
: 'Execute a task with LLM configuration. Returns a Promise that resolves when the task completes. Optional hooks parameter for listening to events during task execution.'} : 'Execute a task with configuration. Returns a Promise that resolves when the task completes. Config includes LLM settings, options, and event callbacks.'}
</p> </p>
<CodeEditor <CodeEditor
code={ code={
isZh isZh
? `// 使用 LLM 配置和 hooks 执行任务 ? `// 使用配置执行任务
const result = await window.PAGE_AGENT_EXT.execute( const result = await window.PAGE_AGENT_EXT.execute(
'在 GitHub 上搜索 "page-agent" 并打开第一个结果', '在 GitHub 上搜索 "page-agent" 并打开第一个结果',
{ {
baseURL: 'https://api.openai.com/v1', baseURL: 'https://api.openai.com/v1',
apiKey: 'your-api-key', apiKey: 'your-api-key',
model: 'gpt-5-2' model: 'gpt-5-2',
}, // includeInitialTab: false, // 设为 false 排除初始标签页
{
onStatusChange: status => console.log('状态变化:', status), onStatusChange: status => console.log('状态变化:', status),
onActivity: activity => console.log('活动:', activity), onActivity: activity => console.log('活动:', activity),
onHistoryUpdate: history => console.log('历史更新:', history), onHistoryUpdate: history => console.log('历史更新:', history),
@@ -181,15 +178,14 @@ const result = await window.PAGE_AGENT_EXT.execute(
) )
console.log(result) // 任务执行结果` console.log(result) // 任务执行结果`
: `// Execute a task with LLM configuration and hooks : `// Execute a task with configuration
const result = await window.PAGE_AGENT_EXT.execute( const result = await window.PAGE_AGENT_EXT.execute(
'Search for "page-agent" on GitHub and open the first result', 'Search for "page-agent" on GitHub and open the first result',
{ {
baseURL: 'https://api.openai.com/v1', baseURL: 'https://api.openai.com/v1',
apiKey: 'your-api-key', apiKey: 'your-api-key',
model: 'gpt-5-2' model: 'gpt-5-2',
}, // includeInitialTab: false, // Set to false to exclude initial tab
{
onStatusChange: status => console.log('Status change:', status), onStatusChange: status => console.log('Status change:', status),
onActivity: activity => console.log('Activity:', activity), onActivity: activity => console.log('Activity:', activity),
onHistoryUpdate: history => console.log('History update:', history), onHistoryUpdate: history => console.log('History update:', history),
@@ -221,41 +217,26 @@ window.PAGE_AGENT_EXT.dispose()`
/> />
</section> </section>
{/* LLM Config */} {/* ExecuteConfig */}
<section> <section>
<h2 className="text-2xl font-bold mb-4">{isZh ? 'LLM 配置' : 'LLM Configuration'}</h2> <h2 className="text-2xl font-bold mb-4">{isZh ? '执行配置' : 'Execute Configuration'}</h2>
<CodeEditor
code={
isZh
? `interface LLMConfig {
baseURL: string // LLM API 端点
apiKey: string // API 密钥
model: string // 模型名称
}`
: `interface LLMConfig {
baseURL: string // LLM API endpoint
apiKey: string // API key
model: string // Model name
}`
}
language="typescript"
/>
</section>
{/* Execute Hooks */}
<section>
<h2 className="text-2xl font-bold mb-4">{isZh ? 'Execute Hooks' : 'Execute Hooks'}</h2>
<p className="text-gray-600 dark:text-gray-300 mb-4"> <p className="text-gray-600 dark:text-gray-300 mb-4">
{isZh {isZh
? '通过 hooks 参数,你可以监听任务执行过程中的各种事件,实现实时更新 UI、日志记录等功能。' ? 'config 参数包含 LLM 设置、选项和事件回调,用于控制任务执行行为。'
: 'With hooks parameter, you can listen to various events during task execution for real-time UI updates, logging, and more.'} : 'The config parameter includes LLM settings, options, and event callbacks to control task execution behavior.'}
</p> </p>
<CodeEditor <CodeEditor
code={ code={
isZh isZh
? `interface ExecuteHooks { ? `interface ExecuteConfig {
baseURL: string // LLM API 端点
apiKey: string // API 密钥
model: string // 模型名称
// 是否将初始标签页包含在任务中,默认 true
includeInitialTab?: boolean
// Agent 状态变化时调用(idle, running, error, completed 等) // Agent 状态变化时调用(idle, running, error, completed 等)
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
@@ -268,7 +249,14 @@ window.PAGE_AGENT_EXT.dispose()`
// Agent 被停止时调用 // Agent 被停止时调用
onDispose?: () => void onDispose?: () => void
}` }`
: `interface ExecuteHooks { : `interface ExecuteConfig {
baseURL: string // LLM API endpoint
apiKey: string // API key
model: string // Model name
// Whether to include the initial tab in the task, default true
includeInitialTab?: boolean
// Called when agent status changes (idle, running, error, completed, etc.) // Called when agent status changes (idle, running, error, completed, etc.)
onStatusChange?: (status: AgentStatus) => void onStatusChange?: (status: AgentStatus) => void
@@ -25,7 +25,7 @@ export default function CustomTools() {
</p> </p>
<CodeEditor <CodeEditor
code={`import zod from 'zod' code={`import * as zod from 'zod'
import { PageAgent, tool } from 'page-agent' import { PageAgent, tool } from 'page-agent'
// override internal tool // override internal tool
@@ -1,34 +1,49 @@
import { Fragment } from 'react'
import CodeEditor from '@/components/CodeEditor' import CodeEditor from '@/components/CodeEditor'
import { useLanguage } from '@/i18n/context' import { useLanguage } from '@/i18n/context'
// Recommended models: lightweight with excellent tool call capabilities const BASELINE = new Set([
const MODELS = { 'gpt-5.1',
recommended: [ 'claude-haiku-4.5',
'gpt-4.1-mini', 'gemini-3-flash',
'claude-haiku-4.5', 'deepseek-3.2',
'gemini-3-flash', 'qwen3-coder-next',
'deepseek-3.2', ])
'gpt-5.2',
'qwen-3-max', // Models grouped by brand, newest first
], const MODEL_GROUPS: Record<string, string[]> = {
verified: [ OpenAI: ['gpt-5.2', 'gpt-5.1', 'gpt-5', 'gpt-5-mini', 'gpt-4.1', 'gpt-4.1-mini'],
'qwen-3-plus', Google: ['gemini-3-pro', 'gemini-3-flash', 'gemini-2.5'],
'gpt-4.1', Qwen: ['qwen3-coder-next', 'qwen-3-max', 'qwen-3-plus', 'qwen3:14b (ollama)'],
'gpt-5', DeepSeek: ['deepseek-3.2'],
'gpt-5-mini', Anthropic: [
'grok-4', 'claude-opus-4.6',
'grok-code-fast',
'claude-sonnet-3.5',
'claude-sonnet-4.5',
'claude-opus-4.5', 'claude-opus-4.5',
'gemini-2.5', 'claude-sonnet-4.5',
'gemini-3-pro', 'claude-haiku-4.5',
'claude-sonnet-3.5',
], ],
xAI: ['grok-4.1-fast', 'grok-4', 'grok-code-fast'],
MoonshotAI: ['kimi-k2.5'],
'Z.AI': ['glm-4.7'],
} }
const ModelBadge = ({ model, baseline }: { model: string; baseline?: boolean }) => (
<div
className={`px-3 py-1.5 rounded-md text-xs font-medium font-mono transition-colors ${
baseline
? 'bg-emerald-500 text-white shadow-sm'
: 'bg-white/80 dark:bg-gray-800/80 text-gray-800 dark:text-gray-200 border border-gray-300 dark:border-gray-600'
}`}
>
{model}
{baseline && <span className="ml-1"></span>}
</div>
)
export default function Models() { export default function Models() {
const { isZh } = useLanguage() const { isZh } = useLanguage()
const allModels = [...MODELS.recommended, ...MODELS.verified]
return ( return (
<div className="max-w-4xl"> <div className="max-w-4xl">
@@ -45,28 +60,23 @@ export default function Models() {
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4"> <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
{isZh {isZh
? '推荐使用 ToolCall 能力强的轻量级模型。' ? '推荐使用 ToolCall 能力强的轻量级模型。'
: 'Recommended: Lightweight models with strong ToolCall capabilities.'} : 'Recommended: Fast, lightweight models with strong ToolCall capabilities.'}
</p> </p>
<div className="bg-linear-to-br from-emerald-50 to-cyan-50 dark:from-emerald-950/30 dark:to-cyan-950/30 rounded-xl p-6 border border-emerald-200/50 dark:border-emerald-800/50"> <div className="bg-linear-to-br from-emerald-50 to-cyan-50 dark:from-emerald-950/30 dark:to-cyan-950/30 rounded-xl p-6 border border-emerald-200/50 dark:border-emerald-800/50">
<div className="flex flex-wrap gap-2"> <div className="grid grid-cols-[5rem_1fr] gap-x-3 gap-y-3 items-start">
{allModels.map((model) => { {Object.entries(MODEL_GROUPS).map(([brand, models]) => (
const isRecommended = MODELS.recommended.includes(model) <Fragment key={brand}>
return ( <span className="text-xs font-semibold text-gray-500 dark:text-gray-400 pt-2">
<div {brand}
key={model} </span>
className={`px-3 py-1.5 rounded-md text-sm font-medium font-mono transition-colors ${ <div className="flex flex-wrap gap-2">
isRecommended {models.map((model) => (
? 'bg-emerald-500 text-white shadow-sm' <ModelBadge key={model} model={model} baseline={BASELINE.has(model)} />
: 'bg-white/80 dark:bg-gray-800/80 text-gray-800 dark:text-gray-200 border border-gray-300 dark:border-gray-600' ))}
}`}
>
{model}
{isRecommended && <span className="ml-1"></span>}
</div> </div>
) </Fragment>
})} ))}
</div> </div>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-5"> baseline models</p>
</div> </div>
</section> </section>
@@ -20,6 +20,7 @@ import Limitations from './introduction/limitations/page'
// Introduction // Introduction
import Overview from './introduction/overview/page' import Overview from './introduction/overview/page'
import QuickStart from './introduction/quick-start/page' import QuickStart from './introduction/quick-start/page'
import Troubleshooting from './introduction/troubleshooting/page'
function DocsPage({ children }: { children: React.ReactNode }) { function DocsPage({ children }: { children: React.ReactNode }) {
return ( return (
@@ -51,6 +52,11 @@ export default function DocsRouter() {
<Limitations /> <Limitations />
</DocsPage> </DocsPage>
</Route> </Route>
<Route path="/introduction/troubleshooting">
<DocsPage>
<Troubleshooting />
</DocsPage>
</Route>
{/* Features */} {/* Features */}
<Route path="/features/custom-tools"> <Route path="/features/custom-tools">
@@ -0,0 +1,424 @@
import { useEffect, useRef, useState } from 'react'
import { Link } from 'wouter'
import CodeEditor from '@/components/CodeEditor'
import { useLanguage } from '@/i18n/context'
// ---------------------------------------------------------------------------
// Data: each section is a typed object for easy extension
// ---------------------------------------------------------------------------
interface TroubleshootingSection {
id: string
title: { en: string; zh: string }
symptom: { en: string; zh: string }
color: 'red' | 'amber' | 'orange' | 'violet'
content: (isZh: boolean) => React.ReactNode
}
const SECTIONS: TroubleshootingSection[] = [
{
id: 'format-errors',
title: { en: 'Model Response Format Errors', zh: '模型返回格式错误' },
symptom: {
en: 'The model returns malformed tool calls, plain text, or unexpected JSON instead of structured actions.',
zh: '模型返回了格式错误的 tool call、纯文本或非预期的 JSON,而非结构化的操作指令。',
},
color: 'amber',
content: FormatErrorsContent,
},
{
id: 'low-success-rate',
title: { en: 'Low Task Success Rate', zh: '任务成功率低' },
symptom: {
en: 'The agent appears to understand the task but frequently fails to complete it, or produces incorrect results.',
zh: 'Agent 似乎理解了任务,但频繁执行失败或产生不正确的结果。',
},
color: 'amber',
content: LowSuccessRateContent,
},
{
id: 'wrong-element',
title: { en: "Can't Hit Target Elements", zh: '无法点击目标元素' },
symptom: {
en: 'The agent repeatedly retries but keeps interacting with the wrong element, or fails to locate the correct one.',
zh: 'Agent 反复重试,但始终点击在错误的元素上,或无法定位到正确的目标元素。',
},
color: 'amber',
content: WrongElementContent,
},
{
id: 'api-errors',
title: { en: 'API Request Errors', zh: 'API 请求错误' },
symptom: {
en: 'HTTP 400 Bad Request or similar errors when calling the LLM API.',
zh: '调用 LLM API 时出现 HTTP 400 Bad Request 或类似的参数错误。',
},
color: 'amber',
content: ApiErrorsContent,
},
]
// ---------------------------------------------------------------------------
// Section content components
// ---------------------------------------------------------------------------
function FormatErrorsContent(isZh: boolean) {
return (
<ol className="list-decimal pl-5 space-y-4 text-gray-700 dark:text-gray-300">
<li>
<strong>{isZh ? '确认模型是否支持' : 'Verify model compatibility'}</strong>
<p className="mt-1">
{isZh
? '并非所有模型都能正确处理 page-agent 的 tool 定义。请查看'
: 'Not all models can handle page-agent tool definitions correctly. Check the '}
<Link
href="/features/models"
className="text-blue-600 dark:text-blue-400 underline underline-offset-2"
>
{isZh ? '已测试模型列表' : 'tested models list'}
</Link>
{isZh ? '。' : '.'}
</p>
</li>
<li>
<strong>
{isZh ? '检查代理/网关的参数转发' : 'Check proxy/gateway parameter forwarding'}
</strong>
<p className="mt-1">
{isZh
? '如果使用了 API 代理或网关,请确保请求中的 '
: 'If using an API proxy or gateway, make sure the '}
<code>tools</code>
{isZh
? ' 字段被完整、无修改地转发给模型供应商。部分代理可能会剥离或修改此字段。'
: ' parameter is forwarded to the model provider intact. Some proxies may strip or alter this field.'}
</p>
</li>
<li>
<strong>{isZh ? '寻求社区帮助' : 'Get community help'}</strong>
<p className="mt-1">
{isZh ? (
<>
{' '}
<a
href="https://github.com/alibaba/page-agent/discussions"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 underline underline-offset-2"
>
GitHub Discussions
</a>{' '}
</>
) : (
<>
If the above steps don't help, join the{' '}
<a
href="https://github.com/alibaba/page-agent/discussions"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 underline underline-offset-2"
>
GitHub Discussions
</a>{' '}
with your model name and error details.
</>
)}
</p>
</li>
</ol>
)
}
function LowSuccessRateContent(isZh: boolean) {
return (
<>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 italic">
{isZh
? ''
: 'Follow this diagnostic funnel from simplest to most advanced:'}
</p>
<ol className="list-decimal pl-5 space-y-4 text-gray-700 dark:text-gray-300">
<li>
<strong>{isZh ? '' : 'Start with a simple instruction'}</strong>
<p className="mt-1">
{isZh
? '"点击登录按钮" Agent '
: 'Give a concrete, single-step instruction (e.g. "click the login button"). If even simple actions fail, the issue is likely not model capability.'}
</p>
</li>
<li>
<strong>{isZh ? '' : 'Try the strongest model available'}</strong>
<p className="mt-1">
{isZh
? ''
: "Switch to the most capable model you have access to, to isolate whether it's a model intelligence issue."}
</p>
</li>
<li>
<strong>{isZh ? '优化指令质量' : 'Improve instruction quality'}</strong>
<p className="mt-1">
{isZh
? '给出尽可能具体的指令。对于复杂任务,建议使用另一个 LLM 来预先拆分和细化用户的需求,然后逐步执行。'
: "Be as specific as possible. For complex tasks, consider using another LLM to decompose and refine the user's request before execution."}
</p>
</li>
<li>
<strong>{isZh ? '' : 'Provide sufficient context'}</strong>
<p className="mt-1">
{isZh
? ' instructions Agent '
: 'Use the instructions config to inject website descriptions, key terminology, and background context to help the agent understand the page.'}
</p>
</li>
<li>
<strong>{isZh ? ' HTML ' : 'Check HTML sanitization output'}</strong>
<p className="mt-1">
{isZh
? '使 HTML'
: 'Inspect the sanitized HTML in dev tools to confirm that key information, text, and interactive elements are preserved correctly.'}
</p>
</li>
</ol>
</>
)
}
function WrongElementContent(isZh: boolean) {
return (
<ol className="list-decimal pl-5 space-y-4 text-gray-700 dark:text-gray-300">
<li>
<strong>{isZh ? '' : 'Understand the reality'}</strong>
<p className="mt-1">
{isZh
? ' HTML accessibility DOM '
: 'Not all websites provide proper semantic HTML and accessibility labels. For such sites, DOM sanitization may not produce good enough results.'}
</p>
</li>
<li>
<strong>{isZh ? '' : 'Check target element type'}</strong>
<p className="mt-1">
{isZh
? 'Canvas'
: 'Verify if the target is an image, Canvas, or requires complex interactions (drag-and-drop, coordinate-based clicking). These are beyond current capabilities.'}
</p>
</li>
<li>
<strong>{isZh ? ' HTML' : 'Inspect sanitized HTML'}</strong>
<p className="mt-1">
{isZh
? ''
: 'Look for missing key information or unnumbered interactive elements in the sanitized output.'}
</p>
</li>
<li>
<strong>{isZh ? ' accessibility ' : 'Inject accessibility improvements'}</strong>
<p className="mt-1">
{isZh
? ' aria-label accessibility DOM '
: 'Inject scripts to add aria-labels, semantic attributes, and other a11y improvements to enhance DOM sanitization quality.'}
</p>
</li>
<li>
<strong>{isZh ? ' Tool' : 'Build a custom Tool'}</strong>
<p className="mt-1">
{isZh ? (
<>
对于特定的、持续难以操作的元素,考虑开发{' '}
<Link
href="/features/custom-tools"
className="text-blue-600 dark:text-blue-400 underline underline-offset-2"
>
自定义 Tool
</Link>{' '}
来直接操作这些元素。
</>
) : (
<>
For consistently difficult elements, consider building a{' '}
<Link
href="/features/custom-tools"
className="text-blue-600 dark:text-blue-400 underline underline-offset-2"
>
custom Tool
</Link>{' '}
to interact with them directly.
</>
)}
</p>
</li>
</ol>
)
}
function ApiErrorsContent(isZh: boolean) {
return (
<div className="space-y-4 text-gray-700 dark:text-gray-300">
<p>
{isZh
? ' LLM 使 OpenAI '
: 'Some LLM providers use parameter formats that are not fully compatible with the OpenAI spec, causing request validation failures.'}
</p>
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4">
<p className="font-medium mb-2">
{isZh ? '使 customFetch' : 'Solution: use customFetch'}
</p>
<p className="text-sm mb-3">
{isZh
? ' customFetch '
: 'Use the customFetch config to intercept requests and adapt parameters before sending them to the target provider.'}
</p>
<CodeEditor
code={`const agent = new PageAgent({
// ...
customFetch: async (url, init) => {
const body = JSON.parse(init.body)
// Adapt parameters for your provider
delete body.stream_options
return fetch(url, { ...init, body: JSON.stringify(body) })
},
})`}
/>
</div>
<p className="text-sm">
{isZh ? ' ' : 'See '}
<Link
href="/advanced/page-agent-core"
className="text-blue-600 dark:text-blue-400 underline underline-offset-2"
>
PageAgentCore API
</Link>
{isZh ? ' customFetch ' : ' for full customFetch documentation.'}
</p>
</div>
)
}
// ---------------------------------------------------------------------------
// Color mapping for symptom callouts
// ---------------------------------------------------------------------------
const SYMPTOM_COLORS = {
red: 'border-red-400 bg-red-50 dark:bg-red-900/15 text-red-800 dark:text-red-200',
amber: 'border-amber-400 bg-amber-50 dark:bg-amber-900/15 text-amber-800 dark:text-amber-200',
orange:
'border-orange-400 bg-orange-50 dark:bg-orange-900/15 text-orange-800 dark:text-orange-200',
violet:
'border-violet-400 bg-violet-50 dark:bg-violet-900/15 text-violet-800 dark:text-violet-200',
} as const
// ---------------------------------------------------------------------------
// Right-side TOC with IntersectionObserver
// ---------------------------------------------------------------------------
function useActiveSection(ids: string[]) {
const [activeId, setActiveId] = useState(ids[0])
const observerRef = useRef<IntersectionObserver | null>(null)
useEffect(() => {
observerRef.current?.disconnect()
const visibleEntries = new Map<string, number>()
observerRef.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
visibleEntries.set(entry.target.id, entry.intersectionRatio)
} else {
visibleEntries.delete(entry.target.id)
}
}
// Pick the first visible section in document order
const firstVisible = ids.find((id) => visibleEntries.has(id))
if (firstVisible) setActiveId(firstVisible)
},
{ rootMargin: '-80px 0px -60% 0px', threshold: [0, 0.25] }
)
for (const id of ids) {
const el = document.getElementById(id)
if (el) observerRef.current.observe(el)
}
return () => observerRef.current?.disconnect()
}, [ids])
return activeId
}
// ---------------------------------------------------------------------------
// Page component
// ---------------------------------------------------------------------------
export default function TroubleshootingPage() {
const { isZh } = useLanguage()
const sectionIds = SECTIONS.map((s) => s.id)
const activeId = useActiveSection(sectionIds)
return (
<div className="max-w-5xl mx-auto">
{/* Header */}
<div className="mb-10">
<h1 className="text-4xl font-bold mb-4 text-gray-900 dark:text-white">Troubleshooting</h1>
</div>
{/* Two-column: content + TOC */}
<div className="flex gap-8">
{/* Main content */}
<div className="flex-1 min-w-0 space-y-12">
{SECTIONS.map((section) => (
<section key={section.id} id={section.id} className="scroll-mt-24">
<h2 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">
{isZh ? section.title.zh : section.title.en}
</h2>
{/* Symptom callout */}
<div
className={`border-l-4 px-4 py-3 rounded-r-lg mb-6 ${SYMPTOM_COLORS[section.color]}`}
>
<span className="text-xs font-semibold uppercase tracking-wider opacity-70">
{isZh ? '' : 'Symptom'}
</span>
<p className="mt-1 text-sm">{isZh ? section.symptom.zh : section.symptom.en}</p>
</div>
{/* Diagnostic steps */}
<div className="prose-sm">{section.content(isZh)}</div>
</section>
))}
</div>
{/* Right TOC */}
<aside className="hidden lg:block w-48 shrink-0">
<div className="sticky top-24">
<h4 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
{isZh ? '' : 'On this page'}
</h4>
<nav className="space-y-1">
{SECTIONS.map((section) => (
<button
key={section.id}
type="button"
onClick={() =>
document
.getElementById(section.id)
?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
className={`block text-sm py-1 text-left transition-colors ${
activeId === section.id
? 'text-blue-600 dark:text-blue-400 font-medium'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
{isZh ? section.title.zh : section.title.en}
</button>
))}
</nav>
</div>
</aside>
</div>
</div>
)
}