Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c04c02723d | |||
| 77468e4ab4 | |||
| 7f9cb61fc2 | |||
| 76fea5b285 | |||
| a796453c6e | |||
| a1b0dd90eb | |||
| fb8c42daa4 | |||
| 8eaa784a8a | |||
| 7dffaa4718 | |||
| 0f3f639497 | |||
| 67b1874836 | |||
| 56c09a9ae9 | |||
| e65c7c9601 | |||
| a646da80f5 | |||
| d3797109bf | |||
| 4e881f7eb0 | |||
| 768b07e28c | |||
| 4828f9f726 | |||
| edb769b826 | |||
| e270ba15b5 | |||
| c19891926b | |||
| 0438bf6265 | |||
| 255e8fc861 | |||
| 4690aefec5 | |||
| 8f9a637bdb | |||
| c2d6a864f8 | |||
| 1deaa2147e | |||
| 052a302a08 | |||
| 9ed0a09194 | |||
| 73810b3ed8 | |||
| 01c9cc9ea7 | |||
| a7beabbbe2 | |||
| b3ff0a1a25 | |||
| daae28db34 | |||
| 797c238862 | |||
| 270791bb54 | |||
| 8ec460f431 | |||
| 8c3a5f8188 | |||
| 4ee773bef9 | |||
| 7b623c5b22 | |||
| e525e4c6dd | |||
| 903c2a736c | |||
| c6f09375f8 | |||
| cc33297cc4 | |||
| 8556745645 |
+2
-1
@@ -39,4 +39,5 @@ package.json.bak
|
||||
# AI
|
||||
.claude
|
||||
.cursor
|
||||
.gemini
|
||||
.gemini
|
||||
.codex
|
||||
Vendored
+1
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"agentic",
|
||||
"contenteditable",
|
||||
"deepseek",
|
||||
"historychange",
|
||||
|
||||
@@ -13,7 +13,9 @@ The GUI Agent Living in Your Webpage. Control web interfaces with natural langua
|
||||
|
||||
<a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank"><b>📖 Docs</b></a> | <a href="https://news.ycombinator.com/item?id=47264138" target="_blank"><b>📢 HN Discussion</b></a> | <a href="https://x.com/simonluvramen" target="_blank"><b>𝕏 Follow on X</b></a>
|
||||
|
||||
<video id="demo-video" src="https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2" controls crossorigin muted></video>
|
||||
<!-- demo video -->
|
||||
|
||||
https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2
|
||||
|
||||
---
|
||||
|
||||
@@ -50,8 +52,8 @@ Fastest way to try PageAgent with our free Demo LLM:
|
||||
|
||||
| Mirrors | URL |
|
||||
| ------- | ---------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.9.0/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.9.0/files/dist/iife/page-agent.demo.js |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.10.0/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.10.0/files/dist/iife/page-agent.demo.js |
|
||||
|
||||
Add `?autoInit=false` to load the script without creating the demo agent automatically. You can then instantiate it with `new window.PageAgent(...)`.
|
||||
|
||||
|
||||
@@ -5,6 +5,99 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.10.0] - 2026-06-15
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **Agent run lifecycle rework** - `stop()` is now async and resolves only after the run fully settles. Run status is decoupled from task outcome: a new `stopped` state was added, and LLM self-reported failures now end as `completed`. Lifecycle hooks re-throw instead of folding errors into the result, agent errors are recorded in history, and `agent.lastResult` was added.
|
||||
|
||||
### Features
|
||||
|
||||
- **Abortable JavaScript execution** - `execute_javascript` now honors the `AbortSignal`.
|
||||
- **Leaner agent prompts** - Simplified the waiting-response flow and removed navigation-back instructions to reduce LLM cognitive load.
|
||||
- **MultiPageAgent safety** - Disabled `ScriptExecutionTool` for `MultiPageAgent`.
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Dark mode detection** - Refined detection heuristics and made `isMainContentDark` less aggressive by checking `html` and `body` data attributes independently.
|
||||
- **Extension lifecycle robustness** - Drove heartbeat and running state from status changes, cleared stale activity on any non-running status, handled the stopped lifecycle state, and cleared `currentTabId` on `TabsController.init`.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Accurate wait reporting** - Wait steps now report the actual wait duration.
|
||||
- **Scroll predicates** - Scroll predicates now return booleans.
|
||||
- **Docs** - Fixed the broken demo video on GitHub.
|
||||
|
||||
## [1.9.0] - 2026-06-08
|
||||
|
||||
### Features
|
||||
|
||||
- **Robust abort handling** - Rewrote the aborting system; sync tools, loop execution, and LLM clients now correctly respect task abort signals (`ctx.signal`). Also decoupled `AbortError` from `InvokeError` in `@page-agent/llms`.
|
||||
- **Claude Opus 4.8 support** - Added support for Claude Opus 4.8 model.
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Concurrency guard** - Prevented concurrent `execute()` calls on a single PageAgent/Core instance to avoid race conditions.
|
||||
- **Model recommendations refresh** - Updated default and tested model list recommendations.
|
||||
- **Test coverage** - Added comprehensive Vitest unit tests for the `@page-agent/llms` package.
|
||||
- **Improved documentation** - Added website documentation for the `ctx.signal` abort contract and `execute()` concurrency rules.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **DTS bundle fix** - Fixed a packaging bug where global type declarations were incorrectly bundled into `.d.ts` outputs.
|
||||
- **Website sidebar fix** - Normalized trailing slashes in the website's sidebar location comparison.
|
||||
|
||||
## [1.8.2] - 2026-05-11
|
||||
|
||||
### Features
|
||||
|
||||
- **IIFE demo control** - Added `showPanel` and `autoInit` switches to the IIFE CDN script to control whether the UI panel automatically displays or initializes on load.
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Build toolchain modernization** - Upgraded build infrastructure to Vite 8.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **TypeScript `InvokeErrorType` fix** - Separated the value and type space for `InvokeErrorType` to resolve TypeScript compilation issues.
|
||||
- **Website chunking fix** - Restored working code-splitting with `manualChunks` for the documentation website.
|
||||
|
||||
## [1.8.1] - 2026-04-27
|
||||
|
||||
### Features
|
||||
|
||||
- **GPT-5.4 & Qwen 3.6 support** - Added support for `gpt-5.4` and `qwen3.6-max/flash` in the recommended LLM list.
|
||||
- **Custom LLM request hook** - Added a `transformRequestBody` hook to allow custom modification of payloads before sending requests to LLM providers.
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Accessibility (a11y) enhancements** - Added descriptive accessible labels to `ConfigPanel` input fields and icon buttons in compliance with WCAG 4.1.2.
|
||||
- **UI polish** - Improved `HistoryList` loading and empty states, and added helpful tooltips for actions.
|
||||
- **Prompt caching guidance** - Added website documentation for prompt caching optimization.
|
||||
- **Build speedups** - Added parallel build scripts to accelerate local development compilation.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **DeepSeek tool choice fix** - Disabled explicit `tool_choice` for DeepSeek models to avoid API compatibility errors.
|
||||
- **MCP version advertising** - MCP server now correctly advertises its package version.
|
||||
|
||||
## [1.8.0] - 2026-04-15
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **TypeScript 6 & ESLint 10 upgrade** - Major toolchain modernization. Upgraded the entire monorepo to TypeScript 6 and ESLint 10 with source-first monorepo resolution (library exports resolve to source files directly during local development).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **MCP security hardening** - Bound the MCP HTTP + WebSocket server to `localhost` only.
|
||||
- **Extension UI refinement** - Made the history panel height responsive to the viewport and improved the result card readability by increasing font size.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **SimulatorMask memory leak** - Fixed a memory leak by ensuring the `requestAnimationFrame` loop is cancelled when disposing the SimulatorMask.
|
||||
- **Autofixer format fix** - Corrected the fallback action format for `autoFixer` when waiting.
|
||||
- **IIFE scope protection** - Fixed a name collision in IIFE builds by preventing global helper function re-declarations.
|
||||
|
||||
## [1.7.1] - 2026-04-04
|
||||
|
||||
### Features
|
||||
|
||||
+5
-3
@@ -13,7 +13,9 @@
|
||||
|
||||
<a href="https://alibaba.github.io/page-agent/" target="_blank"><b>🚀 Demo</b></a> | <a href="https://alibaba.github.io/page-agent/docs/introduction/overview" target="_blank"><b>📖 Docs</b></a> | <a href="https://news.ycombinator.com/item?id=47264138" target="_blank"><b>📢 HN Discussion</b></a> | <a href="https://x.com/simonluvramen" target="_blank"><b>𝕏 Follow on X</b></a>
|
||||
|
||||
<video id="demo-video" src="https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2" controls crossorigin muted></video>
|
||||
<!-- demo video -->
|
||||
|
||||
https://github.com/user-attachments/assets/a1f2eae2-13fb-4aae-98cf-a3fc1620a6c2
|
||||
|
||||
---
|
||||
|
||||
@@ -49,8 +51,8 @@
|
||||
|
||||
| Mirrors | URL |
|
||||
| ------- | ---------------------------------------------------------------------------------- |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.9.0/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.9.0/files/dist/iife/page-agent.demo.js |
|
||||
| Global | https://cdn.jsdelivr.net/npm/page-agent@1.10.0/dist/iife/page-agent.demo.js |
|
||||
| China | https://registry.npmmirror.com/page-agent/1.10.0/files/dist/iife/page-agent.demo.js |
|
||||
|
||||
在 URL 后添加 `?autoInit=false` 可只加载脚本,不自动创建 Demo Agent;之后可通过 `new window.PageAgent(...)` 手动初始化。
|
||||
|
||||
|
||||
Generated
+438
-450
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/page-controller",
|
||||
@@ -45,12 +45,12 @@
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^21.0.1",
|
||||
"@commitlint/config-conventional": "^21.0.1",
|
||||
"@eslint-react/eslint-plugin": "^5.8.6",
|
||||
"@eslint-react/eslint-plugin": "^5.8.16",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@microsoft/api-extractor": "^7.58.7",
|
||||
"@microsoft/api-extractor": "^7.58.8",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/node": "^25.9.2",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"chalk": "^5.6.2",
|
||||
"concurrently": "^10.0.3",
|
||||
@@ -61,7 +61,7 @@
|
||||
"lint-staged": "^17.0.5",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.60.0",
|
||||
"typescript-eslint": "^8.61.0",
|
||||
"unplugin-dts": "^1.0.1",
|
||||
"vite": "^8.0.14",
|
||||
"vite-plugin-css-injected-by-js": "^5.0.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@page-agent/core",
|
||||
"private": false,
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"type": "module",
|
||||
"main": "./src/PageAgentCore.ts",
|
||||
"types": "./src/PageAgentCore.ts",
|
||||
@@ -48,14 +48,15 @@
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"dev:iife": "concurrently \"vite build --config vite.iife.config.js --watch\" \"npx serve dist/iife -p 5174\"",
|
||||
"prepublishOnly": "node ../../scripts/pre-publish.js",
|
||||
"postpublish": "node ../../scripts/post-publish.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"@page-agent/llms": "1.9.0",
|
||||
"@page-agent/page-controller": "1.9.0"
|
||||
"@page-agent/llms": "1.10.0",
|
||||
"@page-agent/page-controller": "1.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import type { BrowserState, PageController } from '@page-agent/page-controller'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import * as z from 'zod/v4'
|
||||
|
||||
import { PageAgentCore, tool } from './PageAgentCore'
|
||||
import type { ExecutionResult } from './types'
|
||||
|
||||
type TestFetch = (...args: Parameters<typeof globalThis.fetch>) => Promise<Response>
|
||||
|
||||
function agentResponse(args: unknown): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
finish_reason: 'tool_calls',
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
name: 'AgentOutput',
|
||||
arguments: JSON.stringify(args),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
usage: {},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function createPageController(): PageController {
|
||||
const browserState: BrowserState = {
|
||||
url: 'https://example.test/',
|
||||
title: 'Test page',
|
||||
header: '',
|
||||
content: '',
|
||||
footer: '',
|
||||
}
|
||||
|
||||
return {
|
||||
showMask: vi.fn(async () => {}),
|
||||
hideMask: vi.fn(),
|
||||
cleanUpHighlights: vi.fn(),
|
||||
getLastUpdateTime: vi.fn(() => Date.now()),
|
||||
getBrowserState: vi.fn(async () => browserState),
|
||||
dispose: vi.fn(),
|
||||
} as unknown as PageController
|
||||
}
|
||||
|
||||
function createAgent(
|
||||
customFetch: TestFetch,
|
||||
options: Partial<ConstructorParameters<typeof PageAgentCore>[0]> = {}
|
||||
): PageAgentCore {
|
||||
return new PageAgentCore({
|
||||
baseURL: 'https://llm.test',
|
||||
model: 'test-model',
|
||||
maxRetries: 0,
|
||||
stepDelay: 0,
|
||||
customFetch,
|
||||
customSystemPrompt: 'test',
|
||||
pageController: createPageController(),
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
function createFetchMock() {
|
||||
return vi.fn<TestFetch>()
|
||||
}
|
||||
|
||||
function onceActivity(
|
||||
agent: PageAgentCore,
|
||||
predicate: (detail: unknown) => boolean
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const onActivity = (event: Event) => {
|
||||
if (!predicate((event as CustomEvent).detail)) return
|
||||
agent.removeEventListener('activity', onActivity)
|
||||
resolve()
|
||||
}
|
||||
|
||||
agent.addEventListener('activity', onActivity)
|
||||
})
|
||||
}
|
||||
|
||||
function isExecutingTool(detail: unknown, toolName: string): boolean {
|
||||
return (
|
||||
typeof detail === 'object' &&
|
||||
detail !== null &&
|
||||
'type' in detail &&
|
||||
'tool' in detail &&
|
||||
detail.type === 'executing' &&
|
||||
detail.tool === toolName
|
||||
)
|
||||
}
|
||||
|
||||
function doneResponse(text: string, success = true): Response {
|
||||
return agentResponse({ action: { done: { text, success } } })
|
||||
}
|
||||
|
||||
function waitResponse(seconds = 10): Response {
|
||||
return agentResponse({ action: { wait: { seconds } } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a task that blocks on `wait`, returning once the tool is executing.
|
||||
* The running promise is wrapped so awaiting this helper does not await the task.
|
||||
*/
|
||||
async function startBlockedTask(
|
||||
agent: PageAgentCore,
|
||||
task = 'first'
|
||||
): Promise<{ result: Promise<ExecutionResult> }> {
|
||||
const waitStarted = onceActivity(agent, (detail) => isExecutingTool(detail, 'wait'))
|
||||
const result = agent.execute(task)
|
||||
await waitStarted
|
||||
return { result }
|
||||
}
|
||||
|
||||
describe.concurrent('PageAgentCore lifecycle', () => {
|
||||
describe('normal execution', () => {
|
||||
it('runs a task to natural completion', async () => {
|
||||
const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('all done'))
|
||||
const agent = createAgent(fetchMock)
|
||||
|
||||
const result = await agent.execute('do something')
|
||||
|
||||
expect(result).toMatchObject({ success: true, data: 'all done' })
|
||||
expect(agent.status).toBe('completed')
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('completes (not errors) when the LLM reports task failure', async () => {
|
||||
const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('gave up', false))
|
||||
const agent = createAgent(fetchMock)
|
||||
|
||||
const result = await agent.execute('do something')
|
||||
|
||||
expect(result).toMatchObject({ success: false, data: 'gave up' })
|
||||
expect(agent.status).toBe('completed')
|
||||
})
|
||||
|
||||
it('throws when a task is already running', async () => {
|
||||
const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse())
|
||||
const agent = createAgent(fetchMock)
|
||||
const { result } = await startBlockedTask(agent)
|
||||
|
||||
await expect(agent.execute('second')).rejects.toThrow('A task is already running.')
|
||||
|
||||
await agent.stop()
|
||||
await result
|
||||
})
|
||||
})
|
||||
|
||||
describe('stop', () => {
|
||||
it('aborts the running task and keeps the agent reusable', async () => {
|
||||
const fetchMock = createFetchMock()
|
||||
.mockResolvedValueOnce(waitResponse())
|
||||
.mockResolvedValueOnce(doneResponse('second task'))
|
||||
const agent = createAgent(fetchMock)
|
||||
const { result: firstTask } = await startBlockedTask(agent)
|
||||
|
||||
await agent.stop()
|
||||
expect(agent.status).toBe('stopped')
|
||||
await expect(firstTask).resolves.toMatchObject({ success: false, data: 'Task aborted' })
|
||||
|
||||
const secondTask = await agent.execute('second')
|
||||
expect(secondTask).toMatchObject({ success: true, data: 'second task' })
|
||||
expect(agent.status).toBe('completed')
|
||||
})
|
||||
|
||||
it('resolves only after the run has fully settled', async () => {
|
||||
const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse())
|
||||
const agent = createAgent(fetchMock)
|
||||
const { result } = await startBlockedTask(agent)
|
||||
|
||||
await agent.stop()
|
||||
expect(agent.status).toBe('stopped')
|
||||
await expect(result).resolves.toMatchObject({ success: false })
|
||||
})
|
||||
|
||||
it('is a no-op when no task is running', async () => {
|
||||
const agent = createAgent(createFetchMock())
|
||||
|
||||
await expect(agent.stop()).resolves.toBeUndefined()
|
||||
await expect(agent.stop()).resolves.toBeUndefined()
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispose', () => {
|
||||
it('aborts the running task and blocks further execution', async () => {
|
||||
const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse())
|
||||
const agent = createAgent(fetchMock)
|
||||
const { result: task } = await startBlockedTask(agent)
|
||||
|
||||
agent.dispose()
|
||||
await expect(task).resolves.toMatchObject({ success: false, data: 'Task aborted' })
|
||||
|
||||
expect(agent.disposed).toBe(true)
|
||||
await expect(agent.execute('again')).rejects.toThrow('has been disposed')
|
||||
})
|
||||
|
||||
it('is idempotent', () => {
|
||||
const agent = createAgent(createFetchMock())
|
||||
|
||||
expect(() => {
|
||||
agent.dispose()
|
||||
agent.dispose()
|
||||
}).not.toThrow()
|
||||
expect(agent.disposed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('fails the task when the network request rejects', async () => {
|
||||
const fetchMock = createFetchMock().mockRejectedValue(new Error('network down'))
|
||||
const agent = createAgent(fetchMock)
|
||||
|
||||
const result = await agent.execute('do something')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(agent.status).toBe('error')
|
||||
})
|
||||
|
||||
it('fails the task when a tool throws', async () => {
|
||||
const fetchMock = createFetchMock().mockResolvedValue(agentResponse({ action: { boom: {} } }))
|
||||
const agent = createAgent(fetchMock, {
|
||||
customTools: {
|
||||
boom: tool({
|
||||
description: 'Always throws.',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
throw new Error('tool exploded')
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const result = await agent.execute('trigger tool error')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(agent.status).toBe('error')
|
||||
})
|
||||
|
||||
it('re-throws and sets error status when onBeforeTask throws', async () => {
|
||||
const agent = createAgent(createFetchMock(), {
|
||||
onBeforeTask: async () => {
|
||||
throw new Error('setup failed')
|
||||
},
|
||||
})
|
||||
|
||||
await expect(agent.execute('do something')).rejects.toThrow('setup failed')
|
||||
expect(agent.status).toBe('error')
|
||||
expect(agent.history.some((e) => e.type === 'error')).toBe(false)
|
||||
})
|
||||
|
||||
it('re-throws and sets error status when onAfterTask throws', async () => {
|
||||
const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('all done'))
|
||||
const agent = createAgent(fetchMock, {
|
||||
onAfterTask: async () => {
|
||||
throw new Error('teardown failed')
|
||||
},
|
||||
})
|
||||
|
||||
await expect(agent.execute('do something')).rejects.toThrow('teardown failed')
|
||||
expect(agent.status).toBe('error')
|
||||
})
|
||||
|
||||
it('stays reusable after onBeforeTask throws', async () => {
|
||||
const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('second'))
|
||||
let failOnce = true
|
||||
const agent = createAgent(fetchMock, {
|
||||
onBeforeTask: async () => {
|
||||
if (failOnce) {
|
||||
failOnce = false
|
||||
throw new Error('setup failed')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
await expect(agent.execute('first')).rejects.toThrow('setup failed')
|
||||
const result = await agent.execute('second')
|
||||
expect(result).toMatchObject({ success: true, data: 'second' })
|
||||
})
|
||||
|
||||
it('re-throws and sets error status when onBeforeStep throws', async () => {
|
||||
const agent = createAgent(createFetchMock(), {
|
||||
onBeforeStep: async () => {
|
||||
throw new Error('before step failed')
|
||||
},
|
||||
})
|
||||
|
||||
await expect(agent.execute('do something')).rejects.toThrow('before step failed')
|
||||
expect(agent.status).toBe('error')
|
||||
expect(agent.history.some((e) => e.type === 'error')).toBe(false)
|
||||
})
|
||||
|
||||
it('re-throws and sets error status when onAfterStep throws', async () => {
|
||||
const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('all done'))
|
||||
const agent = createAgent(fetchMock, {
|
||||
onAfterStep: async () => {
|
||||
throw new Error('after step failed')
|
||||
},
|
||||
})
|
||||
|
||||
await expect(agent.execute('do something')).rejects.toThrow('after step failed')
|
||||
expect(agent.status).toBe('error')
|
||||
expect(agent.history.some((e) => e.type === 'error')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancellation edge cases', () => {
|
||||
it('rejects a new task while a stop is still settling', async () => {
|
||||
const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse())
|
||||
const agent = createAgent(fetchMock)
|
||||
const { result: firstTask } = await startBlockedTask(agent)
|
||||
|
||||
const stopped = agent.stop()
|
||||
|
||||
await expect(agent.execute('too early')).rejects.toThrow('A task is already running.')
|
||||
|
||||
await stopped
|
||||
await expect(firstTask).resolves.toMatchObject({ success: false, data: 'Task aborted' })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('discards a custom tool result that resolves after stop', async () => {
|
||||
let resolveTool!: () => void
|
||||
let notifyToolStarted!: () => void
|
||||
const toolFinished = new Promise<void>((resolve) => {
|
||||
resolveTool = resolve
|
||||
})
|
||||
const toolStarted = new Promise<void>((resolve) => {
|
||||
notifyToolStarted = resolve
|
||||
})
|
||||
const fetchMock = createFetchMock().mockResolvedValue(
|
||||
agentResponse({ action: { slow_tool: {} } })
|
||||
)
|
||||
const agent = createAgent(fetchMock, {
|
||||
customTools: {
|
||||
slow_tool: tool({
|
||||
description: 'A tool that deliberately ignores cancellation.',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
notifyToolStarted()
|
||||
await toolFinished
|
||||
return 'ignored stop'
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const task = agent.execute('run slow tool')
|
||||
await toolStarted
|
||||
|
||||
const stopped = agent.stop()
|
||||
resolveTool()
|
||||
await stopped
|
||||
|
||||
await expect(task).resolves.toMatchObject({ success: false, data: 'Task aborted' })
|
||||
expect(agent.status).toBe('stopped')
|
||||
})
|
||||
})
|
||||
})
|
||||
+144
-110
@@ -21,7 +21,7 @@ import type {
|
||||
MacroToolInput,
|
||||
MacroToolResult,
|
||||
} from './types'
|
||||
import { assert, fetchLlmsTxt, normalizeResponse, uid, waitFor } from './utils'
|
||||
import { assert, fetchLlmsTxt, normalizeResponse, suppress, uid, waitFor } from './utils'
|
||||
|
||||
export { tool, type PageAgentTool } from './tools'
|
||||
export type * from './types'
|
||||
@@ -42,7 +42,7 @@ export type PageAgentCoreConfig = AgentConfig & { pageController: PageController
|
||||
* - loop
|
||||
*
|
||||
* ## Event System
|
||||
* - `statuschange` - Agent status transitions (idle → running → completed/error)
|
||||
* - `statuschange` - Agent status transitions (idle → running → completed/error/stopped)
|
||||
* - `historychange` - History events updated (persistent, part of agent memory)
|
||||
* - `activity` - Real-time activity feedback (transient, for UI only)
|
||||
* - `dispose` - Agent cleanup triggered
|
||||
@@ -91,6 +91,10 @@ export class PageAgentCore extends EventTarget {
|
||||
#abortController = new AbortController()
|
||||
#observations: string[] = []
|
||||
|
||||
/** Resolves when the current run has fully settled. Awaited by `stop()`. */
|
||||
#running: Promise<void> = Promise.resolve()
|
||||
#lastResult: ExecutionResult | null = null
|
||||
|
||||
/** internal states during a single task execution */
|
||||
#states = {
|
||||
/** Accumulated wait time in seconds */
|
||||
@@ -147,13 +151,19 @@ export class PageAgentCore extends EventTarget {
|
||||
return this.#status
|
||||
}
|
||||
|
||||
/** Result of the most recent run, or `null` before the first run completes. */
|
||||
get lastResult(): ExecutionResult | null {
|
||||
return this.#lastResult
|
||||
}
|
||||
|
||||
/** Emit statuschange event */
|
||||
#emitStatusChange(): void {
|
||||
this.dispatchEvent(new Event('statuschange'))
|
||||
}
|
||||
|
||||
/** Emit historychange event */
|
||||
#emitHistoryChange(): void {
|
||||
#emitHistoryChange(pushHistoricalEvent?: HistoricalEvent): void {
|
||||
if (pushHistoricalEvent) this.history.push(pushHistoricalEvent)
|
||||
this.dispatchEvent(new Event('historychange'))
|
||||
}
|
||||
|
||||
@@ -183,14 +193,22 @@ export class PageAgentCore extends EventTarget {
|
||||
this.#observations.push(content)
|
||||
}
|
||||
|
||||
/** Stop the current task. Agent remains reusable. */
|
||||
stop() {
|
||||
this.pageController.cleanUpHighlights()
|
||||
this.pageController.hideMask()
|
||||
/**
|
||||
* Stop the current task and wait until the run has fully settled (including lifecycle hooks).
|
||||
* @note never await .stop() in a lifecycle hook.
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (this.#status !== 'running') return
|
||||
this.#abortController.abort()
|
||||
await this.#running
|
||||
}
|
||||
|
||||
/**
|
||||
* external errors (pre-checks/config/hooks) will threw;
|
||||
* agent errors will be caught and added to history, and return a failed result
|
||||
*/
|
||||
async execute(task: string): Promise<ExecutionResult> {
|
||||
// pre-checks
|
||||
if (this.disposed) throw new Error('PageAgent has been disposed. Create a new instance.')
|
||||
if (this.#status === 'running') throw new Error('A task is already running.')
|
||||
if (!task) throw new Error('Task is required')
|
||||
@@ -202,135 +220,158 @@ export class PageAgentCore extends EventTarget {
|
||||
this.#observations = []
|
||||
this.#states = { totalWaitTime: 0, lastURL: '', browserState: null }
|
||||
this.#abortController = new AbortController()
|
||||
const signal = this.#abortController.signal
|
||||
|
||||
let resolveRunning!: () => void
|
||||
this.#running = new Promise<void>((r) => (resolveRunning = r))
|
||||
|
||||
this.#setStatus('running')
|
||||
this.#emitHistoryChange()
|
||||
|
||||
// Disable ask_user tool if onAskUser is not set
|
||||
if (!this.onAskUser) {
|
||||
this.tools.delete('ask_user')
|
||||
}
|
||||
if (!this.onAskUser) this.tools.delete('ask_user')
|
||||
|
||||
const onBeforeStep = this.config.onBeforeStep
|
||||
const onAfterStep = this.config.onAfterStep
|
||||
const onBeforeTask = this.config.onBeforeTask
|
||||
const onAfterTask = this.config.onAfterTask
|
||||
|
||||
try {
|
||||
await onBeforeTask?.(this)
|
||||
await this.pageController.showMask()
|
||||
} catch (error) {
|
||||
this.#setStatus('error')
|
||||
throw error
|
||||
}
|
||||
const stepDelay = this.config.stepDelay ?? 0.4
|
||||
const maxSteps = this.config.maxSteps
|
||||
|
||||
let step = 0
|
||||
let taskSuccess: boolean
|
||||
let taskResult: string
|
||||
let taskResult: ExecutionResult
|
||||
let finalStatus: AgentStatus = 'error'
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
console.group(`step: ${step}`)
|
||||
await suppress(() => this.pageController.showMask())
|
||||
|
||||
// graceful exit
|
||||
try {
|
||||
await onBeforeTask?.(this)
|
||||
|
||||
while (true) {
|
||||
await onBeforeStep?.(this, step)
|
||||
|
||||
// observe
|
||||
// handle internal agent errors
|
||||
try {
|
||||
console.group(`step: ${step}`)
|
||||
|
||||
console.log(chalk.blue.bold('👀 Observing...'))
|
||||
// @note It's convenient to treat stepDelay as part of the next step.
|
||||
// Maybe move it to a dedicated try block for better semantics?
|
||||
if (step > 0) await waitFor(stepDelay, signal)
|
||||
|
||||
this.#states.browserState = await this.pageController.getBrowserState()
|
||||
await this.#handleObservations(step)
|
||||
signal.throwIfAborted()
|
||||
|
||||
// assemble prompts
|
||||
// observe
|
||||
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: this.#getSystemPrompt() },
|
||||
{ role: 'user' as const, content: await this.#assembleUserPrompt() },
|
||||
]
|
||||
console.log(chalk.blue.bold('👀 Observing...'))
|
||||
|
||||
const macroTool = { AgentOutput: this.#packMacroTool() }
|
||||
this.#states.browserState = await this.pageController.getBrowserState()
|
||||
await this.#handleObservations(step)
|
||||
|
||||
// invoke LLM
|
||||
// assemble prompts
|
||||
|
||||
console.log(chalk.blue.bold('🧠 Thinking...'))
|
||||
this.#emitActivity({ type: 'thinking' })
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: this.#getSystemPrompt() },
|
||||
{ role: 'user' as const, content: await this.#assembleUserPrompt() },
|
||||
]
|
||||
|
||||
const result = await this.#llm.invoke(messages, macroTool, this.#abortController.signal, {
|
||||
toolChoiceName: 'AgentOutput',
|
||||
normalizeResponse: (res) => normalizeResponse(res, this.tools),
|
||||
})
|
||||
const macroTool = { AgentOutput: this.#packMacroTool() }
|
||||
|
||||
// assemble history
|
||||
// invoke LLM
|
||||
|
||||
const macroResult = result.toolResult as MacroToolResult
|
||||
const input = macroResult.input
|
||||
const output = macroResult.output
|
||||
const reflection: Partial<AgentReflection> = {
|
||||
evaluation_previous_goal: input.evaluation_previous_goal,
|
||||
memory: input.memory,
|
||||
next_goal: input.next_goal,
|
||||
}
|
||||
const actionName = Object.keys(input.action)[0]
|
||||
const action: AgentStepEvent['action'] = {
|
||||
name: actionName,
|
||||
input: input.action[actionName],
|
||||
output: output,
|
||||
console.log(chalk.blue.bold('🧠 Thinking...'))
|
||||
this.#emitActivity({ type: 'thinking' })
|
||||
|
||||
const result = await this.#llm.invoke(messages, macroTool, signal, {
|
||||
toolChoiceName: 'AgentOutput',
|
||||
normalizeResponse: (res) => normalizeResponse(res, this.tools),
|
||||
})
|
||||
|
||||
// assemble history
|
||||
|
||||
const macroResult = result.toolResult as MacroToolResult
|
||||
const input = macroResult.input
|
||||
const output = macroResult.output
|
||||
const reflection: Partial<AgentReflection> = {
|
||||
evaluation_previous_goal: input.evaluation_previous_goal,
|
||||
memory: input.memory,
|
||||
next_goal: input.next_goal,
|
||||
}
|
||||
const actionName = Object.keys(input.action)[0]
|
||||
const action: AgentStepEvent['action'] = {
|
||||
name: actionName,
|
||||
input: input.action[actionName],
|
||||
output: output,
|
||||
}
|
||||
|
||||
this.#emitHistoryChange({
|
||||
type: 'step',
|
||||
stepIndex: step,
|
||||
reflection,
|
||||
action,
|
||||
usage: result.usage,
|
||||
rawResponse: result.rawResponse,
|
||||
rawRequest: result.rawRequest,
|
||||
})
|
||||
|
||||
if (actionName === 'done') {
|
||||
const success = action.input?.success ?? false
|
||||
const data = action.input?.text || 'no text provided'
|
||||
console.log(chalk.green.bold('Task completed'), success, data)
|
||||
taskResult = { success, data, history: this.history }
|
||||
this.#lastResult = taskResult
|
||||
finalStatus = 'completed'
|
||||
break
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// catch block must not throw error. otherwise the error may be overridden if finally block also throws error.
|
||||
|
||||
const isAbortError = (error as any)?.name === 'AbortError'
|
||||
if (!isAbortError) console.error('Task failed', error)
|
||||
const message = isAbortError ? 'Task aborted' : String(error)
|
||||
this.#emitActivity({ type: 'error', message: message })
|
||||
this.#emitHistoryChange({ type: 'error', message: message, rawResponse: error })
|
||||
taskResult = { success: false, data: message, history: this.history }
|
||||
this.#lastResult = taskResult
|
||||
finalStatus = isAbortError ? 'stopped' : 'error'
|
||||
break
|
||||
} finally {
|
||||
// finally block runs before the break above.
|
||||
|
||||
console.groupEnd()
|
||||
// @note hook may throw error.
|
||||
// which will override the `break` above and be handled as an external error.
|
||||
// as expected.
|
||||
await onAfterStep?.(this, this.history)
|
||||
}
|
||||
|
||||
this.history.push({
|
||||
type: 'step',
|
||||
stepIndex: step,
|
||||
reflection,
|
||||
action,
|
||||
usage: result.usage,
|
||||
rawResponse: result.rawResponse,
|
||||
rawRequest: result.rawRequest,
|
||||
} as AgentStepEvent)
|
||||
this.#emitHistoryChange()
|
||||
|
||||
await onAfterStep?.(this, this.history)
|
||||
|
||||
console.groupEnd()
|
||||
|
||||
if (actionName === 'done') {
|
||||
taskSuccess = action.input?.success ?? false
|
||||
taskResult = action.input?.text || 'no text provided'
|
||||
console.log(chalk.green.bold('Task completed'), taskSuccess, taskResult)
|
||||
step++
|
||||
if (step > maxSteps) {
|
||||
const message = 'Step count exceeded maximum limit'
|
||||
console.error(message)
|
||||
this.#emitActivity({ type: 'error', message: message })
|
||||
this.#emitHistoryChange({ type: 'error', message: message })
|
||||
taskResult = { success: false, data: message, history: this.history }
|
||||
this.#lastResult = taskResult
|
||||
finalStatus = 'error'
|
||||
break
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.groupEnd()
|
||||
const isAbortError = (error as any)?.name === 'AbortError'
|
||||
if (!isAbortError) console.error('Task failed', error)
|
||||
taskResult = isAbortError ? 'Task aborted' : String(error)
|
||||
taskSuccess = false
|
||||
this.#emitActivity({ type: 'error', message: taskResult })
|
||||
this.history.push({ type: 'error', message: taskResult, rawResponse: error })
|
||||
this.#emitHistoryChange()
|
||||
break
|
||||
}
|
||||
} // while
|
||||
|
||||
step++
|
||||
if (step > this.config.maxSteps) {
|
||||
taskResult = 'Step count exceeded maximum limit'
|
||||
taskSuccess = false
|
||||
this.#emitActivity({ type: 'error', message: taskResult })
|
||||
this.history.push({ type: 'error', message: taskResult })
|
||||
this.#emitHistoryChange()
|
||||
break
|
||||
}
|
||||
await onAfterTask?.(this, taskResult)
|
||||
|
||||
await waitFor(this.config.stepDelay ?? 0.4)
|
||||
return taskResult
|
||||
} catch (error) {
|
||||
this.#emitActivity({ type: 'error', message: String(error) })
|
||||
finalStatus = 'error'
|
||||
throw error
|
||||
} finally {
|
||||
await suppress(() => this.pageController.cleanUpHighlights())
|
||||
await suppress(() => this.pageController.hideMask())
|
||||
this.#abortController.abort()
|
||||
resolveRunning()
|
||||
this.#setStatus(finalStatus)
|
||||
}
|
||||
|
||||
this.#onDone(taskSuccess)
|
||||
const result: ExecutionResult = {
|
||||
success: taskSuccess,
|
||||
data: taskResult,
|
||||
history: this.history,
|
||||
}
|
||||
await onAfterTask?.(this, result)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -605,13 +646,6 @@ export class PageAgentCore extends EventTarget {
|
||||
return prompt
|
||||
}
|
||||
|
||||
#onDone(success = true) {
|
||||
this.pageController.cleanUpHighlights()
|
||||
this.pageController.hideMask() // No await - fire and forget
|
||||
this.#setStatus(success ? 'completed' : 'error')
|
||||
this.#abortController.abort()
|
||||
}
|
||||
|
||||
dispose() {
|
||||
console.log('Disposing PageAgent...')
|
||||
this.disposed = true
|
||||
|
||||
@@ -70,7 +70,6 @@ Strictly follow these rules while using the browser and navigating the web:
|
||||
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
|
||||
- All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed.
|
||||
- If a captcha appears, tell user you can not solve captcha. Finish the task and ask user to solve it.
|
||||
- If expected elements are missing, try scrolling, or navigating back.
|
||||
- If the page is not fully loaded, use the `wait` action.
|
||||
- Do not repeat one action for more than 3 times unless some conditions changed.
|
||||
- If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field.
|
||||
|
||||
@@ -61,11 +61,13 @@ tools.set(
|
||||
execute: async function (this: PageAgentCore, input, { signal }) {
|
||||
// try to subtract LLM calling time from the actual wait time
|
||||
const lastTimeUpdate = await this.pageController.getLastUpdateTime()
|
||||
const actualWaitTime = Math.max(0, input.seconds - (Date.now() - lastTimeUpdate) / 1000)
|
||||
const secondsSinceLastUpdate = (Date.now() - lastTimeUpdate) / 1000
|
||||
const actualWaitTime = Math.max(0, input.seconds - secondsSinceLastUpdate)
|
||||
console.log(`actualWaitTime: ${actualWaitTime} seconds`)
|
||||
await waitFor(actualWaitTime, signal)
|
||||
|
||||
return `✅ Waited for ${input.seconds} seconds.`
|
||||
const waitedSeconds = (secondsSinceLastUpdate + actualWaitTime).toFixed(2)
|
||||
return `✅ Waited for ${waitedSeconds} seconds.`
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -181,12 +183,15 @@ tools.set(
|
||||
'execute_javascript',
|
||||
tool({
|
||||
description:
|
||||
'Execute JavaScript code on the current page. Supports async/await syntax. Use with caution!',
|
||||
'Execute JavaScript code on the current page. Supports async/await syntax. Use with caution! ' +
|
||||
'An `AbortSignal` named `signal` is available in scope: long-running async code MUST honor it ' +
|
||||
'(e.g. `await fetch(url, { signal })`, or `signal.throwIfAborted()` in loops)',
|
||||
inputSchema: z.object({
|
||||
script: z.string(),
|
||||
}),
|
||||
execute: async function (this: PageAgentCore, input) {
|
||||
const result = await this.pageController.executeJavascript(input.script)
|
||||
execute: async function (this: PageAgentCore, input, { signal }) {
|
||||
const result = await this.pageController.executeJavascript(input.script, signal)
|
||||
signal.throwIfAborted()
|
||||
return result.message
|
||||
},
|
||||
})
|
||||
@@ -194,5 +199,4 @@ tools.set(
|
||||
|
||||
// @todo send_keys
|
||||
// @todo upload_file
|
||||
// @todo go_back
|
||||
// @todo extract_structured_data
|
||||
|
||||
@@ -262,9 +262,9 @@ export type HistoricalEvent =
|
||||
| AgentErrorEvent
|
||||
|
||||
/**
|
||||
* Agent execution status
|
||||
* Agent lifecycle status.
|
||||
*/
|
||||
export type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
|
||||
export type AgentStatus = 'idle' | 'running' | 'completed' | 'error' | 'stopped'
|
||||
|
||||
/**
|
||||
* Agent activity - transient state for immediate UI feedback.
|
||||
|
||||
@@ -129,3 +129,15 @@ export function assert(condition: unknown, message?: string, silent?: boolean):
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress errors from a function.
|
||||
*/
|
||||
export async function suppress<T>(fn: () => T | Promise<T>): Promise<Awaited<T> | undefined> {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
name: 'core',
|
||||
include: ['src/**/*.test.ts'],
|
||||
silent: 'passed-only',
|
||||
},
|
||||
})
|
||||
@@ -131,7 +131,7 @@ export type Execute = (task: string, config: ExecuteConfig) => Promise<Execution
|
||||
`AgentStatus`
|
||||
|
||||
```typescript
|
||||
type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
|
||||
type AgentStatus = 'idle' | 'running' | 'completed' | 'error' | 'stopped'
|
||||
```
|
||||
|
||||
`AgentActivity`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@page-agent/ext",
|
||||
"private": true,
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wxt",
|
||||
@@ -10,15 +10,15 @@
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-hover-card": "^1.1.16",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-label": "^2.1.9",
|
||||
"@radix-ui/react-separator": "^1.1.9",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-switch": "^1.3.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/chrome": "^0.1.42",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/chrome": "^0.1.43",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@wxt-dev/module-react": "^1.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -30,7 +30,7 @@
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"rough-notation": "^0.5.1",
|
||||
"simple-icons": "^16.21.0",
|
||||
"simple-icons": "^16.23.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
@@ -38,10 +38,10 @@
|
||||
"wxt": "^0.20.26"
|
||||
},
|
||||
"dependencies": {
|
||||
"@page-agent/core": "1.9.0",
|
||||
"@page-agent/llms": "1.9.0",
|
||||
"@page-agent/page-controller": "1.9.0",
|
||||
"@page-agent/ui": "1.9.0",
|
||||
"@page-agent/core": "1.10.0",
|
||||
"@page-agent/llms": "1.10.0",
|
||||
"@page-agent/page-controller": "1.10.0",
|
||||
"@page-agent/ui": "1.10.0",
|
||||
"ai-motion": "^0.4.8",
|
||||
"chalk": "^5.6.2"
|
||||
},
|
||||
|
||||
@@ -40,43 +40,27 @@ export class MultiPageAgent extends PageAgentCore {
|
||||
const experimentalIncludeAllTabs = config.experimentalIncludeAllTabs ?? false
|
||||
|
||||
/**
|
||||
* Project agent status into chrome.storage. The content script polls
|
||||
* `isAgentRunning` + `agentHeartbeat` (eventually consistent by design).
|
||||
*
|
||||
* When the agent is in side-panel and user closed the side-panel.
|
||||
* There is no chance for isAgentRunning to be set false.
|
||||
* (unload event doesn't work well in side panel.)
|
||||
* (I'm trying not to use long-lived connection because the lifecycle of a sw is hard to predict.)
|
||||
* This heartbeat mechanism acts as a backup.
|
||||
*/
|
||||
let heartBeatInterval: null | number = null
|
||||
let heartBeatInterval: number | null = null
|
||||
|
||||
super({
|
||||
...config,
|
||||
// Disabled: AbortSignal cannot cross contexts
|
||||
experimentalScriptExecutionTool: false,
|
||||
pageController: pageController as any,
|
||||
customTools: customTools,
|
||||
customSystemPrompt: systemPrompt,
|
||||
|
||||
onBeforeTask: async (agent) => {
|
||||
await tabsController.init(agent.task, { includeInitialTab, experimentalIncludeAllTabs })
|
||||
|
||||
heartBeatInterval = window.setInterval(() => {
|
||||
chrome.storage.local.set({
|
||||
agentHeartbeat: Date.now(),
|
||||
})
|
||||
}, 1_000)
|
||||
|
||||
await chrome.storage.local.set({
|
||||
isAgentRunning: true,
|
||||
})
|
||||
},
|
||||
|
||||
onAfterTask: async () => {
|
||||
if (heartBeatInterval) {
|
||||
window.clearInterval(heartBeatInterval)
|
||||
heartBeatInterval = null
|
||||
}
|
||||
|
||||
await chrome.storage.local.set({
|
||||
isAgentRunning: false,
|
||||
})
|
||||
},
|
||||
|
||||
onBeforeStep: async (agent) => {
|
||||
@@ -87,16 +71,28 @@ export class MultiPageAgent extends PageAgentCore {
|
||||
|
||||
onDispose: () => {
|
||||
if (heartBeatInterval) {
|
||||
window.clearInterval(heartBeatInterval)
|
||||
clearInterval(heartBeatInterval)
|
||||
heartBeatInterval = null
|
||||
}
|
||||
|
||||
chrome.storage.local.set({
|
||||
isAgentRunning: false,
|
||||
})
|
||||
chrome.storage.local.set({ isAgentRunning: false }).catch(console.error)
|
||||
|
||||
tabsController.dispose()
|
||||
},
|
||||
})
|
||||
|
||||
this.addEventListener('statuschange', () => {
|
||||
const running = this.status === 'running'
|
||||
|
||||
if (running && !heartBeatInterval) {
|
||||
heartBeatInterval = window.setInterval(() => {
|
||||
void chrome.storage.local.set({ agentHeartbeat: Date.now() })
|
||||
}, 1_000)
|
||||
} else if (!running && heartBeatInterval) {
|
||||
clearInterval(heartBeatInterval)
|
||||
heartBeatInterval = null
|
||||
}
|
||||
|
||||
chrome.storage.local.set({ isAgentRunning: running }).catch(console.error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,9 +133,7 @@ export class RemotePageController {
|
||||
return this.remoteCallDomAction('scroll_horizontally', args)
|
||||
}
|
||||
|
||||
async executeJavascript(...args: any[]): Promise<DomActionReturn> {
|
||||
return this.remoteCallDomAction('execute_javascript', args)
|
||||
}
|
||||
// `execute_javascript` is intentionally not implemented: AbortSignal cannot cross context
|
||||
|
||||
/** @note Managed by content script via storage polling. */
|
||||
async showMask(): Promise<void> {}
|
||||
|
||||
@@ -42,7 +42,7 @@ export class TabsController {
|
||||
throw new Error('TabsController already disposed')
|
||||
}
|
||||
|
||||
this.currentTabId = null
|
||||
await this.updateCurrentTabId(null)
|
||||
this.disposed = false
|
||||
this.port = undefined
|
||||
this.portRetries = 0
|
||||
|
||||
@@ -72,7 +72,6 @@ Strictly follow these rules while using the browser and navigating the web:
|
||||
- You can scroll by a specific number of pages using the num_pages parameter (e.g., 0.5 for half page, 2.0 for two pages).
|
||||
- All the elements that are scrollable are marked with `data-scrollable` attribute. Including the scrollable distance in every directions. You can scroll *the element* in case some area are overflowed.
|
||||
- If a captcha appears, tell user you can not solve captcha. Finish the task and ask user to solve it.
|
||||
- If expected elements are missing, try scrolling, or navigating back.
|
||||
- If the page is not fully loaded, use the `wait` action.
|
||||
- Do not repeat one action for more than 3 times unless some conditions changed.
|
||||
- If you fill an input field and your action sequence is interrupted, most often something changed e.g. suggestions popped up under the field.
|
||||
|
||||
@@ -80,7 +80,7 @@ export function useAgent(): UseAgentResult {
|
||||
const handleStatusChange = (e: Event) => {
|
||||
const newStatus = agent.status as AgentStatus
|
||||
setStatus(newStatus)
|
||||
if (newStatus === 'idle' || newStatus === 'completed' || newStatus === 'error') {
|
||||
if (newStatus !== 'running') {
|
||||
setActivity(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export function StatusDot({ status }: { status: AgentStatus }) {
|
||||
running: 'bg-blue-500',
|
||||
completed: 'bg-green-500',
|
||||
error: 'bg-destructive',
|
||||
stopped: 'bg-muted-foreground',
|
||||
}[status]
|
||||
|
||||
const label = {
|
||||
@@ -21,6 +22,7 @@ export function StatusDot({ status }: { status: AgentStatus }) {
|
||||
running: 'Running',
|
||||
completed: 'Done',
|
||||
error: 'Error',
|
||||
stopped: 'Stopped',
|
||||
}[status]
|
||||
|
||||
return (
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function App() {
|
||||
|
||||
if (
|
||||
prev === 'running' &&
|
||||
(status === 'completed' || status === 'error') &&
|
||||
(status === 'completed' || status === 'error' || status === 'stopped') &&
|
||||
history.length > 0 &&
|
||||
currentTask
|
||||
) {
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface SessionRecord {
|
||||
id: string
|
||||
task: string
|
||||
history: HistoricalEvent[]
|
||||
status: 'completed' | 'error'
|
||||
status: 'completed' | 'error' | 'stopped'
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@page-agent/llms",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@page-agent/mcp",
|
||||
"private": false,
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"page-agent-mcp": "src/index.js"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "page-agent",
|
||||
"private": false,
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"type": "module",
|
||||
"main": "./src/PageAgent.ts",
|
||||
"types": "./src/PageAgent.ts",
|
||||
@@ -54,10 +54,10 @@
|
||||
"postpublish": "node ../../scripts/post-publish.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@page-agent/core": "1.9.0",
|
||||
"@page-agent/llms": "1.9.0",
|
||||
"@page-agent/page-controller": "1.9.0",
|
||||
"@page-agent/ui": "1.9.0",
|
||||
"@page-agent/core": "1.10.0",
|
||||
"@page-agent/llms": "1.10.0",
|
||||
"@page-agent/page-controller": "1.10.0",
|
||||
"@page-agent/ui": "1.10.0",
|
||||
"chalk": "^5.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@page-agent/page-controller",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"type": "module",
|
||||
"main": "./src/PageController.ts",
|
||||
"types": "./src/PageController.ts",
|
||||
@@ -42,10 +42,14 @@
|
||||
"homepage": "https://alibaba.github.io/page-agent/",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"prepublishOnly": "node ../../scripts/pre-publish.js",
|
||||
"postpublish": "node ../../scripts/post-publish.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"ai-motion": "^0.4.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"happy-dom": "^20.10.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { PageController } from './PageController'
|
||||
|
||||
describe('PageController', () => {
|
||||
it('constructs and exposes the current url', async () => {
|
||||
const controller = new PageController()
|
||||
expect(controller).toBeInstanceOf(PageController)
|
||||
expect(await controller.getCurrentUrl()).toBe(window.location.href)
|
||||
})
|
||||
|
||||
describe('executeJavascript', () => {
|
||||
it('runs a script and returns its result', async () => {
|
||||
const controller = new PageController()
|
||||
const result = await controller.executeJavascript('return 1 + 2')
|
||||
expect(result).toMatchObject({ success: true })
|
||||
expect(result.message).toContain('3')
|
||||
})
|
||||
|
||||
it('exposes the abort signal to the script scope', async () => {
|
||||
const controller = new PageController()
|
||||
const controllerSignal = new AbortController()
|
||||
controllerSignal.abort()
|
||||
|
||||
const result = await controller.executeJavascript(
|
||||
'return signal.aborted',
|
||||
controllerSignal.signal
|
||||
)
|
||||
expect(result).toMatchObject({ success: true })
|
||||
expect(result.message).toContain('true')
|
||||
})
|
||||
|
||||
it('reports a syntax error as a failed result', async () => {
|
||||
const controller = new PageController()
|
||||
const result = await controller.executeJavascript('return (')
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toContain('❌')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -376,13 +376,15 @@ export class PageController extends EventTarget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute arbitrary JavaScript on the page
|
||||
* Execute arbitrary JavaScript on the page.
|
||||
* The optional `signal` is exposed to the script scope so cooperative code
|
||||
* can abort promptly when the task is stopped.
|
||||
*/
|
||||
async executeJavascript(script: string): Promise<ActionResult> {
|
||||
async executeJavascript(script: string, signal?: AbortSignal): Promise<ActionResult> {
|
||||
try {
|
||||
// Wrap script in async function to support await
|
||||
const asyncFunction = eval(`(async () => { ${script} })`)
|
||||
const result = await asyncFunction()
|
||||
// Wrap script in async function to support await, exposing `signal`.
|
||||
const asyncFunction = eval(`(async (signal) => { ${script} })`)
|
||||
const result = await asyncFunction(signal)
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Executed JavaScript. Result: ${result}`,
|
||||
|
||||
@@ -336,11 +336,13 @@ export async function scrollVertically(scroll_amount: number, element?: HTMLElem
|
||||
|
||||
const dy = scroll_amount
|
||||
const bigEnough = (el: HTMLElement) => el.clientHeight >= window.innerHeight * 0.5
|
||||
const canScroll = (el: HTMLElement | null) =>
|
||||
el &&
|
||||
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowY) &&
|
||||
el.scrollHeight > el.clientHeight &&
|
||||
bigEnough(el)
|
||||
const canScroll = (el: HTMLElement | null): boolean =>
|
||||
Boolean(
|
||||
el &&
|
||||
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowY) &&
|
||||
el.scrollHeight > el.clientHeight &&
|
||||
bigEnough(el)
|
||||
)
|
||||
|
||||
// @deprecated Heuristic container search.
|
||||
// Unreliable in multi-panel layouts. Should guide LLMs to use indexed scroll for consistency.
|
||||
@@ -479,11 +481,13 @@ export async function scrollHorizontally(scroll_amount: number, element?: HTMLEl
|
||||
const dx = scroll_amount
|
||||
|
||||
const bigEnough = (el: HTMLElement) => el.clientWidth >= window.innerWidth * 0.5
|
||||
const canScroll = (el: HTMLElement | null) =>
|
||||
el &&
|
||||
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowX) &&
|
||||
el.scrollWidth > el.clientWidth &&
|
||||
bigEnough(el)
|
||||
const canScroll = (el: HTMLElement | null): boolean =>
|
||||
Boolean(
|
||||
el &&
|
||||
/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowX) &&
|
||||
el.scrollWidth > el.clientWidth &&
|
||||
bigEnough(el)
|
||||
)
|
||||
|
||||
// @deprecated Same heuristic container search as scrollVertically.
|
||||
// TODO: Remove once LLMs reliably use indexed scrolling via data-scrollable.
|
||||
|
||||
@@ -552,8 +552,6 @@ export default (
|
||||
scrollData: scrollData,
|
||||
})
|
||||
|
||||
console.log('scrollData!!!', scrollData)
|
||||
|
||||
return scrollData
|
||||
}
|
||||
|
||||
|
||||
@@ -209,7 +209,6 @@ export class SimulatorMask extends EventTarget {
|
||||
|
||||
dispose() {
|
||||
this.#disposed = true
|
||||
console.log('dispose SimulatorMask')
|
||||
this.motion?.dispose()
|
||||
this.wrapper.remove()
|
||||
this.dispatchEvent(new Event('dispose'))
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
/**
|
||||
* A comprehensive function to determine if the page is currently in a dark theme.
|
||||
* Heuristic check. Only work for common patterns. Return false by default.
|
||||
*/
|
||||
export function isPageDark() {
|
||||
try {
|
||||
if (hasDarkModeClass()) return true
|
||||
if (hasDarkModeDataAttribute()) return true
|
||||
if (isColorSchemeDark()) return true
|
||||
if (isBackgroundDark()) return true
|
||||
if (isMainContentBackgroundDark()) return true
|
||||
if (isTextColorLight()) return true
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.warn('Error determining if page is dark:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for common dark mode CSS classes on the html or body elements.
|
||||
* @returns {boolean} - True if a common dark mode class is found.
|
||||
*/
|
||||
function hasDarkModeClass() {
|
||||
const DEFAULT_DARK_MODE_CLASSES = ['dark', 'dark-mode', 'theme-dark', 'night', 'night-mode']
|
||||
@@ -15,57 +34,47 @@ function hasDarkModeClass() {
|
||||
}
|
||||
}
|
||||
|
||||
// Some sites use data attributes
|
||||
const darkThemeAttribute = htmlElement.getAttribute('data-theme')
|
||||
if (darkThemeAttribute?.toLowerCase().includes('dark')) {
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Some UI frameworks use data attributes to indicate theme
|
||||
*/
|
||||
function hasDarkModeDataAttribute() {
|
||||
const htmlElement = document.documentElement
|
||||
const bodyElement = document.body || document.documentElement // can be null in some cases
|
||||
|
||||
const dataAttrs = ['data-theme', 'data-color-mode', 'data-bs-theme', 'data-mui-color-scheme']
|
||||
for (const attr of dataAttrs) {
|
||||
const bodyValue = bodyElement?.getAttribute(attr)
|
||||
const htmlValue = htmlElement.getAttribute(attr)
|
||||
|
||||
if (bodyValue?.toLowerCase() === 'dark' || htmlValue?.toLowerCase() === 'dark') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an RGB or RGBA color string and returns an object with r, g, b properties.
|
||||
* @param {string} colorString - e.g., "rgb(34, 34, 34)" or "rgba(0, 0, 0, 0.5)"
|
||||
* @returns {{r: number, g: number, b: number}|null}
|
||||
* Checks the CSS `color-scheme` property and `<meta name="color-scheme">` tag.
|
||||
* Only "dark"/"only dark" counts as dark; "light dark" is ambiguous and ignored.
|
||||
*/
|
||||
function parseRgbColor(colorString: string) {
|
||||
const rgbMatch = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(colorString)
|
||||
if (!rgbMatch) {
|
||||
return null // Not a valid rgb/rgba string
|
||||
}
|
||||
return {
|
||||
r: parseInt(rgbMatch[1]),
|
||||
g: parseInt(rgbMatch[2]),
|
||||
b: parseInt(rgbMatch[3]),
|
||||
}
|
||||
}
|
||||
function isColorSchemeDark() {
|
||||
// Check <meta name="color-scheme" content="dark">
|
||||
const meta = document.querySelector<HTMLMetaElement>('meta[name="color-scheme"]')
|
||||
const metaContent = meta?.content.toLowerCase()
|
||||
if (metaContent === 'dark' || metaContent === 'only dark') return true
|
||||
|
||||
/**
|
||||
* Determines if a color is "dark" based on its calculated luminance.
|
||||
* @param {string} colorString - The CSS color string (e.g., "rgb(50, 50, 50)").
|
||||
* @param {number} threshold - A value between 0 and 255. Colors with luminance below this will be considered dark. Default is 128.
|
||||
* @returns {boolean} - True if the color is considered dark.
|
||||
*/
|
||||
function isColorDark(colorString: string, threshold = 128) {
|
||||
if (!colorString || colorString === 'transparent' || colorString.startsWith('rgba(0, 0, 0, 0)')) {
|
||||
return false // Transparent is not dark
|
||||
}
|
||||
|
||||
const rgb = parseRgbColor(colorString)
|
||||
if (!rgb) {
|
||||
return false // Could not parse color
|
||||
}
|
||||
|
||||
// Calculate perceived luminance using the standard formula
|
||||
const luminance = 0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b
|
||||
|
||||
return luminance < threshold
|
||||
// Check the computed color-scheme CSS property on :root
|
||||
const rootStyle = window.getComputedStyle(document.documentElement)
|
||||
const colorScheme = rootStyle.getPropertyValue('color-scheme').trim().toLowerCase()
|
||||
return colorScheme === 'dark' || colorScheme === 'only dark'
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the background color of the body element to determine if the page is dark.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isBackgroundDark() {
|
||||
// We check both <html> and <body> because some pages set the color on <html>
|
||||
@@ -88,28 +97,85 @@ function isBackgroundDark() {
|
||||
}
|
||||
|
||||
/**
|
||||
* A comprehensive function to determine if the page is currently in a dark theme.
|
||||
* It combines class checking and background color analysis.
|
||||
* @returns {boolean} - True if the page is likely dark.
|
||||
* Checks if the text color on the body is light, which implies a dark background.
|
||||
*/
|
||||
export function isPageDark() {
|
||||
try {
|
||||
// Strategy 1: Check for common dark mode classes
|
||||
if (hasDarkModeClass()) {
|
||||
return true
|
||||
}
|
||||
function isTextColorLight() {
|
||||
/** Luminance (0-255) above which body text is considered light */
|
||||
const LIGHT_TEXT_LUMINANCE = 200
|
||||
|
||||
// Strategy 2: Analyze the computed background color
|
||||
if (isBackgroundDark()) {
|
||||
return true
|
||||
}
|
||||
const bodyStyle = window.getComputedStyle(document.body || document.documentElement)
|
||||
const luminance = getLuminance(bodyStyle.color)
|
||||
|
||||
// @TODO add more checks here, e.g., analyzing text color,
|
||||
// or checking the background of major layout elements like <main> or #app.
|
||||
// Light text has high luminance (e.g. white text on dark bg)
|
||||
return luminance !== null && luminance > LIGHT_TEXT_LUMINANCE
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.warn('Error determining if page is dark:', error)
|
||||
return false
|
||||
/**
|
||||
* Checks the background color of major layout elements (#app, #root, etc.).
|
||||
* Many SPAs render into a container that may have its own dark background while
|
||||
* <body> remains transparent.
|
||||
*/
|
||||
function isMainContentBackgroundDark() {
|
||||
const { innerWidth: vw, innerHeight: vh } = window
|
||||
const minArea = vw * vh * 0.5
|
||||
|
||||
const selectors = ['#app', '#root', '#__next']
|
||||
for (const selector of selectors) {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) continue
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width * rect.height < minArea) continue
|
||||
|
||||
if (isColorDark(window.getComputedStyle(el).backgroundColor)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- utils ---
|
||||
|
||||
/**
|
||||
* Parses an RGB or RGBA color string and returns an object with r, g, b properties.
|
||||
* @param {string} colorString - e.g., "rgb(34, 34, 34)" or "rgba(0, 0, 0, 0.5)"
|
||||
* @returns {{r: number, g: number, b: number}|null}
|
||||
*/
|
||||
function parseRgbColor(colorString: string) {
|
||||
const rgbMatch = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(colorString)
|
||||
if (!rgbMatch) {
|
||||
return null // Not a valid rgb/rgba string
|
||||
}
|
||||
return {
|
||||
r: parseInt(rgbMatch[1]),
|
||||
g: parseInt(rgbMatch[2]),
|
||||
b: parseInt(rgbMatch[3]),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the perceived luminance (0-255) of a CSS color string.
|
||||
* @param {string} colorString - e.g., "rgb(50, 50, 50)" or "rgba(0, 0, 0, 0.5)"
|
||||
* @returns {number|null} - The luminance, or null if the color is transparent or unparseable.
|
||||
*/
|
||||
function getLuminance(colorString: string): number | null {
|
||||
if (!colorString || colorString === 'transparent' || colorString.startsWith('rgba(0, 0, 0, 0)')) {
|
||||
return null // Transparent has no meaningful luminance
|
||||
}
|
||||
|
||||
const rgb = parseRgbColor(colorString)
|
||||
if (!rgb) {
|
||||
return null // Could not parse color
|
||||
}
|
||||
|
||||
// Standard perceived luminance formula
|
||||
return 0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a color is "dark" based on its calculated luminance.
|
||||
* @param {string} colorString - The CSS color string (e.g., "rgb(50, 50, 50)").
|
||||
* @param {number} threshold - A value between 0 and 255. Colors with luminance below this will be considered dark. Default is 128.
|
||||
*/
|
||||
function isColorDark(colorString: string, threshold = 128) {
|
||||
const luminance = getLuminance(colorString)
|
||||
return luminance !== null && luminance < threshold
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
name: 'page-controller',
|
||||
environment: 'happy-dom',
|
||||
include: ['src/**/*.test.ts'],
|
||||
silent: 'passed-only',
|
||||
},
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@page-agent/ui",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
|
||||
@@ -100,10 +100,10 @@ export class Panel {
|
||||
#handleStatusChange(): void {
|
||||
const status = this.#agent.status
|
||||
|
||||
// Map agent status to UI indicator type
|
||||
const indicatorType =
|
||||
status === 'running' ? 'thinking' : status === 'idle' ? 'thinking' : status
|
||||
this.#updateStatusIndicator(indicatorType)
|
||||
// Map agent status to UI indicator. A `completed` run whose result reports
|
||||
// failure shows as error; other statuses map to their own indicator.
|
||||
const failed = status === 'completed' && this.#agent.lastResult?.success === false
|
||||
this.#updateStatusIndicator(failed ? 'error' : status)
|
||||
|
||||
// Morph action button: running = stop (■), not running = close (X)
|
||||
if (status === 'running') {
|
||||
@@ -121,7 +121,7 @@ export class Panel {
|
||||
}
|
||||
|
||||
// Handle completion
|
||||
if (status === 'completed' || status === 'error') {
|
||||
if (status === 'completed' || status === 'error' || status === 'stopped') {
|
||||
if (!this.#isExpanded) {
|
||||
this.#expand()
|
||||
}
|
||||
@@ -376,7 +376,7 @@ export class Panel {
|
||||
}
|
||||
|
||||
const status = this.#agent.status
|
||||
const isTaskEnded = status === 'completed' || status === 'error'
|
||||
const isTaskEnded = status === 'completed' || status === 'error' || status === 'stopped'
|
||||
|
||||
// Only show input area after task completion if configured to do so
|
||||
if (isTaskEnded) {
|
||||
@@ -559,13 +559,23 @@ export class Panel {
|
||||
}
|
||||
|
||||
#updateStatusIndicator(
|
||||
type: 'thinking' | 'executing' | 'executed' | 'retrying' | 'completed' | 'error'
|
||||
type:
|
||||
| 'idle'
|
||||
| 'running'
|
||||
| 'thinking'
|
||||
| 'executing'
|
||||
| 'executed'
|
||||
| 'retrying'
|
||||
| 'completed'
|
||||
| 'error'
|
||||
| 'stopped'
|
||||
): void {
|
||||
// Clear all status classes
|
||||
// `running` animates like thinking; `idle`/`stopped` use the neutral base.
|
||||
const variant = type === 'running' ? 'thinking' : type
|
||||
this.#indicator.className = styles.indicator
|
||||
|
||||
// Add corresponding status class
|
||||
this.#indicator.classList.add(styles[type])
|
||||
if (variant !== 'idle' && variant !== 'stopped') {
|
||||
this.#indicator.classList.add(styles[variant])
|
||||
}
|
||||
}
|
||||
|
||||
#scrollToBottom(): void {
|
||||
|
||||
@@ -22,14 +22,17 @@ export type AgentActivity =
|
||||
* This enables decoupling and allows any agent implementation to work with Panel.
|
||||
*
|
||||
* Events:
|
||||
* - 'statuschange': Agent status changed (idle/running/completed/error)
|
||||
* - 'statuschange': Agent status changed
|
||||
* - 'historychange': Historical events updated (persisted)
|
||||
* - 'activity': Transient activity for immediate UI feedback (thinking/executing/etc)
|
||||
* - 'dispose': Agent is being disposed
|
||||
*/
|
||||
export interface PanelAgentAdapter extends EventTarget {
|
||||
/** Current agent status */
|
||||
readonly status: 'idle' | 'running' | 'completed' | 'error'
|
||||
readonly status: 'idle' | 'running' | 'completed' | 'error' | 'stopped'
|
||||
|
||||
/** Result of the most recent run, or `null` before the first run completes */
|
||||
readonly lastResult: { success: boolean } | null
|
||||
|
||||
/** History of agent events */
|
||||
readonly history: readonly {
|
||||
@@ -71,7 +74,7 @@ export interface PanelAgentAdapter extends EventTarget {
|
||||
execute(task: string): Promise<unknown>
|
||||
|
||||
/** Stop the current task (agent remains reusable) */
|
||||
stop(): void
|
||||
stop(): Promise<void>
|
||||
|
||||
/** Dispose the agent (terminal, cannot be reused) */
|
||||
dispose(): void
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@page-agent/website",
|
||||
"private": true,
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
@@ -10,11 +10,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-separator": "^1.1.9",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@types/react": "^19.2.15",
|
||||
"@radix-ui/react-switch": "^1.3.0",
|
||||
"@radix-ui/react-tooltip": "^1.2.9",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -24,7 +24,7 @@
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"rough-notation": "^0.5.1",
|
||||
"simple-icons": "^16.21.0",
|
||||
"simple-icons": "^16.23.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Demo build (auto-init with demo LLM, for quick testing)
|
||||
export const CDN_DEMO_URL =
|
||||
'https://cdn.jsdelivr.net/npm/page-agent@1.9.0/dist/iife/page-agent.demo.js'
|
||||
'https://cdn.jsdelivr.net/npm/page-agent@1.10.0/dist/iife/page-agent.demo.js'
|
||||
export const CDN_DEMO_CN_URL =
|
||||
'https://registry.npmmirror.com/page-agent/1.9.0/files/dist/iife/page-agent.demo.js'
|
||||
'https://registry.npmmirror.com/page-agent/1.10.0/files/dist/iife/page-agent.demo.js'
|
||||
|
||||
// Demo LLM for website testing (homepage quick trial uses flash)
|
||||
export const DEMO_MODEL = 'qwen3.5-flash'
|
||||
|
||||
@@ -128,8 +128,8 @@ export default function CustomUIDocs() {
|
||||
name: 'statuschange',
|
||||
type: 'Event',
|
||||
description: isZh
|
||||
? 'Agent 状态变化 (idle → running → completed/error)'
|
||||
: 'Agent status changes (idle → running → completed/error)',
|
||||
? 'Agent 状态变化 (idle → running → completed/error/stopped)'
|
||||
: 'Agent status changes (idle → running → completed/error/stopped)',
|
||||
},
|
||||
{
|
||||
name: 'historychange',
|
||||
|
||||
@@ -281,8 +281,8 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-4">
|
||||
<p className="text-amber-800 dark:text-amber-200 text-sm">
|
||||
{isZh
|
||||
? '这些接口高度实验性,可能在未来版本中发生变化。'
|
||||
: 'These APIs are highly experimental and may change in future versions. '}
|
||||
? '这些接口高度实验性,可能在未来版本中发生变化。钩子中抛出的错误会使任务失败并从 execute() 抛出;如不希望影响任务,请在钩子内部自行捕获。'
|
||||
: 'These APIs are highly experimental and may change in future versions. Errors thrown from hooks fail the run and propagate from execute(); catch errors inside the hook if the task should not be affected.'}
|
||||
</p>
|
||||
</div>
|
||||
<APIReference
|
||||
@@ -325,7 +325,7 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
properties={[
|
||||
{
|
||||
name: 'status',
|
||||
type: "'idle' | 'running' | 'completed' | 'error'",
|
||||
type: "'idle' | 'running' | 'completed' | 'error' | 'stopped'",
|
||||
description: isZh ? '当前 Agent 执行状态' : 'Current agent execution status',
|
||||
},
|
||||
{
|
||||
@@ -378,10 +378,10 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
},
|
||||
{
|
||||
name: 'stop()',
|
||||
type: 'void',
|
||||
type: 'Promise<void>',
|
||||
description: isZh
|
||||
? '停止当前任务。Agent 仍可复用。'
|
||||
: 'Stop the current task. Agent remains reusable.',
|
||||
? '停止当前任务,并在任务完全结束后 resolve。Agent 仍可复用。'
|
||||
: 'Stop the current task; resolves once the run has fully settled. Agent remains reusable.',
|
||||
},
|
||||
{
|
||||
name: 'dispose()',
|
||||
@@ -415,8 +415,8 @@ const result = await agent.execute('Fill in the form with test data')`}
|
||||
name: 'statuschange',
|
||||
type: 'Event',
|
||||
description: isZh
|
||||
? 'Agent 状态变化时触发 (idle → running → completed/error)'
|
||||
: 'Fired when agent status changes (idle → running → completed/error)',
|
||||
? 'Agent 状态变化时触发 (idle → running → completed/error/stopped)'
|
||||
: 'Fired when agent status changes (idle → running → completed/error/stopped)',
|
||||
},
|
||||
{
|
||||
name: 'historychange',
|
||||
|
||||
Reference in New Issue
Block a user