feat: multi tabs control
This commit is contained in:
@@ -1,259 +1,191 @@
|
||||
/**
|
||||
* Background Script Entry Point
|
||||
* Background Script (Service Worker) - Stateless Message Relay
|
||||
*
|
||||
* This script runs as the extension's service worker and hosts:
|
||||
* - PageAgentCore (headless agent)
|
||||
* - RemotePageController (proxy to ContentScript)
|
||||
* - Command handlers for SidePanel
|
||||
* - Event broadcasting to SidePanel
|
||||
* MV3 COMPLIANT: This script is completely stateless.
|
||||
* It only relays messages between contexts:
|
||||
* - SidePanel ↔ ContentScript (RPC for PageController)
|
||||
* - ContentScript → SidePanel (queries like shouldShowMask)
|
||||
* - Tab events → SidePanel (chrome.tabs API events)
|
||||
*
|
||||
* NO agent logic, NO state, NO long-running operations.
|
||||
*/
|
||||
import { PageAgentCore } from '@page-agent/core'
|
||||
|
||||
import { RemotePageController } from '../agent/RemotePageController'
|
||||
import { eventBroadcaster } from '../messaging/events'
|
||||
import {
|
||||
type AgentActivity,
|
||||
type AgentState,
|
||||
type AgentStatus,
|
||||
type HistoricalEvent,
|
||||
agentCommands,
|
||||
contentScriptQuery,
|
||||
type CSQueryMessage,
|
||||
type CSRPCMessage,
|
||||
type ExtensionMessage,
|
||||
type QueryResponseMessage,
|
||||
type RPCCallMessage,
|
||||
type RPCResponseMessage,
|
||||
type TabEventMessage,
|
||||
generateMessageId,
|
||||
isExtensionMessage,
|
||||
} from '../messaging/protocol'
|
||||
import { DEMO_API_KEY, DEMO_BASE_URL, DEMO_MODEL } from '../utils/constants'
|
||||
|
||||
// Agent instance (singleton for now - single page control)
|
||||
let agent: PageAgentCore | null = null
|
||||
// Track the target tab ID for event filtering
|
||||
let targetTabId: number | null = null
|
||||
// ============================================================================
|
||||
// Message Relay Handlers
|
||||
// ============================================================================
|
||||
|
||||
// LLM configuration (persisted in storage)
|
||||
interface LLMConfig {
|
||||
apiKey: string
|
||||
baseURL: string
|
||||
model: string
|
||||
/**
|
||||
* Handle messages from SidePanel and ContentScript
|
||||
*/
|
||||
chrome.runtime.onMessage.addListener(
|
||||
(
|
||||
message: unknown,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response?: unknown) => void
|
||||
): boolean => {
|
||||
if (!isExtensionMessage(message)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const msg = message as ExtensionMessage
|
||||
|
||||
switch (msg.type) {
|
||||
case 'rpc:call':
|
||||
// SidePanel → SW: Forward RPC to content script
|
||||
handleRPCCall(msg as RPCCallMessage)
|
||||
return false // No sync response needed
|
||||
|
||||
case 'cs:query':
|
||||
// ContentScript → SW: Forward query to sidepanel
|
||||
handleCSQuery(msg as CSQueryMessage, sender)
|
||||
return false
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Forward RPC call from SidePanel to ContentScript
|
||||
*/
|
||||
async function handleRPCCall(msg: RPCCallMessage): Promise<void> {
|
||||
const { id, tabId, method, args } = msg
|
||||
|
||||
// Create message for content script
|
||||
const csMessage: CSRPCMessage = {
|
||||
type: 'cs:rpc',
|
||||
id,
|
||||
method,
|
||||
args,
|
||||
}
|
||||
|
||||
try {
|
||||
// Send to content script and wait for response
|
||||
const result = await chrome.tabs.sendMessage(tabId, csMessage)
|
||||
|
||||
// Forward response back to sidepanel
|
||||
const response: RPCResponseMessage = {
|
||||
type: 'rpc:response',
|
||||
id,
|
||||
success: true,
|
||||
result,
|
||||
}
|
||||
await chrome.runtime.sendMessage(response)
|
||||
} catch (error) {
|
||||
// Forward error back to sidepanel
|
||||
const response: RPCResponseMessage = {
|
||||
type: 'rpc:response',
|
||||
id,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
await chrome.runtime.sendMessage(response).catch(() => {
|
||||
// Sidepanel may be closed
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Default to demo config
|
||||
let llmConfig: LLMConfig = {
|
||||
apiKey: DEMO_API_KEY,
|
||||
baseURL: DEMO_BASE_URL,
|
||||
model: DEMO_MODEL,
|
||||
/**
|
||||
* Forward query from ContentScript to SidePanel
|
||||
*/
|
||||
async function handleCSQuery(
|
||||
msg: CSQueryMessage,
|
||||
sender: chrome.runtime.MessageSender
|
||||
): Promise<void> {
|
||||
const { id, queryType, tabId } = msg
|
||||
|
||||
// For shouldShowMask, we need to ask the sidepanel
|
||||
// Since sidepanel may not be open, we'll use a timeout approach
|
||||
// The sidepanel registers a listener for these queries
|
||||
|
||||
try {
|
||||
// Broadcast to sidepanel (it will respond via query:response)
|
||||
const response = await chrome.runtime.sendMessage(msg)
|
||||
|
||||
// Forward response back to content script
|
||||
if (sender.tab?.id) {
|
||||
const queryResponse: QueryResponseMessage = {
|
||||
type: 'query:response',
|
||||
id,
|
||||
result: response,
|
||||
}
|
||||
await chrome.tabs.sendMessage(sender.tab.id, queryResponse)
|
||||
}
|
||||
} catch (error) {
|
||||
// Sidepanel not open or no response, return default
|
||||
if (sender.tab?.id) {
|
||||
const queryResponse: QueryResponseMessage = {
|
||||
type: 'query:response',
|
||||
id,
|
||||
result: queryType === 'shouldShowMask' ? false : null,
|
||||
}
|
||||
await chrome.tabs.sendMessage(sender.tab.id, queryResponse).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineBackground(() => {
|
||||
console.log('[PageAgentExt] Background script started')
|
||||
// ============================================================================
|
||||
// Tab Event Forwarding
|
||||
// ============================================================================
|
||||
|
||||
// Load saved config from storage
|
||||
loadConfig()
|
||||
|
||||
// Register command handlers
|
||||
registerCommandHandlers()
|
||||
|
||||
// Register tab event listeners for page reload/close detection
|
||||
registerTabEventListeners()
|
||||
|
||||
// Register content script notification handlers
|
||||
registerContentScriptHandlers()
|
||||
|
||||
// Open sidepanel on action click
|
||||
chrome.sidePanel
|
||||
.setPanelBehavior({ openPanelOnActionClick: true })
|
||||
.catch((error) => console.error('[PageAgentExt] Failed to set panel behavior:', error))
|
||||
/**
|
||||
* Forward tab removed events to sidepanel
|
||||
*/
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
const message: TabEventMessage = {
|
||||
type: 'tab:event',
|
||||
id: generateMessageId(),
|
||||
eventType: 'removed',
|
||||
tabId,
|
||||
}
|
||||
chrome.runtime.sendMessage(message).catch(() => {
|
||||
// Sidepanel may not be open
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Load LLM configuration from storage (falls back to demo config)
|
||||
* Forward tab updated events to sidepanel
|
||||
*/
|
||||
async function loadConfig(): Promise<void> {
|
||||
const result = await chrome.storage.local.get('llmConfig')
|
||||
if (result.llmConfig) {
|
||||
llmConfig = result.llmConfig as LLMConfig
|
||||
console.log('[PageAgentExt] Loaded LLM config from storage')
|
||||
} else {
|
||||
console.log('[PageAgentExt] Using default demo config')
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
|
||||
// Only forward loading/complete status changes
|
||||
if (!changeInfo.status) return
|
||||
|
||||
const message: TabEventMessage = {
|
||||
type: 'tab:event',
|
||||
id: generateMessageId(),
|
||||
eventType: 'updated',
|
||||
tabId,
|
||||
data: {
|
||||
status: changeInfo.status,
|
||||
url: changeInfo.url,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save LLM configuration to storage
|
||||
*/
|
||||
async function saveConfig(config: LLMConfig): Promise<void> {
|
||||
llmConfig = config
|
||||
await chrome.storage.local.set({ llmConfig: config })
|
||||
console.log('[PageAgentExt] Saved LLM config')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current agent state snapshot
|
||||
*/
|
||||
function getAgentState(): AgentState {
|
||||
if (!agent) {
|
||||
return {
|
||||
status: 'idle',
|
||||
task: '',
|
||||
history: [],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: agent.status as AgentStatus,
|
||||
task: agent.task,
|
||||
history: agent.history as HistoricalEvent[],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and configure agent instance
|
||||
*/
|
||||
function createAgent(): PageAgentCore {
|
||||
const pageController = new RemotePageController()
|
||||
|
||||
// Track the target tab ID for event filtering
|
||||
pageController.tabIdPromise.then((tabId) => {
|
||||
targetTabId = tabId
|
||||
console.log('[PageAgentExt] Tracking tab:', tabId)
|
||||
chrome.runtime.sendMessage(message).catch(() => {
|
||||
// Sidepanel may not be open
|
||||
})
|
||||
})
|
||||
|
||||
const newAgent = new PageAgentCore({
|
||||
...llmConfig,
|
||||
pageController: pageController as any, // Type assertion for interface compatibility
|
||||
language: 'en-US',
|
||||
// ============================================================================
|
||||
// Extension Setup
|
||||
// ============================================================================
|
||||
|
||||
export default defineBackground(() => {
|
||||
console.log('[Background] Service Worker started (stateless relay mode)')
|
||||
|
||||
// Open sidepanel on action click
|
||||
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {
|
||||
// Side panel may not be supported
|
||||
})
|
||||
|
||||
// Forward agent events to SidePanel
|
||||
newAgent.addEventListener('statuschange', () => {
|
||||
eventBroadcaster.status(newAgent.status as AgentStatus)
|
||||
})
|
||||
|
||||
newAgent.addEventListener('historychange', () => {
|
||||
eventBroadcaster.history(newAgent.history as HistoricalEvent[])
|
||||
})
|
||||
|
||||
newAgent.addEventListener('activity', (e) => {
|
||||
const activity = (e as CustomEvent).detail as AgentActivity
|
||||
eventBroadcaster.activity(activity)
|
||||
})
|
||||
|
||||
newAgent.addEventListener('dispose', () => {
|
||||
if (agent === newAgent) {
|
||||
agent = null
|
||||
targetTabId = null
|
||||
}
|
||||
eventBroadcaster.status('idle')
|
||||
})
|
||||
|
||||
return newAgent
|
||||
}
|
||||
|
||||
/**
|
||||
* Register command handlers for SidePanel communication
|
||||
*/
|
||||
function registerCommandHandlers(): void {
|
||||
// Execute task
|
||||
agentCommands.onMessage('agent:execute', async ({ data: task }) => {
|
||||
console.log('[PageAgentExt] Executing task:', task)
|
||||
|
||||
// Create new agent if needed
|
||||
if (!agent || agent.disposed) {
|
||||
agent = createAgent()
|
||||
}
|
||||
|
||||
// Execute task (don't await - runs in background)
|
||||
agent.execute(task).catch((error) => {
|
||||
console.error('[PageAgentExt] Task execution error:', error)
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
// Broadcast error as a history event so it persists in UI
|
||||
const errorEvent: HistoricalEvent = { type: 'error', message }
|
||||
eventBroadcaster.history([errorEvent])
|
||||
eventBroadcaster.status('error')
|
||||
})
|
||||
})
|
||||
|
||||
// Stop agent
|
||||
agentCommands.onMessage('agent:stop', async () => {
|
||||
console.log('[PageAgentExt] Stopping agent')
|
||||
if (agent) {
|
||||
agent.dispose('User requested stop')
|
||||
agent = null
|
||||
}
|
||||
})
|
||||
|
||||
// Get current state
|
||||
agentCommands.onMessage('agent:getState', async () => {
|
||||
return getAgentState()
|
||||
})
|
||||
|
||||
// Configure LLM
|
||||
agentCommands.onMessage('agent:configure', async ({ data: config }) => {
|
||||
await saveConfig(config)
|
||||
|
||||
// Recreate agent with new config if it exists
|
||||
if (agent && !agent.disposed) {
|
||||
agent.dispose('Configuration changed')
|
||||
agent = null
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[PageAgentExt] Command handlers registered')
|
||||
}
|
||||
|
||||
/**
|
||||
* Register tab event listeners for detecting page reload/navigation/close
|
||||
*/
|
||||
function registerTabEventListeners(): void {
|
||||
// Listen for tab updates (page reload, navigation)
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, _tab) => {
|
||||
// Only handle events for the target tab when agent is running
|
||||
if (!agent || agent.disposed || tabId !== targetTabId) return
|
||||
|
||||
if (changeInfo.status === 'loading') {
|
||||
// Page is reloading or navigating
|
||||
console.log('[PageAgentExt] Target page is reloading/navigating')
|
||||
agent.pushObservation(
|
||||
'⚠️ Page is reloading. DOM state will change - wait for page to stabilize before next action.'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for tab close
|
||||
chrome.tabs.onRemoved.addListener((tabId, _removeInfo) => {
|
||||
// Only handle events for the target tab when agent is running
|
||||
if (!agent || agent.disposed || tabId !== targetTabId) return
|
||||
|
||||
console.log('[PageAgentExt] Target page was closed')
|
||||
agent.pushObservation(
|
||||
'⚠️ Target page was closed by user. If this page is required for the task, consider marking the task as failed.'
|
||||
)
|
||||
// Clear target tab ID since it no longer exists
|
||||
targetTabId = null
|
||||
})
|
||||
|
||||
console.log('[PageAgentExt] Tab event listeners registered')
|
||||
}
|
||||
|
||||
/**
|
||||
* Register handlers for content script queries
|
||||
*/
|
||||
function registerContentScriptHandlers(): void {
|
||||
// Handle shouldShowMask query - content script asks if mask should be shown
|
||||
contentScriptQuery.onMessage('content:shouldShowMask', async ({ sender }) => {
|
||||
const tabId = sender.tab?.id
|
||||
// Check if there's an active task for this tab
|
||||
const shouldShow = Boolean(tabId && agent && !agent.disposed && tabId === targetTabId)
|
||||
console.log('[PageAgentExt] shouldShowMask query:', { tabId, targetTabId, shouldShow })
|
||||
return shouldShow
|
||||
})
|
||||
|
||||
// Handle content script errors - broadcast to sidepanel for user visibility
|
||||
contentScriptQuery.onMessage('content:error', async ({ data }) => {
|
||||
console.error('[PageAgentExt] Content script error:', data.message, 'on', data.url)
|
||||
// Broadcast error to sidepanel
|
||||
const errorEvent: HistoricalEvent = {
|
||||
type: 'error',
|
||||
message: `Content script error on ${data.url}: ${data.message}`,
|
||||
}
|
||||
eventBroadcaster.history([errorEvent])
|
||||
})
|
||||
|
||||
console.log('[PageAgentExt] Content script handlers registered')
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user