Add Hub Console with agent management and message routing
Implement Hub that auto-connects to Gateway, manages agents via REST API, and routes WebSocket messages to agents by payload.agentId with echo responses sent back to the original sender. - Add Hub with GatewayClient auto-connect, agent CRUD, and message routing - Add Console (NestJS) with REST API and static pages (management + demo client) - Switch Gateway registration from explicit event to query-based on connect - Remove deprecated types (RegisterPayload, metadata, SendMessagePayload) - Add @nestjs/serve-static for serving console UI Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4b3592b5e4
commit
dcca9333ab
18 changed files with 773 additions and 79 deletions
|
|
@ -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
27
pnpm-lock.yaml
generated
|
|
@ -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)
|
||||
|
|
|
|||
45
src/console/app.controller.ts
Normal file
45
src/console/app.controller.ts
Normal 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
48
src/console/app.module.ts
Normal 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
20
src/console/main.ts
Normal 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);
|
||||
});
|
||||
211
src/console/public/client.html
Normal file
211
src/console/public/client.html
Normal 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>
|
||||
83
src/console/public/index.html
Normal file
83
src/console/public/index.html
Normal 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>
|
||||
|
|
@ -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);
|
||||
|
||||
// 检查发送者是否已注册
|
||||
|
|
|
|||
|
|
@ -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
44
src/hub/agent.ts
Normal 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() 将消息放入 channel,read() 从 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
64
src/hub/channel.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* Go channel 风格的异步可迭代队列。
|
||||
* 支持多 writer、单 reader,close 后结束迭代。
|
||||
*/
|
||||
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
23
src/hub/device.ts
Normal 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
131
src/hub/hub.ts
Normal 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
5
src/hub/index.ts
Normal 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
11
src/hub/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ export {
|
|||
GatewayEvents,
|
||||
type DeviceType,
|
||||
type DeviceInfo,
|
||||
type RegisterPayload,
|
||||
type RegisteredResponse,
|
||||
type RoutedMessage,
|
||||
type SendErrorResponse,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue