refactor(ext): rewrite ext. totally-broken -> still-broken; THIS IS NOT WORKING
This commit is contained in:
@@ -1,14 +1,8 @@
|
||||
/**
|
||||
* AgentController - Manages agent lifecycle in SidePanel context
|
||||
*
|
||||
* This class encapsulates all agent logic, keeping it isolated from the React UI.
|
||||
* It runs entirely in the SidePanel frontend context, using the Background Script
|
||||
* only as a stateless message relay for communicating with content scripts.
|
||||
*
|
||||
* Design goals:
|
||||
* - Agent state lives here, not in Service Worker
|
||||
* - SW is only a relay - no agent logic there
|
||||
* - Future-proof: can be moved to other contexts (e.g., a controlling web page)
|
||||
* Agent state lives here, SW is only a relay.
|
||||
* Mask visibility is managed via chrome.storage (content scripts poll it).
|
||||
*/
|
||||
import { PageAgentCore } from '@page-agent/core'
|
||||
import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } from '@page-agent/core'
|
||||
@@ -16,8 +10,7 @@ import type { AgentActivity, AgentStatus, ExecutionResult, HistoricalEvent } fro
|
||||
import { DEMO_API_KEY, DEMO_BASE_URL, DEMO_MODEL } from '../utils/constants'
|
||||
import { RemotePageController } from './RemotePageController'
|
||||
import { type TabInfo, TabsManager } from './TabsManager'
|
||||
import type { TabEventMessage } from './protocol'
|
||||
import { isExtensionMessage } from './protocol'
|
||||
import type { AgentState as StorageAgentState } from './protocol'
|
||||
import { createTabTools } from './tabTools'
|
||||
|
||||
/** LLM configuration */
|
||||
@@ -34,16 +27,6 @@ export interface AgentState {
|
||||
history: HistoricalEvent[]
|
||||
}
|
||||
|
||||
/** Event types emitted by AgentController */
|
||||
export interface AgentControllerEvents {
|
||||
statuschange: AgentStatus
|
||||
historychange: HistoricalEvent[]
|
||||
activity: AgentActivity
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tab list for browser state header
|
||||
*/
|
||||
function formatTabListHeader(tabs: TabInfo[], currentTabId: number | null): string {
|
||||
if (tabs.length === 0) return ''
|
||||
|
||||
@@ -74,102 +57,53 @@ function formatTabListHeader(tabs: TabInfo[], currentTabId: number | null): stri
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* AgentController manages the agent lifecycle in the SidePanel.
|
||||
* Emits events for React UI to subscribe to.
|
||||
*/
|
||||
export class AgentController extends EventTarget {
|
||||
private agent: PageAgentCore | null = null
|
||||
private tabsManager: TabsManager | null = null
|
||||
private pageController: RemotePageController | null = null
|
||||
private llmConfig: LLMConfig
|
||||
|
||||
/** Current task being executed */
|
||||
currentTask = ''
|
||||
|
||||
// ===== Mask State Management =====
|
||||
/** Browser's currently active tab (the one user sees) */
|
||||
private browserActiveTabId: number | null = null
|
||||
/** Whether the browser window has focus */
|
||||
private windowHasFocus = true
|
||||
/** Bound handler for tab events */
|
||||
private tabEventHandler: (message: unknown) => void
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
// Default to demo config
|
||||
this.llmConfig = {
|
||||
apiKey: DEMO_API_KEY,
|
||||
baseURL: DEMO_BASE_URL,
|
||||
model: DEMO_MODEL,
|
||||
}
|
||||
// Bind tab event handler
|
||||
this.tabEventHandler = this.handleTabEvent.bind(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize controller and load saved config
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
await this.loadConfig()
|
||||
|
||||
// Initialize browser active tab
|
||||
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true })
|
||||
if (activeTab?.id) {
|
||||
this.browserActiveTabId = activeTab.id
|
||||
}
|
||||
|
||||
// Register tab event listener
|
||||
chrome.runtime.onMessage.addListener(this.tabEventHandler)
|
||||
|
||||
console.log('[AgentController] Initialized, browserActiveTabId:', this.browserActiveTabId)
|
||||
this.updateStorageState(null, false)
|
||||
console.log('[AgentController] Initialized')
|
||||
}
|
||||
|
||||
/**
|
||||
* Load LLM configuration from storage
|
||||
*/
|
||||
private async loadConfig(): Promise<void> {
|
||||
const result = await chrome.storage.local.get('llmConfig')
|
||||
if (result.llmConfig) {
|
||||
this.llmConfig = result.llmConfig as LLMConfig
|
||||
console.log('[AgentController] Loaded LLM config from storage')
|
||||
} else {
|
||||
console.log('[AgentController] Using default demo config')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save LLM configuration to storage
|
||||
*/
|
||||
async configure(config: LLMConfig): Promise<void> {
|
||||
this.llmConfig = config
|
||||
await chrome.storage.local.set({ llmConfig: config })
|
||||
console.log('[AgentController] Saved LLM config')
|
||||
|
||||
// Dispose existing agent if any
|
||||
if (this.agent && !this.agent.disposed) {
|
||||
this.agent.dispose()
|
||||
this.agent = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current LLM config
|
||||
*/
|
||||
getConfig(): LLMConfig {
|
||||
return { ...this.llmConfig }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current agent state
|
||||
*/
|
||||
getState(): AgentState {
|
||||
if (!this.agent) {
|
||||
return {
|
||||
status: 'idle',
|
||||
task: '',
|
||||
history: [],
|
||||
}
|
||||
return { status: 'idle', task: '', history: [] }
|
||||
}
|
||||
return {
|
||||
status: this.agent.status,
|
||||
@@ -178,86 +112,64 @@ export class AgentController extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current agent status
|
||||
*/
|
||||
get status(): AgentStatus {
|
||||
return this.agent?.status ?? 'idle'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent history
|
||||
*/
|
||||
get history(): HistoricalEvent[] {
|
||||
return this.agent?.history ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tab is managed by this controller
|
||||
*/
|
||||
isTabManaged(tabId: number): boolean {
|
||||
return this.tabsManager?.isTabManaged(tabId) ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current tab ID
|
||||
*/
|
||||
getCurrentTabId(): number | null {
|
||||
return this.tabsManager?.getCurrentTabId() ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if mask should be shown for a specific tab.
|
||||
* Used by content script queries on page load.
|
||||
*/
|
||||
shouldShowMaskForTab(tabId: number): boolean {
|
||||
const agentCurrentTabId = this.tabsManager?.getCurrentTabId()
|
||||
const isRunning = this.status === 'running'
|
||||
const isBrowserActiveTab = this.browserActiveTabId === tabId
|
||||
const isAgentCurrentTab = agentCurrentTabId === tabId
|
||||
const shouldShow = isRunning && this.windowHasFocus && isBrowserActiveTab && isAgentCurrentTab
|
||||
|
||||
console.debug('[AgentController] shouldShowMaskForTab:', {
|
||||
queryTabId: tabId,
|
||||
agentStatus: this.status,
|
||||
isRunning,
|
||||
windowHasFocus: this.windowHasFocus,
|
||||
browserActiveTabId: this.browserActiveTabId,
|
||||
isBrowserActiveTab,
|
||||
agentCurrentTabId,
|
||||
isAgentCurrentTab,
|
||||
shouldShow,
|
||||
})
|
||||
|
||||
return shouldShow
|
||||
/** Update storage state (fire-and-forget, no need to await) */
|
||||
private updateStorageState(tabId: number | null, running: boolean): void {
|
||||
const agentState: StorageAgentState = { tabId, running }
|
||||
chrome.storage.local.set({ agentState })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and configure agent instance
|
||||
*/
|
||||
private async createAgent(): Promise<PageAgentCore> {
|
||||
// Create page controller
|
||||
this.pageController = new RemotePageController()
|
||||
/** Synchronously dispose current agent and clear state */
|
||||
private disposeCurrentAgent(): void {
|
||||
if (this.agent && !this.agent.disposed) {
|
||||
this.agent.dispose()
|
||||
}
|
||||
if (this.tabsManager) {
|
||||
this.tabsManager.dispose()
|
||||
}
|
||||
this.agent = null
|
||||
this.tabsManager = null
|
||||
this.pageController = null
|
||||
this.updateStorageState(null, false)
|
||||
}
|
||||
|
||||
// Create tabs manager
|
||||
private async createAgent(): Promise<PageAgentCore> {
|
||||
this.pageController = new RemotePageController()
|
||||
this.tabsManager = new TabsManager()
|
||||
|
||||
// Generate task ID
|
||||
const taskId = Math.random().toString(36).slice(2, 10)
|
||||
|
||||
// Initialize tabs manager
|
||||
await this.tabsManager.init(taskId, this.pageController)
|
||||
// Pass callback to update storage when tab changes
|
||||
await this.tabsManager.init(taskId, this.pageController, (tabId) => {
|
||||
this.updateStorageState(tabId, true)
|
||||
})
|
||||
|
||||
// Create tab tools
|
||||
const tabTools = createTabTools(this.tabsManager)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
const controller = this
|
||||
|
||||
const newAgent = new PageAgentCore({
|
||||
...this.llmConfig,
|
||||
pageController: this.createPageControllerProxy(this.pageController, this.tabsManager) as any,
|
||||
language: 'en-US',
|
||||
customTools: tabTools,
|
||||
onBeforeStep: async (agentInstance: PageAgentCore) => {
|
||||
// Check for tab changes and push observations
|
||||
if (this.tabsManager) {
|
||||
const changes = this.tabsManager.getAndClearChanges()
|
||||
|
||||
@@ -278,7 +190,6 @@ export class AgentController extends EventTarget {
|
||||
},
|
||||
})
|
||||
|
||||
// Forward agent events
|
||||
newAgent.addEventListener('statuschange', () => {
|
||||
this.dispatchEvent(new CustomEvent('statuschange', { detail: newAgent.status }))
|
||||
})
|
||||
@@ -292,19 +203,13 @@ export class AgentController extends EventTarget {
|
||||
this.dispatchEvent(new CustomEvent('activity', { detail: activity }))
|
||||
})
|
||||
|
||||
newAgent.addEventListener('dispose', async () => {
|
||||
console.debug('[AgentController] Agent dispose event received')
|
||||
newAgent.addEventListener('dispose', () => {
|
||||
if (this.agent === newAgent) {
|
||||
// Dispose all PageControllers on all managed tabs
|
||||
if (this.tabsManager) {
|
||||
console.debug('[AgentController] Disposing all PageControllers...')
|
||||
await this.tabsManager.disposeAllPageControllers()
|
||||
this.tabsManager.dispose()
|
||||
}
|
||||
this.tabsManager?.dispose()
|
||||
this.agent = null
|
||||
this.tabsManager = null
|
||||
this.pageController = null
|
||||
console.debug('[AgentController] Agent and TabsManager disposed')
|
||||
controller.updateStorageState(null, false)
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent('statuschange', { detail: 'idle' }))
|
||||
})
|
||||
@@ -312,17 +217,11 @@ export class AgentController extends EventTarget {
|
||||
return newAgent
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a proxy for PageController that:
|
||||
* 1. Injects tab info into BrowserState.header
|
||||
* 2. Syncs mask state after setTargetTab
|
||||
*/
|
||||
/** Proxy that injects tab list into browser state header */
|
||||
private createPageControllerProxy(
|
||||
controller: RemotePageController,
|
||||
tabs: TabsManager
|
||||
): RemotePageController {
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
const agentController = this
|
||||
return new Proxy(controller, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === 'getBrowserState') {
|
||||
@@ -338,58 +237,28 @@ export class AgentController extends EventTarget {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (prop === 'setTargetTab') {
|
||||
return async function (tabId: number) {
|
||||
await target.setTargetTab(tabId)
|
||||
// Sync mask after tab switch
|
||||
await agentController.syncMaskState()
|
||||
}
|
||||
}
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a task
|
||||
*/
|
||||
async execute(task: string): Promise<ExecutionResult | null> {
|
||||
console.log('[AgentController] ===== EXECUTE TASK =====')
|
||||
console.log('[AgentController] Task:', task)
|
||||
console.log('[AgentController] Execute:', task)
|
||||
|
||||
this.currentTask = task
|
||||
|
||||
// Emit running status immediately
|
||||
this.dispatchEvent(new CustomEvent('statuschange', { detail: 'running' }))
|
||||
|
||||
try {
|
||||
// Clean up any existing agent
|
||||
if (this.agent && !this.agent.disposed) {
|
||||
console.log('[AgentController] Disposing existing agent before new task')
|
||||
this.agent.dispose()
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
}
|
||||
// Clean up previous agent synchronously
|
||||
this.disposeCurrentAgent()
|
||||
|
||||
// Clear old references
|
||||
this.agent = null
|
||||
this.tabsManager = null
|
||||
this.pageController = null
|
||||
|
||||
// Create fresh agent
|
||||
console.log('[AgentController] Creating new agent...')
|
||||
this.agent = await this.createAgent()
|
||||
console.log('[AgentController] Agent created successfully')
|
||||
// Note: storage state is updated by TabsManager.init() via onTabSwitch callback
|
||||
|
||||
// Show mask if conditions are met (agent running + tab in foreground)
|
||||
await this.syncMaskState()
|
||||
|
||||
// Execute task
|
||||
console.log('[AgentController] Starting task execution...')
|
||||
const result = await this.agent.execute(task)
|
||||
console.log('[AgentController] Task completed:', result)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('[AgentController] Task execution error:', error)
|
||||
console.error('[AgentController] Error:', error)
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('historychange', {
|
||||
@@ -401,115 +270,20 @@ export class AgentController extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop current task
|
||||
*/
|
||||
stop(): void {
|
||||
console.log('[AgentController] Stopping agent')
|
||||
if (this.agent) {
|
||||
this.agent.dispose()
|
||||
}
|
||||
console.log('[AgentController] Stop')
|
||||
this.agent?.dispose()
|
||||
}
|
||||
|
||||
// ===== Mask State Management =====
|
||||
|
||||
/**
|
||||
* Handle tab events from background script
|
||||
*/
|
||||
private handleTabEvent(message: unknown): void {
|
||||
if (!isExtensionMessage(message)) return
|
||||
if (message.type !== 'tab:event') return
|
||||
|
||||
const event = message as TabEventMessage
|
||||
|
||||
switch (event.eventType) {
|
||||
case 'activated':
|
||||
this.browserActiveTabId = event.tabId
|
||||
console.debug('[AgentController] Tab activated:', event.tabId)
|
||||
this.syncMaskState()
|
||||
break
|
||||
|
||||
case 'windowFocusChanged':
|
||||
this.windowHasFocus = event.data?.focused ?? false
|
||||
console.debug('[AgentController] Window focus changed:', this.windowHasFocus)
|
||||
this.syncMaskState()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate whether mask should be visible.
|
||||
* Mask is shown only when:
|
||||
* 1. Agent is running
|
||||
* 2. Window has focus
|
||||
* 3. Browser's active tab === agent's current tab
|
||||
*/
|
||||
private get shouldMaskBeVisible(): boolean {
|
||||
const agentCurrentTabId = this.tabsManager?.getCurrentTabId()
|
||||
return (
|
||||
this.status === 'running' &&
|
||||
this.windowHasFocus &&
|
||||
this.browserActiveTabId !== null &&
|
||||
agentCurrentTabId !== null &&
|
||||
this.browserActiveTabId === agentCurrentTabId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync mask visibility based on current state.
|
||||
* Shows mask on agent's current tab if conditions are met, hides otherwise.
|
||||
*/
|
||||
async syncMaskState(): Promise<void> {
|
||||
const agentCurrentTabId = this.tabsManager?.getCurrentTabId()
|
||||
if (!this.pageController || agentCurrentTabId === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const shouldShow = this.shouldMaskBeVisible
|
||||
console.debug('[AgentController] syncMaskState:', {
|
||||
shouldShow,
|
||||
agentCurrentTabId,
|
||||
browserActiveTabId: this.browserActiveTabId,
|
||||
windowHasFocus: this.windowHasFocus,
|
||||
status: this.status,
|
||||
})
|
||||
|
||||
try {
|
||||
if (shouldShow) {
|
||||
await this.pageController.showMask()
|
||||
} else {
|
||||
await this.pageController.hideMask()
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('[AgentController] syncMaskState failed (ignored):', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose controller and clean up
|
||||
*/
|
||||
dispose(): void {
|
||||
console.log('[AgentController] Disposing controller')
|
||||
|
||||
// Remove tab event listener
|
||||
chrome.runtime.onMessage.removeListener(this.tabEventHandler)
|
||||
|
||||
if (this.agent && !this.agent.disposed) {
|
||||
this.agent.dispose()
|
||||
}
|
||||
this.agent = null
|
||||
this.tabsManager = null
|
||||
this.pageController = null
|
||||
console.log('[AgentController] Dispose')
|
||||
this.disposeCurrentAgent()
|
||||
this.currentTask = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let controllerInstance: AgentController | null = null
|
||||
|
||||
/**
|
||||
* Get or create the AgentController singleton
|
||||
*/
|
||||
export function getAgentController(): AgentController {
|
||||
if (!controllerInstance) {
|
||||
controllerInstance = new AgentController()
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
/**
|
||||
* RemotePageController - Proxy for PageController in ContentScript
|
||||
*
|
||||
* This class implements the same interface as PageController but forwards
|
||||
* all method calls via RPC to the real PageController running in ContentScript.
|
||||
* This allows PageAgentCore to work transparently with remote DOM operations.
|
||||
*
|
||||
* Tab targeting is managed externally by TabsManager via setTargetTab().
|
||||
* Forwards method calls via RPC to the real PageController in ContentScript.
|
||||
* Mask visibility is managed by content script via storage polling.
|
||||
*/
|
||||
import type {
|
||||
ActionResult,
|
||||
@@ -15,16 +12,12 @@ import type {
|
||||
} from './protocol'
|
||||
import { type RPCClient, createRPCClient } from './rpc'
|
||||
|
||||
const DEBUG_PREFIX = '[RemotePageController]'
|
||||
|
||||
/**
|
||||
* Check if a URL can run content scripts.
|
||||
* Chrome extensions cannot inject content scripts into certain pages.
|
||||
*/
|
||||
export function isContentScriptAllowed(url: string | undefined): boolean {
|
||||
if (!url) return false
|
||||
|
||||
// Restricted URL patterns
|
||||
const restrictedPatterns = [
|
||||
/^chrome:\/\//,
|
||||
/^chrome-extension:\/\//,
|
||||
@@ -41,95 +34,50 @@ export function isContentScriptAllowed(url: string | undefined): boolean {
|
||||
return !restrictedPatterns.some((pattern) => pattern.test(url))
|
||||
}
|
||||
|
||||
/**
|
||||
* RemotePageController is a proxy that implements the PageController interface.
|
||||
* All methods are async and forward to ContentScript via RPC.
|
||||
*
|
||||
* This class extends EventTarget to maintain API compatibility with PageController,
|
||||
* though events in the remote context are not currently bridged.
|
||||
*/
|
||||
export class RemotePageController {
|
||||
private rpc: RPCClient | null = null
|
||||
private _currentTabId: number | null = null
|
||||
private _currentTabUrl: string | undefined = undefined
|
||||
private _previousTabId: number | null = null
|
||||
|
||||
/** Get the current target tab ID */
|
||||
get currentTabId(): number | null {
|
||||
return this._currentTabId
|
||||
}
|
||||
|
||||
/** Get the current target tab URL */
|
||||
get currentTabUrl(): string | undefined {
|
||||
return this._currentTabUrl
|
||||
}
|
||||
|
||||
/** Check if current tab supports content scripts */
|
||||
get isCurrentTabAccessible(): boolean {
|
||||
return isContentScriptAllowed(this._currentTabUrl)
|
||||
}
|
||||
|
||||
// Tab ID is now set externally via setTargetTab()
|
||||
|
||||
/**
|
||||
* Set the target tab for all RPC operations.
|
||||
* Called by TabsManager when switching tabs.
|
||||
* Only handles cleanup on old tab - mask control is managed by AgentController.
|
||||
*/
|
||||
async setTargetTab(tabId: number): Promise<void> {
|
||||
const previousTabId = this._currentTabId
|
||||
const previousRpc = this.rpc
|
||||
|
||||
console.debug(`${DEBUG_PREFIX} setTargetTab: ${previousTabId} → ${tabId}`)
|
||||
|
||||
// Get tab info to check URL
|
||||
const tab = await chrome.tabs.get(tabId)
|
||||
const tabUrl = tab.url
|
||||
|
||||
// Update state
|
||||
this._previousTabId = previousTabId
|
||||
this._currentTabId = tabId
|
||||
this._currentTabUrl = tabUrl
|
||||
this._currentTabUrl = tab.url
|
||||
|
||||
// Check if this tab can run content scripts
|
||||
if (!isContentScriptAllowed(tabUrl)) {
|
||||
console.debug(`${DEBUG_PREFIX} Tab ${tabId} cannot run content scripts: ${tabUrl}`)
|
||||
// Clear RPC - operations will return restricted page state
|
||||
if (!isContentScriptAllowed(tab.url)) {
|
||||
this.rpc = null
|
||||
return
|
||||
}
|
||||
|
||||
// Create new RPC client for the new tab
|
||||
this.rpc = createRPCClient(tabId)
|
||||
|
||||
// Verify content script is ready by making a test call
|
||||
// This uses the retry mechanism to wait for content script initialization
|
||||
// Verify content script is ready
|
||||
try {
|
||||
await this.rpc.getLastUpdateTime()
|
||||
console.debug(`${DEBUG_PREFIX} Content script ready on tab ${tabId}`)
|
||||
} catch (error) {
|
||||
console.error(`${DEBUG_PREFIX} Content script not ready on tab ${tabId}:`, error)
|
||||
// Don't clear rpc - subsequent calls will retry and may succeed
|
||||
} catch {
|
||||
// Don't clear rpc - subsequent calls will retry
|
||||
}
|
||||
|
||||
// Note: Mask show/hide is now controlled by AgentController.syncMaskState()
|
||||
console.debug(`${DEBUG_PREFIX} Target tab set to ${tabId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure RPC client is initialized
|
||||
* @throws Error if setTargetTab() has not been called
|
||||
*/
|
||||
private ensureInitialized(): void {
|
||||
if (!this._currentTabId) {
|
||||
throw new Error('RemotePageController not initialized. Call setTargetTab() first.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a browser state for restricted pages that cannot run content scripts.
|
||||
* Treats restricted pages as empty pages rather than errors.
|
||||
*/
|
||||
private createRestrictedPageState(): BrowserState {
|
||||
return {
|
||||
url: this._currentTabUrl || '',
|
||||
@@ -140,9 +88,6 @@ export class RemotePageController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a no-op action result for restricted pages
|
||||
*/
|
||||
private createRestrictedActionResult(action: string): ActionResult {
|
||||
return {
|
||||
success: false,
|
||||
@@ -150,157 +95,77 @@ export class RemotePageController {
|
||||
}
|
||||
}
|
||||
|
||||
// ======= State Queries =======
|
||||
|
||||
/**
|
||||
* Get current page URL
|
||||
*/
|
||||
async getCurrentUrl(): Promise<string> {
|
||||
// Can return URL even for restricted pages
|
||||
return this._currentTabUrl || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last tree update timestamp
|
||||
*/
|
||||
async getLastUpdateTime(): Promise<number> {
|
||||
if (!this.rpc) return Date.now()
|
||||
return this.rpc.getLastUpdateTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get structured browser state for LLM consumption.
|
||||
*/
|
||||
async getBrowserState(): Promise<BrowserState> {
|
||||
// Return restricted page state if content scripts cannot run
|
||||
if (!this.rpc) {
|
||||
return this.createRestrictedPageState()
|
||||
}
|
||||
return this.rpc.getBrowserState()
|
||||
}
|
||||
|
||||
// ======= DOM Tree Operations =======
|
||||
|
||||
/**
|
||||
* Update DOM tree, returns simplified HTML for LLM.
|
||||
*/
|
||||
async updateTree(): Promise<string> {
|
||||
this.ensureInitialized()
|
||||
if (!this.rpc) return '(empty page)'
|
||||
return this.rpc.updateTree()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all element highlights
|
||||
*/
|
||||
async cleanUpHighlights(): Promise<void> {
|
||||
if (!this.rpc) return
|
||||
return this.rpc.cleanUpHighlights()
|
||||
}
|
||||
|
||||
// ======= Element Actions =======
|
||||
|
||||
/**
|
||||
* Click element by index
|
||||
*/
|
||||
async clickElement(index: number): Promise<ActionResult> {
|
||||
this.ensureInitialized()
|
||||
if (!this.rpc) return this.createRestrictedActionResult('click')
|
||||
return this.rpc.clickElement(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Input text into element by index
|
||||
*/
|
||||
async inputText(index: number, text: string): Promise<ActionResult> {
|
||||
this.ensureInitialized()
|
||||
if (!this.rpc) return this.createRestrictedActionResult('input text')
|
||||
return this.rpc.inputText(index, text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select dropdown option by index and option text
|
||||
*/
|
||||
async selectOption(index: number, optionText: string): Promise<ActionResult> {
|
||||
this.ensureInitialized()
|
||||
if (!this.rpc) return this.createRestrictedActionResult('select option')
|
||||
return this.rpc.selectOption(index, optionText)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll vertically
|
||||
*/
|
||||
async scroll(options: ScrollOptions): Promise<ActionResult> {
|
||||
this.ensureInitialized()
|
||||
if (!this.rpc) return this.createRestrictedActionResult('scroll')
|
||||
return this.rpc.scroll(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll horizontally
|
||||
*/
|
||||
async scrollHorizontally(options: ScrollHorizontallyOptions): Promise<ActionResult> {
|
||||
this.ensureInitialized()
|
||||
if (!this.rpc) return this.createRestrictedActionResult('scroll')
|
||||
return this.rpc.scrollHorizontally(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute arbitrary JavaScript on the page
|
||||
*/
|
||||
async executeJavascript(script: string): Promise<ActionResult> {
|
||||
this.ensureInitialized()
|
||||
if (!this.rpc) return this.createRestrictedActionResult('execute script')
|
||||
return this.rpc.executeJavascript(script)
|
||||
}
|
||||
|
||||
// ======= Mask Operations =======
|
||||
/** @note Mask visibility is managed by content script via storage polling. */
|
||||
async showMask(): Promise<void> {}
|
||||
/** @note Mask visibility is managed by content script via storage polling. */
|
||||
async hideMask(): Promise<void> {}
|
||||
|
||||
/**
|
||||
* Show the visual mask overlay.
|
||||
*/
|
||||
async showMask(): Promise<void> {
|
||||
if (!this.rpc) return
|
||||
return this.rpc.showMask()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the visual mask overlay.
|
||||
*/
|
||||
async hideMask(): Promise<void> {
|
||||
if (!this.rpc) return
|
||||
await this.cleanUpHighlights()
|
||||
return this.rpc.hideMask()
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose and clean up resources on current tab
|
||||
*/
|
||||
/** Clear local state. Content script PageControllers clean up via storage polling. */
|
||||
dispose(): void {
|
||||
console.debug(`${DEBUG_PREFIX} dispose() called, current tab: ${this._currentTabId}`)
|
||||
if (this.rpc) {
|
||||
this.rpc.dispose().catch((e) => {
|
||||
console.debug(`${DEBUG_PREFIX} dispose RPC failed (ignored):`, e)
|
||||
})
|
||||
}
|
||||
this._currentTabId = null
|
||||
this._previousTabId = null
|
||||
this.rpc = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose PageController on a specific tab (cleanup for multi-tab scenarios)
|
||||
*/
|
||||
async disposeTab(tabId: number): Promise<void> {
|
||||
console.debug(`${DEBUG_PREFIX} disposeTab(${tabId})`)
|
||||
try {
|
||||
const rpc = createRPCClient(tabId)
|
||||
await rpc.cleanUpHighlights()
|
||||
await rpc.hideMask()
|
||||
await rpc.dispose()
|
||||
console.debug(`${DEBUG_PREFIX} Tab ${tabId} disposed successfully`)
|
||||
} catch (e) {
|
||||
console.debug(`${DEBUG_PREFIX} disposeTab(${tabId}) failed (ignored):`, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,16 +83,25 @@ export class TabsManager {
|
||||
/** Bound handler for cleanup */
|
||||
private onTabRemovedHandler: (tabId: number) => void
|
||||
|
||||
/** Callback when current tab changes */
|
||||
private onTabSwitch: ((tabId: number) => void) | null = null
|
||||
|
||||
constructor() {
|
||||
this.onTabRemovedHandler = this.onTabRemoved.bind(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the manager with current active tab
|
||||
* @param onTabSwitch - Callback when current tab changes (for storage updates)
|
||||
*/
|
||||
async init(taskId: string, pageController: RemotePageController): Promise<void> {
|
||||
async init(
|
||||
taskId: string,
|
||||
pageController: RemotePageController,
|
||||
onTabSwitch?: (tabId: number) => void
|
||||
): Promise<void> {
|
||||
this.taskId = taskId
|
||||
this.pageController = pageController
|
||||
this.onTabSwitch = onTabSwitch ?? null
|
||||
this.disposed = false
|
||||
|
||||
// Get current active tab as initial tab
|
||||
@@ -104,6 +113,8 @@ export class TabsManager {
|
||||
throw new Error('No active tab found')
|
||||
}
|
||||
|
||||
console.log(`${DEBUG_PREFIX} Initialized with tab:`, activeTab.id)
|
||||
|
||||
this.initialTabId = activeTab.id
|
||||
this.currentTabId = activeTab.id
|
||||
this.currentTabHistory = []
|
||||
@@ -118,11 +129,10 @@ export class TabsManager {
|
||||
|
||||
// Set target tab on page controller
|
||||
await pageController.setTargetTab(activeTab.id)
|
||||
this.onTabSwitch?.(activeTab.id)
|
||||
|
||||
// Register tab removal listener
|
||||
chrome.tabs.onRemoved.addListener(this.onTabRemovedHandler)
|
||||
|
||||
console.debug(`${DEBUG_PREFIX} Initialized with tab:`, activeTab.id)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,6 +274,7 @@ export class TabsManager {
|
||||
|
||||
// Update page controller target
|
||||
await this.pageController.setTargetTab(tabId)
|
||||
this.onTabSwitch?.(tabId)
|
||||
|
||||
// Update tab info cache
|
||||
const tab = await chrome.tabs.get(tabId)
|
||||
@@ -411,34 +422,10 @@ export class TabsManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose PageController on all managed tabs.
|
||||
* This cleans up highlights and masks on every tab.
|
||||
* Should be called before dispose() to ensure clean state.
|
||||
*/
|
||||
async disposeAllPageControllers(): Promise<void> {
|
||||
if (!this.pageController) return
|
||||
|
||||
const allTabIds = this.getAllManagedTabIds()
|
||||
console.debug(
|
||||
`${DEBUG_PREFIX} Disposing PageControllers on ${allTabIds.length} tabs:`,
|
||||
allTabIds
|
||||
)
|
||||
|
||||
// Dispose each tab in parallel
|
||||
await Promise.all(
|
||||
allTabIds.map((tabId) =>
|
||||
this.pageController!.disposeTab(tabId).catch((e) => {
|
||||
console.debug(`${DEBUG_PREFIX} disposeTab(${tabId}) failed:`, e)
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
console.debug(`${DEBUG_PREFIX} All PageControllers disposed`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose manager and clean up
|
||||
* Note: Tab group is intentionally kept - only internal state is cleared
|
||||
* Dispose manager and clean up.
|
||||
* Tab group is intentionally kept for user.
|
||||
* PageControllers in content scripts are not explicitly disposed - they are
|
||||
* lazy-loaded and will clean up via storage polling (running=false).
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this.disposed) return
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* Message Protocol for PageAgentExt
|
||||
*
|
||||
* MV3 Compliant Architecture:
|
||||
* - SidePanel hosts the agent, all state lives there
|
||||
* - Background (SW) is a stateless message relay
|
||||
* - Content Script runs PageController
|
||||
* Simple unidirectional architecture:
|
||||
* - AGENT_TO_PAGE: SidePanel → SW → ContentScript (RPC calls)
|
||||
* - TAB_CHANGE: SW broadcasts tab events to all extension pages
|
||||
*
|
||||
* Message flows:
|
||||
* 1. RPC: SidePanel → SW → ContentScript → sendResponse (PageController calls)
|
||||
* 2. Query: ContentScript → SW → SidePanel → SW → ContentScript (mask state check)
|
||||
* 3. Events: SW → SidePanel (tab events from chrome.tabs API)
|
||||
* Key principles:
|
||||
* - SW is stateless, only relays messages
|
||||
* - No long-lived connections
|
||||
* - All responses via sendResponse callback
|
||||
* - Content script never sends messages, only responds
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
@@ -46,117 +46,53 @@ export interface ScrollHorizontallyOptions {
|
||||
index?: number
|
||||
}
|
||||
|
||||
/** Agent state stored in chrome.storage for mask coordination */
|
||||
export interface AgentState {
|
||||
tabId: number | null
|
||||
running: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Message Types
|
||||
// Message Types (only 2)
|
||||
// ============================================================================
|
||||
|
||||
/** Message type identifier */
|
||||
type MessageType =
|
||||
| 'rpc:call' // SidePanel → SW: RPC call to content script (response via sendResponse)
|
||||
| 'cs:rpc' // SW → ContentScript: Forwarded RPC call
|
||||
| 'cs:query' // ContentScript → SW: Query to sidepanel
|
||||
| 'query:response' // SW → ContentScript: Query response
|
||||
| 'tab:event' // SW → SidePanel: Tab event notification
|
||||
export type MessageType = 'AGENT_TO_PAGE' | 'TAB_CHANGE'
|
||||
|
||||
/** Base message structure */
|
||||
interface BaseMessage {
|
||||
type: MessageType
|
||||
id: string // Unique message ID for request-response matching
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RPC Messages (SidePanel ↔ SW ↔ ContentScript)
|
||||
// ============================================================================
|
||||
|
||||
/** SidePanel → SW: Request to call PageController method */
|
||||
export interface RPCCallMessage extends BaseMessage {
|
||||
type: 'rpc:call'
|
||||
/** SidePanel → SW → ContentScript: RPC call to PageController */
|
||||
export interface AgentToPageMessage {
|
||||
type: 'AGENT_TO_PAGE'
|
||||
tabId: number
|
||||
method: string
|
||||
args: unknown[]
|
||||
}
|
||||
|
||||
/** SW → ContentScript: Forwarded RPC call */
|
||||
export interface CSRPCMessage extends BaseMessage {
|
||||
type: 'cs:rpc'
|
||||
method: string
|
||||
args: unknown[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Query Messages (ContentScript → SW → SidePanel)
|
||||
// ============================================================================
|
||||
|
||||
/** Query types that content script can ask */
|
||||
export type QueryType = 'shouldShowMask'
|
||||
|
||||
/** ContentScript → SW: Query to sidepanel */
|
||||
export interface CSQueryMessage extends BaseMessage {
|
||||
type: 'cs:query'
|
||||
queryType: QueryType
|
||||
tabId: number
|
||||
}
|
||||
|
||||
/** SW → ContentScript: Query response */
|
||||
export interface QueryResponseMessage extends BaseMessage {
|
||||
type: 'query:response'
|
||||
result: unknown
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tab Event Messages (SW → SidePanel)
|
||||
// ============================================================================
|
||||
|
||||
/** Tab event types */
|
||||
export type TabEventType = 'removed' | 'updated' | 'activated' | 'windowFocusChanged'
|
||||
|
||||
/** SW → SidePanel: Tab event notification */
|
||||
export interface TabEventMessage extends BaseMessage {
|
||||
type: 'tab:event'
|
||||
/** SW → All: Tab event broadcast */
|
||||
export interface TabChangeMessage {
|
||||
type: 'TAB_CHANGE'
|
||||
eventType: TabEventType
|
||||
tabId: number
|
||||
data?: {
|
||||
// For 'updated' events
|
||||
status?: string
|
||||
url?: string
|
||||
// For 'activated' events
|
||||
windowId?: number
|
||||
// For 'windowFocusChanged' events
|
||||
focused?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Union Types
|
||||
// ============================================================================
|
||||
|
||||
/** All message types */
|
||||
export type ExtensionMessage =
|
||||
| RPCCallMessage
|
||||
| CSRPCMessage
|
||||
| CSQueryMessage
|
||||
| QueryResponseMessage
|
||||
| TabEventMessage
|
||||
export type ExtensionMessage = AgentToPageMessage | TabChangeMessage
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// Type Guard
|
||||
// ============================================================================
|
||||
|
||||
/** Generate unique message ID */
|
||||
export function generateMessageId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
const MESSAGE_TYPES = new Set<string>(['AGENT_TO_PAGE', 'TAB_CHANGE'])
|
||||
|
||||
/** Known message types for type guard */
|
||||
const MESSAGE_TYPES = new Set<string>([
|
||||
'rpc:call',
|
||||
'cs:rpc',
|
||||
'cs:query',
|
||||
'query:response',
|
||||
'tab:event',
|
||||
])
|
||||
|
||||
/** Type guard - checks if message has a known type */
|
||||
/** Type guard - checks if message is a known extension message */
|
||||
export function isExtensionMessage(msg: unknown): msg is ExtensionMessage {
|
||||
return typeof msg === 'object' && msg !== null && MESSAGE_TYPES.has((msg as any).type)
|
||||
}
|
||||
|
||||
@@ -1,41 +1,25 @@
|
||||
/**
|
||||
* RPC Client for PageController remote calls
|
||||
*
|
||||
* This module provides RPC functionality from SidePanel to ContentScript
|
||||
* via the Background (SW) relay.
|
||||
*
|
||||
* Flow: SidePanel → SW (relay) → ContentScript → sendResponse → SidePanel
|
||||
*
|
||||
* MV3 Compliant: Uses chrome.runtime.sendMessage with direct sendResponse,
|
||||
* no pending calls map or custom response listeners needed.
|
||||
* Flow: SidePanel → SW (relay) → ContentScript → sendResponse
|
||||
*/
|
||||
import {
|
||||
type ActionResult,
|
||||
type BrowserState,
|
||||
type RPCCallMessage,
|
||||
type ScrollHorizontallyOptions,
|
||||
type ScrollOptions,
|
||||
generateMessageId,
|
||||
import type {
|
||||
ActionResult,
|
||||
AgentToPageMessage,
|
||||
BrowserState,
|
||||
ScrollHorizontallyOptions,
|
||||
ScrollOptions,
|
||||
} from './protocol'
|
||||
|
||||
/** RPC configuration */
|
||||
const RPC_CONFIG = {
|
||||
/** Maximum retry attempts for transient failures */
|
||||
maxRetries: 3,
|
||||
/** Base delay between retries in ms (exponential backoff) */
|
||||
retryDelayMs: 500,
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for a given number of milliseconds
|
||||
*/
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tab exists
|
||||
*/
|
||||
async function tabExists(tabId: number): Promise<boolean> {
|
||||
try {
|
||||
await chrome.tabs.get(tabId)
|
||||
@@ -45,9 +29,6 @@ async function tabExists(tabId: number): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when RPC call fails
|
||||
*/
|
||||
export class RPCError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -58,21 +39,15 @@ export class RPCError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Response type from background script */
|
||||
interface RPCResponse {
|
||||
success: boolean
|
||||
result?: unknown
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a single RPC call (no retry)
|
||||
* Uses chrome.runtime.sendMessage which returns the response directly via sendResponse
|
||||
*/
|
||||
async function callOnce(tabId: number, method: string, args: unknown[]): Promise<unknown> {
|
||||
const message: RPCCallMessage = {
|
||||
type: 'rpc:call',
|
||||
id: generateMessageId(),
|
||||
const message: AgentToPageMessage = {
|
||||
type: 'AGENT_TO_PAGE',
|
||||
tabId,
|
||||
method,
|
||||
args,
|
||||
@@ -87,9 +62,6 @@ async function callOnce(tabId: number, method: string, args: unknown[]): Promise
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an RPC call with retry logic
|
||||
*/
|
||||
async function call(tabId: number, method: string, args: unknown[]): Promise<unknown> {
|
||||
let lastError: Error | null = null
|
||||
|
||||
@@ -100,38 +72,33 @@ async function call(tabId: number, method: string, args: unknown[]): Promise<unk
|
||||
lastError = error as Error
|
||||
const message = lastError.message || String(error)
|
||||
|
||||
// Check if tab still exists
|
||||
if (!(await tabExists(tabId))) {
|
||||
throw new RPCError(`Tab ${tabId} was closed`, 'TAB_CLOSED')
|
||||
}
|
||||
|
||||
// Check for retryable errors
|
||||
if (
|
||||
message.includes('Could not establish connection') ||
|
||||
message.includes('Receiving end does not exist') ||
|
||||
message.includes('content script not ready')
|
||||
) {
|
||||
const delay = RPC_CONFIG.retryDelayMs * Math.pow(2, attempt)
|
||||
console.debug(
|
||||
`[RPC] Retry ${attempt + 1}/${RPC_CONFIG.maxRetries} for ${method}, waiting ${delay}ms`
|
||||
)
|
||||
console.debug(`[RPC] Retry ${attempt + 1}/${RPC_CONFIG.maxRetries} for ${method}`)
|
||||
await sleep(delay)
|
||||
continue
|
||||
}
|
||||
|
||||
// Non-retryable error
|
||||
throw lastError
|
||||
}
|
||||
}
|
||||
|
||||
throw new RPCError(
|
||||
`Content script not ready after ${RPC_CONFIG.maxRetries} attempts for ${method}`,
|
||||
`Content script not ready after ${RPC_CONFIG.maxRetries} attempts`,
|
||||
'CONTENT_SCRIPT_NOT_READY'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC client interface matching PageController methods
|
||||
* RPC client interface (no mask/dispose - content manages via storage polling)
|
||||
*/
|
||||
export interface RPCClient {
|
||||
tabId: number
|
||||
@@ -146,17 +113,9 @@ export interface RPCClient {
|
||||
scroll(options: ScrollOptions): Promise<ActionResult>
|
||||
scrollHorizontally(options: ScrollHorizontallyOptions): Promise<ActionResult>
|
||||
executeJavascript(script: string): Promise<ActionResult>
|
||||
showMask(): Promise<void>
|
||||
hideMask(): Promise<void>
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an RPC client bound to a specific tab
|
||||
*/
|
||||
export function createRPCClient(tabId: number): RPCClient {
|
||||
console.debug(`[RPC] Creating client for tab ${tabId}`)
|
||||
|
||||
return {
|
||||
tabId,
|
||||
|
||||
@@ -203,27 +162,5 @@ export function createRPCClient(tabId: number): RPCClient {
|
||||
async executeJavascript(script: string): Promise<ActionResult> {
|
||||
return call(tabId, 'executeJavascript', [script]) as Promise<ActionResult>
|
||||
},
|
||||
|
||||
async showMask(): Promise<void> {
|
||||
await call(tabId, 'showMask', [])
|
||||
},
|
||||
|
||||
async hideMask(): Promise<void> {
|
||||
// Best effort - don't throw if content script is gone
|
||||
try {
|
||||
await callOnce(tabId, 'hideMask', [])
|
||||
} catch (e) {
|
||||
console.debug('[RPC] hideMask failed (ignored):', e)
|
||||
}
|
||||
},
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
// Best effort - don't throw if content script is gone
|
||||
try {
|
||||
await callOnce(tabId, 'dispose', [])
|
||||
} catch (e) {
|
||||
console.debug('[RPC] dispose failed (ignored):', e)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
/**
|
||||
* React hook for using AgentController
|
||||
*
|
||||
* This hook provides a React-friendly interface to the AgentController,
|
||||
* handling event subscriptions and state updates.
|
||||
*/
|
||||
import type { AgentActivity, AgentStatus, HistoricalEvent } from '@page-agent/core'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { type AgentController, type LLMConfig, getAgentController } from './AgentController'
|
||||
import type { CSQueryMessage } from './protocol'
|
||||
import { isExtensionMessage } from './protocol'
|
||||
|
||||
export interface UseAgentResult {
|
||||
// State
|
||||
status: AgentStatus
|
||||
history: HistoricalEvent[]
|
||||
activity: AgentActivity | null
|
||||
currentTask: string
|
||||
config: LLMConfig
|
||||
|
||||
// Actions
|
||||
execute: (task: string) => Promise<void>
|
||||
stop: () => void
|
||||
configure: (config: LLMConfig) => Promise<void>
|
||||
@@ -37,17 +29,14 @@ export function useAgent(): UseAgentResult {
|
||||
model: '',
|
||||
})
|
||||
|
||||
// Initialize controller and subscribe to events
|
||||
useEffect(() => {
|
||||
const controller = getAgentController()
|
||||
controllerRef.current = controller
|
||||
|
||||
// Initialize
|
||||
controller.init().then(() => {
|
||||
setConfig(controller.getConfig())
|
||||
})
|
||||
|
||||
// Event handlers
|
||||
const handleStatusChange = (e: Event) => {
|
||||
const newStatus = (e as CustomEvent).detail as AgentStatus
|
||||
setStatus(newStatus)
|
||||
@@ -70,50 +59,10 @@ export function useAgent(): UseAgentResult {
|
||||
controller.addEventListener('historychange', handleHistoryChange)
|
||||
controller.addEventListener('activity', handleActivity)
|
||||
|
||||
// Handle shouldShowMask queries from content scripts
|
||||
const handleMessage = (
|
||||
message: unknown,
|
||||
_sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response?: unknown) => void
|
||||
): boolean => {
|
||||
if (!isExtensionMessage(message)) return false
|
||||
if (message.type !== 'cs:query') return false
|
||||
|
||||
const query = message as CSQueryMessage
|
||||
if (query.queryType === 'shouldShowMask') {
|
||||
const ctrl = controllerRef.current
|
||||
if (!ctrl) {
|
||||
sendResponse(false)
|
||||
return true
|
||||
}
|
||||
|
||||
// Use AgentController's shouldShowMaskForTab which checks:
|
||||
// 1. Agent is running
|
||||
// 2. Window has focus
|
||||
// 3. Browser's active tab === query.tabId
|
||||
// 4. Agent's current tab === query.tabId
|
||||
const shouldShow = ctrl.shouldShowMaskForTab(query.tabId)
|
||||
|
||||
console.debug('[useAgent] shouldShowMask query:', {
|
||||
tabId: query.tabId,
|
||||
shouldShow,
|
||||
})
|
||||
|
||||
sendResponse(shouldShow)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener(handleMessage)
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
controller.removeEventListener('statuschange', handleStatusChange)
|
||||
controller.removeEventListener('historychange', handleHistoryChange)
|
||||
controller.removeEventListener('activity', handleActivity)
|
||||
chrome.runtime.onMessage.removeListener(handleMessage)
|
||||
controller.dispose()
|
||||
}
|
||||
}, [])
|
||||
|
||||
Reference in New Issue
Block a user