Merge pull request #57 from multica-ai/feature/ws-migration
feat: migrate frontend to pure WebSocket RPC
This commit is contained in:
commit
ebcab2477a
29 changed files with 645 additions and 152 deletions
|
|
@ -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" });
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@multica/fetch": "workspace:*",
|
||||
"@multica/sdk": "workspace:*",
|
||||
"@multica/store": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
|
|
|
|||
130
docs/rpc.md
130
docs/rpc.md
|
|
@ -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/`:
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"name": "@multica/fetch",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { setConfig, getConsoleUrl, getGatewayUrl } from "./config"
|
||||
export { consoleApi, HttpError } from "./http-client"
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export {
|
|||
type ConnectionState,
|
||||
type PingPayload,
|
||||
type PongResponse,
|
||||
type ListDevicesResponse,
|
||||
} from "./types.js";
|
||||
|
||||
// Actions
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
"./*": "./src/*.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@multica/fetch": "workspace:*",
|
||||
"@multica/sdk": "workspace:*",
|
||||
"react": "catalog:",
|
||||
"sonner": "^2.0.7",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
64
packages/ui/src/hooks/use-auto-scroll.ts
Normal file
64
packages/ui/src/hooks/use-auto-scroll.ts
Normal 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
12
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
13
src/hub/rpc/handlers/create-agent.ts
Normal file
13
src/hub/rpc/handlers/create-agent.ts
Normal 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 };
|
||||
};
|
||||
}
|
||||
19
src/hub/rpc/handlers/delete-agent.ts
Normal file
19
src/hub/rpc/handlers/delete-agent.ts
Normal 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 };
|
||||
};
|
||||
}
|
||||
17
src/hub/rpc/handlers/get-hub-info.ts
Normal file
17
src/hub/rpc/handlers/get-hub-info.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
16
src/hub/rpc/handlers/list-agents.ts
Normal file
16
src/hub/rpc/handlers/list-agents.ts
Normal 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 };
|
||||
};
|
||||
}
|
||||
21
src/hub/rpc/handlers/update-gateway.ts
Normal file
21
src/hub/rpc/handlers/update-gateway.ts
Normal 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 };
|
||||
};
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue