feat(ext): style; code split

This commit is contained in:
Simon
2026-01-20 21:13:44 +08:00
parent f500445965
commit 4200c78327
4 changed files with 387 additions and 351 deletions

View File

@@ -1,367 +1,21 @@
import { import { Send, Settings, Square } from 'lucide-react'
ArrowRight, import { useCallback, useEffect, useRef, useState } from 'react'
CheckCircle,
ChevronDown,
ChevronRight,
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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { import {
InputGroup, InputGroup,
InputGroupAddon, InputGroupAddon,
InputGroupButton, InputGroupButton,
InputGroupTextarea, InputGroupTextarea,
} from '@/components/ui/input-group' } from '@/components/ui/input-group'
import { cn } from '@/lib/utils'
import { subscribeToEvents } from '@/messaging/events' import { subscribeToEvents } from '@/messaging/events'
import { agentCommands } from '@/messaging/protocol' import { agentCommands } from '@/messaging/protocol'
import type { AgentActivity, AgentState, AgentStatus, HistoricalEvent } from '@/messaging/protocol' import type { AgentActivity, AgentState, AgentStatus, HistoricalEvent } from '@/messaging/protocol'
import { DEMO_API_KEY, DEMO_BASE_URL, DEMO_MODEL } from '@/utils/constants' import { DEMO_API_KEY, DEMO_BASE_URL, DEMO_MODEL } from '@/utils/constants'
// Configuration panel component import { EmptyState, Logo, StatusDot } from './components'
function ConfigPanel({ onClose }: { onClose: () => void }) { import { ConfigPanel } from './components/ConfigPanel'
const [apiKey, setApiKey] = useState(DEMO_API_KEY) import { ActivityCard, EventCard } from './components/cards'
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,
children,
}: {
success: boolean
text: string
children?: React.ReactNode
}) {
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>
{children}
</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>
)
}
// Raw response section (collapsible, for debugging)
function RawResponseSection({ rawResponse }: { rawResponse: unknown }) {
const [expanded, setExpanded] = useState(false)
return (
<div className="mt-2 border-t border-dashed pt-2">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
>
{expanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
<span>Raw Response</span>
</button>
{expanded && (
<pre
className="mt-1.5 p-2 text-[10px] bg-muted/50 rounded overflow-x-auto max-h-60 overflow-y-auto select-all"
style={{ userSelect: 'all' }}
>
{JSON.stringify(rawResponse, null, 4)}
</pre>
)}
</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 (
<div>
<ResultCard
success={input?.success ?? true}
text={input?.text || event.action.output || ''}
>
{!event.rawResponse || <RawResponseSection rawResponse={event.rawResponse as any} />}
</ResultCard>
</div>
)
}
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>
)}
{/* Raw Response */}
{!event.rawResponse || <RawResponseSection rawResponse={event.rawResponse as any} />}
</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="rounded-lg border border-destructive/30 bg-destructive/10 p-2.5">
<div className="flex items-start gap-1.5">
<XCircle className="size-3 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">{event.message}</span>
</div>
{!event.rawResponse || <RawResponseSection rawResponse={event.rawResponse as any} />}
</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>
)
}
function Logo({ className }: { className?: string }) {
return <img src="/assets/page-agent-256.webp" alt="Page Agent" className={cn('', 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() { export default function App() {
const [showConfig, setShowConfig] = useState(false) const [showConfig, setShowConfig] = useState(false)

View File

@@ -0,0 +1,82 @@
import { Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { agentCommands } from '@/messaging'
// Configuration panel component
export 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>
)
}

View File

@@ -0,0 +1,255 @@
import {
CheckCircle,
ChevronDown,
ChevronRight,
Eye,
Globe,
Keyboard,
Mouse,
MoveVertical,
Sparkles,
XCircle,
Zap,
} from 'lucide-react'
import { Fragment } from 'react/jsx-runtime'
import { cn } from '@/lib/utils'
import { AgentActivity, HistoricalEvent } from '@/messaging'
// Result card for done action
function ResultCard({
success,
text,
children,
}: {
success: boolean
text: string
children?: React.ReactNode
}) {
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-2">
{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>
{children}
</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-3">
<div className="text-[11px] font-semibold text-foreground uppercase tracking-wide mb-2">
Reflection
</div>
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-2">
{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>
)
}
// Get icon for action type
function ActionIcon({ name, className }: { name: string; className?: string }) {
const icons: Record<string, React.ReactNode> = {
click_element_by_index: <Mouse className={className} />,
input: <Keyboard className={className} />,
scroll: <MoveVertical className={className} />,
go_to_url: <Globe className={className} />,
}
return icons[name] || <Zap className={className} />
}
// Raw response section (collapsible, for debugging)
function RawResponseSection({ rawResponse }: { rawResponse: unknown }) {
const [expanded, setExpanded] = useState(false)
return (
<div className="mt-2 border-t border-dashed pt-2">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
>
{expanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
<span>Raw Response</span>
</button>
{expanded && (
<pre
className="mt-1.5 p-2 text-[10px] bg-muted/50 rounded overflow-x-auto max-h-60 overflow-y-auto select-all"
style={{ userSelect: 'all' }}
>
{JSON.stringify(rawResponse, null, 4)}
</pre>
)}
</div>
)
}
// History event card component
export 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 (
<div>
<ResultCard
success={input?.success ?? true}
text={input?.text || event.action.output || ''}
>
{!event.rawResponse || <RawResponseSection rawResponse={event.rawResponse as any} />}
</ResultCard>
</div>
)
}
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-[11px] font-semibold text-foreground uppercase tracking-wide mb-2">
Action
</div>
<div className="flex items-start gap-2">
<ActionIcon
name={event.action.name}
className="size-3.5 text-blue-500 shrink-0 mt-0.5"
/>
<div className="flex-1 min-w-0">
<p className="text-xs text-foreground/80 mb-0.5">
<span className="font-medium">{event.action.name}</span>
<span className="text-muted-foreground ml-1.5">
{JSON.stringify(event.action.input)}
</span>
</p>
<p className="text-[11px] text-muted-foreground/70"> {event.action.output}</p>
</div>
</div>
</div>
)}
{/* Raw Response */}
{!event.rawResponse || <RawResponseSection rawResponse={event.rawResponse as any} />}
</div>
)
}
if (event.type === 'observation') {
return (
<div className="rounded-lg border bg-card p-2.5">
<div className="text-[11px] font-semibold text-foreground uppercase tracking-wide mb-2">
Observation
</div>
<div className="flex items-start gap-2">
<Eye className="size-3.5 text-green-500 shrink-0 mt-0.5" />
<span className="text-xs text-muted-foreground">{event.content}</span>
</div>
</div>
)
}
if (event.type === 'error') {
return (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-2.5">
<div className="flex items-start gap-1.5">
<XCircle className="size-3 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">{event.message}</span>
</div>
{!event.rawResponse || <RawResponseSection rawResponse={event.rawResponse as any} />}
</div>
)
}
return null
}
// Activity card with animation
export 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>
)
}

View File

@@ -0,0 +1,45 @@
import { cn } from '@/lib/utils'
import { AgentStatus } from '@/messaging'
// Status dot indicator
export 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>
)
}
export function Logo({ className }: { className?: string }) {
return <img src="/assets/page-agent-256.webp" alt="Page Agent" className={cn('', className)} />
}
// Empty state with logo
export 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>
)
}