@johpaz/hive 1.1.0
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/CONTRIBUTING.md +44 -0
- package/README.md +310 -0
- package/package.json +96 -0
- package/packages/cli/package.json +28 -0
- package/packages/cli/src/commands/agent-run.ts +168 -0
- package/packages/cli/src/commands/agents.ts +398 -0
- package/packages/cli/src/commands/chat.ts +142 -0
- package/packages/cli/src/commands/config.ts +50 -0
- package/packages/cli/src/commands/cron.ts +161 -0
- package/packages/cli/src/commands/dev.ts +95 -0
- package/packages/cli/src/commands/doctor.ts +133 -0
- package/packages/cli/src/commands/gateway.ts +443 -0
- package/packages/cli/src/commands/logs.ts +57 -0
- package/packages/cli/src/commands/mcp.ts +175 -0
- package/packages/cli/src/commands/message.ts +77 -0
- package/packages/cli/src/commands/onboard.ts +1868 -0
- package/packages/cli/src/commands/security.ts +144 -0
- package/packages/cli/src/commands/service.ts +50 -0
- package/packages/cli/src/commands/sessions.ts +116 -0
- package/packages/cli/src/commands/skills.ts +187 -0
- package/packages/cli/src/commands/update.ts +25 -0
- package/packages/cli/src/index.ts +185 -0
- package/packages/cli/src/utils/token.ts +6 -0
- package/packages/code-bridge/README.md +78 -0
- package/packages/code-bridge/package.json +18 -0
- package/packages/code-bridge/src/index.ts +95 -0
- package/packages/code-bridge/src/process-manager.ts +212 -0
- package/packages/code-bridge/src/schemas.ts +133 -0
- package/packages/core/package.json +46 -0
- package/packages/core/src/agent/agent-loop.ts +369 -0
- package/packages/core/src/agent/compaction.ts +140 -0
- package/packages/core/src/agent/context-compiler.ts +378 -0
- package/packages/core/src/agent/context-guard.ts +91 -0
- package/packages/core/src/agent/context.ts +138 -0
- package/packages/core/src/agent/conversation-store.ts +198 -0
- package/packages/core/src/agent/curator.ts +158 -0
- package/packages/core/src/agent/hooks.ts +166 -0
- package/packages/core/src/agent/index.ts +116 -0
- package/packages/core/src/agent/llm-client.ts +503 -0
- package/packages/core/src/agent/native-tools.ts +505 -0
- package/packages/core/src/agent/prompt-builder.ts +532 -0
- package/packages/core/src/agent/providers/index.ts +167 -0
- package/packages/core/src/agent/providers.ts +1 -0
- package/packages/core/src/agent/reflector.ts +170 -0
- package/packages/core/src/agent/service.ts +64 -0
- package/packages/core/src/agent/stuck-loop.ts +133 -0
- package/packages/core/src/agent/supervisor.ts +39 -0
- package/packages/core/src/agent/tracer.ts +102 -0
- package/packages/core/src/agent/workspace.ts +110 -0
- package/packages/core/src/canvas/canvas-manager.test.ts +161 -0
- package/packages/core/src/canvas/canvas-manager.ts +319 -0
- package/packages/core/src/canvas/canvas-tools.ts +420 -0
- package/packages/core/src/canvas/emitter.ts +115 -0
- package/packages/core/src/canvas/index.ts +2 -0
- package/packages/core/src/channels/base.ts +138 -0
- package/packages/core/src/channels/discord.ts +260 -0
- package/packages/core/src/channels/index.ts +7 -0
- package/packages/core/src/channels/manager.ts +383 -0
- package/packages/core/src/channels/slack.ts +287 -0
- package/packages/core/src/channels/telegram.ts +502 -0
- package/packages/core/src/channels/webchat.ts +128 -0
- package/packages/core/src/channels/whatsapp.ts +375 -0
- package/packages/core/src/config/index.ts +12 -0
- package/packages/core/src/config/loader.ts +529 -0
- package/packages/core/src/events/event-bus.ts +169 -0
- package/packages/core/src/gateway/index.ts +5 -0
- package/packages/core/src/gateway/initializer.ts +290 -0
- package/packages/core/src/gateway/lane-queue.ts +169 -0
- package/packages/core/src/gateway/resolver.ts +108 -0
- package/packages/core/src/gateway/router.ts +124 -0
- package/packages/core/src/gateway/server.ts +3317 -0
- package/packages/core/src/gateway/session.ts +95 -0
- package/packages/core/src/gateway/slash-commands.ts +192 -0
- package/packages/core/src/heartbeat/index.ts +157 -0
- package/packages/core/src/index.ts +19 -0
- package/packages/core/src/integrations/catalog.ts +286 -0
- package/packages/core/src/integrations/env.ts +64 -0
- package/packages/core/src/integrations/index.ts +2 -0
- package/packages/core/src/memory/index.ts +1 -0
- package/packages/core/src/memory/notes.ts +68 -0
- package/packages/core/src/plugins/api.ts +128 -0
- package/packages/core/src/plugins/index.ts +2 -0
- package/packages/core/src/plugins/loader.ts +365 -0
- package/packages/core/src/resilience/circuit-breaker.ts +225 -0
- package/packages/core/src/security/google-chat.ts +269 -0
- package/packages/core/src/security/index.ts +192 -0
- package/packages/core/src/security/pairing.ts +250 -0
- package/packages/core/src/security/rate-limit.ts +270 -0
- package/packages/core/src/security/signal.ts +321 -0
- package/packages/core/src/state/store.ts +312 -0
- package/packages/core/src/storage/bun-sqlite-store.ts +188 -0
- package/packages/core/src/storage/crypto.ts +101 -0
- package/packages/core/src/storage/db-context.ts +333 -0
- package/packages/core/src/storage/onboarding.ts +1087 -0
- package/packages/core/src/storage/schema.ts +541 -0
- package/packages/core/src/storage/seed.ts +571 -0
- package/packages/core/src/storage/sqlite.ts +387 -0
- package/packages/core/src/storage/usage.ts +212 -0
- package/packages/core/src/tools/bridge-events.ts +74 -0
- package/packages/core/src/tools/browser.ts +275 -0
- package/packages/core/src/tools/codebridge.ts +421 -0
- package/packages/core/src/tools/coordinator-tools.ts +179 -0
- package/packages/core/src/tools/cron.ts +611 -0
- package/packages/core/src/tools/exec.ts +140 -0
- package/packages/core/src/tools/fs.ts +364 -0
- package/packages/core/src/tools/index.ts +12 -0
- package/packages/core/src/tools/memory.ts +176 -0
- package/packages/core/src/tools/notify.ts +113 -0
- package/packages/core/src/tools/project-management.ts +376 -0
- package/packages/core/src/tools/project.ts +375 -0
- package/packages/core/src/tools/read.ts +158 -0
- package/packages/core/src/tools/web.ts +436 -0
- package/packages/core/src/tools/workspace.ts +171 -0
- package/packages/core/src/utils/benchmark.ts +80 -0
- package/packages/core/src/utils/crypto.ts +73 -0
- package/packages/core/src/utils/date.ts +42 -0
- package/packages/core/src/utils/index.ts +4 -0
- package/packages/core/src/utils/logger.ts +388 -0
- package/packages/core/src/utils/retry.ts +70 -0
- package/packages/core/src/voice/index.ts +583 -0
- package/packages/core/tsconfig.json +9 -0
- package/packages/mcp/package.json +26 -0
- package/packages/mcp/src/config.ts +13 -0
- package/packages/mcp/src/index.ts +1 -0
- package/packages/mcp/src/logger.ts +42 -0
- package/packages/mcp/src/manager.ts +434 -0
- package/packages/mcp/src/transports/index.ts +67 -0
- package/packages/mcp/src/transports/sse.ts +241 -0
- package/packages/mcp/src/transports/websocket.ts +159 -0
- package/packages/skills/package.json +21 -0
- package/packages/skills/src/bundled/agent_management/SKILL.md +24 -0
- package/packages/skills/src/bundled/browser_automation/SKILL.md +30 -0
- package/packages/skills/src/bundled/context_compact/SKILL.md +35 -0
- package/packages/skills/src/bundled/cron_manager/SKILL.md +52 -0
- package/packages/skills/src/bundled/file_manager/SKILL.md +76 -0
- package/packages/skills/src/bundled/http_client/SKILL.md +24 -0
- package/packages/skills/src/bundled/memory/SKILL.md +42 -0
- package/packages/skills/src/bundled/project_management/SKILL.md +26 -0
- package/packages/skills/src/bundled/shell/SKILL.md +43 -0
- package/packages/skills/src/bundled/system_notify/SKILL.md +52 -0
- package/packages/skills/src/bundled/voice/SKILL.md +25 -0
- package/packages/skills/src/bundled/web_search/SKILL.md +29 -0
- package/packages/skills/src/index.ts +1 -0
- package/packages/skills/src/loader.ts +282 -0
- package/packages/tools/package.json +43 -0
- package/packages/tools/src/browser/browser.test.ts +111 -0
- package/packages/tools/src/browser/index.ts +272 -0
- package/packages/tools/src/canvas/index.ts +220 -0
- package/packages/tools/src/cron/cron.test.ts +164 -0
- package/packages/tools/src/cron/index.ts +304 -0
- package/packages/tools/src/filesystem/filesystem.test.ts +240 -0
- package/packages/tools/src/filesystem/index.ts +379 -0
- package/packages/tools/src/git/index.ts +239 -0
- package/packages/tools/src/index.ts +4 -0
- package/packages/tools/src/shell/detect-env.ts +70 -0
- package/packages/tools/tsconfig.json +9 -0
|
@@ -0,0 +1,3317 @@
|
|
|
1
|
+
import type { Config } from "../config/loader";
|
|
2
|
+
import { loadConfig, getHiveDir } from "../config/loader";
|
|
3
|
+
import { logger, onLogEntry } from "../utils/logger";
|
|
4
|
+
import { sessionManager, parseSessionId } from "./session";
|
|
5
|
+
import { laneQueue } from "./lane-queue";
|
|
6
|
+
import {
|
|
7
|
+
type InboundMessage,
|
|
8
|
+
type OutboundMessage,
|
|
9
|
+
isSlashCommand,
|
|
10
|
+
executeSlashCommand,
|
|
11
|
+
} from "./slash-commands";
|
|
12
|
+
import { ChannelManager } from "../channels/manager";
|
|
13
|
+
import { Agent } from "../agent/index";
|
|
14
|
+
import { AgentRunner } from "../agent/providers/index";
|
|
15
|
+
import type { IncomingMessage } from "../channels/base";
|
|
16
|
+
import { mkdirSync, rmSync, unlinkSync, watch, existsSync } from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
import { cpus as osCpus } from "node:os";
|
|
19
|
+
import { dbService, getDb, getDbPathLazy, type ChatMessageRow } from "../storage/sqlite";
|
|
20
|
+
import { canvasManager } from "../canvas/canvas-manager.ts";
|
|
21
|
+
import { subscribeCanvas, unsubscribeCanvas, emitCanvas, getCanvasSnapshot } from "../canvas/emitter";
|
|
22
|
+
import { subscribeBridge, unsubscribeBridge } from "../tools/bridge-events";
|
|
23
|
+
import { buildSupervisorGraph, rebuildSupervisorGraph, getSupervisorGraph } from "../agent/supervisor";
|
|
24
|
+
import { randomUUID } from "crypto";
|
|
25
|
+
import { resolveContext } from "./resolver";
|
|
26
|
+
import { getUsageStats } from "../storage/usage";
|
|
27
|
+
import { voiceService } from "../voice/index";
|
|
28
|
+
import { Benchmark } from "../utils/benchmark";
|
|
29
|
+
import { initializeGateway, type GatewayInitializationResult } from "./initializer";
|
|
30
|
+
import { encryptConfig, decryptConfig, encryptApiKey, decryptApiKey, maskApiKey } from "../storage/crypto";
|
|
31
|
+
import { resolveBestChannel } from "../tools/cron";
|
|
32
|
+
import { loadContextToStore } from "../storage/bun-sqlite-store";
|
|
33
|
+
|
|
34
|
+
const logSubscribers = new Set<string>();
|
|
35
|
+
|
|
36
|
+
// āāā Tool narration map āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
37
|
+
// Maps tool name prefixes/exact names to human-readable Spanish narrations.
|
|
38
|
+
// Shown to the user while the agent executes a tool.
|
|
39
|
+
const TOOL_NARRATIONS: Record<string, string> = {
|
|
40
|
+
// Web
|
|
41
|
+
web_search: "Buscando en la web...",
|
|
42
|
+
web_fetch: "Leyendo pƔgina web...",
|
|
43
|
+
// Files
|
|
44
|
+
read: "Leyendo archivo...",
|
|
45
|
+
write: "Escribiendo archivo...",
|
|
46
|
+
edit: "Editando archivo...",
|
|
47
|
+
exec: "Ejecutando comando...",
|
|
48
|
+
// Cron
|
|
49
|
+
cron_add: "Programando tarea...",
|
|
50
|
+
cron_list: "Consultando tareas programadas...",
|
|
51
|
+
cron_remove: "Eliminando tarea programada...",
|
|
52
|
+
cron_edit: "Actualizando tarea programada...",
|
|
53
|
+
// Projects
|
|
54
|
+
project_create: "Creando proyecto...",
|
|
55
|
+
project_update: "Actualizando proyecto...",
|
|
56
|
+
project_done: "Marcando proyecto como completado...",
|
|
57
|
+
project_fail: "Registrando falla en el proyecto...",
|
|
58
|
+
task_create: "Creando tarea...",
|
|
59
|
+
task_update: "Actualizando tarea...",
|
|
60
|
+
// Agents
|
|
61
|
+
create_agent: "Creando agente worker...",
|
|
62
|
+
find_agent: "Buscando agente disponible...",
|
|
63
|
+
archive_agent: "Archivando agente...",
|
|
64
|
+
// Memory
|
|
65
|
+
save_note: "Guardando nota...",
|
|
66
|
+
memory_write: "Guardando en memoria...",
|
|
67
|
+
memory_read: "Leyendo memoria...",
|
|
68
|
+
memory_search: "Buscando en memoria...",
|
|
69
|
+
memory_delete: "Eliminando de memoria...",
|
|
70
|
+
memory_list: "Listando notas...",
|
|
71
|
+
// Browser
|
|
72
|
+
browser_navigate: "Navegando a la pƔgina...",
|
|
73
|
+
browser_click: "Haciendo clic...",
|
|
74
|
+
browser_type: "Escribiendo en la pƔgina...",
|
|
75
|
+
browser_screenshot:"Tomando captura de pantalla...",
|
|
76
|
+
browser_extract: "Extrayendo información de la pÔgina...",
|
|
77
|
+
// Canvas
|
|
78
|
+
canvas_add_node: "Actualizando canvas...",
|
|
79
|
+
canvas_update: "Actualizando canvas...",
|
|
80
|
+
// Code Bridge
|
|
81
|
+
bridge_send: "Enviando tarea al CLI...",
|
|
82
|
+
bridge_exec: "Ejecutando en el Code Bridge...",
|
|
83
|
+
// Notify
|
|
84
|
+
notify: "Enviando notificación...",
|
|
85
|
+
report_progress: "Reportando progreso...",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getNarration(toolName: string): string {
|
|
89
|
+
if (TOOL_NARRATIONS[toolName]) return TOOL_NARRATIONS[toolName]
|
|
90
|
+
// Prefix matching for MCP tools like "github__create_pr" ā "Ejecutando github..."
|
|
91
|
+
const prefix = toolName.split("__")[0]
|
|
92
|
+
if (prefix && prefix !== toolName) return `Ejecutando ${prefix}...`
|
|
93
|
+
// Fallback
|
|
94
|
+
return `Ejecutando ${toolName.replace(/_/g, " ")}...`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function expandPath(p: string): string {
|
|
98
|
+
if (p.startsWith("~")) {
|
|
99
|
+
return path.join(process.env.HOME ?? "", p.slice(1));
|
|
100
|
+
}
|
|
101
|
+
return p;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// CORS helper for Vite dev server (port 5173)
|
|
105
|
+
const CORS_ORIGINS = ["http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:3000", "http://127.0.0.1:3000"];
|
|
106
|
+
function addCorsHeaders(response: Response, request: Request): Response {
|
|
107
|
+
const origin = request.headers.get("Origin");
|
|
108
|
+
if (!origin) return response;
|
|
109
|
+
|
|
110
|
+
// Allow any localhost origin for development
|
|
111
|
+
const isLocalhost = origin.includes("localhost") || origin.includes("127.0.0.1");
|
|
112
|
+
const isCorsOrigin = CORS_ORIGINS.some(o => origin.includes(o.replace("http://", "")));
|
|
113
|
+
|
|
114
|
+
if (isCorsOrigin || isLocalhost) {
|
|
115
|
+
const headers = new Headers(response.headers);
|
|
116
|
+
headers.set("Access-Control-Allow-Origin", origin);
|
|
117
|
+
headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
118
|
+
headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept, X-Requested-With");
|
|
119
|
+
headers.set("Access-Control-Allow-Credentials", "true");
|
|
120
|
+
headers.set("Access-Control-Max-Age", "86400");
|
|
121
|
+
return new Response(response.body, {
|
|
122
|
+
status: response.status,
|
|
123
|
+
statusText: response.statusText,
|
|
124
|
+
headers,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return response;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// FIX 1 ā Redactar secrets antes de exponer config al dashboard
|
|
131
|
+
// Tokens y API keys nunca viajan completos al cliente
|
|
132
|
+
function redactValue(value: string): string {
|
|
133
|
+
if (!value || value.length < 8) return "ā¢ā¢ā¢ā¢ā¢ā¢ā¢ā¢";
|
|
134
|
+
return `${value.slice(0, 4)}ā¢ā¢ā¢ā¢ā¢ā¢ā¢ā¢`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function redactConfig(cfg: Config): Record<string, unknown> {
|
|
138
|
+
const redacted = JSON.parse(JSON.stringify(cfg)) as any;
|
|
139
|
+
|
|
140
|
+
// Redactar authToken del gateway
|
|
141
|
+
if (redacted.gateway?.authToken) {
|
|
142
|
+
redacted.gateway.authToken = redactValue(redacted.gateway.authToken);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Redactar API keys de providers
|
|
146
|
+
if (redacted.models?.providers) {
|
|
147
|
+
for (const provider of Object.values(redacted.models.providers) as any[]) {
|
|
148
|
+
if (provider?.apiKey) provider.apiKey = redactValue(provider.apiKey);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Redactar tokens de canales
|
|
153
|
+
if (redacted.channels) {
|
|
154
|
+
for (const channel of Object.values(redacted.channels) as any[]) {
|
|
155
|
+
if (channel?.accounts) {
|
|
156
|
+
for (const acc of Object.values(channel.accounts) as any[]) {
|
|
157
|
+
if (acc?.botToken) acc.botToken = redactValue(acc.botToken);
|
|
158
|
+
if (acc?.appToken) acc.appToken = redactValue(acc.appToken);
|
|
159
|
+
if (acc?.signingSecret) acc.signingSecret = redactValue(acc.signingSecret);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Redactar headers de MCP servers
|
|
166
|
+
if (redacted.mcp?.servers) {
|
|
167
|
+
for (const server of Object.values(redacted.mcp.servers) as any[]) {
|
|
168
|
+
if (server?.headers) {
|
|
169
|
+
for (const [k, v] of Object.entries(server.headers)) {
|
|
170
|
+
const lk = k.toLowerCase();
|
|
171
|
+
if (lk.includes("auth") || lk.includes("token") || lk.includes("key")) {
|
|
172
|
+
server.headers[k] = redactValue(v as string);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return redacted;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// FIX 3 ā Evitar "redaction round-trip"
|
|
183
|
+
// Si recibimos una configuración con headers redactados (terminan en ā¢ā¢ā¢ā¢ā¢ā¢ā¢ā¢),
|
|
184
|
+
// preservamos el valor real que ya tenemos en memoria o BD.
|
|
185
|
+
function mergeHeaders(newHeaders: Record<string, string>, oldHeaders: Record<string, string> = {}): Record<string, string> {
|
|
186
|
+
const merged = { ...newHeaders };
|
|
187
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
188
|
+
if (typeof value === "string" && value.endsWith("ā¢ā¢ā¢ā¢ā¢ā¢ā¢ā¢")) {
|
|
189
|
+
if (oldHeaders[key]) {
|
|
190
|
+
merged[key] = oldHeaders[key];
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return merged;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
interface WebSocketData {
|
|
198
|
+
sessionId: string;
|
|
199
|
+
authenticatedAt: number;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function startGateway(config: Config): Promise<void> {
|
|
203
|
+
const host = config.gateway?.host ?? "127.0.0.1";
|
|
204
|
+
const port = config.gateway?.port ?? 18790;
|
|
205
|
+
const pidFile = expandPath(config.gateway?.pidFile ?? "~/.hive/gateway.pid");
|
|
206
|
+
|
|
207
|
+
// FIX 2 ā startTime para calcular uptime en /status y /api/agents
|
|
208
|
+
const startTime = Date.now();
|
|
209
|
+
|
|
210
|
+
// CPU delta sampling ā process.cpuUsage() is cumulative; we diff between calls
|
|
211
|
+
const numCores = osCpus().length || 1;
|
|
212
|
+
let lastCpuSample = process.cpuUsage();
|
|
213
|
+
let lastCpuSampleTime = Date.now();
|
|
214
|
+
const log = logger.child("gateway");
|
|
215
|
+
const mcpLog = logger.child("mcp:api");
|
|
216
|
+
|
|
217
|
+
log.info(`Starting gateway on ${host}:${port}`);
|
|
218
|
+
|
|
219
|
+
// āā Inicialización modular con manejo de errores āāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
220
|
+
let agent: Agent;
|
|
221
|
+
let runner: AgentRunner;
|
|
222
|
+
let channelManager: ChannelManager;
|
|
223
|
+
let dbProvider: string;
|
|
224
|
+
let dbModel: string;
|
|
225
|
+
const agentList = config.agents?.list ?? [];
|
|
226
|
+
const defaultAgent = agentList.find((a) => a.default) ?? agentList[0];
|
|
227
|
+
const workspacePath = expandPath(defaultAgent?.workspace ?? "~/.hive/workspace");
|
|
228
|
+
|
|
229
|
+
// āā Bind port immediately so parent health-check doesn't timeout āāāāāāāāāā
|
|
230
|
+
// The full handler is loaded via server.reload() once initialization finishes
|
|
231
|
+
let server = Bun.serve<WebSocketData>({
|
|
232
|
+
port,
|
|
233
|
+
hostname: host,
|
|
234
|
+
fetch: (_req) => Response.json({ status: "starting" }),
|
|
235
|
+
websocket: { open() { }, message() { }, close() { } },
|
|
236
|
+
});
|
|
237
|
+
log.info(`Port ${port} bound (initializing gateway...)`);
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
// Usar el inicializador modular para todos los componentes crĆticos
|
|
241
|
+
const init = await initializeGateway(config, pidFile);
|
|
242
|
+
|
|
243
|
+
agent = init.agent;
|
|
244
|
+
runner = init.runner;
|
|
245
|
+
channelManager = init.channelManager;
|
|
246
|
+
dbProvider = init.provider;
|
|
247
|
+
dbModel = init.model;
|
|
248
|
+
|
|
249
|
+
log.info("ā
Gateway initialization completed successfully");
|
|
250
|
+
} catch (error) {
|
|
251
|
+
log.error(`ā Gateway initialization failed: ${(error as Error).message}`);
|
|
252
|
+
log.error("Stack trace:", (error as Error).stack);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Check for insecure binding
|
|
257
|
+
if (host === "0.0.0.0" && config.security?.warnOnInsecureConfig !== false) {
|
|
258
|
+
log.warn("Gateway binding to 0.0.0.0 exposes server to all network interfaces!");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// āā CRON Handler setup āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
262
|
+
function prepareTools(agentInstance: Agent, sessionId: string) {
|
|
263
|
+
// Tools are now handled by LangGraph internally via the supervisor
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
agent.on("cron", async (sessionId: string, task: string, jobId?: string, context?: { fecha_usuario: string; hora_usuario: string }) => {
|
|
268
|
+
log.info(`[CRON] Triggered task '${task}' for session ${sessionId}, jobId: ${jobId || 'unknown'} [${context?.fecha_usuario} ${context?.hora_usuario}]`);
|
|
269
|
+
|
|
270
|
+
let notifyChannel = "webchat";
|
|
271
|
+
let notifySessionId = sessionId;
|
|
272
|
+
|
|
273
|
+
if (jobId) {
|
|
274
|
+
try {
|
|
275
|
+
const db = getDb();
|
|
276
|
+
const cronJob = db.query<any, [string]>(
|
|
277
|
+
"SELECT user_id, notify_channel_id, task_config FROM cron_jobs WHERE id = ?"
|
|
278
|
+
).get(jobId) as any;
|
|
279
|
+
|
|
280
|
+
if (cronJob) {
|
|
281
|
+
// Apply priority chain: explicit job channel ā user preference ā auto-detect
|
|
282
|
+
notifyChannel = resolveBestChannel(cronJob.user_id, cronJob.notify_channel_id);
|
|
283
|
+
|
|
284
|
+
// For external channels (telegram, discord, etc.) we need the channel-specific
|
|
285
|
+
// session ID (e.g. Telegram chatId), not the internal Hive userId.
|
|
286
|
+
// user_identities.channel_user_id is the exact value the channel uses as sessionId.
|
|
287
|
+
if (notifyChannel !== "webchat") {
|
|
288
|
+
const identity = db.query<{ channel_user_id: string }, [string, string]>(
|
|
289
|
+
"SELECT channel_user_id FROM user_identities WHERE user_id = ? AND channel = ? LIMIT 1"
|
|
290
|
+
).get(cronJob.user_id, notifyChannel);
|
|
291
|
+
if (identity?.channel_user_id) {
|
|
292
|
+
notifySessionId = identity.channel_user_id;
|
|
293
|
+
} else {
|
|
294
|
+
// No identity found for this channel ā fall back to webchat to avoid
|
|
295
|
+
// sending a non-parseable user UUID as a chat ID to an external channel.
|
|
296
|
+
log.warn(`[CRON] No identity found for channel '${notifyChannel}', falling back to webchat`);
|
|
297
|
+
notifyChannel = "webchat";
|
|
298
|
+
notifySessionId = cronJob.user_id;
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
notifySessionId = cronJob.user_id;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
} catch (e) {
|
|
305
|
+
log.warn(`[CRON] Could not fetch notify channel for job ${jobId}: ${(e as Error).message}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Use the resolved session for both internal processing and notifications
|
|
310
|
+
const activeSessionId = notifySessionId;
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
await channelManager.send(notifyChannel, activeSessionId, {
|
|
314
|
+
content: `ā° Ejecutando tarea: ${task}`,
|
|
315
|
+
type: "progress"
|
|
316
|
+
});
|
|
317
|
+
} catch (notifyErr) {
|
|
318
|
+
log.warn(`[CRON] Could not send start notification: ${(notifyErr as Error).message}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Add as 'user' role so it appears in the chat history and the agent sees it as a direct request
|
|
322
|
+
const cronMessage = `[CRON] Scheduled task triggered: ${task}.
|
|
323
|
+
[fecha_usuario: ${context?.fecha_usuario || ""}]
|
|
324
|
+
[hora_usuario: ${context?.hora_usuario || ""}]
|
|
325
|
+
Please execute it now.`;
|
|
326
|
+
dbService.addMessage(activeSessionId, "user", cronMessage);
|
|
327
|
+
|
|
328
|
+
laneQueue.enqueue(activeSessionId, async (_t, signal) => {
|
|
329
|
+
if (signal.aborted) return;
|
|
330
|
+
try {
|
|
331
|
+
const history = dbService.getMessages(activeSessionId);
|
|
332
|
+
const messages = history.map((row: ChatMessageRow) => ({
|
|
333
|
+
role: row.role as "user" | "assistant" | "system",
|
|
334
|
+
content: row.content,
|
|
335
|
+
}));
|
|
336
|
+
|
|
337
|
+
const provider = dbProvider;
|
|
338
|
+
|
|
339
|
+
log.info(`[CRON] Generating response for session ${activeSessionId}...`);
|
|
340
|
+
const response = await runner.generate({
|
|
341
|
+
provider: provider as any,
|
|
342
|
+
messages,
|
|
343
|
+
maxTokens: 4096,
|
|
344
|
+
tools: prepareTools(agent, activeSessionId),
|
|
345
|
+
maxSteps: 15,
|
|
346
|
+
threadId: activeSessionId,
|
|
347
|
+
onStep: async (step) => {
|
|
348
|
+
if (step.type === "text" && step.message) {
|
|
349
|
+
const trimmedMessage = (typeof step.message === "string" ? step.message : "").trim();
|
|
350
|
+
if (trimmedMessage) {
|
|
351
|
+
await channelManager.send(notifyChannel, notifySessionId, {
|
|
352
|
+
content: trimmedMessage,
|
|
353
|
+
type: "progress"
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (step.type === "tool_result" && step.message) {
|
|
358
|
+
try {
|
|
359
|
+
const result = JSON.parse(step.message);
|
|
360
|
+
if (result._sendToUser || result.status) {
|
|
361
|
+
const userMessage = result.message || result.status || step.message;
|
|
362
|
+
await channelManager.send(notifyChannel, notifySessionId, {
|
|
363
|
+
content: `š ${userMessage}`,
|
|
364
|
+
type: "progress"
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
} catch { }
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const responseContent = response.content?.trim() || "Task completed.";
|
|
373
|
+
dbService.addMessage(sessionId, "assistant", responseContent, {
|
|
374
|
+
response_type: "text",
|
|
375
|
+
channel: "cron"
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const session = sessionManager.get(sessionId);
|
|
379
|
+
if (session?.ws) {
|
|
380
|
+
session.ws.send(JSON.stringify({ type: "message", sessionId: sessionId, content: responseContent } as OutboundMessage));
|
|
381
|
+
} else {
|
|
382
|
+
await channelManager.send(notifyChannel, notifySessionId, responseContent);
|
|
383
|
+
}
|
|
384
|
+
} catch (error) {
|
|
385
|
+
log.error(`[CRON] Error for session ${sessionId}: ${(error as Error).message}`);
|
|
386
|
+
await channelManager.send(notifyChannel, notifySessionId, {
|
|
387
|
+
content: `ā Error en tarea programada: ${(error as Error).message}`,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// Set up hot reload watchers
|
|
394
|
+
const watchers: Array<() => void> = [];
|
|
395
|
+
|
|
396
|
+
// Note: Context store, Ethics, Supervisor Graph, LLM runner, and Channel Manager
|
|
397
|
+
// are now initialized by initializeGateway() above
|
|
398
|
+
|
|
399
|
+
// Handle messages from channels (Telegram, Discord, WhatsApp, Slack)
|
|
400
|
+
channelManager.onMessage(async (message: IncomingMessage) => {
|
|
401
|
+
log.info(`š„ Message from ${message.channel}:${message.accountId}`);
|
|
402
|
+
log.info(` Session: ${message.sessionId}`);
|
|
403
|
+
|
|
404
|
+
const voiceConfig = voiceService.getChannelVoiceConfig(message.channel);
|
|
405
|
+
let messageContent = message.content;
|
|
406
|
+
|
|
407
|
+
let preferAudioResponse = false;
|
|
408
|
+
let inputType: "text" | "audio_transcribed" = "text";
|
|
409
|
+
let sttProviderUsed: string | null = null;
|
|
410
|
+
|
|
411
|
+
if (voiceConfig.voiceEnabled && message.audio) {
|
|
412
|
+
log.info(`šļø Voice enabled, processing audio...`);
|
|
413
|
+
|
|
414
|
+
if (!voiceConfig.sttProvider) {
|
|
415
|
+
log.warn(`ā ļø STT provider not configured for channel ${message.channel}`);
|
|
416
|
+
await channelManager.send(message.channel, message.sessionId, {
|
|
417
|
+
content: `šļø Para usar notas de voz, necesitas configurar el proveedor STT en la configuración del canal. Ve a Configuración > Canales > [Tu canal] y configura "Prov. STT" (ej: groq-whisper o openai)`,
|
|
418
|
+
});
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
const audioInput = voiceService.normalizeAudioFromChannel(message.channel, message.audio);
|
|
424
|
+
sttProviderUsed = voiceConfig.sttProvider || "groq-whisper";
|
|
425
|
+
messageContent = await voiceService.transcribe(audioInput, sttProviderUsed);
|
|
426
|
+
log.info(`š Transcribed: ${messageContent.substring(0, 100)}...`);
|
|
427
|
+
|
|
428
|
+
inputType = "audio_transcribed";
|
|
429
|
+
// If user sent audio and TTS is available, always respond in audio
|
|
430
|
+
preferAudioResponse = !!voiceConfig.ttsProvider;
|
|
431
|
+
|
|
432
|
+
await channelManager.send(message.channel, message.sessionId, {
|
|
433
|
+
content: `šļø Transcripción: ${messageContent}`,
|
|
434
|
+
type: "message"
|
|
435
|
+
});
|
|
436
|
+
} catch (error) {
|
|
437
|
+
log.error(`ā Transcription failed: ${(error as Error).message}`);
|
|
438
|
+
await channelManager.send(message.channel, message.sessionId, {
|
|
439
|
+
content: `Error al transcribir audio: ${(error as Error).message}`,
|
|
440
|
+
});
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
log.info(` Content: ${messageContent.substring(0, 150)}${messageContent.length > 150 ? "..." : ""}`);
|
|
446
|
+
|
|
447
|
+
const { userId } = resolveContext({
|
|
448
|
+
channel: message.channel,
|
|
449
|
+
channelUserId: message.sessionId,
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
const telegramMeta = message.metadata?.telegram as { messageId?: number } | undefined;
|
|
453
|
+
const messageId = telegramMeta?.messageId?.toString();
|
|
454
|
+
await Promise.all([
|
|
455
|
+
channelManager.markAsRead(message.channel, message.sessionId, messageId),
|
|
456
|
+
channelManager.startTyping(message.channel, message.sessionId),
|
|
457
|
+
]);
|
|
458
|
+
|
|
459
|
+
// unifiedSessionId = userId del onboarding ā historial y thread LangGraph unificados
|
|
460
|
+
const unifiedSessionId = userId;
|
|
461
|
+
// routingSessionId = peerId del canal ā para enviar respuestas de vuelta al canal correcto
|
|
462
|
+
const routingSessionId = message.sessionId;
|
|
463
|
+
|
|
464
|
+
const userMetadata = inputType === "audio_transcribed"
|
|
465
|
+
? { input_type: "audio_transcribed", stt_provider: sttProviderUsed, channel: message.channel }
|
|
466
|
+
: { input_type: "text", channel: message.channel };
|
|
467
|
+
|
|
468
|
+
dbService.addMessage(unifiedSessionId, "user", messageContent, userMetadata);
|
|
469
|
+
|
|
470
|
+
// Obtener la zona horaria del usuario para el timestamp exacto
|
|
471
|
+
const userRow = getDb()
|
|
472
|
+
.query<any, [string]>("SELECT * FROM users WHERE id = ?")
|
|
473
|
+
.get(userId);
|
|
474
|
+
const userTimezone = userRow?.timezone || "UTC";
|
|
475
|
+
const now = new Date();
|
|
476
|
+
let exactTime = "";
|
|
477
|
+
try {
|
|
478
|
+
exactTime = now.toLocaleString("en-US", {
|
|
479
|
+
timeZone: userTimezone,
|
|
480
|
+
dateStyle: "full",
|
|
481
|
+
timeStyle: "long",
|
|
482
|
+
});
|
|
483
|
+
} catch (e) {
|
|
484
|
+
exactTime = now.toISOString();
|
|
485
|
+
}
|
|
486
|
+
const messageContentWithTime = `[Timestamp: ${exactTime} (${userTimezone})]\n${messageContent}`;
|
|
487
|
+
|
|
488
|
+
const messages = [{ role: "user" as const, content: messageContentWithTime }];
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
log.info(`š¤ Routing to agent loop...`);
|
|
492
|
+
|
|
493
|
+
const response = await runner.generate({
|
|
494
|
+
provider: dbProvider as any,
|
|
495
|
+
messages,
|
|
496
|
+
maxTokens: 4096,
|
|
497
|
+
tools: prepareTools(agent, unifiedSessionId),
|
|
498
|
+
maxSteps: 15,
|
|
499
|
+
threadId: unifiedSessionId,
|
|
500
|
+
userId,
|
|
501
|
+
channel: message.channel,
|
|
502
|
+
onStep: async (step) => {
|
|
503
|
+
// "text" = el agente narra lo que estĆ” pensando/haciendo antes de un tool_call
|
|
504
|
+
if (step.type === "text" && step.message) {
|
|
505
|
+
const trimmedMessage = (typeof step.message === "string" ? step.message : "").trim();
|
|
506
|
+
if (trimmedMessage) {
|
|
507
|
+
log.debug(`[NARRATION] ${trimmedMessage.substring(0, 100)}`);
|
|
508
|
+
await channelManager.send(message.channel, routingSessionId, {
|
|
509
|
+
content: trimmedMessage,
|
|
510
|
+
type: "progress",
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// "tool_call" = el agente va a ejecutar una herramienta ā narrar al usuario
|
|
517
|
+
if (step.type === "tool_call" && step.toolName) {
|
|
518
|
+
const narration = getNarration(step.toolName);
|
|
519
|
+
log.debug(`[TOOL] ${step.toolName} ā "${narration}"`);
|
|
520
|
+
await channelManager.send(message.channel, routingSessionId, {
|
|
521
|
+
content: narration,
|
|
522
|
+
type: "progress",
|
|
523
|
+
});
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// "tool_result" = resultado de la herramienta
|
|
528
|
+
// Solo enviamos al usuario si el resultado lo pide explĆcitamente
|
|
529
|
+
if (step.type === "tool_result" && step.message) {
|
|
530
|
+
try {
|
|
531
|
+
const result = JSON.parse(step.message);
|
|
532
|
+
if (result._sendToUser) {
|
|
533
|
+
const userMessage = result.message || result.status || step.message;
|
|
534
|
+
await channelManager.send(message.channel, routingSessionId, {
|
|
535
|
+
content: userMessage,
|
|
536
|
+
type: "progress",
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
} catch {
|
|
540
|
+
// No es JSON estructurado ā no enviamos resultados crudos al usuario
|
|
541
|
+
}
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
const responseContent = response.content || "...";
|
|
548
|
+
log.info(`š¤ LLM response: ${responseContent.substring(0, 100)}${responseContent.length > 100 ? "..." : ""}`);
|
|
549
|
+
|
|
550
|
+
const shouldSpeak = preferAudioResponse;
|
|
551
|
+
let responseType: "text" | "audio" = "text";
|
|
552
|
+
let ttsProviderUsed: string | null = null;
|
|
553
|
+
let ttsMimeType: string | null = null;
|
|
554
|
+
|
|
555
|
+
if (responseContent && responseContent !== "...") {
|
|
556
|
+
if (shouldSpeak) {
|
|
557
|
+
if (!voiceConfig.ttsProvider) {
|
|
558
|
+
log.warn(`ā ļø TTS provider not configured, user requested audio`);
|
|
559
|
+
await channelManager.send(message.channel, routingSessionId, {
|
|
560
|
+
content: `${responseContent}\n\nš Para recibir respuestas en audio, configura el proveedor TTS en Configuración > Canales > [Tu canal] (ej: elevenlabs, openai-tts)`
|
|
561
|
+
});
|
|
562
|
+
} else {
|
|
563
|
+
try {
|
|
564
|
+
log.info(`š TTS enabled, synthesizing audio...`);
|
|
565
|
+
const audioOutput = await voiceService.speak(responseContent, voiceConfig.ttsProvider, voiceConfig.ttsVoiceId || undefined);
|
|
566
|
+
ttsProviderUsed = voiceConfig.ttsProvider;
|
|
567
|
+
ttsMimeType = audioOutput.mimeType;
|
|
568
|
+
responseType = "audio";
|
|
569
|
+
|
|
570
|
+
const channel = channelManager.getChannel(message.channel);
|
|
571
|
+
if (channel?.sendAudio) {
|
|
572
|
+
await channel.sendAudio(routingSessionId, audioOutput.data as Buffer, audioOutput.mimeType);
|
|
573
|
+
log.info(`ā
Audio sent to ${routingSessionId}`);
|
|
574
|
+
} else {
|
|
575
|
+
await channelManager.send(message.channel, routingSessionId, { content: responseContent });
|
|
576
|
+
}
|
|
577
|
+
} catch (error) {
|
|
578
|
+
log.error(`ā TTS failed: ${(error as Error).message}), sending text instead`);
|
|
579
|
+
await channelManager.send(message.channel, routingSessionId, { content: responseContent });
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
} else {
|
|
583
|
+
await channelManager.send(message.channel, routingSessionId, { content: responseContent });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const assistantMetadata = {
|
|
588
|
+
response_type: responseType,
|
|
589
|
+
tts_provider: ttsProviderUsed,
|
|
590
|
+
mime_type: ttsMimeType,
|
|
591
|
+
channel: message.channel
|
|
592
|
+
};
|
|
593
|
+
dbService.addMessage(unifiedSessionId, "assistant", responseContent, assistantMetadata);
|
|
594
|
+
|
|
595
|
+
await channelManager.stopTyping(message.channel, routingSessionId);
|
|
596
|
+
log.info(`ā
Response sent to ${routingSessionId} via ${message.channel}`);
|
|
597
|
+
} catch (error) {
|
|
598
|
+
await channelManager.stopTyping(message.channel, routingSessionId);
|
|
599
|
+
log.error(`ā Error: ${(error as Error).message} `);
|
|
600
|
+
await channelManager.send(message.channel, routingSessionId, {
|
|
601
|
+
content: `Error: ${(error as Error).message} `,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// āā Auth helper āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
607
|
+
// En modo desarrollo (HIVE_DEV=true), no requerimos autenticación
|
|
608
|
+
const isDev = process.env.HIVE_DEV === "true" || process.env.NODE_ENV === "development";
|
|
609
|
+
|
|
610
|
+
function checkAuth(req: Request, url: URL): boolean {
|
|
611
|
+
// En modo desarrollo, permitir todo
|
|
612
|
+
if (isDev) return true;
|
|
613
|
+
|
|
614
|
+
// Read live from env so the token set during setup/complete takes effect immediately
|
|
615
|
+
const activeToken = process.env.HIVE_AUTH_TOKEN;
|
|
616
|
+
if (!activeToken) return true;
|
|
617
|
+
const authHeader = req.headers.get("authorization");
|
|
618
|
+
const provided = authHeader?.replace(/^Bearer\s+/i, "") ?? url.searchParams.get("token");
|
|
619
|
+
return provided === activeToken;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Reload with full handler now that initialization is complete
|
|
623
|
+
server.reload({
|
|
624
|
+
async fetch(req, server) {
|
|
625
|
+
const start = Date.now();
|
|
626
|
+
const url = new URL(req.url);
|
|
627
|
+
const method = req.method;
|
|
628
|
+
|
|
629
|
+
const logRequest = (status: number, duration: number) => {
|
|
630
|
+
// Skip health checks from spamming logs unless debug
|
|
631
|
+
if (url.pathname === "/health" || url.pathname === "/health/") {
|
|
632
|
+
log.debug(`${method} ${url.pathname} - ${status} (${duration}ms)`);
|
|
633
|
+
} else {
|
|
634
|
+
log.info(`${method} ${url.pathname} - ${status} (${duration}ms)`);
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
const handleRequest = async (): Promise<Response | undefined> => {
|
|
639
|
+
|
|
640
|
+
// āā CORS preflight āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
641
|
+
if (req.method === "OPTIONS") {
|
|
642
|
+
const origin = req.headers.get("Origin");
|
|
643
|
+
if (origin && (origin.includes("localhost") || origin.includes("127.0.0.1") || CORS_ORIGINS.some(o => origin.includes(o.replace("http://", ""))))) {
|
|
644
|
+
return new Response(null, {
|
|
645
|
+
status: 204,
|
|
646
|
+
headers: {
|
|
647
|
+
"Access-Control-Allow-Origin": origin,
|
|
648
|
+
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
649
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, Accept, X-Requested-With",
|
|
650
|
+
"Access-Control-Allow-Credentials": "true",
|
|
651
|
+
"Access-Control-Max-Age": "86400",
|
|
652
|
+
},
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
return new Response(null, { status: 204 });
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// āā WebSocket upgrade āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
659
|
+
if (url.pathname === "/ws" || url.pathname === "/ws/") {
|
|
660
|
+
// En modo desarrollo, no requerir autenticación para WebSocket
|
|
661
|
+
if (!isDev && !checkAuth(req, url)) {
|
|
662
|
+
return new Response("Unauthorized", { status: 401 });
|
|
663
|
+
}
|
|
664
|
+
const sessionId = url.searchParams.get("session") || process.env.HIVE_USER_ID;
|
|
665
|
+
if (!sessionId) {
|
|
666
|
+
return new Response("Missing session or user ID", { status: 400 });
|
|
667
|
+
}
|
|
668
|
+
const success = server.upgrade(req, {
|
|
669
|
+
data: { sessionId, authenticatedAt: Date.now() },
|
|
670
|
+
});
|
|
671
|
+
if (success) return undefined;
|
|
672
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// āā Bridge Events WebSocket upgrade āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
676
|
+
if (url.pathname === "/bridge-events" || url.pathname === "/bridge-events/") {
|
|
677
|
+
const sessionId = `bridge:${url.searchParams.get("sessionId") ?? (process.env.HIVE_USER_ID ?? "default")}`;
|
|
678
|
+
const success = server.upgrade(req, { data: { sessionId, authenticatedAt: Date.now() } });
|
|
679
|
+
if (success) return undefined;
|
|
680
|
+
return new Response("Bridge events WebSocket upgrade failed", { status: 400 });
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// āā Canvas WebSocket upgrade āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
684
|
+
if (url.pathname === "/canvas" || url.pathname === "/canvas/") {
|
|
685
|
+
// En modo desarrollo, no requerir autenticación para Canvas WebSocket
|
|
686
|
+
let sessionId = url.searchParams.get("sessionId") ?? url.searchParams.get("session");
|
|
687
|
+
if (!sessionId && process.env.HIVE_USER_ID) {
|
|
688
|
+
sessionId = `canvas:${process.env.HIVE_USER_ID}`;
|
|
689
|
+
}
|
|
690
|
+
if (!sessionId) {
|
|
691
|
+
return new Response("Missing session or user ID for canvas", { status: 400 });
|
|
692
|
+
}
|
|
693
|
+
const success = server.upgrade(req, {
|
|
694
|
+
data: { sessionId: sessionId.startsWith("canvas:") ? sessionId : `canvas:${sessionId}`, authenticatedAt: Date.now() },
|
|
695
|
+
});
|
|
696
|
+
if (success) return undefined;
|
|
697
|
+
return new Response("Canvas WebSocket upgrade failed", { status: 400 });
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// āā Health (must be before UI routing so it works in dev mode too) āāā
|
|
701
|
+
if (url.pathname === "/health" || url.pathname === "/health/") {
|
|
702
|
+
return addCorsHeaders(Response.json({ status: "ok", pid: process.pid }), req);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// āā Dashboard / UI āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
706
|
+
// In development: UI is served by Vite on port 5173, Gateway only handles /api and /ws
|
|
707
|
+
// In production: serve static files from packages/hive-ui/dist
|
|
708
|
+
|
|
709
|
+
// Check if this is an API or WebSocket request
|
|
710
|
+
const isApiRequest = url.pathname.startsWith("/api");
|
|
711
|
+
const isWsRequest = url.pathname.startsWith("/ws");
|
|
712
|
+
const isUiRequest = url.pathname === "/ui" || url.pathname === "/ui/" || url.pathname.startsWith("/ui/") || url.pathname.startsWith("/ui?");
|
|
713
|
+
const isSetupRequest = url.pathname === "/setup" || url.pathname === "/setup/" || url.pathname.startsWith("/setup/") || url.pathname.startsWith("/setup?");
|
|
714
|
+
|
|
715
|
+
// In development mode, skip UI handling - Vite handles it directly
|
|
716
|
+
// Only serve static files from dist if they exist (production mode)
|
|
717
|
+
if (!isApiRequest && !isWsRequest) {
|
|
718
|
+
// In development: tell user to use Vite directly
|
|
719
|
+
if (isDev) {
|
|
720
|
+
return new Response(
|
|
721
|
+
"UI not available through Gateway in development.\n\n" +
|
|
722
|
+
"Use Vite directly: http://localhost:5173\n",
|
|
723
|
+
{ status: 404, headers: { "Content-Type": "text/plain" } }
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// In production: serve from dist folder
|
|
728
|
+
const uiDir = path.join(process.cwd(), "packages/hive-ui/dist");
|
|
729
|
+
let subPath = url.pathname;
|
|
730
|
+
|
|
731
|
+
// Normalize path for /ui routes
|
|
732
|
+
if (subPath === "/ui" || subPath === "/ui/") {
|
|
733
|
+
subPath = "/index.html";
|
|
734
|
+
} else if (subPath.startsWith("/ui/")) {
|
|
735
|
+
subPath = subPath.replace(/^\/ui/, "");
|
|
736
|
+
if (!subPath) subPath = "/index.html";
|
|
737
|
+
} else if (subPath === "/") {
|
|
738
|
+
subPath = "/index.html";
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Normalize path for /setup routes
|
|
742
|
+
if (subPath === "/setup" || subPath === "/setup/") {
|
|
743
|
+
subPath = "/index.html";
|
|
744
|
+
} else if (subPath.startsWith("/setup/")) {
|
|
745
|
+
subPath = subPath.replace(/^\/setup/, "");
|
|
746
|
+
if (!subPath) subPath = "/index.html";
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const filePath = path.join(uiDir, subPath);
|
|
750
|
+
const uiFile = Bun.file(filePath);
|
|
751
|
+
if (await uiFile.exists()) {
|
|
752
|
+
return new Response(uiFile);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// If it's a UI route and no dist, show message
|
|
756
|
+
if (isUiRequest || isSetupRequest) {
|
|
757
|
+
return new Response(
|
|
758
|
+
"UI not found.\n\n" +
|
|
759
|
+
"To build for production:\n" +
|
|
760
|
+
" cd packages/hive-ui && bun run build\n",
|
|
761
|
+
{ status: 404, headers: { "Content-Type": "text/plain" } }
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Handle /dashboard redirect for backwards compatibility
|
|
767
|
+
if (url.pathname.startsWith("/dashboard")) {
|
|
768
|
+
const tokenParam = url.searchParams.get("token") ? `? token = ${url.searchParams.get("token")} ` : "";
|
|
769
|
+
return Response.redirect(`/ ui${tokenParam} `, 301);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// āā Rutas que requieren autenticación āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
773
|
+
if (!checkAuth(req, url)) {
|
|
774
|
+
log.warn(`[AUTH] Unauthorized request to ${url.pathname} from ${req.headers.get("origin")} `);
|
|
775
|
+
return new Response("Unauthorized", { status: 401 });
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// āā Setup API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
779
|
+
// Only available when hive.db does not exist (first-run mode)
|
|
780
|
+
const isSetupMode = !existsSync(getDbPathLazy());
|
|
781
|
+
|
|
782
|
+
// GET /api/setup/status - Check if setup is needed
|
|
783
|
+
if (url.pathname === "/api/setup/status" || url.pathname === "/api/setup/status/") {
|
|
784
|
+
return addCorsHeaders(Response.json({
|
|
785
|
+
configured: !isSetupMode,
|
|
786
|
+
setupMode: isSetupMode,
|
|
787
|
+
}), req);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// POST /api/setup/verify-provider - Verify API key
|
|
791
|
+
if (url.pathname === "/api/setup/verify-provider" && req.method === "POST") {
|
|
792
|
+
const body = await req.json().catch(() => ({}));
|
|
793
|
+
const { provider, apiKey, model } = body;
|
|
794
|
+
|
|
795
|
+
if (!provider || !apiKey) {
|
|
796
|
+
return addCorsHeaders(Response.json({
|
|
797
|
+
success: false,
|
|
798
|
+
error: "Provider and API key are required",
|
|
799
|
+
}, { status: 400 }), req);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
try {
|
|
803
|
+
let testUrl: string | null = null;
|
|
804
|
+
let testBody: any = null;
|
|
805
|
+
let headers: Record<string, string> = {};
|
|
806
|
+
|
|
807
|
+
const testMessages = [{ role: "user" as const, content: "Say 'ok' if you can read this." }];
|
|
808
|
+
|
|
809
|
+
if (provider === "ollama") {
|
|
810
|
+
// Ollama doesn't require API key, just check if it's running
|
|
811
|
+
try {
|
|
812
|
+
const response = await fetch("http://localhost:11434/api/tags", {
|
|
813
|
+
signal: AbortSignal.timeout(5000),
|
|
814
|
+
});
|
|
815
|
+
return addCorsHeaders(Response.json({
|
|
816
|
+
success: response.ok,
|
|
817
|
+
error: response.ok ? null : "Could not connect to Ollama",
|
|
818
|
+
}), req);
|
|
819
|
+
} catch {
|
|
820
|
+
return addCorsHeaders(Response.json({
|
|
821
|
+
success: false,
|
|
822
|
+
error: "Could not connect to Ollama at http://localhost:11434",
|
|
823
|
+
}), req);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (provider === "anthropic") {
|
|
828
|
+
testUrl = "https://api.anthropic.com/v1/messages";
|
|
829
|
+
testBody = {
|
|
830
|
+
model: model || "claude-sonnet-4-6",
|
|
831
|
+
max_tokens: 10,
|
|
832
|
+
messages: testMessages,
|
|
833
|
+
};
|
|
834
|
+
headers = {
|
|
835
|
+
"Content-Type": "application/json",
|
|
836
|
+
"x-api-key": apiKey,
|
|
837
|
+
"anthropic-version": "2023-06-01",
|
|
838
|
+
"anthropic-dangerous-direct-browser-access": "true",
|
|
839
|
+
};
|
|
840
|
+
} else if (provider === "openai") {
|
|
841
|
+
testUrl = "https://api.openai.com/v1/chat/completions";
|
|
842
|
+
testBody = {
|
|
843
|
+
model: model || "gpt-5.2",
|
|
844
|
+
max_tokens: 10,
|
|
845
|
+
messages: testMessages,
|
|
846
|
+
};
|
|
847
|
+
headers = {
|
|
848
|
+
"Content-Type": "application/json",
|
|
849
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
850
|
+
};
|
|
851
|
+
} else if (provider === "gemini") {
|
|
852
|
+
testUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model || "gemini-2.5-flash"}:generateContent?key=${apiKey}`;
|
|
853
|
+
testBody = {
|
|
854
|
+
contents: [{ parts: [{ text: "Say ok" }] }],
|
|
855
|
+
};
|
|
856
|
+
headers = { "Content-Type": "application/json" };
|
|
857
|
+
} else if (provider === "groq") {
|
|
858
|
+
testUrl = "https://api.groq.com/openai/v1/chat/completions";
|
|
859
|
+
testBody = {
|
|
860
|
+
model: model || "llama-3.3-70b-versatile",
|
|
861
|
+
max_tokens: 10,
|
|
862
|
+
messages: testMessages,
|
|
863
|
+
};
|
|
864
|
+
headers = {
|
|
865
|
+
"Content-Type": "application/json",
|
|
866
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
867
|
+
};
|
|
868
|
+
} else if (provider === "openrouter") {
|
|
869
|
+
testUrl = "https://openrouter.ai/api/v1/chat/completions";
|
|
870
|
+
testBody = {
|
|
871
|
+
model: model || "meta-llama/llama-3.3-70b-instruct",
|
|
872
|
+
max_tokens: 10,
|
|
873
|
+
messages: testMessages,
|
|
874
|
+
};
|
|
875
|
+
headers = {
|
|
876
|
+
"Content-Type": "application/json",
|
|
877
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (!testUrl) {
|
|
882
|
+
return addCorsHeaders(Response.json({
|
|
883
|
+
success: false,
|
|
884
|
+
error: "Unsupported provider",
|
|
885
|
+
}, { status: 400 }), req);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const response = await fetch(testUrl, {
|
|
889
|
+
method: "POST",
|
|
890
|
+
headers,
|
|
891
|
+
body: JSON.stringify(testBody),
|
|
892
|
+
signal: AbortSignal.timeout(10000),
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
return addCorsHeaders(Response.json({
|
|
896
|
+
success: response.ok,
|
|
897
|
+
error: response.ok ? null : `API error: ${response.status}`,
|
|
898
|
+
}), req);
|
|
899
|
+
} catch (error) {
|
|
900
|
+
return addCorsHeaders(Response.json({
|
|
901
|
+
success: false,
|
|
902
|
+
error: `Connection error: ${(error as Error).message}`,
|
|
903
|
+
}), req);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// POST /api/setup/complete - Complete setup wizard
|
|
908
|
+
if (url.pathname === "/api/setup/complete" && req.method === "POST") {
|
|
909
|
+
if (!isSetupMode) {
|
|
910
|
+
return addCorsHeaders(Response.json({
|
|
911
|
+
success: false,
|
|
912
|
+
error: "Setup already completed. Use config endpoints to modify settings.",
|
|
913
|
+
}, { status: 400 }), req);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const body = await req.json().catch(() => ({}));
|
|
917
|
+
|
|
918
|
+
try {
|
|
919
|
+
// Import onboarding functions
|
|
920
|
+
const {
|
|
921
|
+
initOnboardingDb,
|
|
922
|
+
saveUserProfile,
|
|
923
|
+
saveProviderConfig,
|
|
924
|
+
activateChannel,
|
|
925
|
+
saveVoiceConfig,
|
|
926
|
+
} = await import("../storage/onboarding");
|
|
927
|
+
const { randomUUID } = await import("node:crypto");
|
|
928
|
+
const { saveToken } = await import("../integrations/env");
|
|
929
|
+
|
|
930
|
+
// Initialize database with schema and seeds
|
|
931
|
+
initOnboardingDb();
|
|
932
|
+
log.info("ā
Database initialized with schema and seeds");
|
|
933
|
+
|
|
934
|
+
// Generate IDs
|
|
935
|
+
const userId = `user_${randomUUID().split("-")[0]}`;
|
|
936
|
+
const agentId = `agent_${randomUUID().split("-")[0]}`;
|
|
937
|
+
const channelUserId = randomUUID();
|
|
938
|
+
|
|
939
|
+
// Save user profile
|
|
940
|
+
saveUserProfile({
|
|
941
|
+
userId,
|
|
942
|
+
userName: body.userName || "User",
|
|
943
|
+
userLanguage: body.userLanguage || "es",
|
|
944
|
+
userTimezone: body.userTimezone || "UTC",
|
|
945
|
+
userOccupation: body.userOccupation || "",
|
|
946
|
+
userNotes: body.userNotes || "",
|
|
947
|
+
agentName: body.agentName || "Bee",
|
|
948
|
+
agentId,
|
|
949
|
+
agentDescription: body.agentDescription || "",
|
|
950
|
+
agentTone: "friendly",
|
|
951
|
+
channelUserId,
|
|
952
|
+
});
|
|
953
|
+
log.info(`ā
User profile saved: ${userId}`);
|
|
954
|
+
|
|
955
|
+
// Save provider config with API key
|
|
956
|
+
if (body.provider && body.apiKey) {
|
|
957
|
+
await saveProviderConfig({
|
|
958
|
+
userId,
|
|
959
|
+
provider: body.provider,
|
|
960
|
+
model: body.model,
|
|
961
|
+
apiKey: body.apiKey,
|
|
962
|
+
});
|
|
963
|
+
log.info(`ā
Provider config saved: ${body.provider}/${body.model}`);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Activate WebChat channel (always enabled)
|
|
967
|
+
await activateChannel(userId, {
|
|
968
|
+
channelId: "webchat",
|
|
969
|
+
channelUserId,
|
|
970
|
+
config: {},
|
|
971
|
+
});
|
|
972
|
+
log.info("ā
WebChat channel activated");
|
|
973
|
+
|
|
974
|
+
// Activate other channels if configured
|
|
975
|
+
if (body.channels) {
|
|
976
|
+
for (const [channelId, channelData] of Object.entries(body.channels as Record<string, any>)) {
|
|
977
|
+
if (channelId !== "webchat" && channelData.enabled) {
|
|
978
|
+
await activateChannel(userId, {
|
|
979
|
+
channelId,
|
|
980
|
+
config: channelData.config || {},
|
|
981
|
+
});
|
|
982
|
+
log.info(`ā
Channel activated: ${channelId}`);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// Save voice config if enabled
|
|
988
|
+
if (body.voiceEnabled) {
|
|
989
|
+
await saveVoiceConfig({
|
|
990
|
+
userId,
|
|
991
|
+
channelId: "webchat",
|
|
992
|
+
voiceEnabled: true,
|
|
993
|
+
sttProvider: body.sttProvider || "groq-whisper",
|
|
994
|
+
ttsProvider: body.ttsProvider || "elevenlabs",
|
|
995
|
+
});
|
|
996
|
+
log.info("ā
Voice config saved");
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Generate auth token and persist to .env (loaded by buildDefaultConfig on next start)
|
|
1000
|
+
const authToken = randomUUID().replace(/-/g, "");
|
|
1001
|
+
saveToken("HIVE_AUTH_TOKEN", authToken);
|
|
1002
|
+
|
|
1003
|
+
// Update the running process env + in-memory config so auth works immediately
|
|
1004
|
+
process.env.HIVE_AUTH_TOKEN = authToken;
|
|
1005
|
+
process.env.HIVE_USER_ID = userId;
|
|
1006
|
+
process.env.HIVE_AGENT_ID = agentId;
|
|
1007
|
+
config.gateway = { ...config.gateway, authToken };
|
|
1008
|
+
|
|
1009
|
+
return addCorsHeaders(Response.json({
|
|
1010
|
+
success: true,
|
|
1011
|
+
userId,
|
|
1012
|
+
agentId,
|
|
1013
|
+
authToken,
|
|
1014
|
+
message: "Setup completed successfully",
|
|
1015
|
+
}), req);
|
|
1016
|
+
} catch (error) {
|
|
1017
|
+
log.error(`ā Setup failed: ${(error as Error).message}`);
|
|
1018
|
+
return addCorsHeaders(Response.json({
|
|
1019
|
+
success: false,
|
|
1020
|
+
error: (error as Error).message,
|
|
1021
|
+
}, { status: 500 }), req);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// āā Status āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
1026
|
+
// FIX 3 ā Cache-Control: max-age=5 para que el dashboard no haga
|
|
1027
|
+
// polling mƔs frecuente de cada 5 segundos (causa del spam en Network tab)
|
|
1028
|
+
if (url.pathname === "/status" || url.pathname === "/status/") {
|
|
1029
|
+
return addCorsHeaders(new Response(
|
|
1030
|
+
JSON.stringify({
|
|
1031
|
+
status: "ok",
|
|
1032
|
+
version: "0.1.7",
|
|
1033
|
+
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
1034
|
+
gateway: { host, port },
|
|
1035
|
+
sessions: sessionManager.list().map((s) => ({
|
|
1036
|
+
id: s.id,
|
|
1037
|
+
createdAt: s.createdAt,
|
|
1038
|
+
messageCount: s.messageCount,
|
|
1039
|
+
})),
|
|
1040
|
+
channels: channelManager.listChannels(),
|
|
1041
|
+
queue: { activeSessions: 0 },
|
|
1042
|
+
}),
|
|
1043
|
+
{
|
|
1044
|
+
headers: {
|
|
1045
|
+
"Content-Type": "application/json",
|
|
1046
|
+
// Decirle al browser/dashboard que este endpoint no cambia
|
|
1047
|
+
// mĆ”s rĆ”pido que 5 segundos ā frena el polling descontrolado
|
|
1048
|
+
"Cache-Control": "max-age=5",
|
|
1049
|
+
},
|
|
1050
|
+
}
|
|
1051
|
+
), req);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// āā Activity Stats (message counts per hour) āāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
1055
|
+
if (url.pathname === "/api/activity-stats" || url.pathname === "/api/activity-stats/") {
|
|
1056
|
+
const hours = Math.min(parseInt(url.searchParams.get("hours") || "12"), 48);
|
|
1057
|
+
try {
|
|
1058
|
+
const db = getDb();
|
|
1059
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1060
|
+
const result: Array<{ time: string; count: number }> = [];
|
|
1061
|
+
for (let i = hours - 1; i >= 0; i--) {
|
|
1062
|
+
const from = now - (i + 1) * 3600;
|
|
1063
|
+
const to = now - i * 3600;
|
|
1064
|
+
const row = db.prepare(
|
|
1065
|
+
`SELECT COUNT(*) as count FROM messages WHERE created_at >= ? AND created_at < ? AND role IN ('user','assistant')`
|
|
1066
|
+
).get(from, to) as { count: number };
|
|
1067
|
+
result.push({ time: i === 0 ? "Ahora" : `${i}h`, count: row.count });
|
|
1068
|
+
}
|
|
1069
|
+
return addCorsHeaders(Response.json(result, {
|
|
1070
|
+
headers: { "Cache-Control": "max-age=60" }
|
|
1071
|
+
}), req);
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
log.error(`[API] Failed to get activity stats: ${(error as Error).message}`);
|
|
1074
|
+
return addCorsHeaders(Response.json({ error: "Failed" }, { status: 500 }), req);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// āā System Stats (CPU, Memory, Uptime) āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
1079
|
+
if (url.pathname === "/api/system-stats" || url.pathname === "/api/system-stats/") {
|
|
1080
|
+
const mem = process.memoryUsage();
|
|
1081
|
+
const uptimeSeconds = Math.floor((Date.now() - startTime) / 1000);
|
|
1082
|
+
|
|
1083
|
+
// Proper CPU % = delta CPU time / delta wall time (per core)
|
|
1084
|
+
const nowMs = Date.now();
|
|
1085
|
+
const currentCpu = process.cpuUsage();
|
|
1086
|
+
const elapsedUs = (nowMs - lastCpuSampleTime) * 1000;
|
|
1087
|
+
const cpuDeltaUs = (currentCpu.user - lastCpuSample.user) + (currentCpu.system - lastCpuSample.system);
|
|
1088
|
+
const cpuPercent = elapsedUs > 0
|
|
1089
|
+
? Math.min(100, Math.round((cpuDeltaUs / (elapsedUs * numCores)) * 100))
|
|
1090
|
+
: 0;
|
|
1091
|
+
lastCpuSample = currentCpu;
|
|
1092
|
+
lastCpuSampleTime = nowMs;
|
|
1093
|
+
|
|
1094
|
+
const formatUptime = (seconds: number): string => {
|
|
1095
|
+
const days = Math.floor(seconds / 86400);
|
|
1096
|
+
const hours = Math.floor((seconds % 86400) / 3600);
|
|
1097
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
1098
|
+
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
|
|
1099
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
1100
|
+
if (minutes > 0) return `${minutes}m`;
|
|
1101
|
+
return `${seconds % 60}s`;
|
|
1102
|
+
};
|
|
1103
|
+
|
|
1104
|
+
const fmt = (b: number) => `${(b / 1024 / 1024).toFixed(1)} MB`;
|
|
1105
|
+
|
|
1106
|
+
const rssMB = Math.round(mem.rss / 1024 / 1024);
|
|
1107
|
+
const heapUsedMB = Math.round(mem.heapUsed / 1024 / 1024);
|
|
1108
|
+
const heapTotalMB = Math.round(mem.heapTotal / 1024 / 1024);
|
|
1109
|
+
// heapUsed can briefly exceed heapTotal while V8 expands ā cap at 100
|
|
1110
|
+
const heapPercent = Math.min(100, Math.round((mem.heapUsed / Math.max(mem.heapTotal, 1)) * 100));
|
|
1111
|
+
|
|
1112
|
+
// Recent messages (last 5 min) as activity indicator
|
|
1113
|
+
let recentMessages = 0;
|
|
1114
|
+
try {
|
|
1115
|
+
const since5m = Math.floor(Date.now() / 1000) - 300;
|
|
1116
|
+
recentMessages = (getDb().prepare(
|
|
1117
|
+
`SELECT COUNT(*) as c FROM messages WHERE created_at >= ? AND role IN ('user','assistant')`
|
|
1118
|
+
).get(since5m) as { c: number }).c;
|
|
1119
|
+
} catch { /* non-critical */ }
|
|
1120
|
+
|
|
1121
|
+
log.debug(`[API] System stats: rss=${rssMB}MB heap=${heapUsedMB}/${heapTotalMB}MB msgs5m=${recentMessages}`);
|
|
1122
|
+
|
|
1123
|
+
return addCorsHeaders(Response.json({
|
|
1124
|
+
cpu: cpuPercent,
|
|
1125
|
+
memory: {
|
|
1126
|
+
rss: rssMB,
|
|
1127
|
+
heapUsed: heapUsedMB,
|
|
1128
|
+
heapTotal: heapTotalMB,
|
|
1129
|
+
heapPercent,
|
|
1130
|
+
external: Math.round(mem.external / 1024 / 1024),
|
|
1131
|
+
// legacy fields kept for compatibility
|
|
1132
|
+
used: heapUsedMB,
|
|
1133
|
+
total: heapTotalMB,
|
|
1134
|
+
percentage: heapPercent,
|
|
1135
|
+
},
|
|
1136
|
+
uptime: formatUptime(uptimeSeconds),
|
|
1137
|
+
connections: sessionManager.list().length,
|
|
1138
|
+
cores: numCores,
|
|
1139
|
+
recentMessages,
|
|
1140
|
+
}, {
|
|
1141
|
+
headers: { "Cache-Control": "max-age=2" }
|
|
1142
|
+
}), req);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// āā Interactive Benchmark āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
1146
|
+
if (url.pathname === "/api/benchmark" || url.pathname === "/api/benchmark/") {
|
|
1147
|
+
if (req.method === "POST") {
|
|
1148
|
+
log.info(`[API] Running interactive benchmark...`);
|
|
1149
|
+
const bench = new Benchmark("UI Interactive").start();
|
|
1150
|
+
|
|
1151
|
+
// Capture a snapshot of current resource usage
|
|
1152
|
+
const metrics = bench.stop();
|
|
1153
|
+
|
|
1154
|
+
return addCorsHeaders(Response.json({
|
|
1155
|
+
success: true,
|
|
1156
|
+
timestamp: new Date().toISOString(),
|
|
1157
|
+
metrics
|
|
1158
|
+
}), req);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// āā System Reload (Hot Reload for MCP, Tools, Agents) āāāāāāāāāāāāāāāā
|
|
1163
|
+
if (url.pathname === "/api/system/reload" || url.pathname === "/api/system/reload/") {
|
|
1164
|
+
if (req.method === "POST") {
|
|
1165
|
+
if (!checkAuth(req, url)) {
|
|
1166
|
+
return addCorsHeaders(new Response("Unauthorized", { status: 401 }), req);
|
|
1167
|
+
}
|
|
1168
|
+
log.info(`[API] š System reload requested...`);
|
|
1169
|
+
try {
|
|
1170
|
+
// Re-init the agent to pick up new MCP servers from the DB
|
|
1171
|
+
await agent.initialize();
|
|
1172
|
+
log.info(`[API] Agent re - initialized(MCPs reconnected)`);
|
|
1173
|
+
|
|
1174
|
+
// Rebuild the supervisor graph so sub-agents are refreshed too
|
|
1175
|
+
const mcp = agent.getMCPManager();
|
|
1176
|
+
if (mcp) {
|
|
1177
|
+
await rebuildSupervisorGraph({ mcpManager: mcp });
|
|
1178
|
+
log.info(`[API] Supervisor graph rebuilt`);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
return addCorsHeaders(Response.json({
|
|
1182
|
+
success: true,
|
|
1183
|
+
message: "System reloaded. New tools, MCP servers, and providers are now active.",
|
|
1184
|
+
timestamp: new Date().toISOString(),
|
|
1185
|
+
}), req);
|
|
1186
|
+
} catch (error) {
|
|
1187
|
+
log.error(`[API] System reload failed: ${(error as Error).message} `);
|
|
1188
|
+
return addCorsHeaders(Response.json({
|
|
1189
|
+
success: false,
|
|
1190
|
+
error: (error as Error).message,
|
|
1191
|
+
}, { status: 500 }), req);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// āā Usage Stats (tokens, costs) āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
1197
|
+
if (url.pathname === "/api/usage-stats" || url.pathname === "/api/usage-stats/") {
|
|
1198
|
+
const hours = parseInt(url.searchParams.get("hours") || "24");
|
|
1199
|
+
log.info(`[API] GET / api / usage - stats hours = ${hours} `);
|
|
1200
|
+
try {
|
|
1201
|
+
const stats = getUsageStats(Math.min(hours, 720));
|
|
1202
|
+
log.info(`[API] Usage stats response: totalTokens = ${stats.totalTokens} totalCost = $${stats.totalCostUsd.toFixed(4)} providers = ${Object.keys(stats.byProvider).length} `);
|
|
1203
|
+
return addCorsHeaders(Response.json(stats, {
|
|
1204
|
+
headers: { "Cache-Control": "max-age=30" }
|
|
1205
|
+
}), req);
|
|
1206
|
+
} catch (error) {
|
|
1207
|
+
log.error(`[API] Failed to get usage stats: ${(error as Error).message} `);
|
|
1208
|
+
return addCorsHeaders(Response.json({ error: "Failed to get usage stats" }, { status: 500 }), req);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// āā Config āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
1213
|
+
if (url.pathname === "/api/config") {
|
|
1214
|
+
if (req.method === "GET") {
|
|
1215
|
+
// FIX 1 ā nunca devolver el config raw con secrets
|
|
1216
|
+
return addCorsHeaders(Response.json(redactConfig(config)), req);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// āā Projects API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
1221
|
+
|
|
1222
|
+
// GET /api/projects ā lista todos los proyectos con sus tareas anidadas
|
|
1223
|
+
if (url.pathname === "/api/projects" || url.pathname === "/api/projects/") {
|
|
1224
|
+
const db = getDb();
|
|
1225
|
+
if (req.method === "GET") {
|
|
1226
|
+
const statusFilter = url.searchParams.get("status");
|
|
1227
|
+
const query = statusFilter
|
|
1228
|
+
? `SELECT * FROM projects WHERE status = ? ORDER BY created_at DESC`
|
|
1229
|
+
: `SELECT * FROM projects ORDER BY created_at DESC`;
|
|
1230
|
+
const projects = (statusFilter
|
|
1231
|
+
? db.query(query).all(statusFilter)
|
|
1232
|
+
: db.query(query).all()) as any[];
|
|
1233
|
+
|
|
1234
|
+
const result = projects.map((p: any) => ({
|
|
1235
|
+
...p,
|
|
1236
|
+
tasks: db.query("SELECT * FROM tasks WHERE project_id = ? ORDER BY id ASC").all(p.id),
|
|
1237
|
+
}));
|
|
1238
|
+
return addCorsHeaders(Response.json(result), req);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
if (url.pathname === "/api/projects/active") {
|
|
1243
|
+
if (req.method === "GET") {
|
|
1244
|
+
const db = getDb();
|
|
1245
|
+
const projects = db.query("SELECT * FROM projects WHERE status IN ('active','pending','paused') ORDER BY created_at DESC").all() as any[];
|
|
1246
|
+
const result = projects.map((p: any) => ({
|
|
1247
|
+
...p,
|
|
1248
|
+
tasks: db.query("SELECT * FROM tasks WHERE project_id = ? ORDER BY id ASC").all(p.id),
|
|
1249
|
+
}));
|
|
1250
|
+
return addCorsHeaders(Response.json(result), req);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
if (url.pathname === "/api/projects/history") {
|
|
1255
|
+
if (req.method === "GET") {
|
|
1256
|
+
const page = parseInt(url.searchParams.get("page") || "1");
|
|
1257
|
+
const limit = parseInt(url.searchParams.get("limit") || "20");
|
|
1258
|
+
const offset = (page - 1) * limit;
|
|
1259
|
+
|
|
1260
|
+
const db = getDb();
|
|
1261
|
+
const projects = db.query(
|
|
1262
|
+
"SELECT * FROM projects WHERE status IN ('done','failed') ORDER BY completed_at DESC LIMIT ? OFFSET ?"
|
|
1263
|
+
).all(limit, offset) as any[];
|
|
1264
|
+
|
|
1265
|
+
const total = db.query("SELECT COUNT(*) as count FROM projects WHERE status IN ('done', 'failed')").get() as { count: number };
|
|
1266
|
+
|
|
1267
|
+
return addCorsHeaders(Response.json({
|
|
1268
|
+
data: projects.map((p: any) => ({
|
|
1269
|
+
...p,
|
|
1270
|
+
tasks: db.query("SELECT * FROM tasks WHERE project_id = ? ORDER BY id ASC").all(p.id),
|
|
1271
|
+
})),
|
|
1272
|
+
pagination: {
|
|
1273
|
+
page,
|
|
1274
|
+
limit,
|
|
1275
|
+
total: total.count,
|
|
1276
|
+
pages: Math.ceil(total.count / limit)
|
|
1277
|
+
}
|
|
1278
|
+
}), req);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// GET/PATCH /api/projects/:id
|
|
1283
|
+
const projectDetailMatch = url.pathname.match(/^\/api\/projects\/([^/]+)$/);
|
|
1284
|
+
if (projectDetailMatch) {
|
|
1285
|
+
const projectId = projectDetailMatch[1];
|
|
1286
|
+
const db = getDb();
|
|
1287
|
+
|
|
1288
|
+
if (req.method === "GET") {
|
|
1289
|
+
const project = db.query("SELECT * FROM projects WHERE id = ?").get(projectId) as any;
|
|
1290
|
+
if (!project) return new Response("Project not found", { status: 404 });
|
|
1291
|
+
|
|
1292
|
+
const tasks = db.query("SELECT * FROM tasks WHERE project_id = ? ORDER BY id ASC").all(projectId);
|
|
1293
|
+
const subprojects = db.query("SELECT * FROM projects WHERE parent_id = ?").all(projectId);
|
|
1294
|
+
return addCorsHeaders(Response.json({ ...project, tasks, subprojects }), req);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
if (req.method === "PATCH") {
|
|
1298
|
+
const body = await req.json().catch(() => ({}));
|
|
1299
|
+
const { status } = body;
|
|
1300
|
+
if (!status || !['active', 'paused', 'done', 'failed', 'pending'].includes(status)) {
|
|
1301
|
+
return new Response("Invalid status", { status: 400 });
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
const result = db.query("UPDATE projects SET status = ?, updated_at = unixepoch() WHERE id = ?").run(status, projectId);
|
|
1305
|
+
if (result.changes === 0) return new Response("Project not found", { status: 404 });
|
|
1306
|
+
|
|
1307
|
+
return addCorsHeaders(Response.json({ success: true, status }), req);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// GET /api/projects/:id/tasks
|
|
1312
|
+
const projectTasksMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/tasks$/);
|
|
1313
|
+
if (projectTasksMatch) {
|
|
1314
|
+
const projectId = projectTasksMatch[1];
|
|
1315
|
+
const db = getDb();
|
|
1316
|
+
|
|
1317
|
+
if (req.method === "GET") {
|
|
1318
|
+
const project = db.query("SELECT id FROM projects WHERE id = ?").get(projectId);
|
|
1319
|
+
if (!project) return new Response("Project not found", { status: 404 });
|
|
1320
|
+
const tasks = db.query("SELECT * FROM tasks WHERE project_id = ? ORDER BY id ASC").all(projectId);
|
|
1321
|
+
return addCorsHeaders(Response.json(tasks), req);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// GET /api/tasks ā lista tareas (filtro por agentId opcional)
|
|
1326
|
+
if (url.pathname === "/api/tasks" || url.pathname === "/api/tasks/") {
|
|
1327
|
+
const db = getDb();
|
|
1328
|
+
if (req.method === "GET") {
|
|
1329
|
+
const agentId = url.searchParams.get("agentId");
|
|
1330
|
+
const tasks = agentId
|
|
1331
|
+
? db.query("SELECT * FROM tasks WHERE agent_id = ? ORDER BY id ASC").all(agentId)
|
|
1332
|
+
: db.query("SELECT * FROM tasks ORDER BY id DESC LIMIT 100").all();
|
|
1333
|
+
return addCorsHeaders(Response.json(tasks), req);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// PATCH /api/tasks/:id
|
|
1338
|
+
const taskDetailMatch = url.pathname.match(/^\/api\/tasks\/(\d+)$/);
|
|
1339
|
+
if (taskDetailMatch) {
|
|
1340
|
+
const taskId = parseInt(taskDetailMatch[1]);
|
|
1341
|
+
const db = getDb();
|
|
1342
|
+
|
|
1343
|
+
if (req.method === "PATCH") {
|
|
1344
|
+
const body = await req.json().catch(() => ({}));
|
|
1345
|
+
const allowed = ["status", "progress", "result", "agent_id"];
|
|
1346
|
+
const fields: string[] = ["updated_at = unixepoch()"];
|
|
1347
|
+
const values: any[] = [];
|
|
1348
|
+
|
|
1349
|
+
for (const key of allowed) {
|
|
1350
|
+
if (body[key] !== undefined) {
|
|
1351
|
+
fields.push(`${key} = ?`);
|
|
1352
|
+
values.push(body[key]);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
values.push(taskId);
|
|
1356
|
+
|
|
1357
|
+
const result = db.query(`UPDATE tasks SET ${fields.join(", ")} WHERE id = ?`).run(...values);
|
|
1358
|
+
if (result.changes === 0) return new Response("Task not found", { status: 404 });
|
|
1359
|
+
|
|
1360
|
+
// Recalcular progreso del proyecto
|
|
1361
|
+
const task = db.query<any, [number]>("SELECT project_id FROM tasks WHERE id = ?").get(taskId);
|
|
1362
|
+
if (task?.project_id) {
|
|
1363
|
+
const avgRow = db.query<any, [string]>("SELECT AVG(progress) as avg FROM tasks WHERE project_id = ?").get(task.project_id) as any;
|
|
1364
|
+
const avg = Math.round(avgRow?.avg ?? 0);
|
|
1365
|
+
db.query("UPDATE projects SET progress = ?, updated_at = unixepoch() WHERE id = ?").run(avg, task.project_id);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
return addCorsHeaders(Response.json({ success: true }), req);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
// āā Channels API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
1376
|
+
if (url.pathname === "/api/channels" || url.pathname === "/api/channels/") {
|
|
1377
|
+
if (req.method === "POST") {
|
|
1378
|
+
const body = await req.json().catch(() => ({}));
|
|
1379
|
+
const { name, accountId, config: channelConfigData } = body;
|
|
1380
|
+
if (!name || !accountId || !channelConfigData) {
|
|
1381
|
+
return addCorsHeaders(new Response("Missing name, accountId or config", { status: 400 }), req);
|
|
1382
|
+
}
|
|
1383
|
+
config.channels = config.channels || {};
|
|
1384
|
+
config.channels[name] = config.channels[name] || { enabled: true, accounts: {} };
|
|
1385
|
+
const channelEntry = config.channels[name] as any;
|
|
1386
|
+
channelEntry.accounts = channelEntry.accounts || {};
|
|
1387
|
+
channelEntry.accounts[accountId] = channelConfigData;
|
|
1388
|
+
await channelManager.removeChannel(name, accountId);
|
|
1389
|
+
await channelManager.startChannel(name, accountId);
|
|
1390
|
+
return addCorsHeaders(Response.json({ success: true }), req);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const channelDetailMatch = url.pathname.match(/^\/api\/channels\/([^/]+)\/([^/]+)$/);
|
|
1395
|
+
if (channelDetailMatch) {
|
|
1396
|
+
const name = channelDetailMatch[1];
|
|
1397
|
+
const accountId = channelDetailMatch[2];
|
|
1398
|
+
|
|
1399
|
+
if (req.method === "GET") {
|
|
1400
|
+
const accConfig = channelManager.getAccountConfig(name, accountId);
|
|
1401
|
+
if (!accConfig) return new Response("Channel account not found", { status: 404 });
|
|
1402
|
+
return Response.json({ name, accountId, config: accConfig });
|
|
1403
|
+
}
|
|
1404
|
+
if (req.method === "PUT") {
|
|
1405
|
+
const body = await req.json().catch(() => ({}));
|
|
1406
|
+
if (!body.config) return new Response("Missing config", { status: 400 });
|
|
1407
|
+
config.channels = config.channels || {};
|
|
1408
|
+
config.channels[name] = config.channels[name] || { enabled: true, accounts: {} };
|
|
1409
|
+
const channelEntry = config.channels[name] as any;
|
|
1410
|
+
channelEntry.accounts = channelEntry.accounts || {};
|
|
1411
|
+
channelEntry.accounts[accountId] = body.config;
|
|
1412
|
+
await channelManager.removeChannel(name, accountId);
|
|
1413
|
+
await channelManager.startChannel(name, accountId);
|
|
1414
|
+
return Response.json({ success: true });
|
|
1415
|
+
}
|
|
1416
|
+
if (req.method === "DELETE") {
|
|
1417
|
+
if (config.channels?.[name]) {
|
|
1418
|
+
const channelEntry = config.channels[name] as any;
|
|
1419
|
+
if (channelEntry.accounts) {
|
|
1420
|
+
delete channelEntry.accounts[accountId];
|
|
1421
|
+
if (Object.keys(channelEntry.accounts).length === 0) {
|
|
1422
|
+
delete config.channels[name];
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
await channelManager.removeChannel(name, accountId);
|
|
1426
|
+
}
|
|
1427
|
+
return Response.json({ success: true });
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
const channelActionMatch = url.pathname.match(
|
|
1432
|
+
/^\/api\/channels\/([^/]+)\/([^/]+)\/(start|stop)$/
|
|
1433
|
+
);
|
|
1434
|
+
if (channelActionMatch) {
|
|
1435
|
+
const [, name, accountId, action] = channelActionMatch;
|
|
1436
|
+
if (req.method === "POST") {
|
|
1437
|
+
try {
|
|
1438
|
+
if (action === "start") await channelManager.startChannel(name, accountId);
|
|
1439
|
+
else await channelManager.stopChannel(name, accountId);
|
|
1440
|
+
return Response.json({ success: true });
|
|
1441
|
+
} catch (error) {
|
|
1442
|
+
return Response.json(
|
|
1443
|
+
{ success: false, error: (error as Error).message },
|
|
1444
|
+
{ status: 500 }
|
|
1445
|
+
);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// āā Skills API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
1451
|
+
if (url.pathname === "/api/skills" || url.pathname === "/api/skills/") {
|
|
1452
|
+
if (req.method === "POST") {
|
|
1453
|
+
const body = await req.json().catch(() => ({}));
|
|
1454
|
+
const { name, description, content, raw } = body;
|
|
1455
|
+
if (!name || (!content && !raw)) {
|
|
1456
|
+
return addCorsHeaders(new Response("Missing name or content", { status: 400 }), req);
|
|
1457
|
+
}
|
|
1458
|
+
const managedDir = expandPath(config.skills?.managedDir ?? "~/.hive/skills");
|
|
1459
|
+
const skillDir = path.join(managedDir, name);
|
|
1460
|
+
const skillFile = path.join(skillDir, "SKILL.md");
|
|
1461
|
+
mkdirSync(skillDir, { recursive: true });
|
|
1462
|
+
const skillMd = raw || `-- -\nname: ${name} \ndescription: ${description || ""} \n-- -\n${content} `;
|
|
1463
|
+
await Bun.write(skillFile, skillMd);
|
|
1464
|
+
agent.reloadSkills();
|
|
1465
|
+
return addCorsHeaders(Response.json({ success: true }), req);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// āā Model Config API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
1470
|
+
if (url.pathname === "/api/config/models") {
|
|
1471
|
+
if (req.method === "GET") {
|
|
1472
|
+
return addCorsHeaders(Response.json({
|
|
1473
|
+
config: config.models || {},
|
|
1474
|
+
availableProviders: ["openai", "anthropic", "gemini", "kimi", "ollama", "openrouter", "deepseek"],
|
|
1475
|
+
}), req);
|
|
1476
|
+
}
|
|
1477
|
+
if (req.method === "POST") {
|
|
1478
|
+
const body = await req.json().catch(() => ({}));
|
|
1479
|
+
const { defaultProvider, defaults, providers } = body;
|
|
1480
|
+
config.models = config.models || {};
|
|
1481
|
+
if (defaultProvider) config.models.defaultProvider = defaultProvider;
|
|
1482
|
+
if (defaults) config.models.defaults = { ...(config.models.defaults || {}), ...defaults };
|
|
1483
|
+
if (providers) config.models.providers = { ...(config.models.providers || {}), ...providers };
|
|
1484
|
+
await agent.updateConfig(config);
|
|
1485
|
+
return addCorsHeaders(Response.json({ success: true }), req);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// āā MCP API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
1490
|
+
if (url.pathname.startsWith("/api/mcp/")) {
|
|
1491
|
+
const mcp = agent.getMCPManager();
|
|
1492
|
+
if (!mcp) return new Response("MCP is disabled", { status: 404 });
|
|
1493
|
+
|
|
1494
|
+
if (url.pathname === "/api/mcp/servers") {
|
|
1495
|
+
if (!checkAuth(req, url)) return new Response("Unauthorized", { status: 401 });
|
|
1496
|
+
|
|
1497
|
+
if (req.method === "GET") {
|
|
1498
|
+
// Obtener los MCPs activos del manager
|
|
1499
|
+
const activeServers = mcp.listServers();
|
|
1500
|
+
|
|
1501
|
+
// Obtener todos los MCPs de la base de datos
|
|
1502
|
+
const dbServers = dbService.listMCPServers();
|
|
1503
|
+
|
|
1504
|
+
// Combinar información: DB + estado de conexión en tiempo real
|
|
1505
|
+
const allServers = dbServers.map(s => {
|
|
1506
|
+
const activeServer = activeServers.find((as: any) => as.name === s.name);
|
|
1507
|
+
const isEnabled = s.enabled === 1;
|
|
1508
|
+
|
|
1509
|
+
// Redact headers for safe UI display
|
|
1510
|
+
let headers = undefined;
|
|
1511
|
+
if (s.headers_encrypted && s.headers_iv) {
|
|
1512
|
+
try {
|
|
1513
|
+
const decryptedHeaders = decryptConfig(s.headers_encrypted, s.headers_iv);
|
|
1514
|
+
headers = Object.fromEntries(
|
|
1515
|
+
Object.entries(decryptedHeaders).map(([k, v]) => [
|
|
1516
|
+
k,
|
|
1517
|
+
k.toLowerCase().includes("auth") ||
|
|
1518
|
+
k.toLowerCase().includes("token") ||
|
|
1519
|
+
k.toLowerCase().includes("key")
|
|
1520
|
+
? `${(v as string).slice(0, 4)}ā¢ā¢ā¢ā¢ā¢ā¢ā¢ā¢`
|
|
1521
|
+
: v,
|
|
1522
|
+
])
|
|
1523
|
+
);
|
|
1524
|
+
} catch (e) {
|
|
1525
|
+
log.error(`Failed to decrypt headers for ${s.name}: ${(e as Error).message} `);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
return {
|
|
1530
|
+
name: s.name,
|
|
1531
|
+
enabled: isEnabled,
|
|
1532
|
+
status: isEnabled
|
|
1533
|
+
? (activeServer?.status || "disconnected")
|
|
1534
|
+
: "not_configured",
|
|
1535
|
+
config: {
|
|
1536
|
+
transport: s.transport,
|
|
1537
|
+
command: s.command,
|
|
1538
|
+
args: s.args ? JSON.parse(s.args) : [],
|
|
1539
|
+
url: s.url,
|
|
1540
|
+
headers,
|
|
1541
|
+
enabled: isEnabled
|
|
1542
|
+
},
|
|
1543
|
+
tools_count: s.tools_count || 0,
|
|
1544
|
+
tools: activeServer?.tools || [],
|
|
1545
|
+
};
|
|
1546
|
+
});
|
|
1547
|
+
|
|
1548
|
+
return addCorsHeaders(Response.json(allServers), req);
|
|
1549
|
+
}
|
|
1550
|
+
if (req.method === "POST") {
|
|
1551
|
+
const body = await req.json().catch(() => ({}));
|
|
1552
|
+
if (!body.name || !body.config) {
|
|
1553
|
+
return new Response("Missing name or config", { status: 400 });
|
|
1554
|
+
}
|
|
1555
|
+
mcpLog.info(`Creating MCP server: ${body.name} `, { config: body.config });
|
|
1556
|
+
// Save to SQLite
|
|
1557
|
+
config.mcp = config.mcp || {};
|
|
1558
|
+
config.mcp.servers = config.mcp.servers || {};
|
|
1559
|
+
config.mcp.servers[body.name] = body.config;
|
|
1560
|
+
await agent.updateConfig(config);
|
|
1561
|
+
|
|
1562
|
+
// Update AgentLoop singleton with new mcpManager
|
|
1563
|
+
const agentLoop = getSupervisorGraph();
|
|
1564
|
+
if (agentLoop) {
|
|
1565
|
+
agentLoop.setMCPManager(agent.getMCPManager());
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// Encrypt sensitive headers for DB
|
|
1569
|
+
let headersEnc = undefined;
|
|
1570
|
+
if (body.config.headers) {
|
|
1571
|
+
// No hay viejos headers en un POST de server nuevo,
|
|
1572
|
+
// pero por consistencia usamos la función
|
|
1573
|
+
headersEnc = encryptConfig(body.config.headers);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// Save to SQLite
|
|
1577
|
+
dbService.saveMCPServer({
|
|
1578
|
+
name: body.name,
|
|
1579
|
+
...body.config,
|
|
1580
|
+
headers_encrypted: headersEnc?.encrypted,
|
|
1581
|
+
headers_iv: headersEnc?.iv,
|
|
1582
|
+
enabled: body.config.enabled !== false,
|
|
1583
|
+
builtin: false
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
mcpLog.info(`Successfully created MCP server: ${body.name} `);
|
|
1587
|
+
return addCorsHeaders(Response.json({ success: true }), req);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// FIX 5 ā separar el endpoint de tools del de detalles del servidor
|
|
1592
|
+
const toolsMatch = url.pathname.match(/^\/api\/mcp\/servers\/([^/]+)\/tools$/);
|
|
1593
|
+
if (toolsMatch && req.method === "GET") {
|
|
1594
|
+
if (!checkAuth(req, url)) return new Response("Unauthorized", { status: 401 });
|
|
1595
|
+
const serverName = decodeURIComponent(toolsMatch[1]);
|
|
1596
|
+
const tools = mcp.getServerTools(serverName);
|
|
1597
|
+
return addCorsHeaders(Response.json(tools), req);
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
const serverMatch = url.pathname.match(/^\/api\/mcp\/servers\/([^/]+)$/);
|
|
1601
|
+
if (serverMatch) {
|
|
1602
|
+
if (!checkAuth(req, url)) return new Response("Unauthorized", { status: 401 });
|
|
1603
|
+
const serverName = decodeURIComponent(serverMatch[1]);
|
|
1604
|
+
const details = mcp.getServerDetails(serverName);
|
|
1605
|
+
|
|
1606
|
+
if (req.method === "GET") {
|
|
1607
|
+
if (!details) return new Response("Server not found", { status: 404 });
|
|
1608
|
+
return addCorsHeaders(Response.json(details), req);
|
|
1609
|
+
}
|
|
1610
|
+
if (req.method === "PUT") {
|
|
1611
|
+
const body = await req.json().catch(() => ({}));
|
|
1612
|
+
if (!body.config) return new Response("Missing config", { status: 400 });
|
|
1613
|
+
|
|
1614
|
+
mcpLog.info(`Updating MCP server: ${serverName} `, { config: body.config });
|
|
1615
|
+
const oldConfig = config.mcp?.servers?.[serverName];
|
|
1616
|
+
|
|
1617
|
+
// Smart merge for headers
|
|
1618
|
+
if (body.config.headers && oldConfig?.headers) {
|
|
1619
|
+
body.config.headers = mergeHeaders(body.config.headers, oldConfig.headers);
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// Update SQLite
|
|
1623
|
+
config.mcp = config.mcp || {};
|
|
1624
|
+
config.mcp.servers = config.mcp.servers || {};
|
|
1625
|
+
config.mcp.servers[serverName] = body.config;
|
|
1626
|
+
await agent.updateConfig(config);
|
|
1627
|
+
|
|
1628
|
+
// Encrypt sensitive headers for DB
|
|
1629
|
+
let headersEnc = undefined;
|
|
1630
|
+
if (body.config.headers) {
|
|
1631
|
+
headersEnc = encryptConfig(body.config.headers);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// Update SQLite
|
|
1635
|
+
dbService.updateMCPServer(serverName, {
|
|
1636
|
+
...body.config,
|
|
1637
|
+
headers_encrypted: headersEnc?.encrypted,
|
|
1638
|
+
headers_iv: headersEnc?.iv,
|
|
1639
|
+
enabled: body.config.enabled !== false
|
|
1640
|
+
});
|
|
1641
|
+
|
|
1642
|
+
mcpLog.info(`Successfully updated MCP server: ${serverName} `);
|
|
1643
|
+
return addCorsHeaders(Response.json({ success: true }), req);
|
|
1644
|
+
}
|
|
1645
|
+
if (req.method === "DELETE") {
|
|
1646
|
+
mcpLog.info(`Deleting MCP server: ${serverName} `);
|
|
1647
|
+
if (config.mcp?.servers?.[serverName]) {
|
|
1648
|
+
delete config.mcp.servers[serverName];
|
|
1649
|
+
await agent.updateConfig(config);
|
|
1650
|
+
}
|
|
1651
|
+
// Logic to delete from SQLite could be added if desired,
|
|
1652
|
+
// but usually we just disable them or keep the record.
|
|
1653
|
+
// For now, let's just keep it simple and sync yaml.
|
|
1654
|
+
return addCorsHeaders(Response.json({ success: true }), req);
|
|
1655
|
+
}
|
|
1656
|
+
if (req.method === "PATCH") {
|
|
1657
|
+
const body = await req.json().catch(() => ({}));
|
|
1658
|
+
if (body.enabled !== undefined) {
|
|
1659
|
+
mcpLog.info(`${body.enabled ? "Enabling" : "Disabling"} MCP server: ${serverName} `);
|
|
1660
|
+
// Update SQLite
|
|
1661
|
+
config.mcp = config.mcp || {};
|
|
1662
|
+
config.mcp.servers = config.mcp.servers || {};
|
|
1663
|
+
config.mcp.servers[serverName] = {
|
|
1664
|
+
...config.mcp.servers[serverName],
|
|
1665
|
+
enabled: body.enabled,
|
|
1666
|
+
};
|
|
1667
|
+
await agent.updateConfig(config);
|
|
1668
|
+
|
|
1669
|
+
// Update SQLite
|
|
1670
|
+
dbService.updateMCPServer(serverName, { enabled: body.enabled });
|
|
1671
|
+
|
|
1672
|
+
return addCorsHeaders(Response.json({ success: true, enabled: body.enabled }), req);
|
|
1673
|
+
}
|
|
1674
|
+
return new Response("Missing enabled field", { status: 400 });
|
|
1675
|
+
}
|
|
1676
|
+
if (req.method === "POST") {
|
|
1677
|
+
const body = await req.json().catch(() => ({}));
|
|
1678
|
+
if (body.action === "connect") {
|
|
1679
|
+
// Check if server exists in DB (not in config variable which may be stale)
|
|
1680
|
+
const dbServer = dbService.listMCPServers().find((s: any) => s.name === serverName);
|
|
1681
|
+
if (!dbServer || !dbServer.enabled) {
|
|
1682
|
+
return new Response("Server not found or disabled", { status: 400 });
|
|
1683
|
+
}
|
|
1684
|
+
await mcp.connectServer(serverName);
|
|
1685
|
+
|
|
1686
|
+
// Update tools count after connection
|
|
1687
|
+
const tools = mcp.getServerTools(serverName) || [];
|
|
1688
|
+
dbService.updateMCPServer(serverName, {
|
|
1689
|
+
status: "connected",
|
|
1690
|
+
tools_count: tools.length
|
|
1691
|
+
});
|
|
1692
|
+
|
|
1693
|
+
return addCorsHeaders(Response.json({ success: true, tools_count: tools.length }), req);
|
|
1694
|
+
}
|
|
1695
|
+
if (body.action === "disconnect") {
|
|
1696
|
+
await mcp.disconnectServer(serverName);
|
|
1697
|
+
return addCorsHeaders(Response.json({ success: true }), req);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// āā Workspace API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
1704
|
+
for (const wsType of ["soul", "user", "ethics"] as const) {
|
|
1705
|
+
if (url.pathname === `/api/workspace/${wsType}`) {
|
|
1706
|
+
const filePath = path.join(workspacePath, `${wsType.toUpperCase()}.md`);
|
|
1707
|
+
|
|
1708
|
+
if (req.method === "GET") {
|
|
1709
|
+
const defaults: Record<string, string> = {
|
|
1710
|
+
soul: "# Agent Soul\n\nDefine your agent's personality here.",
|
|
1711
|
+
user: "# User Profile\n\nAdd user preferences here.",
|
|
1712
|
+
ethics: "# Ethics\n\nDefine ethical guidelines here.",
|
|
1713
|
+
};
|
|
1714
|
+
const wsFile = Bun.file(filePath);
|
|
1715
|
+
const content = (await wsFile.exists())
|
|
1716
|
+
? await wsFile.text()
|
|
1717
|
+
: defaults[wsType];
|
|
1718
|
+
return addCorsHeaders(new Response(content, { headers: { "Content-Type": "text/plain" } }), req);
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
if (req.method === "POST") {
|
|
1722
|
+
const content = await req.text();
|
|
1723
|
+
mkdirSync(workspacePath, { recursive: true });
|
|
1724
|
+
await Bun.write(filePath, content);
|
|
1725
|
+
if (wsType === "soul") agent.reloadSoul();
|
|
1726
|
+
if (wsType === "user") agent.reloadUser();
|
|
1727
|
+
if (wsType === "ethics") await agent.reloadEthics();
|
|
1728
|
+
return addCorsHeaders(Response.json({ success: true, savedAt: new Date().toISOString() }), req);
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// āā Reload API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
1734
|
+
if (url.pathname === "/api/reload" && req.method === "POST") {
|
|
1735
|
+
try {
|
|
1736
|
+
const newConfig = await loadConfig();
|
|
1737
|
+
await agent.updateConfig(newConfig);
|
|
1738
|
+
await agent.reload();
|
|
1739
|
+
log.info("Configuration reloaded via API");
|
|
1740
|
+
return addCorsHeaders(Response.json({ success: true, message: "Configuration reloaded" }), req);
|
|
1741
|
+
} catch (error) {
|
|
1742
|
+
return addCorsHeaders(Response.json(
|
|
1743
|
+
{ success: false, error: (error as Error).message },
|
|
1744
|
+
{ status: 500 }
|
|
1745
|
+
), req);
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
// āā User Channel Linking API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
1750
|
+
if (url.pathname === "/api/user/channels" && req.method === "POST") {
|
|
1751
|
+
const body = await req.json().catch(() => ({}));
|
|
1752
|
+
const { channel, channelUserId } = body;
|
|
1753
|
+
|
|
1754
|
+
if (!channel || !channelUserId) {
|
|
1755
|
+
return addCorsHeaders(Response.json({ success: false, error: "Missing channel or channelUserId" }, { status: 400 }), req);
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
config.user = config.user || { id: "", name: "User" };
|
|
1759
|
+
config.user.channels = config.user.channels || {};
|
|
1760
|
+
config.user.channels[channel] = channelUserId;
|
|
1761
|
+
|
|
1762
|
+
log.info(`Linked channel ${channel} to user ID ${channelUserId} `);
|
|
1763
|
+
|
|
1764
|
+
return addCorsHeaders(Response.json({ success: true, channels: config.user.channels }), req);
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
if (url.pathname === "/api/user/channels" && req.method === "GET") {
|
|
1768
|
+
return addCorsHeaders(Response.json({
|
|
1769
|
+
user: config.user || { id: "", name: "User", channels: {} },
|
|
1770
|
+
}), req);
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
// āā Agents API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
1774
|
+
if (url.pathname === "/api/agents" && req.method === "GET") {
|
|
1775
|
+
const rows = getDb().query(`
|
|
1776
|
+
SELECT a.*, u.notes as user_preferences,
|
|
1777
|
+
CASE WHEN a.headers_encrypted IS NOT NULL THEN 1 ELSE 0 END as has_headers
|
|
1778
|
+
FROM agents a
|
|
1779
|
+
LEFT JOIN users u ON a.user_id = u.id
|
|
1780
|
+
ORDER BY a.created_at DESC
|
|
1781
|
+
`).all() as any[];
|
|
1782
|
+
const agents = rows.map(row => ({
|
|
1783
|
+
id: row.id,
|
|
1784
|
+
name: row.name,
|
|
1785
|
+
description: row.description,
|
|
1786
|
+
status: row.status,
|
|
1787
|
+
enabled: Boolean(row.enabled),
|
|
1788
|
+
is_coordinator: Boolean(row.is_coordinator),
|
|
1789
|
+
isCoordinator: Boolean(row.is_coordinator),
|
|
1790
|
+
providerId: row.provider_id,
|
|
1791
|
+
modelId: row.model_id,
|
|
1792
|
+
tone: row.tone,
|
|
1793
|
+
hasHeaders: row.has_headers === 1,
|
|
1794
|
+
createdAt: new Date(row.created_at * 1000).toISOString(),
|
|
1795
|
+
// UI specific mocks/defaults
|
|
1796
|
+
taskCount: 0,
|
|
1797
|
+
successRate: 100,
|
|
1798
|
+
}));
|
|
1799
|
+
return addCorsHeaders(Response.json({ agents }), req);
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
if (url.pathname === "/api/agents" && req.method === "POST") {
|
|
1803
|
+
const body = await req.json().catch(() => ({}));
|
|
1804
|
+
const { encrypted: headersEnc, iv: headersIv } = body.headers ? encryptConfig(body.headers) : { encrypted: null, iv: null };
|
|
1805
|
+
|
|
1806
|
+
let agentId: string;
|
|
1807
|
+
|
|
1808
|
+
if (body.id) {
|
|
1809
|
+
agentId = body.id;
|
|
1810
|
+
getDb().query(`
|
|
1811
|
+
INSERT INTO agents(id, name, description, provider_id, model_id, tone, enabled, headers_encrypted, headers_iv)
|
|
1812
|
+
VALUES(?, ?, ?, ?, ?, ?, 1, ?, ?)
|
|
1813
|
+
`).run(
|
|
1814
|
+
agentId,
|
|
1815
|
+
body.name,
|
|
1816
|
+
body.description || "",
|
|
1817
|
+
body.providerId || "openai",
|
|
1818
|
+
body.modelId || "gpt-4o",
|
|
1819
|
+
body.tone || "friendly",
|
|
1820
|
+
headersEnc,
|
|
1821
|
+
headersIv
|
|
1822
|
+
);
|
|
1823
|
+
} else {
|
|
1824
|
+
const result = getDb().query(`
|
|
1825
|
+
INSERT INTO agents(name, description, provider_id, model_id, tone, enabled, headers_encrypted, headers_iv)
|
|
1826
|
+
VALUES(?, ?, ?, ?, ?, 1, ?, ?)
|
|
1827
|
+
RETURNING id
|
|
1828
|
+
`).get(
|
|
1829
|
+
body.name,
|
|
1830
|
+
body.description || "",
|
|
1831
|
+
body.providerId || "openai",
|
|
1832
|
+
body.modelId || "gpt-4o",
|
|
1833
|
+
body.tone || "friendly",
|
|
1834
|
+
headersEnc,
|
|
1835
|
+
headersIv
|
|
1836
|
+
) as { id: string } | undefined;
|
|
1837
|
+
agentId = result?.id || "";
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
emitCanvas("canvas:node_add", {
|
|
1841
|
+
node: { id: agentId, name: body.name, status: "idle", type: "agent" }
|
|
1842
|
+
});
|
|
1843
|
+
|
|
1844
|
+
// Return the created agent
|
|
1845
|
+
const agent = getDb().query(`
|
|
1846
|
+
SELECT id, name, description, provider_id, model_id, tone, status, enabled, active, created_at
|
|
1847
|
+
FROM agents WHERE id = ?
|
|
1848
|
+
`).get(agentId) as any;
|
|
1849
|
+
|
|
1850
|
+
return addCorsHeaders(Response.json({
|
|
1851
|
+
ok: true,
|
|
1852
|
+
agent: {
|
|
1853
|
+
id: agent.id,
|
|
1854
|
+
name: agent.name,
|
|
1855
|
+
description: agent.description,
|
|
1856
|
+
providerId: agent.provider_id,
|
|
1857
|
+
modelId: agent.model_id,
|
|
1858
|
+
tone: agent.tone,
|
|
1859
|
+
status: agent.status,
|
|
1860
|
+
enabled: agent.enabled === 1,
|
|
1861
|
+
active: agent.active === 1,
|
|
1862
|
+
createdAt: new Date(agent.created_at * 1000).toISOString(),
|
|
1863
|
+
}
|
|
1864
|
+
}), req);
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
// PATCH /api/agents/:id
|
|
1868
|
+
if (url.pathname.startsWith("/api/agents/") && (req.method === "PATCH" || req.method === "PUT")) {
|
|
1869
|
+
const agentId = url.pathname.split("/").pop();
|
|
1870
|
+
const requestHeaders = Object.fromEntries(req.headers.entries());
|
|
1871
|
+
log.info(`[API] PATCH / api / agents / ${agentId} reqHeaders = ${JSON.stringify(requestHeaders)} `);
|
|
1872
|
+
if (!agentId) return addCorsHeaders(new Response("Missing ID", { status: 400 }), req);
|
|
1873
|
+
|
|
1874
|
+
const body = await req.json().catch(() => ({}));
|
|
1875
|
+
log.info(`[API] PATCH full body: ${JSON.stringify(body)} `);
|
|
1876
|
+
|
|
1877
|
+
// Build dynamic SET clause for agents table
|
|
1878
|
+
const updates: string[] = [];
|
|
1879
|
+
const params: any[] = [];
|
|
1880
|
+
|
|
1881
|
+
const possibleFields = [
|
|
1882
|
+
"name", "description", "provider_id", "model_id", "status",
|
|
1883
|
+
"enabled", "tone"
|
|
1884
|
+
];
|
|
1885
|
+
|
|
1886
|
+
for (const field of possibleFields) {
|
|
1887
|
+
const bodyKey = field.replace(/_([a-z])/g, (g) => g[1].toUpperCase()); // camelCase check
|
|
1888
|
+
let val = body[field] !== undefined ? body[field] : body[bodyKey];
|
|
1889
|
+
|
|
1890
|
+
if (val !== undefined) {
|
|
1891
|
+
updates.push(`${field} = ?`);
|
|
1892
|
+
params.push(typeof val === 'object' ? JSON.stringify(val) : val);
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
// Handle headers separately for encryption
|
|
1897
|
+
const agentHeaders = body.headers !== undefined ? body.headers : body.config?.headers;
|
|
1898
|
+
log.info(`[API] PATCH agent headers: ${JSON.stringify(agentHeaders)} `);
|
|
1899
|
+
if (agentHeaders !== undefined) {
|
|
1900
|
+
const { encrypted, iv } = encryptConfig(agentHeaders);
|
|
1901
|
+
updates.push("headers_encrypted = ?");
|
|
1902
|
+
params.push(encrypted);
|
|
1903
|
+
updates.push("headers_iv = ?");
|
|
1904
|
+
params.push(iv);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// Handle userPreferences separately (stored in users table)
|
|
1908
|
+
const userPreferences = body.userPreferences !== undefined ? body.userPreferences : body.user_preferences;
|
|
1909
|
+
if (userPreferences !== undefined) {
|
|
1910
|
+
// Get the user_id for this agent
|
|
1911
|
+
const agentRow = getDb().query("SELECT user_id FROM agents WHERE id = ?").get(agentId) as any;
|
|
1912
|
+
if (agentRow && agentRow.user_id) {
|
|
1913
|
+
getDb().query(`UPDATE users SET notes = ? WHERE id = ? `).run(userPreferences, agentRow.user_id);
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
if (updates.length > 0) {
|
|
1918
|
+
params.push(agentId);
|
|
1919
|
+
getDb().query(`UPDATE agents SET ${updates.join(", ")} WHERE id = ? `).run(...params);
|
|
1920
|
+
|
|
1921
|
+
emitCanvas("canvas:node_update", {
|
|
1922
|
+
id: agentId,
|
|
1923
|
+
updates: body
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
return addCorsHeaders(Response.json({ ok: true }), req);
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// āā Providers API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
1931
|
+
if (url.pathname === "/api/providers" && req.method === "GET") {
|
|
1932
|
+
const rawProviders = getDb().query(`
|
|
1933
|
+
SELECT id, name, base_url, enabled, active, num_ctx,
|
|
1934
|
+
api_key_encrypted, api_key_iv,
|
|
1935
|
+
CASE WHEN api_key_encrypted IS NOT NULL THEN 1 ELSE 0 END as has_api_key,
|
|
1936
|
+
CASE WHEN headers_encrypted IS NOT NULL THEN 1 ELSE 0 END as has_headers
|
|
1937
|
+
FROM providers
|
|
1938
|
+
`).all() as any[];
|
|
1939
|
+
|
|
1940
|
+
const modelsRows = getDb().query(`
|
|
1941
|
+
SELECT id, name, provider_id, enabled, active FROM models
|
|
1942
|
+
`).all() as any[];
|
|
1943
|
+
|
|
1944
|
+
const modelsByProvider: Record<string, any[]> = {};
|
|
1945
|
+
for (const m of modelsRows) {
|
|
1946
|
+
if (!modelsByProvider[m.provider_id]) modelsByProvider[m.provider_id] = [];
|
|
1947
|
+
modelsByProvider[m.provider_id].push({ id: m.id, name: m.name, provider_id: m.provider_id, enabled: !!m.enabled, active: !!m.active });
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
const providers = rawProviders.map((p) => {
|
|
1951
|
+
let masked_api_key: string | null = null;
|
|
1952
|
+
if (p.api_key_encrypted && p.api_key_iv) {
|
|
1953
|
+
try {
|
|
1954
|
+
const plain = decryptApiKey(p.api_key_encrypted, p.api_key_iv);
|
|
1955
|
+
masked_api_key = maskApiKey(plain);
|
|
1956
|
+
} catch { /* silently ignore */ }
|
|
1957
|
+
}
|
|
1958
|
+
return {
|
|
1959
|
+
id: p.id,
|
|
1960
|
+
name: p.name,
|
|
1961
|
+
base_url: p.base_url,
|
|
1962
|
+
enabled: p.enabled,
|
|
1963
|
+
active: p.active,
|
|
1964
|
+
num_ctx: p.num_ctx ?? null,
|
|
1965
|
+
has_api_key: p.has_api_key,
|
|
1966
|
+
has_headers: p.has_headers,
|
|
1967
|
+
masked_api_key,
|
|
1968
|
+
models: modelsByProvider[p.id] || [],
|
|
1969
|
+
};
|
|
1970
|
+
});
|
|
1971
|
+
|
|
1972
|
+
return addCorsHeaders(Response.json({ providers }), req);
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
if (url.pathname === "/api/providers" && req.method === "POST") {
|
|
1976
|
+
const body = await req.json().catch(() => ({}));
|
|
1977
|
+
getDb().query(`
|
|
1978
|
+
INSERT OR REPLACE INTO providers(id, name, base_url, enabled, active)
|
|
1979
|
+
VALUES(?, ?, ?, ?, 1)
|
|
1980
|
+
`).run(body.id, body.name, body.base_url || null, body.enabled !== undefined ? body.enabled : 1);
|
|
1981
|
+
return addCorsHeaders(Response.json({ ok: true }), req);
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
if (url.pathname.match(/^\/api\/providers\/[^/]+\/toggle$/)) {
|
|
1985
|
+
const providerId = url.pathname.split("/")[3];
|
|
1986
|
+
if (req.method === "POST") {
|
|
1987
|
+
const body = await req.json().catch(() => ({}));
|
|
1988
|
+
const { active } = body;
|
|
1989
|
+
if (active === undefined) {
|
|
1990
|
+
return addCorsHeaders(new Response("Missing active field", { status: 400 }), req);
|
|
1991
|
+
}
|
|
1992
|
+
getDb().query(`UPDATE providers SET active = ?, enabled = ? WHERE id = ? `).run(active ? 1 : 0, active ? 1 : 0, providerId);
|
|
1993
|
+
return addCorsHeaders(Response.json({ success: true, active }), req);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
const providerIdMatch = url.pathname.match(/^\/api\/providers\/([^/]+)$/);
|
|
1998
|
+
if (providerIdMatch && (req.method === "PUT" || req.method === "PATCH")) {
|
|
1999
|
+
const id = providerIdMatch[1];
|
|
2000
|
+
const body = await req.json().catch(() => ({}));
|
|
2001
|
+
const updates: string[] = [];
|
|
2002
|
+
const params: any[] = [];
|
|
2003
|
+
|
|
2004
|
+
if (body.name) {
|
|
2005
|
+
updates.push("name = ?");
|
|
2006
|
+
params.push(body.name);
|
|
2007
|
+
}
|
|
2008
|
+
const baseUrl = body.base_url !== undefined ? body.base_url : body.baseUrl;
|
|
2009
|
+
if (baseUrl !== undefined) {
|
|
2010
|
+
updates.push("base_url = ?");
|
|
2011
|
+
params.push(baseUrl || null);
|
|
2012
|
+
}
|
|
2013
|
+
if (body.enabled !== undefined) {
|
|
2014
|
+
updates.push("enabled = ?");
|
|
2015
|
+
params.push(body.enabled ? 1 : 0);
|
|
2016
|
+
}
|
|
2017
|
+
if (body.active !== undefined) {
|
|
2018
|
+
updates.push("active = ?");
|
|
2019
|
+
params.push(body.active ? 1 : 0);
|
|
2020
|
+
}
|
|
2021
|
+
if (body.config?.apiKey || body.apiKey) {
|
|
2022
|
+
const apiKey = body.config?.apiKey || body.apiKey;
|
|
2023
|
+
const { encrypted, iv } = encryptApiKey(apiKey);
|
|
2024
|
+
updates.push("api_key_encrypted = ?");
|
|
2025
|
+
params.push(encrypted);
|
|
2026
|
+
updates.push("api_key_iv = ?");
|
|
2027
|
+
params.push(iv);
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
if (body.headers) {
|
|
2031
|
+
const { encrypted, iv } = encryptConfig(body.headers);
|
|
2032
|
+
updates.push("headers_encrypted = ?");
|
|
2033
|
+
params.push(encrypted);
|
|
2034
|
+
updates.push("headers_iv = ?");
|
|
2035
|
+
params.push(iv);
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
const numCtx = body.num_ctx !== undefined ? body.num_ctx : body.numCtx;
|
|
2039
|
+
if (numCtx !== undefined) {
|
|
2040
|
+
updates.push("num_ctx = ?");
|
|
2041
|
+
params.push(numCtx ? Number(numCtx) : null);
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
if (updates.length > 0) {
|
|
2045
|
+
params.push(id);
|
|
2046
|
+
getDb().query(`UPDATE providers SET ${updates.join(", ")} WHERE id = ? `).run(...params);
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
const updated = getDb().query<any, [string]>(`
|
|
2050
|
+
SELECT id, name, base_url, enabled, active, num_ctx,
|
|
2051
|
+
api_key_encrypted, api_key_iv,
|
|
2052
|
+
CASE WHEN api_key_encrypted IS NOT NULL THEN 1 ELSE 0 END as has_api_key,
|
|
2053
|
+
CASE WHEN headers_encrypted IS NOT NULL THEN 1 ELSE 0 END as has_headers
|
|
2054
|
+
FROM providers WHERE id = ?
|
|
2055
|
+
`).get(id);
|
|
2056
|
+
|
|
2057
|
+
let masked_api_key: string | null = null;
|
|
2058
|
+
if (updated?.api_key_encrypted && updated?.api_key_iv) {
|
|
2059
|
+
try {
|
|
2060
|
+
masked_api_key = maskApiKey(decryptApiKey(updated.api_key_encrypted, updated.api_key_iv));
|
|
2061
|
+
} catch { /* ignore */ }
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
const models = getDb().query<any, [string]>(`
|
|
2065
|
+
SELECT id, name, provider_id, enabled, active FROM models WHERE provider_id = ?
|
|
2066
|
+
`).all(id);
|
|
2067
|
+
|
|
2068
|
+
return addCorsHeaders(Response.json({
|
|
2069
|
+
success: true,
|
|
2070
|
+
provider: {
|
|
2071
|
+
id: updated.id,
|
|
2072
|
+
name: updated.name,
|
|
2073
|
+
base_url: updated.base_url,
|
|
2074
|
+
enabled: !!updated.enabled,
|
|
2075
|
+
active: !!updated.active,
|
|
2076
|
+
num_ctx: updated.num_ctx ?? null,
|
|
2077
|
+
has_api_key: !!updated.has_api_key,
|
|
2078
|
+
has_headers: !!updated.has_headers,
|
|
2079
|
+
masked_api_key,
|
|
2080
|
+
models,
|
|
2081
|
+
}
|
|
2082
|
+
}), req);
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
// āā Models API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
2086
|
+
// GET /api/models?provider_id=xxx - Get models filtered by provider
|
|
2087
|
+
if (url.pathname === "/api/models" && req.method === "GET") {
|
|
2088
|
+
const searchParams = new URL(req.url).searchParams;
|
|
2089
|
+
const providerId = searchParams.get("provider_id");
|
|
2090
|
+
let models;
|
|
2091
|
+
if (providerId) {
|
|
2092
|
+
models = getDb().query(`
|
|
2093
|
+
SELECT id, name, provider_id, context_window, capabilities, enabled, active
|
|
2094
|
+
FROM models
|
|
2095
|
+
WHERE provider_id = ? AND(enabled = 1 OR active = 1)
|
|
2096
|
+
`).all(providerId);
|
|
2097
|
+
} else {
|
|
2098
|
+
models = getDb().query(`
|
|
2099
|
+
SELECT id, name, provider_id, context_window, capabilities, enabled, active
|
|
2100
|
+
FROM models
|
|
2101
|
+
`).all();
|
|
2102
|
+
}
|
|
2103
|
+
return addCorsHeaders(Response.json({ models }), req);
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
// POST /api/providers/:id/sync-models ā sincroniza modelos desde la API local del provider
|
|
2107
|
+
const syncModelsMatch = url.pathname.match(/^\/api\/providers\/([^/]+)\/sync-models$/)
|
|
2108
|
+
if (syncModelsMatch && req.method === "POST") {
|
|
2109
|
+
const providerId = syncModelsMatch[1]
|
|
2110
|
+
const providerRow = getDb().query<any, [string]>(
|
|
2111
|
+
"SELECT * FROM providers WHERE id = ?"
|
|
2112
|
+
).get(providerId)
|
|
2113
|
+
|
|
2114
|
+
if (!providerRow) {
|
|
2115
|
+
return addCorsHeaders(new Response("Provider not found", { status: 404 }), req)
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
const baseUrl = (providerRow.base_url || "http://localhost:11434").replace(/\/(v1|api)\/?$/, "")
|
|
2119
|
+
|
|
2120
|
+
try {
|
|
2121
|
+
const res = await fetch(`${baseUrl}/api/tags`)
|
|
2122
|
+
if (!res.ok) {
|
|
2123
|
+
return addCorsHeaders(Response.json({ error: `Ollama responded ${res.status}` }, { status: 502 }), req)
|
|
2124
|
+
}
|
|
2125
|
+
const data = await res.json() as { models: Array<{ name: string; size: number; details?: { parameter_size?: string } }> }
|
|
2126
|
+
const ollamaModels = data.models || []
|
|
2127
|
+
|
|
2128
|
+
// Upsert each model ā id = "providerId/modelName", name = modelName (what Ollama accepts)
|
|
2129
|
+
const upsert = getDb().query(`
|
|
2130
|
+
INSERT INTO models (id, provider_id, name, model_type, enabled, active)
|
|
2131
|
+
VALUES (?, ?, ?, 'llm', 1, 1)
|
|
2132
|
+
ON CONFLICT(id) DO UPDATE SET name = excluded.name, enabled = 1
|
|
2133
|
+
`)
|
|
2134
|
+
for (const m of ollamaModels) {
|
|
2135
|
+
upsert.run(`${providerId}/${m.name}`, providerId, m.name)
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// Desactiva modelos que ya no estƔn instalados
|
|
2139
|
+
const installedIds = ollamaModels.map(m => `${providerId}/${m.name}`)
|
|
2140
|
+
const existingModels = getDb().query<any, [string]>(
|
|
2141
|
+
"SELECT id FROM models WHERE provider_id = ?"
|
|
2142
|
+
).all(providerId) as { id: string }[]
|
|
2143
|
+
|
|
2144
|
+
const disable = getDb().query("UPDATE models SET active = 0, enabled = 0 WHERE id = ?")
|
|
2145
|
+
for (const row of existingModels) {
|
|
2146
|
+
if (!installedIds.includes(row.id)) {
|
|
2147
|
+
disable.run(row.id)
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
const models = getDb().query<any, [string]>(
|
|
2152
|
+
"SELECT id, name, provider_id, enabled, active FROM models WHERE provider_id = ?"
|
|
2153
|
+
).all(providerId)
|
|
2154
|
+
|
|
2155
|
+
return addCorsHeaders(Response.json({ success: true, synced: ollamaModels.length, models }), req)
|
|
2156
|
+
} catch (err: any) {
|
|
2157
|
+
return addCorsHeaders(Response.json({ error: `No se pudo conectar a Ollama: ${err.message}` }, { status: 502 }), req)
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// POST /api/models - Create a new model
|
|
2162
|
+
if (url.pathname === "/api/models" && req.method === "POST") {
|
|
2163
|
+
const body = await req.json().catch(() => ({}));
|
|
2164
|
+
const { provider_id, name, model_type, context_window, capabilities } = body;
|
|
2165
|
+
if (!provider_id || !name) {
|
|
2166
|
+
return addCorsHeaders(new Response("Missing provider_id or name", { status: 400 }), req);
|
|
2167
|
+
}
|
|
2168
|
+
const id = `${provider_id}/${name}`;
|
|
2169
|
+
getDb().query(`
|
|
2170
|
+
INSERT OR IGNORE INTO models (id, provider_id, name, model_type, context_window, capabilities, enabled, active)
|
|
2171
|
+
VALUES (?, ?, ?, ?, ?, ?, 1, 1)
|
|
2172
|
+
`).run(id, provider_id, name, model_type || "llm", context_window || null, capabilities || null);
|
|
2173
|
+
return addCorsHeaders(Response.json({ success: true, id }), req);
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
if (url.pathname.match(/^\/api\/models\/[^/]+\/toggle$/)) {
|
|
2177
|
+
const modelId = url.pathname.split("/")[3];
|
|
2178
|
+
if (req.method === "POST") {
|
|
2179
|
+
const body = await req.json().catch(() => ({}));
|
|
2180
|
+
const { active } = body;
|
|
2181
|
+
if (active === undefined) {
|
|
2182
|
+
return addCorsHeaders(new Response("Missing active field", { status: 400 }), req);
|
|
2183
|
+
}
|
|
2184
|
+
getDb().query(`UPDATE models SET active = ?, enabled = ? WHERE id = ? `).run(active ? 1 : 0, active ? 1 : 0, modelId);
|
|
2185
|
+
return addCorsHeaders(Response.json({ success: true, active }), req);
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
// āā Channels API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
2190
|
+
if (url.pathname === "/api/channels" && req.method === "GET") {
|
|
2191
|
+
const channels = getDb().query("SELECT id, type, id as account_id, enabled, active, status FROM channels").all();
|
|
2192
|
+
return addCorsHeaders(Response.json({ channels }), req);
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
// āā MCP Servers API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
2196
|
+
if (url.pathname === "/api/mcp" && req.method === "GET") {
|
|
2197
|
+
const servers = getDb().query("SELECT id, name, transport, status, enabled, active, builtin FROM mcp_servers").all();
|
|
2198
|
+
return addCorsHeaders(Response.json({ servers }), req);
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
// āā Tools API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
2202
|
+
if (url.pathname === "/api/tools" && req.method === "GET") {
|
|
2203
|
+
const tools = getDb().query("SELECT id, name, description, category, enabled, active FROM tools").all();
|
|
2204
|
+
return addCorsHeaders(Response.json({ tools }), req);
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
if (url.pathname.match(/^\/api\/tools\/[^/]+\/toggle$/)) {
|
|
2208
|
+
const toolId = url.pathname.split("/")[3];
|
|
2209
|
+
if (req.method === "POST") {
|
|
2210
|
+
const body = await req.json().catch(() => ({}));
|
|
2211
|
+
const { active } = body;
|
|
2212
|
+
if (active === undefined) {
|
|
2213
|
+
return addCorsHeaders(new Response("Missing active field", { status: 400 }), req);
|
|
2214
|
+
}
|
|
2215
|
+
getDb().query(`UPDATE tools SET active = ?, enabled = ? WHERE id = ? `).run(active ? 1 : 0, active ? 1 : 0, toolId);
|
|
2216
|
+
return addCorsHeaders(Response.json({ success: true, active }), req);
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
if (url.pathname.match(/^\/api\/tools\/[^/]+$/)) {
|
|
2221
|
+
const toolId = url.pathname.split("/")[3];
|
|
2222
|
+
if (req.method === "PUT") {
|
|
2223
|
+
const body = await req.json().catch(() => ({}));
|
|
2224
|
+
const { name, description } = body;
|
|
2225
|
+
const updates: string[] = [];
|
|
2226
|
+
const params: any[] = [];
|
|
2227
|
+
|
|
2228
|
+
if (name !== undefined) {
|
|
2229
|
+
updates.push("name = ?");
|
|
2230
|
+
params.push(name);
|
|
2231
|
+
}
|
|
2232
|
+
if (description !== undefined) {
|
|
2233
|
+
updates.push("description = ?");
|
|
2234
|
+
params.push(description);
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
if (updates.length > 0) {
|
|
2238
|
+
params.push(toolId);
|
|
2239
|
+
getDb().query(`UPDATE tools SET ${updates.join(", ")} WHERE id = ?`).run(...params);
|
|
2240
|
+
}
|
|
2241
|
+
return addCorsHeaders(Response.json({ success: true }), req);
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
// āā Skills API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
2246
|
+
if (url.pathname === "/api/skills" && req.method === "GET") {
|
|
2247
|
+
const skills = getDb().query("SELECT id, name, description, source, enabled, active FROM skills").all();
|
|
2248
|
+
return addCorsHeaders(Response.json({ skills }), req);
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
if (url.pathname === "/api/skills" && req.method === "POST") {
|
|
2252
|
+
const body = await req.json().catch(() => ({}));
|
|
2253
|
+
const { name, description, source, enabled } = body;
|
|
2254
|
+
if (!name) return addCorsHeaders(new Response("Missing name", { status: 400 }), req);
|
|
2255
|
+
|
|
2256
|
+
const id = randomUUID();
|
|
2257
|
+
getDb().query(`
|
|
2258
|
+
INSERT INTO skills(id, name, description, source, enabled, active)
|
|
2259
|
+
VALUES(?, ?, ?, ?, ?, 1)
|
|
2260
|
+
`).run(id, name, description || "", source || "user", enabled !== undefined ? (enabled ? 1 : 0) : 1);
|
|
2261
|
+
|
|
2262
|
+
return addCorsHeaders(Response.json({ success: true, id }), req);
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
if (url.pathname.match(/^\/api\/skills\/[^/]+\/toggle$/)) {
|
|
2266
|
+
const skillId = url.pathname.split("/")[3];
|
|
2267
|
+
if (req.method === "POST") {
|
|
2268
|
+
const body = await req.json().catch(() => ({}));
|
|
2269
|
+
const { active } = body;
|
|
2270
|
+
if (active === undefined) {
|
|
2271
|
+
return addCorsHeaders(new Response("Missing active field", { status: 400 }), req);
|
|
2272
|
+
}
|
|
2273
|
+
getDb().query(`UPDATE skills SET active = ?, enabled = ? WHERE id = ? `).run(active ? 1 : 0, active ? 1 : 0, skillId);
|
|
2274
|
+
await loadContextToStore(getDb());
|
|
2275
|
+
return addCorsHeaders(Response.json({ success: true, active }), req);
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
if (url.pathname.match(/^\/api\/skills\/[^/]+$/)) {
|
|
2280
|
+
const skillId = url.pathname.split("/")[3];
|
|
2281
|
+
if (req.method === "PUT") {
|
|
2282
|
+
const body = await req.json().catch(() => ({}));
|
|
2283
|
+
const { name, description } = body;
|
|
2284
|
+
const updates: string[] = [];
|
|
2285
|
+
const params: any[] = [];
|
|
2286
|
+
|
|
2287
|
+
if (name !== undefined) {
|
|
2288
|
+
updates.push("name = ?");
|
|
2289
|
+
params.push(name);
|
|
2290
|
+
}
|
|
2291
|
+
if (description !== undefined) {
|
|
2292
|
+
updates.push("description = ?");
|
|
2293
|
+
params.push(description);
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
if (updates.length > 0) {
|
|
2297
|
+
params.push(skillId);
|
|
2298
|
+
getDb().query(`UPDATE skills SET ${updates.join(", ")} WHERE id = ?`).run(...params);
|
|
2299
|
+
await loadContextToStore(getDb());
|
|
2300
|
+
}
|
|
2301
|
+
return addCorsHeaders(Response.json({ success: true }), req);
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
if (req.method === "DELETE") {
|
|
2305
|
+
getDb().query(`DELETE FROM skills WHERE id = ? AND source != 'bundled'`).run(skillId);
|
|
2306
|
+
await loadContextToStore(getDb());
|
|
2307
|
+
return addCorsHeaders(Response.json({ success: true }), req);
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
// āā Ethics API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
2312
|
+
if (url.pathname === "/api/ethics" && req.method === "GET") {
|
|
2313
|
+
const ethics = getDb().query("SELECT * FROM ethics").all();
|
|
2314
|
+
return addCorsHeaders(Response.json({ ethics }), req);
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
if (url.pathname === "/api/ethics" && req.method === "POST") {
|
|
2318
|
+
const body = await req.json().catch(() => ({}));
|
|
2319
|
+
const { name, description, content, is_default } = body;
|
|
2320
|
+
if (!name || !content) return addCorsHeaders(Response.json({ success: false, error: "Missing name or content", message: "Faltan campos requeridos: nombre y contenido" }, { status: 400 }), req);
|
|
2321
|
+
|
|
2322
|
+
const id = randomUUID();
|
|
2323
|
+
getDb().query(`
|
|
2324
|
+
INSERT INTO ethics(id, name, description, content, is_default, enabled, active)
|
|
2325
|
+
VALUES(?, ?, ?, ?, ?, 1, 1)
|
|
2326
|
+
`).run(id, name, description || "", content, is_default ? 1 : 0);
|
|
2327
|
+
|
|
2328
|
+
return addCorsHeaders(Response.json({ success: true, id, message: `Registro de Ʃtica "${name}" creado correctamente` }), req);
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
const ethicsIdMatch = url.pathname.match(/^\/api\/ethics\/([^/]+)$/);
|
|
2332
|
+
if (ethicsIdMatch) {
|
|
2333
|
+
const id = ethicsIdMatch[1];
|
|
2334
|
+
if (req.method === "PUT") {
|
|
2335
|
+
const body = await req.json().catch(() => ({}));
|
|
2336
|
+
const updates: string[] = [];
|
|
2337
|
+
const params: any[] = [];
|
|
2338
|
+
for (const [key, val] of Object.entries(body)) {
|
|
2339
|
+
if (["name", "description", "content", "is_default", "enabled", "active"].includes(key)) {
|
|
2340
|
+
updates.push(`${key} = ?`);
|
|
2341
|
+
params.push(val);
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
if (updates.length > 0) {
|
|
2345
|
+
params.push(id);
|
|
2346
|
+
getDb().query(`UPDATE ethics SET ${updates.join(", ")} WHERE id = ? `).run(...params);
|
|
2347
|
+
}
|
|
2348
|
+
return addCorsHeaders(Response.json({ success: true, message: "Registro de Ʃtica actualizado correctamente" }), req);
|
|
2349
|
+
}
|
|
2350
|
+
if (req.method === "DELETE") {
|
|
2351
|
+
getDb().query("DELETE FROM ethics WHERE id = ?").run(id);
|
|
2352
|
+
return addCorsHeaders(Response.json({ success: true, message: "Registro de Ʃtica eliminado" }), req);
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
// āā Users API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
2357
|
+
if (url.pathname === "/api/users" && req.method === "GET") {
|
|
2358
|
+
const users = getDb().query("SELECT * FROM users").all();
|
|
2359
|
+
return addCorsHeaders(Response.json({ users }), req);
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
if (url.pathname === "/api/users" && req.method === "POST") {
|
|
2363
|
+
const body = await req.json().catch(() => ({}));
|
|
2364
|
+
const { id, name, language, timezone, occupation, notes, preferred_cron_channel } = body;
|
|
2365
|
+
|
|
2366
|
+
let userId: string;
|
|
2367
|
+
let isUpdate: boolean;
|
|
2368
|
+
|
|
2369
|
+
if (id) {
|
|
2370
|
+
userId = id;
|
|
2371
|
+
isUpdate = true;
|
|
2372
|
+
getDb().query(`
|
|
2373
|
+
INSERT INTO users(id, name, language, timezone, occupation, notes, preferred_cron_channel)
|
|
2374
|
+
VALUES(?, ?, ?, ?, ?, ?, ?)
|
|
2375
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
2376
|
+
name = excluded.name,
|
|
2377
|
+
language = excluded.language,
|
|
2378
|
+
timezone = excluded.timezone,
|
|
2379
|
+
occupation = excluded.occupation,
|
|
2380
|
+
notes = excluded.notes,
|
|
2381
|
+
preferred_cron_channel = excluded.preferred_cron_channel
|
|
2382
|
+
`).run(id, name, language, timezone, occupation, notes, preferred_cron_channel ?? "auto");
|
|
2383
|
+
} else {
|
|
2384
|
+
isUpdate = false;
|
|
2385
|
+
const result = getDb().query(`
|
|
2386
|
+
INSERT INTO users(name, language, timezone, occupation, notes, preferred_cron_channel)
|
|
2387
|
+
VALUES(?, ?, ?, ?, ?, ?)
|
|
2388
|
+
RETURNING id
|
|
2389
|
+
`).get(name, language, timezone, occupation, notes, preferred_cron_channel ?? "auto") as { id: string } | undefined;
|
|
2390
|
+
userId = result?.id || "";
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
return addCorsHeaders(Response.json({
|
|
2394
|
+
success: true,
|
|
2395
|
+
id: userId,
|
|
2396
|
+
message: isUpdate ? "Perfil de usuario actualizado correctamente" : "Usuario creado correctamente",
|
|
2397
|
+
}), req);
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
// āā User settings (partial update) āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
2401
|
+
if (url.pathname === "/api/user/settings" && req.method === "PATCH") {
|
|
2402
|
+
const body = await req.json().catch(() => ({}));
|
|
2403
|
+
const { preferred_cron_channel } = body;
|
|
2404
|
+
|
|
2405
|
+
const user = getDb().query("SELECT id FROM users LIMIT 1").get() as { id: string } | undefined;
|
|
2406
|
+
if (!user) return addCorsHeaders(Response.json({ success: false, error: "No user found", message: "No se encontró ningún usuario" }, { status: 404 }), req);
|
|
2407
|
+
|
|
2408
|
+
const sets: string[] = [];
|
|
2409
|
+
const vals: any[] = [];
|
|
2410
|
+
|
|
2411
|
+
if (preferred_cron_channel !== undefined) {
|
|
2412
|
+
sets.push("preferred_cron_channel = ?");
|
|
2413
|
+
vals.push(preferred_cron_channel);
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
if (sets.length > 0) {
|
|
2417
|
+
vals.push(user.id);
|
|
2418
|
+
getDb().query(`UPDATE users SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
return addCorsHeaders(Response.json({ success: true, message: "Configuración de usuario guardada" }), req);
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
// āā MCP Servers API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
2425
|
+
if (url.pathname === "/api/mcp" && req.method === "GET") {
|
|
2426
|
+
const servers = getDb().query("SELECT id, name, transport, status, enabled, active, builtin, command, args, url FROM mcp_servers").all();
|
|
2427
|
+
return addCorsHeaders(Response.json({ servers }), req);
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
if (url.pathname.match(/^\/api\/mcp\/[^/]+\/toggle$/)) {
|
|
2431
|
+
const mcpId = url.pathname.split("/")[3];
|
|
2432
|
+
if (req.method === "POST") {
|
|
2433
|
+
const body = await req.json().catch(() => ({}));
|
|
2434
|
+
const { active } = body;
|
|
2435
|
+
if (active === undefined) {
|
|
2436
|
+
return addCorsHeaders(Response.json({ success: false, error: "Missing active field", message: "Falta el campo 'active'" }, { status: 400 }), req);
|
|
2437
|
+
}
|
|
2438
|
+
getDb().query(`UPDATE mcp_servers SET active = ?, enabled = ? WHERE id = ? `).run(active ? 1 : 0, active ? 1 : 0, mcpId);
|
|
2439
|
+
return addCorsHeaders(Response.json({ success: true, active, message: active ? "Servidor MCP activado" : "Servidor MCP desactivado" }), req);
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
// āā Channels API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
2444
|
+
if (url.pathname === "/api/channels" && req.method === "GET") {
|
|
2445
|
+
const channels = getDb().query("SELECT id, type, id as account_id, enabled, active, status FROM channels").all();
|
|
2446
|
+
return addCorsHeaders(Response.json({ channels }), req);
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
// PUT /api/channels/:id - Update channel settings
|
|
2450
|
+
const channelIdMatch = url.pathname.match(/^\/api\/channels\/([^/]+)$/);
|
|
2451
|
+
if (channelIdMatch && req.method === "PUT") {
|
|
2452
|
+
const channelId = channelIdMatch[1];
|
|
2453
|
+
const body = await req.json().catch(() => ({})) as Record<string, unknown>;
|
|
2454
|
+
const allowed = ["voice_enabled", "tts_enabled", "stt_provider", "tts_provider", "tts_voice_id", "step_delivery_mode"] as const;
|
|
2455
|
+
const updates: string[] = [];
|
|
2456
|
+
const params: unknown[] = [];
|
|
2457
|
+
for (const key of allowed) {
|
|
2458
|
+
if (key in body) {
|
|
2459
|
+
updates.push(`${key} = ?`);
|
|
2460
|
+
params.push(typeof body[key] === "boolean" ? (body[key] ? 1 : 0) : body[key]);
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
if (updates.length === 0) {
|
|
2464
|
+
return addCorsHeaders(Response.json({ error: "No valid fields to update" }, { status: 400 }), req);
|
|
2465
|
+
}
|
|
2466
|
+
params.push(channelId);
|
|
2467
|
+
(getDb().query(`UPDATE channels SET ${updates.join(", ")} WHERE id = ?`) as any).run(...params);
|
|
2468
|
+
return addCorsHeaders(Response.json({ success: true }), req);
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
if (url.pathname.match(/^\/api\/channels\/[^/]+\/toggle$/)) {
|
|
2472
|
+
const channelId = url.pathname.split("/")[3];
|
|
2473
|
+
if (req.method === "POST") {
|
|
2474
|
+
const body = await req.json().catch(() => ({}));
|
|
2475
|
+
const { active } = body;
|
|
2476
|
+
if (active === undefined) {
|
|
2477
|
+
return addCorsHeaders(Response.json({ success: false, error: "Missing active field", message: "Falta el campo 'active'" }, { status: 400 }), req);
|
|
2478
|
+
}
|
|
2479
|
+
getDb().query(`UPDATE channels SET active = ?, enabled = ? WHERE id = ? `).run(active ? 1 : 0, active ? 1 : 0, channelId);
|
|
2480
|
+
return addCorsHeaders(Response.json({ success: true, active, message: active ? `Canal "${channelId}" activado` : `Canal "${channelId}" desactivado` }), req);
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
// āā Voice API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
2485
|
+
|
|
2486
|
+
// GET /api/voice/providers - List STT and TTS providers
|
|
2487
|
+
if (url.pathname === "/api/voice/providers" && req.method === "GET") {
|
|
2488
|
+
const providers = getDb().query(`
|
|
2489
|
+
SELECT m.id, m.name, m.model_type, p.name as provider_name, p.id as provider_id,
|
|
2490
|
+
CASE WHEN p.api_key_encrypted IS NOT NULL THEN 1 ELSE 0 END as has_api_key
|
|
2491
|
+
FROM models m
|
|
2492
|
+
JOIN providers p ON m.provider_id = p.id
|
|
2493
|
+
WHERE m.model_type IN('stt', 'tts')
|
|
2494
|
+
`).all();
|
|
2495
|
+
return addCorsHeaders(Response.json({ providers }), req);
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
// GET /api/voice/elevenlabs/voices - List ElevenLabs voices
|
|
2499
|
+
if (url.pathname === "/api/voice/elevenlabs/voices" && req.method === "GET") {
|
|
2500
|
+
try {
|
|
2501
|
+
const voices = await voiceService.getElevenLabsVoices();
|
|
2502
|
+
return addCorsHeaders(Response.json({ voices }), req);
|
|
2503
|
+
} catch (error) {
|
|
2504
|
+
return addCorsHeaders(Response.json({ error: (error as Error).message }), req);
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
// GET /api/voice/configured-providers - Which voice providers have API keys
|
|
2509
|
+
if (url.pathname === "/api/voice/configured-providers" && req.method === "GET") {
|
|
2510
|
+
const providers = voiceService.getConfiguredVoiceProviders();
|
|
2511
|
+
return addCorsHeaders(Response.json(providers), req);
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
// GET /api/voice/openai/voices - List OpenAI TTS voices
|
|
2515
|
+
if (url.pathname === "/api/voice/openai/voices" && req.method === "GET") {
|
|
2516
|
+
const voices = voiceService.getOpenAIVoices();
|
|
2517
|
+
return addCorsHeaders(Response.json({ voices }), req);
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
// GET /api/voice/gemini/voices - List Gemini TTS voices
|
|
2521
|
+
if (url.pathname === "/api/voice/gemini/voices" && req.method === "GET") {
|
|
2522
|
+
const voices = voiceService.getGeminiVoices();
|
|
2523
|
+
return addCorsHeaders(Response.json({ voices }), req);
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
// GET /api/voice/qwen/voices - List Qwen TTS voices
|
|
2527
|
+
if (url.pathname === "/api/voice/qwen/voices" && req.method === "GET") {
|
|
2528
|
+
const voices = voiceService.getQwenVoices();
|
|
2529
|
+
return addCorsHeaders(Response.json({ voices }), req);
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
// GET /api/channels/:id/voice - Get voice config for a channel
|
|
2533
|
+
const channelVoiceMatch = url.pathname.match(/^\/api\/channels\/([^/]+)\/voice$/);
|
|
2534
|
+
if (channelVoiceMatch && req.method === "GET") {
|
|
2535
|
+
const channelId = channelVoiceMatch[1];
|
|
2536
|
+
const voiceConfig = voiceService.getChannelVoiceConfig(channelId);
|
|
2537
|
+
return addCorsHeaders(Response.json(voiceConfig), req);
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
// PATCH /api/channels/:id/voice - Update voice config for a channel
|
|
2541
|
+
if (channelVoiceMatch && req.method === "PATCH") {
|
|
2542
|
+
const channelId = channelVoiceMatch[1];
|
|
2543
|
+
const body = await req.json().catch(() => ({}));
|
|
2544
|
+
|
|
2545
|
+
const updates: string[] = [];
|
|
2546
|
+
const params: any[] = [];
|
|
2547
|
+
|
|
2548
|
+
if (body.voiceEnabled !== undefined) {
|
|
2549
|
+
updates.push("voice_enabled = ?");
|
|
2550
|
+
params.push(body.voiceEnabled ? 1 : 0);
|
|
2551
|
+
}
|
|
2552
|
+
if (body.ttsEnabled !== undefined) {
|
|
2553
|
+
updates.push("tts_enabled = ?");
|
|
2554
|
+
params.push(body.ttsEnabled ? 1 : 0);
|
|
2555
|
+
}
|
|
2556
|
+
if (body.sttProvider !== undefined) {
|
|
2557
|
+
updates.push("stt_provider = ?");
|
|
2558
|
+
params.push(body.sttProvider);
|
|
2559
|
+
}
|
|
2560
|
+
if (body.ttsProvider !== undefined) {
|
|
2561
|
+
updates.push("tts_provider = ?");
|
|
2562
|
+
params.push(body.ttsProvider);
|
|
2563
|
+
}
|
|
2564
|
+
if (body.ttsVoiceId !== undefined) {
|
|
2565
|
+
updates.push("tts_voice_id = ?");
|
|
2566
|
+
params.push(body.ttsVoiceId);
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
if (updates.length > 0) {
|
|
2570
|
+
params.push(channelId);
|
|
2571
|
+
getDb().query(`UPDATE channels SET ${updates.join(", ")} WHERE id = ? `).run(...params);
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
return addCorsHeaders(Response.json({ success: true }), req);
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
// POST /api/voice/test - Test TTS with a channel's config
|
|
2578
|
+
if (url.pathname === "/api/voice/test" && req.method === "POST") {
|
|
2579
|
+
const body = await req.json().catch(() => ({}));
|
|
2580
|
+
const { channelId, text } = body;
|
|
2581
|
+
|
|
2582
|
+
if (!channelId || !text) {
|
|
2583
|
+
return addCorsHeaders(new Response("Missing channelId or text", { status: 400 }), req);
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
try {
|
|
2587
|
+
const voiceConfig = voiceService.getChannelVoiceConfig(channelId);
|
|
2588
|
+
|
|
2589
|
+
if (!voiceConfig.ttsEnabled || !voiceConfig.ttsProvider) {
|
|
2590
|
+
return addCorsHeaders(Response.json({ error: "TTS not enabled for this channel" }), req);
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
const audioOutput = await voiceService.speak(text, voiceConfig.ttsProvider, voiceConfig.ttsVoiceId || undefined);
|
|
2594
|
+
|
|
2595
|
+
let base64Audio: string;
|
|
2596
|
+
if (audioOutput.type === "buffer") {
|
|
2597
|
+
base64Audio = (audioOutput.data as Buffer).toString("base64");
|
|
2598
|
+
} else {
|
|
2599
|
+
base64Audio = audioOutput.data as string;
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
return addCorsHeaders(Response.json({
|
|
2603
|
+
audio: base64Audio,
|
|
2604
|
+
mimeType: audioOutput.mimeType,
|
|
2605
|
+
}), req);
|
|
2606
|
+
} catch (error) {
|
|
2607
|
+
return addCorsHeaders(Response.json({ error: (error as Error).message }), req);
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
// āā Chat API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
2612
|
+
if (url.pathname === "/api/chat/history" && req.method === "GET") {
|
|
2613
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
2614
|
+
if (!sessionId) {
|
|
2615
|
+
return addCorsHeaders(new Response("Missing sessionId", { status: 400 }), req);
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
try {
|
|
2619
|
+
const messages = dbService.getMessages(sessionId);
|
|
2620
|
+
return addCorsHeaders(Response.json({ messages }), req);
|
|
2621
|
+
} catch (error) {
|
|
2622
|
+
log.error(`[API] Failed to get chat history: ${(error as Error).message}`);
|
|
2623
|
+
return addCorsHeaders(Response.json({ error: "Failed to get chat history" }, { status: 500 }), req);
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
// āā Canvas API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
2627
|
+
if (url.pathname === "/api/canvas" && req.method === "GET") {
|
|
2628
|
+
return addCorsHeaders(Response.json(getCanvasSnapshot()), req);
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
// āā Notes API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
2632
|
+
if (url.pathname === "/api/notes" && req.method === "GET") {
|
|
2633
|
+
const notes = getDb().query("SELECT * FROM notes ORDER BY createdAt DESC").all();
|
|
2634
|
+
return addCorsHeaders(Response.json({ notes }), req);
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
if (url.pathname.match(/^\/api\/notes\/[^/]+\/toggle$/)) {
|
|
2638
|
+
const noteId = url.pathname.split("/")[3];
|
|
2639
|
+
if (req.method === "PATCH") {
|
|
2640
|
+
const body = await req.json().catch(() => ({}));
|
|
2641
|
+
const { active } = body;
|
|
2642
|
+
if (active === undefined) {
|
|
2643
|
+
return addCorsHeaders(Response.json({ success: false, error: "Missing active field", message: "Falta el campo 'active'" }, { status: 400 }), req);
|
|
2644
|
+
}
|
|
2645
|
+
getDb().query(`UPDATE notes SET active = ?, updatedAt = unixepoch() WHERE id = ? `).run(active ? 1 : 0, noteId);
|
|
2646
|
+
return addCorsHeaders(Response.json({ success: true, active: !!active, message: active ? "Nota activada" : "Nota desactivada" }), req);
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
// āā Cron Jobs API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
2651
|
+
if (url.pathname === "/api/cron-jobs" && req.method === "GET") {
|
|
2652
|
+
const jobs = getDb().query("SELECT * FROM cron_jobs ORDER BY created_at DESC").all();
|
|
2653
|
+
return addCorsHeaders(Response.json({ jobs }), req);
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
// Returns active channels with recommended flag for cron channel picker
|
|
2657
|
+
if (url.pathname === "/api/cron-jobs/channels" && req.method === "GET") {
|
|
2658
|
+
const db = getDb();
|
|
2659
|
+
const channels = db.query<any, []>(
|
|
2660
|
+
"SELECT id, type, active, enabled FROM channels ORDER BY id"
|
|
2661
|
+
).all();
|
|
2662
|
+
|
|
2663
|
+
// Priority order for auto-selection
|
|
2664
|
+
const priority: Record<string, number> = { telegram: 0, discord: 1, webchat: 2 };
|
|
2665
|
+
const sorted = [...channels].sort(
|
|
2666
|
+
(a, b) => (priority[a.id] ?? 99) - (priority[b.id] ?? 99)
|
|
2667
|
+
);
|
|
2668
|
+
|
|
2669
|
+
// Recommended = first active channel in priority order
|
|
2670
|
+
const recommended = sorted.find(c => c.active === 1)?.id ?? "webchat";
|
|
2671
|
+
|
|
2672
|
+
const user = db.query("SELECT preferred_cron_channel FROM users LIMIT 1").get() as
|
|
2673
|
+
| { preferred_cron_channel: string }
|
|
2674
|
+
| undefined;
|
|
2675
|
+
|
|
2676
|
+
const result = channels.map((ch: any) => ({
|
|
2677
|
+
id: ch.id,
|
|
2678
|
+
type: ch.type,
|
|
2679
|
+
active: ch.active === 1,
|
|
2680
|
+
recommended: ch.id === recommended,
|
|
2681
|
+
}));
|
|
2682
|
+
|
|
2683
|
+
return addCorsHeaders(
|
|
2684
|
+
Response.json({ channels: result, recommended, userPreference: user?.preferred_cron_channel ?? "auto" }),
|
|
2685
|
+
req
|
|
2686
|
+
);
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
if (url.pathname.match(/^\/api\/cron-jobs\/[^/]+\/toggle$/)) {
|
|
2690
|
+
const jobId = url.pathname.split("/")[3];
|
|
2691
|
+
if (req.method === "PATCH") {
|
|
2692
|
+
const body = await req.json().catch(() => ({}));
|
|
2693
|
+
const { enabled } = body;
|
|
2694
|
+
if (enabled === undefined) {
|
|
2695
|
+
return addCorsHeaders(Response.json({ success: false, error: "Missing enabled field", message: "Falta el campo 'enabled'" }, { status: 400 }), req);
|
|
2696
|
+
}
|
|
2697
|
+
getDb().query(`UPDATE cron_jobs SET enabled = ?, updated_at = unixepoch() WHERE id = ? `).run(enabled ? 1 : 0, jobId);
|
|
2698
|
+
return addCorsHeaders(Response.json({ success: true, enabled: !!enabled, message: enabled ? "Tarea programada activada" : "Tarea programada desactivada" }), req);
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
return addCorsHeaders(new Response("Not Found", { status: 404 }), req);
|
|
2703
|
+
};
|
|
2704
|
+
|
|
2705
|
+
try {
|
|
2706
|
+
const response = await handleRequest();
|
|
2707
|
+
const duration = Date.now() - start;
|
|
2708
|
+
if (response) {
|
|
2709
|
+
logRequest(response.status, duration);
|
|
2710
|
+
} else {
|
|
2711
|
+
// Bun upgrade returns undefined on success
|
|
2712
|
+
log.info(`${method} ${url.pathname} - 101 Switching Protocols(${duration}ms)`);
|
|
2713
|
+
}
|
|
2714
|
+
return response;
|
|
2715
|
+
} catch (error) {
|
|
2716
|
+
const duration = Date.now() - start;
|
|
2717
|
+
log.error(`${method} ${url.pathname} - Internal Error(${duration}ms): ${(error as Error).message} `);
|
|
2718
|
+
return addCorsHeaders(Response.json({ success: false, error: (error as Error).message, message: "Error interno del servidor" }, { status: 500 }), req);
|
|
2719
|
+
}
|
|
2720
|
+
},
|
|
2721
|
+
|
|
2722
|
+
websocket: {
|
|
2723
|
+
open(ws) {
|
|
2724
|
+
const data = ws.data;
|
|
2725
|
+
const isCanvas = data.sessionId.startsWith("canvas:");
|
|
2726
|
+
const isBridge = data.sessionId.startsWith("bridge:");
|
|
2727
|
+
|
|
2728
|
+
if (isBridge) {
|
|
2729
|
+
log.info(`Bridge events client connected: ${data.sessionId}`);
|
|
2730
|
+
subscribeBridge(ws as any);
|
|
2731
|
+
ws.send(JSON.stringify({ type: "bridge:connected", sessionId: data.sessionId }));
|
|
2732
|
+
return;
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
if (isCanvas) {
|
|
2736
|
+
log.info(`Canvas session connected: ${data.sessionId} `);
|
|
2737
|
+
canvasManager.registerSession(data.sessionId, ws as any);
|
|
2738
|
+
subscribeCanvas(ws as any);
|
|
2739
|
+
ws.send(JSON.stringify({ type: "canvas:connected", sessionId: data.sessionId }));
|
|
2740
|
+
// Send initial snapshot so canvas shows current state
|
|
2741
|
+
ws.send(JSON.stringify({ type: "canvas:snapshot", data: getCanvasSnapshot() }));
|
|
2742
|
+
return;
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
log.debug(`WebSocket connected: ${data.sessionId} `);
|
|
2746
|
+
|
|
2747
|
+
sessionManager.create(data.sessionId, ws);
|
|
2748
|
+
|
|
2749
|
+
const channel = channelManager.getChannel("webchat") as any;
|
|
2750
|
+
if (channel?.registerConnection) channel.registerConnection(ws);
|
|
2751
|
+
|
|
2752
|
+
// Send status message
|
|
2753
|
+
ws.send(JSON.stringify({
|
|
2754
|
+
type: "status",
|
|
2755
|
+
sessionId: data.sessionId,
|
|
2756
|
+
status: { state: "connected", model: `${dbProvider}/${dbModel}` },
|
|
2757
|
+
} as OutboundMessage));
|
|
2758
|
+
|
|
2759
|
+
// Send welcome message with real user data
|
|
2760
|
+
try {
|
|
2761
|
+
const db = getDb();
|
|
2762
|
+
const user = db.query("SELECT id, name, language FROM users LIMIT 1").get() as { id: string; name: string; language: string } | undefined;
|
|
2763
|
+
const agent = db.query("SELECT id, name, provider_id, model_id FROM agents WHERE is_coordinator = 1 LIMIT 1").get() as { id: string; name: string; provider_id: string; model_id: string } | undefined;
|
|
2764
|
+
|
|
2765
|
+
// Get channels
|
|
2766
|
+
const channels = db.query("SELECT id FROM channels WHERE active = 1").all() as Array<{ id: string }>;
|
|
2767
|
+
|
|
2768
|
+
// Get voice config from webchat channel
|
|
2769
|
+
const voiceConfig = db.query("SELECT voice_enabled, stt_provider, tts_provider FROM channels WHERE id = 'webchat'").get() as { voice_enabled: number; stt_provider: string; tts_provider: string } | undefined;
|
|
2770
|
+
|
|
2771
|
+
// Get code bridge
|
|
2772
|
+
const codeBridge = db.query("SELECT id FROM code_bridge WHERE enabled = 1").all() as Array<{ id: string }>;
|
|
2773
|
+
|
|
2774
|
+
ws.send(JSON.stringify({
|
|
2775
|
+
type: "welcome",
|
|
2776
|
+
sessionId: user?.id || data.sessionId,
|
|
2777
|
+
user: user ? { id: user.id, name: user.name, language: user.language } : null,
|
|
2778
|
+
agent: agent ? { id: agent.id, name: agent.name, provider: agent.provider_id, model: agent.model_id } : null,
|
|
2779
|
+
channels: channels.map(c => c.id),
|
|
2780
|
+
voice: voiceConfig ? {
|
|
2781
|
+
enabled: voiceConfig.voice_enabled === 1,
|
|
2782
|
+
sttProvider: voiceConfig.stt_provider,
|
|
2783
|
+
ttsProvider: voiceConfig.tts_provider
|
|
2784
|
+
} : { enabled: false, sttProvider: null, ttsProvider: null },
|
|
2785
|
+
codeBridge: codeBridge.map(cb => cb.id)
|
|
2786
|
+
} as OutboundMessage));
|
|
2787
|
+
} catch (err) {
|
|
2788
|
+
log.error("Error sending welcome message:", err);
|
|
2789
|
+
}
|
|
2790
|
+
},
|
|
2791
|
+
|
|
2792
|
+
async message(ws, message) {
|
|
2793
|
+
const data = ws.data;
|
|
2794
|
+
|
|
2795
|
+
// Bridge events clients are read-only; ignore any messages they send
|
|
2796
|
+
if (data.sessionId.startsWith("bridge:")) return;
|
|
2797
|
+
|
|
2798
|
+
let msg: InboundMessage;
|
|
2799
|
+
try {
|
|
2800
|
+
msg = JSON.parse(message.toString()) as InboundMessage;
|
|
2801
|
+
} catch {
|
|
2802
|
+
ws.send(JSON.stringify({
|
|
2803
|
+
type: "error",
|
|
2804
|
+
sessionId: data.sessionId,
|
|
2805
|
+
error: "Invalid JSON message",
|
|
2806
|
+
} as OutboundMessage));
|
|
2807
|
+
return;
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
msg.sessionId = msg.sessionId ?? data.sessionId;
|
|
2811
|
+
sessionManager.touch(msg.sessionId);
|
|
2812
|
+
|
|
2813
|
+
if (msg.type === "ping") {
|
|
2814
|
+
ws.send(JSON.stringify({ type: "pong", sessionId: msg.sessionId } as OutboundMessage));
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
// Canvas subscribe
|
|
2819
|
+
if (msg.type === "canvas_subscribe") {
|
|
2820
|
+
subscribeCanvas(ws);
|
|
2821
|
+
ws.send(JSON.stringify({
|
|
2822
|
+
type: "canvas:snapshot",
|
|
2823
|
+
data: getCanvasSnapshot(),
|
|
2824
|
+
}));
|
|
2825
|
+
return;
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
// Canvas unsubscribe
|
|
2829
|
+
if (msg.type === "canvas_unsubscribe") {
|
|
2830
|
+
unsubscribeCanvas(ws);
|
|
2831
|
+
return;
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
// Canvas session - handle interactions
|
|
2835
|
+
if (data.sessionId.startsWith("canvas:")) {
|
|
2836
|
+
canvasManager.handleMessage(data.sessionId, message);
|
|
2837
|
+
return;
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
if (msg.type === "command" || (msg.content && isSlashCommand(msg.content))) {
|
|
2841
|
+
const result = await executeSlashCommand(msg.sessionId, msg.content ?? `/${msg.command}`, ws);
|
|
2842
|
+
if (result) {
|
|
2843
|
+
ws.send(JSON.stringify(result));
|
|
2844
|
+
return;
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
// Logs subscription
|
|
2849
|
+
if (msg.type === "logs_subscribe") {
|
|
2850
|
+
logSubscribers.add(data.sessionId);
|
|
2851
|
+
log.debug(`Session ${data.sessionId} subscribed to logs`);
|
|
2852
|
+
return;
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
if (msg.type === "logs_unsubscribe") {
|
|
2856
|
+
logSubscribers.delete(data.sessionId);
|
|
2857
|
+
log.debug(`Session ${data.sessionId} unsubscribed from logs`);
|
|
2858
|
+
return;
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
// Handle audio messages from WebChat
|
|
2862
|
+
let webchatPreferAudio = false;
|
|
2863
|
+
if (msg.type === "audio" && msg.audio) {
|
|
2864
|
+
log.info(`WebChat audio from session ${msg.sessionId}`);
|
|
2865
|
+
|
|
2866
|
+
const voiceConfig = voiceService.getChannelVoiceConfig("webchat");
|
|
2867
|
+
|
|
2868
|
+
if (!voiceConfig.voiceEnabled) {
|
|
2869
|
+
ws.send(JSON.stringify({
|
|
2870
|
+
type: "error",
|
|
2871
|
+
sessionId: msg.sessionId,
|
|
2872
|
+
error: "Voice input not enabled for this channel"
|
|
2873
|
+
} as OutboundMessage));
|
|
2874
|
+
return;
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
if (!voiceConfig.sttProvider) {
|
|
2878
|
+
ws.send(JSON.stringify({
|
|
2879
|
+
type: "message",
|
|
2880
|
+
sessionId: msg.sessionId,
|
|
2881
|
+
content: "šļø Para usar notas de voz, configura el proveedor STT en Configuración > Canales > WebChat (ej: groq-whisper)"
|
|
2882
|
+
} as OutboundMessage));
|
|
2883
|
+
return;
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
ws.send(JSON.stringify({
|
|
2887
|
+
type: "typing",
|
|
2888
|
+
isTyping: true,
|
|
2889
|
+
sessionId: msg.sessionId,
|
|
2890
|
+
} as OutboundMessage));
|
|
2891
|
+
|
|
2892
|
+
try {
|
|
2893
|
+
const audioInput = { type: "base64" as const, data: msg.audio, mimeType: "audio/webm" };
|
|
2894
|
+
const sttProvider = voiceConfig.sttProvider || "groq-whisper";
|
|
2895
|
+
const messageContent = await voiceService.transcribe(audioInput, sttProvider);
|
|
2896
|
+
|
|
2897
|
+
log.info(`š Transcribed: ${messageContent.substring(0, 100)}...`);
|
|
2898
|
+
|
|
2899
|
+
webchatPreferAudio = true;
|
|
2900
|
+
|
|
2901
|
+
ws.send(JSON.stringify({
|
|
2902
|
+
type: "message",
|
|
2903
|
+
sessionId: msg.sessionId,
|
|
2904
|
+
content: `šļø Transcripción: ${messageContent}`
|
|
2905
|
+
} as OutboundMessage));
|
|
2906
|
+
|
|
2907
|
+
dbService.addMessage(msg.sessionId, "user", messageContent, {
|
|
2908
|
+
input_type: "audio_transcribed",
|
|
2909
|
+
stt_provider: sttProvider,
|
|
2910
|
+
channel: "webchat"
|
|
2911
|
+
});
|
|
2912
|
+
|
|
2913
|
+
ws.send(JSON.stringify({
|
|
2914
|
+
type: "typing",
|
|
2915
|
+
isTyping: false,
|
|
2916
|
+
sessionId: msg.sessionId,
|
|
2917
|
+
} as OutboundMessage));
|
|
2918
|
+
|
|
2919
|
+
laneQueue.enqueue(msg.sessionId, async (_task, signal) => {
|
|
2920
|
+
if (signal.aborted) {
|
|
2921
|
+
ws.send(JSON.stringify({ type: "typing", isTyping: false, sessionId: msg.sessionId } as OutboundMessage));
|
|
2922
|
+
ws.send(JSON.stringify({ type: "error", sessionId: msg.sessionId, error: "Task cancelled" } as OutboundMessage));
|
|
2923
|
+
return;
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
try {
|
|
2927
|
+
const unifiedSessionId = msg.sessionId;
|
|
2928
|
+
const messages = [{ role: "user" as const, content: messageContent }];
|
|
2929
|
+
log.info(`Generating response for session ${unifiedSessionId}...`);
|
|
2930
|
+
|
|
2931
|
+
const { userId } = resolveContext({
|
|
2932
|
+
channel: "webchat",
|
|
2933
|
+
channelUserId: msg.sessionId,
|
|
2934
|
+
});
|
|
2935
|
+
|
|
2936
|
+
const response = await runner.generate({
|
|
2937
|
+
provider: dbProvider as any,
|
|
2938
|
+
messages,
|
|
2939
|
+
maxTokens: 4096,
|
|
2940
|
+
tools: prepareTools(agent, unifiedSessionId),
|
|
2941
|
+
maxSteps: 15,
|
|
2942
|
+
threadId: unifiedSessionId,
|
|
2943
|
+
userId,
|
|
2944
|
+
onStep: async (step) => {
|
|
2945
|
+
if (signal.aborted) return;
|
|
2946
|
+
if (step.type === "tool_result" && step.message) {
|
|
2947
|
+
try {
|
|
2948
|
+
const result = JSON.parse(step.message);
|
|
2949
|
+
if (result._sendToUser || result.status) {
|
|
2950
|
+
const userMessage = result.message || result.status || step.message;
|
|
2951
|
+
ws.send(JSON.stringify({
|
|
2952
|
+
type: "message",
|
|
2953
|
+
sessionId: unifiedSessionId,
|
|
2954
|
+
content: `š ${userMessage}`,
|
|
2955
|
+
isStep: true,
|
|
2956
|
+
} as unknown as OutboundMessage));
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
} catch { }
|
|
2960
|
+
}
|
|
2961
|
+
log.debug(`[TOOL] ${step.type}: ${step.toolName || ""}`);
|
|
2962
|
+
},
|
|
2963
|
+
});
|
|
2964
|
+
|
|
2965
|
+
const content = response.content?.trim() || "...";
|
|
2966
|
+
log.info(`Response sent to session ${unifiedSessionId} (${content.length} chars)`);
|
|
2967
|
+
|
|
2968
|
+
const voiceCfg = voiceService.getChannelVoiceConfig("webchat");
|
|
2969
|
+
const shouldSpeak = webchatPreferAudio;
|
|
2970
|
+
let responseType: "text" | "audio" = "text";
|
|
2971
|
+
let ttsProviderUsed: string | null = null;
|
|
2972
|
+
let ttsMimeType: string | null = null;
|
|
2973
|
+
|
|
2974
|
+
ws.send(JSON.stringify({ type: "typing", isTyping: false, sessionId: unifiedSessionId } as OutboundMessage));
|
|
2975
|
+
if (content && content !== "...") {
|
|
2976
|
+
if (shouldSpeak) {
|
|
2977
|
+
if (!voiceCfg.ttsProvider) {
|
|
2978
|
+
ws.send(JSON.stringify({
|
|
2979
|
+
type: "message",
|
|
2980
|
+
sessionId: unifiedSessionId,
|
|
2981
|
+
content: `${content}\n\nš Para recibir respuestas en audio, configura el proveedor TTS en Configuración > Canales > WebChat (ej: elevenlabs)`,
|
|
2982
|
+
isStep: false,
|
|
2983
|
+
} as OutboundMessage));
|
|
2984
|
+
} else {
|
|
2985
|
+
try {
|
|
2986
|
+
log.info(`š TTS enabled, synthesizing audio for WebChat...`);
|
|
2987
|
+
const audioOutput = await voiceService.speak(content, voiceCfg.ttsProvider, voiceCfg.ttsVoiceId || undefined);
|
|
2988
|
+
ttsProviderUsed = voiceCfg.ttsProvider;
|
|
2989
|
+
ttsMimeType = audioOutput.mimeType;
|
|
2990
|
+
responseType = "audio";
|
|
2991
|
+
const base64Audio = (audioOutput.data as Buffer).toString("base64");
|
|
2992
|
+
ws.send(JSON.stringify({
|
|
2993
|
+
type: "audio",
|
|
2994
|
+
sessionId: unifiedSessionId,
|
|
2995
|
+
audio: base64Audio,
|
|
2996
|
+
content,
|
|
2997
|
+
mimeType: audioOutput.mimeType,
|
|
2998
|
+
} as OutboundMessage));
|
|
2999
|
+
} catch (ttsError) {
|
|
3000
|
+
log.error(`TTS failed: ${(ttsError as Error).message}), sending text instead`);
|
|
3001
|
+
ws.send(JSON.stringify({ type: "message", sessionId: unifiedSessionId, content, isStep: false } as OutboundMessage));
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
} else {
|
|
3005
|
+
ws.send(JSON.stringify({ type: "message", sessionId: unifiedSessionId, content, isStep: false } as OutboundMessage));
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
dbService.addMessage(unifiedSessionId, "assistant", content, {
|
|
3010
|
+
response_type: responseType,
|
|
3011
|
+
tts_provider: ttsProviderUsed,
|
|
3012
|
+
mime_type: ttsMimeType,
|
|
3013
|
+
channel: "webchat"
|
|
3014
|
+
});
|
|
3015
|
+
} catch (error) {
|
|
3016
|
+
ws.send(JSON.stringify({ type: "typing", isTyping: false, sessionId: msg.sessionId } as OutboundMessage));
|
|
3017
|
+
ws.send(JSON.stringify({
|
|
3018
|
+
type: "error",
|
|
3019
|
+
sessionId: msg.sessionId,
|
|
3020
|
+
error: (error as Error).message,
|
|
3021
|
+
} as OutboundMessage));
|
|
3022
|
+
log.error(`Error for session ${msg.sessionId}: ${(error as Error).message}`);
|
|
3023
|
+
}
|
|
3024
|
+
});
|
|
3025
|
+
} catch (error) {
|
|
3026
|
+
ws.send(JSON.stringify({
|
|
3027
|
+
type: "typing",
|
|
3028
|
+
isTyping: false,
|
|
3029
|
+
sessionId: msg.sessionId,
|
|
3030
|
+
} as OutboundMessage));
|
|
3031
|
+
ws.send(JSON.stringify({
|
|
3032
|
+
type: "error",
|
|
3033
|
+
sessionId: msg.sessionId,
|
|
3034
|
+
error: `Transcription failed: ${(error as Error).message}`
|
|
3035
|
+
} as OutboundMessage));
|
|
3036
|
+
}
|
|
3037
|
+
return;
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
if (msg.type === "message" && msg.content) {
|
|
3041
|
+
log.info(`WebChat message from session ${msg.sessionId}: ${msg.content.substring(0, 100)}`);
|
|
3042
|
+
|
|
3043
|
+
dbService.addMessage(msg.sessionId, "user", msg.content, {
|
|
3044
|
+
input_type: "text",
|
|
3045
|
+
channel: "webchat"
|
|
3046
|
+
});
|
|
3047
|
+
|
|
3048
|
+
// FIX 6 ā typing indicator inmediato ANTES de encolar
|
|
3049
|
+
// El usuario ve "escribiendo..." de inmediato, no despuƩs del queue
|
|
3050
|
+
ws.send(JSON.stringify({
|
|
3051
|
+
type: "typing",
|
|
3052
|
+
isTyping: true,
|
|
3053
|
+
sessionId: msg.sessionId,
|
|
3054
|
+
} as OutboundMessage));
|
|
3055
|
+
|
|
3056
|
+
laneQueue.enqueue(msg.sessionId, async (_task, signal) => {
|
|
3057
|
+
if (signal.aborted) {
|
|
3058
|
+
ws.send(JSON.stringify({ type: "typing", isTyping: false, sessionId: msg.sessionId } as OutboundMessage));
|
|
3059
|
+
ws.send(JSON.stringify({ type: "error", sessionId: msg.sessionId, error: "Task cancelled" } as OutboundMessage));
|
|
3060
|
+
return;
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
try {
|
|
3064
|
+
const unifiedSessionId = msg.sessionId;
|
|
3065
|
+
const messages = [{ role: "user" as const, content: msg.content }];
|
|
3066
|
+
log.info(`Generating response for session ${unifiedSessionId}...`);
|
|
3067
|
+
|
|
3068
|
+
const { userId } = resolveContext({
|
|
3069
|
+
channel: "webchat",
|
|
3070
|
+
channelUserId: msg.sessionId,
|
|
3071
|
+
});
|
|
3072
|
+
|
|
3073
|
+
const response = await runner.generate({
|
|
3074
|
+
provider: dbProvider as any,
|
|
3075
|
+
messages,
|
|
3076
|
+
maxTokens: 4096,
|
|
3077
|
+
tools: prepareTools(agent, unifiedSessionId),
|
|
3078
|
+
maxSteps: 15,
|
|
3079
|
+
threadId: unifiedSessionId,
|
|
3080
|
+
userId,
|
|
3081
|
+
onStep: async (step) => {
|
|
3082
|
+
if (signal.aborted) return;
|
|
3083
|
+
|
|
3084
|
+
// Para tool_result, verificar si es un mensaje de progreso
|
|
3085
|
+
if (step.type === "tool_result" && step.message) {
|
|
3086
|
+
try {
|
|
3087
|
+
const result = JSON.parse(step.message);
|
|
3088
|
+
if (result._sendToUser || result.status) {
|
|
3089
|
+
const userMessage = result.message || result.status || step.message;
|
|
3090
|
+
ws.send(JSON.stringify({
|
|
3091
|
+
type: "message",
|
|
3092
|
+
sessionId: unifiedSessionId,
|
|
3093
|
+
content: `š ${userMessage}`,
|
|
3094
|
+
isStep: true,
|
|
3095
|
+
} as unknown as OutboundMessage));
|
|
3096
|
+
return;
|
|
3097
|
+
}
|
|
3098
|
+
} catch {
|
|
3099
|
+
// No es JSON de progreso
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
log.debug(`[TOOL] ${step.type}: ${step.toolName || ""}`);
|
|
3104
|
+
},
|
|
3105
|
+
});
|
|
3106
|
+
|
|
3107
|
+
const content = response.content?.trim() || "...";
|
|
3108
|
+
log.info(`Response sent to session ${unifiedSessionId} (${content.length} chars)`);
|
|
3109
|
+
|
|
3110
|
+
const voiceConfig = voiceService.getChannelVoiceConfig("webchat");
|
|
3111
|
+
const shouldSpeak = webchatPreferAudio;
|
|
3112
|
+
let responseType: "text" | "audio" = "text";
|
|
3113
|
+
let ttsProviderUsed: string | null = null;
|
|
3114
|
+
let ttsMimeType: string | null = null;
|
|
3115
|
+
|
|
3116
|
+
ws.send(JSON.stringify({ type: "typing", isTyping: false, sessionId: unifiedSessionId } as OutboundMessage));
|
|
3117
|
+
if (content && content !== "...") {
|
|
3118
|
+
if (shouldSpeak) {
|
|
3119
|
+
if (!voiceConfig.ttsProvider) {
|
|
3120
|
+
ws.send(JSON.stringify({
|
|
3121
|
+
type: "message",
|
|
3122
|
+
sessionId: unifiedSessionId,
|
|
3123
|
+
content: `${content}\n\nš Para recibir respuestas en audio, configura el proveedor TTS en Configuración > Canales > WebChat (ej: elevenlabs)`,
|
|
3124
|
+
isStep: false
|
|
3125
|
+
} as OutboundMessage));
|
|
3126
|
+
} else {
|
|
3127
|
+
try {
|
|
3128
|
+
log.info(`š TTS enabled, synthesizing audio for WebChat...`);
|
|
3129
|
+
const audioOutput = await voiceService.speak(content, voiceConfig.ttsProvider, voiceConfig.ttsVoiceId || undefined);
|
|
3130
|
+
ttsProviderUsed = voiceConfig.ttsProvider;
|
|
3131
|
+
ttsMimeType = audioOutput.mimeType;
|
|
3132
|
+
responseType = "audio";
|
|
3133
|
+
|
|
3134
|
+
const base64Audio = (audioOutput.data as Buffer).toString("base64");
|
|
3135
|
+
|
|
3136
|
+
ws.send(JSON.stringify({
|
|
3137
|
+
type: "audio",
|
|
3138
|
+
sessionId: unifiedSessionId,
|
|
3139
|
+
audio: base64Audio,
|
|
3140
|
+
content,
|
|
3141
|
+
mimeType: audioOutput.mimeType,
|
|
3142
|
+
} as OutboundMessage));
|
|
3143
|
+
} catch (ttsError) {
|
|
3144
|
+
log.error(`TTS failed: ${(ttsError as Error).message}), sending text instead`);
|
|
3145
|
+
ws.send(JSON.stringify({ type: "message", sessionId: unifiedSessionId, content, isStep: false } as OutboundMessage));
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
} else {
|
|
3149
|
+
ws.send(JSON.stringify({ type: "message", sessionId: unifiedSessionId, content, isStep: false } as OutboundMessage));
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
dbService.addMessage(unifiedSessionId, "assistant", content, {
|
|
3154
|
+
response_type: responseType,
|
|
3155
|
+
tts_provider: ttsProviderUsed,
|
|
3156
|
+
mime_type: ttsMimeType,
|
|
3157
|
+
channel: "webchat"
|
|
3158
|
+
});
|
|
3159
|
+
} catch (error) {
|
|
3160
|
+
const unifiedSessionId = msg.sessionId;
|
|
3161
|
+
// Detener typing aunque falle ā nunca dejar el spinner infinito
|
|
3162
|
+
ws.send(JSON.stringify({ type: "typing", isTyping: false, sessionId: unifiedSessionId } as OutboundMessage));
|
|
3163
|
+
ws.send(JSON.stringify({
|
|
3164
|
+
type: "error",
|
|
3165
|
+
sessionId: unifiedSessionId,
|
|
3166
|
+
error: (error as Error).message,
|
|
3167
|
+
} as OutboundMessage));
|
|
3168
|
+
log.error(`Error for session ${unifiedSessionId}: ${(error as Error).message}`);
|
|
3169
|
+
}
|
|
3170
|
+
});
|
|
3171
|
+
|
|
3172
|
+
return;
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
ws.send(JSON.stringify({
|
|
3176
|
+
type: "error",
|
|
3177
|
+
sessionId: msg.sessionId,
|
|
3178
|
+
error: "Unknown message type",
|
|
3179
|
+
} as OutboundMessage));
|
|
3180
|
+
},
|
|
3181
|
+
|
|
3182
|
+
close(ws) {
|
|
3183
|
+
const data = ws.data;
|
|
3184
|
+
const isCanvas = data.sessionId.startsWith("canvas:");
|
|
3185
|
+
const isBridge = data.sessionId.startsWith("bridge:");
|
|
3186
|
+
|
|
3187
|
+
if (isBridge) {
|
|
3188
|
+
unsubscribeBridge(ws as any);
|
|
3189
|
+
return;
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
if (isCanvas) {
|
|
3193
|
+
canvasManager.unregisterSession(data.sessionId);
|
|
3194
|
+
unsubscribeCanvas(ws as any);
|
|
3195
|
+
return;
|
|
3196
|
+
}
|
|
3197
|
+
|
|
3198
|
+
log.debug(`WebSocket disconnected: ${data.sessionId}`);
|
|
3199
|
+
logSubscribers.delete(data.sessionId);
|
|
3200
|
+
sessionManager.delete(data.sessionId);
|
|
3201
|
+
laneQueue.cancel(data.sessionId);
|
|
3202
|
+
|
|
3203
|
+
const channel = channelManager.getChannel("webchat") as any;
|
|
3204
|
+
if (channel?.unregisterConnection) channel.unregisterConnection(data.sessionId);
|
|
3205
|
+
},
|
|
3206
|
+
},
|
|
3207
|
+
});
|
|
3208
|
+
|
|
3209
|
+
onLogEntry((entry) => {
|
|
3210
|
+
if (logSubscribers.size === 0) return;
|
|
3211
|
+
|
|
3212
|
+
const payload = JSON.stringify({
|
|
3213
|
+
type: "log",
|
|
3214
|
+
sessionId: entry.meta?.sessionId || "system",
|
|
3215
|
+
logEntry: entry,
|
|
3216
|
+
});
|
|
3217
|
+
|
|
3218
|
+
for (const sessionId of logSubscribers) {
|
|
3219
|
+
const session = sessionManager.get(sessionId);
|
|
3220
|
+
if (session?.ws && session.ws.readyState === 1) {
|
|
3221
|
+
try {
|
|
3222
|
+
session.ws.send(payload);
|
|
3223
|
+
} catch {
|
|
3224
|
+
logSubscribers.delete(sessionId);
|
|
3225
|
+
}
|
|
3226
|
+
} else {
|
|
3227
|
+
logSubscribers.delete(sessionId);
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3230
|
+
});
|
|
3231
|
+
|
|
3232
|
+
log.info(`Gateway started successfully`);
|
|
3233
|
+
|
|
3234
|
+
// Check if running as child process in dev mode (parent handles browser open)
|
|
3235
|
+
const isGatewayChild = process.env.HIVE_GATEWAY_CHILD === "1";
|
|
3236
|
+
|
|
3237
|
+
// Print URLs based on mode
|
|
3238
|
+
if (isDev) {
|
|
3239
|
+
// In development: UI is served by Vite on port 5173
|
|
3240
|
+
log.info(`[gateway] API: http://${host}:${port}`);
|
|
3241
|
+
log.info(`[gateway] WebSocket: ws://${host}:${port}/ws`);
|
|
3242
|
+
log.info(`[gateway] Canvas: ws://${host}:${port}/canvas`);
|
|
3243
|
+
log.info(`[gateway] Modo: desarrollo`);
|
|
3244
|
+
if (!isGatewayChild) {
|
|
3245
|
+
log.info(`š Administra tu Hive aquĆ: http://localhost:5173`);
|
|
3246
|
+
}
|
|
3247
|
+
} else {
|
|
3248
|
+
// In production: Gateway serves UI from dist/
|
|
3249
|
+
// Check if this is first-run setup mode
|
|
3250
|
+
const isSetupMode = !existsSync(getDbPathLazy());
|
|
3251
|
+
const baseUrl = `http://${host}:${port}`;
|
|
3252
|
+
const uiUrl = isSetupMode ? `${baseUrl}/setup` : `${baseUrl}/ui`;
|
|
3253
|
+
|
|
3254
|
+
log.info(`[gateway] UI: ${uiUrl}`);
|
|
3255
|
+
log.info(`[gateway] API: http://${host}:${port}`);
|
|
3256
|
+
log.info(`[gateway] WebSocket: ws://${host}:${port}/ws`);
|
|
3257
|
+
log.info(`[gateway] Canvas: ws://${host}:${port}/canvas`);
|
|
3258
|
+
|
|
3259
|
+
log.info(isSetupMode ? `š Primer arranque ā abriendo wizard de configuración...` : `š Administra tu Hive aquĆ: ${uiUrl}`);
|
|
3260
|
+
|
|
3261
|
+
// Always open browser on startup (setup and normal mode).
|
|
3262
|
+
// Set NO_BROWSER=1 to skip in headless/server environments.
|
|
3263
|
+
if (!process.env.NO_BROWSER) {
|
|
3264
|
+
try {
|
|
3265
|
+
const platform = process.platform;
|
|
3266
|
+
let shellCmd: string;
|
|
3267
|
+
if (platform === "win32") {
|
|
3268
|
+
shellCmd = `start "" "${uiUrl}"`;
|
|
3269
|
+
} else if (platform === "darwin") {
|
|
3270
|
+
shellCmd = `open "${uiUrl}"`;
|
|
3271
|
+
} else {
|
|
3272
|
+
// Linux: gio open first (GNOME/Wayland native), then xdg-open fallbacks
|
|
3273
|
+
shellCmd = `gio open "${uiUrl}" 2>/dev/null || xdg-open "${uiUrl}" 2>/dev/null || sensible-browser "${uiUrl}" 2>/dev/null || x-www-browser "${uiUrl}" 2>/dev/null || true`;
|
|
3274
|
+
}
|
|
3275
|
+
const shell = platform === "win32" ? "cmd" : "/bin/sh";
|
|
3276
|
+
const shellArg = platform === "win32" ? "/c" : "-c";
|
|
3277
|
+
// Use Bun.spawn (native Bun API) for reliable detached subprocess
|
|
3278
|
+
const proc = Bun.spawn([shell, shellArg, shellCmd], {
|
|
3279
|
+
stdout: "ignore",
|
|
3280
|
+
stderr: "ignore",
|
|
3281
|
+
stdin: "ignore",
|
|
3282
|
+
});
|
|
3283
|
+
proc.unref();
|
|
3284
|
+
} catch (err) {
|
|
3285
|
+
log.warn(`Could not open browser: ${(err as Error).message}`);
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
}
|
|
3289
|
+
log.info(`Channels: ${channelManager.listChannels().map((c) => c.name).join(", ") || "none"}`);
|
|
3290
|
+
|
|
3291
|
+
// FIX 7 ā SIGTERM desconecta MCP limpiamente antes de cerrar
|
|
3292
|
+
process.on("SIGTERM", async () => {
|
|
3293
|
+
log.info("Received SIGTERM, shutting down gracefully...");
|
|
3294
|
+
watchers.forEach((close) => close());
|
|
3295
|
+
const mcp = agent.getMCPManager();
|
|
3296
|
+
if (mcp) {
|
|
3297
|
+
log.info("Disconnecting MCP servers...");
|
|
3298
|
+
await mcp.disconnectAll().catch(() => { });
|
|
3299
|
+
}
|
|
3300
|
+
await channelManager.stopAll();
|
|
3301
|
+
server.stop();
|
|
3302
|
+
try { unlinkSync(pidFile); } catch { }
|
|
3303
|
+
process.exit(0);
|
|
3304
|
+
});
|
|
3305
|
+
|
|
3306
|
+
process.on("SIGHUP", async () => {
|
|
3307
|
+
log.info("Received SIGHUP, reloading configuration...");
|
|
3308
|
+
try {
|
|
3309
|
+
const newConfig = await loadConfig();
|
|
3310
|
+
await agent.updateConfig(newConfig);
|
|
3311
|
+
await agent.reload();
|
|
3312
|
+
log.info("Configuration reloaded successfully");
|
|
3313
|
+
} catch (error) {
|
|
3314
|
+
log.error(`Failed to reload configuration: ${(error as Error).message}`);
|
|
3315
|
+
}
|
|
3316
|
+
});
|
|
3317
|
+
}
|