Merge pull request #3 from multica-ai/ldnvnbl/hub-agent-mgmt

Add Hub Console with agent management and message routing
This commit is contained in:
LinYushen 2026-01-29 16:15:05 +08:00 committed by GitHub
commit 4a7d0faa31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 773 additions and 79 deletions

View file

@ -7,6 +7,7 @@
"scripts": {
"dev": "tsx src/index.ts",
"dev:gateway": "tsx --watch src/gateway/main.ts",
"dev:console": "tsx --watch src/console/main.ts",
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
@ -26,6 +27,7 @@
"@nestjs/core": "^11.1.12",
"@nestjs/platform-express": "^11.1.12",
"@nestjs/platform-socket.io": "^11.1.12",
"@nestjs/serve-static": "^5.0.4",
"@nestjs/websockets": "^11.1.12",
"nestjs-pino": "^4.5.0",
"pino": "^10.3.0",

27
pnpm-lock.yaml generated
View file

@ -20,6 +20,9 @@ importers:
'@nestjs/platform-socket.io':
specifier: ^11.1.12
version: 11.1.12(@nestjs/common@11.1.12(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.12)(rxjs@7.8.2)
'@nestjs/serve-static':
specifier: ^5.0.4
version: 5.0.4(@nestjs/common@11.1.12(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(express@5.2.1)
'@nestjs/websockets':
specifier: ^11.1.12
version: 11.1.12(@nestjs/common@11.1.12(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(@nestjs/platform-socket.io@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@ -273,6 +276,22 @@ packages:
'@nestjs/websockets': ^11.0.0
rxjs: ^7.1.0
'@nestjs/serve-static@5.0.4':
resolution: {integrity: sha512-3kO1M9D3vsPyWPFardxIjUYeuolS58PnhCoBTkS7t3BrdZFZCKHnBZ15js+UOzOR2Q6HmD7ssGjLd0DVYVdvOw==}
peerDependencies:
'@fastify/static': ^8.0.4
'@nestjs/common': ^11.0.2
'@nestjs/core': ^11.0.2
express: ^5.0.1
fastify: ^5.2.1
peerDependenciesMeta:
'@fastify/static':
optional: true
express:
optional: true
fastify:
optional: true
'@nestjs/websockets@11.1.12':
resolution: {integrity: sha512-ulSOYcgosx1TqY425cRC5oXtAu1R10+OSmVfgyR9ueR25k4luekURt8dzAZxhxSCI0OsDj9WKCFLTkEuAwg0wg==}
peerDependencies:
@ -1009,6 +1028,14 @@ snapshots:
- supports-color
- utf-8-validate
'@nestjs/serve-static@5.0.4(@nestjs/common@11.1.12(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(express@5.2.1)':
dependencies:
'@nestjs/common': 11.1.12(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.12(@nestjs/common@11.1.12(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
path-to-regexp: 8.3.0
optionalDependencies:
express: 5.2.1
'@nestjs/websockets@11.1.12(@nestjs/common@11.1.12(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(@nestjs/platform-socket.io@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
'@nestjs/common': 11.1.12(reflect-metadata@0.2.2)(rxjs@7.8.2)

View file

@ -0,0 +1,45 @@
import {
Controller,
Get,
Post,
Delete,
Param,
Body,
Inject,
} from "@nestjs/common";
import { Hub } from "../hub/hub.js";
@Controller("api")
export class AppController {
constructor(@Inject("HUB") private readonly hub: Hub) {}
@Get("hub")
getHub() {
return {
deviceId: this.hub.deviceId,
url: this.hub.url,
connectionState: this.hub.connectionState,
agentCount: this.hub.listAgents().length,
};
}
@Get("agents")
listAgents() {
return this.hub.listAgents().map((id) => {
const agent = this.hub.getAgent(id);
return { id, closed: agent?.closed ?? true };
});
}
@Post("agents")
createAgent(@Body() body?: { id?: string }) {
const agent = this.hub.createAgent(body?.id);
return { id: agent.id };
}
@Delete("agents/:id")
deleteAgent(@Param("id") id: string) {
const ok = this.hub.closeAgent(id);
return { ok };
}
}

48
src/console/app.module.ts Normal file
View file

@ -0,0 +1,48 @@
import { Module } from "@nestjs/common";
import { ServeStaticModule } from "@nestjs/serve-static";
import { LoggerModule } from "nestjs-pino";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { AppController } from "./app.controller.js";
import { Hub } from "../hub/hub.js";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const isDev = process.env["NODE_ENV"] !== "production";
@Module({
imports: [
ServeStaticModule.forRoot({
rootPath: join(__dirname, "public"),
serveRoot: "/",
exclude: ["/api/(.*)"],
}),
LoggerModule.forRoot({
pinoHttp: isDev
? {
transport: {
target: "pino-pretty",
options: {
colorize: true,
singleLine: true,
},
},
level: process.env["LOG_LEVEL"] ?? "debug",
}
: {
level: process.env["LOG_LEVEL"] ?? "info",
},
}),
],
controllers: [AppController],
providers: [
{
provide: "HUB",
useFactory: () => {
const gatewayUrl =
process.env["GATEWAY_URL"] ?? "http://localhost:3000";
return new Hub(gatewayUrl);
},
},
],
})
export class AppModule {}

20
src/console/main.ts Normal file
View file

@ -0,0 +1,20 @@
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { Logger } from "nestjs-pino";
import { AppModule } from "./app.module.js";
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(Logger));
const port = process.env["PORT"] ?? 4000;
await app.listen(port);
const logger = app.get(Logger);
logger.log(`Console is running on http://localhost:${port}`);
}
bootstrap().catch((err) => {
console.error("Failed to start console:", err);
process.exit(1);
});

View file

@ -0,0 +1,211 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Demo Client</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; background: #0a0a0a; color: #e0e0e0; padding: 2rem; }
h1 { font-size: 1.4rem; margin-bottom: 0.3rem; color: #fff; }
.badge { display: inline-block; font-size: 0.7rem; background: #3a2a00; color: #ffaa33; border: 1px solid #554400; border-radius: 3px; padding: 0.1rem 0.4rem; margin-left: 0.5rem; vertical-align: middle; }
.subtitle { color: #555; font-size: 0.8rem; margin-bottom: 1.5rem; }
.card { background: #161616; border: 1px solid #2a2a2a; border-radius: 8px; padding: 1.2rem; margin-bottom: 1.2rem; }
label { display: block; color: #888; font-size: 0.8rem; margin-bottom: 0.3rem; }
input { width: 100%; background: #0e0e0e; border: 1px solid #333; border-radius: 4px; padding: 0.5rem 0.7rem; color: #e0e0e0; font-family: monospace; font-size: 0.85rem; margin-bottom: 0.8rem; }
input:focus { outline: none; border-color: #555; }
input:disabled { opacity: 0.5; }
button { background: #2a2a2a; color: #e0e0e0; border: 1px solid #444; border-radius: 4px; padding: 0.5rem 1rem; cursor: pointer; font-size: 0.85rem; }
button:hover { background: #3a3a3a; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-connect { background: #1a3a1a; border-color: #2a5a2a; }
.btn-connect:hover { background: #2a4a2a; }
.btn-disconnect { background: #3a1a1a; border-color: #5a2a2a; }
.btn-disconnect:hover { background: #4a2a2a; }
.btn-send { background: #1a2a3a; border-color: #2a4a5a; }
.btn-send:hover { background: #2a3a4a; }
.status { font-size: 0.8rem; margin-bottom: 0.8rem; }
.status .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 0.4rem; vertical-align: middle; }
.dot-off { background: #555; }
.dot-on { background: #4a4; }
.dot-connecting { background: #aa4; }
.device-id { font-size: 0.75rem; color: #555; margin-bottom: 1rem; word-break: break-all; }
.chat { display: none; }
.chat.active { display: block; }
.messages { background: #0e0e0e; border: 1px solid #222; border-radius: 4px; height: 300px; overflow-y: auto; padding: 0.6rem; margin-bottom: 0.8rem; font-size: 0.8rem; font-family: monospace; }
.messages .msg { padding: 0.3rem 0; border-bottom: 1px solid #1a1a1a; }
.messages .msg:last-child { border-bottom: none; }
.msg-from { color: #888; }
.msg-self { color: #6a8faa; }
.msg-in { color: #6aaa6a; }
.msg-system { color: #aa8833; font-style: italic; }
.send-row { display: flex; gap: 0.5rem; }
.send-row input { margin-bottom: 0; flex: 1; }
</style>
</head>
<body>
<h1>Demo Client <span class="badge">DEMO</span></h1>
<p class="subtitle">Test client for sending messages to agents via Gateway</p>
<div class="card">
<div class="device-id">Device ID: <span id="device-id"></span></div>
<div class="status">
Status: <span class="dot dot-off" id="status-dot"></span><span id="status-text">Disconnected</span>
</div>
<div id="connect-form">
<label>Gateway URL</label>
<input type="text" id="gateway-url" placeholder="http://localhost:3000" />
<button class="btn-connect" id="btn-connect" onclick="doConnect()">Connect</button>
</div>
<div id="connected-form" style="display:none;">
<button class="btn-disconnect" onclick="doDisconnect()">Disconnect</button>
</div>
</div>
<div class="card chat" id="chat">
<label>Messages</label>
<div class="messages" id="messages"></div>
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
<div style="flex:1;">
<label>Target Device ID</label>
<input type="text" id="target-device-id" placeholder="Hub device ID" />
</div>
<div style="flex:1;">
<label>Agent ID</label>
<input type="text" id="target-agent-id" placeholder="Agent ID on that device" />
</div>
</div>
<div class="send-row">
<input type="text" id="msg-input" placeholder="Type a message..." onkeydown="if(event.key==='Enter')doSend()" />
<button class="btn-send" onclick="doSend()">Send</button>
</div>
</div>
<script src="https://cdn.socket.io/4.8.3/socket.io.min.js"></script>
<script>
// Device ID: persist in localStorage
const STORAGE_KEY = 'demo-client-device-id';
let deviceId = localStorage.getItem(STORAGE_KEY);
if (!deviceId) {
// Simple UUID v4 for demo (no uuid lib in browser)
deviceId = crypto.randomUUID();
localStorage.setItem(STORAGE_KEY, deviceId);
}
document.getElementById('device-id').textContent = deviceId;
// Restore last used values
document.getElementById('gateway-url').value = localStorage.getItem('demo-client-gateway-url') || 'http://localhost:3000';
let socket = null;
function setStatus(state) {
const dot = document.getElementById('status-dot');
const text = document.getElementById('status-text');
dot.className = 'dot ' + ({ disconnected: 'dot-off', connecting: 'dot-connecting', connected: 'dot-on', registered: 'dot-on' }[state] || 'dot-off');
text.textContent = state.charAt(0).toUpperCase() + state.slice(1);
}
function appendMsg(type, text) {
const el = document.getElementById('messages');
const div = document.createElement('div');
div.className = 'msg msg-' + type;
div.textContent = text;
el.appendChild(div);
el.scrollTop = el.scrollHeight;
}
function doConnect() {
const url = document.getElementById('gateway-url').value.trim();
if (!url) { alert('Please fill in Gateway URL'); return; }
localStorage.setItem('demo-client-gateway-url', url);
setStatus('connecting');
document.getElementById('btn-connect').disabled = true;
socket = io(url, {
path: '/ws',
query: { deviceId, deviceType: 'client' },
reconnection: true,
reconnectionDelay: 1000,
});
socket.on('connect', () => {
setStatus('connected');
// 服务端从 query 自动注册,等待 registered 事件
});
socket.on('registered', (res) => {
if (res.success) {
setStatus('registered');
document.getElementById('connect-form').style.display = 'none';
document.getElementById('connected-form').style.display = 'block';
document.getElementById('chat').classList.add('active');
document.getElementById('target-device-id').value = localStorage.getItem('demo-client-target-device-id') || '';
document.getElementById('target-agent-id').value = localStorage.getItem('demo-client-target-agent-id') || '';
appendMsg('system', `Connected as ${deviceId}`);
} else {
appendMsg('system', `Registration failed: ${res.error}`);
setStatus('disconnected');
document.getElementById('btn-connect').disabled = false;
}
});
socket.on('receive', (msg) => {
appendMsg('in', `[${msg.from}] ${typeof msg.payload === 'string' ? msg.payload : JSON.stringify(msg.payload)}`);
});
socket.on('send_error', (err) => {
appendMsg('system', `Send error: ${err.error} (${err.code})`);
});
socket.on('disconnect', (reason) => {
setStatus('disconnected');
appendMsg('system', `Disconnected: ${reason}`);
});
socket.on('connect_error', (err) => {
appendMsg('system', `Connection error: ${err.message}`);
setStatus('disconnected');
document.getElementById('btn-connect').disabled = false;
});
}
function doDisconnect() {
if (socket) { socket.disconnect(); socket = null; }
setStatus('disconnected');
document.getElementById('connect-form').style.display = 'block';
document.getElementById('connected-form').style.display = 'none';
document.getElementById('chat').classList.remove('active');
document.getElementById('btn-connect').disabled = false;
}
function doSend() {
const input = document.getElementById('msg-input');
const text = input.value.trim();
const targetDeviceId = document.getElementById('target-device-id').value.trim();
const targetAgentId = document.getElementById('target-agent-id').value.trim();
if (!text || !socket) return;
if (!targetDeviceId || !targetAgentId) { alert('Please fill in Target Device ID and Agent ID'); return; }
localStorage.setItem('demo-client-target-device-id', targetDeviceId);
localStorage.setItem('demo-client-target-agent-id', targetAgentId);
const msgId = crypto.randomUUID();
socket.emit('send', {
id: msgId,
uid: null,
from: deviceId,
to: targetDeviceId,
action: 'message',
payload: { agentId: targetAgentId, content: text },
});
appendMsg('self', `[you -> ${targetDeviceId}/${targetAgentId}] ${text}`);
input.value = '';
}
</script>
</body>
</html>

View file

@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hub Console</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; background: #0a0a0a; color: #e0e0e0; padding: 2rem; }
h1 { font-size: 1.4rem; margin-bottom: 1.5rem; color: #fff; }
h2 { font-size: 1rem; margin-bottom: 0.8rem; color: #999; text-transform: uppercase; letter-spacing: 0.05em; }
.card { background: #161616; border: 1px solid #2a2a2a; border-radius: 8px; padding: 1.2rem; margin-bottom: 1.2rem; }
.hub-info span { display: inline-block; margin-right: 2rem; color: #888; }
.hub-info span b { color: #e0e0e0; }
.agent-list { list-style: none; }
.agent-list li { display: flex; justify-content: space-between; align-items: center; padding: 0.6rem 0; border-bottom: 1px solid #222; font-family: monospace; font-size: 0.85rem; }
.agent-list li:last-child { border-bottom: none; }
button { background: #2a2a2a; color: #e0e0e0; border: 1px solid #444; border-radius: 4px; padding: 0.4rem 0.8rem; cursor: pointer; font-size: 0.8rem; }
button:hover { background: #3a3a3a; }
.btn-create { background: #1a3a1a; border-color: #2a5a2a; }
.btn-create:hover { background: #2a4a2a; }
.btn-delete { background: #3a1a1a; border-color: #5a2a2a; }
.btn-delete:hover { background: #4a2a2a; }
.empty { color: #555; font-style: italic; font-size: 0.85rem; }
</style>
</head>
<body>
<h1>Hub Console</h1>
<div class="card">
<h2>Hub Status</h2>
<div class="hub-info" id="hub-info">Loading...</div>
</div>
<div class="card">
<h2>Agents</h2>
<div style="margin-bottom: 0.8rem;">
<button class="btn-create" onclick="createAgent()">+ Create Agent</button>
</div>
<ul class="agent-list" id="agent-list"></ul>
</div>
<script>
const API = '/api';
async function refresh() {
const [hubRes, agentsRes] = await Promise.all([
fetch(`${API}/hub`).then(r => r.json()),
fetch(`${API}/agents`).then(r => r.json()),
]);
const stateColor = hubRes.connectionState === 'registered' ? '#4a4' : hubRes.connectionState === 'connected' ? '#aa4' : '#a44';
document.getElementById('hub-info').innerHTML =
`<span>Device: <b>${hubRes.deviceId}</b></span>` +
`<span>Gateway: <b>${hubRes.url}</b></span>` +
`<span>State: <b style="color:${stateColor}">${hubRes.connectionState}</b></span>` +
`<span>Agents: <b>${hubRes.agentCount}</b></span>`;
const list = document.getElementById('agent-list');
if (agentsRes.length === 0) {
list.innerHTML = '<li class="empty">No agents</li>';
} else {
list.innerHTML = agentsRes.map(a =>
`<li><span>${a.id}</span><button class="btn-delete" onclick="deleteAgent('${a.id}')">Delete</button></li>`
).join('');
}
}
async function createAgent() {
await fetch(`${API}/agents`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
refresh();
}
async function deleteAgent(id) {
await fetch(`${API}/agents/${id}`, { method: 'DELETE' });
refresh();
}
refresh();
setInterval(refresh, 3000);
</script>
</body>
</html>

View file

@ -15,13 +15,12 @@ import type { Server, Socket } from "socket.io";
import { InjectPinoLogger, PinoLogger } from "nestjs-pino";
import {
GatewayEvents,
type RegisterPayload,
type RegisteredResponse,
type RoutedMessage,
type SendErrorResponse,
type PingPayload,
type PongResponse,
type DeviceInfo,
type DeviceType,
} from "../shared/gateway-sdk/index.js";
@Injectable()
@ -55,12 +54,56 @@ export class EventsGateway
}
handleConnection(client: Socket): void {
this.logger.info({ socketId: client.id }, "Socket connected");
const query = client.handshake.query;
const deviceId = query["deviceId"] as string | undefined;
const deviceType = query["deviceType"] as DeviceType | undefined;
this.logger.debug(
{ socketId: client.id, deviceId, deviceType },
"Incoming connection"
);
if (!deviceId || !deviceType) {
this.logger.warn(
{ socketId: client.id },
"Missing deviceId or deviceType in query, disconnecting"
);
client.disconnect(true);
return;
}
// 检查 deviceId 是否已被其他 socket 使用
const existingSocketId = this.deviceToSocket.get(deviceId);
if (existingSocketId && existingSocketId !== client.id) {
this.logger.warn(
{ deviceId, existingSocketId },
"Device already registered by another socket, disconnecting"
);
client.emit(GatewayEvents.REGISTERED, {
success: false,
deviceId,
error: "Device ID already in use",
});
client.disconnect(true);
return;
}
// 注册设备
const deviceInfo: DeviceInfo = { deviceId, deviceType };
this.deviceToSocket.set(deviceId, client.id);
this.socketToDevice.set(client.id, deviceInfo);
this.logger.info({ deviceId, deviceType }, "Device connected and registered");
client.emit(GatewayEvents.REGISTERED, { success: true, deviceId });
}
handleDisconnect(client: Socket): void {
const deviceInfo = this.socketToDevice.get(client.id);
if (deviceInfo) {
this.logger.debug(
{ socketId: client.id, deviceId: deviceInfo.deviceId, deviceType: deviceInfo.deviceType },
"Device disconnecting"
);
this.logger.info(
{ deviceId: deviceInfo.deviceId, deviceType: deviceInfo.deviceType },
"Device disconnected"
@ -72,45 +115,16 @@ export class EventsGateway
}
}
@SubscribeMessage(GatewayEvents.REGISTER)
handleRegister(
@MessageBody() data: RegisterPayload,
@ConnectedSocket() client: Socket
): void {
const { deviceId, deviceType, metadata } = data;
// 检查 deviceId 是否已被其他 socket 使用
const existingSocketId = this.deviceToSocket.get(deviceId);
if (existingSocketId && existingSocketId !== client.id) {
this.logger.warn(
{ deviceId, existingSocketId },
"Device already registered by another socket"
);
const response: RegisteredResponse = {
success: false,
deviceId,
error: "Device ID already in use",
};
client.emit(GatewayEvents.REGISTERED, response);
return;
}
// 注册设备
const deviceInfo: DeviceInfo = { deviceId, deviceType, metadata };
this.deviceToSocket.set(deviceId, client.id);
this.socketToDevice.set(client.id, deviceInfo);
this.logger.info({ deviceId, deviceType }, "Device registered");
const response: RegisteredResponse = { success: true, deviceId };
client.emit(GatewayEvents.REGISTERED, response);
}
@SubscribeMessage(GatewayEvents.SEND)
handleSend(
@MessageBody() message: RoutedMessage,
@ConnectedSocket() client: Socket
): void {
this.logger.debug(
{ socketId: client.id, message },
"Received send event"
);
const senderDevice = this.socketToDevice.get(client.id);
// 检查发送者是否已注册

View file

@ -11,7 +11,6 @@ const client = new GatewayClient({
url: "http://localhost:3000",
deviceId: "client-001",
deviceType: "client",
metadata: { name: "Test Client" },
});
// 模拟一个 Agent
@ -19,7 +18,6 @@ const agent = new GatewayClient({
url: "http://localhost:3000",
deviceId: "agent-001",
deviceType: "agent",
metadata: { name: "Test Agent" },
});
// Agent 监听消息

44
src/hub/agent.ts Normal file
View file

@ -0,0 +1,44 @@
import { v7 as uuidv7 } from "uuid";
import { Channel } from "./channel.js";
import type { Message } from "./types.js";
/**
* Mock Agent
* write() channelread() channel
*/
export class Agent {
readonly id: string;
private readonly channel = new Channel<Message>();
private _closed = false;
constructor(id?: string) {
this.id = id ?? uuidv7();
}
get closed(): boolean {
return this._closed;
}
/** 写入消息到 agent非阻塞 */
write(content: string): void {
if (this._closed) {
throw new Error("Agent is closed");
}
this.channel.send({
id: uuidv7(),
content: `[mock-agent:${this.id}] echo: ${content}`,
});
}
/** 持续读取消息流 */
read(): AsyncIterable<Message> {
return this.channel;
}
/** 关闭 agent停止所有读取 */
close(): void {
if (this._closed) return;
this._closed = true;
this.channel.close();
}
}

64
src/hub/channel.ts Normal file
View file

@ -0,0 +1,64 @@
/**
* Go channel
* writer readerclose
*/
export class Channel<T> implements AsyncIterable<T> {
private buffer: T[] = [];
private closed = false;
private readers: Array<{
resolve: (result: IteratorResult<T>) => void;
}> = [];
get isClosed(): boolean {
return this.closed;
}
get size(): number {
return this.buffer.length;
}
/** 发送值到 channel。channel 已关闭时返回 false。 */
send(value: T): boolean {
if (this.closed) return false;
const reader = this.readers.shift();
if (reader) {
reader.resolve({ value, done: false });
return true;
}
this.buffer.push(value);
return true;
}
/** 关闭 channel唤醒所有等待中的 reader。 */
close(): void {
if (this.closed) return;
this.closed = true;
for (const reader of this.readers) {
reader.resolve({ value: undefined as T, done: true });
}
this.readers = [];
}
[Symbol.asyncIterator](): AsyncIterator<T> {
return {
next: (): Promise<IteratorResult<T>> => {
if (this.buffer.length > 0) {
const value = this.buffer.shift()!;
return Promise.resolve({ value, done: false });
}
if (this.closed) {
return Promise.resolve({ value: undefined as T, done: true });
}
return new Promise<IteratorResult<T>>((resolve) => {
this.readers.push({ resolve });
});
},
};
}
}

23
src/hub/device.ts Normal file
View file

@ -0,0 +1,23 @@
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { v7 as uuidv7 } from "uuid";
const MULTICA_DIR = join(homedir(), ".multica");
const DEVICE_ID_FILE = join(MULTICA_DIR, "device-id");
/**
* ID
* UUIDv7 ~/.multica/device-id
*
*/
export function getDeviceId(): string {
try {
return readFileSync(DEVICE_ID_FILE, "utf-8").trim();
} catch {
const id = uuidv7();
mkdirSync(MULTICA_DIR, { recursive: true });
writeFileSync(DEVICE_ID_FILE, id, "utf-8");
return id;
}
}

131
src/hub/hub.ts Normal file
View file

@ -0,0 +1,131 @@
import type { HubOptions } from "./types.js";
import type { ConnectionState } from "../shared/gateway-sdk/types.js";
import { Agent } from "./agent.js";
import { getDeviceId } from "./device.js";
import { GatewayClient } from "../shared/gateway-sdk/client.js";
export class Hub {
private readonly agents = new Map<string, Agent>();
private readonly agentSenders = new Map<string, string>();
private readonly client: GatewayClient;
readonly url: string;
readonly path: string;
readonly deviceId: string;
/** 当前 Gateway 连接状态 */
get connectionState(): ConnectionState {
return this.client.state;
}
constructor(url: string, path?: string) {
this.url = url;
this.path = path ?? "/ws";
this.deviceId = getDeviceId();
this.client = new GatewayClient({
url: this.url,
path: this.path,
deviceId: this.deviceId,
deviceType: "client",
autoReconnect: true,
reconnectDelay: 1000,
});
this.client.onStateChange((state) => {
console.log(`[Hub] Connection state: ${state}`);
});
this.client.onRegistered((deviceId) => {
console.log(`[Hub] Registered as: ${deviceId}`);
});
this.client.onError((err) => {
console.error(`[Hub] Connection error:`, err.message);
});
this.client.onMessage((msg) => {
console.log(`[Hub] Received message: id=${msg.id} from=${msg.from} to=${msg.to} action=${msg.action} payload=${JSON.stringify(msg.payload)}`);
const payload = msg.payload as { agentId?: string; content?: string } | undefined;
const agentId = payload?.agentId;
const content = payload?.content;
if (!agentId || !content) {
console.warn(`[Hub] Invalid payload, missing agentId or content`);
return;
}
const agent = this.agents.get(agentId);
if (agent && !agent.closed) {
this.agentSenders.set(agentId, msg.from);
agent.write(content);
} else {
console.warn(`[Hub] Agent not found or closed: ${agentId}`);
}
});
this.client.onSendError((err) => {
console.error(`[Hub] Send error: messageId=${err.messageId} code=${err.code} error=${err.error}`);
});
this.client.connect();
}
/** 创建新 Agent或用已有 ID 重建 */
createAgent(id?: string): Agent {
if (id) {
const existing = this.agents.get(id);
if (existing && !existing.closed) {
return existing;
}
}
const agent = new Agent(id);
this.agents.set(agent.id, agent);
// 内部消费 agent 产出的消息
void this.consumeAgent(agent);
console.log(`Agent created: ${agent.id}`);
return agent;
}
/** 内部读取 agent 的输出并通过 Gateway 发送 */
private async consumeAgent(agent: Agent): Promise<void> {
for await (const msg of agent.read()) {
console.log(`[${agent.id}] ${msg.content}`);
const targetDeviceId = this.agentSenders.get(agent.id);
if (targetDeviceId) {
this.client.send(targetDeviceId, "message", {
agentId: agent.id,
content: msg.content,
});
}
}
}
getAgent(id: string): Agent | undefined {
return this.agents.get(id);
}
listAgents(): string[] {
return Array.from(this.agents.entries())
.filter(([, a]) => !a.closed)
.map(([id]) => id);
}
closeAgent(id: string): boolean {
const agent = this.agents.get(id);
if (!agent) return false;
agent.close();
this.agents.delete(id);
this.agentSenders.delete(id);
return true;
}
shutdown(): void {
for (const [id, agent] of this.agents) {
agent.close();
this.agents.delete(id);
}
this.client.disconnect();
console.log("Hub shut down");
}
}

5
src/hub/index.ts Normal file
View file

@ -0,0 +1,5 @@
export { Channel } from "./channel.js";
export { Agent } from "./agent.js";
export { Hub } from "./hub.js";
export { getDeviceId } from "./device.js";
export type { Message, HubOptions } from "./types.js";

11
src/hub/types.ts Normal file
View file

@ -0,0 +1,11 @@
export interface Message {
readonly id: string;
readonly content: string;
}
export interface HubOptions {
/** 远端 Gateway WebSocket 地址,如 "http://localhost:3000" */
url: string;
/** WebSocket 路径,默认 "/ws" */
path?: string | undefined;
}

View file

@ -17,7 +17,6 @@ interface ResolvedOptions {
path: string;
deviceId: string;
deviceType: DeviceType;
metadata: Record<string, unknown> | undefined;
autoReconnect: boolean;
reconnectDelay: number;
}
@ -38,7 +37,6 @@ export class GatewayClient {
path: options.path ?? "/ws",
deviceId: options.deviceId,
deviceType: options.deviceType,
metadata: options.metadata,
autoReconnect: options.autoReconnect ?? true,
reconnectDelay: options.reconnectDelay ?? 1000,
};
@ -74,7 +72,7 @@ export class GatewayClient {
return this._state === "registered";
}
/** 连接到服务器 */
/** 连接到服务器deviceId 和 deviceType 通过 query 传递 */
connect(): this {
if (this.socket) {
return this;
@ -82,8 +80,14 @@ export class GatewayClient {
this.setState("connecting");
const query: Record<string, string> = {
deviceId: this.options.deviceId,
deviceType: this.options.deviceType,
};
this.socket = io(this.options.url, {
path: this.options.path,
query,
reconnection: this.options.autoReconnect,
reconnectionDelay: this.options.reconnectDelay,
});
@ -204,24 +208,13 @@ export class GatewayClient {
return uuidv7();
}
private register(): void {
if (!this.socket) return;
this.socket.emit(GatewayEvents.REGISTER, {
deviceId: this.options.deviceId,
deviceType: this.options.deviceType,
metadata: this.options.metadata,
});
}
private setupListeners(): void {
if (!this.socket) return;
this.socket.on("connect", () => {
this.setState("connected");
this.callbacks.onConnect?.(this.socket!.id!);
// 连接后自动注册
this.register();
// 服务端在连接时从 query 自动注册,等待 registered 事件即可
});
this.socket.on("disconnect", (reason: string) => {

View file

@ -3,7 +3,6 @@ export {
GatewayEvents,
type DeviceType,
type DeviceInfo,
type RegisterPayload,
type RegisteredResponse,
type RoutedMessage,
type SendErrorResponse,

View file

@ -3,7 +3,6 @@ export const GatewayEvents = {
// 系统事件
PING: "ping",
PONG: "pong",
REGISTER: "register",
REGISTERED: "registered",
// 消息路由
@ -21,14 +20,6 @@ export type DeviceType = "client" | "agent";
export interface DeviceInfo {
deviceId: string;
deviceType: DeviceType;
metadata?: Record<string, unknown> | undefined;
}
/** 注册请求 */
export interface RegisterPayload {
deviceId: string;
deviceType: DeviceType;
metadata?: Record<string, unknown>;
}
/** 注册响应 */
@ -88,8 +79,6 @@ export interface GatewayClientOptions {
deviceId: string;
/** 设备类型 */
deviceType: DeviceType;
/** 设备元数据 */
metadata?: Record<string, unknown> | undefined;
/** 自动重连,默认 true */
autoReconnect?: boolean | undefined;
/** 重连延迟(毫秒),默认 1000 */
@ -115,16 +104,3 @@ export interface GatewayClientCallbacks {
onStateChange?: (state: ConnectionState) => void;
}
// ============ 兼容旧API可删除 ============
/** @deprecated 使用 RoutedMessage */
export interface SendMessagePayload {
text: string;
}
/** @deprecated 使用 RoutedMessage */
export interface BroadcastMessage {
from: string;
text: string;
timestamp: string;
}