From 1ede1d991177b17c2da44d9f237308c5edbdf472 Mon Sep 17 00:00:00 2001 From: Simon <10131203+gaomeng1900@users.noreply.github.com> Date: Sat, 17 Jan 2026 23:14:26 +0800 Subject: [PATCH] feat!: redo `Panel`; decouple `Panel` from `Agent` BREAKING CHANGES: Agent and Panel API Changes --- packages/llms/src/index.ts | 16 +- packages/page-agent/src/PageAgent.ts | 217 +++++++---- packages/page-agent/src/config/index.ts | 25 +- packages/page-agent/src/iife.ts | 23 +- packages/page-agent/src/tools/index.ts | 8 +- packages/ui/src/Panel.module.css | 13 +- packages/ui/src/Panel.ts | 488 ++++++++++++------------ packages/ui/src/UIState.ts | 101 ----- packages/ui/src/cards.ts | 62 +++ packages/ui/src/i18n/locales.ts | 2 + packages/ui/src/index.ts | 4 +- packages/ui/src/types.ts | 67 ++++ packages/website/src/pages/Home.tsx | 5 + 13 files changed, 565 insertions(+), 466 deletions(-) delete mode 100644 packages/ui/src/UIState.ts create mode 100644 packages/ui/src/cards.ts create mode 100644 packages/ui/src/types.ts diff --git a/packages/llms/src/index.ts b/packages/llms/src/index.ts index 6785b68..fb9f9ed 100644 --- a/packages/llms/src/index.ts +++ b/packages/llms/src/index.ts @@ -56,9 +56,9 @@ export class LLM extends EventTarget { // retry settings { maxRetries: this.config.maxRetries, - onRetry: (current: number) => { + onRetry: (attempt: number) => { this.dispatchEvent( - new CustomEvent('retry', { detail: { current, max: this.config.maxRetries } }) + new CustomEvent('retry', { detail: { attempt, maxAttempts: this.config.maxRetries } }) ) }, onError: (error: Error) => { @@ -73,15 +73,15 @@ async function withRetry( fn: () => Promise, settings: { maxRetries: number - onRetry: (retries: number) => void + onRetry: (attempt: number) => void onError: (error: Error) => void } ): Promise { - let retries = 0 + let attempt = 0 let lastError: Error | null = null - while (retries <= settings.maxRetries) { - if (retries > 0) { - settings.onRetry(retries) + while (attempt <= settings.maxRetries) { + if (attempt > 0) { + settings.onRetry(attempt) await new Promise((resolve) => setTimeout(resolve, 100)) } @@ -98,7 +98,7 @@ async function withRetry( if (error instanceof InvokeError && !error.retryable) throw error lastError = error as Error - retries++ + attempt++ await new Promise((resolve) => setTimeout(resolve, 100)) } diff --git a/packages/page-agent/src/PageAgent.ts b/packages/page-agent/src/PageAgent.ts index f92776f..c808d31 100644 --- a/packages/page-agent/src/PageAgent.ts +++ b/packages/page-agent/src/PageAgent.ts @@ -4,7 +4,6 @@ */ import { LLM, type Tool } from '@page-agent/llms' import { PageController } from '@page-agent/page-controller' -import { Panel } from '@page-agent/ui' import chalk from 'chalk' import zod from 'zod' @@ -85,30 +84,69 @@ export interface UserTakeoverEvent { 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 */ -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 { success: boolean data: string - history: HistoryEvent[] + history: HistoricalEvent[] } export class PageAgent extends EventTarget { config: PageAgentConfig id = uid() - panel: Panel | null = null tools: typeof tools disposed = false task = '' 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 + #llm: LLM #abortController = new AbortController() - #llmRetryListener: ((e: Event) => void) | null = null - #llmErrorListener: ((e: Event) => void) | null = null #beforeUnloadListener: ((e: Event) => void) | null = null /** PageController for DOM operations */ @@ -123,24 +161,13 @@ export class PageAgent extends EventTarget { } /** History events */ - history: HistoryEvent[] = [] + history: HistoricalEvent[] = [] constructor(config: PageAgentConfig) { super() this.config = 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) // Initialize PageController with config (mask enabled by default) @@ -149,17 +176,32 @@ export class PageAgent extends EventTarget { enableMask: this.config.enableMask ?? true, }) - // Listen to LLM events - this.#llmRetryListener = (e) => { - const { current, max } = (e as CustomEvent).detail - this.panel?.update({ type: 'retry', current, max }) - } - this.#llmErrorListener = (e) => { + // Listen to LLM retry events + this.#llm.addEventListener('retry', (e) => { + const { attempt, maxAttempts } = (e as CustomEvent).detail + this.emitActivity({ type: 'retrying', attempt, maxAttempts }) + // Also push to history for panel rendering + 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 - this.panel?.update({ type: 'error', message: `step failed: ${error.message}` }) - } - this.#llm.addEventListener('retry', this.#llmRetryListener) - this.#llm.addEventListener('error', this.#llmErrorListener) + const message = String(error) + this.emitActivity({ type: 'error', message }) + // Also push to history for panel rendering + this.history.push({ + type: 'error', + errorType: 'error', + message, + }) + this.#emitHistoryChange() + }) if (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') } - // 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) => { if (!this.disposed) this.dispose('PAGE_UNLOADING') } 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. * This will be visible in and remain in memory across steps. */ pushObservation(content: string): void { this.history.push({ type: 'observation', content }) - this.panel?.update({ type: 'observation', content }) + this.#emitHistoryChange() } async execute(task: string): Promise { @@ -200,6 +268,11 @@ export class PageAgent extends EventTarget { this.task = task 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 onAfterStep = this.config.onAfterStep || (() => void 0) const onBeforeTask = this.config.onBeforeTask || (() => void 0) @@ -207,20 +280,17 @@ export class PageAgent extends EventTarget { await onBeforeTask.call(this) - // Show mask and panel + // Show mask this.pageController.showMask() - this.panel?.show() - this.panel?.reset() - - this.panel?.update({ type: 'input', task: this.task }) - if (this.#abortController) { this.#abortController.abort() this.#abortController = new AbortController() } this.history = [] + this.#setStatus('running') + this.#emitHistoryChange() // Reset states this.states = { @@ -241,9 +311,9 @@ export class PageAgent extends EventTarget { // abort if (this.#abortController.signal.aborted) throw new Error('AbortError') - // Update status to thinking + // Thinking console.log(chalk.blue('Thinking...')) - this.panel?.update({ type: 'thinking' }) + this.emitActivity({ type: 'thinking' }) const result = await this.#llm.invoke( [ @@ -285,6 +355,7 @@ export class PageAgent extends EventTarget { action, usage: result.usage, } as AgentStep) + this.#emitHistoryChange() console.log(chalk.green('Step finished:'), actionName) console.groupEnd() @@ -318,10 +389,12 @@ export class PageAgent extends EventTarget { } } catch (error: unknown) { 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 = { success: false, - data: String(error), + data: errorMessage, history: this.history, } await onAfterTask.call(this, result) @@ -381,7 +454,6 @@ export class PageAgent extends EventTarget { if (reflectionText) { console.log(reflectionText) - this.panel?.update({ type: 'thinking', text: reflectionText }) } // 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!!!)`) 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() @@ -399,23 +473,20 @@ export class PageAgent extends EventTarget { const duration = Date.now() - startTime 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 if (toolName !== 'wait') { 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 { input, @@ -551,6 +622,9 @@ export class PageAgent extends EventTarget { prompt += `${event.content}\n` } else if (event.type === 'user_takeover') { prompt += `User took over control and made changes to the page.\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) { 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.#setStatus(success ? 'completed' : 'error') this.#abortController.abort() } @@ -604,26 +667,18 @@ export class PageAgent extends EventTarget { console.log('Disposing PageAgent...') this.disposed = true this.pageController.dispose() - this.panel?.dispose() this.history = [] 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 if (this.#beforeUnloadListener) { window.removeEventListener('beforeunload', this.#beforeUnloadListener) this.#beforeUnloadListener = null } + // Emit dispose event for UI cleanup + this.dispatchEvent(new Event('dispose')) + this.config.onDispose?.call(this, reason) } } diff --git a/packages/page-agent/src/config/index.ts b/packages/page-agent/src/config/index.ts index af2647c..6141443 100644 --- a/packages/page-agent/src/config/index.ts +++ b/packages/page-agent/src/config/index.ts @@ -1,37 +1,18 @@ import type { LLMConfig } from '@page-agent/llms' import type { PageControllerConfig } from '@page-agent/page-controller' -import type { SupportedLanguage } from '@page-agent/ui' import type { ExecutionResult, HistoryEvent, PageAgent } from '../PageAgent' import type { PageAgentTool } from '../tools' export type { LLMConfig } +/** Supported UI languages */ +export type SupportedLanguage = 'en-US' | 'zh-CN' + export interface AgentConfig { // theme?: 'light' | 'dark' 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 * @experimental diff --git a/packages/page-agent/src/iife.ts b/packages/page-agent/src/iife.ts index 3ab3cb5..def6180 100644 --- a/packages/page-agent/src/iife.ts +++ b/packages/page-agent/src/iife.ts @@ -1,6 +1,8 @@ /** * 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' // 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 setTimeout(() => { const currentScript = document.currentScript as HTMLScriptElement | null + let config: PageAgentConfig + if (currentScript) { console.log('๐Ÿš€ page-agent.js detected current script:', currentScript.src) const url = new URL(currentScript.src) @@ -31,23 +35,22 @@ setTimeout(() => { const baseURL = url.searchParams.get('baseURL') || DEMO_BASE_URL const apiKey = url.searchParams.get('apiKey') || DEMO_API_KEY const language = (url.searchParams.get('lang') as 'zh-CN' | 'en-US') || 'zh-CN' - const config: PageAgentConfig = { model, baseURL, apiKey, language } - window.pageAgent = new PageAgent(config) + config = { model, baseURL, apiKey, language } } else { console.log('๐Ÿš€ page-agent.js no current script detected, using default demo config') - const config: PageAgentConfig = { - // model: DEMO_MODEL, - // baseURL: DEMO_BASE_URL, - // apiKey: DEMO_API_KEY, - + config = { 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, 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) }) diff --git a/packages/page-agent/src/tools/index.ts b/packages/page-agent/src/tools/index.ts index caf7200..0d6b36e 100644 --- a/packages/page-agent/src/tools/index.ts +++ b/packages/page-agent/src/tools/index.ts @@ -80,11 +80,11 @@ tools.set( question: zod.string(), }), execute: async function (this: PageAgent, input) { - if (!this.panel) { - throw new Error('ask_user tool requires panel to be enabled') + if (!this.onAskUser) { + throw new Error('ask_user tool requires onAskUser callback to be set') } - const answer = await this.panel.askUser(input.question) - return `โœ… Received user answer: ${answer}` + const answer = await this.onAskUser(input.question) + return `User answered: ${answer}` }, }) ) diff --git a/packages/ui/src/Panel.module.css b/packages/ui/src/Panel.module.css index 4e807b7..6863fa7 100644 --- a/packages/ui/src/Panel.module.css +++ b/packages/ui/src/Panel.module.css @@ -357,6 +357,11 @@ 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 ๆˆๅŠŸ็ป“ๆžœ */ &.doneSuccess { background: linear-gradient( @@ -439,7 +444,7 @@ .historyContent { display: flex; - align-items: center; + align-items: flex-start; gap: 8px; word-break: break-all; @@ -453,6 +458,12 @@ line-height: 1; transition: all 0.3s ease; } + + .reflectionLines { + display: flex; + flex-direction: column; + gap: 4px; + } } .historyMeta { diff --git a/packages/ui/src/Panel.ts b/packages/ui/src/Panel.ts index 8a2b602..1e9f1ec 100644 --- a/packages/ui/src/Panel.ts +++ b/packages/ui/src/Panel.ts @@ -1,6 +1,7 @@ -import { type Step, UIState } from './UIState' +import { createCard, createReflectionLines, formatTime } from './cards' 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' @@ -9,8 +10,6 @@ import styles from './Panel.module.css' */ export interface PanelConfig { language?: SupportedLanguage - onExecuteTask: (task: string) => void - onStop: () => void /** * Whether to prompt for next task after task completion * @default true @@ -18,24 +17,15 @@ export interface PanelConfig { 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 + * + * 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 { #wrapper: HTMLElement @@ -47,9 +37,9 @@ export class Panel { #inputSection: HTMLElement #taskInput: HTMLInputElement - #state = new UIState() - #isExpanded = false + #agent: PanelAgentAdapter #config: PanelConfig + #isExpanded = false #i18n: I18n #userAnswerResolver: ((input: string) => void) | null = null #isWaitingForUserAnswer: boolean = false @@ -57,13 +47,30 @@ export class Panel { #pendingHeaderText: string | null = null #isAnimating = false + // Event handlers (bound for removal) + #onStatusChange = () => this.#handleStatusChange() + #onHistoryChange = () => this.#handleHistoryChange() + #onActivity = (e: Event) => this.#handleActivity((e as CustomEvent).detail) + #onAgentDispose = () => this.dispose() + get wrapper(): HTMLElement { 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.#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.#indicator = this.#wrapper.querySelector(`.${styles.indicator}`)! this.#statusText = this.#wrapper.querySelector(`.${styles.statusText}`)! @@ -73,6 +80,12 @@ export class Panel { this.#inputSection = this.#wrapper.querySelector(`.${styles.inputSectionWrapper}`)! 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.#startHeaderUpdateLoop() @@ -81,24 +94,98 @@ export class Panel { 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 { + #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 { return new Promise((resolve) => { // Set `waiting for user answer` state this.#isWaitingForUserAnswer = true this.#userAnswerResolver = resolve - // Update state to `running` - this.#updateInternal({ - type: 'output', - displayText: this.#i18n.t('ui.panel.question', { question }), - }) // Expand history panel + // Expand history panel if (!this.#isExpanded) { 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')) }) } @@ -119,10 +206,9 @@ export class Panel { } reset(): void { - this.#state.reset() this.#statusText.textContent = this.#i18n.t('ui.panel.ready') this.#updateStatusIndicator('thinking') - this.#updateHistory() + this.#renderHistory() this.#collapse() // Reset user input state this.#isWaitingForUserAnswer = false @@ -140,17 +226,16 @@ export class Panel { } /** - * Update panel with semantic data - i18n handled internally - */ - update(data: PanelUpdate): void { - const stepData = this.#toStepData(data) - this.#updateInternal(stepData) - } - - /** - * Dispose panel + * Dispose panel and clean up event listeners */ 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.#stopHeaderUpdateLoop() this.wrapper.remove() @@ -158,69 +243,21 @@ export class Panel { // ========== Private methods ========== - /** - * Convert semantic update to step data with i18n - */ - #toStepData(data: PanelUpdate): Omit { - 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 { + #getToolExecutingText(toolName: string, args: unknown): string { + const a = args as Record switch (toolName) { 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': - return this.#i18n.t('ui.tools.inputting', { index: args.index }) + return this.#i18n.t('ui.tools.inputting', { index: a.index }) 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': return this.#i18n.t('ui.tools.scrolling') 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': return this.#i18n.t('ui.tools.done') 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): 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 */ #stopAgent(): void { - // Update status display - this.#updateInternal({ - type: 'error', - displayText: this.#i18n.t('ui.panel.taskTerminated'), - }) - - this.#config.onStop() + this.#agent.dispose() } /** @@ -305,7 +286,8 @@ export class Panel { // Handle user input mode this.#handleUserAnswer(input) } else { - this.#config.onExecuteTask(input) + // Execute task via agent + this.#agent.execute(input) } } @@ -313,10 +295,11 @@ export class Panel { * Handle user answer */ #handleUserAnswer(input: string): void { - // Add user input to history - this.#updateInternal({ - type: 'input', - displayText: this.#i18n.t('ui.panel.userAnswer', { input }), + // Remove temporary question cards (only direct children for safety) + Array.from(this.#historySection.children).forEach((child) => { + if (child.getAttribute('data-temp-card') === 'true') { + child.remove() + } }) // Reset state @@ -357,13 +340,13 @@ export class Panel { // Always show input area if waiting for user input if (this.#isWaitingForUserAnswer) return true - const steps = this.#state.getAllSteps() - if (steps.length === 0) { + const history = this.#agent.history + if (history.length === 0) { return true // Initial state } - const lastStep = steps[steps.length - 1] - const isTaskEnded = lastStep.type === 'completed' || lastStep.type === 'error' + const status = this.#agent.status + const isTaskEnded = status === 'completed' || status === 'error' // Only show input area after task completion if configured to do so if (isTaskEnded) { @@ -383,13 +366,12 @@ export class Panel {
- ${this.#createHistoryItem({ - id: 'placeholder', - stepNumber: 0, - timestamp: new Date(), - type: 'thinking', - displayText: this.#i18n.t('ui.panel.waitingPlaceholder'), - })} +
+
+ ๐Ÿง  + ${this.#i18n.t('ui.panel.waitingPlaceholder')} +
+
@@ -544,7 +526,9 @@ export class Panel { }, 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 this.#indicator.className = styles.indicator @@ -552,12 +536,6 @@ export class Panel { 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 { // Execute in next event loop to ensure DOM update completion setTimeout(() => { @@ -565,71 +543,107 @@ export class Panel { }, 0) } - #createHistoryItem(step: Step): string { - const time = step.timestamp.toLocaleTimeString('zh-CN', { - hour12: false, - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }) + /** + * Render history directly from agent.history + * + * Renders: + * 1. Task (first item, from agent.task) + * 2. Reflection cards (evaluation, memory, next_goal) + * 3. Tool execution with output + * 4. Observations + */ + #renderHistory(): void { + const items: string[] = [] - let typeClass = '' - let statusIcon = '' - - // Set styles and icons based on step type - 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 = '๐Ÿง ' + // 1. Task card (always first) + const task = this.#agent.task + if (task) { + items.push(this.#createTaskCard(task)) } - const durationText = step.duration ? ` ยท ${step.duration}ms` : '' - const stepLabel = this.#i18n.t('ui.panel.step', { - number: step.stepNumber.toString(), + // 2. Render each history event + const history = this.#agent.history + 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, - duration: durationText || '', // Explicitly pass empty string to replace template + duration: '', }) - return ` -
-
- ${statusIcon} - ${escapeHtml(step.displayText)} -
-
- ${stepLabel} -
-
- ` + if (event.type === 'step') { + // Reflection card + if (event.reflection) { + const lines = createReflectionLines(event.reflection) + if (lines.length > 0) { + cards.push(createCard({ icon: '๐Ÿง ', content: lines, meta })) + } + } + + // 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 } } diff --git a/packages/ui/src/UIState.ts b/packages/ui/src/UIState.ts deleted file mode 100644 index 1782e1b..0000000 --- a/packages/ui/src/UIState.ts +++ /dev/null @@ -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 { - 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 | 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)}` - } -} diff --git a/packages/ui/src/cards.ts b/packages/ui/src/cards.ts new file mode 100644 index 0000000..a6006c0 --- /dev/null +++ b/packages/ui/src/cards.ts @@ -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) + ? `
${content.join('')}
` + : `${escapeHtml(content)}` + + return ` +
+
+ ${icon} + ${contentHtml} +
+ ${meta ? `
${meta}
` : ''} +
+ ` +} + +/** 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(`
๐Ÿ” ${escapeHtml(reflection.evaluation_previous_goal)}
`) + } + if (reflection.memory) { + lines.push(`
๐Ÿ’พ ${escapeHtml(reflection.memory)}
`) + } + if (reflection.next_goal) { + lines.push(`
๐ŸŽฏ ${escapeHtml(reflection.next_goal)}
`) + } + return lines +} diff --git a/packages/ui/src/i18n/locales.ts b/packages/ui/src/i18n/locales.ts index b8cf62e..70a02e8 100644 --- a/packages/ui/src/i18n/locales.ts +++ b/packages/ui/src/i18n/locales.ts @@ -22,6 +22,7 @@ const enUS = { selecting: 'Selecting option "{{text}}"...', scrolling: 'Scrolling page...', waiting: 'Waiting {{seconds}} seconds...', + askingUser: 'Asking user...', done: 'Task done', clicked: '๐Ÿ–ฑ๏ธ Clicked element [{{index}}]', inputted: 'โŒจ๏ธ Inputted text "{{text}}"', @@ -68,6 +69,7 @@ const zhCN = { selecting: 'ๆญฃๅœจ้€‰ๆ‹ฉ้€‰้กน "{{text}}"...', scrolling: 'ๆญฃๅœจๆปšๅŠจ้กต้ข...', waiting: '็ญ‰ๅพ… {{seconds}} ็ง’...', + askingUser: 'ๆญฃๅœจ่ฏข้—ฎ็”จๆˆท...', done: '็ป“ๆŸไปปๅŠก', clicked: '๐Ÿ–ฑ๏ธ ๅทฒ็‚นๅ‡ปๅ…ƒ็ด  [{{index}}]', inputted: 'โŒจ๏ธ ๅทฒ่พ“ๅ…ฅๆ–‡ๆœฌ "{{text}}"', diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 7e42775..afe4052 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,3 +1,3 @@ -export { Panel, type PanelConfig, type PanelUpdate } from './Panel' -export { UIState, type Step, type AgentStatus } from './UIState' +export { Panel, type PanelConfig } from './Panel' +export type { AgentActivity, PanelAgentAdapter } from './types' export { I18n, type SupportedLanguage, type TranslationKey } from './i18n' diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts new file mode 100644 index 0000000..1347851 --- /dev/null +++ b/packages/ui/src/types.ts @@ -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 + */ +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 + + /** Execute a task */ + execute(task: string): Promise + + /** Dispose the agent */ + dispose(): void +} diff --git a/packages/website/src/pages/Home.tsx b/packages/website/src/pages/Home.tsx index ba2c37a..9955387 100644 --- a/packages/website/src/pages/Home.tsx +++ b/packages/website/src/pages/Home.tsx @@ -1,4 +1,5 @@ /* 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 { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -94,6 +95,10 @@ export default function HomePage() { // promptForNextTask: 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)