feat!: redo Panel; decouple Panel from Agent
BREAKING CHANGES: Agent and Panel API Changes
This commit is contained in:
@@ -56,9 +56,9 @@ export class LLM extends EventTarget {
|
|||||||
// retry settings
|
// retry settings
|
||||||
{
|
{
|
||||||
maxRetries: this.config.maxRetries,
|
maxRetries: this.config.maxRetries,
|
||||||
onRetry: (current: number) => {
|
onRetry: (attempt: number) => {
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
new CustomEvent('retry', { detail: { current, max: this.config.maxRetries } })
|
new CustomEvent('retry', { detail: { attempt, maxAttempts: this.config.maxRetries } })
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
@@ -73,15 +73,15 @@ async function withRetry<T>(
|
|||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
settings: {
|
settings: {
|
||||||
maxRetries: number
|
maxRetries: number
|
||||||
onRetry: (retries: number) => void
|
onRetry: (attempt: number) => void
|
||||||
onError: (error: Error) => void
|
onError: (error: Error) => void
|
||||||
}
|
}
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
let retries = 0
|
let attempt = 0
|
||||||
let lastError: Error | null = null
|
let lastError: Error | null = null
|
||||||
while (retries <= settings.maxRetries) {
|
while (attempt <= settings.maxRetries) {
|
||||||
if (retries > 0) {
|
if (attempt > 0) {
|
||||||
settings.onRetry(retries)
|
settings.onRetry(attempt)
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ async function withRetry<T>(
|
|||||||
if (error instanceof InvokeError && !error.retryable) throw error
|
if (error instanceof InvokeError && !error.retryable) throw error
|
||||||
|
|
||||||
lastError = error as Error
|
lastError = error as Error
|
||||||
retries++
|
attempt++
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
import { LLM, type Tool } from '@page-agent/llms'
|
import { LLM, type Tool } from '@page-agent/llms'
|
||||||
import { PageController } from '@page-agent/page-controller'
|
import { PageController } from '@page-agent/page-controller'
|
||||||
import { Panel } from '@page-agent/ui'
|
|
||||||
import chalk from 'chalk'
|
import chalk from 'chalk'
|
||||||
import zod from 'zod'
|
import zod from 'zod'
|
||||||
|
|
||||||
@@ -85,30 +84,69 @@ export interface UserTakeoverEvent {
|
|||||||
type: 'user_takeover'
|
type: 'user_takeover'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error event (retry or error from LLM)
|
||||||
|
*/
|
||||||
|
export interface ErrorEvent {
|
||||||
|
type: 'error'
|
||||||
|
errorType: 'retry' | 'error'
|
||||||
|
message: string
|
||||||
|
attempt?: number
|
||||||
|
maxAttempts?: number
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Union type for all history events
|
* Union type for all history events
|
||||||
*/
|
*/
|
||||||
export type HistoryEvent = AgentStep | ObservationEvent | UserTakeoverEvent
|
export type HistoricalEvent = AgentStep | ObservationEvent | UserTakeoverEvent | ErrorEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent execution status
|
||||||
|
*/
|
||||||
|
export type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent activity - transient state for immediate UI feedback.
|
||||||
|
*
|
||||||
|
* Unlike historical events (which are persisted), activities are ephemeral
|
||||||
|
* and represent "what the agent is doing right now". UI components should
|
||||||
|
* listen to 'activity' events to show real-time feedback.
|
||||||
|
*
|
||||||
|
* Note: There is no 'idle' activity - absence of activity events means idle.
|
||||||
|
*/
|
||||||
|
export type AgentActivity =
|
||||||
|
| { type: 'thinking' }
|
||||||
|
| { type: 'executing'; tool: string; input: unknown }
|
||||||
|
| { type: 'executed'; tool: string; input: unknown; output: string; duration: number }
|
||||||
|
| { type: 'retrying'; attempt: number; maxAttempts: number }
|
||||||
|
| { type: 'error'; message: string }
|
||||||
|
|
||||||
export interface ExecutionResult {
|
export interface ExecutionResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
data: string
|
data: string
|
||||||
history: HistoryEvent[]
|
history: HistoricalEvent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PageAgent extends EventTarget {
|
export class PageAgent extends EventTarget {
|
||||||
config: PageAgentConfig
|
config: PageAgentConfig
|
||||||
id = uid()
|
id = uid()
|
||||||
panel: Panel | null = null
|
|
||||||
tools: typeof tools
|
tools: typeof tools
|
||||||
disposed = false
|
disposed = false
|
||||||
task = ''
|
task = ''
|
||||||
taskId = ''
|
taskId = ''
|
||||||
|
|
||||||
|
/** Agent execution status */
|
||||||
|
#status: AgentStatus = 'idle'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for when agent needs user input (ask_user tool)
|
||||||
|
* If not set, ask_user tool will be disabled
|
||||||
|
* @example onAskUser: (q) => window.prompt(q) || ''
|
||||||
|
*/
|
||||||
|
onAskUser?: (question: string) => Promise<string>
|
||||||
|
|
||||||
#llm: LLM
|
#llm: LLM
|
||||||
#abortController = new AbortController()
|
#abortController = new AbortController()
|
||||||
#llmRetryListener: ((e: Event) => void) | null = null
|
|
||||||
#llmErrorListener: ((e: Event) => void) | null = null
|
|
||||||
#beforeUnloadListener: ((e: Event) => void) | null = null
|
#beforeUnloadListener: ((e: Event) => void) | null = null
|
||||||
|
|
||||||
/** PageController for DOM operations */
|
/** PageController for DOM operations */
|
||||||
@@ -123,24 +161,13 @@ export class PageAgent extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** History events */
|
/** History events */
|
||||||
history: HistoryEvent[] = []
|
history: HistoricalEvent[] = []
|
||||||
|
|
||||||
constructor(config: PageAgentConfig) {
|
constructor(config: PageAgentConfig) {
|
||||||
super()
|
super()
|
||||||
|
|
||||||
this.config = config
|
this.config = config
|
||||||
this.#llm = new LLM(this.config)
|
this.#llm = new LLM(this.config)
|
||||||
|
|
||||||
// Conditionally initialize Panel
|
|
||||||
if (this.config.enablePanel !== false) {
|
|
||||||
this.panel = new Panel({
|
|
||||||
language: this.config.language,
|
|
||||||
onExecuteTask: (task) => this.execute(task),
|
|
||||||
onStop: () => this.dispose(),
|
|
||||||
promptForNextTask: this.config.promptForNextTask,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.tools = new Map(tools)
|
this.tools = new Map(tools)
|
||||||
|
|
||||||
// Initialize PageController with config (mask enabled by default)
|
// Initialize PageController with config (mask enabled by default)
|
||||||
@@ -149,17 +176,32 @@ export class PageAgent extends EventTarget {
|
|||||||
enableMask: this.config.enableMask ?? true,
|
enableMask: this.config.enableMask ?? true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Listen to LLM events
|
// Listen to LLM retry events
|
||||||
this.#llmRetryListener = (e) => {
|
this.#llm.addEventListener('retry', (e) => {
|
||||||
const { current, max } = (e as CustomEvent).detail
|
const { attempt, maxAttempts } = (e as CustomEvent).detail
|
||||||
this.panel?.update({ type: 'retry', current, max })
|
this.emitActivity({ type: 'retrying', attempt, maxAttempts })
|
||||||
}
|
// Also push to history for panel rendering
|
||||||
this.#llmErrorListener = (e) => {
|
this.history.push({
|
||||||
|
type: 'error',
|
||||||
|
errorType: 'retry',
|
||||||
|
message: `LLM retry attempt ${attempt} of ${maxAttempts}`,
|
||||||
|
attempt,
|
||||||
|
maxAttempts,
|
||||||
|
})
|
||||||
|
this.#emitHistoryChange()
|
||||||
|
})
|
||||||
|
this.#llm.addEventListener('error', (e) => {
|
||||||
const { error } = (e as CustomEvent).detail
|
const { error } = (e as CustomEvent).detail
|
||||||
this.panel?.update({ type: 'error', message: `step failed: ${error.message}` })
|
const message = String(error)
|
||||||
}
|
this.emitActivity({ type: 'error', message })
|
||||||
this.#llm.addEventListener('retry', this.#llmRetryListener)
|
// Also push to history for panel rendering
|
||||||
this.#llm.addEventListener('error', this.#llmErrorListener)
|
this.history.push({
|
||||||
|
type: 'error',
|
||||||
|
errorType: 'error',
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
this.#emitHistoryChange()
|
||||||
|
})
|
||||||
|
|
||||||
if (this.config.customTools) {
|
if (this.config.customTools) {
|
||||||
for (const [name, tool] of Object.entries(this.config.customTools)) {
|
for (const [name, tool] of Object.entries(this.config.customTools)) {
|
||||||
@@ -175,24 +217,50 @@ export class PageAgent extends EventTarget {
|
|||||||
this.tools.delete('execute_javascript')
|
this.tools.delete('execute_javascript')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable ask_user tool if enableAskUser is false or if panel is disabled
|
|
||||||
if (this.config.enableAskUser === false || this.config.enablePanel === false) {
|
|
||||||
this.tools.delete('ask_user')
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#beforeUnloadListener = (e) => {
|
this.#beforeUnloadListener = (e) => {
|
||||||
if (!this.disposed) this.dispose('PAGE_UNLOADING')
|
if (!this.disposed) this.dispose('PAGE_UNLOADING')
|
||||||
}
|
}
|
||||||
window.addEventListener('beforeunload', this.#beforeUnloadListener)
|
window.addEventListener('beforeunload', this.#beforeUnloadListener)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get current agent status */
|
||||||
|
get status(): AgentStatus {
|
||||||
|
return this.#status
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit statuschange event */
|
||||||
|
#emitStatusChange(): void {
|
||||||
|
this.dispatchEvent(new Event('statuschange'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit historychange event */
|
||||||
|
#emitHistoryChange(): void {
|
||||||
|
this.dispatchEvent(new Event('historychange'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit activity event - for transient UI feedback
|
||||||
|
* @param activity - Current agent activity
|
||||||
|
*/
|
||||||
|
emitActivity(activity: AgentActivity): void {
|
||||||
|
this.dispatchEvent(new CustomEvent('activity', { detail: activity }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update status and emit event */
|
||||||
|
#setStatus(status: AgentStatus): void {
|
||||||
|
if (this.#status !== status) {
|
||||||
|
this.#status = status
|
||||||
|
this.#emitStatusChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Push a persistent observation to the history event stream.
|
* Push a persistent observation 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 in memory across steps.
|
||||||
*/
|
*/
|
||||||
pushObservation(content: string): void {
|
pushObservation(content: string): void {
|
||||||
this.history.push({ type: 'observation', content })
|
this.history.push({ type: 'observation', content })
|
||||||
this.panel?.update({ type: 'observation', content })
|
this.#emitHistoryChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute(task: string): Promise<ExecutionResult> {
|
async execute(task: string): Promise<ExecutionResult> {
|
||||||
@@ -200,6 +268,11 @@ export class PageAgent extends EventTarget {
|
|||||||
this.task = task
|
this.task = task
|
||||||
this.taskId = uid()
|
this.taskId = uid()
|
||||||
|
|
||||||
|
// Disable ask_user tool if onAskUser is not set
|
||||||
|
if (!this.onAskUser) {
|
||||||
|
this.tools.delete('ask_user')
|
||||||
|
}
|
||||||
|
|
||||||
const onBeforeStep = this.config.onBeforeStep || (() => void 0)
|
const onBeforeStep = this.config.onBeforeStep || (() => void 0)
|
||||||
const onAfterStep = this.config.onAfterStep || (() => void 0)
|
const onAfterStep = this.config.onAfterStep || (() => void 0)
|
||||||
const onBeforeTask = this.config.onBeforeTask || (() => void 0)
|
const onBeforeTask = this.config.onBeforeTask || (() => void 0)
|
||||||
@@ -207,20 +280,17 @@ export class PageAgent extends EventTarget {
|
|||||||
|
|
||||||
await onBeforeTask.call(this)
|
await onBeforeTask.call(this)
|
||||||
|
|
||||||
// Show mask and panel
|
// Show mask
|
||||||
this.pageController.showMask()
|
this.pageController.showMask()
|
||||||
|
|
||||||
this.panel?.show()
|
|
||||||
this.panel?.reset()
|
|
||||||
|
|
||||||
this.panel?.update({ type: 'input', task: this.task })
|
|
||||||
|
|
||||||
if (this.#abortController) {
|
if (this.#abortController) {
|
||||||
this.#abortController.abort()
|
this.#abortController.abort()
|
||||||
this.#abortController = new AbortController()
|
this.#abortController = new AbortController()
|
||||||
}
|
}
|
||||||
|
|
||||||
this.history = []
|
this.history = []
|
||||||
|
this.#setStatus('running')
|
||||||
|
this.#emitHistoryChange()
|
||||||
|
|
||||||
// Reset states
|
// Reset states
|
||||||
this.states = {
|
this.states = {
|
||||||
@@ -241,9 +311,9 @@ export class PageAgent extends EventTarget {
|
|||||||
// abort
|
// abort
|
||||||
if (this.#abortController.signal.aborted) throw new Error('AbortError')
|
if (this.#abortController.signal.aborted) throw new Error('AbortError')
|
||||||
|
|
||||||
// Update status to thinking
|
// Thinking
|
||||||
console.log(chalk.blue('Thinking...'))
|
console.log(chalk.blue('Thinking...'))
|
||||||
this.panel?.update({ type: 'thinking' })
|
this.emitActivity({ type: 'thinking' })
|
||||||
|
|
||||||
const result = await this.#llm.invoke(
|
const result = await this.#llm.invoke(
|
||||||
[
|
[
|
||||||
@@ -285,6 +355,7 @@ export class PageAgent extends EventTarget {
|
|||||||
action,
|
action,
|
||||||
usage: result.usage,
|
usage: result.usage,
|
||||||
} as AgentStep)
|
} as AgentStep)
|
||||||
|
this.#emitHistoryChange()
|
||||||
|
|
||||||
console.log(chalk.green('Step finished:'), actionName)
|
console.log(chalk.green('Step finished:'), actionName)
|
||||||
console.groupEnd()
|
console.groupEnd()
|
||||||
@@ -318,10 +389,12 @@ export class PageAgent extends EventTarget {
|
|||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('Task failed', error)
|
console.error('Task failed', error)
|
||||||
this.#onDone(String(error), false)
|
const errorMessage = String(error)
|
||||||
|
this.emitActivity({ type: 'error', message: errorMessage })
|
||||||
|
this.#onDone(errorMessage, false)
|
||||||
const result: ExecutionResult = {
|
const result: ExecutionResult = {
|
||||||
success: false,
|
success: false,
|
||||||
data: String(error),
|
data: errorMessage,
|
||||||
history: this.history,
|
history: this.history,
|
||||||
}
|
}
|
||||||
await onAfterTask.call(this, result)
|
await onAfterTask.call(this, result)
|
||||||
@@ -381,7 +454,6 @@ export class PageAgent extends EventTarget {
|
|||||||
|
|
||||||
if (reflectionText) {
|
if (reflectionText) {
|
||||||
console.log(reflectionText)
|
console.log(reflectionText)
|
||||||
this.panel?.update({ type: 'thinking', text: reflectionText })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the corresponding tool
|
// Find the corresponding tool
|
||||||
@@ -389,7 +461,9 @@ export class PageAgent extends EventTarget {
|
|||||||
assert(tool, `Tool ${toolName} not found. (@note should have been caught before this!!!)`)
|
assert(tool, `Tool ${toolName} not found. (@note should have been caught before this!!!)`)
|
||||||
|
|
||||||
console.log(chalk.blue.bold(`Executing tool: ${toolName}`), toolInput)
|
console.log(chalk.blue.bold(`Executing tool: ${toolName}`), toolInput)
|
||||||
this.panel?.update({ type: 'toolExecuting', toolName, args: toolInput })
|
|
||||||
|
// Emit executing activity
|
||||||
|
this.emitActivity({ type: 'executing', tool: toolName, input: toolInput })
|
||||||
|
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
|
|
||||||
@@ -399,23 +473,20 @@ export class PageAgent extends EventTarget {
|
|||||||
const duration = Date.now() - startTime
|
const duration = Date.now() - startTime
|
||||||
console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result)
|
console.log(chalk.green.bold(`Tool (${toolName}) executed for ${duration}ms`), result)
|
||||||
|
|
||||||
|
// Emit executed activity
|
||||||
|
this.emitActivity({
|
||||||
|
type: 'executed',
|
||||||
|
tool: toolName,
|
||||||
|
input: toolInput,
|
||||||
|
output: result,
|
||||||
|
duration,
|
||||||
|
})
|
||||||
|
|
||||||
// Reset wait time for non-wait tools
|
// Reset wait time for non-wait tools
|
||||||
if (toolName !== 'wait') {
|
if (toolName !== 'wait') {
|
||||||
this.states.totalWaitTime = 0
|
this.states.totalWaitTime = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Briefly display execution result
|
|
||||||
this.panel?.update({
|
|
||||||
type: 'toolCompleted',
|
|
||||||
toolName,
|
|
||||||
args: toolInput,
|
|
||||||
result,
|
|
||||||
duration,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wait a moment to let user see the result
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
||||||
|
|
||||||
// Return structured result
|
// Return structured result
|
||||||
return {
|
return {
|
||||||
input,
|
input,
|
||||||
@@ -551,6 +622,9 @@ export class PageAgent extends EventTarget {
|
|||||||
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') {
|
||||||
|
// Error events are mainly for panel rendering, not included in LLM context
|
||||||
|
// to avoid polluting the agent's reasoning with transient errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,19 +639,8 @@ export class PageAgent extends EventTarget {
|
|||||||
|
|
||||||
#onDone(text: string, success = true) {
|
#onDone(text: string, success = true) {
|
||||||
this.pageController.cleanUpHighlights()
|
this.pageController.cleanUpHighlights()
|
||||||
|
|
||||||
// Update panel status
|
|
||||||
if (success) {
|
|
||||||
this.panel?.update({ type: 'output', text })
|
|
||||||
} else {
|
|
||||||
this.panel?.update({ type: 'error', message: text })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Task completed
|
|
||||||
this.panel?.update({ type: 'completed' })
|
|
||||||
|
|
||||||
this.pageController.hideMask()
|
this.pageController.hideMask()
|
||||||
|
this.#setStatus(success ? 'completed' : 'error')
|
||||||
this.#abortController.abort()
|
this.#abortController.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,26 +667,18 @@ export class PageAgent extends EventTarget {
|
|||||||
console.log('Disposing PageAgent...')
|
console.log('Disposing PageAgent...')
|
||||||
this.disposed = true
|
this.disposed = true
|
||||||
this.pageController.dispose()
|
this.pageController.dispose()
|
||||||
this.panel?.dispose()
|
|
||||||
this.history = []
|
this.history = []
|
||||||
this.#abortController.abort(reason ?? 'PageAgent disposed')
|
this.#abortController.abort(reason ?? 'PageAgent disposed')
|
||||||
|
|
||||||
// Clean up LLM event listeners
|
|
||||||
if (this.#llmRetryListener) {
|
|
||||||
this.#llm.removeEventListener('retry', this.#llmRetryListener)
|
|
||||||
this.#llmRetryListener = null
|
|
||||||
}
|
|
||||||
if (this.#llmErrorListener) {
|
|
||||||
this.#llm.removeEventListener('error', this.#llmErrorListener)
|
|
||||||
this.#llmErrorListener = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up window event listeners
|
// Clean up window event listeners
|
||||||
if (this.#beforeUnloadListener) {
|
if (this.#beforeUnloadListener) {
|
||||||
window.removeEventListener('beforeunload', this.#beforeUnloadListener)
|
window.removeEventListener('beforeunload', this.#beforeUnloadListener)
|
||||||
this.#beforeUnloadListener = null
|
this.#beforeUnloadListener = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit dispose event for UI cleanup
|
||||||
|
this.dispatchEvent(new Event('dispose'))
|
||||||
|
|
||||||
this.config.onDispose?.call(this, reason)
|
this.config.onDispose?.call(this, reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,18 @@
|
|||||||
import type { LLMConfig } from '@page-agent/llms'
|
import type { LLMConfig } from '@page-agent/llms'
|
||||||
import type { PageControllerConfig } from '@page-agent/page-controller'
|
import type { PageControllerConfig } from '@page-agent/page-controller'
|
||||||
import type { SupportedLanguage } from '@page-agent/ui'
|
|
||||||
|
|
||||||
import type { ExecutionResult, HistoryEvent, PageAgent } from '../PageAgent'
|
import type { ExecutionResult, HistoryEvent, PageAgent } from '../PageAgent'
|
||||||
import type { PageAgentTool } from '../tools'
|
import type { PageAgentTool } from '../tools'
|
||||||
|
|
||||||
export type { LLMConfig }
|
export type { LLMConfig }
|
||||||
|
|
||||||
|
/** Supported UI languages */
|
||||||
|
export type SupportedLanguage = 'en-US' | 'zh-CN'
|
||||||
|
|
||||||
export interface AgentConfig {
|
export interface AgentConfig {
|
||||||
// theme?: 'light' | 'dark'
|
// theme?: 'light' | 'dark'
|
||||||
language?: SupportedLanguage
|
language?: SupportedLanguage
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to prompt for next task after task completion
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
promptForNextTask?: boolean
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable the UI panel for visual feedback and user interaction
|
|
||||||
* When disabled, the panel will not be created and all UI operations will be skipped.
|
|
||||||
* Useful for automated testing or when integrating PageAgent as a library.
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
enablePanel?: boolean
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable the ask_user tool for agent to ask questions
|
|
||||||
* When disabled, the agent cannot ask user questions during execution.
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
enableAskUser?: boolean
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom tools to extend PageAgent capabilities
|
* Custom tools to extend PageAgent capabilities
|
||||||
* @experimental
|
* @experimental
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Auto-run entry for page-agent.js. Insert this script into your page to get page-agent functionality.
|
* Auto-run entry for page-agent.js. Insert this script into your page to get page-agent functionality.
|
||||||
*/
|
*/
|
||||||
|
import { Panel } from '@page-agent/ui'
|
||||||
|
|
||||||
import { PageAgent, type PageAgentConfig } from './PageAgent'
|
import { PageAgent, type PageAgentConfig } from './PageAgent'
|
||||||
|
|
||||||
// Clean up existing instances to prevent multiple injections from bookmarklet
|
// Clean up existing instances to prevent multiple injections from bookmarklet
|
||||||
@@ -24,6 +26,8 @@ const DEMO_API_KEY = 'PAGE-AGENT-FREE-TESTING-RANDOM'
|
|||||||
// @todo give a switch to disable auto-init
|
// @todo give a switch to disable auto-init
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const currentScript = document.currentScript as HTMLScriptElement | null
|
const currentScript = document.currentScript as HTMLScriptElement | null
|
||||||
|
let config: PageAgentConfig
|
||||||
|
|
||||||
if (currentScript) {
|
if (currentScript) {
|
||||||
console.log('🚀 page-agent.js detected current script:', currentScript.src)
|
console.log('🚀 page-agent.js detected current script:', currentScript.src)
|
||||||
const url = new URL(currentScript.src)
|
const url = new URL(currentScript.src)
|
||||||
@@ -31,23 +35,22 @@ setTimeout(() => {
|
|||||||
const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL
|
const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL
|
||||||
const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY
|
const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY
|
||||||
const language = (url.searchParams.get('lang') as 'zh-CN' | 'en-US') || 'zh-CN'
|
const language = (url.searchParams.get('lang') as 'zh-CN' | 'en-US') || 'zh-CN'
|
||||||
const config: PageAgentConfig = { model, baseURL, apiKey, language }
|
config = { model, baseURL, apiKey, language }
|
||||||
window.pageAgent = new PageAgent(config)
|
|
||||||
} else {
|
} else {
|
||||||
console.log('🚀 page-agent.js no current script detected, using default demo config')
|
console.log('🚀 page-agent.js no current script detected, using default demo config')
|
||||||
const config: PageAgentConfig = {
|
config = {
|
||||||
// model: DEMO_MODEL,
|
|
||||||
// baseURL: DEMO_BASE_URL,
|
|
||||||
// apiKey: DEMO_API_KEY,
|
|
||||||
|
|
||||||
model: import.meta.env.LLM_MODEL_NAME ? import.meta.env.LLM_MODEL_NAME : DEMO_MODEL,
|
model: import.meta.env.LLM_MODEL_NAME ? import.meta.env.LLM_MODEL_NAME : DEMO_MODEL,
|
||||||
baseURL: import.meta.env.LLM_BASE_URL ? import.meta.env.LLM_BASE_URL : DEMO_BASE_URL,
|
baseURL: import.meta.env.LLM_BASE_URL ? import.meta.env.LLM_BASE_URL : DEMO_BASE_URL,
|
||||||
apiKey: import.meta.env.LLM_API_KEY ? import.meta.env.LLM_API_KEY : DEMO_API_KEY,
|
apiKey: import.meta.env.LLM_API_KEY ? import.meta.env.LLM_API_KEY : DEMO_API_KEY,
|
||||||
}
|
}
|
||||||
window.pageAgent = new PageAgent(config)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
|
// Create agent
|
||||||
|
window.pageAgent = new PageAgent(config)
|
||||||
|
|
||||||
window.pageAgent.panel!.show() // Show panel
|
// Create and bind Panel
|
||||||
|
const panel = new Panel(window.pageAgent, { language: config.language })
|
||||||
|
panel.show()
|
||||||
|
|
||||||
|
console.log('🚀 page-agent.js initialized with config:', window.pageAgent.config)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -80,11 +80,11 @@ tools.set(
|
|||||||
question: zod.string(),
|
question: zod.string(),
|
||||||
}),
|
}),
|
||||||
execute: async function (this: PageAgent, input) {
|
execute: async function (this: PageAgent, input) {
|
||||||
if (!this.panel) {
|
if (!this.onAskUser) {
|
||||||
throw new Error('ask_user tool requires panel to be enabled')
|
throw new Error('ask_user tool requires onAskUser callback to be set')
|
||||||
}
|
}
|
||||||
const answer = await this.panel.askUser(input.question)
|
const answer = await this.onAskUser(input.question)
|
||||||
return `✅ Received user answer: ${answer}`
|
return `User answered: ${answer}`
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -357,6 +357,11 @@
|
|||||||
background: linear-gradient(135deg, rgba(147, 51, 234, 0.1), rgba(147, 51, 234, 0.05));
|
background: linear-gradient(135deg, rgba(147, 51, 234, 0.1), rgba(147, 51, 234, 0.05));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.question {
|
||||||
|
border-left-color: rgb(255, 159, 67);
|
||||||
|
background: linear-gradient(135deg, rgba(255, 159, 67, 0.15), rgba(255, 159, 67, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
/* 突出显示 done 成功结果 */
|
/* 突出显示 done 成功结果 */
|
||||||
&.doneSuccess {
|
&.doneSuccess {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
@@ -439,7 +444,7 @@
|
|||||||
|
|
||||||
.historyContent {
|
.historyContent {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
@@ -453,6 +458,12 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reflectionLines {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.historyMeta {
|
.historyMeta {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type Step, UIState } from './UIState'
|
import { createCard, createReflectionLines, formatTime } from './cards'
|
||||||
import { I18n, type SupportedLanguage } from './i18n'
|
import { I18n, type SupportedLanguage } from './i18n'
|
||||||
import { escapeHtml, truncate } from './utils'
|
import type { AgentActivity, PanelAgentAdapter } from './types'
|
||||||
|
import { truncate } from './utils'
|
||||||
|
|
||||||
import styles from './Panel.module.css'
|
import styles from './Panel.module.css'
|
||||||
|
|
||||||
@@ -9,8 +10,6 @@ import styles from './Panel.module.css'
|
|||||||
*/
|
*/
|
||||||
export interface PanelConfig {
|
export interface PanelConfig {
|
||||||
language?: SupportedLanguage
|
language?: SupportedLanguage
|
||||||
onExecuteTask: (task: string) => void
|
|
||||||
onStop: () => void
|
|
||||||
/**
|
/**
|
||||||
* Whether to prompt for next task after task completion
|
* Whether to prompt for next task after task completion
|
||||||
* @default true
|
* @default true
|
||||||
@@ -18,24 +17,15 @@ export interface PanelConfig {
|
|||||||
promptForNextTask?: boolean
|
promptForNextTask?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Semantic update types - Panel handles i18n internally
|
|
||||||
*/
|
|
||||||
export type PanelUpdate =
|
|
||||||
| { type: 'thinking'; text?: string } // text is optional, defaults to i18n thinking text
|
|
||||||
| { type: 'input'; task: string }
|
|
||||||
| { type: 'question'; question: string }
|
|
||||||
| { type: 'userAnswer'; input: string }
|
|
||||||
| { type: 'retry'; current: number; max: number }
|
|
||||||
| { type: 'error'; message: string }
|
|
||||||
| { type: 'output'; text: string }
|
|
||||||
| { type: 'completed' }
|
|
||||||
| { type: 'toolExecuting'; toolName: string; args: any }
|
|
||||||
| { type: 'toolCompleted'; toolName: string; args: any; result?: string; duration?: number }
|
|
||||||
| { type: 'observation'; content: string }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent control panel
|
* Agent control panel
|
||||||
|
*
|
||||||
|
* Architecture:
|
||||||
|
* - History list: renders directly from agent.history (historical events)
|
||||||
|
* - Header bar: shows activity events (transient state) and agent status
|
||||||
|
*
|
||||||
|
* This separation ensures data consistency - history is the single source of truth
|
||||||
|
* for what has been done, while activity shows what is happening now.
|
||||||
*/
|
*/
|
||||||
export class Panel {
|
export class Panel {
|
||||||
#wrapper: HTMLElement
|
#wrapper: HTMLElement
|
||||||
@@ -47,9 +37,9 @@ export class Panel {
|
|||||||
#inputSection: HTMLElement
|
#inputSection: HTMLElement
|
||||||
#taskInput: HTMLInputElement
|
#taskInput: HTMLInputElement
|
||||||
|
|
||||||
#state = new UIState()
|
#agent: PanelAgentAdapter
|
||||||
#isExpanded = false
|
|
||||||
#config: PanelConfig
|
#config: PanelConfig
|
||||||
|
#isExpanded = false
|
||||||
#i18n: I18n
|
#i18n: I18n
|
||||||
#userAnswerResolver: ((input: string) => void) | null = null
|
#userAnswerResolver: ((input: string) => void) | null = null
|
||||||
#isWaitingForUserAnswer: boolean = false
|
#isWaitingForUserAnswer: boolean = false
|
||||||
@@ -57,13 +47,30 @@ export class Panel {
|
|||||||
#pendingHeaderText: string | null = null
|
#pendingHeaderText: string | null = null
|
||||||
#isAnimating = false
|
#isAnimating = false
|
||||||
|
|
||||||
|
// Event handlers (bound for removal)
|
||||||
|
#onStatusChange = () => this.#handleStatusChange()
|
||||||
|
#onHistoryChange = () => this.#handleHistoryChange()
|
||||||
|
#onActivity = (e: Event) => this.#handleActivity((e as CustomEvent<AgentActivity>).detail)
|
||||||
|
#onAgentDispose = () => this.dispose()
|
||||||
|
|
||||||
get wrapper(): HTMLElement {
|
get wrapper(): HTMLElement {
|
||||||
return this.#wrapper
|
return this.#wrapper
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(config: PanelConfig) {
|
/**
|
||||||
|
* Create a Panel bound to an agent
|
||||||
|
* @param agent - Agent instance that implements PanelAgentAdapter
|
||||||
|
* @param config - Optional panel configuration
|
||||||
|
*/
|
||||||
|
constructor(agent: PanelAgentAdapter, config: PanelConfig = {}) {
|
||||||
|
this.#agent = agent
|
||||||
this.#config = config
|
this.#config = config
|
||||||
this.#i18n = new I18n(config.language ?? 'en-US')
|
this.#i18n = new I18n(config.language ?? 'en-US')
|
||||||
|
|
||||||
|
// Set up askUser callback on agent
|
||||||
|
this.#agent.onAskUser = (question) => this.#askUser(question)
|
||||||
|
|
||||||
|
// Create UI elements
|
||||||
this.#wrapper = this.#createWrapper()
|
this.#wrapper = this.#createWrapper()
|
||||||
this.#indicator = this.#wrapper.querySelector(`.${styles.indicator}`)!
|
this.#indicator = this.#wrapper.querySelector(`.${styles.indicator}`)!
|
||||||
this.#statusText = this.#wrapper.querySelector(`.${styles.statusText}`)!
|
this.#statusText = this.#wrapper.querySelector(`.${styles.statusText}`)!
|
||||||
@@ -73,6 +80,12 @@ export class Panel {
|
|||||||
this.#inputSection = this.#wrapper.querySelector(`.${styles.inputSectionWrapper}`)!
|
this.#inputSection = this.#wrapper.querySelector(`.${styles.inputSectionWrapper}`)!
|
||||||
this.#taskInput = this.#wrapper.querySelector(`.${styles.taskInput}`)!
|
this.#taskInput = this.#wrapper.querySelector(`.${styles.taskInput}`)!
|
||||||
|
|
||||||
|
// Listen to agent events
|
||||||
|
this.#agent.addEventListener('statuschange', this.#onStatusChange)
|
||||||
|
this.#agent.addEventListener('historychange', this.#onHistoryChange)
|
||||||
|
this.#agent.addEventListener('activity', this.#onActivity)
|
||||||
|
this.#agent.addEventListener('dispose', this.#onAgentDispose)
|
||||||
|
|
||||||
this.#setupEventListeners()
|
this.#setupEventListeners()
|
||||||
this.#startHeaderUpdateLoop()
|
this.#startHeaderUpdateLoop()
|
||||||
|
|
||||||
@@ -81,24 +94,98 @@ export class Panel {
|
|||||||
this.hide() // Start hidden
|
this.hide() // Start hidden
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Agent event handlers ==========
|
||||||
|
|
||||||
|
/** Handle agent status change */
|
||||||
|
#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)
|
||||||
|
|
||||||
|
// Show/hide based on status
|
||||||
|
if (status === 'running') {
|
||||||
|
this.show()
|
||||||
|
this.#hideInputArea() // Hide input while running
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle completion
|
||||||
|
if (status === 'completed' || status === 'error') {
|
||||||
|
if (!this.#isExpanded) {
|
||||||
|
this.#expand()
|
||||||
|
}
|
||||||
|
if (this.#shouldShowInputArea()) {
|
||||||
|
this.#showInputArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle agent history change - re-render history list from agent.history */
|
||||||
|
#handleHistoryChange(): void {
|
||||||
|
this.#renderHistory()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ask for user input
|
* Handle agent activity - transient state for immediate UI feedback
|
||||||
|
* Activity events are NOT persisted in history, only used for header bar updates
|
||||||
*/
|
*/
|
||||||
async askUser(question: string): Promise<string> {
|
#handleActivity(activity: AgentActivity): void {
|
||||||
|
switch (activity.type) {
|
||||||
|
case 'thinking':
|
||||||
|
this.#pendingHeaderText = this.#i18n.t('ui.panel.thinking')
|
||||||
|
this.#updateStatusIndicator('thinking')
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'executing':
|
||||||
|
this.#pendingHeaderText = this.#getToolExecutingText(activity.tool, activity.input)
|
||||||
|
this.#updateStatusIndicator('executing')
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'executed':
|
||||||
|
this.#pendingHeaderText = truncate(activity.output, 50)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'retrying':
|
||||||
|
this.#pendingHeaderText = `Retrying (${activity.attempt}/${activity.maxAttempts})`
|
||||||
|
this.#updateStatusIndicator('retrying')
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
this.#pendingHeaderText = truncate(activity.message, 50)
|
||||||
|
this.#updateStatusIndicator('error')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask for user input (internal, called by agent via onAskUser)
|
||||||
|
*/
|
||||||
|
#askUser(question: string): Promise<string> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
// Set `waiting for user answer` state
|
// Set `waiting for user answer` state
|
||||||
this.#isWaitingForUserAnswer = true
|
this.#isWaitingForUserAnswer = true
|
||||||
this.#userAnswerResolver = resolve
|
this.#userAnswerResolver = resolve
|
||||||
|
|
||||||
// Update state to `running`
|
// Expand history panel
|
||||||
this.#updateInternal({
|
|
||||||
type: 'output',
|
|
||||||
displayText: this.#i18n.t('ui.panel.question', { question }),
|
|
||||||
}) // Expand history panel
|
|
||||||
if (!this.#isExpanded) {
|
if (!this.#isExpanded) {
|
||||||
this.#expand()
|
this.#expand()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add temporary question card so user can see the full question
|
||||||
|
const tempCard = document.createElement('div')
|
||||||
|
tempCard.innerHTML = createCard({
|
||||||
|
icon: '❓',
|
||||||
|
content: `Question: ${question}`,
|
||||||
|
meta: formatTime(),
|
||||||
|
type: 'question',
|
||||||
|
})
|
||||||
|
const cardElement = tempCard.firstElementChild as HTMLElement
|
||||||
|
cardElement.setAttribute('data-temp-card', 'true')
|
||||||
|
this.#historySection.appendChild(cardElement)
|
||||||
|
this.#scrollToBottom()
|
||||||
|
|
||||||
this.#showInputArea(this.#i18n.t('ui.panel.userAnswerPrompt'))
|
this.#showInputArea(this.#i18n.t('ui.panel.userAnswerPrompt'))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -119,10 +206,9 @@ export class Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.#state.reset()
|
|
||||||
this.#statusText.textContent = this.#i18n.t('ui.panel.ready')
|
this.#statusText.textContent = this.#i18n.t('ui.panel.ready')
|
||||||
this.#updateStatusIndicator('thinking')
|
this.#updateStatusIndicator('thinking')
|
||||||
this.#updateHistory()
|
this.#renderHistory()
|
||||||
this.#collapse()
|
this.#collapse()
|
||||||
// Reset user input state
|
// Reset user input state
|
||||||
this.#isWaitingForUserAnswer = false
|
this.#isWaitingForUserAnswer = false
|
||||||
@@ -140,17 +226,16 @@ export class Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update panel with semantic data - i18n handled internally
|
* Dispose panel and clean up event listeners
|
||||||
*/
|
|
||||||
update(data: PanelUpdate): void {
|
|
||||||
const stepData = this.#toStepData(data)
|
|
||||||
this.#updateInternal(stepData)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dispose panel
|
|
||||||
*/
|
*/
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
|
// Remove agent event listeners
|
||||||
|
this.#agent.removeEventListener('statuschange', this.#onStatusChange)
|
||||||
|
this.#agent.removeEventListener('historychange', this.#onHistoryChange)
|
||||||
|
this.#agent.removeEventListener('activity', this.#onActivity)
|
||||||
|
this.#agent.removeEventListener('dispose', this.#onAgentDispose)
|
||||||
|
|
||||||
|
// Clean up UI
|
||||||
this.#isWaitingForUserAnswer = false
|
this.#isWaitingForUserAnswer = false
|
||||||
this.#stopHeaderUpdateLoop()
|
this.#stopHeaderUpdateLoop()
|
||||||
this.wrapper.remove()
|
this.wrapper.remove()
|
||||||
@@ -158,69 +243,21 @@ export class Panel {
|
|||||||
|
|
||||||
// ========== Private methods ==========
|
// ========== Private methods ==========
|
||||||
|
|
||||||
/**
|
#getToolExecutingText(toolName: string, args: unknown): string {
|
||||||
* Convert semantic update to step data with i18n
|
const a = args as Record<string, string | number>
|
||||||
*/
|
|
||||||
#toStepData(data: PanelUpdate): Omit<Step, 'id' | 'stepNumber' | 'timestamp'> {
|
|
||||||
switch (data.type) {
|
|
||||||
case 'thinking':
|
|
||||||
return { type: 'thinking', displayText: data.text ?? this.#i18n.t('ui.panel.thinking') }
|
|
||||||
case 'input':
|
|
||||||
return { type: 'input', displayText: data.task }
|
|
||||||
case 'question':
|
|
||||||
return {
|
|
||||||
type: 'output',
|
|
||||||
displayText: this.#i18n.t('ui.panel.question', { question: data.question }),
|
|
||||||
}
|
|
||||||
case 'userAnswer':
|
|
||||||
return {
|
|
||||||
type: 'input',
|
|
||||||
displayText: this.#i18n.t('ui.panel.userAnswer', { input: data.input }),
|
|
||||||
}
|
|
||||||
case 'retry':
|
|
||||||
return { type: 'retry', displayText: `retry-ing (${data.current} / ${data.max})` }
|
|
||||||
case 'error':
|
|
||||||
return { type: 'error', displayText: data.message }
|
|
||||||
case 'output':
|
|
||||||
return { type: 'output', displayText: data.text }
|
|
||||||
case 'completed':
|
|
||||||
return { type: 'completed', displayText: this.#i18n.t('ui.panel.taskCompleted') }
|
|
||||||
case 'toolExecuting':
|
|
||||||
return {
|
|
||||||
type: 'tool_executing',
|
|
||||||
toolName: data.toolName,
|
|
||||||
toolArgs: data.args,
|
|
||||||
displayText: this.#getToolExecutingText(data.toolName, data.args),
|
|
||||||
}
|
|
||||||
case 'toolCompleted': {
|
|
||||||
const displayText = this.#getToolCompletedText(data.toolName, data.args)
|
|
||||||
if (!displayText) return { type: 'tool_executing', displayText: '' } // will be filtered
|
|
||||||
return {
|
|
||||||
type: 'tool_executing',
|
|
||||||
toolName: data.toolName,
|
|
||||||
toolArgs: data.args,
|
|
||||||
toolResult: data.result,
|
|
||||||
displayText,
|
|
||||||
duration: data.duration,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 'observation':
|
|
||||||
return { type: 'observation', displayText: data.content }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#getToolExecutingText(toolName: string, args: any): string {
|
|
||||||
switch (toolName) {
|
switch (toolName) {
|
||||||
case 'click_element_by_index':
|
case 'click_element_by_index':
|
||||||
return this.#i18n.t('ui.tools.clicking', { index: args.index })
|
return this.#i18n.t('ui.tools.clicking', { index: a.index })
|
||||||
case 'input_text':
|
case 'input_text':
|
||||||
return this.#i18n.t('ui.tools.inputting', { index: args.index })
|
return this.#i18n.t('ui.tools.inputting', { index: a.index })
|
||||||
case 'select_dropdown_option':
|
case 'select_dropdown_option':
|
||||||
return this.#i18n.t('ui.tools.selecting', { text: args.text })
|
return this.#i18n.t('ui.tools.selecting', { text: a.text })
|
||||||
case 'scroll':
|
case 'scroll':
|
||||||
return this.#i18n.t('ui.tools.scrolling')
|
return this.#i18n.t('ui.tools.scrolling')
|
||||||
case 'wait':
|
case 'wait':
|
||||||
return this.#i18n.t('ui.tools.waiting', { seconds: args.seconds })
|
return this.#i18n.t('ui.tools.waiting', { seconds: a.seconds })
|
||||||
|
case 'ask_user':
|
||||||
|
return this.#i18n.t('ui.tools.askingUser')
|
||||||
case 'done':
|
case 'done':
|
||||||
return this.#i18n.t('ui.tools.done')
|
return this.#i18n.t('ui.tools.done')
|
||||||
default:
|
default:
|
||||||
@@ -228,67 +265,11 @@ export class Panel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#getToolCompletedText(toolName: string, args: any): string | null {
|
|
||||||
switch (toolName) {
|
|
||||||
case 'click_element_by_index':
|
|
||||||
return this.#i18n.t('ui.tools.clicked', { index: args.index })
|
|
||||||
case 'input_text':
|
|
||||||
return this.#i18n.t('ui.tools.inputted', { text: args.text })
|
|
||||||
case 'select_dropdown_option':
|
|
||||||
return this.#i18n.t('ui.tools.selected', { text: args.text })
|
|
||||||
case 'scroll':
|
|
||||||
return this.#i18n.t('ui.tools.scrolled')
|
|
||||||
case 'wait':
|
|
||||||
return this.#i18n.t('ui.tools.waited')
|
|
||||||
case 'done':
|
|
||||||
return null
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update status (internal)
|
|
||||||
*/
|
|
||||||
#updateInternal(stepData: Omit<Step, 'id' | 'stepNumber' | 'timestamp'>): void {
|
|
||||||
// Skip empty displayText (filtered toolCompleted for 'done')
|
|
||||||
if (!stepData.displayText) return
|
|
||||||
|
|
||||||
const step = this.#state.addStep(stepData)
|
|
||||||
|
|
||||||
// Queue header text update (will be processed by periodic check)
|
|
||||||
const headerText = truncate(step.displayText, 20)
|
|
||||||
this.#pendingHeaderText = headerText
|
|
||||||
|
|
||||||
this.#updateStatusIndicator(step.type)
|
|
||||||
this.#updateHistory()
|
|
||||||
|
|
||||||
// Auto-expand history after task completion
|
|
||||||
if (step.type === 'completed' || step.type === 'error') {
|
|
||||||
if (!this.#isExpanded) {
|
|
||||||
this.#expand()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Control input area display based on status
|
|
||||||
if (this.#shouldShowInputArea()) {
|
|
||||||
this.#showInputArea()
|
|
||||||
} else {
|
|
||||||
this.#hideInputArea()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop Agent
|
* Stop Agent
|
||||||
*/
|
*/
|
||||||
#stopAgent(): void {
|
#stopAgent(): void {
|
||||||
// Update status display
|
this.#agent.dispose()
|
||||||
this.#updateInternal({
|
|
||||||
type: 'error',
|
|
||||||
displayText: this.#i18n.t('ui.panel.taskTerminated'),
|
|
||||||
})
|
|
||||||
|
|
||||||
this.#config.onStop()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -305,7 +286,8 @@ export class Panel {
|
|||||||
// Handle user input mode
|
// Handle user input mode
|
||||||
this.#handleUserAnswer(input)
|
this.#handleUserAnswer(input)
|
||||||
} else {
|
} else {
|
||||||
this.#config.onExecuteTask(input)
|
// Execute task via agent
|
||||||
|
this.#agent.execute(input)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,10 +295,11 @@ export class Panel {
|
|||||||
* Handle user answer
|
* Handle user answer
|
||||||
*/
|
*/
|
||||||
#handleUserAnswer(input: string): void {
|
#handleUserAnswer(input: string): void {
|
||||||
// Add user input to history
|
// Remove temporary question cards (only direct children for safety)
|
||||||
this.#updateInternal({
|
Array.from(this.#historySection.children).forEach((child) => {
|
||||||
type: 'input',
|
if (child.getAttribute('data-temp-card') === 'true') {
|
||||||
displayText: this.#i18n.t('ui.panel.userAnswer', { input }),
|
child.remove()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
@@ -357,13 +340,13 @@ export class Panel {
|
|||||||
// Always show input area if waiting for user input
|
// Always show input area if waiting for user input
|
||||||
if (this.#isWaitingForUserAnswer) return true
|
if (this.#isWaitingForUserAnswer) return true
|
||||||
|
|
||||||
const steps = this.#state.getAllSteps()
|
const history = this.#agent.history
|
||||||
if (steps.length === 0) {
|
if (history.length === 0) {
|
||||||
return true // Initial state
|
return true // Initial state
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastStep = steps[steps.length - 1]
|
const status = this.#agent.status
|
||||||
const isTaskEnded = lastStep.type === 'completed' || lastStep.type === 'error'
|
const isTaskEnded = status === 'completed' || status === 'error'
|
||||||
|
|
||||||
// Only show input area after task completion if configured to do so
|
// Only show input area after task completion if configured to do so
|
||||||
if (isTaskEnded) {
|
if (isTaskEnded) {
|
||||||
@@ -383,13 +366,12 @@ export class Panel {
|
|||||||
<div class="${styles.background}"></div>
|
<div class="${styles.background}"></div>
|
||||||
<div class="${styles.historySectionWrapper}">
|
<div class="${styles.historySectionWrapper}">
|
||||||
<div class="${styles.historySection}">
|
<div class="${styles.historySection}">
|
||||||
${this.#createHistoryItem({
|
<div class="${styles.historyItem}">
|
||||||
id: 'placeholder',
|
<div class="${styles.historyContent}">
|
||||||
stepNumber: 0,
|
<span class="${styles.statusIcon}">🧠</span>
|
||||||
timestamp: new Date(),
|
<span>${this.#i18n.t('ui.panel.waitingPlaceholder')}</span>
|
||||||
type: 'thinking',
|
</div>
|
||||||
displayText: this.#i18n.t('ui.panel.waitingPlaceholder'),
|
</div>
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="${styles.header}">
|
<div class="${styles.header}">
|
||||||
@@ -544,7 +526,9 @@ export class Panel {
|
|||||||
}, 150) // Half the duration of fade out animation
|
}, 150) // Half the duration of fade out animation
|
||||||
}
|
}
|
||||||
|
|
||||||
#updateStatusIndicator(type: Step['type']): void {
|
#updateStatusIndicator(
|
||||||
|
type: 'thinking' | 'executing' | 'executed' | 'retrying' | 'completed' | 'error'
|
||||||
|
): void {
|
||||||
// Clear all status classes
|
// Clear all status classes
|
||||||
this.#indicator.className = styles.indicator
|
this.#indicator.className = styles.indicator
|
||||||
|
|
||||||
@@ -552,12 +536,6 @@ export class Panel {
|
|||||||
this.#indicator.classList.add(styles[type])
|
this.#indicator.classList.add(styles[type])
|
||||||
}
|
}
|
||||||
|
|
||||||
#updateHistory(): void {
|
|
||||||
const steps = this.#state.getAllSteps()
|
|
||||||
this.#historySection.innerHTML = steps.map((step) => this.#createHistoryItem(step)).join('')
|
|
||||||
this.#scrollToBottom()
|
|
||||||
}
|
|
||||||
|
|
||||||
#scrollToBottom(): void {
|
#scrollToBottom(): void {
|
||||||
// Execute in next event loop to ensure DOM update completion
|
// Execute in next event loop to ensure DOM update completion
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -565,71 +543,107 @@ export class Panel {
|
|||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
#createHistoryItem(step: Step): string {
|
/**
|
||||||
const time = step.timestamp.toLocaleTimeString('zh-CN', {
|
* Render history directly from agent.history
|
||||||
hour12: false,
|
*
|
||||||
hour: '2-digit',
|
* Renders:
|
||||||
minute: '2-digit',
|
* 1. Task (first item, from agent.task)
|
||||||
second: '2-digit',
|
* 2. Reflection cards (evaluation, memory, next_goal)
|
||||||
})
|
* 3. Tool execution with output
|
||||||
|
* 4. Observations
|
||||||
|
*/
|
||||||
|
#renderHistory(): void {
|
||||||
|
const items: string[] = []
|
||||||
|
|
||||||
let typeClass = ''
|
// 1. Task card (always first)
|
||||||
let statusIcon = ''
|
const task = this.#agent.task
|
||||||
|
if (task) {
|
||||||
// Set styles and icons based on step type
|
items.push(this.#createTaskCard(task))
|
||||||
if (step.type === 'completed') {
|
|
||||||
// Check if this is a result from done tool
|
|
||||||
if (step.toolName === 'done') {
|
|
||||||
// Judge success or failure based on result
|
|
||||||
const failureKeyword = this.#i18n.t('ui.tools.resultFailure')
|
|
||||||
const errorKeyword = this.#i18n.t('ui.tools.resultError')
|
|
||||||
const isSuccess =
|
|
||||||
!step.toolResult ||
|
|
||||||
(!step.toolResult.includes(failureKeyword) && !step.toolResult.includes(errorKeyword))
|
|
||||||
typeClass = isSuccess ? styles.doneSuccess : styles.doneError
|
|
||||||
statusIcon = isSuccess ? '🎉' : '❌'
|
|
||||||
} else {
|
|
||||||
typeClass = styles.completed
|
|
||||||
statusIcon = '✅'
|
|
||||||
}
|
|
||||||
} else if (step.type === 'error') {
|
|
||||||
typeClass = styles.error
|
|
||||||
statusIcon = '❌'
|
|
||||||
} else if (step.type === 'tool_executing') {
|
|
||||||
statusIcon = '🔨'
|
|
||||||
} else if (step.type === 'output') {
|
|
||||||
typeClass = styles.output
|
|
||||||
statusIcon = '🤖'
|
|
||||||
} else if (step.type === 'input') {
|
|
||||||
typeClass = styles.input
|
|
||||||
statusIcon = '🎯'
|
|
||||||
} else if (step.type === 'retry') {
|
|
||||||
typeClass = styles.retry
|
|
||||||
statusIcon = '🔄'
|
|
||||||
} else if (step.type === 'observation') {
|
|
||||||
typeClass = styles.observation
|
|
||||||
statusIcon = '👁️'
|
|
||||||
} else {
|
|
||||||
statusIcon = '🧠'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const durationText = step.duration ? ` · ${step.duration}ms` : ''
|
// 2. Render each history event
|
||||||
const stepLabel = this.#i18n.t('ui.panel.step', {
|
const history = this.#agent.history
|
||||||
number: step.stepNumber.toString(),
|
for (let i = 0; i < history.length; i++) {
|
||||||
|
const event = history[i]
|
||||||
|
items.push(...this.#createHistoryCards(event, i + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#historySection.innerHTML = items.join('')
|
||||||
|
this.#scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
#createTaskCard(task: string): string {
|
||||||
|
return createCard({ icon: '🎯', content: task, type: 'input' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create cards for a history event */
|
||||||
|
#createHistoryCards(event: PanelAgentAdapter['history'][number], stepNumber: number): string[] {
|
||||||
|
const cards: string[] = []
|
||||||
|
const time = formatTime()
|
||||||
|
const meta = this.#i18n.t('ui.panel.step', {
|
||||||
|
number: stepNumber.toString(),
|
||||||
time,
|
time,
|
||||||
duration: durationText || '', // Explicitly pass empty string to replace template
|
duration: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
return `
|
if (event.type === 'step') {
|
||||||
<div class="${styles.historyItem} ${typeClass}">
|
// Reflection card
|
||||||
<div class="${styles.historyContent}">
|
if (event.reflection) {
|
||||||
<span class="${styles.statusIcon}">${statusIcon}</span>
|
const lines = createReflectionLines(event.reflection)
|
||||||
<span>${escapeHtml(step.displayText)}</span>
|
if (lines.length > 0) {
|
||||||
</div>
|
cards.push(createCard({ icon: '🧠', content: lines, meta }))
|
||||||
<div class="${styles.historyMeta}">
|
}
|
||||||
${stepLabel}
|
}
|
||||||
</div>
|
|
||||||
</div>
|
// Action card
|
||||||
`
|
const action = event.action
|
||||||
|
if (action) {
|
||||||
|
cards.push(...this.#createActionCards(action, meta))
|
||||||
|
}
|
||||||
|
} else if (event.type === 'observation') {
|
||||||
|
cards.push(
|
||||||
|
createCard({ icon: '👁️', content: event.content || '', meta, type: 'observation' })
|
||||||
|
)
|
||||||
|
} else if (event.type === 'user_takeover') {
|
||||||
|
cards.push(createCard({ icon: '👤', content: 'User takeover', meta, type: 'input' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return cards
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create cards for an action */
|
||||||
|
#createActionCards(
|
||||||
|
action: { name: string; input: unknown; output: string },
|
||||||
|
meta: string
|
||||||
|
): string[] {
|
||||||
|
const cards: string[] = []
|
||||||
|
|
||||||
|
if (action.name === 'done') {
|
||||||
|
const input = action.input as { text?: string }
|
||||||
|
const text = input.text || action.output || ''
|
||||||
|
if (text) {
|
||||||
|
cards.push(createCard({ icon: '🤖', content: text, meta, type: 'output' }))
|
||||||
|
}
|
||||||
|
} else if (action.name === 'ask_user') {
|
||||||
|
const input = action.input as { question?: string }
|
||||||
|
const answer = action.output.replace(/^User answered:\s*/i, '')
|
||||||
|
cards.push(
|
||||||
|
createCard({
|
||||||
|
icon: '❓',
|
||||||
|
content: `Question: ${input.question || ''}`,
|
||||||
|
meta,
|
||||||
|
type: 'question',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
cards.push(createCard({ icon: '💬', content: `Answer: ${answer}`, meta, type: 'input' }))
|
||||||
|
} else {
|
||||||
|
const toolText = this.#getToolExecutingText(action.name, action.input)
|
||||||
|
cards.push(createCard({ icon: '🔨', content: toolText, meta }))
|
||||||
|
if (action.output?.length > 0) {
|
||||||
|
cards.push(createCard({ icon: '🔨', content: action.output, meta, type: 'output' }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cards
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
/**
|
|
||||||
* Agent execution state management
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface Step {
|
|
||||||
id: string
|
|
||||||
stepNumber: number
|
|
||||||
timestamp: Date
|
|
||||||
type:
|
|
||||||
| 'thinking'
|
|
||||||
| 'tool_executing'
|
|
||||||
| 'completed'
|
|
||||||
| 'error'
|
|
||||||
| 'output'
|
|
||||||
| 'input'
|
|
||||||
| 'retry'
|
|
||||||
| 'observation'
|
|
||||||
|
|
||||||
// Tool execution related
|
|
||||||
toolName?: string
|
|
||||||
toolArgs?: any
|
|
||||||
toolResult?: any
|
|
||||||
|
|
||||||
// Display data
|
|
||||||
displayText: string
|
|
||||||
duration?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AgentStatus = 'idle' | 'running' | 'completed' | 'error'
|
|
||||||
|
|
||||||
export class UIState {
|
|
||||||
private steps: Step[] = []
|
|
||||||
private currentStep: Step | null = null
|
|
||||||
private status: AgentStatus = 'idle'
|
|
||||||
private stepCounter = 0
|
|
||||||
|
|
||||||
addStep(stepData: Omit<Step, 'id' | 'stepNumber' | 'timestamp'>): Step {
|
|
||||||
const step: Step = {
|
|
||||||
id: this.generateId(),
|
|
||||||
stepNumber: ++this.stepCounter,
|
|
||||||
timestamp: new Date(),
|
|
||||||
...stepData,
|
|
||||||
}
|
|
||||||
|
|
||||||
this.steps.push(step)
|
|
||||||
this.currentStep = step
|
|
||||||
|
|
||||||
// Update overall status
|
|
||||||
this.updateStatus(step.type)
|
|
||||||
|
|
||||||
return step
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCurrentStep(updates: Partial<Step>): Step | null {
|
|
||||||
if (!this.currentStep) return null
|
|
||||||
|
|
||||||
Object.assign(this.currentStep, updates)
|
|
||||||
return this.currentStep
|
|
||||||
}
|
|
||||||
|
|
||||||
getCurrentStep(): Step | null {
|
|
||||||
return this.currentStep
|
|
||||||
}
|
|
||||||
|
|
||||||
getAllSteps(): Step[] {
|
|
||||||
return [...this.steps]
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatus(): AgentStatus {
|
|
||||||
return this.status
|
|
||||||
}
|
|
||||||
|
|
||||||
reset(): void {
|
|
||||||
this.steps = []
|
|
||||||
this.currentStep = null
|
|
||||||
this.status = 'idle'
|
|
||||||
this.stepCounter = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateStatus(stepType: Step['type']): void {
|
|
||||||
switch (stepType) {
|
|
||||||
case 'thinking':
|
|
||||||
case 'tool_executing':
|
|
||||||
case 'output':
|
|
||||||
case 'input':
|
|
||||||
case 'retry':
|
|
||||||
this.status = 'running'
|
|
||||||
break
|
|
||||||
case 'completed':
|
|
||||||
this.status = 'completed'
|
|
||||||
break
|
|
||||||
case 'error':
|
|
||||||
this.status = 'error'
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateId(): string {
|
|
||||||
return `step_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
62
packages/ui/src/cards.ts
Normal file
62
packages/ui/src/cards.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Card HTML generation utilities for Panel
|
||||||
|
*/
|
||||||
|
import { escapeHtml } from './utils'
|
||||||
|
|
||||||
|
import styles from './Panel.module.css'
|
||||||
|
|
||||||
|
type CardType = 'default' | 'input' | 'output' | 'question' | 'observation'
|
||||||
|
|
||||||
|
interface CardOptions {
|
||||||
|
icon: string
|
||||||
|
content: string | string[]
|
||||||
|
meta?: string
|
||||||
|
type?: CardType
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a single history card */
|
||||||
|
export function createCard({ icon, content, meta, type }: CardOptions): string {
|
||||||
|
const typeClass = type ? styles[type] : ''
|
||||||
|
const contentHtml = Array.isArray(content)
|
||||||
|
? `<div class="${styles.reflectionLines}">${content.join('')}</div>`
|
||||||
|
: `<span>${escapeHtml(content)}</span>`
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="${styles.historyItem} ${typeClass}">
|
||||||
|
<div class="${styles.historyContent}">
|
||||||
|
<span class="${styles.statusIcon}">${icon}</span>
|
||||||
|
${contentHtml}
|
||||||
|
</div>
|
||||||
|
${meta ? `<div class="${styles.historyMeta}">${meta}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format timestamp for cards */
|
||||||
|
export function formatTime(): string {
|
||||||
|
return new Date().toLocaleTimeString('zh-CN', {
|
||||||
|
hour12: false,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create reflection lines from reflection object */
|
||||||
|
export function createReflectionLines(reflection: {
|
||||||
|
evaluation_previous_goal?: string
|
||||||
|
memory?: string
|
||||||
|
next_goal?: string
|
||||||
|
}): string[] {
|
||||||
|
const lines: string[] = []
|
||||||
|
if (reflection.evaluation_previous_goal) {
|
||||||
|
lines.push(`<div>🔍 ${escapeHtml(reflection.evaluation_previous_goal)}</div>`)
|
||||||
|
}
|
||||||
|
if (reflection.memory) {
|
||||||
|
lines.push(`<div>💾 ${escapeHtml(reflection.memory)}</div>`)
|
||||||
|
}
|
||||||
|
if (reflection.next_goal) {
|
||||||
|
lines.push(`<div>🎯 ${escapeHtml(reflection.next_goal)}</div>`)
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ const enUS = {
|
|||||||
selecting: 'Selecting option "{{text}}"...',
|
selecting: 'Selecting option "{{text}}"...',
|
||||||
scrolling: 'Scrolling page...',
|
scrolling: 'Scrolling page...',
|
||||||
waiting: 'Waiting {{seconds}} seconds...',
|
waiting: 'Waiting {{seconds}} seconds...',
|
||||||
|
askingUser: 'Asking user...',
|
||||||
done: 'Task done',
|
done: 'Task done',
|
||||||
clicked: '🖱️ Clicked element [{{index}}]',
|
clicked: '🖱️ Clicked element [{{index}}]',
|
||||||
inputted: '⌨️ Inputted text "{{text}}"',
|
inputted: '⌨️ Inputted text "{{text}}"',
|
||||||
@@ -68,6 +69,7 @@ const zhCN = {
|
|||||||
selecting: '正在选择选项 "{{text}}"...',
|
selecting: '正在选择选项 "{{text}}"...',
|
||||||
scrolling: '正在滚动页面...',
|
scrolling: '正在滚动页面...',
|
||||||
waiting: '等待 {{seconds}} 秒...',
|
waiting: '等待 {{seconds}} 秒...',
|
||||||
|
askingUser: '正在询问用户...',
|
||||||
done: '结束任务',
|
done: '结束任务',
|
||||||
clicked: '🖱️ 已点击元素 [{{index}}]',
|
clicked: '🖱️ 已点击元素 [{{index}}]',
|
||||||
inputted: '⌨️ 已输入文本 "{{text}}"',
|
inputted: '⌨️ 已输入文本 "{{text}}"',
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export { Panel, type PanelConfig, type PanelUpdate } from './Panel'
|
export { Panel, type PanelConfig } from './Panel'
|
||||||
export { UIState, type Step, type AgentStatus } from './UIState'
|
export type { AgentActivity, PanelAgentAdapter } from './types'
|
||||||
export { I18n, type SupportedLanguage, type TranslationKey } from './i18n'
|
export { I18n, type SupportedLanguage, type TranslationKey } from './i18n'
|
||||||
|
|||||||
67
packages/ui/src/types.ts
Normal file
67
packages/ui/src/types.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Agent activity - transient state for immediate UI feedback.
|
||||||
|
*
|
||||||
|
* Unlike historical events (which are persisted), activities are ephemeral
|
||||||
|
* and represent "what the agent is doing right now". UI components should
|
||||||
|
* listen to 'activity' events to show real-time feedback.
|
||||||
|
*
|
||||||
|
* Note: There is no 'idle' activity - absence of activity events means idle.
|
||||||
|
*
|
||||||
|
* Events dispatched: CustomEvent<AgentActivity>
|
||||||
|
*/
|
||||||
|
export type AgentActivity =
|
||||||
|
| { type: 'thinking' }
|
||||||
|
| { type: 'executing'; tool: string; input: unknown }
|
||||||
|
| { type: 'executed'; tool: string; input: unknown; output: string; duration: number }
|
||||||
|
| { type: 'retrying'; attempt: number; maxAttempts: number }
|
||||||
|
| { type: 'error'; message: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal interface that Panel expects from an agent.
|
||||||
|
* Panel does not depend on PageAgent directly - it only requires this interface.
|
||||||
|
* This enables decoupling and allows any agent implementation to work with Panel.
|
||||||
|
*
|
||||||
|
* Events:
|
||||||
|
* - 'statuschange': Agent status changed (idle/running/completed/error)
|
||||||
|
* - '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'
|
||||||
|
|
||||||
|
/** History of agent events */
|
||||||
|
readonly history: readonly {
|
||||||
|
type: 'step' | 'observation' | 'user_takeover'
|
||||||
|
/** For 'step' type */
|
||||||
|
reflection?: {
|
||||||
|
evaluation_previous_goal?: string
|
||||||
|
memory?: string
|
||||||
|
next_goal?: string
|
||||||
|
}
|
||||||
|
/** For 'step' type */
|
||||||
|
action?: {
|
||||||
|
name: string
|
||||||
|
input: unknown
|
||||||
|
output: string
|
||||||
|
}
|
||||||
|
/** For 'observation' type */
|
||||||
|
content?: string
|
||||||
|
}[]
|
||||||
|
|
||||||
|
/** Current task being executed */
|
||||||
|
readonly task: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for when agent needs user input.
|
||||||
|
* Panel will set this to handle user questions via its UI.
|
||||||
|
*/
|
||||||
|
onAskUser?: (question: string) => Promise<string>
|
||||||
|
|
||||||
|
/** Execute a task */
|
||||||
|
execute(task: string): Promise<unknown>
|
||||||
|
|
||||||
|
/** Dispose the agent */
|
||||||
|
dispose(): void
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
/* eslint-disable react-dom/no-dangerously-set-innerhtml */
|
/* eslint-disable react-dom/no-dangerously-set-innerhtml */
|
||||||
|
import { Panel } from '@page-agent/ui'
|
||||||
import { Bot, Box, MessageSquare, PlayCircle, Shield, Sparkles, Users, Zap } from 'lucide-react'
|
import { Bot, Box, MessageSquare, PlayCircle, Shield, Sparkles, Users, Zap } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -94,6 +95,10 @@ export default function HomePage() {
|
|||||||
// promptForNextTask: false,
|
// promptForNextTask: false,
|
||||||
// enablePanel: false,
|
// enablePanel: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Create and bind Panel
|
||||||
|
const panel = new Panel(win.pageAgent, { language: i18n.language as any })
|
||||||
|
panel.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await win.pageAgent.execute(task)
|
const result = await win.pageAgent.execute(task)
|
||||||
|
|||||||
Reference in New Issue
Block a user