Merge pull request #57 from multica-ai/feature/ws-migration

feat: migrate frontend to pure WebSocket RPC
This commit is contained in:
Naiyuan Qing 2026-02-02 16:35:19 +08:00 committed by GitHub
commit ebcab2477a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 645 additions and 152 deletions

View file

@ -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" });

View file

@ -9,7 +9,6 @@
"lint": "eslint"
},
"dependencies": {
"@multica/fetch": "workspace:*",
"@multica/sdk": "workspace:*",
"@multica/store": "workspace:*",
"@multica/ui": "workspace:*",

View file

@ -207,6 +207,136 @@ const result = await client.request<GetAgentMessagesResult>(
}
```
### `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<GetHubInfoResult>(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<ListAgentsResult>(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<CreateAgentResult>(hubDeviceId, "createAgent");
// or with specific ID:
const result = await client.request<CreateAgentResult>(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<DeleteAgentResult>(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<UpdateGatewayResult>(hubDeviceId, "updateGateway", { url: "http://localhost:4000" });
```
---
## Adding New RPC Methods
1. Create a handler file in `src/hub/rpc/handlers/`:

View file

@ -1,13 +0,0 @@
{
"name": "@multica/fetch",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./*": "./src/*.ts"
},
"devDependencies": {
"typescript": "catalog:"
}
}

View file

@ -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
}

View file

@ -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<T>(method: string, path: string, body?: unknown): Promise<T> {
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: <T>(path: string) => request<T>("GET", path),
post: <T>(path: string, body?: unknown) => request<T>("POST", path, body),
put: <T>(path: string, body: unknown) => request<T>("PUT", path, body),
delete: <T>(path: string) => request<T>("DELETE", path),
}

View file

@ -1,2 +0,0 @@
export { setConfig, getConsoleUrl, getGatewayUrl } from "./config"
export { consoleApi, HttpError } from "./http-client"

View file

@ -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"]
}

View file

@ -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";

View file

@ -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;
}

View file

@ -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<DeviceInfo[]> {
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<T = unknown>(
to: string,

View file

@ -11,6 +11,7 @@ export {
type ConnectionState,
type PingPayload,
type PongResponse,
type ListDevicesResponse,
} from "./types.js";
// Actions

View file

@ -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 */

View file

@ -8,7 +8,6 @@
"./*": "./src/*.ts"
},
"dependencies": {
"@multica/fetch": "workspace:*",
"@multica/sdk": "workspace:*",
"react": "catalog:",
"sonner": "^2.0.7",

View file

@ -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<DeviceInfo[]>
send: (to: string, action: string, payload: unknown) => void
request: <T = unknown>(method: string, params?: unknown) => Promise<T>
}
export type GatewayStore = GatewayState & GatewayActions
let client: GatewayClient | null = null
export const useGatewayStore = create<GatewayStore>()((set) => ({
export const useGatewayStore = create<GatewayStore>()((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<GatewayStore>()((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: <T = unknown>(method: string, params?: unknown): Promise<T> => {
const { hubId } = get()
if (!client?.isRegistered || !hubId) {
return Promise.reject(new Error("Not connected"))
}
return client.request<T>(hubId, method, params)
},
}))

View file

@ -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])
}

View file

@ -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<void>
fetchAgents: () => Promise<void>
fetchAgentMessages: (agentId: string) => Promise<void>
createAgent: (options?: Record<string, unknown>) => Promise<void>
deleteAgent: (id: string) => Promise<void>
}
@ -39,16 +54,23 @@ export const useHubStore = create<HubStore>()((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<HubInfo>("/api/hub")
set({
hub: data,
status: data.connectionState === "registered" ? "connected" : "error",
})
const { request } = useGatewayStore.getState()
const data = await request<GetHubInfoResult>("getHubInfo")
set({ hub: data, status: "connected" })
} catch {
set({ status: "error", hub: null })
}
@ -56,17 +78,40 @@ export const useHubStore = create<HubStore>()((set, get) => ({
fetchAgents: async () => {
try {
const data = await consoleApi.get<Agent[]>("/api/agents")
set({ agents: data })
const { request } = useGatewayStore.getState()
const data = await request<ListAgentsResult>("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<GetAgentMessagesResult>("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<CreateAgentResult>("createAgent", options)
await get().fetchAgents()
if (data.id) set({ activeAgentId: data.id })
} catch (e) {
@ -78,7 +123,8 @@ export const useHubStore = create<HubStore>()((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<DeleteAgentResult>("deleteAgent", { id })
await get().fetchAgents()
} catch (e) {
toast.error("Failed to delete agent")

View file

@ -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<HTMLElement>(null)
const fadeStyle = useScrollFade(mainRef)
useAutoScroll(mainRef)
return (
<div className="h-dvh flex flex-col overflow-hidden w-full">

View file

@ -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 (
<>
<SidebarGroup>
<SidebarGroupLabel>Hub</SidebarGroupLabel>
<SidebarGroupContent>
<div className="flex items-center gap-2 px-2 py-1 text-sm">
<span className={`size-2 rounded-full shrink-0 ${STATUS_DOT[status]}`} />
<span className="text-muted-foreground/70 text-xs">{STATUS_LABEL[status]}</span>
</div>
{status === "connected" && hub ? (
<div className="px-2 text-xs text-muted-foreground/50 font-mono truncate">
{hub.hubId}
{!isRegistered ? (
<div className="flex items-center gap-2 px-2 py-1 text-sm">
<span className="size-2 rounded-full shrink-0 bg-yellow-500/50 animate-pulse" />
<span className="text-muted-foreground/70 text-xs">
{gwState === "disconnected" ? "Connecting to Gateway..." : "Registering..."}
</span>
</div>
) : (status === "idle" || status === "loading") ? (
<Skeleton className="mx-2 h-3.5 w-32" />
) : null}
{status === "error" && (
<div className="px-2 pt-1">
<Button variant="outline" size="sm" onClick={fetchHub} className="w-full text-xs">
Retry
</Button>
) : needsHubSelection ? (
<div className="px-2 space-y-2 py-1">
{hubs.length > 0 && (
<div className="space-y-1">
{hubs.map((h) => (
<button
key={h.deviceId}
onClick={() => setHubId(h.deviceId)}
className="w-full text-left px-2 py-1 rounded-md text-xs font-mono hover:bg-sidebar-accent hover:text-sidebar-accent-foreground cursor-pointer truncate"
>
{h.deviceId}
</button>
))}
</div>
)}
<Input
value={hubIdInput}
onChange={(e) => setHubIdInput(e.target.value)}
placeholder="Or enter Hub ID..."
className="h-7 text-xs font-mono"
onKeyDown={(e) => e.key === "Enter" && handleConnect()}
/>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={handleConnect}
disabled={!hubIdInput.trim()}
className="flex-1 text-xs"
>
Connect
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => listDevices()}
className="text-xs"
>
Refresh
</Button>
</div>
</div>
) : (
<>
<div className="flex items-center gap-2 px-2 py-1 text-sm">
<span className={`size-2 rounded-full shrink-0 ${STATUS_DOT[status] ?? STATUS_DOT.idle}`} />
<span className="text-muted-foreground/70 text-xs">
{STATUS_LABEL[status]}
</span>
</div>
{status === "connected" && hub ? (
<div className="px-2 text-xs text-muted-foreground/50 font-mono truncate">
{hub.hubId}
</div>
) : (status === "idle" || status === "loading") ? (
<Skeleton className="mx-2 h-3.5 w-32" />
) : null}
<div className="px-2 pt-1">
<Button variant="outline" size="sm" onClick={handleDisconnect} className="w-full text-xs">
Disconnect
</Button>
</div>
</>
)}
</SidebarGroupContent>
</SidebarGroup>
{(status === "idle" || status === "loading") && (
{isRegistered && hubId && (status === "idle" || status === "loading") && (
<SidebarGroup>
<SidebarGroupLabel>Agents</SidebarGroupLabel>
<SidebarGroupContent>

View file

@ -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<HTMLElement | null>) {
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])
}

12
pnpm-lock.yaml generated
View file

@ -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

View file

@ -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);
}

View file

@ -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<string, AsyncAgent>();
@ -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,
});

View file

@ -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 };
};
}

View file

@ -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 };
};
}

View file

@ -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,
});
}

View file

@ -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 };
};
}

View file

@ -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 };
};
}

View file

@ -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";