diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 716a2e72..780ad64d 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono, Inter, Playfair_Display } from "next/font/google"; -import { setConfig } from "@multica/fetch"; +import { useGatewayStore } from "@multica/store"; import "@multica/ui/globals.css"; import { SidebarProvider, @@ -11,10 +11,10 @@ import { ThemeProvider } from "@multica/ui/components/theme-provider"; import { Toaster } from "@multica/ui/components/ui/sonner"; import { HubSidebar } from "@multica/ui/components/hub-sidebar"; -setConfig({ - consoleUrl: process.env.NEXT_PUBLIC_CONSOLE_URL ?? "http://localhost:4000", - gatewayUrl: process.env.NEXT_PUBLIC_GATEWAY_URL ?? "http://localhost:3000", -}); +const gatewayUrl = process.env.NEXT_PUBLIC_GATEWAY_URL; +if (gatewayUrl) { + useGatewayStore.getState().setGatewayUrl(gatewayUrl); +} const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); diff --git a/apps/web/package.json b/apps/web/package.json index 09bac1ef..954a4008 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,6 @@ "lint": "eslint" }, "dependencies": { - "@multica/fetch": "workspace:*", "@multica/sdk": "workspace:*", "@multica/store": "workspace:*", "@multica/ui": "workspace:*", diff --git a/docs/rpc.md b/docs/rpc.md index 535a8078..d3ce36dc 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -207,6 +207,136 @@ const result = await client.request( } ``` +### `getHubInfo` + +Returns Hub status information. No parameters required. + +**Response:** + +```ts +interface GetHubInfoResult { + hubId: string; // Hub device ID + url: string; // Current Gateway URL + connectionState: string; // "disconnected" | "connecting" | "connected" | "registered" + agentCount: number; // Number of active agents +} +``` + +**Example:** + +```ts +const info = await client.request(hubDeviceId, "getHubInfo"); +``` + +--- + +### `listAgents` + +Lists all active agents. No parameters required. + +**Response:** + +```ts +interface ListAgentsResult { + agents: { id: string; closed: boolean }[]; +} +``` + +**Example:** + +```ts +const result = await client.request(hubDeviceId, "listAgents"); +``` + +--- + +### `createAgent` + +Creates a new agent or restores an existing one. + +**Parameters:** + +```ts +interface CreateAgentParams { + id?: string; // optional - reuse existing session ID +} +``` + +**Response:** + +```ts +interface CreateAgentResult { + id: string; // the created/restored agent session ID +} +``` + +**Example:** + +```ts +const result = await client.request(hubDeviceId, "createAgent"); +// or with specific ID: +const result = await client.request(hubDeviceId, "createAgent", { id: "existing-id" }); +``` + +--- + +### `deleteAgent` + +Closes and removes an agent. + +**Parameters:** + +```ts +interface DeleteAgentParams { + id: string; // required - agent ID to delete +} +``` + +**Response:** + +```ts +interface DeleteAgentResult { + ok: boolean; // true if agent was found and deleted +} +``` + +**Example:** + +```ts +const result = await client.request(hubDeviceId, "deleteAgent", { id: "019abc12-..." }); +``` + +--- + +### `updateGateway` + +Reconnects the Hub to a different Gateway URL. + +**Parameters:** + +```ts +interface UpdateGatewayParams { + url: string; // required - new Gateway URL +} +``` + +**Response:** + +```ts +interface UpdateGatewayResult { + url: string; // the new URL + connectionState: string; // connection state after reconnect +} +``` + +**Example:** + +```ts +const result = await client.request(hubDeviceId, "updateGateway", { url: "http://localhost:4000" }); +``` + +--- + ## Adding New RPC Methods 1. Create a handler file in `src/hub/rpc/handlers/`: diff --git a/packages/fetch/package.json b/packages/fetch/package.json deleted file mode 100644 index d5bd6971..00000000 --- a/packages/fetch/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "@multica/fetch", - "version": "0.1.0", - "private": true, - "type": "module", - "exports": { - ".": "./src/index.ts", - "./*": "./src/*.ts" - }, - "devDependencies": { - "typescript": "catalog:" - } -} diff --git a/packages/fetch/src/config.ts b/packages/fetch/src/config.ts deleted file mode 100644 index ff2abde9..00000000 --- a/packages/fetch/src/config.ts +++ /dev/null @@ -1,15 +0,0 @@ -let consoleUrl = "http://localhost:4000" -let gatewayUrl = "http://localhost:3000" - -export function setConfig(config: { consoleUrl?: string; gatewayUrl?: string }) { - if (config.consoleUrl) consoleUrl = config.consoleUrl - if (config.gatewayUrl) gatewayUrl = config.gatewayUrl -} - -export function getConsoleUrl(): string { - return consoleUrl -} - -export function getGatewayUrl(): string { - return gatewayUrl -} diff --git a/packages/fetch/src/http-client.ts b/packages/fetch/src/http-client.ts deleted file mode 100644 index e9564d09..00000000 --- a/packages/fetch/src/http-client.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { getConsoleUrl } from "./config" - -export class HttpError extends Error { - constructor( - public status: number, - public statusText: string, - ) { - super(`HTTP ${status}: ${statusText}`) - } -} - -async function request(method: string, path: string, body?: unknown): Promise { - const res = await fetch(`${getConsoleUrl()}${path}`, { - method, - headers: body ? { "Content-Type": "application/json" } : undefined, - body: body ? JSON.stringify(body) : undefined, - }) - if (!res.ok) throw new HttpError(res.status, res.statusText) - return res.json() -} - -/** Console REST API */ -export const consoleApi = { - get: (path: string) => request("GET", path), - post: (path: string, body?: unknown) => request("POST", path, body), - put: (path: string, body: unknown) => request("PUT", path, body), - delete: (path: string) => request("DELETE", path), -} diff --git a/packages/fetch/src/index.ts b/packages/fetch/src/index.ts deleted file mode 100644 index 8500fa04..00000000 --- a/packages/fetch/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { setConfig, getConsoleUrl, getGatewayUrl } from "./config" -export { consoleApi, HttpError } from "./http-client" diff --git a/packages/fetch/tsconfig.json b/packages/fetch/tsconfig.json deleted file mode 100644 index c1103210..00000000 --- a/packages/fetch/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "declaration": true, - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src"] -} diff --git a/packages/sdk/src/actions/index.ts b/packages/sdk/src/actions/index.ts index d60637ee..ab6307fa 100644 --- a/packages/sdk/src/actions/index.ts +++ b/packages/sdk/src/actions/index.ts @@ -14,8 +14,17 @@ export { type ResponseErrorPayload, isResponseSuccess, isResponseError, + type AgentMessageItem, type GetAgentMessagesParams, type GetAgentMessagesResult, + type GetHubInfoResult, + type ListAgentsResult, + type CreateAgentParams, + type CreateAgentResult, + type DeleteAgentParams, + type DeleteAgentResult, + type UpdateGatewayParams, + type UpdateGatewayResult, } from "./rpc.js"; export { StreamAction, type StreamPayload } from "./stream.js"; diff --git a/packages/sdk/src/actions/rpc.ts b/packages/sdk/src/actions/rpc.ts index 0cdc3200..a81a15b1 100644 --- a/packages/sdk/src/actions/rpc.ts +++ b/packages/sdk/src/actions/rpc.ts @@ -65,10 +65,83 @@ export interface GetAgentMessagesParams { limit?: number; } +/** Content block types from the agent engine */ +export interface TextContentBlock { + type: "text"; + text: string; +} + +export interface ThinkingContentBlock { + type: "thinking"; + thinking: string; +} + +export interface ToolCallBlock { + type: "tool_use"; + id: string; + name: string; + input: unknown; +} + +export interface ImageContentBlock { + type: "image"; + url: string; +} + +/** Agent message returned by getAgentMessages (mirrors pi-ai Message) */ +export type AgentMessageItem = + | { role: "user"; content: string | (TextContentBlock | ImageContentBlock)[]; timestamp: number } + | { role: "assistant"; content: (TextContentBlock | ThinkingContentBlock | ToolCallBlock)[]; timestamp: number } + | { role: "tool_result"; toolCallId: string; content: (TextContentBlock | ImageContentBlock)[]; isError: boolean; timestamp: number } + /** getAgentMessages - response payload */ export interface GetAgentMessagesResult { - messages: unknown[]; + messages: AgentMessageItem[]; total: number; offset: number; limit: number; } + +/** getHubInfo - no params needed */ +export interface GetHubInfoResult { + hubId: string; + url: string; + connectionState: string; + agentCount: number; +} + +/** listAgents - no params needed */ +export interface ListAgentsResult { + agents: { id: string; closed: boolean }[]; +} + +/** createAgent - request params */ +export interface CreateAgentParams { + id?: string; +} + +/** createAgent - response payload */ +export interface CreateAgentResult { + id: string; +} + +/** deleteAgent - request params */ +export interface DeleteAgentParams { + id: string; +} + +/** deleteAgent - response payload */ +export interface DeleteAgentResult { + ok: boolean; +} + +/** updateGateway - request params */ +export interface UpdateGatewayParams { + url: string; +} + +/** updateGateway - response payload */ +export interface UpdateGatewayResult { + url: string; + connectionState: string; +} diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 05849715..f38e6d5d 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -9,6 +9,8 @@ import type { SendErrorResponse, PingPayload, DeviceType, + DeviceInfo, + ListDevicesResponse, } from "./types.js"; import { GatewayEvents } from "./types.js"; import { @@ -163,6 +165,24 @@ export class GatewayClient { }); } + /** List all devices connected to the Gateway */ + listDevices(): Promise { + return new Promise((resolve, reject) => { + if (!this.socket || !this.isRegistered) { + reject(new Error("Not registered")); + return; + } + + this.socket.emit( + GatewayEvents.LIST_DEVICES, + {}, + (response: ListDevicesResponse) => { + resolve(response.devices); + } + ); + }); + } + /** Send an RPC request and wait for the response */ request( to: string, diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index e1b696f2..7d9643e0 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -11,6 +11,7 @@ export { type ConnectionState, type PingPayload, type PongResponse, + type ListDevicesResponse, } from "./types.js"; // Actions diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index eec02b8d..d0067591 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -4,6 +4,7 @@ export const GatewayEvents = { PING: "ping", PONG: "pong", REGISTERED: "registered", + LIST_DEVICES: "list-devices", // Message routing SEND: "send", @@ -14,7 +15,7 @@ export const GatewayEvents = { // ============ Device Related ============ /** Device type */ -export type DeviceType = "client" | "agent"; +export type DeviceType = "client" | "hub" | "agent"; /** Device information */ export interface DeviceInfo { @@ -54,6 +55,11 @@ export interface SendErrorResponse { code: "DEVICE_NOT_FOUND" | "NOT_REGISTERED" | "INVALID_MESSAGE"; } +/** List devices response */ +export interface ListDevicesResponse { + devices: DeviceInfo[]; +} + // ============ Ping/Pong ============ /** Ping request */ diff --git a/packages/store/package.json b/packages/store/package.json index e79cdef1..61a1b936 100644 --- a/packages/store/package.json +++ b/packages/store/package.json @@ -8,7 +8,6 @@ "./*": "./src/*.ts" }, "dependencies": { - "@multica/fetch": "workspace:*", "@multica/sdk": "workspace:*", "react": "catalog:", "sonner": "^2.0.7", diff --git a/packages/store/src/gateway.ts b/packages/store/src/gateway.ts index da5dd0d9..fb70f6c0 100644 --- a/packages/store/src/gateway.ts +++ b/packages/store/src/gateway.ts @@ -1,32 +1,45 @@ import { create } from "zustand" -import { GatewayClient, type ConnectionState, type SendErrorResponse } from "@multica/sdk" -import { getGatewayUrl } from "@multica/fetch" +import { GatewayClient, type ConnectionState, type DeviceInfo, type SendErrorResponse } from "@multica/sdk" import { useMessagesStore } from "./messages" +const DEFAULT_GATEWAY_URL = "http://localhost:3000" + interface GatewayState { + gatewayUrl: string connectionState: ConnectionState + hubId: string | null + hubs: DeviceInfo[] lastError: SendErrorResponse | null } interface GatewayActions { + setGatewayUrl: (url: string) => void connect: (deviceId: string) => void disconnect: () => void + setHubId: (hubId: string) => void + listDevices: () => Promise send: (to: string, action: string, payload: unknown) => void + request: (method: string, params?: unknown) => Promise } export type GatewayStore = GatewayState & GatewayActions let client: GatewayClient | null = null -export const useGatewayStore = create()((set) => ({ +export const useGatewayStore = create()((set, get) => ({ + gatewayUrl: DEFAULT_GATEWAY_URL, connectionState: "disconnected", + hubId: null, + hubs: [], lastError: null, + setGatewayUrl: (url) => set({ gatewayUrl: url }), + connect: (deviceId) => { if (client) return client = new GatewayClient({ - url: getGatewayUrl(), + url: get().gatewayUrl, deviceId, deviceType: "client", }) @@ -47,11 +60,29 @@ export const useGatewayStore = create()((set) => ({ client.disconnect() client = null } - set({ connectionState: "disconnected" }) + set({ connectionState: "disconnected", hubId: null, hubs: [] }) + }, + + setHubId: (hubId) => set({ hubId }), + + listDevices: async () => { + if (!client?.isRegistered) return [] + const devices = await client.listDevices() + const hubs = devices.filter((d) => d.deviceType === "hub") + set({ hubs }) + return devices }, send: (to, action, payload) => { if (!client?.isRegistered) return client.send(to, action, payload) }, + + request: (method: string, params?: unknown): Promise => { + const { hubId } = get() + if (!client?.isRegistered || !hubId) { + return Promise.reject(new Error("Not connected")) + } + return client.request(hubId, method, params) + }, })) diff --git a/packages/store/src/hub-init.ts b/packages/store/src/hub-init.ts index bf06c56a..ca9de4da 100644 --- a/packages/store/src/hub-init.ts +++ b/packages/store/src/hub-init.ts @@ -6,25 +6,35 @@ import { useDeviceId } from "./device-id" import { useGatewayStore } from "./gateway" export function useHubInit() { - const fetchHub = useHubStore((s) => s.fetchHub) - const status = useHubStore((s) => s.status) - const fetchAgents = useHubStore((s) => s.fetchAgents) const deviceId = useDeviceId() + const gwState = useGatewayStore((s) => s.connectionState) + const hubId = useGatewayStore((s) => s.hubId) + const fetchHub = useHubStore((s) => s.fetchHub) + const fetchAgents = useHubStore((s) => s.fetchAgents) - useEffect(() => { fetchHub() }, [fetchHub]) + // Auto-connect WS when deviceId is available useEffect(() => { - if (status === "connected") fetchAgents() - }, [status, fetchAgents]) - useEffect(() => { - const id = setInterval(fetchHub, 30_000) - return () => clearInterval(id) - }, [fetchHub]) - - // Connect gateway when hub is ready and deviceId is available - useEffect(() => { - if (status === "connected" && deviceId) { + if (deviceId) { useGatewayStore.getState().connect(deviceId) return () => { useGatewayStore.getState().disconnect() } } - }, [status, deviceId]) + }, [deviceId]) + + // Once WS is registered, discover available hubs + useEffect(() => { + if (gwState === "registered") { + useGatewayStore.getState().listDevices() + } + }, [gwState]) + + // Once hubId is set and WS is registered, fetch hub info and agents via RPC + useEffect(() => { + if (gwState === "registered" && hubId) { + fetchHub() + fetchAgents() + } + if (gwState === "disconnected") { + useHubStore.setState({ status: "idle", hub: null, agents: [], activeAgentId: null }) + } + }, [gwState, hubId, fetchHub, fetchAgents]) } diff --git a/packages/store/src/hub.ts b/packages/store/src/hub.ts index ed5cc41f..2bb0ef70 100644 --- a/packages/store/src/hub.ts +++ b/packages/store/src/hub.ts @@ -1,14 +1,28 @@ import { create } from "zustand" -import { consoleApi } from "@multica/fetch" import { toast } from "sonner" +import { v7 as uuidv7 } from "uuid" +import type { + GetHubInfoResult, + ListAgentsResult, + CreateAgentResult, + DeleteAgentResult, + GetAgentMessagesResult, + AgentMessageItem, +} from "@multica/sdk" +import { useGatewayStore } from "./gateway" +import { useMessagesStore } from "./messages" -export interface HubInfo { - hubId: string - url: string - connectionState: string - agentCount: number +/** Extract plain text from agent message content (string or content block array) */ +function extractText(content: string | { type: string; text?: string }[]): string { + if (typeof content === "string") return content + return content + .filter((b) => b.type === "text" && b.text) + .map((b) => b.text!) + .join("\n") } +export type HubInfo = GetHubInfoResult + export interface Agent { id: string closed: boolean @@ -27,6 +41,7 @@ interface HubActions { setActiveAgentId: (id: string | null) => void fetchHub: () => Promise fetchAgents: () => Promise + fetchAgentMessages: (agentId: string) => Promise createAgent: (options?: Record) => Promise deleteAgent: (id: string) => Promise } @@ -39,16 +54,23 @@ export const useHubStore = create()((set, get) => ({ agents: [], activeAgentId: null, - setActiveAgentId: (id) => set({ activeAgentId: id }), + setActiveAgentId: (id) => { + set({ activeAgentId: id }) + if (id) { + // Load history if no messages exist for this agent yet + const existing = useMessagesStore.getState().messages.filter((m) => m.agentId === id) + if (existing.length === 0) { + get().fetchAgentMessages(id) + } + } + }, fetchHub: async () => { set({ status: "loading" }) try { - const data = await consoleApi.get("/api/hub") - set({ - hub: data, - status: data.connectionState === "registered" ? "connected" : "error", - }) + const { request } = useGatewayStore.getState() + const data = await request("getHubInfo") + set({ hub: data, status: "connected" }) } catch { set({ status: "error", hub: null }) } @@ -56,17 +78,40 @@ export const useHubStore = create()((set, get) => ({ fetchAgents: async () => { try { - const data = await consoleApi.get("/api/agents") - set({ agents: data }) + const { request } = useGatewayStore.getState() + const data = await request("listAgents") + set({ agents: data.agents }) } catch (e) { toast.error("Failed to fetch agents") console.error(e) } }, + fetchAgentMessages: async (agentId) => { + try { + const { request } = useGatewayStore.getState() + const data = await request("getAgentMessages", { agentId }) + const msgs = data.messages + .filter((m): m is AgentMessageItem & { role: "user" | "assistant" } => + m.role === "user" || m.role === "assistant" + ) + .map((m) => ({ + id: uuidv7(), + role: m.role, + content: extractText(m.content), + agentId, + })) + .filter((m) => m.content.length > 0) + useMessagesStore.getState().loadMessages(agentId, msgs) + } catch (e) { + console.error("Failed to fetch agent messages:", e) + } + }, + createAgent: async (options?) => { try { - const data = await consoleApi.post<{ id: string }>("/api/agents", options) + const { request } = useGatewayStore.getState() + const data = await request("createAgent", options) await get().fetchAgents() if (data.id) set({ activeAgentId: data.id }) } catch (e) { @@ -78,7 +123,8 @@ export const useHubStore = create()((set, get) => ({ deleteAgent: async (id) => { if (get().activeAgentId === id) set({ activeAgentId: null }) try { - await consoleApi.delete("/api/agents/" + id) + const { request } = useGatewayStore.getState() + await request("deleteAgent", { id }) await get().fetchAgents() } catch (e) { toast.error("Failed to delete agent") diff --git a/packages/ui/src/components/chat.tsx b/packages/ui/src/components/chat.tsx index a510ca30..53476365 100644 --- a/packages/ui/src/components/chat.tsx +++ b/packages/ui/src/components/chat.tsx @@ -11,6 +11,7 @@ import { UserIcon, Copy01Icon, CheckmarkCircle02Icon } from "@hugeicons/core-fre import { toast } from "@multica/ui/components/ui/sonner"; import { useHubStore, useDeviceId, useMessagesStore, useGatewayStore } from "@multica/store"; import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade"; +import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll"; import { Skeleton } from "@multica/ui/components/ui/skeleton"; import { cn } from "@multica/ui/lib/utils"; @@ -29,11 +30,11 @@ export function Chat() { const filtered = useMemo(() => messages.filter(m => m.agentId === activeAgentId), [messages, activeAgentId]) const handleSend = useCallback((text: string) => { - const hub = useHubStore.getState().hub + const { hubId } = useGatewayStore.getState() const agentId = useHubStore.getState().activeAgentId - if (!hub?.hubId || !agentId) return + if (!hubId || !agentId) return useMessagesStore.getState().addUserMessage(text, agentId) - useGatewayStore.getState().send(hub.hubId, "message", { agentId, content: text }) + useGatewayStore.getState().send(hubId, "message", { agentId, content: text }) }, []) const canSend = gwState === "registered" && !!activeAgentId @@ -54,6 +55,7 @@ export function Chat() { const mainRef = useRef(null) const fadeStyle = useScrollFade(mainRef) + useAutoScroll(mainRef) return (
diff --git a/packages/ui/src/components/hub-sidebar.tsx b/packages/ui/src/components/hub-sidebar.tsx index fdd51596..a64b232e 100644 --- a/packages/ui/src/components/hub-sidebar.tsx +++ b/packages/ui/src/components/hub-sidebar.tsx @@ -1,5 +1,6 @@ "use client" +import { useState } from "react" import { SidebarGroup, SidebarGroupLabel, @@ -9,9 +10,10 @@ import { SidebarMenuItem, } from "@multica/ui/components/ui/sidebar" import { Button } from "@multica/ui/components/ui/button" +import { Input } from "@multica/ui/components/ui/input" import { HugeiconsIcon } from "@hugeicons/react" import { PlusSignIcon, Delete02Icon } from "@hugeicons/core-free-icons" -import { useHubStore } from "@multica/store" +import { useHubStore, useDeviceId, useGatewayStore } from "@multica/store" import { useHubInit } from "@multica/store" import { Skeleton } from "@multica/ui/components/ui/skeleton" @@ -36,38 +38,111 @@ export function HubSidebar() { const hub = useHubStore((s) => s.hub) const agents = useHubStore((s) => s.agents) const activeAgentId = useHubStore((s) => s.activeAgentId) - const fetchHub = useHubStore((s) => s.fetchHub) const createAgent = useHubStore((s) => s.createAgent) const deleteAgent = useHubStore((s) => s.deleteAgent) const setActiveAgentId = useHubStore((s) => s.setActiveAgentId) + const gwState = useGatewayStore((s) => s.connectionState) + const hubId = useGatewayStore((s) => s.hubId) + const hubs = useGatewayStore((s) => s.hubs) + const setHubId = useGatewayStore((s) => s.setHubId) + const listDevices = useGatewayStore((s) => s.listDevices) + + const [hubIdInput, setHubIdInput] = useState("") + const isRegistered = gwState === "registered" + const needsHubSelection = isRegistered && !hubId + + const handleConnect = () => { + const id = hubIdInput.trim() + if (!id) return + setHubId(id) + } + + const handleDisconnect = () => { + useGatewayStore.setState({ hubId: null }) + useHubStore.setState({ status: "idle", hub: null, agents: [], activeAgentId: null }) + } + return ( <> Hub -
- - {STATUS_LABEL[status]} -
- {status === "connected" && hub ? ( -
- {hub.hubId} + {!isRegistered ? ( +
+ + + {gwState === "disconnected" ? "Connecting to Gateway..." : "Registering..."} +
- ) : (status === "idle" || status === "loading") ? ( - - ) : null} - {status === "error" && ( -
- + ) : needsHubSelection ? ( +
+ {hubs.length > 0 && ( +
+ {hubs.map((h) => ( + + ))} +
+ )} + setHubIdInput(e.target.value)} + placeholder="Or enter Hub ID..." + className="h-7 text-xs font-mono" + onKeyDown={(e) => e.key === "Enter" && handleConnect()} + /> +
+ + +
+ ) : ( + <> +
+ + + {STATUS_LABEL[status]} + +
+ {status === "connected" && hub ? ( +
+ {hub.hubId} +
+ ) : (status === "idle" || status === "loading") ? ( + + ) : null} +
+ +
+ )} - {(status === "idle" || status === "loading") && ( + {isRegistered && hubId && (status === "idle" || status === "loading") && ( Agents diff --git a/packages/ui/src/hooks/use-auto-scroll.ts b/packages/ui/src/hooks/use-auto-scroll.ts new file mode 100644 index 00000000..8090631b --- /dev/null +++ b/packages/ui/src/hooks/use-auto-scroll.ts @@ -0,0 +1,64 @@ +import { type RefObject, useEffect, useRef } from "react" + +/** + * Auto-scrolls a scroll container to the bottom when its inner content grows, + * as long as the user hasn't scrolled up to read older content. + * + * Observes child element size changes via ResizeObserver on all children, + * plus MutationObserver for added/removed nodes. Works for new messages, + * history loads, streaming updates, and image loads. + */ +export function useAutoScroll(ref: RefObject) { + const stickRef = useRef(true) + + useEffect(() => { + const el = ref.current + if (!el) return + + const scrollToBottom = () => { + el.scrollTo({ top: el.scrollHeight }) + } + + const onScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = el + stickRef.current = scrollHeight - scrollTop - clientHeight < 50 + } + + const onContentChange = () => { + if (stickRef.current) { + scrollToBottom() + } + } + + // Watch child element resizes (content growth, image loads, streaming) + const ro = new ResizeObserver(onContentChange) + for (const child of el.children) { + ro.observe(child) + } + + // Watch for added/removed child nodes (new messages rendered) + const mo = new MutationObserver((mutations) => { + // Also observe newly added elements + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node instanceof Element) { + ro.observe(node) + } + } + } + onContentChange() + }) + mo.observe(el, { childList: true, subtree: true }) + + el.addEventListener("scroll", onScroll, { passive: true }) + + // Initial scroll to bottom + scrollToBottom() + + return () => { + el.removeEventListener("scroll", onScroll) + ro.disconnect() + mo.disconnect() + } + }, [ref]) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6272ecc3..0fc53daf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -214,9 +214,6 @@ importers: '@hugeicons/react': specifier: ^1.1.4 version: 1.1.4(react@19.2.3) - '@multica/fetch': - specifier: workspace:* - version: link:../../packages/fetch '@multica/sdk': specifier: workspace:* version: link:../../packages/sdk @@ -261,12 +258,6 @@ importers: specifier: 'catalog:' version: 5.9.3 - packages/fetch: - devDependencies: - typescript: - specifier: 'catalog:' - version: 5.9.3 - packages/sdk: dependencies: socket.io-client: @@ -285,9 +276,6 @@ importers: packages/store: dependencies: - '@multica/fetch': - specifier: workspace:* - version: link:../fetch '@multica/sdk': specifier: workspace:* version: link:../sdk diff --git a/src/gateway/events.gateway.ts b/src/gateway/events.gateway.ts index 0197e0f4..ebeb2191 100644 --- a/src/gateway/events.gateway.ts +++ b/src/gateway/events.gateway.ts @@ -169,6 +169,17 @@ export class EventsGateway this.server.to(targetSocketId).emit(GatewayEvents.RECEIVE, message); } + @SubscribeMessage(GatewayEvents.LIST_DEVICES) + handleListDevices( + @ConnectedSocket() client: Socket + ): { devices: DeviceInfo[] } { + const senderDevice = this.socketToDevice.get(client.id); + if (!senderDevice) { + return { devices: [] }; + } + return { devices: this.getOnlineDevices() }; + } + @SubscribeMessage(GatewayEvents.PING) handlePing( @MessageBody() data: PingPayload, @@ -184,7 +195,7 @@ export class EventsGateway } /** Get online devices of specified type */ - getOnlineDevicesByType(type: "client" | "agent"): DeviceInfo[] { + getOnlineDevicesByType(type: DeviceType): DeviceInfo[] { return this.getOnlineDevices().filter((d) => d.deviceType === type); } diff --git a/src/hub/hub.ts b/src/hub/hub.ts index 5765cef1..984082a2 100644 --- a/src/hub/hub.ts +++ b/src/hub/hub.ts @@ -12,6 +12,11 @@ import { getHubId } from "./hub-identity.js"; import { loadAgentRecords, addAgentRecord, removeAgentRecord } from "./agent-store.js"; import { RpcDispatcher, RpcError } from "./rpc/dispatcher.js"; import { createGetAgentMessagesHandler } from "./rpc/handlers/get-agent-messages.js"; +import { createGetHubInfoHandler } from "./rpc/handlers/get-hub-info.js"; +import { createListAgentsHandler } from "./rpc/handlers/list-agents.js"; +import { createCreateAgentHandler } from "./rpc/handlers/create-agent.js"; +import { createDeleteAgentHandler } from "./rpc/handlers/delete-agent.js"; +import { createUpdateGatewayHandler } from "./rpc/handlers/update-gateway.js"; export class Hub { private readonly agents = new Map(); @@ -34,6 +39,11 @@ export class Hub { this.rpc = new RpcDispatcher(); this.rpc.register("getAgentMessages", createGetAgentMessagesHandler()); + this.rpc.register("getHubInfo", createGetHubInfoHandler(this)); + this.rpc.register("listAgents", createListAgentsHandler(this)); + this.rpc.register("createAgent", createCreateAgentHandler(this)); + this.rpc.register("deleteAgent", createDeleteAgentHandler(this)); + this.rpc.register("updateGateway", createUpdateGatewayHandler(this)); this.client = this.createClient(this.url); this.client.connect(); @@ -56,7 +66,7 @@ export class Hub { url, path: this.path, deviceId: this.hubId, - deviceType: "client", + deviceType: "hub", autoReconnect: true, reconnectDelay: 1000, }); diff --git a/src/hub/rpc/handlers/create-agent.ts b/src/hub/rpc/handlers/create-agent.ts new file mode 100644 index 00000000..c0ab1186 --- /dev/null +++ b/src/hub/rpc/handlers/create-agent.ts @@ -0,0 +1,13 @@ +import type { RpcHandler } from "../dispatcher.js"; + +interface HubLike { + createAgent(id?: string): { sessionId: string }; +} + +export function createCreateAgentHandler(hub: HubLike): RpcHandler { + return (params: unknown) => { + const { id } = (params ?? {}) as { id?: string }; + const agent = hub.createAgent(id); + return { id: agent.sessionId }; + }; +} diff --git a/src/hub/rpc/handlers/delete-agent.ts b/src/hub/rpc/handlers/delete-agent.ts new file mode 100644 index 00000000..72207f24 --- /dev/null +++ b/src/hub/rpc/handlers/delete-agent.ts @@ -0,0 +1,19 @@ +import { RpcError, type RpcHandler } from "../dispatcher.js"; + +interface HubLike { + closeAgent(id: string): boolean; +} + +export function createDeleteAgentHandler(hub: HubLike): RpcHandler { + return (params: unknown) => { + if (!params || typeof params !== "object") { + throw new RpcError("INVALID_PARAMS", "params must be an object"); + } + const { id } = params as { id?: string }; + if (!id) { + throw new RpcError("INVALID_PARAMS", "Missing required param: id"); + } + const ok = hub.closeAgent(id); + return { ok }; + }; +} diff --git a/src/hub/rpc/handlers/get-hub-info.ts b/src/hub/rpc/handlers/get-hub-info.ts new file mode 100644 index 00000000..46da8784 --- /dev/null +++ b/src/hub/rpc/handlers/get-hub-info.ts @@ -0,0 +1,17 @@ +import type { RpcHandler } from "../dispatcher.js"; + +interface HubLike { + hubId: string; + url: string; + connectionState: string; + listAgents(): string[]; +} + +export function createGetHubInfoHandler(hub: HubLike): RpcHandler { + return () => ({ + hubId: hub.hubId, + url: hub.url, + connectionState: hub.connectionState, + agentCount: hub.listAgents().length, + }); +} diff --git a/src/hub/rpc/handlers/list-agents.ts b/src/hub/rpc/handlers/list-agents.ts new file mode 100644 index 00000000..ccd5c3e2 --- /dev/null +++ b/src/hub/rpc/handlers/list-agents.ts @@ -0,0 +1,16 @@ +import type { RpcHandler } from "../dispatcher.js"; + +interface HubLike { + listAgents(): string[]; + getAgent(id: string): { closed: boolean } | undefined; +} + +export function createListAgentsHandler(hub: HubLike): RpcHandler { + return () => { + const agents = hub.listAgents().map((id) => { + const agent = hub.getAgent(id); + return { id, closed: agent?.closed ?? true }; + }); + return { agents }; + }; +} diff --git a/src/hub/rpc/handlers/update-gateway.ts b/src/hub/rpc/handlers/update-gateway.ts new file mode 100644 index 00000000..acc4c9ce --- /dev/null +++ b/src/hub/rpc/handlers/update-gateway.ts @@ -0,0 +1,21 @@ +import { RpcError, type RpcHandler } from "../dispatcher.js"; + +interface HubLike { + url: string; + connectionState: string; + reconnect(url: string): void; +} + +export function createUpdateGatewayHandler(hub: HubLike): RpcHandler { + return (params: unknown) => { + if (!params || typeof params !== "object") { + throw new RpcError("INVALID_PARAMS", "params must be an object"); + } + const { url } = params as { url?: string }; + if (!url) { + throw new RpcError("INVALID_PARAMS", "Missing required param: url"); + } + hub.reconnect(url); + return { url: hub.url, connectionState: hub.connectionState }; + }; +} diff --git a/src/hub/rpc/index.ts b/src/hub/rpc/index.ts index c482999a..7bd88565 100644 --- a/src/hub/rpc/index.ts +++ b/src/hub/rpc/index.ts @@ -1,2 +1,7 @@ export { RpcDispatcher, RpcError, type RpcHandler } from "./dispatcher.js"; export { createGetAgentMessagesHandler } from "./handlers/get-agent-messages.js"; +export { createGetHubInfoHandler } from "./handlers/get-hub-info.js"; +export { createListAgentsHandler } from "./handlers/list-agents.js"; +export { createCreateAgentHandler } from "./handlers/create-agent.js"; +export { createDeleteAgentHandler } from "./handlers/delete-agent.js"; +export { createUpdateGatewayHandler } from "./handlers/update-gateway.js";