feat: multi tabs control

This commit is contained in:
Simon
2026-01-24 19:29:27 +08:00
parent 2aa9c3b978
commit fa5ab9d567
17 changed files with 2303 additions and 1061 deletions

View File

@@ -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')
}
})