Compare commits
22 Commits
v1.1.1
...
EXT_v0.1.5
| Author | SHA1 | Date | |
|---|---|---|---|
| 11d66f42c4 | |||
| 20e947a1c1 | |||
| 7c87c90258 | |||
| fcb9ec4e57 | |||
| 1fcf931685 | |||
| c71ffcec1c | |||
| 430be466fc | |||
| 3f284cf2e6 | |||
| 6f0655478a | |||
| 7d5449967b | |||
| fd47888d1b | |||
| 10f470ce7d | |||
| cb29db1cc4 | |||
| 8c08f639dd | |||
| 488f080e68 | |||
| 6e07af422b | |||
| e9d721f6b9 | |||
| 840162df8c | |||
| 1517bc4868 | |||
| 67f6a56a87 | |||
| 6f0ff1fd33 | |||
| 6fdb8d03f2 |
+3
-3
@@ -40,7 +40,7 @@
|
||||
|
||||
```html
|
||||
<script
|
||||
src="https://registry.npmmirror.com/page-agent/1.1.1/files/dist/iife/page-agent.demo.js"
|
||||
src="https://registry.npmmirror.com/page-agent/1.2.0/files/dist/iife/page-agent.demo.js"
|
||||
crossorigin="true"
|
||||
></script>
|
||||
```
|
||||
@@ -50,8 +50,8 @@
|
||||
|
||||
| Mirrors | URL |
|
||||
| ------- | ---------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.1.1/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.1.1/files/dist/iife/page-agent.demo.js |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.2.0/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.2.0/files/dist/iife/page-agent.demo.js |
|
||||
|
||||
### NPM 安装
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ Fastest way to try PageAgent with our free Demo LLM:
|
||||
|
||||
```html
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/page-agent@1.1.1/dist/iife/page-agent.demo.js"
|
||||
src="https://cdn.jsdelivr.net/npm/page-agent@1.2.0/dist/iife/page-agent.demo.js"
|
||||
crossorigin="true"
|
||||
></script>
|
||||
```
|
||||
@@ -50,8 +50,8 @@ Fastest way to try PageAgent with our free Demo LLM:
|
||||
|
||||
| Mirrors | URL |
|
||||
| ------- | ---------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.1.1/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.1.1/files/dist/iife/page-agent.demo.js |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.2.0/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.2.0/files/dist/iife/page-agent.demo.js |
|
||||
|
||||
### NPM Installation
|
||||
|
||||
|
||||
Generated
+387
-376
File diff suppressed because it is too large
Load Diff
+12
-10
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/page-controller",
|
||||
@@ -21,8 +21,7 @@
|
||||
},
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"npm": ">=10.0.0"
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"scripts": {
|
||||
"cleanup": "rm -rf packages/*/dist",
|
||||
@@ -39,27 +38,30 @@
|
||||
"@commitlint/cli": "^20.3.1",
|
||||
"@commitlint/config-conventional": "^20.3.1",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@microsoft/api-extractor": "^7.55.2",
|
||||
"@microsoft/api-extractor": "^7.56.3",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/node": "^25.0.9",
|
||||
"@types/node": "^25.2.2",
|
||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||
"chalk": "^5.6.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"dotenv": "^17.2.4",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-dom": "^2.7.2",
|
||||
"eslint-plugin-react-dom": "^2.12.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.0",
|
||||
"eslint-plugin-react-x": "^2.7.2",
|
||||
"eslint-plugin-react-x": "^2.12.2",
|
||||
"globals": "^17.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.4",
|
||||
"prettier": "^3.8.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"typescript-eslint": "^8.55.0",
|
||||
"unplugin-dts": "^1.0.0-beta.6",
|
||||
"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": {
|
||||
"typescript": "^5.9.3"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@page-agent/core",
|
||||
"private": false,
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"main": "./dist/esm/page-agent-core.js",
|
||||
"module": "./dist/esm/page-agent-core.js",
|
||||
@@ -45,7 +45,7 @@
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"zod": "^4.3.5",
|
||||
"@page-agent/llms": "1.1.1",
|
||||
"@page-agent/page-controller": "1.1.1"
|
||||
"@page-agent/llms": "1.2.0",
|
||||
"@page-agent/page-controller": "1.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
+146
-124
@@ -3,15 +3,15 @@
|
||||
* All rights reserved.
|
||||
*/
|
||||
import { InvokeError, LLM, type Tool } from '@page-agent/llms'
|
||||
import type { PageController } from '@page-agent/page-controller'
|
||||
import type { BrowserState, PageController } from '@page-agent/page-controller'
|
||||
import chalk from 'chalk'
|
||||
import zod from 'zod'
|
||||
import * as zod from 'zod'
|
||||
|
||||
import { type PageAgentConfig } from './config'
|
||||
import { DEFAULT_MAX_STEPS } from './config/constants'
|
||||
import SYSTEM_PROMPT from './prompts/system_prompt.md?raw'
|
||||
import { tools } from './tools'
|
||||
import {
|
||||
import type {
|
||||
AgentActivity,
|
||||
AgentReflection,
|
||||
AgentStatus,
|
||||
@@ -21,17 +21,25 @@ import {
|
||||
MacroToolInput,
|
||||
MacroToolResult,
|
||||
} from './types'
|
||||
import { normalizeResponse, trimLines, uid, waitFor } from './utils'
|
||||
import { assert } from './utils/assert'
|
||||
import { assert, normalizeResponse, uid, waitFor } from './utils'
|
||||
|
||||
export { type PageAgentConfig }
|
||||
export { tool, type PageAgentTool } from './tools'
|
||||
export type * from './types'
|
||||
|
||||
/**
|
||||
* AI agent for browser DOM automation.
|
||||
* AI agent for browser automation.
|
||||
*
|
||||
* @remarks
|
||||
* ## Re-act Agent Loop
|
||||
* - step
|
||||
* - observe (gather information about current environment and context)
|
||||
* - think (LLM calling)
|
||||
* - reflection (evaluate history, generate memory, short-term planning)
|
||||
* - action (give the action to approach the next goal)
|
||||
* - act (execute the action)
|
||||
* - loop
|
||||
*
|
||||
* ## Event System
|
||||
* - `statuschange` - Agent status transitions (idle → running → completed/error)
|
||||
* - `historychange` - History events updated (persistent, part of agent memory)
|
||||
@@ -50,14 +58,16 @@ export type * from './types'
|
||||
* - Types: thinking, executing, executed, retrying, error
|
||||
*/
|
||||
export class PageAgentCore extends EventTarget {
|
||||
config: PageAgentConfig & { maxSteps: number }
|
||||
id = uid()
|
||||
tools: typeof tools
|
||||
readonly id = uid()
|
||||
readonly config: PageAgentConfig & { maxSteps: number }
|
||||
readonly tools: typeof tools
|
||||
/** PageController for DOM operations */
|
||||
readonly pageController: PageController
|
||||
|
||||
task = ''
|
||||
taskId = ''
|
||||
|
||||
/** Agent execution status */
|
||||
#status: AgentStatus = 'idle'
|
||||
/** History events */
|
||||
history: HistoricalEvent[] = []
|
||||
|
||||
/**
|
||||
* Callback for when agent needs user input (ask_user tool)
|
||||
@@ -66,23 +76,21 @@ export class PageAgentCore extends EventTarget {
|
||||
*/
|
||||
onAskUser?: (question: string) => Promise<string>
|
||||
|
||||
#status: AgentStatus = 'idle'
|
||||
#llm: LLM
|
||||
#abortController = new AbortController()
|
||||
#observations: string[] = []
|
||||
|
||||
/** PageController for DOM operations */
|
||||
pageController: PageController
|
||||
|
||||
/** Runtime states for tracking across steps */
|
||||
states = {
|
||||
/** Accumulated wait time in seconds, used by wait tool */
|
||||
/** internal states during a single task execution */
|
||||
#states = {
|
||||
/** Accumulated wait time in seconds */
|
||||
totalWaitTime: 0,
|
||||
/** Last known URL for detecting navigation */
|
||||
/** For detecting navigation */
|
||||
lastURL: '',
|
||||
/** Browser state */
|
||||
browserState: null as BrowserState | null,
|
||||
}
|
||||
|
||||
/** History events */
|
||||
history: HistoricalEvent[] = []
|
||||
|
||||
constructor(config: PageAgentConfig & { pageController: PageController }) {
|
||||
super()
|
||||
|
||||
@@ -166,12 +174,13 @@ export class PageAgentCore extends EventTarget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a persistent observation to the history event stream.
|
||||
* This will be visible in <agent_history> and remain in memory across steps.
|
||||
* Push a observation message to the history event stream.
|
||||
* This will be visible in <agent_history> and remain persistent in memory across steps.
|
||||
* @experimental @internal
|
||||
* @note history change will be emitted before next step starts
|
||||
*/
|
||||
pushObservation(content: string): void {
|
||||
this.history.push({ type: 'observation', content })
|
||||
this.#emitHistoryChange()
|
||||
this.#observations.push(content)
|
||||
}
|
||||
|
||||
async execute(task: string): Promise<ExecutionResult> {
|
||||
@@ -203,30 +212,25 @@ export class PageAgentCore extends EventTarget {
|
||||
this.#setStatus('running')
|
||||
this.#emitHistoryChange()
|
||||
|
||||
// Reset states
|
||||
this.states = {
|
||||
totalWaitTime: 0,
|
||||
lastURL: '',
|
||||
}
|
||||
// Reset internal states
|
||||
this.#states = { totalWaitTime: 0, lastURL: '', browserState: null }
|
||||
|
||||
try {
|
||||
let step = 0
|
||||
let step = 0
|
||||
|
||||
while (true) {
|
||||
await this.#generateObservations(step)
|
||||
while (true) {
|
||||
try {
|
||||
console.group(`step: ${step}`)
|
||||
|
||||
await onBeforeStep?.(this, step)
|
||||
|
||||
console.group(`step: ${step}`)
|
||||
// observe
|
||||
|
||||
// abort
|
||||
if (this.#abortController.signal.aborted) throw new Error('AbortError')
|
||||
console.log(chalk.blue.bold('👀 Observing...'))
|
||||
|
||||
// Thinking
|
||||
console.log(chalk.blue('Thinking...'))
|
||||
this.#emitActivity({ type: 'thinking' })
|
||||
this.#states.browserState = await this.pageController.getBrowserState()
|
||||
await this.#handleObservations(step)
|
||||
|
||||
// invoke LLM
|
||||
// assemble prompts
|
||||
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: this.#getSystemPrompt() },
|
||||
@@ -235,12 +239,17 @@ export class PageAgentCore extends EventTarget {
|
||||
|
||||
const tools = { AgentOutput: this.#packMacroTool() }
|
||||
|
||||
// invoke LLM
|
||||
|
||||
console.log(chalk.blue.bold('🧠 Thinking...'))
|
||||
this.#emitActivity({ type: 'thinking' })
|
||||
|
||||
const result = await this.#llm.invoke(messages, tools, this.#abortController.signal, {
|
||||
toolChoiceName: 'AgentOutput',
|
||||
normalizeResponse,
|
||||
})
|
||||
|
||||
// assemble history event
|
||||
// assemble history
|
||||
|
||||
const macroResult = result.toolResult as MacroToolResult
|
||||
const input = macroResult.input
|
||||
@@ -270,22 +279,12 @@ export class PageAgentCore extends EventTarget {
|
||||
|
||||
//
|
||||
|
||||
console.log(chalk.green('Step finished:'), actionName)
|
||||
console.groupEnd()
|
||||
|
||||
await onAfterStep?.(this, this.history)
|
||||
|
||||
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
|
||||
}
|
||||
console.groupEnd()
|
||||
|
||||
// finish task if done
|
||||
|
||||
if (actionName === 'done') {
|
||||
const success = action.input?.success ?? false
|
||||
const text = action.input?.text || 'no text provided'
|
||||
@@ -299,19 +298,32 @@ export class PageAgentCore extends EventTarget {
|
||||
await onAfterTask?.(this, 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)
|
||||
const errorMessage = String(error)
|
||||
this.#emitActivity({ type: 'error', message: errorMessage })
|
||||
this.#onDone(false)
|
||||
const result: ExecutionResult = {
|
||||
success: false,
|
||||
data: errorMessage,
|
||||
history: this.history,
|
||||
|
||||
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
|
||||
}
|
||||
await onAfterTask?.(this, result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,13 +356,13 @@ export class PageAgentCore extends EventTarget {
|
||||
})
|
||||
|
||||
return {
|
||||
description: 'You MUST call this tool every step. Outputs your reflections and next action.',
|
||||
description: 'You MUST call this tool every step!',
|
||||
inputSchema: macroToolSchema as zod.ZodType<MacroToolInput>,
|
||||
execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
|
||||
// abort
|
||||
if (this.#abortController.signal.aborted) throw new Error('AbortError')
|
||||
|
||||
console.log(chalk.blue.bold('MacroTool execute'), input)
|
||||
console.log(chalk.blue.bold('MacroTool input'), input)
|
||||
const action = input.action
|
||||
|
||||
const toolName = Object.keys(action)[0]
|
||||
@@ -371,7 +383,7 @@ export class PageAgentCore extends EventTarget {
|
||||
|
||||
// Find the corresponding tool
|
||||
const tool = tools.get(toolName)
|
||||
assert(tool, `Tool ${toolName} not found. (@note should have been caught before this!!!)`)
|
||||
assert(tool, `Tool ${toolName} not found`)
|
||||
|
||||
console.log(chalk.blue.bold(`Executing tool: ${toolName}`), toolInput)
|
||||
|
||||
@@ -395,9 +407,11 @@ export class PageAgentCore extends EventTarget {
|
||||
duration,
|
||||
})
|
||||
|
||||
// Reset wait time for non-wait tools
|
||||
if (toolName !== 'wait') {
|
||||
this.states.totalWaitTime = 0
|
||||
// counting wait time
|
||||
if (toolName === 'wait') {
|
||||
this.#states.totalWaitTime += toolInput?.seconds || 0
|
||||
} else {
|
||||
this.#states.totalWaitTime = 0
|
||||
}
|
||||
|
||||
// Return structured result
|
||||
@@ -427,17 +441,17 @@ export class PageAgentCore extends EventTarget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get instructions from config and format as XML block
|
||||
* Get instructions from config
|
||||
*/
|
||||
async #getInstructions(): Promise<string> {
|
||||
const { instructions } = this.config
|
||||
if (!instructions) return ''
|
||||
|
||||
const systemInstructions = instructions.system?.trim()
|
||||
const url = await this.pageController.getCurrentUrl()
|
||||
let pageInstructions: string | undefined
|
||||
|
||||
if (instructions.getPageInstructions) {
|
||||
const url = this.#states.browserState?.url || ''
|
||||
if (instructions.getPageInstructions && url) {
|
||||
try {
|
||||
pageInstructions = instructions.getPageInstructions(url)?.trim()
|
||||
} catch (error) {
|
||||
@@ -465,23 +479,28 @@ export class PageAgentCore extends EventTarget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate observations before each step
|
||||
* - URL change detection
|
||||
* - Too many steps warning
|
||||
* Generate system observations before each step
|
||||
* @todo loop detection
|
||||
* @todo console error
|
||||
*/
|
||||
async #generateObservations(stepCount: number): Promise<void> {
|
||||
async #handleObservations(step: number): Promise<void> {
|
||||
// Accumulated wait time warning
|
||||
if (this.#states.totalWaitTime >= 3) {
|
||||
this.pushObservation(
|
||||
`You have waited ${this.#states.totalWaitTime} seconds accumulatively. DO NOT wait any longer unless you have a good reason.`
|
||||
)
|
||||
}
|
||||
|
||||
// Detect URL change
|
||||
const currentURL = await this.pageController.getCurrentUrl()
|
||||
if (currentURL !== this.states.lastURL) {
|
||||
const currentURL = this.#states.browserState?.url || ''
|
||||
if (currentURL !== this.#states.lastURL) {
|
||||
this.pushObservation(`Page navigated to → ${currentURL}`)
|
||||
this.states.lastURL = currentURL
|
||||
this.#states.lastURL = currentURL
|
||||
await waitFor(0.5) // wait for page to stabilize
|
||||
}
|
||||
|
||||
// Warn about remaining steps
|
||||
const remaining = this.config.maxSteps - stepCount
|
||||
// Remaining steps warning
|
||||
const remaining = this.config.maxSteps - step
|
||||
if (remaining === 5) {
|
||||
this.pushObservation(
|
||||
`⚠️ Only ${remaining} steps remaining. Consider wrapping up or calling done with partial results.`
|
||||
@@ -491,12 +510,25 @@ export class PageAgentCore extends EventTarget {
|
||||
`⚠️ Critical: Only ${remaining} steps left! You must finish the task or call done immediately.`
|
||||
)
|
||||
}
|
||||
|
||||
// Push observations to history and emit
|
||||
if (this.#observations.length > 0) {
|
||||
for (const content of this.#observations) {
|
||||
this.history.push({ type: 'observation', content })
|
||||
console.log(chalk.cyan('Observation:'), content)
|
||||
}
|
||||
this.#observations = []
|
||||
this.#emitHistoryChange()
|
||||
}
|
||||
}
|
||||
|
||||
async #assembleUserPrompt(): Promise<string> {
|
||||
const browserState = this.#states.browserState!
|
||||
|
||||
let prompt = ''
|
||||
|
||||
// <instructions> (optional)
|
||||
|
||||
prompt += await this.#getInstructions()
|
||||
|
||||
// <agent_state>
|
||||
@@ -506,38 +538,36 @@ export class PageAgentCore extends EventTarget {
|
||||
|
||||
const stepCount = this.history.filter((e) => e.type === 'step').length
|
||||
|
||||
prompt += `<agent_state>
|
||||
<user_request>
|
||||
${this.task}
|
||||
</user_request>
|
||||
<step_info>
|
||||
Step ${stepCount + 1} of ${this.config.maxSteps} max possible steps
|
||||
Current date and time: ${new Date().toLocaleString()}
|
||||
</step_info>
|
||||
</agent_state>
|
||||
`
|
||||
prompt += '<agent_state>\n'
|
||||
prompt += '<user_request>\n'
|
||||
prompt += `${this.task}\n`
|
||||
prompt += '</user_request>\n'
|
||||
prompt += '<step_info>\n'
|
||||
prompt += `Step ${stepCount + 1} of ${this.config.maxSteps} max possible steps\n`
|
||||
prompt += `Current time: ${new Date().toLocaleString()}\n`
|
||||
prompt += '</step_info>\n'
|
||||
prompt += '</agent_state>\n\n'
|
||||
|
||||
// <agent_history>
|
||||
// - <step_N> for steps
|
||||
// - <sys> for observations and system messages
|
||||
|
||||
prompt += '\n<agent_history>\n'
|
||||
prompt += '<agent_history>\n'
|
||||
|
||||
let stepIndex = 0
|
||||
for (const event of this.history) {
|
||||
if (event.type === 'step') {
|
||||
stepIndex++
|
||||
prompt += `<step_${stepIndex}>
|
||||
Evaluation of Previous Step: ${event.reflection.evaluation_previous_goal}
|
||||
Memory: ${event.reflection.memory}
|
||||
Next Goal: ${event.reflection.next_goal}
|
||||
Action Results: ${event.action.output}
|
||||
</step_${stepIndex}>
|
||||
`
|
||||
prompt += `<step_${stepIndex}>\n`
|
||||
prompt += `Evaluation of Previous Step: ${event.reflection.evaluation_previous_goal}\n`
|
||||
prompt += `Memory: ${event.reflection.memory}\n`
|
||||
prompt += `Next Goal: ${event.reflection.next_goal}\n`
|
||||
prompt += `Action Results: ${event.action.output}\n`
|
||||
prompt += `</step_${stepIndex}>\n`
|
||||
} else if (event.type === 'observation') {
|
||||
prompt += `<sys>${event.content}</sys>\n`
|
||||
} else if (event.type === 'user_takeover') {
|
||||
prompt += `<sys>User took over control and made changes to the page.</sys>\n`
|
||||
prompt += `<sys>User took over control and made changes to the page</sys>\n`
|
||||
} else if (event.type === 'error') {
|
||||
// Error events are mainly for panel rendering, not included in LLM context
|
||||
// to avoid polluting the agent's reasoning with transient errors
|
||||
@@ -548,9 +578,18 @@ export class PageAgentCore extends EventTarget {
|
||||
|
||||
// <browser_state>
|
||||
|
||||
prompt += await this.#getBrowserState()
|
||||
let pageContent = browserState.content
|
||||
if (this.config.transformPageContent) {
|
||||
pageContent = await this.config.transformPageContent(pageContent)
|
||||
}
|
||||
|
||||
return trimLines(prompt)
|
||||
prompt += '<browser_state>\n'
|
||||
prompt += browserState.header + '\n'
|
||||
prompt += pageContent + '\n'
|
||||
prompt += browserState.footer + '\n\n'
|
||||
prompt += '</browser_state>\n\n'
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
#onDone(success = true) {
|
||||
@@ -560,23 +599,6 @@ export class PageAgentCore extends EventTarget {
|
||||
this.#abortController.abort()
|
||||
}
|
||||
|
||||
async #getBrowserState(): Promise<string> {
|
||||
const state = await this.pageController.getBrowserState()
|
||||
|
||||
let content = state.content
|
||||
if (this.config.transformPageContent) {
|
||||
content = await this.config.transformPageContent(content)
|
||||
}
|
||||
|
||||
return trimLines(`<browser_state>
|
||||
${state.header}
|
||||
${content}
|
||||
${state.footer}
|
||||
|
||||
</browser_state>
|
||||
`)
|
||||
}
|
||||
|
||||
dispose() {
|
||||
console.log('Disposing PageAgent...')
|
||||
this.pageController.dispose()
|
||||
|
||||
@@ -128,7 +128,6 @@ Exhibit the following reasoning patterns to successfully achieve the <user_reque
|
||||
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>
|
||||
@@ -139,7 +138,6 @@ Here are examples of good output patterns. Use them as reference but never copy
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Internal tools for PageAgent.
|
||||
* @note Adapted from browser-use
|
||||
*/
|
||||
import zod, { type z } from 'zod'
|
||||
import * as zod from 'zod'
|
||||
|
||||
import type { PageAgentCore } from '../PageAgentCore'
|
||||
import { waitFor } from '../utils'
|
||||
@@ -13,7 +13,7 @@ import { waitFor } from '../utils'
|
||||
export interface PageAgentTool<TParams = any> {
|
||||
// name: string
|
||||
description: string
|
||||
inputSchema: z.ZodType<TParams>
|
||||
inputSchema: zod.ZodType<TParams>
|
||||
execute: (this: PageAgentCore, args: TParams) => Promise<string>
|
||||
}
|
||||
|
||||
@@ -31,14 +31,13 @@ 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.',
|
||||
'Complete task. Text is your final response to the user — keep it concise unless the user explicitly asks for detail.',
|
||||
inputSchema: zod.object({
|
||||
text: zod.string(),
|
||||
success: zod.boolean().default(true),
|
||||
}),
|
||||
execute: async function (this: PageAgentCore, input) {
|
||||
// @note main loop will handle this one
|
||||
// this.onDone(input.text, input.success)
|
||||
return Promise.resolve('Task completed')
|
||||
},
|
||||
})
|
||||
@@ -47,25 +46,17 @@ tools.set(
|
||||
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.',
|
||||
description: 'Wait for x seconds. 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: PageAgentCore, input) {
|
||||
// try to subtract LLM calling time from the actual wait time
|
||||
const lastTimeUpdate = await this.pageController.getLastUpdateTime()
|
||||
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000)
|
||||
console.log(`actualWaitTime: ${actualWaitTime} seconds`)
|
||||
await waitFor(actualWaitTime)
|
||||
|
||||
this.states.totalWaitTime += input.seconds
|
||||
|
||||
if (this.states.totalWaitTime >= 3) {
|
||||
this.pushObservation(
|
||||
`You have waited ${this.states.totalWaitTime} seconds accumulatively. Do NOT wait any longer unless you have a good reason.`
|
||||
)
|
||||
}
|
||||
|
||||
return `✅ Waited for ${input.seconds} seconds.`
|
||||
},
|
||||
})
|
||||
@@ -106,7 +97,7 @@ tools.set(
|
||||
tools.set(
|
||||
'input_text',
|
||||
tool({
|
||||
description: 'Click and input text into a input interactive element',
|
||||
description: 'Click and type text into an interactive input element',
|
||||
inputSchema: zod.object({
|
||||
index: zod.int().min(0),
|
||||
text: zod.string(),
|
||||
@@ -140,8 +131,7 @@ tools.set(
|
||||
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.',
|
||||
description: 'Scroll the page vertically. Use index for scroll elements (dropdowns/custom UI).',
|
||||
inputSchema: zod.object({
|
||||
down: zod.boolean().default(true),
|
||||
num_pages: zod.number().min(0).max(10).optional().default(0.1),
|
||||
@@ -158,11 +148,14 @@ tools.set(
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* @todo Tables need a dedicated parser to extract structured data. This tool is useless.
|
||||
*/
|
||||
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).',
|
||||
'Scroll the page horizontally, or within a specific element by index. Useful for wide tables.',
|
||||
inputSchema: zod.object({
|
||||
right: zod.boolean().default(true),
|
||||
pixels: zod.number().int().min(0),
|
||||
@@ -190,8 +183,6 @@ tools.set(
|
||||
})
|
||||
)
|
||||
|
||||
// @todo get_dropdown_options
|
||||
// @todo select_dropdown_option
|
||||
// @todo send_keys
|
||||
// @todo upload_file
|
||||
// @todo go_back
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,7 @@
|
||||
import chalk from 'chalk'
|
||||
|
||||
export { normalizeResponse } from './autoFixer'
|
||||
|
||||
/**
|
||||
* 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 async function waitFor(seconds: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
|
||||
}
|
||||
@@ -37,15 +17,6 @@ export function truncate(text: string, maxLength: number): string {
|
||||
|
||||
//
|
||||
|
||||
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)
|
||||
|
||||
@@ -85,3 +56,19 @@ export function uid() {
|
||||
ids.push(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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@page-agent/ext",
|
||||
"private": true,
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wxt",
|
||||
@@ -10,23 +10,6 @@
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/chrome": "^0.1.34",
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||
"@wxt-dev/module-react": "^1.1.5",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"wxt": "^0.20.13"
|
||||
},
|
||||
"dependencies": {
|
||||
"@page-agent/core": "1.1.1",
|
||||
"@page-agent/llms": "1.1.1",
|
||||
"@page-agent/page-controller": "1.1.1",
|
||||
"@page-agent/ui": "1.1.1",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
@@ -34,14 +17,32 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@types/chrome": "^0.1.34",
|
||||
"@types/react": "^19.2.13",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"@wxt-dev/module-react": "^1.1.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-react": "^0.563.0",
|
||||
"motion": "^12.30.0",
|
||||
"motion": "^12.34.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"rough-notation": "^0.5.1",
|
||||
"simple-icons": "^16.7.0",
|
||||
"simple-icons": "^16.8.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"wxt": "^0.20.14"
|
||||
},
|
||||
"dependencies": {
|
||||
"@page-agent/core": "1.2.0",
|
||||
"@page-agent/llms": "1.2.0",
|
||||
"@page-agent/page-controller": "1.2.0",
|
||||
"@page-agent/ui": "1.2.0",
|
||||
"chalk": "^5.6.2",
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,11 @@ export class MultiPageAgent extends PageAgentCore {
|
||||
})
|
||||
},
|
||||
|
||||
onBeforeStep: async (agent) => {
|
||||
// make sure the current tab is loaded before the step starts
|
||||
await tabsController.waitUntilTabLoaded(tabsController.currentTabId!)
|
||||
},
|
||||
|
||||
onDispose: () => {
|
||||
if (heartBeatInterval) {
|
||||
window.clearInterval(heartBeatInterval)
|
||||
|
||||
@@ -8,13 +8,21 @@ export function handlePageControlMessage(
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response: unknown) => void
|
||||
): true | undefined {
|
||||
const PREFIX = '[RemotePageController.background]'
|
||||
|
||||
function debug(...messages: any[]) {
|
||||
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
|
||||
}
|
||||
|
||||
const { action, payload, targetTabId } = message
|
||||
|
||||
if (action === 'get_my_tab_id') {
|
||||
debug('get_my_tab_id', sender.tab?.id)
|
||||
sendResponse({ tabId: sender.tab?.id || null })
|
||||
return
|
||||
}
|
||||
|
||||
// proxy to content script
|
||||
chrome.tabs
|
||||
.sendMessage(targetTabId, {
|
||||
type: 'PAGE_CONTROL',
|
||||
@@ -25,6 +33,7 @@ export function handlePageControlMessage(
|
||||
sendResponse(result)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(PREFIX, error)
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
|
||||
@@ -12,10 +12,14 @@ export function initPageController() {
|
||||
.then((response) => {
|
||||
return (response as { tabId: number | null }).tabId
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[RemotePageController.ContentScript]: Failed to get my tab id', error)
|
||||
return null
|
||||
})
|
||||
|
||||
function getPC(): PageController {
|
||||
if (!pageController) {
|
||||
pageController = new PageController({ enableMask: false })
|
||||
pageController = new PageController({ enableMask: false, viewportExpansion: 400 })
|
||||
}
|
||||
return pageController
|
||||
}
|
||||
|
||||
@@ -2,13 +2,31 @@ import type { BrowserState } from '@page-agent/page-controller'
|
||||
|
||||
import type { TabsController } from './TabsController'
|
||||
|
||||
const PREFIX = '[RemotePageController]'
|
||||
|
||||
function debug(...messages: any[]) {
|
||||
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
|
||||
}
|
||||
|
||||
function sendMessage(message: {
|
||||
type: 'PAGE_CONTROL'
|
||||
action: string
|
||||
targetTabId: number
|
||||
payload?: any
|
||||
}): Promise<any> {
|
||||
return chrome.runtime.sendMessage(message).catch((error) => {
|
||||
console.error(PREFIX, message.action, error)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent side page controller.
|
||||
* - live in the agent env (extension page or content script)
|
||||
* - communicates with remote PageController via sw
|
||||
*/
|
||||
export class RemotePageController {
|
||||
private tabsController: TabsController
|
||||
tabsController: TabsController
|
||||
|
||||
constructor(tabsController: TabsController) {
|
||||
this.tabsController = tabsController
|
||||
@@ -18,46 +36,46 @@ export class RemotePageController {
|
||||
return this.tabsController.currentTabId
|
||||
}
|
||||
|
||||
async getCurrentUrl(): Promise<string> {
|
||||
private async getCurrentUrl(): Promise<string> {
|
||||
if (!this.currentTabId) return ''
|
||||
const { url } = await this.tabsController.getTabInfo(this.currentTabId)
|
||||
return url || ''
|
||||
}
|
||||
|
||||
async getCurrentTitle(): Promise<string> {
|
||||
private async getCurrentTitle(): Promise<string> {
|
||||
if (!this.currentTabId) return ''
|
||||
const { title } = await this.tabsController.getTabInfo(this.currentTabId)
|
||||
return title || ''
|
||||
}
|
||||
|
||||
get currentTabTitle(): Promise<string> {
|
||||
return this.getCurrentTitle()
|
||||
}
|
||||
|
||||
async getLastUpdateTime(): Promise<number> {
|
||||
if (!this.currentTabId) throw new Error('tabsController not initialized.')
|
||||
|
||||
return await chrome.runtime.sendMessage({
|
||||
return sendMessage({
|
||||
type: 'PAGE_CONTROL',
|
||||
action: 'get_last_update_time',
|
||||
targetTabId: this.currentTabId,
|
||||
})
|
||||
}
|
||||
|
||||
// getBrowserState
|
||||
async getBrowserState(): Promise<BrowserState> {
|
||||
let browserState = {} as BrowserState
|
||||
if (!this.currentTabId) throw new Error('tabsController not initialized.')
|
||||
|
||||
if (!this.currentTabId || !isContentScriptAllowed(await this.currentTabUrl)) {
|
||||
let browserState = {} as BrowserState
|
||||
debug('getBrowserState', this.currentTabId)
|
||||
|
||||
const currentUrl = await this.getCurrentUrl()
|
||||
const currentTitle = await this.getCurrentTitle()
|
||||
|
||||
if (!this.currentTabId || !isContentScriptAllowed(currentUrl)) {
|
||||
browserState = {
|
||||
url: await this.currentTabUrl,
|
||||
title: await this.currentTabTitle,
|
||||
url: currentUrl,
|
||||
title: currentTitle,
|
||||
header: '',
|
||||
content: '(empty page)',
|
||||
content: '(empty page. either current page is not readable or not loaded yet.)',
|
||||
footer: '',
|
||||
}
|
||||
} else {
|
||||
browserState = await chrome.runtime.sendMessage({
|
||||
browserState = await sendMessage({
|
||||
type: 'PAGE_CONTROL',
|
||||
action: 'get_browser_state',
|
||||
targetTabId: this.currentTabId,
|
||||
@@ -67,61 +85,58 @@ export class RemotePageController {
|
||||
const sum = await this.tabsController.summarizeTabs()
|
||||
browserState.header = sum + '\n\n' + (browserState.header || '')
|
||||
|
||||
debug('getBrowserState: success', this.currentTabId, browserState)
|
||||
|
||||
return browserState
|
||||
}
|
||||
|
||||
// updateTree
|
||||
async updateTree(): Promise<void> {
|
||||
if (!this.currentTabId || !isContentScriptAllowed(await this.currentTabUrl)) {
|
||||
if (!this.currentTabId || !isContentScriptAllowed(await this.getCurrentUrl())) {
|
||||
return
|
||||
}
|
||||
|
||||
await chrome.runtime.sendMessage({
|
||||
await sendMessage({
|
||||
type: 'PAGE_CONTROL',
|
||||
action: 'update_tree',
|
||||
targetTabId: this.currentTabId,
|
||||
})
|
||||
}
|
||||
|
||||
// cleanUpHighlights
|
||||
async cleanUpHighlights(): Promise<void> {
|
||||
if (!this.currentTabId || !isContentScriptAllowed(await this.currentTabUrl)) {
|
||||
if (!this.currentTabId || !isContentScriptAllowed(await this.getCurrentUrl())) {
|
||||
return
|
||||
}
|
||||
|
||||
await chrome.runtime.sendMessage({
|
||||
await sendMessage({
|
||||
type: 'PAGE_CONTROL',
|
||||
action: 'clean_up_highlights',
|
||||
targetTabId: this.currentTabId,
|
||||
})
|
||||
}
|
||||
|
||||
// clickElement
|
||||
async clickElement(...args: any[]): Promise<DomActionReturn> {
|
||||
return this.remoteCallDomAction('click_element', args)
|
||||
const res = await this.remoteCallDomAction('click_element', args)
|
||||
// @note may cause page navigation, wait for 1 second to ensure the page loading started
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
return res
|
||||
}
|
||||
|
||||
// inputText
|
||||
async inputText(...args: any[]): Promise<DomActionReturn> {
|
||||
return this.remoteCallDomAction('input_text', args)
|
||||
}
|
||||
|
||||
// selectOption
|
||||
async selectOption(...args: any[]): Promise<DomActionReturn> {
|
||||
return this.remoteCallDomAction('select_option', args)
|
||||
}
|
||||
|
||||
// scroll
|
||||
async scroll(...args: any[]): Promise<DomActionReturn> {
|
||||
return this.remoteCallDomAction('scroll', args)
|
||||
}
|
||||
|
||||
// scrollHorizontally
|
||||
async scrollHorizontally(...args: any[]): Promise<DomActionReturn> {
|
||||
return this.remoteCallDomAction('scroll_horizontally', args)
|
||||
}
|
||||
|
||||
// executeJavascript
|
||||
async executeJavascript(...args: any[]): Promise<DomActionReturn> {
|
||||
return this.remoteCallDomAction('execute_javascript', args)
|
||||
}
|
||||
@@ -133,35 +148,26 @@ export class RemotePageController {
|
||||
/** @note Managed by content script via storage polling. */
|
||||
dispose(): void {}
|
||||
|
||||
private async preCheck() {
|
||||
if (!this.currentTabId) {
|
||||
return 'RemotePageController not initialized.'
|
||||
}
|
||||
|
||||
if (!isContentScriptAllowed(await this.currentTabUrl)) {
|
||||
return 'Operation not allowed on this page. Use open_new_tab to navigate to a web page first.'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private async remoteCallDomAction(action: string, payload: any[]): Promise<DomActionReturn> {
|
||||
const preCheckError = await this.preCheck()
|
||||
if (preCheckError) {
|
||||
return { success: false, message: preCheckError }
|
||||
if (!this.currentTabId) {
|
||||
return { success: false, message: 'RemotePageController not initialized.' }
|
||||
}
|
||||
|
||||
return await chrome.runtime.sendMessage({
|
||||
if (!isContentScriptAllowed(await this.getCurrentUrl())) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
'Operation not allowed on this page. Use open_new_tab to navigate to a web page first.',
|
||||
}
|
||||
}
|
||||
|
||||
return sendMessage({
|
||||
type: 'PAGE_CONTROL',
|
||||
action: action,
|
||||
targetTabId: this.currentTabId!,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
private get currentTabUrl(): Promise<string> {
|
||||
return this.getCurrentUrl()
|
||||
}
|
||||
}
|
||||
|
||||
interface DomActionReturn {
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
*/
|
||||
import type { TabAction } from './TabsController'
|
||||
|
||||
const PREFIX = '[TabsController.background]'
|
||||
|
||||
function debug(...messages: any[]) {
|
||||
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
|
||||
}
|
||||
|
||||
export function handleTabControlMessage(
|
||||
message: { type: 'TAB_CONTROL'; action: TabAction; payload: any },
|
||||
sender: chrome.runtime.MessageSender,
|
||||
@@ -12,10 +18,12 @@ export function handleTabControlMessage(
|
||||
|
||||
switch (action as TabAction) {
|
||||
case 'get_active_tab': {
|
||||
debug('get_active_tab')
|
||||
chrome.tabs
|
||||
.query({ active: true, currentWindow: true })
|
||||
.then((tabs) => {
|
||||
const tabId = tabs.length > 0 ? tabs[0].id || null : null
|
||||
debug('get_active_tab: success', tabId)
|
||||
sendResponse({ success: true, tabId })
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -25,11 +33,12 @@ export function handleTabControlMessage(
|
||||
}
|
||||
|
||||
case 'get_tab_info': {
|
||||
debug('get_tab_info', payload)
|
||||
chrome.tabs
|
||||
.get(payload.tabId)
|
||||
.then((tab) => {
|
||||
const result = { title: tab.title || '', url: tab.url || '' }
|
||||
sendResponse(result)
|
||||
debug('get_tab_info: success', tab)
|
||||
sendResponse(tab)
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||
@@ -38,10 +47,11 @@ export function handleTabControlMessage(
|
||||
}
|
||||
|
||||
case 'open_new_tab': {
|
||||
debug('open_new_tab', payload)
|
||||
chrome.tabs
|
||||
.create({ url: payload.url, active: false })
|
||||
.then((newTab) => {
|
||||
// @todo: wait for tab to be fully loaded
|
||||
debug('open_new_tab: success', newTab)
|
||||
sendResponse({ success: true, tabId: newTab.id, windowId: newTab.windowId })
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -51,20 +61,22 @@ export function handleTabControlMessage(
|
||||
}
|
||||
|
||||
case 'create_tab_group': {
|
||||
debug('create_tab_group', payload)
|
||||
chrome.tabs
|
||||
.group({ tabIds: payload.tabIds, createProperties: { windowId: payload.windowId } })
|
||||
.then((groupId) => {
|
||||
console.log('Created tab group', groupId)
|
||||
debug('create_tab_group: success', groupId)
|
||||
sendResponse({ success: true, groupId })
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to create tab group', error)
|
||||
console.error(PREFIX, 'Failed to create tab group', error)
|
||||
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return true // async response
|
||||
}
|
||||
|
||||
case 'update_tab_group': {
|
||||
debug('update_tab_group', payload)
|
||||
chrome.tabGroups
|
||||
.update(payload.groupId, payload.properties)
|
||||
.then(() => {
|
||||
@@ -77,6 +89,7 @@ export function handleTabControlMessage(
|
||||
}
|
||||
|
||||
case 'add_tab_to_group': {
|
||||
debug('add_tab_to_group', payload)
|
||||
chrome.tabs
|
||||
.group({ tabIds: payload.tabId, groupId: payload.groupId })
|
||||
.then(() => {
|
||||
@@ -89,6 +102,7 @@ export function handleTabControlMessage(
|
||||
}
|
||||
|
||||
case 'close_tab': {
|
||||
debug('close_tab', payload)
|
||||
chrome.tabs
|
||||
.remove(payload.tabId)
|
||||
.then(() => {
|
||||
@@ -105,3 +119,42 @@ export function handleTabControlMessage(
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export function setupTabChangeEvents() {
|
||||
console.log('[TabsController.background] setupTabChangeEvents')
|
||||
|
||||
chrome.tabs.onCreated.addListener((tab) => {
|
||||
debug('onCreated', tab)
|
||||
chrome.runtime
|
||||
.sendMessage({ type: 'TAB_CHANGE', action: 'created', payload: { tab } })
|
||||
.catch((error) => {
|
||||
debug('onCreated error:', error)
|
||||
})
|
||||
})
|
||||
|
||||
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
|
||||
debug('onRemoved', tabId, removeInfo)
|
||||
chrome.runtime
|
||||
.sendMessage({
|
||||
type: 'TAB_CHANGE',
|
||||
action: 'removed',
|
||||
payload: { tabId, removeInfo },
|
||||
})
|
||||
.catch((error) => {
|
||||
debug('onRemoved error:', error)
|
||||
})
|
||||
})
|
||||
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
||||
debug('onUpdated', tabId, changeInfo)
|
||||
chrome.runtime
|
||||
.sendMessage({
|
||||
type: 'TAB_CHANGE',
|
||||
action: 'updated',
|
||||
payload: { tabId, changeInfo, tab },
|
||||
})
|
||||
.catch((error) => {
|
||||
debug('onUpdated error:', error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
const PREFIX = '[TabsController]'
|
||||
|
||||
function debug(...messages: any[]) {
|
||||
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
|
||||
}
|
||||
|
||||
function sendMessage(message: {
|
||||
type: 'TAB_CONTROL'
|
||||
action: TabAction
|
||||
payload?: any
|
||||
}): Promise<any> {
|
||||
return chrome.runtime.sendMessage(message).catch((error) => {
|
||||
console.error(PREFIX, message.action, error)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller for managing browser tabs.
|
||||
* - live in the agent env (extension page or content script)
|
||||
@@ -13,6 +30,8 @@ export class TabsController extends EventTarget {
|
||||
private windowId: number | null = null
|
||||
|
||||
async init(task: string, includeInitialTab: boolean = true) {
|
||||
debug('init', task, includeInitialTab)
|
||||
|
||||
this.task = task
|
||||
this.tabs = []
|
||||
this.currentTabId = null
|
||||
@@ -20,7 +39,7 @@ export class TabsController extends EventTarget {
|
||||
this.initialTabId = null
|
||||
this.windowId = null
|
||||
|
||||
const result = await chrome.runtime.sendMessage({
|
||||
const result = await sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'get_active_tab',
|
||||
})
|
||||
@@ -33,9 +52,20 @@ export class TabsController extends EventTarget {
|
||||
|
||||
if (includeInitialTab) {
|
||||
this.currentTabId = this.initialTabId
|
||||
|
||||
// update tab status immediately
|
||||
const info = await sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'get_tab_info',
|
||||
payload: { tabId: this.initialTabId },
|
||||
})
|
||||
|
||||
this.tabs.push({
|
||||
id: result.tabId,
|
||||
isInitial: true,
|
||||
url: info.url,
|
||||
title: info.title,
|
||||
status: info.status,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -70,6 +100,14 @@ export class TabsController extends EventTarget {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (message.action === 'updated') {
|
||||
const { tabId, tab } = message.payload as { tabId: number; tab: chrome.tabs.Tab }
|
||||
const targetTab = this.tabs.find((t) => t.id === tabId)
|
||||
if (targetTab) {
|
||||
targetTab.url = tab.url
|
||||
targetTab.title = tab.title
|
||||
targetTab.status = tab.status
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,8 +118,10 @@ export class TabsController extends EventTarget {
|
||||
})
|
||||
}
|
||||
|
||||
async openNewTab(url: string): Promise<{ success: boolean; tabId: number; message: string }> {
|
||||
const result = await chrome.runtime.sendMessage({
|
||||
async openNewTab(url: string): Promise<string> {
|
||||
debug('openNewTab', url)
|
||||
|
||||
const result = await sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'open_new_tab',
|
||||
payload: { url },
|
||||
@@ -104,7 +144,7 @@ export class TabsController extends EventTarget {
|
||||
await this.switchToTab(tabId)
|
||||
|
||||
if (!this.tabGroupId) {
|
||||
const result = await chrome.runtime.sendMessage({
|
||||
const result = await sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'create_tab_group',
|
||||
payload: { tabIds: [tabId], windowId: this.windowId },
|
||||
@@ -118,7 +158,7 @@ export class TabsController extends EventTarget {
|
||||
|
||||
this.tabGroupId = groupId
|
||||
|
||||
await chrome.runtime.sendMessage({
|
||||
await sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'update_tab_group',
|
||||
payload: {
|
||||
@@ -131,57 +171,43 @@ export class TabsController extends EventTarget {
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await chrome.runtime.sendMessage({
|
||||
await sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'add_tab_to_group',
|
||||
payload: { tabId: result.tabId, groupId: this.tabGroupId },
|
||||
})
|
||||
}
|
||||
|
||||
// wait for the new tab to be fully loaded
|
||||
// @todo
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
await this.waitUntilTabLoaded(tabId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
tabId,
|
||||
message: `Opened new tab ID ${tabId} with URL ${url}`,
|
||||
}
|
||||
return `✅ Opened new tab ID ${tabId} with URL ${url}`
|
||||
}
|
||||
|
||||
async switchToTab(tabId: number): Promise<{ success: boolean; message: string }> {
|
||||
async switchToTab(tabId: number): Promise<string> {
|
||||
debug('switchToTab', tabId)
|
||||
|
||||
const targetTab = this.tabs.find((t) => t.id === tabId)
|
||||
if (!targetTab) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Tab ID ${tabId} not found in tab list.`,
|
||||
}
|
||||
throw new Error(`Tab ID ${tabId} not found in tab list.`)
|
||||
}
|
||||
|
||||
await this.updateCurrentTabId(tabId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Switched to tab ID ${tabId}.`,
|
||||
}
|
||||
return `✅ Switched to tab ID ${tabId}.`
|
||||
}
|
||||
|
||||
async closeTab(tabId: number): Promise<{ success: boolean; message: string }> {
|
||||
async closeTab(tabId: number): Promise<string> {
|
||||
debug('closeTab', tabId)
|
||||
|
||||
const targetTab = this.tabs.find((t) => t.id === tabId)
|
||||
if (!targetTab) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Tab ID ${tabId} not found in tab list.`,
|
||||
}
|
||||
throw new Error(`Tab ID ${tabId} not found in tab list.`)
|
||||
}
|
||||
if (targetTab.isInitial) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Cannot close the initial tab ID ${tabId}.`,
|
||||
}
|
||||
throw new Error(`Cannot close the initial tab ID ${tabId}.`)
|
||||
}
|
||||
|
||||
const result = await chrome.runtime.sendMessage({
|
||||
const result = await sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'close_tab',
|
||||
payload: { tabId },
|
||||
@@ -198,29 +224,39 @@ export class TabsController extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Closed tab ID ${tabId}.`,
|
||||
}
|
||||
return `✅ Closed tab ID ${tabId}.`
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to close tab ID ${tabId}: ${result.error}`,
|
||||
}
|
||||
throw new Error(`Failed to close tab ID ${tabId}: ${result.error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async updateCurrentTabId(tabId: number | null) {
|
||||
debug('updateCurrentTabId', tabId)
|
||||
|
||||
this.currentTabId = tabId
|
||||
await chrome.storage.local.set({ currentTabId: tabId })
|
||||
}
|
||||
|
||||
async getTabInfo(tabId: number): Promise<{ title: string; url: string }> {
|
||||
const result = await chrome.runtime.sendMessage({
|
||||
// use cached tab info if available
|
||||
const tabMeta = this.tabs.find((t) => t.id === tabId)
|
||||
if (tabMeta && tabMeta.url && tabMeta.title) {
|
||||
return { title: tabMeta.title, url: tabMeta.url }
|
||||
}
|
||||
|
||||
// otherwise, pull the latest tab info from the background script
|
||||
debug('getTabInfo: pulling from background script', tabId)
|
||||
const result = await sendMessage({
|
||||
type: 'TAB_CONTROL',
|
||||
action: 'get_tab_info',
|
||||
payload: { tabId },
|
||||
})
|
||||
|
||||
if (tabMeta) {
|
||||
tabMeta.url = result.url
|
||||
tabMeta.title = result.title
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -239,6 +275,17 @@ export class TabsController extends EventTarget {
|
||||
return summaries.join('\n')
|
||||
}
|
||||
|
||||
async waitUntilTabLoaded(tabId: number): Promise<void> {
|
||||
const tab = this.tabs.find((t) => t.id === tabId)
|
||||
if (!tab) throw new Error(`Tab ID ${tabId} not found in tab list.`)
|
||||
|
||||
if (tab.status === 'unloaded') throw new Error(`Tab ID ${tabId} is unloaded.`)
|
||||
if (tab.status === 'complete') return
|
||||
|
||||
debug('waitUntilTabLoaded', tabId)
|
||||
await waitUntil(() => tab.status === 'complete', 4_000)
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.dispatchEvent(new Event('dispose'))
|
||||
}
|
||||
@@ -257,6 +304,9 @@ export type TabAction =
|
||||
interface TabMeta {
|
||||
id: number
|
||||
isInitial: boolean
|
||||
url?: string
|
||||
title?: string
|
||||
status?: 'loading' | 'unloaded' | 'complete'
|
||||
}
|
||||
|
||||
const TAB_GROUP_COLORS = [
|
||||
@@ -275,3 +325,33 @@ type TabGroupColor = (typeof TAB_GROUP_COLORS)[number]
|
||||
function randomColor(): TabGroupColor {
|
||||
return TAB_GROUP_COLORS[Math.floor(Math.random() * TAB_GROUP_COLORS.length)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until condition becomes true
|
||||
* @returns Returns when condition becomes true, throws otherwise
|
||||
* @param timeoutMS Timeout in milliseconds, default 1 minutes, throws error on timeout
|
||||
* @param error Error object to reject on timeout. If not provided, will resolve with false
|
||||
*/
|
||||
export async function waitUntil(
|
||||
check: () => boolean | Promise<boolean>,
|
||||
timeoutMS = 60_000,
|
||||
error?: string
|
||||
): Promise<boolean> {
|
||||
if (await check()) return true
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now()
|
||||
const poll = async () => {
|
||||
if (await check()) return resolve(true)
|
||||
if (Date.now() - start > timeoutMS) {
|
||||
if (error) {
|
||||
return reject(new Error(error))
|
||||
} else {
|
||||
return resolve(false)
|
||||
}
|
||||
}
|
||||
setTimeout(poll, 100)
|
||||
}
|
||||
setTimeout(poll, 100)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -120,7 +120,6 @@ Exhibit the following reasoning patterns to successfully achieve the <user_reque
|
||||
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>
|
||||
@@ -131,7 +130,6 @@ Here are examples of good output patterns. Use them as reference but never copy
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* - switch_to_tab: Switch to an existing tab
|
||||
* - close_tab: Close a tab (optionally switch to another)
|
||||
*/
|
||||
import zod from 'zod'
|
||||
import * as zod from 'zod'
|
||||
|
||||
import type { TabsController } from './TabsController'
|
||||
|
||||
@@ -31,8 +31,11 @@ export function createTabTools(tabsController: TabsController): Record<string, T
|
||||
}),
|
||||
execute: async (input: unknown) => {
|
||||
const { url } = input as { url: string }
|
||||
const result = await tabsController.openNewTab(url)
|
||||
return result.message
|
||||
try {
|
||||
return await tabsController.openNewTab(url)
|
||||
} catch (error) {
|
||||
return `❌ Failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -44,7 +47,11 @@ export function createTabTools(tabsController: TabsController): Record<string, T
|
||||
}),
|
||||
execute: async (input: unknown) => {
|
||||
const { tab_id } = input as { tab_id: number }
|
||||
return (await tabsController.switchToTab(tab_id)).message
|
||||
try {
|
||||
return await tabsController.switchToTab(tab_id)
|
||||
} catch (error) {
|
||||
return `❌ Failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -56,7 +63,11 @@ export function createTabTools(tabsController: TabsController): Record<string, T
|
||||
}),
|
||||
execute: async (input: unknown) => {
|
||||
const { tab_id } = input as { tab_id: number }
|
||||
return (await tabsController.closeTab(tab_id)).message
|
||||
try {
|
||||
return await tabsController.closeTab(tab_id)
|
||||
} catch (error) {
|
||||
return `❌ Failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export function useAgent(): UseAgentResult {
|
||||
|
||||
const execute = useCallback(async (task: string) => {
|
||||
const agent = agentRef.current
|
||||
console.log('Fire task:', task, agent)
|
||||
console.log('🚀 [useAgent] start executing task:', task)
|
||||
if (!agent) throw new Error('Agent not initialized')
|
||||
|
||||
setCurrentTask(task)
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
import { handlePageControlMessage } from '@/agent/RemotePageController.background'
|
||||
import { handleTabControlMessage } from '@/agent/TabsController.background'
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse): true | undefined => {
|
||||
if (message.type === 'TAB_CONTROL') {
|
||||
return handleTabControlMessage(message, sender, sendResponse)
|
||||
} else if (message.type === 'PAGE_CONTROL') {
|
||||
return handlePageControlMessage(message, sender, sendResponse)
|
||||
} else {
|
||||
sendResponse({ error: 'Unknown message type' })
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Extension Setup
|
||||
// ============================================================================
|
||||
import { handleTabControlMessage, setupTabChangeEvents } from '@/agent/TabsController.background'
|
||||
|
||||
export default defineBackground(() => {
|
||||
console.log('[Background] Service Worker started')
|
||||
|
||||
// tab change events
|
||||
|
||||
setupTabChangeEvents()
|
||||
|
||||
// generate user auth token
|
||||
|
||||
chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => {
|
||||
@@ -28,21 +17,20 @@ export default defineBackground(() => {
|
||||
chrome.storage.local.set({ PageAgentExtUserAuthToken: userAuthToken })
|
||||
})
|
||||
|
||||
// message proxy
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse): true | undefined => {
|
||||
if (message.type === 'TAB_CONTROL') {
|
||||
return handleTabControlMessage(message, sender, sendResponse)
|
||||
} else if (message.type === 'PAGE_CONTROL') {
|
||||
return handlePageControlMessage(message, sender, sendResponse)
|
||||
} else {
|
||||
sendResponse({ error: 'Unknown message type' })
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
// setup
|
||||
|
||||
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
|
||||
|
||||
// Tab change events
|
||||
|
||||
chrome.tabs.onCreated.addListener((tab) => {
|
||||
chrome.runtime.sendMessage({ type: 'TAB_CHANGE', action: 'created', payload: { tab } })
|
||||
})
|
||||
|
||||
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'TAB_CHANGE',
|
||||
action: 'removed',
|
||||
payload: { tabId, removeInfo },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ const DEBUG_PREFIX = '[Content]'
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
runAt: 'document_idle',
|
||||
runAt: 'document_end',
|
||||
|
||||
main() {
|
||||
console.debug(`${DEBUG_PREFIX} Loaded on ${window.location.href}`)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Send, Settings, Square } from 'lucide-react'
|
||||
import { History, Send, Settings, Square } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -8,20 +8,47 @@ import {
|
||||
InputGroupButton,
|
||||
InputGroupTextarea,
|
||||
} from '@/components/ui/input-group'
|
||||
import { saveSession } from '@/lib/db'
|
||||
|
||||
import { useAgent } from '../../agent/useAgent'
|
||||
import { ConfigPanel } from './components/ConfigPanel'
|
||||
import { HistoryDetail } from './components/HistoryDetail'
|
||||
import { HistoryList } from './components/HistoryList'
|
||||
import { ActivityCard, EventCard } from './components/cards'
|
||||
import { EmptyState, Logo, StatusDot } from './components/misc'
|
||||
|
||||
type View =
|
||||
| { name: 'chat' }
|
||||
| { name: 'config' }
|
||||
| { name: 'history' }
|
||||
| { name: 'history-detail'; sessionId: string }
|
||||
|
||||
export default function App() {
|
||||
const [showConfig, setShowConfig] = useState(false)
|
||||
const [view, setView] = useState<View>({ name: 'chat' })
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const historyRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const { status, history, activity, currentTask, config, execute, stop, configure } = useAgent()
|
||||
|
||||
// Persist session when task finishes
|
||||
const prevStatusRef = useRef(status)
|
||||
useEffect(() => {
|
||||
const prev = prevStatusRef.current
|
||||
prevStatusRef.current = status
|
||||
|
||||
if (
|
||||
prev === 'running' &&
|
||||
(status === 'completed' || status === 'error') &&
|
||||
history.length > 0 &&
|
||||
currentTask
|
||||
) {
|
||||
saveSession({ task: currentTask, history, status }).catch((err) =>
|
||||
console.error('[SidePanel] Failed to save session:', err)
|
||||
)
|
||||
}
|
||||
}, [status, history, currentTask])
|
||||
|
||||
// Auto-scroll to bottom on new events
|
||||
useEffect(() => {
|
||||
if (historyRef.current) {
|
||||
@@ -56,19 +83,36 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
if (showConfig) {
|
||||
// --- View routing ---
|
||||
|
||||
if (view.name === 'config') {
|
||||
return (
|
||||
<ConfigPanel
|
||||
config={config}
|
||||
onSave={async (newConfig) => {
|
||||
await configure(newConfig)
|
||||
setShowConfig(false)
|
||||
setView({ name: 'chat' })
|
||||
}}
|
||||
onClose={() => setShowConfig(false)}
|
||||
onClose={() => setView({ name: 'chat' })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (view.name === 'history') {
|
||||
return (
|
||||
<HistoryList
|
||||
onSelect={(id) => setView({ name: 'history-detail', sessionId: id })}
|
||||
onBack={() => setView({ name: 'chat' })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (view.name === 'history-detail') {
|
||||
return <HistoryDetail sessionId={view.sessionId} onBack={() => setView({ name: 'history' })} />
|
||||
}
|
||||
|
||||
// --- Chat view ---
|
||||
|
||||
const isRunning = status === 'running'
|
||||
const showEmptyState = !currentTask && history.length === 0 && !isRunning
|
||||
|
||||
@@ -85,7 +129,15 @@ export default function App() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setShowConfig(true)}
|
||||
onClick={() => setView({ name: 'history' })}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<History className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setView({ name: 'config' })}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Settings className="size-3.5" />
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { type SessionRecord, getSession } from '@/lib/db'
|
||||
|
||||
import { EventCard } from './cards'
|
||||
|
||||
export function HistoryDetail({ sessionId, onBack }: { sessionId: string; onBack: () => void }) {
|
||||
const [session, setSession] = useState<SessionRecord | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
getSession(sessionId).then((s) => setSession(s ?? null))
|
||||
}, [sessionId])
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen text-xs text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Button variant="ghost" size="icon-sm" onClick={onBack} className="cursor-pointer">
|
||||
<ArrowLeft className="size-3.5" />
|
||||
</Button>
|
||||
<span className="text-sm font-medium truncate">History</span>
|
||||
</header>
|
||||
|
||||
{/* Task */}
|
||||
<div className="border-b px-3 py-2 bg-muted/30">
|
||||
<div className="text-[10px] text-muted-foreground uppercase tracking-wide">Task</div>
|
||||
<div className="text-xs font-medium" title={session.task}>
|
||||
{session.task}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events (read-only) */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{session.history.map((event, index) => (
|
||||
// eslint-disable-next-line react-x/no-array-index-key
|
||||
<EventCard key={index} event={event} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { ArrowLeft, CheckCircle, Trash2, XCircle } from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { type SessionRecord, clearSessions, deleteSession, listSessions } from '@/lib/db'
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const seconds = Math.floor((Date.now() - ts) / 1000)
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
export function HistoryList({
|
||||
onSelect,
|
||||
onBack,
|
||||
}: {
|
||||
onSelect: (id: string) => void
|
||||
onBack: () => void
|
||||
}) {
|
||||
const [sessions, setSessions] = useState<SessionRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setSessions(await listSessions())
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation()
|
||||
await deleteSession(id)
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Button variant="ghost" size="icon-sm" onClick={onBack} className="cursor-pointer">
|
||||
<ArrowLeft className="size-3.5" />
|
||||
</Button>
|
||||
<span className="text-sm font-medium flex-1">History</span>
|
||||
{sessions.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await clearSessions()
|
||||
setSessions([])
|
||||
}}
|
||||
className="text-[10px] text-muted-foreground hover:text-destructive cursor-pointer h-6 px-2"
|
||||
>
|
||||
<Trash2 className="size-3 mr-1" />
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-32 text-xs text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && sessions.length === 0 && (
|
||||
<div className="flex items-center justify-center h-32 text-xs text-muted-foreground">
|
||||
No history yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(session.id)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onSelect(session.id)}
|
||||
className="w-full text-left px-3 py-2.5 border-b hover:bg-muted/50 transition-colors cursor-pointer flex items-start gap-2 group"
|
||||
>
|
||||
{/* Status icon */}
|
||||
{session.status === 'completed' ? (
|
||||
<CheckCircle className="size-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<XCircle className="size-3.5 text-destructive shrink-0 mt-0.5" />
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate">{session.task}</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
{timeAgo(session.createdAt)} · {session.history.length} steps
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleDelete(e, session.id)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:text-destructive cursor-pointer shrink-0"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { HistoricalEvent } from '@page-agent/core'
|
||||
import { type DBSchema, type IDBPDatabase, openDB } from 'idb'
|
||||
|
||||
const DB_NAME = 'page-agent-ext'
|
||||
const DB_VERSION = 1
|
||||
|
||||
export interface SessionRecord {
|
||||
id: string
|
||||
task: string
|
||||
history: HistoricalEvent[]
|
||||
status: 'completed' | 'error'
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
interface PageAgentDB extends DBSchema {
|
||||
sessions: {
|
||||
key: string
|
||||
value: SessionRecord
|
||||
indexes: { 'by-created': number }
|
||||
}
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase<PageAgentDB>> | null = null
|
||||
|
||||
function getDB() {
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDB<PageAgentDB>(DB_NAME, DB_VERSION, {
|
||||
upgrade(db) {
|
||||
const store = db.createObjectStore('sessions', { keyPath: 'id' })
|
||||
store.createIndex('by-created', 'createdAt')
|
||||
},
|
||||
})
|
||||
}
|
||||
return dbPromise
|
||||
}
|
||||
|
||||
export async function saveSession(
|
||||
session: Omit<SessionRecord, 'id' | 'createdAt'>
|
||||
): Promise<SessionRecord> {
|
||||
const db = await getDB()
|
||||
const record: SessionRecord = {
|
||||
...session,
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
await db.put('sessions', record)
|
||||
return record
|
||||
}
|
||||
|
||||
/** List sessions, newest first */
|
||||
export async function listSessions(): Promise<SessionRecord[]> {
|
||||
const db = await getDB()
|
||||
const all = await db.getAllFromIndex('sessions', 'by-created')
|
||||
return all.reverse()
|
||||
}
|
||||
|
||||
export async function getSession(id: string): Promise<SessionRecord | undefined> {
|
||||
const db = await getDB()
|
||||
return db.get('sessions', id)
|
||||
}
|
||||
|
||||
export async function deleteSession(id: string): Promise<void> {
|
||||
const db = await getDB()
|
||||
await db.delete('sessions', id)
|
||||
}
|
||||
|
||||
export async function clearSessions(): Promise<void> {
|
||||
const db = await getDB()
|
||||
await db.clear('sessions')
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@page-agent/llms",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-agent-llms.js",
|
||||
"module": "./dist/lib/page-agent-llms.js",
|
||||
|
||||
@@ -49,6 +49,9 @@ export class LLM extends EventTarget {
|
||||
): Promise<InvokeResult> {
|
||||
return await withRetry(
|
||||
async () => {
|
||||
// in case user aborted before invoking
|
||||
if (abortSignal.aborted) throw new Error('AbortError')
|
||||
|
||||
const result = await this.client.invoke(messages, tools, abortSignal, options)
|
||||
|
||||
return result
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Utility functions for LLM integration
|
||||
*/
|
||||
import chalk from 'chalk'
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
|
||||
import type { Tool } from './types'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "page-agent",
|
||||
"private": false,
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"main": "./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{}})\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@page-agent/core": "1.2.0",
|
||||
"@page-agent/llms": "1.2.0",
|
||||
"@page-agent/page-controller": "1.2.0",
|
||||
"@page-agent/ui": "1.2.0",
|
||||
"chalk": "^5.6.2",
|
||||
"zod": "^4.3.5",
|
||||
"@page-agent/llms": "1.1.1",
|
||||
"@page-agent/page-controller": "1.1.1",
|
||||
"@page-agent/core": "1.1.1",
|
||||
"@page-agent/ui": "1.1.1"
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { config as dotenvConfig } from 'dotenv'
|
||||
import { dirname, resolve } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig } from 'vite'
|
||||
// import { analyzer } from 'vite-bundle-analyzer'
|
||||
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
||||
|
||||
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
|
||||
// - no external
|
||||
// - no d.ts. dts does not work with monorepo aliasing
|
||||
export default defineConfig(({ mode }) => ({
|
||||
plugins: [cssInjectedByJsPlugin({ relativeCSSInjection: true })],
|
||||
export default defineConfig(() => ({
|
||||
plugins: [
|
||||
cssInjectedByJsPlugin({ relativeCSSInjection: true }),
|
||||
// analyzer()
|
||||
],
|
||||
publicDir: false,
|
||||
esbuild: {
|
||||
keepNames: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@page-agent/page-controller",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-controller.js",
|
||||
"module": "./dist/lib/page-controller.js",
|
||||
|
||||
@@ -248,7 +248,7 @@ export class PageController extends EventTarget {
|
||||
if (element instanceof HTMLAnchorElement && element.target === '_blank') {
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Clicked element (${elemText ?? index}). ⚠️ Link opens in a new tab. You are not capable of reading new tabs.`,
|
||||
message: `✅ Clicked element (${elemText ?? index}). ⚠️ Link opened in a new tab.`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@page-agent/ui",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"main": "./dist/lib/page-agent-ui.js",
|
||||
"module": "./dist/lib/page-agent-ui.js",
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export { Panel, type PanelConfig } from './panel/Panel'
|
||||
export type { AgentActivity, PanelAgentAdapter } from './panel/types'
|
||||
export { I18n, type SupportedLanguage, type TranslationKey } from './i18n'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@page-agent/website",
|
||||
"private": true,
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
@@ -10,30 +10,26 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"wouter": "^3.9.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@types/react": "^19.2.13",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"motion": "^12.30.0",
|
||||
"motion": "^12.34.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"rough-notation": "^0.5.1",
|
||||
"simple-icons": "^16.7.0",
|
||||
"simple-icons": "^16.8.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"wouter": "^3.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Demo build (auto-init with demo LLM, for quick testing)
|
||||
export const CDN_DEMO_URL =
|
||||
'https://cdn.jsdelivr.net/npm/page-agent@1.1.1/dist/iife/page-agent.demo.js'
|
||||
'https://cdn.jsdelivr.net/npm/page-agent@1.2.0/dist/iife/page-agent.demo.js'
|
||||
export const CDN_DEMO_CN_URL =
|
||||
'https://registry.npmmirror.com/page-agent/1.1.1/files/dist/iife/page-agent.demo.js'
|
||||
'https://registry.npmmirror.com/page-agent/1.2.0/files/dist/iife/page-agent.demo.js'
|
||||
|
||||
// Demo LLM for website testing
|
||||
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 ? '快速开始' : 'Quick Start', path: '/introduction/quick-start' },
|
||||
{ title: isZh ? '使用限制' : 'Limitations', path: '/introduction/limitations' },
|
||||
{
|
||||
title: isZh ? '故障排查' : 'Troubleshooting',
|
||||
path: '/introduction/troubleshooting',
|
||||
},
|
||||
{
|
||||
title: '🚧 ' + (isZh ? '最佳实践' : 'Best Practices'),
|
||||
path: '/integration/best-practices',
|
||||
|
||||
@@ -376,13 +376,6 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
? '执行任务并返回结果。包含 success、data 和 history 字段。'
|
||||
: 'Execute a task and return result. Contains success, data, and history fields.',
|
||||
},
|
||||
{
|
||||
name: 'pushObservation(content: string)',
|
||||
type: 'void',
|
||||
description: isZh
|
||||
? '向历史流推送一个观察事件,会在下一步时被 LLM 看到'
|
||||
: 'Push an observation to history stream, will be seen by LLM in next step',
|
||||
},
|
||||
{
|
||||
name: 'dispose(reason?: string)',
|
||||
type: 'void',
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function CustomTools() {
|
||||
</p>
|
||||
|
||||
<CodeEditor
|
||||
code={`import zod from 'zod'
|
||||
code={`import * as zod from 'zod'
|
||||
import { PageAgent, tool } from 'page-agent'
|
||||
|
||||
// override internal tool
|
||||
|
||||
@@ -20,6 +20,7 @@ import Limitations from './introduction/limitations/page'
|
||||
// Introduction
|
||||
import Overview from './introduction/overview/page'
|
||||
import QuickStart from './introduction/quick-start/page'
|
||||
import Troubleshooting from './introduction/troubleshooting/page'
|
||||
|
||||
function DocsPage({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -51,6 +52,11 @@ export default function DocsRouter() {
|
||||
<Limitations />
|
||||
</DocsPage>
|
||||
</Route>
|
||||
<Route path="/introduction/troubleshooting">
|
||||
<DocsPage>
|
||||
<Troubleshooting />
|
||||
</DocsPage>
|
||||
</Route>
|
||||
|
||||
{/* Features */}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user