Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20e947a1c1 | |||
| 7c87c90258 | |||
| fcb9ec4e57 | |||
| 1fcf931685 | |||
| c71ffcec1c | |||
| 430be466fc | |||
| 3f284cf2e6 | |||
| 6f0655478a | |||
| 7d5449967b | |||
| fd47888d1b | |||
| 10f470ce7d | |||
| cb29db1cc4 | |||
| 8c08f639dd | |||
| 488f080e68 | |||
| 6e07af422b | |||
| e9d721f6b9 | |||
| 840162df8c | |||
| 1517bc4868 |
+3
-3
@@ -40,7 +40,7 @@
|
|||||||
|
|
||||||
```html
|
```html
|
||||||
<script
|
<script
|
||||||
src="https://registry.npmmirror.com/page-agent/1.1.2/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"
|
crossorigin="true"
|
||||||
></script>
|
></script>
|
||||||
```
|
```
|
||||||
@@ -50,8 +50,8 @@
|
|||||||
|
|
||||||
| Mirrors | URL |
|
| Mirrors | URL |
|
||||||
| ------- | ---------------------------------------------------------------------------------- |
|
| ------- | ---------------------------------------------------------------------------------- |
|
||||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.1.2/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.1.2/files/dist/iife/page-agent.demo.js |
|
| China | https://registry.npmmirror.com/page-agent/1.2.0/files/dist/iife/page-agent.demo.js |
|
||||||
|
|
||||||
### NPM 安装
|
### NPM 安装
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Fastest way to try PageAgent with our free Demo LLM:
|
|||||||
|
|
||||||
```html
|
```html
|
||||||
<script
|
<script
|
||||||
src="https://cdn.jsdelivr.net/npm/page-agent@1.1.2/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"
|
crossorigin="true"
|
||||||
></script>
|
></script>
|
||||||
```
|
```
|
||||||
@@ -50,8 +50,8 @@ Fastest way to try PageAgent with our free Demo LLM:
|
|||||||
|
|
||||||
| Mirrors | URL |
|
| Mirrors | URL |
|
||||||
| ------- | ---------------------------------------------------------------------------------- |
|
| ------- | ---------------------------------------------------------------------------------- |
|
||||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.1.2/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.1.2/files/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
|
### NPM Installation
|
||||||
|
|
||||||
|
|||||||
Generated
+376
-376
File diff suppressed because it is too large
Load Diff
+10
-9
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "root",
|
"name": "root",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.2",
|
"version": "1.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/page-controller",
|
"packages/page-controller",
|
||||||
@@ -21,8 +21,7 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://alibaba.github.io/page-agent/",
|
"homepage": "https://alibaba.github.io/page-agent/",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0",
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
"npm": ">=10.0.0"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"cleanup": "rm -rf packages/*/dist",
|
"cleanup": "rm -rf packages/*/dist",
|
||||||
@@ -39,24 +38,26 @@
|
|||||||
"@commitlint/cli": "^20.3.1",
|
"@commitlint/cli": "^20.3.1",
|
||||||
"@commitlint/config-conventional": "^20.3.1",
|
"@commitlint/config-conventional": "^20.3.1",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@microsoft/api-extractor": "^7.55.2",
|
"@microsoft/api-extractor": "^7.56.3",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
"@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",
|
"chalk": "^5.6.2",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.4",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-react-dom": "^2.7.2",
|
"eslint-plugin-react-dom": "^2.12.2",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.0",
|
"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",
|
"globals": "^17.0.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.2.4",
|
"lint-staged": "^16.2.4",
|
||||||
"prettier": "^3.8.0",
|
"prettier": "^3.8.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.54.0",
|
"typescript-eslint": "^8.55.0",
|
||||||
"unplugin-dts": "^1.0.0-beta.6",
|
"unplugin-dts": "^1.0.0-beta.6",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-css-injected-by-js": "^3.5.2",
|
"vite-plugin-css-injected-by-js": "^3.5.2",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/core",
|
"name": "@page-agent/core",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "1.1.2",
|
"version": "1.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/esm/page-agent-core.js",
|
"main": "./dist/esm/page-agent-core.js",
|
||||||
"module": "./dist/esm/page-agent-core.js",
|
"module": "./dist/esm/page-agent-core.js",
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"zod": "^4.3.5",
|
"zod": "^4.3.5",
|
||||||
"@page-agent/llms": "1.1.2",
|
"@page-agent/llms": "1.2.0",
|
||||||
"@page-agent/page-controller": "1.1.2"
|
"@page-agent/page-controller": "1.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* All rights reserved.
|
* All rights reserved.
|
||||||
*/
|
*/
|
||||||
import { InvokeError, LLM, type Tool } from '@page-agent/llms'
|
import { InvokeError, LLM, type Tool } from '@page-agent/llms'
|
||||||
import type { PageController } from '@page-agent/page-controller'
|
import type { BrowserState, PageController } from '@page-agent/page-controller'
|
||||||
import chalk from 'chalk'
|
import chalk from 'chalk'
|
||||||
import * as zod from 'zod'
|
import * as zod from 'zod'
|
||||||
|
|
||||||
@@ -21,16 +21,25 @@ import type {
|
|||||||
MacroToolInput,
|
MacroToolInput,
|
||||||
MacroToolResult,
|
MacroToolResult,
|
||||||
} from './types'
|
} from './types'
|
||||||
import { assert, normalizeResponse, trimLines, uid, waitFor } from './utils'
|
import { assert, normalizeResponse, uid, waitFor } from './utils'
|
||||||
|
|
||||||
export { type PageAgentConfig }
|
export { type PageAgentConfig }
|
||||||
export { tool, type PageAgentTool } from './tools'
|
export { tool, type PageAgentTool } from './tools'
|
||||||
export type * from './types'
|
export type * from './types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI agent for browser DOM automation.
|
* AI agent for browser automation.
|
||||||
*
|
*
|
||||||
* @remarks
|
* @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
|
* ## Event System
|
||||||
* - `statuschange` - Agent status transitions (idle → running → completed/error)
|
* - `statuschange` - Agent status transitions (idle → running → completed/error)
|
||||||
* - `historychange` - History events updated (persistent, part of agent memory)
|
* - `historychange` - History events updated (persistent, part of agent memory)
|
||||||
@@ -49,14 +58,16 @@ export type * from './types'
|
|||||||
* - Types: thinking, executing, executed, retrying, error
|
* - Types: thinking, executing, executed, retrying, error
|
||||||
*/
|
*/
|
||||||
export class PageAgentCore extends EventTarget {
|
export class PageAgentCore extends EventTarget {
|
||||||
config: PageAgentConfig & { maxSteps: number }
|
readonly id = uid()
|
||||||
id = uid()
|
readonly config: PageAgentConfig & { maxSteps: number }
|
||||||
tools: typeof tools
|
readonly tools: typeof tools
|
||||||
|
/** PageController for DOM operations */
|
||||||
|
readonly pageController: PageController
|
||||||
|
|
||||||
task = ''
|
task = ''
|
||||||
taskId = ''
|
taskId = ''
|
||||||
|
/** History events */
|
||||||
/** Agent execution status */
|
history: HistoricalEvent[] = []
|
||||||
#status: AgentStatus = 'idle'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Callback for when agent needs user input (ask_user tool)
|
* Callback for when agent needs user input (ask_user tool)
|
||||||
@@ -65,23 +76,21 @@ export class PageAgentCore extends EventTarget {
|
|||||||
*/
|
*/
|
||||||
onAskUser?: (question: string) => Promise<string>
|
onAskUser?: (question: string) => Promise<string>
|
||||||
|
|
||||||
|
#status: AgentStatus = 'idle'
|
||||||
#llm: LLM
|
#llm: LLM
|
||||||
#abortController = new AbortController()
|
#abortController = new AbortController()
|
||||||
|
#observations: string[] = []
|
||||||
|
|
||||||
/** PageController for DOM operations */
|
/** internal states during a single task execution */
|
||||||
pageController: PageController
|
#states = {
|
||||||
|
/** Accumulated wait time in seconds */
|
||||||
/** Runtime states for tracking across steps */
|
|
||||||
states = {
|
|
||||||
/** Accumulated wait time in seconds, used by wait tool */
|
|
||||||
totalWaitTime: 0,
|
totalWaitTime: 0,
|
||||||
/** Last known URL for detecting navigation */
|
/** For detecting navigation */
|
||||||
lastURL: '',
|
lastURL: '',
|
||||||
|
/** Browser state */
|
||||||
|
browserState: null as BrowserState | null,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** History events */
|
|
||||||
history: HistoricalEvent[] = []
|
|
||||||
|
|
||||||
constructor(config: PageAgentConfig & { pageController: PageController }) {
|
constructor(config: PageAgentConfig & { pageController: PageController }) {
|
||||||
super()
|
super()
|
||||||
|
|
||||||
@@ -165,12 +174,13 @@ export class PageAgentCore extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Push a persistent observation to the history event stream.
|
* Push a observation message to the history event stream.
|
||||||
* This will be visible in <agent_history> and remain in memory across steps.
|
* 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 {
|
pushObservation(content: string): void {
|
||||||
this.history.push({ type: 'observation', content })
|
this.#observations.push(content)
|
||||||
this.#emitHistoryChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute(task: string): Promise<ExecutionResult> {
|
async execute(task: string): Promise<ExecutionResult> {
|
||||||
@@ -202,11 +212,8 @@ export class PageAgentCore extends EventTarget {
|
|||||||
this.#setStatus('running')
|
this.#setStatus('running')
|
||||||
this.#emitHistoryChange()
|
this.#emitHistoryChange()
|
||||||
|
|
||||||
// Reset states
|
// Reset internal states
|
||||||
this.states = {
|
this.#states = { totalWaitTime: 0, lastURL: '', browserState: null }
|
||||||
totalWaitTime: 0,
|
|
||||||
lastURL: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
let step = 0
|
let step = 0
|
||||||
|
|
||||||
@@ -214,18 +221,16 @@ export class PageAgentCore extends EventTarget {
|
|||||||
try {
|
try {
|
||||||
console.group(`step: ${step}`)
|
console.group(`step: ${step}`)
|
||||||
|
|
||||||
await this.#generateObservations(step)
|
|
||||||
|
|
||||||
await onBeforeStep?.(this, step)
|
await onBeforeStep?.(this, step)
|
||||||
|
|
||||||
// abort
|
// observe
|
||||||
if (this.#abortController.signal.aborted) throw new Error('AbortError')
|
|
||||||
|
|
||||||
// status
|
console.log(chalk.blue.bold('👀 Observing...'))
|
||||||
console.log(chalk.blue('Thinking...'))
|
|
||||||
this.#emitActivity({ type: 'thinking' })
|
|
||||||
|
|
||||||
// invoke LLM
|
this.#states.browserState = await this.pageController.getBrowserState()
|
||||||
|
await this.#handleObservations(step)
|
||||||
|
|
||||||
|
// assemble prompts
|
||||||
|
|
||||||
const messages = [
|
const messages = [
|
||||||
{ role: 'system' as const, content: this.#getSystemPrompt() },
|
{ role: 'system' as const, content: this.#getSystemPrompt() },
|
||||||
@@ -234,12 +239,17 @@ export class PageAgentCore extends EventTarget {
|
|||||||
|
|
||||||
const tools = { AgentOutput: this.#packMacroTool() }
|
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, {
|
const result = await this.#llm.invoke(messages, tools, this.#abortController.signal, {
|
||||||
toolChoiceName: 'AgentOutput',
|
toolChoiceName: 'AgentOutput',
|
||||||
normalizeResponse,
|
normalizeResponse,
|
||||||
})
|
})
|
||||||
|
|
||||||
// assemble history event
|
// assemble history
|
||||||
|
|
||||||
const macroResult = result.toolResult as MacroToolResult
|
const macroResult = result.toolResult as MacroToolResult
|
||||||
const input = macroResult.input
|
const input = macroResult.input
|
||||||
@@ -271,7 +281,6 @@ export class PageAgentCore extends EventTarget {
|
|||||||
|
|
||||||
await onAfterStep?.(this, this.history)
|
await onAfterStep?.(this, this.history)
|
||||||
|
|
||||||
console.log(chalk.green('Step finished:'), actionName)
|
|
||||||
console.groupEnd()
|
console.groupEnd()
|
||||||
|
|
||||||
// finish task if done
|
// finish task if done
|
||||||
@@ -347,13 +356,13 @@ export class PageAgentCore extends EventTarget {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
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>,
|
inputSchema: macroToolSchema as zod.ZodType<MacroToolInput>,
|
||||||
execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
|
execute: async (input: MacroToolInput): Promise<MacroToolResult> => {
|
||||||
// abort
|
// abort
|
||||||
if (this.#abortController.signal.aborted) throw new Error('AbortError')
|
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 action = input.action
|
||||||
|
|
||||||
const toolName = Object.keys(action)[0]
|
const toolName = Object.keys(action)[0]
|
||||||
@@ -374,7 +383,7 @@ export class PageAgentCore extends EventTarget {
|
|||||||
|
|
||||||
// Find the corresponding tool
|
// Find the corresponding tool
|
||||||
const tool = tools.get(toolName)
|
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)
|
console.log(chalk.blue.bold(`Executing tool: ${toolName}`), toolInput)
|
||||||
|
|
||||||
@@ -398,9 +407,11 @@ export class PageAgentCore extends EventTarget {
|
|||||||
duration,
|
duration,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Reset wait time for non-wait tools
|
// counting wait time
|
||||||
if (toolName !== 'wait') {
|
if (toolName === 'wait') {
|
||||||
this.states.totalWaitTime = 0
|
this.#states.totalWaitTime += toolInput?.seconds || 0
|
||||||
|
} else {
|
||||||
|
this.#states.totalWaitTime = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return structured result
|
// Return structured result
|
||||||
@@ -430,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> {
|
async #getInstructions(): Promise<string> {
|
||||||
const { instructions } = this.config
|
const { instructions } = this.config
|
||||||
if (!instructions) return ''
|
if (!instructions) return ''
|
||||||
|
|
||||||
const systemInstructions = instructions.system?.trim()
|
const systemInstructions = instructions.system?.trim()
|
||||||
const url = await this.pageController.getCurrentUrl()
|
|
||||||
let pageInstructions: string | undefined
|
let pageInstructions: string | undefined
|
||||||
|
|
||||||
if (instructions.getPageInstructions) {
|
const url = this.#states.browserState?.url || ''
|
||||||
|
if (instructions.getPageInstructions && url) {
|
||||||
try {
|
try {
|
||||||
pageInstructions = instructions.getPageInstructions(url)?.trim()
|
pageInstructions = instructions.getPageInstructions(url)?.trim()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -468,23 +479,28 @@ export class PageAgentCore extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate observations before each step
|
* Generate system observations before each step
|
||||||
* - URL change detection
|
|
||||||
* - Too many steps warning
|
|
||||||
* @todo loop detection
|
* @todo loop detection
|
||||||
* @todo console error
|
* @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
|
// Detect URL change
|
||||||
const currentURL = await this.pageController.getCurrentUrl()
|
const currentURL = this.#states.browserState?.url || ''
|
||||||
if (currentURL !== this.states.lastURL) {
|
if (currentURL !== this.#states.lastURL) {
|
||||||
this.pushObservation(`Page navigated to → ${currentURL}`)
|
this.pushObservation(`Page navigated to → ${currentURL}`)
|
||||||
this.states.lastURL = currentURL
|
this.#states.lastURL = currentURL
|
||||||
await waitFor(0.5) // wait for page to stabilize
|
await waitFor(0.5) // wait for page to stabilize
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn about remaining steps
|
// Remaining steps warning
|
||||||
const remaining = this.config.maxSteps - stepCount
|
const remaining = this.config.maxSteps - step
|
||||||
if (remaining === 5) {
|
if (remaining === 5) {
|
||||||
this.pushObservation(
|
this.pushObservation(
|
||||||
`⚠️ Only ${remaining} steps remaining. Consider wrapping up or calling done with partial results.`
|
`⚠️ Only ${remaining} steps remaining. Consider wrapping up or calling done with partial results.`
|
||||||
@@ -494,12 +510,25 @@ export class PageAgentCore extends EventTarget {
|
|||||||
`⚠️ Critical: Only ${remaining} steps left! You must finish the task or call done immediately.`
|
`⚠️ 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> {
|
async #assembleUserPrompt(): Promise<string> {
|
||||||
|
const browserState = this.#states.browserState!
|
||||||
|
|
||||||
let prompt = ''
|
let prompt = ''
|
||||||
|
|
||||||
// <instructions> (optional)
|
// <instructions> (optional)
|
||||||
|
|
||||||
prompt += await this.#getInstructions()
|
prompt += await this.#getInstructions()
|
||||||
|
|
||||||
// <agent_state>
|
// <agent_state>
|
||||||
@@ -509,38 +538,36 @@ export class PageAgentCore extends EventTarget {
|
|||||||
|
|
||||||
const stepCount = this.history.filter((e) => e.type === 'step').length
|
const stepCount = this.history.filter((e) => e.type === 'step').length
|
||||||
|
|
||||||
prompt += `<agent_state>
|
prompt += '<agent_state>\n'
|
||||||
<user_request>
|
prompt += '<user_request>\n'
|
||||||
${this.task}
|
prompt += `${this.task}\n`
|
||||||
</user_request>
|
prompt += '</user_request>\n'
|
||||||
<step_info>
|
prompt += '<step_info>\n'
|
||||||
Step ${stepCount + 1} of ${this.config.maxSteps} max possible steps
|
prompt += `Step ${stepCount + 1} of ${this.config.maxSteps} max possible steps\n`
|
||||||
Current date and time: ${new Date().toLocaleString()}
|
prompt += `Current time: ${new Date().toLocaleString()}\n`
|
||||||
</step_info>
|
prompt += '</step_info>\n'
|
||||||
</agent_state>
|
prompt += '</agent_state>\n\n'
|
||||||
`
|
|
||||||
|
|
||||||
// <agent_history>
|
// <agent_history>
|
||||||
// - <step_N> for steps
|
// - <step_N> for steps
|
||||||
// - <sys> for observations and system messages
|
// - <sys> for observations and system messages
|
||||||
|
|
||||||
prompt += '\n<agent_history>\n'
|
prompt += '<agent_history>\n'
|
||||||
|
|
||||||
let stepIndex = 0
|
let stepIndex = 0
|
||||||
for (const event of this.history) {
|
for (const event of this.history) {
|
||||||
if (event.type === 'step') {
|
if (event.type === 'step') {
|
||||||
stepIndex++
|
stepIndex++
|
||||||
prompt += `<step_${stepIndex}>
|
prompt += `<step_${stepIndex}>\n`
|
||||||
Evaluation of Previous Step: ${event.reflection.evaluation_previous_goal}
|
prompt += `Evaluation of Previous Step: ${event.reflection.evaluation_previous_goal}\n`
|
||||||
Memory: ${event.reflection.memory}
|
prompt += `Memory: ${event.reflection.memory}\n`
|
||||||
Next Goal: ${event.reflection.next_goal}
|
prompt += `Next Goal: ${event.reflection.next_goal}\n`
|
||||||
Action Results: ${event.action.output}
|
prompt += `Action Results: ${event.action.output}\n`
|
||||||
</step_${stepIndex}>
|
prompt += `</step_${stepIndex}>\n`
|
||||||
`
|
|
||||||
} else if (event.type === 'observation') {
|
} else if (event.type === 'observation') {
|
||||||
prompt += `<sys>${event.content}</sys>\n`
|
prompt += `<sys>${event.content}</sys>\n`
|
||||||
} else if (event.type === 'user_takeover') {
|
} 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') {
|
} else if (event.type === 'error') {
|
||||||
// Error events are mainly for panel rendering, not included in LLM context
|
// Error events are mainly for panel rendering, not included in LLM context
|
||||||
// to avoid polluting the agent's reasoning with transient errors
|
// to avoid polluting the agent's reasoning with transient errors
|
||||||
@@ -551,9 +578,18 @@ export class PageAgentCore extends EventTarget {
|
|||||||
|
|
||||||
// <browser_state>
|
// <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) {
|
#onDone(success = true) {
|
||||||
@@ -563,23 +599,6 @@ export class PageAgentCore extends EventTarget {
|
|||||||
this.#abortController.abort()
|
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() {
|
dispose() {
|
||||||
console.log('Disposing PageAgent...')
|
console.log('Disposing PageAgent...')
|
||||||
this.pageController.dispose()
|
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.
|
Here are examples of good output patterns. Use them as reference but never copy them directly.
|
||||||
|
|
||||||
<evaluation_examples>
|
<evaluation_examples>
|
||||||
- Positive Examples:
|
|
||||||
"evaluation_previous_goal": "Successfully navigated to the product page and found the target information. Verdict: Success"
|
"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_previous_goal": "Clicked the login button and user authentication form appeared. Verdict: Success"
|
||||||
</evaluation_examples>
|
</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_examples>
|
||||||
"next_goal": "Click on the 'Add to Cart' button to proceed with the purchase flow."
|
"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>
|
</next_goal_examples>
|
||||||
</examples>
|
</examples>
|
||||||
|
|
||||||
|
|||||||
@@ -31,14 +31,13 @@ tools.set(
|
|||||||
'done',
|
'done',
|
||||||
tool({
|
tool({
|
||||||
description:
|
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({
|
inputSchema: zod.object({
|
||||||
text: zod.string(),
|
text: zod.string(),
|
||||||
success: zod.boolean().default(true),
|
success: zod.boolean().default(true),
|
||||||
}),
|
}),
|
||||||
execute: async function (this: PageAgentCore, input) {
|
execute: async function (this: PageAgentCore, input) {
|
||||||
// @note main loop will handle this one
|
// @note main loop will handle this one
|
||||||
// this.onDone(input.text, input.success)
|
|
||||||
return Promise.resolve('Task completed')
|
return Promise.resolve('Task completed')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -47,25 +46,17 @@ tools.set(
|
|||||||
tools.set(
|
tools.set(
|
||||||
'wait',
|
'wait',
|
||||||
tool({
|
tool({
|
||||||
description:
|
description: 'Wait for x seconds. Can be used to wait until the page or data is fully loaded.',
|
||||||
'Wait for x seconds. default 1s (max 10 seconds, min 1 second). This can be used to wait until the page or data is fully loaded.',
|
|
||||||
inputSchema: zod.object({
|
inputSchema: zod.object({
|
||||||
seconds: zod.number().min(1).max(10).default(1),
|
seconds: zod.number().min(1).max(10).default(1),
|
||||||
}),
|
}),
|
||||||
execute: async function (this: PageAgentCore, input) {
|
execute: async function (this: PageAgentCore, input) {
|
||||||
|
// try to subtract LLM calling time from the actual wait time
|
||||||
const lastTimeUpdate = await this.pageController.getLastUpdateTime()
|
const lastTimeUpdate = await this.pageController.getLastUpdateTime()
|
||||||
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000)
|
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000)
|
||||||
console.log(`actualWaitTime: ${actualWaitTime} seconds`)
|
console.log(`actualWaitTime: ${actualWaitTime} seconds`)
|
||||||
await waitFor(actualWaitTime)
|
await waitFor(actualWaitTime)
|
||||||
|
|
||||||
this.states.totalWaitTime += input.seconds
|
|
||||||
|
|
||||||
if (this.states.totalWaitTime >= 3) {
|
|
||||||
this.pushObservation(
|
|
||||||
`You have waited ${this.states.totalWaitTime} seconds accumulatively. Do NOT wait any longer unless you have a good reason.`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return `✅ Waited for ${input.seconds} seconds.`
|
return `✅ Waited for ${input.seconds} seconds.`
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -106,7 +97,7 @@ tools.set(
|
|||||||
tools.set(
|
tools.set(
|
||||||
'input_text',
|
'input_text',
|
||||||
tool({
|
tool({
|
||||||
description: 'Click and input text into a input interactive element',
|
description: 'Click and type text into an interactive input element',
|
||||||
inputSchema: zod.object({
|
inputSchema: zod.object({
|
||||||
index: zod.int().min(0),
|
index: zod.int().min(0),
|
||||||
text: zod.string(),
|
text: zod.string(),
|
||||||
@@ -140,8 +131,7 @@ tools.set(
|
|||||||
tools.set(
|
tools.set(
|
||||||
'scroll',
|
'scroll',
|
||||||
tool({
|
tool({
|
||||||
description:
|
description: 'Scroll the page vertically. Use index for scroll elements (dropdowns/custom UI).',
|
||||||
'Scroll the page by specified number of pages (set down=True to scroll down, down=False to scroll up, num_pages=number of pages to scroll like 0.5 for half page, 1.0 for one page, etc.). Optional index parameter to scroll within a specific element or its scroll container (works well for dropdowns and custom UI components). Optional pixels parameter to scroll by a specific number of pixels instead of pages.',
|
|
||||||
inputSchema: zod.object({
|
inputSchema: zod.object({
|
||||||
down: zod.boolean().default(true),
|
down: zod.boolean().default(true),
|
||||||
num_pages: zod.number().min(0).max(10).optional().default(0.1),
|
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(
|
tools.set(
|
||||||
'scroll_horizontally',
|
'scroll_horizontally',
|
||||||
tool({
|
tool({
|
||||||
description:
|
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({
|
inputSchema: zod.object({
|
||||||
right: zod.boolean().default(true),
|
right: zod.boolean().default(true),
|
||||||
pixels: zod.number().int().min(0),
|
pixels: zod.number().int().min(0),
|
||||||
@@ -190,8 +183,6 @@ tools.set(
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// @todo get_dropdown_options
|
|
||||||
// @todo select_dropdown_option
|
|
||||||
// @todo send_keys
|
// @todo send_keys
|
||||||
// @todo upload_file
|
// @todo upload_file
|
||||||
// @todo go_back
|
// @todo go_back
|
||||||
|
|||||||
@@ -2,28 +2,6 @@ import chalk from 'chalk'
|
|||||||
|
|
||||||
export { normalizeResponse } from './autoFixer'
|
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> {
|
export async function waitFor(seconds: number): Promise<void> {
|
||||||
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
|
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
|
||||||
}
|
}
|
||||||
@@ -39,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 {
|
export function randomID(existingIDs?: string[]): string {
|
||||||
let id = Math.random().toString(36).substring(2, 11)
|
let id = Math.random().toString(36).substring(2, 11)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/ext",
|
"name": "@page-agent/ext",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "wxt",
|
"dev": "wxt",
|
||||||
@@ -10,23 +10,6 @@
|
|||||||
"postinstall": "wxt prepare"
|
"postinstall": "wxt prepare"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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.2",
|
|
||||||
"@page-agent/llms": "1.1.2",
|
|
||||||
"@page-agent/page-controller": "1.1.2",
|
|
||||||
"@page-agent/ui": "1.1.2",
|
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
@@ -34,14 +17,32 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@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",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"motion": "^12.30.0",
|
"motion": "^12.34.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
"rough-notation": "^0.5.1",
|
"rough-notation": "^0.5.1",
|
||||||
"simple-icons": "^16.7.0",
|
"simple-icons": "^16.8.0",
|
||||||
"sonner": "^2.0.7",
|
"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: () => {
|
onDispose: () => {
|
||||||
if (heartBeatInterval) {
|
if (heartBeatInterval) {
|
||||||
window.clearInterval(heartBeatInterval)
|
window.clearInterval(heartBeatInterval)
|
||||||
|
|||||||
@@ -8,13 +8,21 @@ export function handlePageControlMessage(
|
|||||||
sender: chrome.runtime.MessageSender,
|
sender: chrome.runtime.MessageSender,
|
||||||
sendResponse: (response: unknown) => void
|
sendResponse: (response: unknown) => void
|
||||||
): true | undefined {
|
): true | undefined {
|
||||||
|
const PREFIX = '[RemotePageController.background]'
|
||||||
|
|
||||||
|
function debug(...messages: any[]) {
|
||||||
|
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
|
||||||
|
}
|
||||||
|
|
||||||
const { action, payload, targetTabId } = message
|
const { action, payload, targetTabId } = message
|
||||||
|
|
||||||
if (action === 'get_my_tab_id') {
|
if (action === 'get_my_tab_id') {
|
||||||
|
debug('get_my_tab_id', sender.tab?.id)
|
||||||
sendResponse({ tabId: sender.tab?.id || null })
|
sendResponse({ tabId: sender.tab?.id || null })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// proxy to content script
|
||||||
chrome.tabs
|
chrome.tabs
|
||||||
.sendMessage(targetTabId, {
|
.sendMessage(targetTabId, {
|
||||||
type: 'PAGE_CONTROL',
|
type: 'PAGE_CONTROL',
|
||||||
@@ -25,6 +33,7 @@ export function handlePageControlMessage(
|
|||||||
sendResponse(result)
|
sendResponse(result)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
console.error(PREFIX, error)
|
||||||
sendResponse({
|
sendResponse({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
|||||||
@@ -12,10 +12,14 @@ export function initPageController() {
|
|||||||
.then((response) => {
|
.then((response) => {
|
||||||
return (response as { tabId: number | null }).tabId
|
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 {
|
function getPC(): PageController {
|
||||||
if (!pageController) {
|
if (!pageController) {
|
||||||
pageController = new PageController({ enableMask: false })
|
pageController = new PageController({ enableMask: false, viewportExpansion: 400 })
|
||||||
}
|
}
|
||||||
return pageController
|
return pageController
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,31 @@ import type { BrowserState } from '@page-agent/page-controller'
|
|||||||
|
|
||||||
import type { TabsController } from './TabsController'
|
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.
|
* Agent side page controller.
|
||||||
* - live in the agent env (extension page or content script)
|
* - live in the agent env (extension page or content script)
|
||||||
* - communicates with remote PageController via sw
|
* - communicates with remote PageController via sw
|
||||||
*/
|
*/
|
||||||
export class RemotePageController {
|
export class RemotePageController {
|
||||||
private tabsController: TabsController
|
tabsController: TabsController
|
||||||
|
|
||||||
constructor(tabsController: TabsController) {
|
constructor(tabsController: TabsController) {
|
||||||
this.tabsController = tabsController
|
this.tabsController = tabsController
|
||||||
@@ -18,46 +36,46 @@ export class RemotePageController {
|
|||||||
return this.tabsController.currentTabId
|
return this.tabsController.currentTabId
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCurrentUrl(): Promise<string> {
|
private async getCurrentUrl(): Promise<string> {
|
||||||
if (!this.currentTabId) return ''
|
if (!this.currentTabId) return ''
|
||||||
const { url } = await this.tabsController.getTabInfo(this.currentTabId)
|
const { url } = await this.tabsController.getTabInfo(this.currentTabId)
|
||||||
return url || ''
|
return url || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCurrentTitle(): Promise<string> {
|
private async getCurrentTitle(): Promise<string> {
|
||||||
if (!this.currentTabId) return ''
|
if (!this.currentTabId) return ''
|
||||||
const { title } = await this.tabsController.getTabInfo(this.currentTabId)
|
const { title } = await this.tabsController.getTabInfo(this.currentTabId)
|
||||||
return title || ''
|
return title || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
get currentTabTitle(): Promise<string> {
|
|
||||||
return this.getCurrentTitle()
|
|
||||||
}
|
|
||||||
|
|
||||||
async getLastUpdateTime(): Promise<number> {
|
async getLastUpdateTime(): Promise<number> {
|
||||||
if (!this.currentTabId) throw new Error('tabsController not initialized.')
|
if (!this.currentTabId) throw new Error('tabsController not initialized.')
|
||||||
|
return sendMessage({
|
||||||
return await chrome.runtime.sendMessage({
|
|
||||||
type: 'PAGE_CONTROL',
|
type: 'PAGE_CONTROL',
|
||||||
action: 'get_last_update_time',
|
action: 'get_last_update_time',
|
||||||
targetTabId: this.currentTabId,
|
targetTabId: this.currentTabId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// getBrowserState
|
|
||||||
async getBrowserState(): Promise<BrowserState> {
|
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 = {
|
browserState = {
|
||||||
url: await this.currentTabUrl,
|
url: currentUrl,
|
||||||
title: await this.currentTabTitle,
|
title: currentTitle,
|
||||||
header: '',
|
header: '',
|
||||||
content: '(empty page)',
|
content: '(empty page. either current page is not readable or not loaded yet.)',
|
||||||
footer: '',
|
footer: '',
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
browserState = await chrome.runtime.sendMessage({
|
browserState = await sendMessage({
|
||||||
type: 'PAGE_CONTROL',
|
type: 'PAGE_CONTROL',
|
||||||
action: 'get_browser_state',
|
action: 'get_browser_state',
|
||||||
targetTabId: this.currentTabId,
|
targetTabId: this.currentTabId,
|
||||||
@@ -67,61 +85,58 @@ export class RemotePageController {
|
|||||||
const sum = await this.tabsController.summarizeTabs()
|
const sum = await this.tabsController.summarizeTabs()
|
||||||
browserState.header = sum + '\n\n' + (browserState.header || '')
|
browserState.header = sum + '\n\n' + (browserState.header || '')
|
||||||
|
|
||||||
|
debug('getBrowserState: success', this.currentTabId, browserState)
|
||||||
|
|
||||||
return browserState
|
return browserState
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateTree
|
|
||||||
async updateTree(): Promise<void> {
|
async updateTree(): Promise<void> {
|
||||||
if (!this.currentTabId || !isContentScriptAllowed(await this.currentTabUrl)) {
|
if (!this.currentTabId || !isContentScriptAllowed(await this.getCurrentUrl())) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await chrome.runtime.sendMessage({
|
await sendMessage({
|
||||||
type: 'PAGE_CONTROL',
|
type: 'PAGE_CONTROL',
|
||||||
action: 'update_tree',
|
action: 'update_tree',
|
||||||
targetTabId: this.currentTabId,
|
targetTabId: this.currentTabId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanUpHighlights
|
|
||||||
async cleanUpHighlights(): Promise<void> {
|
async cleanUpHighlights(): Promise<void> {
|
||||||
if (!this.currentTabId || !isContentScriptAllowed(await this.currentTabUrl)) {
|
if (!this.currentTabId || !isContentScriptAllowed(await this.getCurrentUrl())) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await chrome.runtime.sendMessage({
|
await sendMessage({
|
||||||
type: 'PAGE_CONTROL',
|
type: 'PAGE_CONTROL',
|
||||||
action: 'clean_up_highlights',
|
action: 'clean_up_highlights',
|
||||||
targetTabId: this.currentTabId,
|
targetTabId: this.currentTabId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// clickElement
|
|
||||||
async clickElement(...args: any[]): Promise<DomActionReturn> {
|
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> {
|
async inputText(...args: any[]): Promise<DomActionReturn> {
|
||||||
return this.remoteCallDomAction('input_text', args)
|
return this.remoteCallDomAction('input_text', args)
|
||||||
}
|
}
|
||||||
|
|
||||||
// selectOption
|
|
||||||
async selectOption(...args: any[]): Promise<DomActionReturn> {
|
async selectOption(...args: any[]): Promise<DomActionReturn> {
|
||||||
return this.remoteCallDomAction('select_option', args)
|
return this.remoteCallDomAction('select_option', args)
|
||||||
}
|
}
|
||||||
|
|
||||||
// scroll
|
|
||||||
async scroll(...args: any[]): Promise<DomActionReturn> {
|
async scroll(...args: any[]): Promise<DomActionReturn> {
|
||||||
return this.remoteCallDomAction('scroll', args)
|
return this.remoteCallDomAction('scroll', args)
|
||||||
}
|
}
|
||||||
|
|
||||||
// scrollHorizontally
|
|
||||||
async scrollHorizontally(...args: any[]): Promise<DomActionReturn> {
|
async scrollHorizontally(...args: any[]): Promise<DomActionReturn> {
|
||||||
return this.remoteCallDomAction('scroll_horizontally', args)
|
return this.remoteCallDomAction('scroll_horizontally', args)
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeJavascript
|
|
||||||
async executeJavascript(...args: any[]): Promise<DomActionReturn> {
|
async executeJavascript(...args: any[]): Promise<DomActionReturn> {
|
||||||
return this.remoteCallDomAction('execute_javascript', args)
|
return this.remoteCallDomAction('execute_javascript', args)
|
||||||
}
|
}
|
||||||
@@ -133,35 +148,26 @@ export class RemotePageController {
|
|||||||
/** @note Managed by content script via storage polling. */
|
/** @note Managed by content script via storage polling. */
|
||||||
dispose(): void {}
|
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> {
|
private async remoteCallDomAction(action: string, payload: any[]): Promise<DomActionReturn> {
|
||||||
const preCheckError = await this.preCheck()
|
if (!this.currentTabId) {
|
||||||
if (preCheckError) {
|
return { success: false, message: 'RemotePageController not initialized.' }
|
||||||
return { success: false, message: preCheckError }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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',
|
type: 'PAGE_CONTROL',
|
||||||
action: action,
|
action: action,
|
||||||
targetTabId: this.currentTabId!,
|
targetTabId: this.currentTabId!,
|
||||||
payload,
|
payload,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private get currentTabUrl(): Promise<string> {
|
|
||||||
return this.getCurrentUrl()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DomActionReturn {
|
interface DomActionReturn {
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
*/
|
*/
|
||||||
import type { TabAction } from './TabsController'
|
import type { TabAction } from './TabsController'
|
||||||
|
|
||||||
|
const PREFIX = '[TabsController.background]'
|
||||||
|
|
||||||
|
function debug(...messages: any[]) {
|
||||||
|
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
|
||||||
|
}
|
||||||
|
|
||||||
export function handleTabControlMessage(
|
export function handleTabControlMessage(
|
||||||
message: { type: 'TAB_CONTROL'; action: TabAction; payload: any },
|
message: { type: 'TAB_CONTROL'; action: TabAction; payload: any },
|
||||||
sender: chrome.runtime.MessageSender,
|
sender: chrome.runtime.MessageSender,
|
||||||
@@ -12,10 +18,12 @@ export function handleTabControlMessage(
|
|||||||
|
|
||||||
switch (action as TabAction) {
|
switch (action as TabAction) {
|
||||||
case 'get_active_tab': {
|
case 'get_active_tab': {
|
||||||
|
debug('get_active_tab')
|
||||||
chrome.tabs
|
chrome.tabs
|
||||||
.query({ active: true, currentWindow: true })
|
.query({ active: true, currentWindow: true })
|
||||||
.then((tabs) => {
|
.then((tabs) => {
|
||||||
const tabId = tabs.length > 0 ? tabs[0].id || null : null
|
const tabId = tabs.length > 0 ? tabs[0].id || null : null
|
||||||
|
debug('get_active_tab: success', tabId)
|
||||||
sendResponse({ success: true, tabId })
|
sendResponse({ success: true, tabId })
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -25,11 +33,12 @@ export function handleTabControlMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'get_tab_info': {
|
case 'get_tab_info': {
|
||||||
|
debug('get_tab_info', payload)
|
||||||
chrome.tabs
|
chrome.tabs
|
||||||
.get(payload.tabId)
|
.get(payload.tabId)
|
||||||
.then((tab) => {
|
.then((tab) => {
|
||||||
const result = { title: tab.title || '', url: tab.url || '' }
|
debug('get_tab_info: success', tab)
|
||||||
sendResponse(result)
|
sendResponse(tab)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||||
@@ -38,10 +47,11 @@ export function handleTabControlMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'open_new_tab': {
|
case 'open_new_tab': {
|
||||||
|
debug('open_new_tab', payload)
|
||||||
chrome.tabs
|
chrome.tabs
|
||||||
.create({ url: payload.url, active: false })
|
.create({ url: payload.url, active: false })
|
||||||
.then((newTab) => {
|
.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 })
|
sendResponse({ success: true, tabId: newTab.id, windowId: newTab.windowId })
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -51,20 +61,22 @@ export function handleTabControlMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'create_tab_group': {
|
case 'create_tab_group': {
|
||||||
|
debug('create_tab_group', payload)
|
||||||
chrome.tabs
|
chrome.tabs
|
||||||
.group({ tabIds: payload.tabIds, createProperties: { windowId: payload.windowId } })
|
.group({ tabIds: payload.tabIds, createProperties: { windowId: payload.windowId } })
|
||||||
.then((groupId) => {
|
.then((groupId) => {
|
||||||
console.log('Created tab group', groupId)
|
debug('create_tab_group: success', groupId)
|
||||||
sendResponse({ success: true, groupId })
|
sendResponse({ success: true, groupId })
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.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) })
|
sendResponse({ error: error instanceof Error ? error.message : String(error) })
|
||||||
})
|
})
|
||||||
return true // async response
|
return true // async response
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'update_tab_group': {
|
case 'update_tab_group': {
|
||||||
|
debug('update_tab_group', payload)
|
||||||
chrome.tabGroups
|
chrome.tabGroups
|
||||||
.update(payload.groupId, payload.properties)
|
.update(payload.groupId, payload.properties)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -77,6 +89,7 @@ export function handleTabControlMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'add_tab_to_group': {
|
case 'add_tab_to_group': {
|
||||||
|
debug('add_tab_to_group', payload)
|
||||||
chrome.tabs
|
chrome.tabs
|
||||||
.group({ tabIds: payload.tabId, groupId: payload.groupId })
|
.group({ tabIds: payload.tabId, groupId: payload.groupId })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -89,6 +102,7 @@ export function handleTabControlMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'close_tab': {
|
case 'close_tab': {
|
||||||
|
debug('close_tab', payload)
|
||||||
chrome.tabs
|
chrome.tabs
|
||||||
.remove(payload.tabId)
|
.remove(payload.tabId)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -105,3 +119,42 @@ export function handleTabControlMessage(
|
|||||||
return
|
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.
|
* Controller for managing browser tabs.
|
||||||
* - live in the agent env (extension page or content script)
|
* - live in the agent env (extension page or content script)
|
||||||
@@ -13,6 +30,8 @@ export class TabsController extends EventTarget {
|
|||||||
private windowId: number | null = null
|
private windowId: number | null = null
|
||||||
|
|
||||||
async init(task: string, includeInitialTab: boolean = true) {
|
async init(task: string, includeInitialTab: boolean = true) {
|
||||||
|
debug('init', task, includeInitialTab)
|
||||||
|
|
||||||
this.task = task
|
this.task = task
|
||||||
this.tabs = []
|
this.tabs = []
|
||||||
this.currentTabId = null
|
this.currentTabId = null
|
||||||
@@ -20,7 +39,7 @@ export class TabsController extends EventTarget {
|
|||||||
this.initialTabId = null
|
this.initialTabId = null
|
||||||
this.windowId = null
|
this.windowId = null
|
||||||
|
|
||||||
const result = await chrome.runtime.sendMessage({
|
const result = await sendMessage({
|
||||||
type: 'TAB_CONTROL',
|
type: 'TAB_CONTROL',
|
||||||
action: 'get_active_tab',
|
action: 'get_active_tab',
|
||||||
})
|
})
|
||||||
@@ -33,9 +52,20 @@ export class TabsController extends EventTarget {
|
|||||||
|
|
||||||
if (includeInitialTab) {
|
if (includeInitialTab) {
|
||||||
this.currentTabId = this.initialTabId
|
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({
|
this.tabs.push({
|
||||||
id: result.tabId,
|
id: result.tabId,
|
||||||
isInitial: true,
|
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 }> {
|
async openNewTab(url: string): Promise<string> {
|
||||||
const result = await chrome.runtime.sendMessage({
|
debug('openNewTab', url)
|
||||||
|
|
||||||
|
const result = await sendMessage({
|
||||||
type: 'TAB_CONTROL',
|
type: 'TAB_CONTROL',
|
||||||
action: 'open_new_tab',
|
action: 'open_new_tab',
|
||||||
payload: { url },
|
payload: { url },
|
||||||
@@ -104,7 +144,7 @@ export class TabsController extends EventTarget {
|
|||||||
await this.switchToTab(tabId)
|
await this.switchToTab(tabId)
|
||||||
|
|
||||||
if (!this.tabGroupId) {
|
if (!this.tabGroupId) {
|
||||||
const result = await chrome.runtime.sendMessage({
|
const result = await sendMessage({
|
||||||
type: 'TAB_CONTROL',
|
type: 'TAB_CONTROL',
|
||||||
action: 'create_tab_group',
|
action: 'create_tab_group',
|
||||||
payload: { tabIds: [tabId], windowId: this.windowId },
|
payload: { tabIds: [tabId], windowId: this.windowId },
|
||||||
@@ -118,7 +158,7 @@ export class TabsController extends EventTarget {
|
|||||||
|
|
||||||
this.tabGroupId = groupId
|
this.tabGroupId = groupId
|
||||||
|
|
||||||
await chrome.runtime.sendMessage({
|
await sendMessage({
|
||||||
type: 'TAB_CONTROL',
|
type: 'TAB_CONTROL',
|
||||||
action: 'update_tab_group',
|
action: 'update_tab_group',
|
||||||
payload: {
|
payload: {
|
||||||
@@ -131,57 +171,43 @@ export class TabsController extends EventTarget {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
await chrome.runtime.sendMessage({
|
await sendMessage({
|
||||||
type: 'TAB_CONTROL',
|
type: 'TAB_CONTROL',
|
||||||
action: 'add_tab_to_group',
|
action: 'add_tab_to_group',
|
||||||
payload: { tabId: result.tabId, groupId: this.tabGroupId },
|
payload: { tabId: result.tabId, groupId: this.tabGroupId },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// wait for the new tab to be fully loaded
|
await this.waitUntilTabLoaded(tabId)
|
||||||
// @todo
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
||||||
|
|
||||||
return {
|
return `✅ Opened new tab ID ${tabId} with URL ${url}`
|
||||||
success: true,
|
|
||||||
tabId,
|
|
||||||
message: `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)
|
const targetTab = this.tabs.find((t) => t.id === tabId)
|
||||||
if (!targetTab) {
|
if (!targetTab) {
|
||||||
return {
|
throw new Error(`Tab ID ${tabId} not found in tab list.`)
|
||||||
success: false,
|
|
||||||
message: `Tab ID ${tabId} not found in tab list.`,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.updateCurrentTabId(tabId)
|
await this.updateCurrentTabId(tabId)
|
||||||
|
|
||||||
return {
|
return `✅ Switched to tab ID ${tabId}.`
|
||||||
success: true,
|
|
||||||
message: `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)
|
const targetTab = this.tabs.find((t) => t.id === tabId)
|
||||||
if (!targetTab) {
|
if (!targetTab) {
|
||||||
return {
|
throw new Error(`Tab ID ${tabId} not found in tab list.`)
|
||||||
success: false,
|
|
||||||
message: `Tab ID ${tabId} not found in tab list.`,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (targetTab.isInitial) {
|
if (targetTab.isInitial) {
|
||||||
return {
|
throw new Error(`Cannot close the initial tab ID ${tabId}.`)
|
||||||
success: false,
|
|
||||||
message: `Cannot close the initial tab ID ${tabId}.`,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await chrome.runtime.sendMessage({
|
const result = await sendMessage({
|
||||||
type: 'TAB_CONTROL',
|
type: 'TAB_CONTROL',
|
||||||
action: 'close_tab',
|
action: 'close_tab',
|
||||||
payload: { tabId },
|
payload: { tabId },
|
||||||
@@ -198,29 +224,39 @@ export class TabsController extends EventTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return `✅ Closed tab ID ${tabId}.`
|
||||||
success: true,
|
|
||||||
message: `Closed tab ID ${tabId}.`,
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
return {
|
throw new Error(`Failed to close tab ID ${tabId}: ${result.error}`)
|
||||||
success: false,
|
|
||||||
message: `Failed to close tab ID ${tabId}: ${result.error}`,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateCurrentTabId(tabId: number | null) {
|
async updateCurrentTabId(tabId: number | null) {
|
||||||
|
debug('updateCurrentTabId', tabId)
|
||||||
|
|
||||||
this.currentTabId = tabId
|
this.currentTabId = tabId
|
||||||
await chrome.storage.local.set({ currentTabId: tabId })
|
await chrome.storage.local.set({ currentTabId: tabId })
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTabInfo(tabId: number): Promise<{ title: string; url: string }> {
|
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',
|
type: 'TAB_CONTROL',
|
||||||
action: 'get_tab_info',
|
action: 'get_tab_info',
|
||||||
payload: { tabId },
|
payload: { tabId },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (tabMeta) {
|
||||||
|
tabMeta.url = result.url
|
||||||
|
tabMeta.title = result.title
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +275,17 @@ export class TabsController extends EventTarget {
|
|||||||
return summaries.join('\n')
|
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() {
|
dispose() {
|
||||||
this.dispatchEvent(new Event('dispose'))
|
this.dispatchEvent(new Event('dispose'))
|
||||||
}
|
}
|
||||||
@@ -257,6 +304,9 @@ export type TabAction =
|
|||||||
interface TabMeta {
|
interface TabMeta {
|
||||||
id: number
|
id: number
|
||||||
isInitial: boolean
|
isInitial: boolean
|
||||||
|
url?: string
|
||||||
|
title?: string
|
||||||
|
status?: 'loading' | 'unloaded' | 'complete'
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAB_GROUP_COLORS = [
|
const TAB_GROUP_COLORS = [
|
||||||
@@ -275,3 +325,33 @@ type TabGroupColor = (typeof TAB_GROUP_COLORS)[number]
|
|||||||
function randomColor(): TabGroupColor {
|
function randomColor(): TabGroupColor {
|
||||||
return TAB_GROUP_COLORS[Math.floor(Math.random() * TAB_GROUP_COLORS.length)]
|
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.
|
Here are examples of good output patterns. Use them as reference but never copy them directly.
|
||||||
|
|
||||||
<evaluation_examples>
|
<evaluation_examples>
|
||||||
- Positive Examples:
|
|
||||||
"evaluation_previous_goal": "Successfully navigated to the product page and found the target information. Verdict: Success"
|
"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_previous_goal": "Clicked the login button and user authentication form appeared. Verdict: Success"
|
||||||
</evaluation_examples>
|
</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_examples>
|
||||||
"next_goal": "Click on the 'Add to Cart' button to proceed with the purchase flow."
|
"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>
|
</next_goal_examples>
|
||||||
</examples>
|
</examples>
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,11 @@ export function createTabTools(tabsController: TabsController): Record<string, T
|
|||||||
}),
|
}),
|
||||||
execute: async (input: unknown) => {
|
execute: async (input: unknown) => {
|
||||||
const { url } = input as { url: string }
|
const { url } = input as { url: string }
|
||||||
const result = await tabsController.openNewTab(url)
|
try {
|
||||||
return result.message
|
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) => {
|
execute: async (input: unknown) => {
|
||||||
const { tab_id } = input as { tab_id: number }
|
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) => {
|
execute: async (input: unknown) => {
|
||||||
const { tab_id } = input as { tab_id: number }
|
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 execute = useCallback(async (task: string) => {
|
||||||
const agent = agentRef.current
|
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')
|
if (!agent) throw new Error('Agent not initialized')
|
||||||
|
|
||||||
setCurrentTask(task)
|
setCurrentTask(task)
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
import { handlePageControlMessage } from '@/agent/RemotePageController.background'
|
import { handlePageControlMessage } from '@/agent/RemotePageController.background'
|
||||||
import { handleTabControlMessage } from '@/agent/TabsController.background'
|
import { handleTabControlMessage, setupTabChangeEvents } 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
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export default defineBackground(() => {
|
export default defineBackground(() => {
|
||||||
console.log('[Background] Service Worker started')
|
console.log('[Background] Service Worker started')
|
||||||
|
|
||||||
|
// tab change events
|
||||||
|
|
||||||
|
setupTabChangeEvents()
|
||||||
|
|
||||||
// generate user auth token
|
// generate user auth token
|
||||||
|
|
||||||
chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => {
|
chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => {
|
||||||
@@ -28,21 +17,20 @@ export default defineBackground(() => {
|
|||||||
chrome.storage.local.set({ PageAgentExtUserAuthToken: userAuthToken })
|
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
|
// setup
|
||||||
|
|
||||||
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
|
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({
|
export default defineContentScript({
|
||||||
matches: ['<all_urls>'],
|
matches: ['<all_urls>'],
|
||||||
runAt: 'document_idle',
|
runAt: 'document_end',
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
console.debug(`${DEBUG_PREFIX} Loaded on ${window.location.href}`)
|
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 { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -8,20 +8,47 @@ import {
|
|||||||
InputGroupButton,
|
InputGroupButton,
|
||||||
InputGroupTextarea,
|
InputGroupTextarea,
|
||||||
} from '@/components/ui/input-group'
|
} from '@/components/ui/input-group'
|
||||||
|
import { saveSession } from '@/lib/db'
|
||||||
|
|
||||||
import { useAgent } from '../../agent/useAgent'
|
import { useAgent } from '../../agent/useAgent'
|
||||||
import { ConfigPanel } from './components/ConfigPanel'
|
import { ConfigPanel } from './components/ConfigPanel'
|
||||||
|
import { HistoryDetail } from './components/HistoryDetail'
|
||||||
|
import { HistoryList } from './components/HistoryList'
|
||||||
import { ActivityCard, EventCard } from './components/cards'
|
import { ActivityCard, EventCard } from './components/cards'
|
||||||
import { EmptyState, Logo, StatusDot } from './components/misc'
|
import { EmptyState, Logo, StatusDot } from './components/misc'
|
||||||
|
|
||||||
|
type View =
|
||||||
|
| { name: 'chat' }
|
||||||
|
| { name: 'config' }
|
||||||
|
| { name: 'history' }
|
||||||
|
| { name: 'history-detail'; sessionId: string }
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [showConfig, setShowConfig] = useState(false)
|
const [view, setView] = useState<View>({ name: 'chat' })
|
||||||
const [inputValue, setInputValue] = useState('')
|
const [inputValue, setInputValue] = useState('')
|
||||||
const historyRef = useRef<HTMLDivElement>(null)
|
const historyRef = useRef<HTMLDivElement>(null)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
const { status, history, activity, currentTask, config, execute, stop, configure } = useAgent()
|
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
|
// Auto-scroll to bottom on new events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (historyRef.current) {
|
if (historyRef.current) {
|
||||||
@@ -56,19 +83,36 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showConfig) {
|
// --- View routing ---
|
||||||
|
|
||||||
|
if (view.name === 'config') {
|
||||||
return (
|
return (
|
||||||
<ConfigPanel
|
<ConfigPanel
|
||||||
config={config}
|
config={config}
|
||||||
onSave={async (newConfig) => {
|
onSave={async (newConfig) => {
|
||||||
await configure(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 isRunning = status === 'running'
|
||||||
const showEmptyState = !currentTask && history.length === 0 && !isRunning
|
const showEmptyState = !currentTask && history.length === 0 && !isRunning
|
||||||
|
|
||||||
@@ -85,7 +129,15 @@ export default function App() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
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"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
<Settings className="size-3.5" />
|
<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",
|
"name": "@page-agent/llms",
|
||||||
"version": "1.1.2",
|
"version": "1.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/lib/page-agent-llms.js",
|
"main": "./dist/lib/page-agent-llms.js",
|
||||||
"module": "./dist/lib/page-agent-llms.js",
|
"module": "./dist/lib/page-agent-llms.js",
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export class LLM extends EventTarget {
|
|||||||
): Promise<InvokeResult> {
|
): Promise<InvokeResult> {
|
||||||
return await withRetry(
|
return await withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
|
// in case user aborted before invoking
|
||||||
|
if (abortSignal.aborted) throw new Error('AbortError')
|
||||||
|
|
||||||
const result = await this.client.invoke(messages, tools, abortSignal, options)
|
const result = await this.client.invoke(messages, tools, abortSignal, options)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "page-agent",
|
"name": "page-agent",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "1.1.2",
|
"version": "1.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/esm/page-agent.js",
|
"main": "./dist/esm/page-agent.js",
|
||||||
"module": "./dist/esm/page-agent.js",
|
"module": "./dist/esm/page-agent.js",
|
||||||
@@ -44,10 +44,10 @@
|
|||||||
"postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
"postpublish": "node -e \"['README.md','LICENSE'].forEach(f=>{try{require('fs').unlinkSync(f)}catch{}})\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@page-agent/core": "1.1.2",
|
"@page-agent/core": "1.2.0",
|
||||||
"@page-agent/llms": "1.1.2",
|
"@page-agent/llms": "1.2.0",
|
||||||
"@page-agent/page-controller": "1.1.2",
|
"@page-agent/page-controller": "1.2.0",
|
||||||
"@page-agent/ui": "1.1.2",
|
"@page-agent/ui": "1.2.0",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/page-controller",
|
"name": "@page-agent/page-controller",
|
||||||
"version": "1.1.2",
|
"version": "1.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/lib/page-controller.js",
|
"main": "./dist/lib/page-controller.js",
|
||||||
"module": "./dist/lib/page-controller.js",
|
"module": "./dist/lib/page-controller.js",
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ export class PageController extends EventTarget {
|
|||||||
if (element instanceof HTMLAnchorElement && element.target === '_blank') {
|
if (element instanceof HTMLAnchorElement && element.target === '_blank') {
|
||||||
return {
|
return {
|
||||||
success: true,
|
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",
|
"name": "@page-agent/ui",
|
||||||
"version": "1.1.2",
|
"version": "1.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/lib/page-agent-ui.js",
|
"main": "./dist/lib/page-agent-ui.js",
|
||||||
"module": "./dist/lib/page-agent-ui.js",
|
"module": "./dist/lib/page-agent-ui.js",
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
export { Panel, type PanelConfig } from './panel/Panel'
|
export { Panel, type PanelConfig } from './panel/Panel'
|
||||||
export type { AgentActivity, PanelAgentAdapter } from './panel/types'
|
|
||||||
export { I18n, type SupportedLanguage, type TranslationKey } from './i18n'
|
export { I18n, type SupportedLanguage, type TranslationKey } from './i18n'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@page-agent/website",
|
"name": "@page-agent/website",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.2",
|
"version": "1.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
@@ -10,30 +10,26 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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-icons": "^1.3.2",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@types/react": "^19.2.13",
|
||||||
|
"@types/react-dom": "^19.2.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"motion": "^12.30.0",
|
"motion": "^12.34.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
"rough-notation": "^0.5.1",
|
"rough-notation": "^0.5.1",
|
||||||
"simple-icons": "^16.7.0",
|
"simple-icons": "^16.8.0",
|
||||||
"sonner": "^2.0.7",
|
"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)
|
// Demo build (auto-init with demo LLM, for quick testing)
|
||||||
export const CDN_DEMO_URL =
|
export const CDN_DEMO_URL =
|
||||||
'https://cdn.jsdelivr.net/npm/page-agent@1.1.2/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 =
|
export const CDN_DEMO_CN_URL =
|
||||||
'https://registry.npmmirror.com/page-agent/1.1.2/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
|
// Demo LLM for website testing
|
||||||
export const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
export const DEMO_MODEL = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
||||||
|
|||||||
@@ -376,13 +376,6 @@ const result = await agent.execute('Fill in the form with test data')`}
|
|||||||
? '执行任务并返回结果。包含 success、data 和 history 字段。'
|
? '执行任务并返回结果。包含 success、data 和 history 字段。'
|
||||||
: 'Execute a task and return result. Contains success, data, and history fields.',
|
: '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)',
|
name: 'dispose(reason?: string)',
|
||||||
type: 'void',
|
type: 'void',
|
||||||
|
|||||||
Reference in New Issue
Block a user