feat(ext): draft extension structure (single-page mode)
This commit is contained in:
178
packages/extension/src/entrypoints/background.ts
Normal file
178
packages/extension/src/entrypoints/background.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Background Script Entry Point
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
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,
|
||||
} 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
|
||||
|
||||
// LLM configuration (persisted in storage)
|
||||
interface LLMConfig {
|
||||
apiKey: string
|
||||
baseURL: string
|
||||
model: string
|
||||
}
|
||||
|
||||
// Default to demo config
|
||||
let llmConfig: LLMConfig = {
|
||||
apiKey: DEMO_API_KEY,
|
||||
baseURL: DEMO_BASE_URL,
|
||||
model: DEMO_MODEL,
|
||||
}
|
||||
|
||||
export default defineBackground(() => {
|
||||
console.log('[PageAgentExt] Background script started')
|
||||
|
||||
// Load saved config from storage
|
||||
loadConfig()
|
||||
|
||||
// Register command handlers
|
||||
registerCommandHandlers()
|
||||
|
||||
// Open sidepanel on action click
|
||||
chrome.sidePanel
|
||||
.setPanelBehavior({ openPanelOnActionClick: true })
|
||||
.catch((error) => console.error('[PageAgentExt] Failed to set panel behavior:', error))
|
||||
})
|
||||
|
||||
/**
|
||||
* Load LLM configuration from storage (falls back to demo config)
|
||||
*/
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
|
||||
const newAgent = new PageAgentCore({
|
||||
...llmConfig,
|
||||
pageController: pageController as any, // Type assertion for interface compatibility
|
||||
language: 'en-US',
|
||||
})
|
||||
|
||||
// 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
|
||||
}
|
||||
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)
|
||||
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')
|
||||
}
|
||||
99
packages/extension/src/entrypoints/content.ts
Normal file
99
packages/extension/src/entrypoints/content.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Content Script Entry Point
|
||||
*
|
||||
* This script runs in the context of web pages and hosts the real PageController.
|
||||
* It listens for RPC messages from Background and dispatches them to PageController.
|
||||
*/
|
||||
import { PageController } from '@page-agent/page-controller'
|
||||
|
||||
import { pageControllerRPC } from '../messaging/protocol'
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
runAt: 'document_idle',
|
||||
|
||||
main() {
|
||||
console.log('[PageAgentExt] Content script loaded')
|
||||
|
||||
// Create PageController instance with mask enabled
|
||||
const controller = new PageController({
|
||||
enableMask: true,
|
||||
})
|
||||
|
||||
// Register RPC handlers
|
||||
registerRPCHandlers(controller)
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
controller.dispose()
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Register all RPC message handlers for PageController methods
|
||||
*/
|
||||
function registerRPCHandlers(controller: PageController): void {
|
||||
// State queries
|
||||
pageControllerRPC.onMessage('rpc:getCurrentUrl', async () => {
|
||||
return controller.getCurrentUrl()
|
||||
})
|
||||
|
||||
pageControllerRPC.onMessage('rpc:getLastUpdateTime', async () => {
|
||||
return controller.getLastUpdateTime()
|
||||
})
|
||||
|
||||
pageControllerRPC.onMessage('rpc:getBrowserState', async () => {
|
||||
return controller.getBrowserState()
|
||||
})
|
||||
|
||||
// DOM operations
|
||||
pageControllerRPC.onMessage('rpc:updateTree', async () => {
|
||||
return controller.updateTree()
|
||||
})
|
||||
|
||||
pageControllerRPC.onMessage('rpc:cleanUpHighlights', async () => {
|
||||
await controller.cleanUpHighlights()
|
||||
})
|
||||
|
||||
// Element actions
|
||||
pageControllerRPC.onMessage('rpc:clickElement', async ({ data: index }) => {
|
||||
return controller.clickElement(index)
|
||||
})
|
||||
|
||||
pageControllerRPC.onMessage('rpc:inputText', async ({ data }) => {
|
||||
return controller.inputText(data.index, data.text)
|
||||
})
|
||||
|
||||
pageControllerRPC.onMessage('rpc:selectOption', async ({ data }) => {
|
||||
return controller.selectOption(data.index, data.optionText)
|
||||
})
|
||||
|
||||
pageControllerRPC.onMessage('rpc:scroll', async ({ data: options }) => {
|
||||
return controller.scroll(options)
|
||||
})
|
||||
|
||||
pageControllerRPC.onMessage('rpc:scrollHorizontally', async ({ data: options }) => {
|
||||
return controller.scrollHorizontally(options)
|
||||
})
|
||||
|
||||
pageControllerRPC.onMessage('rpc:executeJavascript', async ({ data: script }) => {
|
||||
return controller.executeJavascript(script)
|
||||
})
|
||||
|
||||
// Mask operations
|
||||
pageControllerRPC.onMessage('rpc:showMask', async () => {
|
||||
await controller.showMask()
|
||||
})
|
||||
|
||||
pageControllerRPC.onMessage('rpc:hideMask', async () => {
|
||||
await controller.hideMask()
|
||||
})
|
||||
|
||||
// Lifecycle
|
||||
pageControllerRPC.onMessage('rpc:dispose', async () => {
|
||||
controller.dispose()
|
||||
})
|
||||
|
||||
console.log('[PageAgentExt] RPC handlers registered')
|
||||
}
|
||||
490
packages/extension/src/entrypoints/sidepanel/App.tsx
Normal file
490
packages/extension/src/entrypoints/sidepanel/App.tsx
Normal file
@@ -0,0 +1,490 @@
|
||||
import {
|
||||
ArrowRight,
|
||||
Bot,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Send,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Square,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupTextarea,
|
||||
} from '@/components/ui/input-group'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { subscribeToEvents } from '@/messaging/events'
|
||||
import { agentCommands } from '@/messaging/protocol'
|
||||
import type { AgentActivity, AgentState, AgentStatus, HistoricalEvent } from '@/messaging/protocol'
|
||||
import { DEMO_API_KEY, DEMO_BASE_URL, DEMO_MODEL } from '@/utils/constants'
|
||||
|
||||
// Configuration panel component
|
||||
function ConfigPanel({ onClose }: { onClose: () => void }) {
|
||||
const [apiKey, setApiKey] = useState(DEMO_API_KEY)
|
||||
const [baseURL, setBaseURL] = useState(DEMO_BASE_URL)
|
||||
const [model, setModel] = useState(DEMO_MODEL)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
chrome.storage.local.get('llmConfig').then((result) => {
|
||||
const config = result.llmConfig as
|
||||
| { apiKey?: string; baseURL?: string; model?: string }
|
||||
| undefined
|
||||
if (config) {
|
||||
setApiKey(config.apiKey || DEMO_API_KEY)
|
||||
setBaseURL(config.baseURL || DEMO_BASE_URL)
|
||||
setModel(config.model || DEMO_MODEL)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await agentCommands.sendMessage('agent:configure', { apiKey, baseURL, model })
|
||||
onClose()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<h2 className="text-base font-semibold">Settings</h2>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">API Key</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="sk-..."
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="text-xs h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">Base URL</label>
|
||||
<Input
|
||||
placeholder="https://api.openai.com/v1"
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
className="text-xs h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">Model</label>
|
||||
<Input
|
||||
placeholder="gpt-4o"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="text-xs h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button variant="outline" onClick={onClose} className="flex-1 h-8 text-xs">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving} className="flex-1 h-8 text-xs">
|
||||
{saving ? <Loader2 className="size-3 animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Result card for done action
|
||||
function ResultCard({ success, text }: { success: boolean; text: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-3',
|
||||
success ? 'border-green-500/30 bg-green-500/10' : 'border-destructive/30 bg-destructive/10'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
{success ? (
|
||||
<CheckCircle className="size-3.5 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="size-3.5 text-destructive" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-medium',
|
||||
success ? 'text-green-600 dark:text-green-400' : 'text-destructive'
|
||||
)}
|
||||
>
|
||||
Result: {success ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground pl-5 whitespace-pre-wrap">{text}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Reflection section in step card
|
||||
function ReflectionSection({
|
||||
reflection,
|
||||
}: {
|
||||
reflection: {
|
||||
evaluation_previous_goal?: string
|
||||
memory?: string
|
||||
next_goal?: string
|
||||
}
|
||||
}) {
|
||||
const items = [
|
||||
{ icon: '✅', label: 'eval', value: reflection.evaluation_previous_goal },
|
||||
{ icon: '💾', label: 'memory', value: reflection.memory },
|
||||
{ icon: '🎯', label: 'goal', value: reflection.next_goal },
|
||||
].filter((item) => item.value)
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide mb-1">
|
||||
Reflection
|
||||
</div>
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5">
|
||||
{items.map((item) => (
|
||||
<Fragment key={item.label}>
|
||||
<span className="text-xs">{item.icon}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.value}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// History event card component
|
||||
function EventCard({ event }: { event: HistoricalEvent }) {
|
||||
// Done action - show as result card
|
||||
if (event.type === 'step' && event.action?.name === 'done') {
|
||||
const input = event.action.input as { text?: string; success?: boolean }
|
||||
return (
|
||||
<ResultCard
|
||||
success={input?.success ?? true}
|
||||
text={input?.text || event.action.output || ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (event.type === 'step') {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-2.5">
|
||||
{/* Reflection */}
|
||||
{event.reflection && <ReflectionSection reflection={event.reflection} />}
|
||||
|
||||
{/* Action */}
|
||||
{event.action && (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide mb-1">
|
||||
{event.action.name}
|
||||
</div>
|
||||
<div className="flex items-start gap-1.5">
|
||||
<ArrowRight className="size-3 text-blue-500 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-muted-foreground mb-0.5">
|
||||
{JSON.stringify(event.action.input)}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground/70">→ {event.action.output}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (event.type === 'observation') {
|
||||
return (
|
||||
<div className="flex items-start gap-1.5 rounded-lg border bg-card p-2.5">
|
||||
<MessageSquare className="size-3 text-green-500 shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-muted-foreground">{event.content}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (event.type === 'error') {
|
||||
return (
|
||||
<div className="flex items-start gap-1.5 rounded-lg border border-destructive/30 bg-destructive/10 p-2.5">
|
||||
<XCircle className="size-3 text-destructive shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-destructive">{event.message}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Activity card with animation
|
||||
function ActivityCard({ activity }: { activity: AgentActivity }) {
|
||||
const getActivityInfo = () => {
|
||||
switch (activity.type) {
|
||||
case 'thinking':
|
||||
return { text: 'Thinking...', color: 'text-blue-500' }
|
||||
case 'executing':
|
||||
return { text: `Executing ${activity.tool}...`, color: 'text-amber-500' }
|
||||
case 'executed':
|
||||
return { text: `Done: ${activity.tool}`, color: 'text-green-500' }
|
||||
case 'retrying':
|
||||
return {
|
||||
text: `Retrying (${activity.attempt}/${activity.maxAttempts})...`,
|
||||
color: 'text-amber-500',
|
||||
}
|
||||
case 'error':
|
||||
return { text: activity.message, color: 'text-destructive' }
|
||||
}
|
||||
}
|
||||
|
||||
const info = getActivityInfo()
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-card/50 p-2.5 animate-pulse">
|
||||
<div className="relative">
|
||||
<Sparkles className={cn('size-3.5', info.color)} />
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -top-0.5 -right-0.5 size-1.5 rounded-full animate-ping',
|
||||
activity.type === 'thinking'
|
||||
? 'bg-blue-500'
|
||||
: activity.type === 'executing'
|
||||
? 'bg-amber-500'
|
||||
: activity.type === 'retrying'
|
||||
? 'bg-amber-500'
|
||||
: activity.type === 'error'
|
||||
? 'bg-destructive'
|
||||
: 'bg-green-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span className={cn('text-xs', info.color)}>{info.text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Status dot indicator
|
||||
function StatusDot({ status }: { status: AgentStatus }) {
|
||||
const colorClass = {
|
||||
idle: 'bg-muted-foreground',
|
||||
running: 'bg-blue-500',
|
||||
completed: 'bg-green-500',
|
||||
error: 'bg-destructive',
|
||||
}[status]
|
||||
|
||||
const label = {
|
||||
idle: 'Ready',
|
||||
running: 'Running',
|
||||
completed: 'Done',
|
||||
error: 'Error',
|
||||
}[status]
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn('size-2 rounded-full', colorClass, status === 'running' && 'animate-pulse')}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Logo component (Bot icon as placeholder until real logo is added)
|
||||
function Logo({ className }: { className?: string }) {
|
||||
return <Bot className={cn('text-primary', className)} />
|
||||
}
|
||||
|
||||
// Empty state with logo
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-6">
|
||||
<Logo className="size-20 opacity-80" />
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-foreground">Page Agent Ext</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">Enter a task to automate this page</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [showConfig, setShowConfig] = useState(false)
|
||||
const [task, setTask] = useState('')
|
||||
const [status, setStatus] = useState<AgentStatus>('idle')
|
||||
const [history, setHistory] = useState<HistoricalEvent[]>([])
|
||||
const [activity, setActivity] = useState<AgentActivity | null>(null)
|
||||
const [currentTask, setCurrentTask] = useState('')
|
||||
const historyRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Subscribe to agent events
|
||||
useEffect(() => {
|
||||
// Initialize with demo config if not set
|
||||
chrome.storage.local.get('llmConfig').then((result) => {
|
||||
if (!result.llmConfig) {
|
||||
chrome.storage.local.set({
|
||||
llmConfig: { apiKey: DEMO_API_KEY, baseURL: DEMO_BASE_URL, model: DEMO_MODEL },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const unsubscribe = subscribeToEvents({
|
||||
onStatus: (newStatus) => {
|
||||
setStatus(newStatus)
|
||||
if (newStatus === 'idle' || newStatus === 'completed' || newStatus === 'error') {
|
||||
setActivity(null)
|
||||
}
|
||||
},
|
||||
onHistory: (newHistory) => {
|
||||
setHistory(newHistory)
|
||||
},
|
||||
onActivity: (newActivity) => {
|
||||
setActivity(newActivity)
|
||||
},
|
||||
onStateSnapshot: (state) => {
|
||||
setStatus(state.status)
|
||||
setHistory(state.history)
|
||||
setCurrentTask(state.task)
|
||||
},
|
||||
})
|
||||
|
||||
// Get initial state
|
||||
agentCommands.sendMessage('agent:getState', undefined).then((state: AgentState) => {
|
||||
setStatus(state.status)
|
||||
setHistory(state.history)
|
||||
setCurrentTask(state.task)
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
// Auto-scroll to bottom on new events
|
||||
useEffect(() => {
|
||||
if (historyRef.current) {
|
||||
historyRef.current.scrollTop = historyRef.current.scrollHeight
|
||||
}
|
||||
}, [history, activity])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e?: React.FormEvent) => {
|
||||
e?.preventDefault()
|
||||
if (!task.trim() || status === 'running') return
|
||||
|
||||
setCurrentTask(task)
|
||||
setHistory([])
|
||||
await agentCommands.sendMessage('agent:execute', task)
|
||||
setTask('')
|
||||
},
|
||||
[task, status]
|
||||
)
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
await agentCommands.sendMessage('agent:stop', undefined)
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
if (showConfig) {
|
||||
return <ConfigPanel onClose={() => setShowConfig(false)} />
|
||||
}
|
||||
|
||||
const isRunning = status === 'running'
|
||||
const showEmptyState = !currentTask && history.length === 0 && !isRunning
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Logo className="size-5" />
|
||||
<span className="text-sm font-medium">Page Agent Ext</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusDot status={status} />
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => setShowConfig(true)}>
|
||||
<Settings className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{/* Current task */}
|
||||
{currentTask && (
|
||||
<div className="border-b px-3 py-2 bg-muted/30">
|
||||
<div className="text-[10px] text-muted-foreground uppercase tracking-wide">Task</div>
|
||||
<div className="text-xs font-medium truncate" title={currentTask}>
|
||||
{currentTask}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
<div ref={historyRef} className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{showEmptyState && <EmptyState />}
|
||||
|
||||
{history.map((event, index) => (
|
||||
<EventCard key={index} event={event} />
|
||||
))}
|
||||
|
||||
{/* Activity indicator at bottom */}
|
||||
{activity && <ActivityCard activity={activity} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t p-3">
|
||||
<InputGroup className="relative">
|
||||
<InputGroupTextarea
|
||||
ref={textareaRef}
|
||||
placeholder="Describe your task... (Enter to send)"
|
||||
value={task}
|
||||
onChange={(e) => setTask(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isRunning}
|
||||
rows={2}
|
||||
className="text-xs pr-12 min-h-[60px]"
|
||||
/>
|
||||
<InputGroupAddon align="inline-end" className="absolute bottom-2 right-2">
|
||||
{isRunning ? (
|
||||
<InputGroupButton
|
||||
size="icon-sm"
|
||||
variant="destructive"
|
||||
onClick={handleStop}
|
||||
className="size-7"
|
||||
>
|
||||
<Square className="size-3" />
|
||||
</InputGroupButton>
|
||||
) : (
|
||||
<InputGroupButton
|
||||
size="icon-sm"
|
||||
variant="default"
|
||||
onClick={() => handleSubmit()}
|
||||
disabled={!task.trim()}
|
||||
className="size-7"
|
||||
>
|
||||
<Send className="size-3" />
|
||||
</InputGroupButton>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
packages/extension/src/entrypoints/sidepanel/index.html
Normal file
12
packages/extension/src/entrypoints/sidepanel/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Page Agent</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
12
packages/extension/src/entrypoints/sidepanel/main.tsx
Normal file
12
packages/extension/src/entrypoints/sidepanel/main.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
|
||||
import App from './App'
|
||||
|
||||
import '@/assets/index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
Reference in New Issue
Block a user