## 1. Fix /nodes Swarm Status Display
- Add SwarmStatusBanner component: clear green/red/loading state
- Shows nodeId, managerAddr, isManager badge
- Error state explains what to check (docker.sock mount)
- Header now shows 'swarm unreachable — check gateway' vs 'active'
- swarmOk now checks nodeId presence, not just data existence
## 2. Autonomous Agent Container
- New docker/Dockerfile.agent — builds Go agent binary from gateway/cmd/agent/
- New gateway/cmd/agent/main.go — standalone HTTP microservice:
* GET /health — liveness probe with idle time info
* POST /task — receives task, forwards to Gateway orchestrator
* GET /info — agent metadata (id, hostname, gateway url)
* Idle watchdog: calls /api/swarm/agents/{name}/stop after IdleTimeoutMinutes
* Connects to Swarm overlay network (goclaw-net) → reaches DB/Gateway by DNS
* Env: AGENT_ID, GATEWAY_URL, DATABASE_URL, IDLE_TIMEOUT_MINUTES
## 3. Swarm Manager Agent (auto-stop after 15min idle)
- New gateway/internal/api/swarm_manager.go:
* SwarmManager goroutine checks every 60s
* Scales idle GoClaw agent services to 0 replicas after 15 min
* Tracks lastActivity from task UpdatedAt timestamps
- New REST endpoints in gateway:
* GET /api/swarm/agents — list agents with idleMinutes
* POST /api/swarm/agents/{name}/start — scale up agent
* POST /api/swarm/agents/{name}/stop — scale to 0
* DELETE /api/swarm/services/{id} — remove service permanently
- SwarmManager started as background goroutine in main.go with context cancel
## 4. Docker Client Enhancements
- Added NetworkAttachment type and Networks field to ServiceSpec
- CreateAgentServiceFull(opts) — supports overlay networks, custom labels
- CreateAgentService() delegates to CreateAgentServiceFull for backward compat
- RemoveService(id) — DELETE /v1.44/services/{id}
- GetServiceLastActivity(id) — finds latest task UpdatedAt for idle detection
## 5. tRPC & Gateway Proxy
- New functions: removeSwarmService, listSwarmAgents, startSwarmAgent, stopSwarmAgent
- SwarmAgentInfo type with idleMinutes, lastActivity, desiredReplicas
- createAgentService now accepts networks[] parameter
- New tRPC endpoints: nodes.removeService, nodes.listAgents, nodes.startAgent, nodes.stopAgent
## 6. Nodes.tsx UI Overhaul
- SwarmStatusBanner component at top — no more silent 'connecting…'
- New 'Agents' tab with AgentManagerRow: idle time, auto-stop warning, start/stop/remove buttons
- IdleColor coding: green < 5m, yellow 5-10m, red 10m+ with countdown to auto-stop
- ServiceRow: added Remove button with confirmation dialog
- RemoveConfirmDialog component
- DeployAgentDialog: added overlay networks field, default env includes GATEWAY_URL
- All queries refetch after agent start/stop/remove
1069 lines
35 KiB
TypeScript
1069 lines
35 KiB
TypeScript
import { COOKIE_NAME } from "@shared/const";
|
||
import { z } from "zod";
|
||
import { getDb } from "./db";
|
||
import { getSessionCookieOptions } from "./_core/cookies";
|
||
import { systemRouter } from "./_core/systemRouter";
|
||
import { publicProcedure, router, protectedProcedure } from "./_core/trpc";
|
||
import { checkOllamaHealth, listModels, chatCompletion } from "./ollama";
|
||
import {
|
||
checkGatewayHealth,
|
||
gatewayChat,
|
||
getGatewayOrchestratorConfig,
|
||
getGatewayModels,
|
||
getGatewayTools,
|
||
executeGatewayTool,
|
||
isGatewayAvailable,
|
||
getGatewayNodes,
|
||
getGatewayNodeStats,
|
||
startChatSession,
|
||
getChatSession,
|
||
getChatEvents,
|
||
listChatSessions,
|
||
getSwarmInfo,
|
||
listSwarmNodes,
|
||
listSwarmServices,
|
||
getServiceTasks,
|
||
scaleSwarmService,
|
||
getSwarmJoinToken,
|
||
execSwarmShell,
|
||
addSwarmNodeLabel,
|
||
setNodeAvailability,
|
||
createAgentService,
|
||
removeSwarmService,
|
||
listSwarmAgents,
|
||
startSwarmAgent,
|
||
stopSwarmAgent,
|
||
getOllamaModelInfo,
|
||
} from "./gateway-proxy";
|
||
|
||
// Shared system user id for non-authenticated agent management
|
||
const SYSTEM_USER_ID = 1;
|
||
|
||
export const appRouter = router({
|
||
system: systemRouter,
|
||
auth: router({
|
||
me: publicProcedure.query(opts => opts.ctx.user),
|
||
logout: publicProcedure.mutation(({ ctx }) => {
|
||
const cookieOptions = getSessionCookieOptions(ctx.req);
|
||
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
|
||
return { success: true } as const;
|
||
}),
|
||
}),
|
||
|
||
/**
|
||
* LLM Providers — full CRUD backed by DB (llmProviders table).
|
||
* API keys stored encrypted with AES-256-GCM; never returned in plaintext to frontend.
|
||
*/
|
||
providers: router({
|
||
/** List all providers (keys masked). */
|
||
list: publicProcedure.query(async () => {
|
||
const { listProviders } = await import("./providers");
|
||
return listProviders();
|
||
}),
|
||
|
||
/** Create a new provider. */
|
||
create: publicProcedure
|
||
.input(z.object({
|
||
name: z.string().min(1),
|
||
baseUrl: z.string().url(),
|
||
apiKey: z.string(),
|
||
modelDefault: z.string().optional(),
|
||
notes: z.string().optional(),
|
||
setActive: z.boolean().default(false),
|
||
}))
|
||
.mutation(async ({ input }) => {
|
||
const { createProvider } = await import("./providers");
|
||
const id = await createProvider(input);
|
||
return { id };
|
||
}),
|
||
|
||
/** Update a provider (pass apiKey="" to keep existing key). */
|
||
update: publicProcedure
|
||
.input(z.object({
|
||
id: z.number(),
|
||
name: z.string().optional(),
|
||
baseUrl: z.string().url().optional(),
|
||
apiKey: z.string().optional(),
|
||
modelDefault: z.string().optional(),
|
||
notes: z.string().optional(),
|
||
isActive: z.boolean().optional(),
|
||
}))
|
||
.mutation(async ({ input }) => {
|
||
const { updateProvider } = await import("./providers");
|
||
const { id, ...rest } = input;
|
||
await updateProvider(id, rest);
|
||
return { ok: true };
|
||
}),
|
||
|
||
/** Delete a provider (cannot delete the active one). */
|
||
delete: publicProcedure
|
||
.input(z.object({ id: z.number() }))
|
||
.mutation(async ({ input }) => {
|
||
const { deleteProvider } = await import("./providers");
|
||
return deleteProvider(input.id);
|
||
}),
|
||
|
||
/** Activate a provider and signal the gateway to reload its config. */
|
||
activate: publicProcedure
|
||
.input(z.object({ id: z.number() }))
|
||
.mutation(async ({ input }) => {
|
||
const { activateProvider } = await import("./providers");
|
||
await activateProvider(input.id);
|
||
return { ok: true };
|
||
}),
|
||
}),
|
||
|
||
/**
|
||
* System config — returns active LLM provider config (key masked).
|
||
* Used by Settings page and AgentDetailModal.
|
||
*/
|
||
config: router({
|
||
providers: publicProcedure.query(async () => {
|
||
const { listProviders, getActiveProvider } = await import("./providers");
|
||
// Try DB first
|
||
try {
|
||
const rows = await listProviders();
|
||
if (rows.length > 0) {
|
||
return {
|
||
providers: rows.map((p) => ({
|
||
id: p.id.toString(),
|
||
name: p.name,
|
||
baseUrl: p.baseUrl,
|
||
hasKey: !!p.apiKeyHint,
|
||
maskedKey: p.apiKeyHint ? `${p.apiKeyHint}${"*".repeat(24)}` : "",
|
||
isActive: p.isActive,
|
||
modelDefault: p.modelDefault,
|
||
})),
|
||
};
|
||
}
|
||
} catch { /* fallback below */ }
|
||
|
||
// Fallback: read from env
|
||
const { ENV } = await import("./_core/env");
|
||
const baseUrl = ENV.ollamaBaseUrl || "https://ollama.com/v1";
|
||
const apiKey = ENV.ollamaApiKey || "";
|
||
const hasKey = apiKey.length > 0;
|
||
const maskedKey = hasKey ? `${apiKey.slice(0, 8)}${"*".repeat(Math.max(0, apiKey.length - 8))}` : "";
|
||
let providerName = "Ollama Cloud";
|
||
if (baseUrl.includes("openai.com")) providerName = "OpenAI";
|
||
else if (baseUrl.includes("anthropic.com")) providerName = "Anthropic";
|
||
else if (baseUrl.includes("groq.com")) providerName = "Groq";
|
||
else if (baseUrl.includes("mistral.ai")) providerName = "Mistral";
|
||
else if (!baseUrl.includes("ollama.com")) providerName = "Custom";
|
||
|
||
return {
|
||
providers: [{ id: "primary", name: providerName, baseUrl, hasKey, maskedKey, isActive: true, modelDefault: null }],
|
||
};
|
||
}),
|
||
}),
|
||
|
||
/**
|
||
* Ollama API — серверный прокси для безопасного доступа
|
||
* Приоритет: Go Gateway → прямой Ollama
|
||
*/
|
||
ollama: router({
|
||
health: publicProcedure.query(async () => {
|
||
// Try Go Gateway first, fall back to direct Ollama
|
||
const gwHealth = await checkGatewayHealth();
|
||
if (gwHealth.connected) {
|
||
return {
|
||
connected: true as const,
|
||
latencyMs: gwHealth.latencyMs,
|
||
source: "gateway" as const,
|
||
llm: gwHealth.llm,
|
||
error: undefined as string | undefined,
|
||
};
|
||
}
|
||
// Fallback: direct Ollama
|
||
const ollamaHealth = await checkOllamaHealth();
|
||
return { ...ollamaHealth, source: "direct" as const };
|
||
}),
|
||
|
||
models: publicProcedure.query(async () => {
|
||
// Try Go Gateway first
|
||
const gwModels = await getGatewayModels();
|
||
if (gwModels) {
|
||
return {
|
||
success: true as const,
|
||
models: gwModels.data ?? [],
|
||
source: "gateway" as const,
|
||
};
|
||
}
|
||
// Fallback: direct Ollama
|
||
try {
|
||
const result = await listModels();
|
||
return {
|
||
success: true as const,
|
||
models: result.data ?? [],
|
||
source: "direct" as const,
|
||
};
|
||
} catch (err: any) {
|
||
return {
|
||
success: false as const,
|
||
models: [],
|
||
error: err.message,
|
||
};
|
||
}
|
||
}),
|
||
|
||
/** Fetch model details (context_length, family, quantization…) from Ollama /api/show */
|
||
modelInfo: publicProcedure
|
||
.input(z.object({ modelId: z.string() }))
|
||
.query(async ({ input }) => {
|
||
const info = await getOllamaModelInfo(input.modelId);
|
||
return info ?? { contextLength: 0 };
|
||
}),
|
||
|
||
chat: publicProcedure
|
||
.input(
|
||
z.object({
|
||
model: z.string(),
|
||
messages: z.array(
|
||
z.object({
|
||
role: z.enum(["system", "user", "assistant"]),
|
||
content: z.string(),
|
||
})
|
||
),
|
||
temperature: z.number().optional(),
|
||
max_tokens: z.number().optional(),
|
||
})
|
||
)
|
||
.mutation(async ({ input }) => {
|
||
try {
|
||
const result = await chatCompletion(input.model, input.messages, {
|
||
temperature: input.temperature,
|
||
max_tokens: input.max_tokens,
|
||
});
|
||
return {
|
||
success: true as const,
|
||
response: result.choices[0]?.message?.content ?? "",
|
||
model: result.model,
|
||
usage: result.usage,
|
||
};
|
||
} catch (err: any) {
|
||
return {
|
||
success: false as const,
|
||
response: "",
|
||
error: err.message,
|
||
};
|
||
}
|
||
}),
|
||
}),
|
||
|
||
/**
|
||
* Agents — управление AI-агентами (public — внутренний инструмент)
|
||
*/
|
||
agents: router({
|
||
list: publicProcedure.query(async () => {
|
||
// getAllAgents returns both system agents (userId=0) and user-created agents
|
||
const { getAllAgents } = await import("./agents");
|
||
return getAllAgents();
|
||
}),
|
||
|
||
get: publicProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
|
||
const { getAgentById } = await import("./agents");
|
||
return getAgentById(input.id);
|
||
}),
|
||
|
||
create: publicProcedure
|
||
.input(
|
||
z.object({
|
||
name: z.string().min(1),
|
||
description: z.string().optional(),
|
||
role: z.string(),
|
||
model: z.string(),
|
||
provider: z.string(),
|
||
temperature: z.number().min(0).max(2).default(0.7),
|
||
maxTokens: z.number().default(2048),
|
||
topP: z.number().min(0).max(1).default(1.0),
|
||
frequencyPenalty: z.number().min(-2).max(2).default(0.0),
|
||
presencePenalty: z.number().min(-2).max(2).default(0.0),
|
||
systemPrompt: z.string().optional(),
|
||
allowedTools: z.array(z.string()).default([]),
|
||
allowedDomains: z.array(z.string()).default([]),
|
||
tags: z.array(z.string()).default([]),
|
||
})
|
||
)
|
||
.mutation(async ({ input }) => {
|
||
const { createAgent } = await import("./agents");
|
||
return createAgent(SYSTEM_USER_ID, {
|
||
...input,
|
||
temperature: input.temperature.toString(),
|
||
topP: input.topP.toString(),
|
||
frequencyPenalty: input.frequencyPenalty.toString(),
|
||
presencePenalty: input.presencePenalty.toString(),
|
||
} as any);
|
||
}),
|
||
|
||
update: publicProcedure
|
||
.input(
|
||
z.object({
|
||
id: z.number(),
|
||
name: z.string().optional(),
|
||
description: z.string().optional(),
|
||
model: z.string().optional(),
|
||
provider: z.string().optional(),
|
||
temperature: z.number().min(0).max(2).optional(),
|
||
maxTokens: z.number().optional(),
|
||
topP: z.number().min(0).max(1).optional(),
|
||
frequencyPenalty: z.number().min(-2).max(2).optional(),
|
||
presencePenalty: z.number().min(-2).max(2).optional(),
|
||
systemPrompt: z.string().optional(),
|
||
allowedTools: z.array(z.string()).optional(),
|
||
allowedDomains: z.array(z.string()).optional(),
|
||
isActive: z.boolean().optional(),
|
||
tags: z.array(z.string()).optional(),
|
||
})
|
||
)
|
||
.mutation(async ({ input }) => {
|
||
const { updateAgent } = await import("./agents");
|
||
const { id, temperature, topP, frequencyPenalty, presencePenalty, ...rest } = input;
|
||
const updates: Record<string, any> = { ...rest };
|
||
if (temperature !== undefined) updates.temperature = temperature.toString();
|
||
if (topP !== undefined) updates.topP = topP.toString();
|
||
if (frequencyPenalty !== undefined) updates.frequencyPenalty = frequencyPenalty.toString();
|
||
if (presencePenalty !== undefined) updates.presencePenalty = presencePenalty.toString();
|
||
return updateAgent(id, updates as any);
|
||
}),
|
||
|
||
delete: publicProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
|
||
const { deleteAgent } = await import("./agents");
|
||
return deleteAgent(input.id);
|
||
}),
|
||
|
||
stats: publicProcedure.input(z.object({ id: z.number(), hoursBack: z.number().default(24) })).query(async ({ input }) => {
|
||
const { getAgentStats } = await import("./agents");
|
||
return getAgentStats(input.id, input.hoursBack);
|
||
}),
|
||
|
||
metrics: publicProcedure.input(z.object({ id: z.number(), hoursBack: z.number().default(24) })).query(async ({ input }) => {
|
||
const { getAgentMetrics } = await import("./agents");
|
||
return getAgentMetrics(input.id, input.hoursBack);
|
||
}),
|
||
|
||
history: publicProcedure.input(z.object({ id: z.number(), limit: z.number().default(50) })).query(async ({ input }) => {
|
||
const { getAgentHistory } = await import("./agents");
|
||
return getAgentHistory(input.id, input.limit);
|
||
}),
|
||
|
||
accessControl: publicProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
|
||
const { getAgentAccessControl } = await import("./agents");
|
||
return getAgentAccessControl(input.id);
|
||
}),
|
||
|
||
updateToolAccess: publicProcedure
|
||
.input(
|
||
z.object({
|
||
agentId: z.number(),
|
||
tool: z.string(),
|
||
isAllowed: z.boolean(),
|
||
maxExecutionsPerHour: z.number().optional(),
|
||
timeoutSeconds: z.number().optional(),
|
||
allowedPatterns: z.array(z.string()).optional(),
|
||
blockedPatterns: z.array(z.string()).optional(),
|
||
})
|
||
)
|
||
.mutation(async ({ input }) => {
|
||
const { updateToolAccess } = await import("./agents");
|
||
const { agentId, ...updates } = input;
|
||
return updateToolAccess(agentId, input.tool, updates);
|
||
}),
|
||
|
||
/**
|
||
* Chat with a specific agent using its configuration
|
||
*/
|
||
chat: publicProcedure
|
||
.input(
|
||
z.object({
|
||
agentId: z.number(),
|
||
message: z.string(),
|
||
conversationId: z.string().optional(),
|
||
})
|
||
)
|
||
.mutation(async ({ input }) => {
|
||
const { getAgentById, saveHistory } = await import("./agents");
|
||
const agent = await getAgentById(input.agentId);
|
||
if (!agent) {
|
||
return { success: false as const, response: "", error: "Agent not found" };
|
||
}
|
||
|
||
const messages: Array<{ role: "system" | "user" | "assistant"; content: string }> = [];
|
||
if (agent.systemPrompt) {
|
||
messages.push({ role: "system", content: agent.systemPrompt });
|
||
}
|
||
messages.push({ role: "user", content: input.message });
|
||
|
||
const startTime = Date.now();
|
||
try {
|
||
const result = await chatCompletion(
|
||
agent.model,
|
||
messages,
|
||
{
|
||
temperature: agent.temperature ? parseFloat(agent.temperature as string) : 0.7,
|
||
max_tokens: agent.maxTokens ?? 2048,
|
||
}
|
||
);
|
||
const processingTimeMs = Date.now() - startTime;
|
||
const response = result.choices[0]?.message?.content ?? "";
|
||
|
||
// Save to history
|
||
await saveHistory(input.agentId, {
|
||
userMessage: input.message,
|
||
agentResponse: response,
|
||
conversationId: input.conversationId,
|
||
status: "success",
|
||
});
|
||
|
||
// Save metric
|
||
const { saveMetric } = await import("./agents");
|
||
await saveMetric(input.agentId, {
|
||
userMessage: input.message,
|
||
agentResponse: response,
|
||
inputTokens: result.usage?.prompt_tokens ?? 0,
|
||
outputTokens: result.usage?.completion_tokens ?? 0,
|
||
totalTokens: result.usage?.total_tokens ?? 0,
|
||
processingTimeMs,
|
||
status: "success",
|
||
toolsCalled: [],
|
||
model: result.model ?? agent.model,
|
||
}).catch(() => {}); // non-fatal
|
||
|
||
return {
|
||
success: true as const,
|
||
response,
|
||
model: result.model,
|
||
usage: result.usage,
|
||
processingTimeMs,
|
||
};
|
||
} catch (err: any) {
|
||
const processingTimeMs = Date.now() - startTime;
|
||
await saveHistory(input.agentId, {
|
||
userMessage: input.message,
|
||
agentResponse: null,
|
||
conversationId: input.conversationId,
|
||
status: "error",
|
||
});
|
||
const { saveMetric } = await import("./agents");
|
||
saveMetric(input.agentId, {
|
||
userMessage: input.message,
|
||
processingTimeMs,
|
||
status: "error",
|
||
errorMessage: err.message,
|
||
toolsCalled: [],
|
||
model: agent.model,
|
||
}).catch(() => {}); // non-fatal
|
||
return {
|
||
success: false as const,
|
||
response: "",
|
||
error: err.message,
|
||
};
|
||
}
|
||
}),
|
||
}),
|
||
|
||
/**
|
||
* Tools — управление инструментами агентов
|
||
*/
|
||
tools: router({
|
||
list: publicProcedure.query(async () => {
|
||
const { getAllTools } = await import("./tools");
|
||
return getAllTools();
|
||
}),
|
||
execute: publicProcedure
|
||
.input(
|
||
z.object({
|
||
agentId: z.number(),
|
||
tool: z.string(),
|
||
params: z.record(z.string(), z.unknown()),
|
||
})
|
||
)
|
||
.mutation(async ({ input }) => {
|
||
const { executeTool } = await import("./tools");
|
||
return executeTool(input.agentId, input.tool, input.params);
|
||
}),
|
||
}),
|
||
|
||
/**
|
||
* Browser Agent — управление браузерными сессиями через Puppeteer
|
||
*/
|
||
browser: router({
|
||
createSession: publicProcedure
|
||
.input(z.object({ agentId: z.number() }))
|
||
.mutation(async ({ input }) => {
|
||
const { createBrowserSession } = await import("./browser-agent");
|
||
return createBrowserSession(input.agentId);
|
||
}),
|
||
|
||
execute: publicProcedure
|
||
.input(
|
||
z.object({
|
||
sessionId: z.string(),
|
||
action: z.object({
|
||
type: z.enum(["navigate", "click", "type", "extract", "screenshot", "scroll", "wait", "evaluate", "close"]),
|
||
params: z.record(z.string(), z.unknown()),
|
||
}),
|
||
})
|
||
)
|
||
.mutation(async ({ input }) => {
|
||
const { executeBrowserAction } = await import("./browser-agent");
|
||
return executeBrowserAction(input.sessionId, input.action as any);
|
||
}),
|
||
|
||
getSessions: publicProcedure
|
||
.input(z.object({ agentId: z.number() }))
|
||
.query(async ({ input }) => {
|
||
const { getAgentSessions } = await import("./browser-agent");
|
||
return getAgentSessions(input.agentId);
|
||
}),
|
||
|
||
closeSession: publicProcedure
|
||
.input(z.object({ sessionId: z.string() }))
|
||
.mutation(async ({ input }) => {
|
||
const { executeBrowserAction } = await import("./browser-agent");
|
||
return executeBrowserAction(input.sessionId, { type: "close", params: {} });
|
||
}),
|
||
|
||
closeAllSessions: publicProcedure
|
||
.input(z.object({ agentId: z.number() }))
|
||
.mutation(async ({ input }) => {
|
||
const { closeAllAgentSessions } = await import("./browser-agent");
|
||
await closeAllAgentSessions(input.agentId);
|
||
return { success: true };
|
||
}),
|
||
}),
|
||
|
||
/**
|
||
* Tool Builder — генерация и установка новых инструментов через LLM
|
||
*/
|
||
toolBuilder: router({
|
||
generate: publicProcedure
|
||
.input(
|
||
z.object({
|
||
name: z.string().min(1),
|
||
description: z.string().min(10),
|
||
category: z.string().optional(),
|
||
exampleInput: z.string().optional(),
|
||
exampleOutput: z.string().optional(),
|
||
dangerous: z.boolean().optional(),
|
||
})
|
||
)
|
||
.mutation(async ({ input }) => {
|
||
const { generateTool } = await import("./tool-builder");
|
||
return generateTool(input);
|
||
}),
|
||
|
||
install: publicProcedure
|
||
.input(
|
||
z.object({
|
||
toolId: z.string(),
|
||
name: z.string(),
|
||
description: z.string(),
|
||
category: z.string(),
|
||
dangerous: z.boolean(),
|
||
parameters: z.record(z.string(), z.object({
|
||
type: z.string(),
|
||
description: z.string(),
|
||
required: z.boolean().optional(),
|
||
})),
|
||
implementation: z.string(),
|
||
})
|
||
)
|
||
.mutation(async ({ input }) => {
|
||
const { installTool } = await import("./tool-builder");
|
||
return installTool(input);
|
||
}),
|
||
|
||
listCustom: publicProcedure.query(async () => {
|
||
const { getCustomTools } = await import("./tool-builder");
|
||
return getCustomTools();
|
||
}),
|
||
|
||
delete: publicProcedure
|
||
.input(z.object({ toolId: z.string() }))
|
||
.mutation(async ({ input }) => {
|
||
const { deleteTool } = await import("./tool-builder");
|
||
return deleteTool(input.toolId);
|
||
}),
|
||
|
||
test: publicProcedure
|
||
.input(
|
||
z.object({
|
||
toolId: z.string(),
|
||
params: z.record(z.string(), z.unknown()),
|
||
})
|
||
)
|
||
.mutation(async ({ input }) => {
|
||
const { testTool } = await import("./tool-builder");
|
||
return testTool(input.toolId, input.params);
|
||
}),
|
||
}),
|
||
|
||
/**
|
||
* Agent Compiler — компиляция агентов по ТЗ через LLM
|
||
*/
|
||
agentCompiler: router({
|
||
compile: publicProcedure
|
||
.input(
|
||
z.object({
|
||
specification: z.string().min(20),
|
||
name: z.string().optional(),
|
||
preferredProvider: z.string().optional(),
|
||
preferredModel: z.string().optional(),
|
||
})
|
||
)
|
||
.mutation(async ({ input }) => {
|
||
const { compileAgentConfig } = await import("./agent-compiler");
|
||
return compileAgentConfig({ ...input, userId: SYSTEM_USER_ID });
|
||
}),
|
||
|
||
deploy: publicProcedure
|
||
.input(
|
||
z.object({
|
||
config: z.object({
|
||
name: z.string(),
|
||
description: z.string(),
|
||
role: z.string(),
|
||
model: z.string(),
|
||
provider: z.string(),
|
||
temperature: z.number(),
|
||
maxTokens: z.number(),
|
||
topP: z.number(),
|
||
frequencyPenalty: z.number(),
|
||
presencePenalty: z.number(),
|
||
systemPrompt: z.string(),
|
||
allowedTools: z.array(z.string()),
|
||
allowedDomains: z.array(z.string()),
|
||
maxRequestsPerHour: z.number(),
|
||
tags: z.array(z.string()),
|
||
reasoning: z.string(),
|
||
}),
|
||
})
|
||
)
|
||
.mutation(async ({ input }) => {
|
||
const { deployCompiledAgent } = await import("./agent-compiler");
|
||
return deployCompiledAgent(input.config, SYSTEM_USER_ID);
|
||
}),
|
||
|
||
compileAndDeploy: publicProcedure
|
||
.input(
|
||
z.object({
|
||
specification: z.string().min(20),
|
||
name: z.string().optional(),
|
||
preferredProvider: z.string().optional(),
|
||
preferredModel: z.string().optional(),
|
||
})
|
||
)
|
||
.mutation(async ({ input }) => {
|
||
const { compileAndDeployAgent } = await import("./agent-compiler");
|
||
return compileAndDeployAgent({ ...input, userId: SYSTEM_USER_ID });
|
||
}),
|
||
}),
|
||
/**
|
||
* Orchestrator — main AI agent with tool-use loop
|
||
* Приоритет: Go Gateway → Node.js fallback
|
||
*/
|
||
orchestrator: router({
|
||
// Get orchestrator config — Go Gateway first, then Node.js DB
|
||
getConfig: publicProcedure.query(async () => {
|
||
const gwConfig = await getGatewayOrchestratorConfig();
|
||
if (gwConfig) {
|
||
return { ...gwConfig, source: "gateway" as const };
|
||
}
|
||
// Fallback: Node.js orchestrator reads from DB directly
|
||
const { getOrchestratorConfig } = await import("./orchestrator");
|
||
const config = await getOrchestratorConfig();
|
||
return { ...config, source: "direct" as const };
|
||
}),
|
||
|
||
chat: publicProcedure
|
||
.input(
|
||
z.object({
|
||
messages: z.array(
|
||
z.object({
|
||
role: z.enum(["user", "assistant", "system"]),
|
||
content: z.string(),
|
||
})
|
||
),
|
||
model: z.string().optional(),
|
||
maxIterations: z.number().min(1).max(20).optional(),
|
||
})
|
||
)
|
||
.mutation(async ({ input }) => {
|
||
// Try Go Gateway first (preferred — full Go tool-use loop)
|
||
const gwAvailable = await isGatewayAvailable();
|
||
if (gwAvailable) {
|
||
const result = await gatewayChat(
|
||
input.messages,
|
||
input.model,
|
||
input.maxIterations ?? 10
|
||
);
|
||
return { ...result, source: "gateway" as const };
|
||
}
|
||
// Fallback: Node.js orchestrator
|
||
const { orchestratorChat } = await import("./orchestrator");
|
||
const result = await orchestratorChat(
|
||
input.messages,
|
||
input.model,
|
||
input.maxIterations ?? 10
|
||
);
|
||
return { ...result, source: "direct" as const };
|
||
}),
|
||
|
||
// List available tools — Go Gateway first
|
||
tools: publicProcedure.query(async () => {
|
||
const gwTools = await getGatewayTools();
|
||
if (gwTools) {
|
||
return gwTools.map((t) => ({
|
||
name: t.name,
|
||
description: t.description,
|
||
parameters: t.parameters,
|
||
source: "gateway" as const,
|
||
}));
|
||
}
|
||
// Fallback: Node.js tool definitions
|
||
const { ORCHESTRATOR_TOOLS } = await import("./orchestrator");
|
||
return ORCHESTRATOR_TOOLS.map((t) => ({
|
||
name: t.function.name,
|
||
description: t.function.description,
|
||
parameters: t.function.parameters,
|
||
source: "direct" as const,
|
||
}));
|
||
}),
|
||
|
||
// Gateway health check
|
||
gatewayHealth: publicProcedure.query(async () => {
|
||
return checkGatewayHealth();
|
||
}),
|
||
|
||
// Execute a single tool via Go Gateway
|
||
executeTool: publicProcedure
|
||
.input(
|
||
z.object({
|
||
tool: z.string(),
|
||
args: z.record(z.string(), z.unknown()),
|
||
})
|
||
)
|
||
.mutation(async ({ input }) => {
|
||
return executeGatewayTool(input.tool, input.args);
|
||
}),
|
||
|
||
// ── Persistent Background Chat Sessions ──────────────────────────────────
|
||
// These routes start a session on the Go Gateway and return immediately.
|
||
// The Go Gateway runs the orchestrator in a detached goroutine — survives
|
||
// HTTP disconnects, page reloads, and laptop sleep.
|
||
// The client polls getEvents until status === "done" | "error".
|
||
|
||
/** Start a background session. Returns { sessionId, status:"running" }. */
|
||
startSession: publicProcedure
|
||
.input(
|
||
z.object({
|
||
messages: z.array(
|
||
z.object({
|
||
role: z.enum(["user", "assistant", "system"]),
|
||
content: z.string(),
|
||
})
|
||
),
|
||
sessionId: z.string(),
|
||
model: z.string().optional(),
|
||
maxIter: z.number().min(1).max(20).optional(),
|
||
})
|
||
)
|
||
.mutation(async ({ input }) => {
|
||
const result = await startChatSession(
|
||
input.messages,
|
||
input.sessionId,
|
||
input.model,
|
||
input.maxIter ?? 10
|
||
);
|
||
if (!result) throw new Error("Gateway unavailable — cannot start background session");
|
||
return result;
|
||
}),
|
||
|
||
/** Get session metadata (status, finalResponse, tokens, model…). */
|
||
getSession: publicProcedure
|
||
.input(z.object({ sessionId: z.string() }))
|
||
.query(async ({ input }) => {
|
||
const sess = await getChatSession(input.sessionId);
|
||
if (!sess) throw new Error("Session not found");
|
||
return sess;
|
||
}),
|
||
|
||
/** Get events for a session after a given seq number (incremental polling). */
|
||
getEvents: publicProcedure
|
||
.input(
|
||
z.object({
|
||
sessionId: z.string(),
|
||
afterSeq: z.number().min(0).default(0),
|
||
})
|
||
)
|
||
.query(async ({ input }) => {
|
||
const result = await getChatEvents(input.sessionId, input.afterSeq);
|
||
if (!result) return { sessionId: input.sessionId, status: "unknown", events: [] };
|
||
return result;
|
||
}),
|
||
|
||
/** List recent sessions (default last 50). */
|
||
listSessions: publicProcedure
|
||
.input(z.object({ limit: z.number().min(1).max(200).default(50) }))
|
||
.query(async ({ input }) => {
|
||
const result = await listChatSessions(input.limit);
|
||
return result?.sessions ?? [];
|
||
}),
|
||
}),
|
||
|
||
/**
|
||
* Dashboard — aggregated real-time stats for the top status bar
|
||
*/
|
||
dashboard: router({
|
||
/**
|
||
* Returns aggregated cluster stats:
|
||
* - uptime: server process uptime formatted as "Xd Yh Zm"
|
||
* - nodes: running container count from Docker
|
||
* - agents: active agent count from DB
|
||
* - cpu: average CPU% across all containers
|
||
* - mem: total RAM used in MB
|
||
*/
|
||
stats: publicProcedure.query(async () => {
|
||
// 1. Server uptime
|
||
const uptimeSec = Math.floor(process.uptime());
|
||
const days = Math.floor(uptimeSec / 86400);
|
||
const hours = Math.floor((uptimeSec % 86400) / 3600);
|
||
const mins = Math.floor((uptimeSec % 3600) / 60);
|
||
const uptime = days > 0
|
||
? `${days}d ${hours}h ${mins}m`
|
||
: hours > 0
|
||
? `${hours}h ${mins}m`
|
||
: `${mins}m`;
|
||
|
||
// 2. Container / node stats from Go Gateway
|
||
const [nodesResult, statsResult] = await Promise.allSettled([
|
||
getGatewayNodes(),
|
||
getGatewayNodeStats(),
|
||
]);
|
||
|
||
const nodes = nodesResult.status === "fulfilled" && nodesResult.value
|
||
? nodesResult.value
|
||
: null;
|
||
const statsData = statsResult.status === "fulfilled" && statsResult.value
|
||
? statsResult.value
|
||
: null;
|
||
|
||
const containerCount = nodes?.containers?.length ?? nodes?.count ?? 0;
|
||
const totalContainers = nodes?.containers?.length ?? 0;
|
||
|
||
// CPU: average across all containers
|
||
const cpuPct = statsData?.stats?.length
|
||
? statsData.stats.reduce((sum, s) => sum + s.cpuPct, 0) / statsData.stats.length
|
||
: 0;
|
||
|
||
// MEM: total used MB
|
||
const memUseMB = statsData?.stats?.length
|
||
? statsData.stats.reduce((sum, s) => sum + s.memUseMB, 0)
|
||
: 0;
|
||
|
||
// 3. Active agents from DB
|
||
let activeAgents = 0;
|
||
try {
|
||
const db = await getDb();
|
||
if (db) {
|
||
const { agents } = await import("../drizzle/schema");
|
||
const { count: drizzleCount, eq } = await import("drizzle-orm");
|
||
const [{ value }] = await db
|
||
.select({ value: drizzleCount() })
|
||
.from(agents)
|
||
.where(eq(agents.isActive, true));
|
||
activeAgents = Number(value);
|
||
}
|
||
} catch {
|
||
// non-fatal
|
||
}
|
||
|
||
return {
|
||
uptime,
|
||
nodes: `${containerCount} / ${totalContainers || containerCount}`,
|
||
agents: activeAgents,
|
||
cpuPct: Math.round(cpuPct * 10) / 10,
|
||
memUseMB: Math.round(memUseMB),
|
||
gatewayOnline: !!nodes,
|
||
fetchedAt: new Date().toISOString(),
|
||
};
|
||
}),
|
||
}),
|
||
|
||
/**
|
||
* Nodes — Docker Swarm / standalone Docker monitoring via Go Gateway
|
||
*/
|
||
nodes: router({
|
||
/**
|
||
* Full Docker Swarm info: status, node count, manager address, join tokens.
|
||
*/
|
||
swarmInfo: publicProcedure.query(async () => {
|
||
return getSwarmInfo();
|
||
}),
|
||
|
||
/**
|
||
* List real Swarm nodes with live state, resources, labels.
|
||
* Falls back to the old gateway nodes endpoint if swarm API unavailable.
|
||
*/
|
||
list: publicProcedure.query(async () => {
|
||
// Try real Swarm API first
|
||
const swarm = await listSwarmNodes();
|
||
if (swarm) return { ...swarm, swarmActive: true, fetchedAt: new Date().toISOString() };
|
||
// Fallback: old gateway nodes
|
||
const result = await getGatewayNodes();
|
||
if (!result) {
|
||
return {
|
||
nodes: [] as import("./gateway-proxy").GatewayNodeInfo[],
|
||
count: 0,
|
||
swarmActive: false,
|
||
fetchedAt: new Date().toISOString(),
|
||
error: "Gateway unavailable — is the Go Gateway running?",
|
||
};
|
||
}
|
||
return result;
|
||
}),
|
||
|
||
/**
|
||
* List all Swarm services with replica counts and running task status.
|
||
*/
|
||
services: publicProcedure.query(async () => {
|
||
const result = await listSwarmServices();
|
||
return result ?? { services: [], count: 0 };
|
||
}),
|
||
|
||
/**
|
||
* Get all tasks for a specific service (where each replica is running).
|
||
*/
|
||
serviceTasks: publicProcedure
|
||
.input(z.object({ serviceId: z.string() }))
|
||
.query(async ({ input }) => {
|
||
const result = await getServiceTasks(input.serviceId);
|
||
return result ?? { tasks: [] };
|
||
}),
|
||
|
||
/**
|
||
* Scale a service to N replicas.
|
||
*/
|
||
scaleService: publicProcedure
|
||
.input(z.object({ serviceId: z.string(), replicas: z.number().min(0).max(100) }))
|
||
.mutation(async ({ input }) => {
|
||
const ok = await scaleSwarmService(input.serviceId, input.replicas);
|
||
return { ok };
|
||
}),
|
||
|
||
/**
|
||
* Get join token and command for adding a new node.
|
||
*/
|
||
joinToken: publicProcedure
|
||
.input(z.object({ role: z.enum(["worker", "manager"]).default("worker") }))
|
||
.query(async ({ input }) => {
|
||
return getSwarmJoinToken(input.role);
|
||
}),
|
||
|
||
/**
|
||
* Execute a shell command on the HOST system via nsenter.
|
||
* Requires gateway container to run with privileged: true + pid: host.
|
||
*/
|
||
execShell: publicProcedure
|
||
.input(z.object({ command: z.string().min(1).max(4096) }))
|
||
.mutation(async ({ input }) => {
|
||
const result = await execSwarmShell(input.command);
|
||
if (!result) throw new Error("Gateway unavailable");
|
||
return result;
|
||
}),
|
||
|
||
/**
|
||
* Add a label to a swarm node.
|
||
*/
|
||
addNodeLabel: publicProcedure
|
||
.input(z.object({ nodeId: z.string(), key: z.string(), value: z.string() }))
|
||
.mutation(async ({ input }) => {
|
||
const ok = await addSwarmNodeLabel(input.nodeId, input.key, input.value);
|
||
return { ok };
|
||
}),
|
||
|
||
/**
|
||
* Set node availability (active | pause | drain).
|
||
*/
|
||
setAvailability: publicProcedure
|
||
.input(z.object({ nodeId: z.string(), availability: z.enum(["active", "pause", "drain"]) }))
|
||
.mutation(async ({ input }) => {
|
||
const ok = await setNodeAvailability(input.nodeId, input.availability);
|
||
return { ok };
|
||
}),
|
||
|
||
/**
|
||
* Deploy an agent as a new Swarm service.
|
||
*/
|
||
deployAgentService: publicProcedure
|
||
.input(z.object({
|
||
name: z.string().min(1),
|
||
image: z.string().min(1),
|
||
replicas: z.number().min(1).max(20).default(1),
|
||
env: z.array(z.string()).optional(),
|
||
port: z.number().optional(),
|
||
networks: z.array(z.string()).optional(),
|
||
}))
|
||
.mutation(async ({ input }) => {
|
||
const result = await createAgentService(input);
|
||
if (!result) throw new Error("Failed to create service");
|
||
return result;
|
||
}),
|
||
|
||
/**
|
||
* Remove (stop and delete) a Swarm service.
|
||
*/
|
||
removeService: publicProcedure
|
||
.input(z.object({ serviceId: z.string().min(1) }))
|
||
.mutation(async ({ input }) => {
|
||
const ok = await removeSwarmService(input.serviceId);
|
||
return { ok };
|
||
}),
|
||
|
||
/**
|
||
* List all GoClaw agent services with idle time info.
|
||
*/
|
||
listAgents: publicProcedure.query(async () => {
|
||
const result = await listSwarmAgents();
|
||
return result ?? { agents: [], count: 0 };
|
||
}),
|
||
|
||
/**
|
||
* Start (scale-up) an agent service by name.
|
||
*/
|
||
startAgent: publicProcedure
|
||
.input(z.object({ name: z.string().min(1), replicas: z.number().min(1).max(20).default(1) }))
|
||
.mutation(async ({ input }) => {
|
||
const ok = await startSwarmAgent(input.name, input.replicas);
|
||
return { ok };
|
||
}),
|
||
|
||
/**
|
||
* Stop (scale-to-0) an agent service by name.
|
||
*/
|
||
stopAgent: publicProcedure
|
||
.input(z.object({ name: z.string().min(1) }))
|
||
.mutation(async ({ input }) => {
|
||
const ok = await stopSwarmAgent(input.name);
|
||
return { ok };
|
||
}),
|
||
|
||
/**
|
||
* Get live container stats (CPU%, RAM) for all running containers.
|
||
*/
|
||
stats: publicProcedure.query(async () => {
|
||
const result = await getGatewayNodeStats();
|
||
if (!result) {
|
||
return {
|
||
stats: [] as import("./gateway-proxy").GatewayContainerStat[],
|
||
count: 0,
|
||
fetchedAt: new Date().toISOString(),
|
||
error: "Gateway unavailable",
|
||
};
|
||
}
|
||
return result;
|
||
}),
|
||
}),
|
||
});
|
||
export type AppRouter = typeof appRouter;
|