feat(web): add gateway, hub, active-agent hooks and real message state

- use-gateway: GatewayClient WebSocket lifecycle with auto-connect/disconnect
- use-hub: REST polling for Hub status and agent CRUD operations
- use-active-agent: Zustand store for cross-component selected agent state
- use-messages: replace mock data with real addUser/addAssistant/clear API

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-01-30 22:40:50 +08:00
parent 3ef7d5351c
commit c5bf56282f
4 changed files with 140 additions and 167 deletions

View file

@ -0,0 +1,11 @@
import { create } from "zustand"
interface ActiveAgentState {
activeAgentId: string | null
setActiveAgentId: (id: string | null) => void
}
export const useActiveAgent = create<ActiveAgentState>()((set) => ({
activeAgentId: null,
setActiveAgentId: (id: string | null) => set({ activeAgentId: id }),
}))

View file

@ -0,0 +1,41 @@
import { useEffect, useRef, useState, useCallback } from "react"
import { GatewayClient, type ConnectionState, type RoutedMessage } from "@multica/sdk"
import { useDeviceId } from "./use-device-id"
import { GATEWAY_URL } from "../lib/config"
interface UseGatewayOptions {
onMessage?: (msg: RoutedMessage) => void
}
export function useGateway(options?: UseGatewayOptions) {
const deviceId = useDeviceId()
const [state, setState] = useState<ConnectionState>("disconnected")
const clientRef = useRef<GatewayClient | null>(null)
const onMessageRef = useRef(options?.onMessage)
onMessageRef.current = options?.onMessage
useEffect(() => {
if (!deviceId) return
const client = new GatewayClient({
url: GATEWAY_URL,
deviceId,
deviceType: "client",
})
.onStateChange(setState)
.onMessage(msg => onMessageRef.current?.(msg))
clientRef.current = client
client.connect()
return () => { client.disconnect() }
}, [deviceId])
const send = useCallback(
(to: string, action: string, payload: unknown) => {
clientRef.current?.send(to, action, payload)
},
[]
)
return { state, send }
}

View file

@ -0,0 +1,65 @@
import { useState, useCallback, useEffect } from "react"
import { CONSOLE_URL } from "../lib/config"
interface HubInfo {
hubId: string
url: string
connectionState: string
agentCount: number
}
interface Agent {
id: string
closed: boolean
}
type HubStatus = "idle" | "loading" | "connected" | "error"
export function useHub() {
const [status, setStatus] = useState<HubStatus>("idle")
const [hub, setHub] = useState<HubInfo | null>(null)
const [agents, setAgents] = useState<Agent[]>([])
const fetchHub = useCallback(async () => {
setStatus("loading")
try {
const res = await fetch(`${CONSOLE_URL}/api/hub`)
if (!res.ok) throw new Error(res.statusText)
const data: HubInfo = await res.json()
setHub(data)
setStatus(data.connectionState === "registered" ? "connected" : "error")
} catch {
setStatus("error")
setHub(null)
}
}, [])
const fetchAgents = useCallback(async () => {
try {
const res = await fetch(`${CONSOLE_URL}/api/agents`)
if (res.ok) setAgents(await res.json())
} catch { /* silent */ }
}, [])
const createAgent = useCallback(async () => {
await fetch(`${CONSOLE_URL}/api/agents`, { method: "POST" })
await fetchAgents()
}, [fetchAgents])
const deleteAgent = useCallback(async (id: string) => {
await fetch(`${CONSOLE_URL}/api/agents/${id}`, { method: "DELETE" })
await fetchAgents()
}, [fetchAgents])
// Auto-fetch hub on mount, agents when connected, poll every 30s
useEffect(() => { fetchHub() }, [fetchHub])
useEffect(() => {
if (status === "connected") fetchAgents()
}, [status, fetchAgents])
useEffect(() => {
const id = setInterval(fetchHub, 30_000)
return () => clearInterval(id)
}, [fetchHub])
return { status, hub, agents, fetchHub, createAgent, deleteAgent }
}

View file

@ -1,169 +1,25 @@
export type Message = {
id: string;
role: "user" | "assistant";
content: string;
};
import { useState, useCallback } from "react"
import { v7 as uuidv7 } from "uuid"
const MOCK_MESSAGES: Message[] = [
{
id: "1",
role: "user",
content: "Hey! Can you explain how WebSocket connections work?",
},
{
id: "2",
role: "assistant",
content: `WebSocket provides **full-duplex** communication over a single TCP connection. Here's how it works:
1. **Handshake** The client sends an HTTP upgrade request
2. **Connection** The server agrees and upgrades the protocol
3. **Messaging** Both sides can send data at any time
4. **Close** Either side can close the connection
A basic example in JavaScript:
\`\`\`ts
const ws = new WebSocket("wss://example.com/ws");
ws.onopen = () => {
console.log("Connected");
ws.send(JSON.stringify({ type: "hello" }));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log("Received:", data);
};
\`\`\`
> Unlike HTTP, WebSocket keeps the connection alive so you don't need to poll.`,
},
{
id: "3",
role: "user",
content: "What's the difference between Socket.IO and raw WebSocket?",
},
{
id: "4",
role: "assistant",
content: `Great question! Here's a comparison:
| Feature | WebSocket | Socket.IO |
|---------|-----------|-----------|
| Protocol | Standard WS | Custom on top of WS |
| Reconnection | Manual | Automatic |
| Rooms/Namespaces | No | Yes |
| Fallback | None | HTTP long-polling |
| Binary support | Yes | Yes |
**When to use raw WebSocket:**
- You need maximum performance
- You're building a simple real-time feature
- You want to keep dependencies minimal
**When to use Socket.IO:**
- You need automatic reconnection
- You want room-based broadcasting
- You need to support older browsers`,
},
{
id: "5",
role: "user",
content:
"Can you show me how to set up a simple Socket.IO server with event handling?",
},
{
id: "6",
role: "assistant",
content: `Sure! Here's a minimal Socket.IO server:
\`\`\`ts
import { Server } from "socket.io";
const io = new Server(3000, {
cors: { origin: "*" },
});
io.on("connection", (socket) => {
console.log(\`Client connected: \${socket.id}\`);
socket.on("message", (data) => {
// Broadcast to all other clients
socket.broadcast.emit("message", {
from: socket.id,
...data,
});
});
socket.on("disconnect", () => {
console.log(\`Client disconnected: \${socket.id}\`);
});
});
\`\`\`
And the client side:
\`\`\`ts
import { io } from "socket.io-client";
const socket = io("http://localhost:3000");
socket.on("connect", () => {
socket.emit("message", { text: "Hello everyone!" });
});
socket.on("message", (data) => {
console.log("New message:", data);
});
\`\`\`
That's it the server handles connections, relays messages, and both sides can emit custom events freely.`,
},
{
id: "7",
role: "user",
content: "Thanks! How would I add authentication to this?",
},
{
id: "8",
role: "assistant",
content: `You can use Socket.IO's **middleware** to authenticate on connection:
\`\`\`ts
io.use((socket, next) => {
const token = socket.handshake.auth.token;
try {
const user = verifyJWT(token);
socket.data.user = user;
next();
} catch {
next(new Error("Authentication failed"));
}
});
\`\`\`
On the client, pass the token when connecting:
\`\`\`ts
const socket = io("http://localhost:3000", {
auth: {
token: "your-jwt-token-here",
},
});
socket.on("connect_error", (err) => {
console.error("Auth failed:", err.message);
});
\`\`\`
Key points:
- The middleware runs **before** the \`connection\` event fires
- You can attach user data to \`socket.data\` for later use
- Failed auth rejects the connection entirely no messages leak`,
},
];
export function useMessages(): Message[] {
return MOCK_MESSAGES;
export interface Message {
id: string
role: "user" | "assistant"
content: string
agentId: string
}
export function useMessages() {
const [messages, setMessages] = useState<Message[]>([])
const addUserMessage = useCallback((content: string, agentId: string) => {
setMessages(prev => [...prev, { id: uuidv7(), role: "user", content, agentId }])
}, [])
const addAssistantMessage = useCallback((content: string, agentId: string) => {
setMessages(prev => [...prev, { id: uuidv7(), role: "assistant", content, agentId }])
}, [])
const clearMessages = useCallback(() => setMessages([]), [])
return { messages, addUserMessage, addAssistantMessage, clearMessages }
}