refactor(frontend): extract shared stores and components into packages

- Create @multica/fetch package for HTTP client and URL config
- Migrate hub store and hub-init hook to @multica/store
- Move HubSidebar component to @multica/ui for web/desktop reuse
- Update web app imports to use shared packages
- Remove counter store example and its component-example usage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-02 10:37:17 +08:00
parent 46b7906272
commit 63861d03c6
15 changed files with 143 additions and 127 deletions

View file

@ -11,7 +11,7 @@ import { UserIcon, Copy01Icon, CheckmarkCircle02Icon } from "@hugeicons/core-fre
import { toast } from "@multica/ui/components/ui/sonner";
import { useMessages } from "../hooks/use-messages";
import { useGateway } from "../hooks/use-gateway";
import { useHubStore } from "../hooks/use-hub-store";
import { useHubStore } from "@multica/store";
import { useDeviceId } from "../hooks/use-device-id";
import { useScrollFade } from "../hooks/use-scroll-fade";
import { cn } from "@multica/ui/lib/utils";

View file

@ -8,7 +8,7 @@ import {
import { AppSidebar } from "@multica/ui/components/app-sidebar";
import { ThemeProvider } from "@multica/ui/components/theme-provider";
import { Toaster } from "@multica/ui/components/ui/sonner";
import { HubSidebar } from "./components/hub-sidebar";
import { HubSidebar } from "@multica/ui/components/hub-sidebar";
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });

View file

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

View file

@ -0,0 +1,15 @@
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

@ -0,0 +1,28 @@
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

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

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}

View file

@ -8,9 +8,12 @@
"./*": "./src/*.ts"
},
"dependencies": {
"@multica/fetch": "workspace:*",
"react": "catalog:",
"zustand": "catalog:"
},
"devDependencies": {
"@types/react": "catalog:",
"typescript": "catalog:"
}
}

View file

@ -1,15 +0,0 @@
import { create } from 'zustand'
interface CounterState {
count: number
increment: () => void
decrement: () => void
reset: () => void
}
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}))

View file

@ -1,7 +1,7 @@
"use client"
import { useEffect } from "react"
import { useHubStore } from "./use-hub-store"
import { useHubStore } from "./hub"
export function useHubInit() {
const fetchHub = useHubStore((s) => s.fetchHub)

View file

@ -1,33 +1,37 @@
import { create } from "zustand"
import { CONSOLE_URL } from "../lib/config"
import { consoleApi } from "@multica/fetch"
interface HubInfo {
export interface HubInfo {
hubId: string
url: string
connectionState: string
agentCount: number
}
interface Agent {
export interface Agent {
id: string
closed: boolean
}
type HubStatus = "idle" | "loading" | "connected" | "error"
export type HubStatus = "idle" | "loading" | "connected" | "error"
interface HubStore {
interface HubState {
status: HubStatus
hub: HubInfo | null
agents: Agent[]
activeAgentId: string | null
}
interface HubActions {
setActiveAgentId: (id: string | null) => void
fetchHub: () => Promise<void>
fetchAgents: () => Promise<void>
createAgent: () => Promise<void>
createAgent: (options?: Record<string, unknown>) => Promise<void>
deleteAgent: (id: string) => Promise<void>
}
export type HubStore = HubState & HubActions
export const useHubStore = create<HubStore>()((set, get) => ({
status: "idle",
hub: null,
@ -39,9 +43,7 @@ export const useHubStore = create<HubStore>()((set, get) => ({
fetchHub: async () => {
set({ status: "loading" })
try {
const res = await fetch(`${CONSOLE_URL}/api/hub`)
if (!res.ok) throw new Error(res.statusText)
const data: HubInfo = await res.json()
const data = await consoleApi.get<HubInfo>("/api/hub")
set({
hub: data,
status: data.connectionState === "registered" ? "connected" : "error",
@ -53,23 +55,24 @@ export const useHubStore = create<HubStore>()((set, get) => ({
fetchAgents: async () => {
try {
const res = await fetch(`${CONSOLE_URL}/api/agents`)
if (res.ok) set({ agents: await res.json() })
const data = await consoleApi.get<Agent[]>("/api/agents")
set({ agents: data })
} catch { /* silent */ }
},
createAgent: async () => {
const res = await fetch(`${CONSOLE_URL}/api/agents`, { method: "POST" })
await get().fetchAgents()
if (res.ok) {
const data = await res.json()
createAgent: async (options?) => {
try {
const data = await consoleApi.post<{ id: string }>("/api/agents", options)
await get().fetchAgents()
if (data.id) set({ activeAgentId: data.id })
}
} catch { /* silent */ }
},
deleteAgent: async (id) => {
if (get().activeAgentId === id) set({ activeAgentId: null })
await fetch(`${CONSOLE_URL}/api/agents/${id}`, { method: "DELETE" })
await get().fetchAgents()
try {
await consoleApi.delete("/api/agents/" + id)
await get().fetchAgents()
} catch { /* silent */ }
},
}))

View file

@ -1 +1,3 @@
export { useCounterStore } from './counter'
export { useHubStore } from "./hub"
export type { HubInfo, Agent, HubStatus, HubStore } from "./hub"
export { useHubInit } from "./hub-init"

View file

@ -65,52 +65,15 @@ import {
import { Textarea } from "@multica/ui/components/ui/textarea"
import { HugeiconsIcon } from "@hugeicons/react"
import { PlusSignIcon, BluetoothIcon, MoreVerticalCircle01Icon, FileIcon, FolderIcon, FolderOpenIcon, CodeIcon, MoreHorizontalCircle01Icon, SearchIcon, FloppyDiskIcon, DownloadIcon, EyeIcon, LayoutIcon, PaintBoardIcon, SunIcon, MoonIcon, ComputerIcon, UserIcon, CreditCardIcon, SettingsIcon, KeyboardIcon, LanguageCircleIcon, NotificationIcon, MailIcon, ShieldIcon, HelpCircleIcon, File01Icon, LogoutIcon } from "@hugeicons/core-free-icons"
import { useCounterStore } from "@multica/store/counter"
export function ComponentExample() {
return (
<ExampleWrapper>
<CounterExample />
<CardExample />
<FormExample />
</ExampleWrapper>
)
}
function CounterExample() {
const { count, increment, decrement, reset } = useCounterStore()
return (
<Example title="Counter (Zustand Store)">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Shared Counter</CardTitle>
<CardDescription>
This counter uses Zustand from @multica/store, shared across web and desktop.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center gap-4">
<Button variant="outline" size="icon" onClick={decrement}>
-
</Button>
<span className="text-4xl font-bold tabular-nums">{count}</span>
<Button variant="outline" size="icon" onClick={increment}>
+
</Button>
</div>
</CardContent>
<CardFooter className="justify-between">
<Button variant="ghost" onClick={reset}>
Reset
</Button>
<Badge variant="secondary">Count: {count}</Badge>
</CardFooter>
</Card>
</Example>
)
}
function CardExample() {
return (
<Example title="Card" className="items-center justify-center">

View file

@ -11,8 +11,8 @@ import {
import { Button } from "@multica/ui/components/ui/button"
import { HugeiconsIcon } from "@hugeicons/react"
import { PlusSignIcon, Delete02Icon } from "@hugeicons/core-free-icons"
import { useHubStore } from "../hooks/use-hub-store"
import { useHubInit } from "../hooks/use-hub-init"
import { useHubStore } from "@multica/store"
import { useHubInit } from "@multica/store"
const STATUS_DOT: Record<string, string> = {
connected: "bg-green-500/60",
@ -67,7 +67,7 @@ export function HubSidebar() {
{status === "connected" && (
<SidebarGroup>
<SidebarGroupLabel>Agents</SidebarGroupLabel>
<SidebarGroupAction onClick={createAgent} title="Create agent">
<SidebarGroupAction onClick={() => createAgent()} title="Create agent">
<HugeiconsIcon icon={PlusSignIcon} strokeWidth={2} className="size-4" />
</SidebarGroupAction>
<SidebarGroupContent>

86
pnpm-lock.yaml generated
View file

@ -34,13 +34,13 @@ importers:
dependencies:
'@mariozechner/pi-agent-core':
specifier: ^0.50.3
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
'@mariozechner/pi-ai':
specifier: ^0.50.3
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
'@mariozechner/pi-coding-agent':
specifier: ^0.50.3
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
'@mozilla/readability':
specifier: ^0.6.0
version: 0.6.0
@ -255,6 +255,12 @@ importers:
specifier: 'catalog:'
version: 5.9.3
packages/fetch:
devDependencies:
typescript:
specifier: 'catalog:'
version: 5.9.3
packages/sdk:
dependencies:
socket.io-client:
@ -273,10 +279,19 @@ importers:
packages/store:
dependencies:
'@multica/fetch':
specifier: workspace:*
version: link:../fetch
react:
specifier: 'catalog:'
version: 19.2.3
zustand:
specifier: 'catalog:'
version: 5.0.10(@types/react@19.2.10)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))
devDependencies:
'@types/react':
specifier: 'catalog:'
version: 19.2.10
typescript:
specifier: 'catalog:'
version: 5.9.3
@ -6400,11 +6415,11 @@ snapshots:
package-manager-detector: 1.6.0
tinyexec: 1.0.2
'@anthropic-ai/sdk@0.71.2(zod@4.3.6)':
'@anthropic-ai/sdk@0.71.2(zod@3.25.76)':
dependencies:
json-schema-to-ts: 3.1.1
optionalDependencies:
zod: 4.3.6
zod: 3.25.76
'@aws-crypto/crc32@5.2.0':
dependencies:
@ -7427,12 +7442,12 @@ snapshots:
'@floating-ui/utils@0.2.10': {}
'@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))':
'@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))':
dependencies:
google-auth-library: 10.5.0
ws: 8.18.3
optionalDependencies:
'@modelcontextprotocol/sdk': 1.25.3(hono@4.11.7)(zod@4.3.6)
'@modelcontextprotocol/sdk': 1.25.3(hono@4.11.7)(zod@3.25.76)
transitivePeerDependencies:
- bufferutil
- supports-color
@ -7687,9 +7702,9 @@ snapshots:
std-env: 3.10.0
yoctocolors: 2.1.2
'@mariozechner/pi-agent-core@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)':
'@mariozechner/pi-agent-core@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)':
dependencies:
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
'@mariozechner/pi-tui': 0.50.3
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
@ -7700,21 +7715,21 @@ snapshots:
- ws
- zod
'@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)':
'@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)':
dependencies:
'@anthropic-ai/sdk': 0.71.2(zod@4.3.6)
'@anthropic-ai/sdk': 0.71.2(zod@3.25.76)
'@aws-sdk/client-bedrock-runtime': 3.978.0
'@google/genai': 1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))
'@google/genai': 1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))
'@mistralai/mistralai': 1.10.0
'@sinclair/typebox': 0.34.48
ajv: 8.17.1
ajv-formats: 3.0.1(ajv@8.17.1)
chalk: 5.6.2
openai: 6.10.0(ws@8.18.3)(zod@4.3.6)
openai: 6.10.0(ws@8.18.3)(zod@3.25.76)
partial-json: 0.1.7
proxy-agent: 6.5.0
undici: 7.19.2
zod-to-json-schema: 3.25.1(zod@4.3.6)
zod-to-json-schema: 3.25.1(zod@3.25.76)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- aws-crt
@ -7724,12 +7739,12 @@ snapshots:
- ws
- zod
'@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)':
'@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)':
dependencies:
'@mariozechner/clipboard': 0.3.0
'@mariozechner/jiti': 2.6.5
'@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
'@mariozechner/pi-tui': 0.50.3
'@silvia-odwyer/photon-node': 0.3.4
chalk: 5.6.2
@ -7787,29 +7802,6 @@ snapshots:
- hono
- supports-color
'@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6)':
dependencies:
'@hono/node-server': 1.19.9(hono@4.11.7)
ajv: 8.17.1
ajv-formats: 3.0.1(ajv@8.17.1)
content-type: 1.0.5
cors: 2.8.6
cross-spawn: 7.0.6
eventsource: 3.0.7
eventsource-parser: 3.0.6
express: 5.2.1
express-rate-limit: 7.5.1(express@5.2.1)
jose: 6.1.3
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
raw-body: 3.0.2
zod: 4.3.6
zod-to-json-schema: 3.25.1(zod@4.3.6)
transitivePeerDependencies:
- hono
- supports-color
optional: true
'@mozilla/readability@0.6.0': {}
'@mswjs/interceptors@0.40.0':
@ -10013,7 +10005,7 @@ snapshots:
eslint: 9.39.2(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1))
@ -10046,7 +10038,7 @@ snapshots:
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
@ -10061,7 +10053,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@ -12049,10 +12041,10 @@ snapshots:
powershell-utils: 0.1.0
wsl-utils: 0.3.1
openai@6.10.0(ws@8.18.3)(zod@4.3.6):
openai@6.10.0(ws@8.18.3)(zod@3.25.76):
optionalDependencies:
ws: 8.18.3
zod: 4.3.6
zod: 3.25.76
optionator@0.9.4:
dependencies:
@ -13668,10 +13660,6 @@ snapshots:
dependencies:
zod: 3.25.76
zod-to-json-schema@3.25.1(zod@4.3.6):
dependencies:
zod: 4.3.6
zod-validation-error@4.0.2(zod@4.3.6):
dependencies:
zod: 4.3.6