feat(ext): hub protocol
This commit is contained in:
152
packages/extension/src/entrypoints/hub/App.tsx
Normal file
152
packages/extension/src/entrypoints/hub/App.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { Plug, PlugZap, Square, Unplug } from 'lucide-react'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
import { useAgent } from '@/agent/useAgent'
|
||||||
|
import { ActivityCard, EventCard } from '@/components/cards'
|
||||||
|
import { Logo, MotionOverlay, StatusDot } from '@/components/misc'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
import { useHubWs } from './hub-ws'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { status, history, activity, currentTask, config, execute, stop, configure } = useAgent()
|
||||||
|
const { wsState } = useHubWs(execute, stop, configure, config)
|
||||||
|
|
||||||
|
const historyRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (historyRef.current) {
|
||||||
|
historyRef.current.scrollTop = historyRef.current.scrollHeight
|
||||||
|
}
|
||||||
|
}, [history, activity])
|
||||||
|
|
||||||
|
const isRunning = status === 'running'
|
||||||
|
const WsIcon = wsState === 'connected' ? PlugZap : wsState === 'connecting' ? Plug : Unplug
|
||||||
|
const wsLabel = {
|
||||||
|
connected: 'Connected',
|
||||||
|
connecting: 'Connecting…',
|
||||||
|
disconnected: new URLSearchParams(location.search).get('ws') ? 'Disconnected' : 'Standalone',
|
||||||
|
}[wsState]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-background">
|
||||||
|
<MotionOverlay active={isRunning} />
|
||||||
|
|
||||||
|
{/* Left — Protocol docs */}
|
||||||
|
<aside className="w-80 shrink-0 border-r flex flex-col bg-muted/20">
|
||||||
|
<div className="flex items-center gap-2 px-5 py-4 border-b">
|
||||||
|
<Logo className="size-5" />
|
||||||
|
<span className="text-sm font-semibold tracking-tight">Page Agent Hub</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||||
|
<ProtocolDocs />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t px-5 py-3 text-[11px] text-muted-foreground/60">
|
||||||
|
Connect via <code className="text-[10px]">hub.html?ws=PORT</code>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Right — Live session */}
|
||||||
|
<main className="flex-1 flex flex-col min-w-0">
|
||||||
|
{/* Header bar */}
|
||||||
|
<header className="flex items-center justify-between border-b px-5 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<WsIcon className="size-3.5" />
|
||||||
|
<span>{wsLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StatusDot status={status} />
|
||||||
|
{isRunning && (
|
||||||
|
<Button variant="destructive" size="sm" onClick={stop} className="h-7 text-xs">
|
||||||
|
<Square className="size-3 mr-1" />
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Task banner */}
|
||||||
|
{currentTask && (
|
||||||
|
<div className="border-b px-5 py-2 bg-muted/30">
|
||||||
|
<div className="text-[10px] text-muted-foreground uppercase tracking-wide">
|
||||||
|
Current Task
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium truncate" title={currentTask}>
|
||||||
|
{currentTask}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Event stream */}
|
||||||
|
<div ref={historyRef} className="flex-1 overflow-y-auto p-5 space-y-2">
|
||||||
|
{!currentTask && history.length === 0 && !isRunning && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
|
||||||
|
<WsIcon className="size-10 opacity-30" />
|
||||||
|
<p className="text-sm">
|
||||||
|
{wsState === 'connected'
|
||||||
|
? 'Waiting for task from external caller…'
|
||||||
|
: 'No active session'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{history.map((event, index) => (
|
||||||
|
// eslint-disable-next-line react-x/no-array-index-key
|
||||||
|
<EventCard key={index} event={event} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{activity && <ActivityCard activity={activity} />}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProtocolDocs() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5 text-xs text-muted-foreground">
|
||||||
|
<section>
|
||||||
|
<h3 className="text-[11px] font-semibold text-foreground/80 uppercase tracking-wider mb-2">
|
||||||
|
Caller → Hub
|
||||||
|
</h3>
|
||||||
|
<pre className="bg-muted/50 rounded-md p-3 font-mono text-[10px] leading-relaxed whitespace-pre-wrap">
|
||||||
|
{`{ type: "execute", task: string, config?: object }
|
||||||
|
{ type: "stop" }`}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="text-[11px] font-semibold text-foreground/80 uppercase tracking-wider mb-2">
|
||||||
|
Hub → Caller
|
||||||
|
</h3>
|
||||||
|
<pre className="bg-muted/50 rounded-md p-3 font-mono text-[10px] leading-relaxed whitespace-pre-wrap">
|
||||||
|
{`{ type: "ready" }
|
||||||
|
{ type: "result", success: boolean, data: string }
|
||||||
|
{ type: "error", message: string }`}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="text-[11px] font-semibold text-foreground/80 uppercase tracking-wider mb-2">
|
||||||
|
Flow
|
||||||
|
</h3>
|
||||||
|
<ol className="list-decimal list-inside space-y-1 text-[11px] leading-relaxed">
|
||||||
|
<li>Hub opens WS to caller's server</li>
|
||||||
|
<li>
|
||||||
|
Sends <code className="text-[10px]">ready</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Caller sends <code className="text-[10px]">execute</code> with task
|
||||||
|
</li>
|
||||||
|
<li>Hub runs agent, streams events</li>
|
||||||
|
<li>
|
||||||
|
Hub sends <code className="text-[10px]">result</code> or{' '}
|
||||||
|
<code className="text-[10px]">error</code>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
219
packages/extension/src/entrypoints/hub/hub-ws.ts
Normal file
219
packages/extension/src/entrypoints/hub/hub-ws.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* Hub WebSocket Protocol
|
||||||
|
*
|
||||||
|
* Hub connects as WS client to `ws://localhost:{port}`.
|
||||||
|
* All messages are JSON. One task at a time.
|
||||||
|
*
|
||||||
|
* Inbound (Caller → Hub):
|
||||||
|
* { type: "execute", task: string, config?: object }
|
||||||
|
* { type: "stop" }
|
||||||
|
*
|
||||||
|
* Outbound (Hub → Caller):
|
||||||
|
* { type: "ready" }
|
||||||
|
* { type: "result", success: boolean, data: string }
|
||||||
|
* { type: "error", message: string }
|
||||||
|
*/
|
||||||
|
import type { ExecutionResult } from '@page-agent/core'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import type { ExtConfig } from '@/agent/useAgent'
|
||||||
|
|
||||||
|
// --- Protocol types ---
|
||||||
|
|
||||||
|
interface ExecuteMessage {
|
||||||
|
type: 'execute'
|
||||||
|
task: string
|
||||||
|
config?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StopMessage {
|
||||||
|
type: 'stop'
|
||||||
|
}
|
||||||
|
|
||||||
|
type InboundMessage = ExecuteMessage | StopMessage
|
||||||
|
|
||||||
|
interface ReadyMessage {
|
||||||
|
type: 'ready'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResultMessage {
|
||||||
|
type: 'result'
|
||||||
|
success: boolean
|
||||||
|
data: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorMessage {
|
||||||
|
type: 'error'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type OutboundMessage = ReadyMessage | ResultMessage | ErrorMessage
|
||||||
|
|
||||||
|
export type HubWsState = 'connecting' | 'connected' | 'disconnected'
|
||||||
|
|
||||||
|
// --- HubWs class ---
|
||||||
|
|
||||||
|
export interface HubWsHandlers {
|
||||||
|
onExecute: (
|
||||||
|
task: string,
|
||||||
|
config?: Record<string, unknown>
|
||||||
|
) => Promise<{ success: boolean; data: string }>
|
||||||
|
onStop: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Framework-agnostic WebSocket client for Hub.
|
||||||
|
* Connects to an external WS server, receives tasks, dispatches to handlers,
|
||||||
|
* and sends results back. No React, no DOM.
|
||||||
|
*/
|
||||||
|
export class HubWs {
|
||||||
|
#ws: WebSocket | null = null
|
||||||
|
#state: HubWsState = 'disconnected'
|
||||||
|
#busy = false
|
||||||
|
#handlers: HubWsHandlers
|
||||||
|
#port: number
|
||||||
|
#onStateChange: (state: HubWsState) => void
|
||||||
|
|
||||||
|
constructor(port: number, handlers: HubWsHandlers, onStateChange: (state: HubWsState) => void) {
|
||||||
|
this.#port = port
|
||||||
|
this.#handlers = handlers
|
||||||
|
this.#onStateChange = onStateChange
|
||||||
|
}
|
||||||
|
|
||||||
|
get state() {
|
||||||
|
return this.#state
|
||||||
|
}
|
||||||
|
|
||||||
|
get busy() {
|
||||||
|
return this.#busy
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (this.#ws) return
|
||||||
|
this.#setState('connecting')
|
||||||
|
|
||||||
|
const ws = new WebSocket(`ws://localhost:${this.#port}`)
|
||||||
|
this.#ws = ws
|
||||||
|
|
||||||
|
ws.addEventListener('open', () => {
|
||||||
|
this.#setState('connected')
|
||||||
|
this.#send({ type: 'ready' })
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.addEventListener('close', () => {
|
||||||
|
this.#ws = null
|
||||||
|
this.#busy = false
|
||||||
|
this.#setState('disconnected')
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.addEventListener('message', (event) => {
|
||||||
|
this.#handleMessage(event.data as string)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.#ws?.close()
|
||||||
|
this.#ws = null
|
||||||
|
this.#busy = false
|
||||||
|
this.#setState('disconnected')
|
||||||
|
}
|
||||||
|
|
||||||
|
#setState(state: HubWsState) {
|
||||||
|
if (this.#state === state) return
|
||||||
|
this.#state = state
|
||||||
|
this.#onStateChange(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#send(msg: OutboundMessage) {
|
||||||
|
if (this.#ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.#ws.send(JSON.stringify(msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#handleMessage(raw: string) {
|
||||||
|
let msg: InboundMessage
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'execute':
|
||||||
|
this.#handleExecute(msg)
|
||||||
|
break
|
||||||
|
case 'stop':
|
||||||
|
this.#handlers.onStop()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #handleExecute(msg: ExecuteMessage) {
|
||||||
|
if (this.#busy) {
|
||||||
|
this.#send({ type: 'error', message: 'Hub is busy with another task' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#busy = true
|
||||||
|
try {
|
||||||
|
const result = await this.#handlers.onExecute(msg.task, msg.config)
|
||||||
|
this.#send({ type: 'result', success: result.success, data: result.data })
|
||||||
|
} catch (err) {
|
||||||
|
this.#send({ type: 'error', message: err instanceof Error ? err.message : String(err) })
|
||||||
|
} finally {
|
||||||
|
this.#busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- React hook ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook that bridges HubWs to the agent's execute/stop/configure.
|
||||||
|
* Handles the config-before-execute dance internally.
|
||||||
|
*/
|
||||||
|
export function useHubWs(
|
||||||
|
execute: (task: string) => Promise<ExecutionResult>,
|
||||||
|
stop: () => void,
|
||||||
|
configure: (config: ExtConfig) => Promise<void>,
|
||||||
|
config: ExtConfig | null
|
||||||
|
): { wsState: HubWsState } {
|
||||||
|
const wsPort = new URLSearchParams(location.search).get('ws')
|
||||||
|
const [wsState, setWsState] = useState<HubWsState>(() => (wsPort ? 'connecting' : 'disconnected'))
|
||||||
|
const hubWsRef = useRef<HubWs | null>(null)
|
||||||
|
|
||||||
|
const latest = useRef({ execute, stop, configure, config })
|
||||||
|
useEffect(() => {
|
||||||
|
latest.current = { execute, stop, configure, config }
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!wsPort) return
|
||||||
|
|
||||||
|
const hubWs = new HubWs(
|
||||||
|
Number(wsPort),
|
||||||
|
{
|
||||||
|
onExecute: async (task, incomingConfig) => {
|
||||||
|
const { execute, configure, config } = latest.current
|
||||||
|
if (incomingConfig) {
|
||||||
|
await configure({ ...config, ...incomingConfig } as ExtConfig)
|
||||||
|
}
|
||||||
|
const result = await execute(task)
|
||||||
|
return { success: result.success, data: result.data }
|
||||||
|
},
|
||||||
|
onStop: () => latest.current.stop(),
|
||||||
|
},
|
||||||
|
setWsState
|
||||||
|
)
|
||||||
|
|
||||||
|
hubWs.connect()
|
||||||
|
hubWsRef.current = hubWs
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
hubWs.disconnect()
|
||||||
|
hubWsRef.current = null
|
||||||
|
}
|
||||||
|
}, [wsPort])
|
||||||
|
|
||||||
|
return { wsState }
|
||||||
|
}
|
||||||
13
packages/extension/src/entrypoints/hub/index.html
Normal file
13
packages/extension/src/entrypoints/hub/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/png" href="/assets/page-agent-64.png" />
|
||||||
|
<title>Page Agent Hub</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
packages/extension/src/entrypoints/hub/main.tsx
Normal file
25
packages/extension/src/entrypoints/hub/main.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
|
||||||
|
import { ErrorBoundary } from '@/components/ErrorBoundary'
|
||||||
|
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
import '@/assets/index.css'
|
||||||
|
|
||||||
|
const syncDarkMode = () => {
|
||||||
|
document.documentElement.classList.toggle(
|
||||||
|
'dark',
|
||||||
|
matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
)
|
||||||
|
}
|
||||||
|
syncDarkMode()
|
||||||
|
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncDarkMode)
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user