refactor(frontend): complete migration of all hooks and components to packages

- Create gateway store in @multica/store (WS connection independent of components)
- Gateway auto-connects when hub is ready, messages handled internally
- Move scroll-fade hook to @multica/ui/hooks
- Move Chat component to @multica/ui/components
- Add setConfig() call in web layout for URL injection
- Delete all web-local hooks, components, and lib/config
- Web app is now a pure shell: layout.tsx + page.tsx

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-02 11:07:57 +08:00
parent 97fce5b113
commit eef26d8675
12 changed files with 88 additions and 60 deletions

View file

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

View file

@ -1,6 +1,12 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono, Inter, Playfair_Display } from "next/font/google";
import { setConfig } from "@multica/fetch";
import "@multica/ui/globals.css";
setConfig({
consoleUrl: process.env.NEXT_PUBLIC_CONSOLE_URL ?? "http://localhost:4000",
gatewayUrl: process.env.NEXT_PUBLIC_GATEWAY_URL ?? "http://localhost:3000",
});
import {
SidebarProvider,
SidebarInset,

View file

@ -1,2 +0,0 @@
export const GATEWAY_URL = process.env.NEXT_PUBLIC_GATEWAY_URL ?? "http://localhost:3000"
export const CONSOLE_URL = process.env.NEXT_PUBLIC_CONSOLE_URL ?? "http://localhost:4000"

View file

@ -1,4 +1,4 @@
import { Chat } from "./components/chat";
import { Chat } from "@multica/ui/components/chat";
export default function Page() {
return <Chat />;

View file

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

View file

@ -9,6 +9,7 @@
},
"dependencies": {
"@multica/fetch": "workspace:*",
"@multica/sdk": "workspace:*",
"react": "catalog:",
"uuid": "^13.0.0",
"zustand": "catalog:"

View file

@ -0,0 +1,57 @@
import { create } from "zustand"
import { GatewayClient, type ConnectionState, type SendErrorResponse } from "@multica/sdk"
import { getGatewayUrl } from "@multica/fetch"
import { useMessagesStore } from "./messages"
interface GatewayState {
connectionState: ConnectionState
lastError: SendErrorResponse | null
}
interface GatewayActions {
connect: (deviceId: string) => void
disconnect: () => void
send: (to: string, action: string, payload: unknown) => void
}
export type GatewayStore = GatewayState & GatewayActions
let client: GatewayClient | null = null
export const useGatewayStore = create<GatewayStore>()((set) => ({
connectionState: "disconnected",
lastError: null,
connect: (deviceId) => {
if (client) return
client = new GatewayClient({
url: getGatewayUrl(),
deviceId,
deviceType: "client",
})
.onStateChange((connectionState) => set({ connectionState }))
.onMessage((msg) => {
const payload = msg.payload as { agentId?: string; content?: string }
if (payload?.agentId && payload?.content) {
useMessagesStore.getState().addAssistantMessage(payload.content, payload.agentId)
}
})
.onSendError((error) => set({ lastError: error }))
client.connect()
},
disconnect: () => {
if (client) {
client.disconnect()
client = null
}
set({ connectionState: "disconnected" })
},
send: (to, action, payload) => {
if (!client?.isRegistered) return
client.send(to, action, payload)
},
}))

View file

@ -2,11 +2,14 @@
import { useEffect } from "react"
import { useHubStore } from "./hub"
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()
useEffect(() => { fetchHub() }, [fetchHub])
useEffect(() => {
@ -16,4 +19,11 @@ export function useHubInit() {
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) {
useGatewayStore.getState().connect(deviceId)
}
}, [status, deviceId])
}

View file

@ -4,3 +4,5 @@ export { useHubInit } from "./hub-init"
export { useDeviceId } from "./device-id"
export { useMessagesStore } from "./messages"
export type { Message, MessagesStore } from "./messages"
export { useGatewayStore } from "./gateway"
export type { GatewayStore } from "./gateway"

View file

@ -9,9 +9,8 @@ import { MemoizedMarkdown } from "@multica/ui/components/markdown";
import { HugeiconsIcon } from "@hugeicons/react";
import { UserIcon, Copy01Icon, CheckmarkCircle02Icon } from "@hugeicons/core-free-icons";
import { toast } from "@multica/ui/components/ui/sonner";
import { useGateway } from "../hooks/use-gateway";
import { useHubStore, useDeviceId, useMessagesStore } from "@multica/store";
import { useScrollFade } from "../hooks/use-scroll-fade";
import { useHubStore, useDeviceId, useMessagesStore, useGatewayStore } from "@multica/store";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import { cn } from "@multica/ui/lib/utils";
const STATE_VARIANT: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
@ -25,17 +24,9 @@ export function Chat() {
const activeAgentId = useHubStore((s) => s.activeAgentId)
const hub = useHubStore((s) => s.hub)
const addUserMessage = useMessagesStore((s) => s.addUserMessage)
const addAssistantMessage = useMessagesStore((s) => s.addAssistantMessage)
const messages = useMessagesStore((s) => s.messages)
const { state: gwState, send } = useGateway({
onMessage: (msg) => {
const payload = msg.payload as { agentId?: string; content?: string }
if (payload?.agentId && payload?.content) {
addAssistantMessage(payload.content, payload.agentId)
}
},
})
const gwState = useGatewayStore((s) => s.connectionState)
const send = useGatewayStore((s) => s.send)
const handleSend = (text: string) => {
if (!hub?.hubId || !activeAgentId) return

6
pnpm-lock.yaml generated
View file

@ -211,6 +211,9 @@ 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
@ -282,6 +285,9 @@ importers:
'@multica/fetch':
specifier: workspace:*
version: link:../fetch
'@multica/sdk':
specifier: workspace:*
version: link:../sdk
react:
specifier: 'catalog:'
version: 19.2.3