From c5bf56282ff80265a67f0c3a43a9f03c09948ea4 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Fri, 30 Jan 2026 22:40:50 +0800 Subject: [PATCH] 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 --- apps/web/app/hooks/use-active-agent.ts | 11 ++ apps/web/app/hooks/use-gateway.ts | 41 ++++++ apps/web/app/hooks/use-hub.ts | 65 +++++++++ apps/web/app/hooks/use-messages.ts | 190 +++---------------------- 4 files changed, 140 insertions(+), 167 deletions(-) create mode 100644 apps/web/app/hooks/use-active-agent.ts create mode 100644 apps/web/app/hooks/use-gateway.ts create mode 100644 apps/web/app/hooks/use-hub.ts diff --git a/apps/web/app/hooks/use-active-agent.ts b/apps/web/app/hooks/use-active-agent.ts new file mode 100644 index 00000000..9d0fe5d4 --- /dev/null +++ b/apps/web/app/hooks/use-active-agent.ts @@ -0,0 +1,11 @@ +import { create } from "zustand" + +interface ActiveAgentState { + activeAgentId: string | null + setActiveAgentId: (id: string | null) => void +} + +export const useActiveAgent = create()((set) => ({ + activeAgentId: null, + setActiveAgentId: (id: string | null) => set({ activeAgentId: id }), +})) diff --git a/apps/web/app/hooks/use-gateway.ts b/apps/web/app/hooks/use-gateway.ts new file mode 100644 index 00000000..f10e0f99 --- /dev/null +++ b/apps/web/app/hooks/use-gateway.ts @@ -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("disconnected") + const clientRef = useRef(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 } +} diff --git a/apps/web/app/hooks/use-hub.ts b/apps/web/app/hooks/use-hub.ts new file mode 100644 index 00000000..b49df592 --- /dev/null +++ b/apps/web/app/hooks/use-hub.ts @@ -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("idle") + const [hub, setHub] = useState(null) + const [agents, setAgents] = useState([]) + + 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 } +} diff --git a/apps/web/app/hooks/use-messages.ts b/apps/web/app/hooks/use-messages.ts index 801ea88b..8982b0d4 100644 --- a/apps/web/app/hooks/use-messages.ts +++ b/apps/web/app/hooks/use-messages.ts @@ -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([]) + + 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 } }