@johpaz/hive-core 1.0.6 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/src/agent/index.ts +145 -17
- package/src/agent/providers/index.ts +20 -1
- package/src/agent/workspace.ts +111 -0
- package/src/channels/base.ts +28 -2
- package/src/channels/discord.ts +58 -10
- package/src/channels/manager.ts +114 -3
- package/src/channels/slack.ts +38 -4
- package/src/channels/telegram.ts +263 -43
- package/src/channels/webchat.ts +22 -0
- package/src/channels/whatsapp.ts +51 -3
- package/src/config/loader.ts +47 -8
- package/src/gateway/server.ts +612 -240
- package/src/gateway/session.ts +2 -1
- package/src/gateway/slash-commands.ts +7 -14
- package/src/memory/notes.ts +28 -130
- package/src/multi-agent/manager.ts +28 -0
- package/src/storage/sqlite.ts +230 -0
- package/src/tools/workspace.ts +171 -0
package/src/gateway/server.ts
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
|
-
import type { Config } from "../config/loader
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import type { Config } from "../config/loader";
|
|
2
|
+
import { loadConfig, saveConfig } from "../config/loader";
|
|
3
|
+
import { logger } from "../utils/logger";
|
|
4
|
+
import { sessionManager, parseSessionId } from "./session";
|
|
5
|
+
import { laneQueue } from "./lane-queue";
|
|
5
6
|
import {
|
|
6
7
|
type InboundMessage,
|
|
7
8
|
type OutboundMessage,
|
|
8
9
|
isSlashCommand,
|
|
9
10
|
executeSlashCommand,
|
|
10
|
-
} from "./slash-commands
|
|
11
|
-
import { ChannelManager } from "../channels/manager
|
|
12
|
-
import { Agent } from "../agent/index
|
|
13
|
-
import { AgentRunner } from "../agent/providers/index
|
|
14
|
-
import type { IncomingMessage } from "../channels/base
|
|
11
|
+
} from "./slash-commands";
|
|
12
|
+
import { ChannelManager } from "../channels/manager";
|
|
13
|
+
import { Agent, watchEthics, watchSoul, watchUser } from "../agent/index";
|
|
14
|
+
import { AgentRunner } from "../agent/providers/index";
|
|
15
|
+
import type { IncomingMessage } from "../channels/base";
|
|
15
16
|
import * as fs from "node:fs";
|
|
16
17
|
import * as path from "node:path";
|
|
18
|
+
import { dbService, type ChatMessageRow } from "../storage/sqlite";
|
|
17
19
|
|
|
18
20
|
function expandPath(p: string): string {
|
|
19
21
|
if (p.startsWith("~")) {
|
|
@@ -22,169 +24,71 @@ function expandPath(p: string): string {
|
|
|
22
24
|
return p;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
<style>
|
|
32
|
-
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
33
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #eee; height: 100vh; display: flex; flex-direction: column; }
|
|
34
|
-
.header { background: #16213e; padding: 1rem; border-bottom: 1px solid #0f3460; display: flex; justify-content: space-between; align-items: center; }
|
|
35
|
-
.header h1 { font-size: 1.2rem; color: #e94560; }
|
|
36
|
-
.status { font-size: 0.8rem; color: #4ecca3; }
|
|
37
|
-
.container { display: flex; flex: 1; overflow: hidden; }
|
|
38
|
-
.sidebar { width: 250px; background: #16213e; border-right: 1px solid #0f3460; display: flex; flex-direction: column; }
|
|
39
|
-
.sidebar-header { padding: 1rem; border-bottom: 1px solid #0f3460; font-weight: bold; }
|
|
40
|
-
.session-list { flex: 1; overflow-y: auto; padding: 0.5rem; }
|
|
41
|
-
.session-item { padding: 0.5rem; cursor: pointer; border-radius: 4px; margin-bottom: 4px; }
|
|
42
|
-
.session-item:hover { background: #0f3460; }
|
|
43
|
-
.session-item.active { background: #e94560; }
|
|
44
|
-
.main { flex: 1; display: flex; flex-direction: column; }
|
|
45
|
-
.chat { flex: 1; overflow-y: auto; padding: 1rem; }
|
|
46
|
-
.message { margin-bottom: 1rem; padding: 0.75rem; border-radius: 8px; max-width: 80%; }
|
|
47
|
-
.message.user { background: #0f3460; margin-left: auto; }
|
|
48
|
-
.message.assistant { background: #16213e; }
|
|
49
|
-
.message .role { font-size: 0.7rem; color: #888; margin-bottom: 0.25rem; }
|
|
50
|
-
.input-area { padding: 1rem; background: #16213e; border-top: 1px solid #0f3460; }
|
|
51
|
-
.input-form { display: flex; gap: 0.5rem; }
|
|
52
|
-
.input { flex: 1; padding: 0.75rem; border: 1px solid #0f3460; border-radius: 8px; background: #1a1a2e; color: #eee; }
|
|
53
|
-
.input:focus { outline: none; border-color: #e94560; }
|
|
54
|
-
.send-btn { padding: 0.75rem 1.5rem; background: #e94560; color: #fff; border: none; border-radius: 8px; cursor: pointer; }
|
|
55
|
-
.send-btn:hover { background: #c23a51; }
|
|
56
|
-
.connecting { text-align: center; padding: 2rem; color: #888; }
|
|
57
|
-
</style>
|
|
58
|
-
</head>
|
|
59
|
-
<body>
|
|
60
|
-
<div class="header">
|
|
61
|
-
<h1>🐝 Hive</h1>
|
|
62
|
-
<span class="status" id="status">Connecting...</span>
|
|
63
|
-
</div>
|
|
64
|
-
<div class="container">
|
|
65
|
-
<div class="sidebar">
|
|
66
|
-
<div class="sidebar-header">Sessions</div>
|
|
67
|
-
<div class="session-list" id="sessionList"></div>
|
|
68
|
-
</div>
|
|
69
|
-
<div class="main">
|
|
70
|
-
<div class="chat" id="chat">
|
|
71
|
-
<div class="connecting">Connecting to gateway...</div>
|
|
72
|
-
</div>
|
|
73
|
-
<div class="input-area">
|
|
74
|
-
<form class="input-form" id="form">
|
|
75
|
-
<input type="text" class="input" id="input" placeholder="Type a message or /help for commands" autocomplete="off">
|
|
76
|
-
<button type="submit" class="send-btn">Send</button>
|
|
77
|
-
</form>
|
|
78
|
-
</div>
|
|
79
|
-
</div>
|
|
80
|
-
</div>
|
|
81
|
-
<script>
|
|
82
|
-
const ws = new WebSocket('ws://' + location.host + '/ws');
|
|
83
|
-
const chat = document.getElementById('chat');
|
|
84
|
-
const form = document.getElementById('form');
|
|
85
|
-
const input = document.getElementById('input');
|
|
86
|
-
const status = document.getElementById('status');
|
|
87
|
-
const sessionList = document.getElementById('sessionList');
|
|
88
|
-
|
|
89
|
-
let currentSession = 'agent:main:webchat:dm:default';
|
|
90
|
-
let messages = [];
|
|
91
|
-
|
|
92
|
-
ws.onopen = () => {
|
|
93
|
-
status.textContent = 'Connected';
|
|
94
|
-
status.style.color = '#4ecca3';
|
|
95
|
-
chat.innerHTML = '';
|
|
96
|
-
addMessage('system', 'Connected to Hive. Type a message to start.');
|
|
97
|
-
|
|
98
|
-
ws.send(JSON.stringify({ type: 'command', sessionId: currentSession, command: 'join' }));
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
ws.onclose = () => {
|
|
102
|
-
status.textContent = 'Disconnected';
|
|
103
|
-
status.style.color = '#e94560';
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
ws.onerror = () => {
|
|
107
|
-
status.textContent = 'Error';
|
|
108
|
-
status.style.color = '#e94560';
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
ws.onmessage = (event) => {
|
|
112
|
-
const msg = JSON.parse(event.data);
|
|
113
|
-
|
|
114
|
-
if (msg.type === 'message' && msg.sessionId === currentSession) {
|
|
115
|
-
addMessage('assistant', msg.content);
|
|
116
|
-
} else if (msg.type === 'stream' && msg.sessionId === currentSession) {
|
|
117
|
-
addStreamChunk(msg.chunk, msg.isLast);
|
|
118
|
-
} else if (msg.type === 'status') {
|
|
119
|
-
updateStatus(msg.status);
|
|
120
|
-
} else if (msg.type === 'command_result') {
|
|
121
|
-
addMessage('system', JSON.stringify(msg.result, null, 2));
|
|
122
|
-
} else if (msg.type === 'error') {
|
|
123
|
-
addMessage('error', msg.error);
|
|
124
|
-
}
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
function addMessage(role, content) {
|
|
128
|
-
const div = document.createElement('div');
|
|
129
|
-
div.className = 'message ' + role;
|
|
130
|
-
div.innerHTML = '<div class="role">' + role.toUpperCase() + '</div>' + escapeHtml(content);
|
|
131
|
-
chat.appendChild(div);
|
|
132
|
-
chat.scrollTop = chat.scrollHeight;
|
|
133
|
-
}
|
|
27
|
+
// FIX 1 — Redactar secrets antes de exponer config al dashboard
|
|
28
|
+
// Tokens y API keys nunca viajan completos al cliente
|
|
29
|
+
function redactValue(value: string): string {
|
|
30
|
+
if (!value || value.length < 8) return "••••••••";
|
|
31
|
+
return `${value.slice(0, 4)}••••••••`;
|
|
32
|
+
}
|
|
134
33
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (
|
|
34
|
+
function redactConfig(cfg: Config): Record<string, unknown> {
|
|
35
|
+
const redacted = JSON.parse(JSON.stringify(cfg)) as any;
|
|
36
|
+
|
|
37
|
+
// Redactar authToken del gateway
|
|
38
|
+
if (redacted.gateway?.authToken) {
|
|
39
|
+
redacted.gateway.authToken = redactValue(redacted.gateway.authToken);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Redactar API keys de providers
|
|
43
|
+
if (redacted.models?.providers) {
|
|
44
|
+
for (const provider of Object.values(redacted.models.providers) as any[]) {
|
|
45
|
+
if (provider?.apiKey) provider.apiKey = redactValue(provider.apiKey);
|
|
147
46
|
}
|
|
47
|
+
}
|
|
148
48
|
|
|
149
|
-
|
|
150
|
-
|
|
49
|
+
// Redactar tokens de canales
|
|
50
|
+
if (redacted.channels) {
|
|
51
|
+
for (const channel of Object.values(redacted.channels) as any[]) {
|
|
52
|
+
if (channel?.accounts) {
|
|
53
|
+
for (const acc of Object.values(channel.accounts) as any[]) {
|
|
54
|
+
if (acc?.botToken) acc.botToken = redactValue(acc.botToken);
|
|
55
|
+
if (acc?.appToken) acc.appToken = redactValue(acc.appToken);
|
|
56
|
+
if (acc?.signingSecret) acc.signingSecret = redactValue(acc.signingSecret);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
151
59
|
}
|
|
60
|
+
}
|
|
152
61
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
62
|
+
// Redactar headers de MCP servers
|
|
63
|
+
if (redacted.mcp?.servers) {
|
|
64
|
+
for (const server of Object.values(redacted.mcp.servers) as any[]) {
|
|
65
|
+
if (server?.headers) {
|
|
66
|
+
for (const [k, v] of Object.entries(server.headers)) {
|
|
67
|
+
const lk = k.toLowerCase();
|
|
68
|
+
if (lk.includes("auth") || lk.includes("token") || lk.includes("key")) {
|
|
69
|
+
server.headers[k] = redactValue(v as string);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
157
73
|
}
|
|
74
|
+
}
|
|
158
75
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const content = input.value.trim();
|
|
162
|
-
if (!content) return;
|
|
163
|
-
|
|
164
|
-
addMessage('user', content);
|
|
165
|
-
ws.send(JSON.stringify({ type: 'message', sessionId: currentSession, content }));
|
|
166
|
-
input.value = '';
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
input.focus();
|
|
170
|
-
</script>
|
|
171
|
-
</body>
|
|
172
|
-
</html>`;
|
|
76
|
+
return redacted;
|
|
77
|
+
}
|
|
173
78
|
|
|
174
79
|
interface WebSocketData {
|
|
175
80
|
sessionId: string;
|
|
176
81
|
authenticatedAt: number;
|
|
177
82
|
}
|
|
178
83
|
|
|
179
|
-
interface SessionMessages {
|
|
180
|
-
messages: Array<{ role: string; content: string }>;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
84
|
export async function startGateway(config: Config): Promise<void> {
|
|
184
85
|
const host = config.gateway?.host ?? "127.0.0.1";
|
|
185
86
|
const port = config.gateway?.port ?? 18790;
|
|
186
87
|
const token = config.gateway?.authToken;
|
|
187
88
|
|
|
89
|
+
// FIX 2 — startTime para calcular uptime en /status y /api/agents
|
|
90
|
+
const startTime = Date.now();
|
|
91
|
+
|
|
188
92
|
const log = logger.child("gateway");
|
|
189
93
|
|
|
190
94
|
log.info(`Starting gateway on ${host}:${port}`);
|
|
@@ -205,16 +109,35 @@ export async function startGateway(config: Config): Promise<void> {
|
|
|
205
109
|
const agentList = config.agents?.list ?? [];
|
|
206
110
|
const defaultAgent = agentList.find((a) => a.default) ?? agentList[0];
|
|
207
111
|
const workspacePath = expandPath(defaultAgent?.workspace ?? "~/.hive/workspace");
|
|
208
|
-
|
|
112
|
+
|
|
209
113
|
const agent = new Agent({
|
|
210
114
|
agentId: defaultAgent?.id ?? "main",
|
|
211
115
|
config,
|
|
212
116
|
workspacePath,
|
|
213
117
|
});
|
|
214
|
-
|
|
118
|
+
|
|
215
119
|
await agent.initialize();
|
|
216
120
|
log.info(`Agent initialized: ${agent.agentId}`);
|
|
217
121
|
|
|
122
|
+
// Set up hot reload watchers
|
|
123
|
+
const watchers: Array<() => void> = [];
|
|
124
|
+
const soulPath = path.join(workspacePath, "SOUL.md");
|
|
125
|
+
const userPath = path.join(workspacePath, "USER.md");
|
|
126
|
+
const ethicsPath = path.join(workspacePath, "ETHICS.md");
|
|
127
|
+
|
|
128
|
+
if (fs.existsSync(soulPath)) {
|
|
129
|
+
watchers.push(watchSoul(soulPath, () => agent.reloadSoul()));
|
|
130
|
+
log.debug("Watching SOUL.md for changes");
|
|
131
|
+
}
|
|
132
|
+
if (fs.existsSync(userPath)) {
|
|
133
|
+
watchers.push(watchUser(userPath, () => agent.reloadUser()));
|
|
134
|
+
log.debug("Watching USER.md for changes");
|
|
135
|
+
}
|
|
136
|
+
if (fs.existsSync(ethicsPath)) {
|
|
137
|
+
watchers.push(watchEthics(ethicsPath, async () => agent.reloadEthics()));
|
|
138
|
+
log.debug("Watching ETHICS.md for changes");
|
|
139
|
+
}
|
|
140
|
+
|
|
218
141
|
// Initialize LLM runner
|
|
219
142
|
const runner = new AgentRunner(config);
|
|
220
143
|
const provider = config.models?.defaultProvider ?? "gemini";
|
|
@@ -223,56 +146,77 @@ export async function startGateway(config: Config): Promise<void> {
|
|
|
223
146
|
|
|
224
147
|
// Initialize channel manager
|
|
225
148
|
const channelManager = new ChannelManager(config);
|
|
226
|
-
|
|
227
|
-
// Track sessions
|
|
228
|
-
const sessionStore = new Map<string, SessionMessages>();
|
|
229
149
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (!session) {
|
|
238
|
-
session = { messages: [] };
|
|
239
|
-
sessionStore.set(message.sessionId, session);
|
|
150
|
+
function getUnifiedUserId(channel: string, peerId: string): string {
|
|
151
|
+
const userConfig = config.user;
|
|
152
|
+
if (!userConfig) return peerId;
|
|
153
|
+
|
|
154
|
+
const channelId = userConfig.channels?.[channel];
|
|
155
|
+
if (channelId && channelId === peerId) {
|
|
156
|
+
return userConfig.id;
|
|
240
157
|
}
|
|
158
|
+
return peerId;
|
|
159
|
+
}
|
|
241
160
|
|
|
242
|
-
|
|
243
|
-
|
|
161
|
+
// Handle messages from channels (Telegram, Discord, WhatsApp, Slack)
|
|
162
|
+
channelManager.onMessage(async (message: IncomingMessage) => {
|
|
163
|
+
log.info(`📥 Message from ${message.channel}:${message.accountId}`);
|
|
164
|
+
log.info(` Session: ${message.sessionId}`);
|
|
165
|
+
log.info(` Content: ${message.content.substring(0, 150)}${message.content.length > 150 ? "..." : ""}`);
|
|
166
|
+
|
|
167
|
+
const userId = getUnifiedUserId(message.channel, message.peerId);
|
|
168
|
+
const telegramMeta = message.metadata?.telegram as { messageId?: number } | undefined;
|
|
169
|
+
const messageId = telegramMeta?.messageId?.toString();
|
|
170
|
+
await Promise.all([
|
|
171
|
+
channelManager.markAsRead(message.channel, message.sessionId, messageId),
|
|
172
|
+
channelManager.startTyping(message.channel, message.sessionId),
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
dbService.addMessage(message.sessionId, "user", message.content, userId);
|
|
176
|
+
const history = dbService.getMessages(message.sessionId);
|
|
177
|
+
const messages = history.map((row: ChatMessageRow) => ({
|
|
178
|
+
role: row.role as "user" | "assistant" | "system",
|
|
179
|
+
content: row.content,
|
|
180
|
+
}));
|
|
244
181
|
|
|
245
182
|
try {
|
|
246
|
-
// Build system prompt
|
|
247
183
|
const systemPrompt = agent.buildPrompt();
|
|
184
|
+
log.info(`🤖 Calling LLM (${provider}/${model})...`);
|
|
248
185
|
|
|
249
|
-
// Generate response
|
|
250
186
|
const response = await runner.generate({
|
|
251
187
|
provider: provider as any,
|
|
252
188
|
system: systemPrompt,
|
|
253
|
-
messages
|
|
189
|
+
messages,
|
|
254
190
|
maxTokens: 4096,
|
|
255
191
|
});
|
|
256
192
|
|
|
257
|
-
|
|
258
|
-
|
|
193
|
+
const responseContent = response.content || "...";
|
|
194
|
+
log.info(`📤 LLM response: ${responseContent.substring(0, 100)}${responseContent.length > 100 ? "..." : ""}`);
|
|
259
195
|
|
|
260
|
-
|
|
261
|
-
await channelManager.
|
|
262
|
-
|
|
263
|
-
log.info(
|
|
196
|
+
dbService.addMessage(message.sessionId, "assistant", responseContent);
|
|
197
|
+
await channelManager.stopTyping(message.channel, message.sessionId);
|
|
198
|
+
await channelManager.send(message.channel, message.sessionId, { content: responseContent });
|
|
199
|
+
log.info(`✅ Response sent to ${message.sessionId}`);
|
|
264
200
|
} catch (error) {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
201
|
+
await channelManager.stopTyping(message.channel, message.sessionId);
|
|
202
|
+
log.error(`❌ Error: ${(error as Error).message}`);
|
|
203
|
+
await channelManager.send(message.channel, message.sessionId, {
|
|
204
|
+
content: `Error: ${(error as Error).message}`,
|
|
268
205
|
});
|
|
269
206
|
}
|
|
270
207
|
});
|
|
271
208
|
|
|
272
|
-
// Initialize and start channels
|
|
273
209
|
await channelManager.initialize();
|
|
274
210
|
await channelManager.startAll();
|
|
275
211
|
|
|
212
|
+
// ── Auth helper ──────────────────────────────────────────────────────────
|
|
213
|
+
function checkAuth(req: Request, url: URL): boolean {
|
|
214
|
+
if (!token) return true;
|
|
215
|
+
const authHeader = req.headers.get("authorization");
|
|
216
|
+
const provided = authHeader?.replace(/^Bearer\s+/i, "") ?? url.searchParams.get("token");
|
|
217
|
+
return provided === token;
|
|
218
|
+
}
|
|
219
|
+
|
|
276
220
|
const server = Bun.serve<WebSocketData>({
|
|
277
221
|
port,
|
|
278
222
|
hostname: host,
|
|
@@ -280,46 +224,428 @@ export async function startGateway(config: Config): Promise<void> {
|
|
|
280
224
|
async fetch(req, server) {
|
|
281
225
|
const url = new URL(req.url);
|
|
282
226
|
|
|
227
|
+
// ── WebSocket upgrade ────────────────────────────────────────────────
|
|
283
228
|
if (url.pathname === "/ws" || url.pathname === "/ws/") {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const providedToken = authHeader?.replace(/^Bearer\s+/i, "") ?? url.searchParams.get("token");
|
|
287
|
-
if (providedToken !== token) {
|
|
288
|
-
return new Response("Unauthorized", { status: 401 });
|
|
289
|
-
}
|
|
229
|
+
if (!checkAuth(req, url)) {
|
|
230
|
+
return new Response("Unauthorized", { status: 401 });
|
|
290
231
|
}
|
|
291
|
-
|
|
292
232
|
const sessionId = url.searchParams.get("session") ?? "agent:main:webchat:dm:default";
|
|
293
|
-
|
|
294
233
|
const success = server.upgrade(req, {
|
|
295
234
|
data: { sessionId, authenticatedAt: Date.now() },
|
|
296
235
|
});
|
|
297
|
-
|
|
298
236
|
if (success) return undefined;
|
|
299
237
|
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
300
238
|
}
|
|
301
239
|
|
|
240
|
+
// ── Dashboard ────────────────────────────────────────────────────────
|
|
302
241
|
if (url.pathname === "/ui" || url.pathname === "/ui/") {
|
|
303
|
-
return
|
|
304
|
-
|
|
305
|
-
|
|
242
|
+
return token
|
|
243
|
+
? Response.redirect(`/dashboard?token=${token}`, 301)
|
|
244
|
+
: Response.redirect("/dashboard", 301);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (url.pathname.startsWith("/dashboard")) {
|
|
248
|
+
const dashboardDir = path.join(process.cwd(), "packages/dashboard/dist/dashboard/browser");
|
|
249
|
+
let subPath = url.pathname.replace(/^\/dashboard\/?/, "");
|
|
250
|
+
if (!subPath) subPath = "index.html";
|
|
251
|
+
|
|
252
|
+
const filePath = path.join(dashboardDir, subPath);
|
|
253
|
+
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
|
254
|
+
if (subPath === "index.html" && token) {
|
|
255
|
+
const urlToken = url.searchParams.get("token");
|
|
256
|
+
if (!urlToken || urlToken !== token) {
|
|
257
|
+
return Response.redirect(`/dashboard?token=${token}`, 302);
|
|
258
|
+
}
|
|
259
|
+
const html = fs.readFileSync(filePath, "utf-8");
|
|
260
|
+
const injected = html.replace(
|
|
261
|
+
"</head>",
|
|
262
|
+
` <script>window.HIVE_AUTH_TOKEN = "${token}";</script>\n</head>`
|
|
263
|
+
);
|
|
264
|
+
return new Response(injected, { headers: { "Content-Type": "text/html" } });
|
|
265
|
+
}
|
|
266
|
+
return new Response(Bun.file(filePath));
|
|
267
|
+
}
|
|
268
|
+
const indexHtml = path.join(dashboardDir, "index.html");
|
|
269
|
+
if (fs.existsSync(indexHtml)) return new Response(Bun.file(indexHtml));
|
|
270
|
+
return new Response("Dashboard build not found. Run 'bun run build' in packages/dashboard.", { status: 404 });
|
|
306
271
|
}
|
|
307
272
|
|
|
273
|
+
// ── Rutas que requieren autenticación ────────────────────────────────
|
|
274
|
+
if (!checkAuth(req, url)) {
|
|
275
|
+
return new Response("Unauthorized", { status: 401 });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── Health ───────────────────────────────────────────────────────────
|
|
308
279
|
if (url.pathname === "/health" || url.pathname === "/health/") {
|
|
309
280
|
return Response.json({ status: "ok", pid: process.pid });
|
|
310
281
|
}
|
|
311
282
|
|
|
283
|
+
// ── Status ───────────────────────────────────────────────────────────
|
|
284
|
+
// FIX 3 — Cache-Control: max-age=5 para que el dashboard no haga
|
|
285
|
+
// polling más frecuente de cada 5 segundos (causa del spam en Network tab)
|
|
312
286
|
if (url.pathname === "/status" || url.pathname === "/status/") {
|
|
287
|
+
return new Response(
|
|
288
|
+
JSON.stringify({
|
|
289
|
+
status: "ok",
|
|
290
|
+
version: "0.1.7",
|
|
291
|
+
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
292
|
+
gateway: { host, port },
|
|
293
|
+
sessions: sessionManager.list().map((s) => ({
|
|
294
|
+
id: s.id,
|
|
295
|
+
createdAt: s.createdAt,
|
|
296
|
+
messageCount: s.messageCount,
|
|
297
|
+
})),
|
|
298
|
+
channels: channelManager.listChannels(),
|
|
299
|
+
queue: { activeSessions: 0 },
|
|
300
|
+
}),
|
|
301
|
+
{
|
|
302
|
+
headers: {
|
|
303
|
+
"Content-Type": "application/json",
|
|
304
|
+
// Decirle al browser/dashboard que este endpoint no cambia
|
|
305
|
+
// más rápido que 5 segundos — frena el polling descontrolado
|
|
306
|
+
"Cache-Control": "max-age=5",
|
|
307
|
+
},
|
|
308
|
+
}
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── Config ───────────────────────────────────────────────────────────
|
|
313
|
+
if (url.pathname === "/api/config") {
|
|
314
|
+
if (req.method === "GET") {
|
|
315
|
+
// FIX 1 — nunca devolver el config raw con secrets
|
|
316
|
+
return Response.json(redactConfig(config));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── Agents ───────────────────────────────────────────────────────────
|
|
321
|
+
// FIX 4 — incluir estado real (connected, uptime, provider, model)
|
|
322
|
+
if (url.pathname === "/api/agents") {
|
|
323
|
+
const agents = (config.agents?.list ?? []).map((a) => ({
|
|
324
|
+
...a,
|
|
325
|
+
connected: true,
|
|
326
|
+
currentTask: null,
|
|
327
|
+
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
328
|
+
provider,
|
|
329
|
+
model,
|
|
330
|
+
}));
|
|
331
|
+
return Response.json(agents);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── Channels API ─────────────────────────────────────────────────────
|
|
335
|
+
if (url.pathname === "/api/channels" || url.pathname === "/api/channels/") {
|
|
336
|
+
if (req.method === "GET") {
|
|
337
|
+
return Response.json(channelManager.listAllAvailableChannels());
|
|
338
|
+
}
|
|
339
|
+
if (req.method === "POST") {
|
|
340
|
+
const body = await req.json().catch(() => ({}));
|
|
341
|
+
const { name, accountId, config: channelConfigData } = body;
|
|
342
|
+
if (!name || !accountId || !channelConfigData) {
|
|
343
|
+
return new Response("Missing name, accountId or config", { status: 400 });
|
|
344
|
+
}
|
|
345
|
+
config.channels = config.channels || {};
|
|
346
|
+
config.channels[name] = config.channels[name] || { enabled: true, accounts: {} };
|
|
347
|
+
const channelEntry = config.channels[name] as any;
|
|
348
|
+
channelEntry.accounts = channelEntry.accounts || {};
|
|
349
|
+
channelEntry.accounts[accountId] = channelConfigData;
|
|
350
|
+
saveConfig(config);
|
|
351
|
+
await channelManager.removeChannel(name, accountId);
|
|
352
|
+
await channelManager.startChannel(name, accountId);
|
|
353
|
+
return Response.json({ success: true });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const channelDetailMatch = url.pathname.match(/^\/api\/channels\/([^/]+)\/([^/]+)$/);
|
|
358
|
+
if (channelDetailMatch) {
|
|
359
|
+
const name = channelDetailMatch[1];
|
|
360
|
+
const accountId = channelDetailMatch[2];
|
|
361
|
+
|
|
362
|
+
if (req.method === "GET") {
|
|
363
|
+
const accConfig = channelManager.getAccountConfig(name, accountId);
|
|
364
|
+
if (!accConfig) return new Response("Channel account not found", { status: 404 });
|
|
365
|
+
return Response.json({ name, accountId, config: accConfig });
|
|
366
|
+
}
|
|
367
|
+
if (req.method === "PUT") {
|
|
368
|
+
const body = await req.json().catch(() => ({}));
|
|
369
|
+
if (!body.config) return new Response("Missing config", { status: 400 });
|
|
370
|
+
config.channels = config.channels || {};
|
|
371
|
+
config.channels[name] = config.channels[name] || { enabled: true, accounts: {} };
|
|
372
|
+
const channelEntry = config.channels[name] as any;
|
|
373
|
+
channelEntry.accounts = channelEntry.accounts || {};
|
|
374
|
+
channelEntry.accounts[accountId] = body.config;
|
|
375
|
+
saveConfig(config);
|
|
376
|
+
await channelManager.removeChannel(name, accountId);
|
|
377
|
+
await channelManager.startChannel(name, accountId);
|
|
378
|
+
return Response.json({ success: true });
|
|
379
|
+
}
|
|
380
|
+
if (req.method === "DELETE") {
|
|
381
|
+
if (config.channels?.[name]) {
|
|
382
|
+
const channelEntry = config.channels[name] as any;
|
|
383
|
+
if (channelEntry.accounts) {
|
|
384
|
+
delete channelEntry.accounts[accountId];
|
|
385
|
+
if (Object.keys(channelEntry.accounts).length === 0) {
|
|
386
|
+
delete config.channels[name];
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
saveConfig(config);
|
|
390
|
+
await channelManager.removeChannel(name, accountId);
|
|
391
|
+
}
|
|
392
|
+
return Response.json({ success: true });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const channelActionMatch = url.pathname.match(
|
|
397
|
+
/^\/api\/channels\/([^/]+)\/([^/]+)\/(start|stop)$/
|
|
398
|
+
);
|
|
399
|
+
if (channelActionMatch) {
|
|
400
|
+
const [, name, accountId, action] = channelActionMatch;
|
|
401
|
+
if (req.method === "POST") {
|
|
402
|
+
try {
|
|
403
|
+
if (action === "start") await channelManager.startChannel(name, accountId);
|
|
404
|
+
else await channelManager.stopChannel(name, accountId);
|
|
405
|
+
return Response.json({ success: true });
|
|
406
|
+
} catch (error) {
|
|
407
|
+
return Response.json(
|
|
408
|
+
{ success: false, error: (error as Error).message },
|
|
409
|
+
{ status: 500 }
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ── Skills API ───────────────────────────────────────────────────────
|
|
416
|
+
if (url.pathname === "/api/skills" || url.pathname === "/api/skills/") {
|
|
417
|
+
if (req.method === "GET") return Response.json(agent.getSkills());
|
|
418
|
+
if (req.method === "POST") {
|
|
419
|
+
const body = await req.json().catch(() => ({}));
|
|
420
|
+
const { name, description, content, raw } = body;
|
|
421
|
+
if (!name || (!content && !raw)) {
|
|
422
|
+
return new Response("Missing name or content", { status: 400 });
|
|
423
|
+
}
|
|
424
|
+
const managedDir = expandPath(config.skills?.managedDir ?? "~/.hive/skills");
|
|
425
|
+
const skillDir = path.join(managedDir, name);
|
|
426
|
+
const skillFile = path.join(skillDir, "SKILL.md");
|
|
427
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
428
|
+
const skillMd = raw || `---\nname: ${name}\ndescription: ${description || ""}\n---\n${content}`;
|
|
429
|
+
fs.writeFileSync(skillFile, skillMd);
|
|
430
|
+
agent.reloadSkills();
|
|
431
|
+
return Response.json({ success: true });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (url.pathname.startsWith("/api/skills/")) {
|
|
436
|
+
const skillName = url.pathname.replace(/^\/api\/skills\//, "");
|
|
437
|
+
|
|
438
|
+
if (skillName === "bundled/toggle" && req.method === "POST") {
|
|
439
|
+
const body = await req.json().catch(() => ({}));
|
|
440
|
+
const { name: bundledName, enabled } = body;
|
|
441
|
+
if (!bundledName) return new Response("Missing name", { status: 400 });
|
|
442
|
+
config.skills = config.skills || {};
|
|
443
|
+
config.skills.allowBundled = config.skills.allowBundled || [];
|
|
444
|
+
if (enabled) {
|
|
445
|
+
if (!config.skills.allowBundled.includes(bundledName)) {
|
|
446
|
+
config.skills.allowBundled.push(bundledName);
|
|
447
|
+
}
|
|
448
|
+
} else {
|
|
449
|
+
config.skills.allowBundled = config.skills.allowBundled.filter((n) => n !== bundledName);
|
|
450
|
+
}
|
|
451
|
+
saveConfig(config);
|
|
452
|
+
await agent.updateConfig(config);
|
|
453
|
+
agent.reloadSkills();
|
|
454
|
+
return Response.json({ success: true });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (req.method === "PUT") {
|
|
458
|
+
const body = await req.json().catch(() => ({}));
|
|
459
|
+
const { name, description, content, raw } = body;
|
|
460
|
+
if (!content && !raw) return new Response("Missing content", { status: 400 });
|
|
461
|
+
const skill = agent.getSkills().find((s) => s.name === skillName);
|
|
462
|
+
if (!skill || (skill.source !== "managed" && skill.source !== "workspace")) {
|
|
463
|
+
return new Response("Skill not found or not editable", { status: 404 });
|
|
464
|
+
}
|
|
465
|
+
const skillFile = path.join(skill.path, "SKILL.md");
|
|
466
|
+
const skillMd = raw || `---\nname: ${name || skillName}\ndescription: ${description || ""}\n---\n${content}`;
|
|
467
|
+
fs.writeFileSync(skillFile, skillMd);
|
|
468
|
+
agent.reloadSkills();
|
|
469
|
+
return Response.json({ success: true });
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (req.method === "DELETE") {
|
|
473
|
+
const skill = agent.getSkills().find((s) => s.name === skillName);
|
|
474
|
+
if (skill?.source === "managed") {
|
|
475
|
+
fs.rmSync(skill.path, { recursive: true, force: true });
|
|
476
|
+
agent.reloadSkills();
|
|
477
|
+
} else if (skill?.source === "bundled") {
|
|
478
|
+
config.skills = config.skills || {};
|
|
479
|
+
config.skills.allowBundled = (config.skills.allowBundled || []).filter((n) => n !== skillName);
|
|
480
|
+
saveConfig(config);
|
|
481
|
+
await agent.updateConfig(config);
|
|
482
|
+
agent.reloadSkills();
|
|
483
|
+
}
|
|
484
|
+
return Response.json({ success: true });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── Model Config API ─────────────────────────────────────────────────
|
|
489
|
+
if (url.pathname === "/api/config/models") {
|
|
490
|
+
if (req.method === "GET") {
|
|
491
|
+
return Response.json({
|
|
492
|
+
config: config.models || {},
|
|
493
|
+
availableProviders: ["openai", "anthropic", "gemini", "kimi", "ollama", "openrouter", "deepseek"],
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
if (req.method === "POST") {
|
|
497
|
+
const body = await req.json().catch(() => ({}));
|
|
498
|
+
const { defaultProvider, defaults, providers } = body;
|
|
499
|
+
config.models = config.models || {};
|
|
500
|
+
if (defaultProvider) config.models.defaultProvider = defaultProvider;
|
|
501
|
+
if (defaults) config.models.defaults = { ...(config.models.defaults || {}), ...defaults };
|
|
502
|
+
if (providers) config.models.providers = { ...(config.models.providers || {}), ...providers };
|
|
503
|
+
saveConfig(config);
|
|
504
|
+
await agent.updateConfig(config);
|
|
505
|
+
return Response.json({ success: true });
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── MCP API ──────────────────────────────────────────────────────────
|
|
510
|
+
if (url.pathname.startsWith("/api/mcp/")) {
|
|
511
|
+
const mcp = agent.getMCPManager();
|
|
512
|
+
if (!mcp) return new Response("MCP is disabled", { status: 404 });
|
|
513
|
+
|
|
514
|
+
if (url.pathname === "/api/mcp/servers") {
|
|
515
|
+
if (req.method === "GET") {
|
|
516
|
+
return Response.json(mcp.listServers());
|
|
517
|
+
}
|
|
518
|
+
if (req.method === "POST") {
|
|
519
|
+
const body = await req.json().catch(() => ({}));
|
|
520
|
+
if (!body.name || !body.config) {
|
|
521
|
+
return new Response("Missing name or config", { status: 400 });
|
|
522
|
+
}
|
|
523
|
+
config.mcp = config.mcp || {};
|
|
524
|
+
config.mcp.servers = config.mcp.servers || {};
|
|
525
|
+
config.mcp.servers[body.name] = body.config;
|
|
526
|
+
saveConfig(config);
|
|
527
|
+
await agent.updateConfig(config);
|
|
528
|
+
return Response.json({ success: true });
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// FIX 5 — separar el endpoint de tools del de detalles del servidor
|
|
533
|
+
const toolsMatch = url.pathname.match(/^\/api\/mcp\/servers\/([^/]+)\/tools$/);
|
|
534
|
+
if (toolsMatch && req.method === "GET") {
|
|
535
|
+
const serverName = toolsMatch[1];
|
|
536
|
+
const tools = mcp.getServerTools(serverName);
|
|
537
|
+
return Response.json(tools);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const serverMatch = url.pathname.match(/^\/api\/mcp\/servers\/([^/]+)$/);
|
|
541
|
+
if (serverMatch) {
|
|
542
|
+
const serverName = serverMatch[1];
|
|
543
|
+
const details = mcp.getServerDetails(serverName);
|
|
544
|
+
|
|
545
|
+
if (req.method === "GET") {
|
|
546
|
+
if (!details) return new Response("Server not found", { status: 404 });
|
|
547
|
+
return Response.json(details);
|
|
548
|
+
}
|
|
549
|
+
if (req.method === "PUT") {
|
|
550
|
+
const body = await req.json().catch(() => ({}));
|
|
551
|
+
if (!body.config) return new Response("Missing config", { status: 400 });
|
|
552
|
+
config.mcp = config.mcp || {};
|
|
553
|
+
config.mcp.servers = config.mcp.servers || {};
|
|
554
|
+
config.mcp.servers[serverName] = body.config;
|
|
555
|
+
saveConfig(config);
|
|
556
|
+
await agent.updateConfig(config);
|
|
557
|
+
return Response.json({ success: true });
|
|
558
|
+
}
|
|
559
|
+
if (req.method === "DELETE") {
|
|
560
|
+
if (config.mcp?.servers?.[serverName]) {
|
|
561
|
+
delete config.mcp.servers[serverName];
|
|
562
|
+
saveConfig(config);
|
|
563
|
+
await agent.updateConfig(config);
|
|
564
|
+
}
|
|
565
|
+
return Response.json({ success: true });
|
|
566
|
+
}
|
|
567
|
+
if (req.method === "POST") {
|
|
568
|
+
if (!details) return new Response("Server not found", { status: 404 });
|
|
569
|
+
const body = await req.json().catch(() => ({}));
|
|
570
|
+
if (body.action === "connect") {
|
|
571
|
+
await mcp.connectServer(serverName);
|
|
572
|
+
return Response.json({ success: true });
|
|
573
|
+
}
|
|
574
|
+
if (body.action === "disconnect") {
|
|
575
|
+
await mcp.disconnectServer(serverName);
|
|
576
|
+
return Response.json({ success: true });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ── Workspace API ────────────────────────────────────────────────────
|
|
583
|
+
for (const wsType of ["soul", "user", "ethics"] as const) {
|
|
584
|
+
if (url.pathname === `/api/workspace/${wsType}`) {
|
|
585
|
+
const filePath = path.join(workspacePath, `${wsType.toUpperCase()}.md`);
|
|
586
|
+
|
|
587
|
+
if (req.method === "GET") {
|
|
588
|
+
const defaults: Record<string, string> = {
|
|
589
|
+
soul: "# Agent Soul\n\nDefine your agent's personality here.",
|
|
590
|
+
user: "# User Profile\n\nAdd user preferences here.",
|
|
591
|
+
ethics: "# Ethics\n\nDefine ethical guidelines here.",
|
|
592
|
+
};
|
|
593
|
+
const content = fs.existsSync(filePath)
|
|
594
|
+
? fs.readFileSync(filePath, "utf-8")
|
|
595
|
+
: defaults[wsType];
|
|
596
|
+
return new Response(content, { headers: { "Content-Type": "text/plain" } });
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (req.method === "POST") {
|
|
600
|
+
const content = await req.text();
|
|
601
|
+
fs.mkdirSync(workspacePath, { recursive: true });
|
|
602
|
+
fs.writeFileSync(filePath, content);
|
|
603
|
+
if (wsType === "soul") agent.reloadSoul();
|
|
604
|
+
if (wsType === "user") agent.reloadUser();
|
|
605
|
+
if (wsType === "ethics") await agent.reloadEthics();
|
|
606
|
+
return Response.json({ success: true, savedAt: new Date().toISOString() });
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ── Reload API ───────────────────────────────────────────────────────
|
|
612
|
+
if (url.pathname === "/api/reload" && req.method === "POST") {
|
|
613
|
+
try {
|
|
614
|
+
const newConfig = loadConfig();
|
|
615
|
+
await agent.updateConfig(newConfig);
|
|
616
|
+
await agent.reload();
|
|
617
|
+
log.info("Configuration reloaded via API");
|
|
618
|
+
return Response.json({ success: true, message: "Configuration reloaded" });
|
|
619
|
+
} catch (error) {
|
|
620
|
+
return Response.json(
|
|
621
|
+
{ success: false, error: (error as Error).message },
|
|
622
|
+
{ status: 500 }
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ── User Channel Linking API ────────────────────────────────────────────
|
|
628
|
+
if (url.pathname === "/api/user/channels" && req.method === "POST") {
|
|
629
|
+
const body = await req.json().catch(() => ({}));
|
|
630
|
+
const { channel, channelUserId } = body;
|
|
631
|
+
|
|
632
|
+
if (!channel || !channelUserId) {
|
|
633
|
+
return Response.json({ success: false, error: "Missing channel or channelUserId" }, { status: 400 });
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
config.user = config.user || { id: "", name: "User" };
|
|
637
|
+
config.user.channels = config.user.channels || {};
|
|
638
|
+
config.user.channels[channel] = channelUserId;
|
|
639
|
+
|
|
640
|
+
saveConfig(config);
|
|
641
|
+
log.info(`Linked channel ${channel} to user ID ${channelUserId}`);
|
|
642
|
+
|
|
643
|
+
return Response.json({ success: true, channels: config.user.channels });
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (url.pathname === "/api/user/channels" && req.method === "GET") {
|
|
313
647
|
return Response.json({
|
|
314
|
-
|
|
315
|
-
id: s.id,
|
|
316
|
-
createdAt: s.createdAt,
|
|
317
|
-
messageCount: s.messageCount,
|
|
318
|
-
})),
|
|
319
|
-
channels: channelManager.listChannels(),
|
|
320
|
-
queue: {
|
|
321
|
-
activeSessions: 0,
|
|
322
|
-
},
|
|
648
|
+
user: config.user || { id: "", name: "User", channels: {} },
|
|
323
649
|
});
|
|
324
650
|
}
|
|
325
651
|
|
|
@@ -329,10 +655,19 @@ export async function startGateway(config: Config): Promise<void> {
|
|
|
329
655
|
websocket: {
|
|
330
656
|
open(ws) {
|
|
331
657
|
const data = ws.data;
|
|
332
|
-
|
|
333
|
-
|
|
658
|
+
const parts = data.sessionId.split(":");
|
|
659
|
+
const isWebchat = parts[2] === "webchat";
|
|
660
|
+
|
|
661
|
+
if (isWebchat) log.info(`WebChat connected: ${data.sessionId}`);
|
|
662
|
+
else log.debug(`WebSocket connected: ${data.sessionId}`);
|
|
663
|
+
|
|
334
664
|
sessionManager.create(data.sessionId, ws);
|
|
335
|
-
|
|
665
|
+
|
|
666
|
+
if (isWebchat) {
|
|
667
|
+
const channel = channelManager.getChannel("webchat") as any;
|
|
668
|
+
if (channel?.registerConnection) channel.registerConnection(ws);
|
|
669
|
+
}
|
|
670
|
+
|
|
336
671
|
ws.send(JSON.stringify({
|
|
337
672
|
type: "status",
|
|
338
673
|
sessionId: data.sessionId,
|
|
@@ -342,7 +677,7 @@ export async function startGateway(config: Config): Promise<void> {
|
|
|
342
677
|
|
|
343
678
|
async message(ws, message) {
|
|
344
679
|
const data = ws.data;
|
|
345
|
-
|
|
680
|
+
|
|
346
681
|
let msg: InboundMessage;
|
|
347
682
|
try {
|
|
348
683
|
msg = JSON.parse(message.toString()) as InboundMessage;
|
|
@@ -363,58 +698,74 @@ export async function startGateway(config: Config): Promise<void> {
|
|
|
363
698
|
return;
|
|
364
699
|
}
|
|
365
700
|
|
|
701
|
+
if (msg.type === "join") {
|
|
702
|
+
ws.send(JSON.stringify({ type: "joined", sessionId: msg.sessionId } as OutboundMessage));
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
|
|
366
706
|
if (msg.type === "command" || (msg.content && isSlashCommand(msg.content))) {
|
|
367
707
|
const result = await executeSlashCommand(msg.sessionId, msg.content ?? `/${msg.command}`, ws);
|
|
368
|
-
|
|
369
|
-
|
|
708
|
+
if (result) {
|
|
709
|
+
ws.send(JSON.stringify(result));
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
370
712
|
}
|
|
371
713
|
|
|
372
714
|
if (msg.type === "message" && msg.content) {
|
|
373
|
-
log.
|
|
715
|
+
log.info(`WebChat message from session ${msg.sessionId}: ${msg.content.substring(0, 100)}`);
|
|
374
716
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
sessionStore.set(msg.sessionId, session);
|
|
380
|
-
}
|
|
717
|
+
const parsedSession = parseSessionId(msg.sessionId);
|
|
718
|
+
const userId = parsedSession?.userId;
|
|
719
|
+
|
|
720
|
+
dbService.addMessage(msg.sessionId, "user", msg.content, userId);
|
|
381
721
|
|
|
382
|
-
//
|
|
383
|
-
|
|
722
|
+
// FIX 6 — typing indicator inmediato ANTES de encolar
|
|
723
|
+
// El usuario ve "escribiendo..." de inmediato, no después del queue
|
|
724
|
+
ws.send(JSON.stringify({
|
|
725
|
+
type: "typing",
|
|
726
|
+
isTyping: true,
|
|
727
|
+
sessionId: msg.sessionId,
|
|
728
|
+
} as OutboundMessage));
|
|
384
729
|
|
|
385
730
|
laneQueue.enqueue(msg.sessionId, async (_task, signal) => {
|
|
386
731
|
if (signal.aborted) {
|
|
387
|
-
ws.send(JSON.stringify({
|
|
388
|
-
|
|
389
|
-
sessionId: msg.sessionId,
|
|
390
|
-
error: "Task cancelled",
|
|
391
|
-
} as OutboundMessage));
|
|
732
|
+
ws.send(JSON.stringify({ type: "typing", isTyping: false, sessionId: msg.sessionId } as OutboundMessage));
|
|
733
|
+
ws.send(JSON.stringify({ type: "error", sessionId: msg.sessionId, error: "Task cancelled" } as OutboundMessage));
|
|
392
734
|
return;
|
|
393
735
|
}
|
|
394
736
|
|
|
395
737
|
try {
|
|
738
|
+
const history = dbService.getMessages(msg.sessionId);
|
|
739
|
+
const messages = history.map((row: ChatMessageRow) => ({
|
|
740
|
+
role: row.role as "user" | "assistant" | "system",
|
|
741
|
+
content: row.content,
|
|
742
|
+
}));
|
|
396
743
|
const systemPrompt = agent.buildPrompt();
|
|
744
|
+
log.info(`Generating response for session ${msg.sessionId}...`);
|
|
397
745
|
|
|
398
746
|
const response = await runner.generate({
|
|
399
747
|
provider: provider as any,
|
|
400
748
|
system: systemPrompt,
|
|
401
|
-
messages
|
|
749
|
+
messages,
|
|
402
750
|
maxTokens: 4096,
|
|
403
751
|
});
|
|
404
752
|
|
|
405
|
-
|
|
753
|
+
const content = response.content?.trim() || "...";
|
|
754
|
+
dbService.addMessage(msg.sessionId, "assistant", content);
|
|
755
|
+
log.info(`Response sent to session ${msg.sessionId} (${content.length} chars)`);
|
|
406
756
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
content: response.content,
|
|
411
|
-
} as OutboundMessage));
|
|
757
|
+
// Detener typing ANTES de enviar la respuesta
|
|
758
|
+
ws.send(JSON.stringify({ type: "typing", isTyping: false, sessionId: msg.sessionId } as OutboundMessage));
|
|
759
|
+
ws.send(JSON.stringify({ type: "message", sessionId: msg.sessionId, content } as OutboundMessage));
|
|
412
760
|
} catch (error) {
|
|
761
|
+
// Detener typing aunque falle — nunca dejar el spinner infinito
|
|
762
|
+
ws.send(JSON.stringify({ type: "typing", isTyping: false, sessionId: msg.sessionId } as OutboundMessage));
|
|
413
763
|
ws.send(JSON.stringify({
|
|
414
764
|
type: "error",
|
|
415
765
|
sessionId: msg.sessionId,
|
|
416
766
|
error: (error as Error).message,
|
|
417
767
|
} as OutboundMessage));
|
|
768
|
+
log.error(`Error for session ${msg.sessionId}: ${(error as Error).message}`);
|
|
418
769
|
}
|
|
419
770
|
});
|
|
420
771
|
|
|
@@ -430,8 +781,18 @@ export async function startGateway(config: Config): Promise<void> {
|
|
|
430
781
|
|
|
431
782
|
close(ws) {
|
|
432
783
|
const data = ws.data;
|
|
433
|
-
|
|
784
|
+
const parts = data.sessionId.split(":");
|
|
785
|
+
const isWebchat = parts[2] === "webchat";
|
|
786
|
+
|
|
787
|
+
if (isWebchat) log.info(`WebChat disconnected: ${data.sessionId}`);
|
|
788
|
+
else log.debug(`WebSocket disconnected: ${data.sessionId}`);
|
|
789
|
+
|
|
434
790
|
laneQueue.cancel(data.sessionId);
|
|
791
|
+
|
|
792
|
+
if (isWebchat) {
|
|
793
|
+
const channel = channelManager.getChannel("webchat") as any;
|
|
794
|
+
if (channel?.unregisterConnection) channel.unregisterConnection(data.sessionId);
|
|
795
|
+
}
|
|
435
796
|
},
|
|
436
797
|
},
|
|
437
798
|
});
|
|
@@ -441,19 +802,30 @@ export async function startGateway(config: Config): Promise<void> {
|
|
|
441
802
|
log.info(`WebSocket: ws://${host}:${port}/ws`);
|
|
442
803
|
log.info(`Channels: ${channelManager.listChannels().map((c) => c.name).join(", ") || "none"}`);
|
|
443
804
|
|
|
805
|
+
// FIX 7 — SIGTERM desconecta MCP limpiamente antes de cerrar
|
|
444
806
|
process.on("SIGTERM", async () => {
|
|
445
807
|
log.info("Received SIGTERM, shutting down gracefully...");
|
|
808
|
+
watchers.forEach((close) => close());
|
|
809
|
+
const mcp = agent.getMCPManager();
|
|
810
|
+
if (mcp) {
|
|
811
|
+
log.info("Disconnecting MCP servers...");
|
|
812
|
+
await mcp.disconnectAll().catch(() => { });
|
|
813
|
+
}
|
|
446
814
|
await channelManager.stopAll();
|
|
447
815
|
server.stop();
|
|
448
|
-
try {
|
|
449
|
-
fs.unlinkSync(pidFile);
|
|
450
|
-
} catch {
|
|
451
|
-
// Ignore
|
|
452
|
-
}
|
|
816
|
+
try { fs.unlinkSync(pidFile); } catch { }
|
|
453
817
|
process.exit(0);
|
|
454
818
|
});
|
|
455
819
|
|
|
456
|
-
process.on("SIGHUP", () => {
|
|
820
|
+
process.on("SIGHUP", async () => {
|
|
457
821
|
log.info("Received SIGHUP, reloading configuration...");
|
|
822
|
+
try {
|
|
823
|
+
const newConfig = loadConfig();
|
|
824
|
+
await agent.updateConfig(newConfig);
|
|
825
|
+
await agent.reload();
|
|
826
|
+
log.info("Configuration reloaded successfully");
|
|
827
|
+
} catch (error) {
|
|
828
|
+
log.error(`Failed to reload configuration: ${(error as Error).message}`);
|
|
829
|
+
}
|
|
458
830
|
});
|
|
459
|
-
}
|
|
831
|
+
}
|