refactor(ext): mv ui components for later reuse
This commit is contained in:
345
packages/extension/src/components/ConfigPanel.tsx
Normal file
345
packages/extension/src/components/ConfigPanel.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
Copy,
|
||||
CornerUpLeft,
|
||||
Eye,
|
||||
EyeOff,
|
||||
HatGlasses,
|
||||
Home,
|
||||
Loader2,
|
||||
Scale,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { siGithub } from 'simple-icons'
|
||||
|
||||
import { DEMO_API_KEY, DEMO_BASE_URL, DEMO_MODEL, isTestingEndpoint } from '@/agent/constants'
|
||||
import type { ExtConfig, LanguagePreference } from '@/agent/useAgent'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
|
||||
interface ConfigPanelProps {
|
||||
config: ExtConfig | null
|
||||
onSave: (config: ExtConfig) => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) {
|
||||
const [apiKey, setApiKey] = useState(config?.apiKey || DEMO_API_KEY)
|
||||
const [baseURL, setBaseURL] = useState(config?.baseURL || DEMO_BASE_URL)
|
||||
const [model, setModel] = useState(config?.model || DEMO_MODEL)
|
||||
const [language, setLanguage] = useState<LanguagePreference>(config?.language)
|
||||
const [maxSteps, setMaxSteps] = useState<number | undefined>(config?.maxSteps)
|
||||
const [systemInstruction, setSystemInstruction] = useState(config?.systemInstruction ?? '')
|
||||
const [experimentalLlmsTxt, setExperimentalLlmsTxt] = useState(
|
||||
config?.experimentalLlmsTxt ?? false
|
||||
)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [userAuthToken, setUserAuthToken] = useState<string>('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [showToken, setShowToken] = useState(false)
|
||||
const [showApiKey, setShowApiKey] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setApiKey(config?.apiKey || DEMO_API_KEY)
|
||||
setBaseURL(config?.baseURL || DEMO_BASE_URL)
|
||||
setModel(config?.model || DEMO_MODEL)
|
||||
setLanguage(config?.language)
|
||||
setMaxSteps(config?.maxSteps)
|
||||
setSystemInstruction(config?.systemInstruction ?? '')
|
||||
setExperimentalLlmsTxt(config?.experimentalLlmsTxt ?? false)
|
||||
}, [config])
|
||||
|
||||
// Poll for user auth token every second until found
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout | null = null
|
||||
|
||||
const fetchToken = async () => {
|
||||
const result = await chrome.storage.local.get('PageAgentExtUserAuthToken')
|
||||
const token = result.PageAgentExtUserAuthToken
|
||||
if (typeof token === 'string' && token) {
|
||||
setUserAuthToken(token)
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
interval = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchToken()
|
||||
interval = setInterval(fetchToken, 1000)
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCopyToken = async () => {
|
||||
if (userAuthToken) {
|
||||
await navigator.clipboard.writeText(userAuthToken)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave({
|
||||
apiKey,
|
||||
baseURL,
|
||||
model,
|
||||
language,
|
||||
maxSteps: maxSteps || undefined,
|
||||
systemInstruction: systemInstruction || undefined,
|
||||
experimentalLlmsTxt,
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 relative">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold">Settings</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-3 cursor-pointer"
|
||||
>
|
||||
<CornerUpLeft className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* User Auth Token Section */}
|
||||
<div className="flex flex-col gap-1.5 p-3 bg-muted/50 rounded-md border">
|
||||
<label className="text-xs font-medium text-muted-foreground">User Auth Token</label>
|
||||
<p className="text-[10px] text-muted-foreground mb-1">
|
||||
Give a website the ability to call this extension.
|
||||
</p>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
readOnly
|
||||
value={
|
||||
userAuthToken
|
||||
? showToken
|
||||
? userAuthToken
|
||||
: `${userAuthToken.slice(0, 4)}${'•'.repeat(userAuthToken.length - 8)}${userAuthToken.slice(-4)}`
|
||||
: 'Loading...'
|
||||
}
|
||||
className="text-xs h-8 font-mono bg-background"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 cursor-pointer"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
disabled={!userAuthToken}
|
||||
>
|
||||
{showToken ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 cursor-pointer"
|
||||
onClick={handleCopyToken}
|
||||
disabled={!userAuthToken}
|
||||
>
|
||||
{copied ? <span className="">✓</span> : <Copy className="size-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* Testing API notice */}
|
||||
{isTestingEndpoint(baseURL) && (
|
||||
<div className="p-2.5 rounded-md border border-amber-500/30 bg-amber-500/5 text-[11px] text-muted-foreground leading-relaxed">
|
||||
<Scale className="size-3 inline-block mr-1 -mt-0.5 text-amber-600" />
|
||||
You are using the free testing API. By using this service you agree to the{' '}
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
Terms of Use & Privacy Policy
|
||||
</a>
|
||||
. No sensitive data. No guaranteed availability.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">Model</label>
|
||||
<Input
|
||||
placeholder="gpt-5.2"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="text-xs h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">API Key</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
placeholder="sk-..."
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="text-xs h-8"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 cursor-pointer"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
>
|
||||
{showApiKey ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">Language</label>
|
||||
<select
|
||||
value={language ?? ''}
|
||||
onChange={(e) => setLanguage((e.target.value || undefined) as LanguagePreference)}
|
||||
className="h-8 text-xs rounded-md border border-input bg-background px-2 cursor-pointer"
|
||||
>
|
||||
<option value="">System</option>
|
||||
<option value="en-US">English</option>
|
||||
<option value="zh-CN">中文</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Advanced Config */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen(!advancedOpen)}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground cursor-pointer mt-1 font-bold"
|
||||
>
|
||||
Advanced
|
||||
<ChevronDown
|
||||
className="size-3 transition-transform"
|
||||
style={{ transform: advancedOpen ? 'rotate(0deg)' : 'rotate(90deg)' }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{advancedOpen && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">Max Steps</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="40"
|
||||
min={1}
|
||||
max={200}
|
||||
value={maxSteps ?? ''}
|
||||
onChange={(e) => setMaxSteps(e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="text-xs h-8 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">System Instruction</label>
|
||||
<textarea
|
||||
placeholder="Additional instructions for the agent..."
|
||||
value={systemInstruction}
|
||||
onChange={(e) => setSystemInstruction(e.target.value)}
|
||||
rows={3}
|
||||
className="text-xs rounded-md border border-input bg-background px-3 py-2 resize-y min-h-[60px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-xs text-muted-foreground">Experimental llms.txt support</span>
|
||||
<Switch checked={experimentalLlmsTxt} onCheckedChange={setExperimentalLlmsTxt} />
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button variant="outline" onClick={onClose} className="flex-1 h-8 text-xs cursor-pointer">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex-1 h-8 text-xs cursor-pointer"
|
||||
>
|
||||
{saving ? <Loader2 className="size-3 animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-4 mb-4 pt-4 border-t border-border/50 flex gap-2 justify-between text-[10px] text-muted-foreground">
|
||||
<div className="flex flex-col justify-between">
|
||||
<span>
|
||||
Version <span className="font-mono">v{__VERSION__}</span>
|
||||
</span>
|
||||
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<svg role="img" viewBox="0 0 24 24" className="size-3 fill-current">
|
||||
<path d={siGithub.path} />
|
||||
</svg>
|
||||
<span>Source Code</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end">
|
||||
<a
|
||||
href="https://alibaba.github.io/page-agent/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<Home className="size-3" />
|
||||
<span>Home Page</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent/blob/main/docs/terms-and-privacy.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<HatGlasses className="size-3" />
|
||||
<span>Privacy</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* attribute */}
|
||||
<div className="text-[10px] text-muted-foreground bg-background fixed bottom-0 w-full flex justify-around">
|
||||
<span className="leading-loose">
|
||||
Built with ♥️ by{' '}
|
||||
<a
|
||||
href="https://github.com/gaomeng1900"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
@Simon
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
60
packages/extension/src/components/ErrorBoundary.tsx
Normal file
60
packages/extension/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { AlertTriangle, Eraser, RotateCcw } from 'lucide-react'
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false, error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('[ErrorBoundary]', error, errorInfo.componentStack)
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
handleResetConfig = async () => {
|
||||
await chrome.storage.local.remove(['llmConfig', 'language', 'advancedConfig'])
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.hasError) {
|
||||
return this.props.children
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-screen bg-background p-6 text-center">
|
||||
<AlertTriangle className="size-12 text-destructive mb-4" />
|
||||
<h2 className="text-lg font-semibold mb-2">Something went wrong</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4 max-w-xs">
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={this.handleResetConfig}>
|
||||
<Eraser className="size-3.5 mr-2" />
|
||||
Reset Config
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={this.handleReload}>
|
||||
<RotateCcw className="size-3.5 mr-2" />
|
||||
Reload Panel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
51
packages/extension/src/components/HistoryDetail.tsx
Normal file
51
packages/extension/src/components/HistoryDetail.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { type SessionRecord, getSession } from '@/lib/db'
|
||||
|
||||
import { EventCard } from './cards'
|
||||
|
||||
export function HistoryDetail({ sessionId, onBack }: { sessionId: string; onBack: () => void }) {
|
||||
const [session, setSession] = useState<SessionRecord | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
getSession(sessionId).then((s) => setSession(s ?? null))
|
||||
}, [sessionId])
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen text-xs text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Button variant="ghost" size="icon-sm" onClick={onBack} className="cursor-pointer">
|
||||
<ArrowLeft className="size-3.5" />
|
||||
</Button>
|
||||
<span className="text-sm font-medium truncate">History</span>
|
||||
</header>
|
||||
|
||||
{/* Task */}
|
||||
<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" title={session.task}>
|
||||
{session.task}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events (read-only) */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{session.history.map((event, index) => (
|
||||
// eslint-disable-next-line react-x/no-array-index-key
|
||||
<EventCard key={index} event={event} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
119
packages/extension/src/components/HistoryList.tsx
Normal file
119
packages/extension/src/components/HistoryList.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { ArrowLeft, CheckCircle, Trash2, XCircle } from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { type SessionRecord, clearSessions, deleteSession, listSessions } from '@/lib/db'
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const seconds = Math.floor((Date.now() - ts) / 1000)
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
export function HistoryList({
|
||||
onSelect,
|
||||
onBack,
|
||||
}: {
|
||||
onSelect: (id: string) => void
|
||||
onBack: () => void
|
||||
}) {
|
||||
const [sessions, setSessions] = useState<SessionRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setSessions(await listSessions())
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation()
|
||||
await deleteSession(id)
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Button variant="ghost" size="icon-sm" onClick={onBack} className="cursor-pointer">
|
||||
<ArrowLeft className="size-3.5" />
|
||||
</Button>
|
||||
<span className="text-sm font-medium flex-1">History</span>
|
||||
{sessions.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await clearSessions()
|
||||
setSessions([])
|
||||
}}
|
||||
className="text-[10px] text-muted-foreground hover:text-destructive cursor-pointer h-6 px-2"
|
||||
>
|
||||
<Trash2 className="size-3 mr-1" />
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-32 text-xs text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && sessions.length === 0 && (
|
||||
<div className="flex items-center justify-center h-32 text-xs text-muted-foreground">
|
||||
No history yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(session.id)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onSelect(session.id)}
|
||||
className="w-full text-left px-3 py-2.5 border-b hover:bg-muted/50 transition-colors cursor-pointer flex items-start gap-2 group"
|
||||
>
|
||||
{/* Status icon */}
|
||||
{session.status === 'completed' ? (
|
||||
<CheckCircle className="size-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<XCircle className="size-3.5 text-destructive shrink-0 mt-0.5" />
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate">{session.task}</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
{timeAgo(session.createdAt)} · {session.history.length} steps
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleDelete(e, session.id)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:text-destructive cursor-pointer shrink-0"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
386
packages/extension/src/components/cards.tsx
Normal file
386
packages/extension/src/components/cards.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
import type {
|
||||
AgentActivity,
|
||||
AgentErrorEvent,
|
||||
AgentStepEvent,
|
||||
HistoricalEvent,
|
||||
ObservationEvent,
|
||||
RetryEvent,
|
||||
} from '@page-agent/core'
|
||||
import {
|
||||
CheckCircle,
|
||||
Eye,
|
||||
Globe,
|
||||
Keyboard,
|
||||
Mouse,
|
||||
MoveVertical,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { Fragment, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// 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-[11px] text-muted-foreground pl-5 whitespace-pre-wrap">{text}</p>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Single reflection item with truncation
|
||||
function ReflectionItem({ icon, value }: { icon: string; value: string }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<span className="text-xs flex justify-center">{icon}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[11px] text-muted-foreground cursor-pointer hover:text-muted-foreground/70',
|
||||
!expanded && 'line-clamp-1'
|
||||
)}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
// 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-[11px] font-semibold text-foreground uppercase tracking-wide mb-2">
|
||||
Reflection
|
||||
</div> */}
|
||||
<div className="grid grid-cols-[14px_1fr] gap-x-2 gap-y-2">
|
||||
{items.map((item) => (
|
||||
<ReflectionItem key={item.label} icon={item.icon} value={item.value!} />
|
||||
))}
|
||||
</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} />
|
||||
}
|
||||
|
||||
// Copy button with "Copied!" feedback
|
||||
function CopyButton({ text, label }: { text: string; label: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}}
|
||||
className="text-[9px] text-muted-foreground hover:text-foreground transition-colors border px-1 rounded shrink-0 cursor-pointer backdrop-blur-xs"
|
||||
>
|
||||
{copied ? 'Copied!' : label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Extract message content by role from raw request
|
||||
function extractPrompt(rawRequest: unknown, role: 'system' | 'user'): string | null {
|
||||
const messages = (rawRequest as { messages?: { role: string; content?: unknown }[] })?.messages
|
||||
if (!messages) return null
|
||||
const msg =
|
||||
role === 'system'
|
||||
? messages.find((m) => m.role === role)
|
||||
: messages.findLast((m) => m.role === role)
|
||||
if (!msg?.content) return null
|
||||
return typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content, null, 2)
|
||||
}
|
||||
|
||||
// Raw request/response section (collapsible tabs, for debugging)
|
||||
function RawSection({ rawRequest, rawResponse }: { rawRequest?: unknown; rawResponse?: unknown }) {
|
||||
const [activeTab, setActiveTab] = useState<'request' | 'response' | null>(null)
|
||||
|
||||
if (!rawRequest && !rawResponse) return null
|
||||
|
||||
const handleTabClick = (tab: 'request' | 'response') => {
|
||||
setActiveTab(activeTab === tab ? null : tab)
|
||||
}
|
||||
|
||||
const content =
|
||||
activeTab === 'request' ? rawRequest : activeTab === 'response' ? rawResponse : null
|
||||
|
||||
const systemPrompt = activeTab === 'request' ? extractPrompt(rawRequest, 'system') : null
|
||||
const userPrompt = activeTab === 'request' ? extractPrompt(rawRequest, 'user') : null
|
||||
|
||||
return (
|
||||
<div className="mt-2 border-t border-dashed pt-2">
|
||||
<div className="flex items-center gap-3 -my-1">
|
||||
{rawRequest != null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabClick('request')}
|
||||
className={cn(
|
||||
'text-[10px] mt-0.5 transition-colors border-b cursor-pointer',
|
||||
activeTab === 'request'
|
||||
? 'text-foreground border-foreground'
|
||||
: 'text-muted-foreground border-transparent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Raw Request
|
||||
</button>
|
||||
)}
|
||||
{rawResponse != null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabClick('response')}
|
||||
className={cn(
|
||||
'text-[10px] mt-0.5 transition-colors border-b cursor-pointer',
|
||||
activeTab === 'response'
|
||||
? 'text-foreground border-foreground'
|
||||
: 'text-muted-foreground border-transparent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Raw Response
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{content != null && (
|
||||
<div className="relative mt-1.5">
|
||||
<div className="absolute top-1 right-1 flex gap-1">
|
||||
{systemPrompt && <CopyButton text={systemPrompt} label="Copy System" />}
|
||||
{userPrompt && <CopyButton text={userPrompt} label="Copy User" />}
|
||||
<CopyButton text={JSON.stringify(content, null, 4)} label="Copy" />
|
||||
</div>
|
||||
<pre className="p-2 pt-5 text-[10px] text-foreground/70 bg-muted rounded overflow-x-auto max-h-60 overflow-y-auto">
|
||||
{JSON.stringify(content, null, 4)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepCard({ event }: { event: AgentStepEvent }) {
|
||||
return (
|
||||
<div className="rounded-lg border-l-2 border-l-blue-500/50 border bg-muted/40 p-2.5">
|
||||
<div className="text-[11px] font-semibold text-foreground tracking-wide mb-2">
|
||||
Step #{event.stepIndex! + 1}
|
||||
</div>
|
||||
|
||||
{/* Reflection */}
|
||||
{event.reflection && <ReflectionSection reflection={event.reflection} />}
|
||||
|
||||
{/* Action */}
|
||||
{event.action && (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-foreground tracking-wide mb-1">
|
||||
Actions
|
||||
</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 wrap-anywhere break-all line-clamp-1 hover:line-clamp-none">
|
||||
<span className="font-medium text-foreground/70">{event.action.name}</span>
|
||||
{event.action.name !== 'done' && (
|
||||
<span className="text-muted-foreground/70 ml-1.5">
|
||||
{JSON.stringify(event.action.input)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground/70 grid grid-cols-[auto_1fr] gap-1.5">
|
||||
<span className="">└</span>
|
||||
<span className="wrap-anywhere break-all line-clamp-1 hover:line-clamp-3">
|
||||
{event.action.output}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw Response */}
|
||||
<RawSection rawRequest={event.rawRequest} rawResponse={event.rawResponse} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ObservationCard({ event }: { event: ObservationEvent }) {
|
||||
return (
|
||||
<div className="rounded-lg border-l-2 border-l-green-500/50 border bg-muted/40 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-[11px] text-muted-foreground">{event.content}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RetryCard({ event }: { event: RetryEvent }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-2.5">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<RefreshCw className="size-3 text-amber-500 shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-amber-600 dark:text-amber-400">
|
||||
{event.message} ({event.attempt}/{event.maxAttempts})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorCard({ event }: { event: AgentErrorEvent }) {
|
||||
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>
|
||||
<RawSection rawResponse={event.rawResponse} />
|
||||
</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 (
|
||||
<>
|
||||
<StepCard event={event as AgentStepEvent} />
|
||||
<ResultCard
|
||||
success={input?.success ?? true}
|
||||
text={input?.text || event.action.output || ''}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (event.type === 'step') {
|
||||
return <StepCard event={event as AgentStepEvent} />
|
||||
}
|
||||
|
||||
if (event.type === 'observation') {
|
||||
return <ObservationCard event={event as ObservationEvent} />
|
||||
}
|
||||
|
||||
if (event.type === 'retry') {
|
||||
return <RetryCard event={event as RetryEvent} />
|
||||
}
|
||||
|
||||
if (event.type === 'error') {
|
||||
return <ErrorCard event={event as AgentErrorEvent} />
|
||||
}
|
||||
|
||||
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-muted/40 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>
|
||||
)
|
||||
}
|
||||
152
packages/extension/src/components/misc.tsx
Normal file
152
packages/extension/src/components/misc.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { AgentStatus } from '@page-agent/core'
|
||||
import { Motion } from 'ai-motion'
|
||||
import { BookOpen, Globe } from 'lucide-react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { siGithub } from 'simple-icons'
|
||||
|
||||
import { TypingAnimation } from '@/components/ui/typing-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// 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 mr-2">
|
||||
<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)} />
|
||||
}
|
||||
|
||||
// Full-screen ai-motion glow overlay, shown only while running
|
||||
export function MotionOverlay({ active }: { active: boolean }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const motionRef = useRef<Motion | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const mode = document.documentElement.classList.contains('dark') ? 'dark' : 'light'
|
||||
const motion = new Motion({
|
||||
mode,
|
||||
borderWidth: 4,
|
||||
borderRadius: 14,
|
||||
glowWidth: mode === 'dark' ? 120 : 60,
|
||||
styles: { position: 'absolute', inset: '0' },
|
||||
})
|
||||
motionRef.current = motion
|
||||
containerRef.current!.appendChild(motion.element)
|
||||
motion.autoResize(containerRef.current!)
|
||||
} catch (e) {
|
||||
console.warn('[MotionOverlay] Motion unavailable:', e)
|
||||
}
|
||||
|
||||
return () => {
|
||||
motionRef.current?.dispose()
|
||||
motionRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const motion = motionRef.current
|
||||
if (!motion) return
|
||||
|
||||
let disposed = false
|
||||
if (active) {
|
||||
motion.start()
|
||||
motion.fadeIn()
|
||||
} else {
|
||||
motion.fadeOut().then(() => !disposed && motion.pause())
|
||||
}
|
||||
return () => {
|
||||
disposed = true
|
||||
}
|
||||
}, [active])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="pointer-events-none absolute inset-0 z-10 opacity-60"
|
||||
style={{ display: active ? undefined : 'none' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Empty state with logo and breathing glow
|
||||
export function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-center px-6">
|
||||
<div className="relative select-none pointer-events-none">
|
||||
<div className="absolute inset-0 -m-6 rounded-full bg-[conic-gradient(from_180deg,oklch(0.55_0.2_280),oklch(0.5_0.15_230),oklch(0.6_0.18_310),oklch(0.55_0.2_280))] blur-2xl animate-[glow-a_5s_ease-in-out_infinite]" />
|
||||
<div className="absolute inset-0 -m-6 rounded-full bg-[conic-gradient(from_0deg,oklch(0.55_0.18_160),oklch(0.5_0.2_200),oklch(0.6_0.15_120),oklch(0.55_0.18_160))] blur-2xl animate-[glow-b_5s_ease-in-out_infinite]" />
|
||||
<Logo className="relative size-20 opacity-80" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-medium text-foreground mb-1">Page Agent Ext</h2>
|
||||
<TypingAnimation
|
||||
className="text-sm text-muted-foreground"
|
||||
words={[
|
||||
'Enter a task to automate this page',
|
||||
'Execute multi-page tasks',
|
||||
'Call this extension from your web page',
|
||||
'Use this extension in your own agents',
|
||||
]}
|
||||
cursorStyle="underscore"
|
||||
loop
|
||||
typeSpeed={20}
|
||||
deleteSpeed={10}
|
||||
pauseDelay={3000}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-muted-foreground">
|
||||
<a
|
||||
href="https://github.com/alibaba/page-agent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors"
|
||||
title="GitHub"
|
||||
>
|
||||
<svg role="img" viewBox="0 0 24 24" className="size-4 fill-current">
|
||||
<path d={siGithub.path} />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://alibaba.github.io/page-agent/docs/features/chrome-extension"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors"
|
||||
title="Documentation"
|
||||
>
|
||||
<BookOpen className="size-4" />
|
||||
</a>
|
||||
<a
|
||||
href="https://alibaba.github.io/page-agent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors"
|
||||
title="Website"
|
||||
>
|
||||
<Globe className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user