Реализовано: - gateway/internal/docker/client.go: Docker API клиент через unix socket (/var/run/docker.sock) - IsSwarmActive(), GetSwarmInfo(), ListNodes(), ListContainers(), GetContainerStats() - CalcCPUPercent() для расчёта CPU% - gateway/internal/api/handlers.go: новые endpoints - GET /api/nodes: список Swarm нод или standalone Docker хост - GET /api/nodes/stats: live CPU/RAM статистика контейнеров - POST /api/tools/execute: выполнение инструментов - gateway/cmd/gateway/main.go: зарегистрированы новые маршруты - server/gateway-proxy.ts: добавлены getGatewayNodes() и getGatewayNodeStats() - server/routers.ts: добавлен nodes router (nodes.list, nodes.stats) - client/src/pages/Nodes.tsx: полностью переписан на реальные данные - Auto-refresh: 10s для нод, 15s для статистики контейнеров - Swarm mode: показывает все ноды кластера - Standalone mode: показывает локальный Docker хост + контейнеры - CPU/RAM gauges из реальных docker stats - Error state при недоступном Gateway - Loading skeleton - server/nodes.test.ts: 14 новых vitest тестов - Все 51 тест пройдены
631 lines
20 KiB
TypeScript
631 lines
20 KiB
TypeScript
import { COOKIE_NAME } from "@shared/const";
|
||
import { z } from "zod";
|
||
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,
|
||
} 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;
|
||
}),
|
||
}),
|
||
|
||
/**
|
||
* 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,
|
||
};
|
||
}
|
||
}),
|
||
|
||
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 () => {
|
||
const { getUserAgents } = await import("./agents");
|
||
return getUserAgents(SYSTEM_USER_ID);
|
||
}),
|
||
|
||
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",
|
||
});
|
||
|
||
return {
|
||
success: true as const,
|
||
response,
|
||
model: result.model,
|
||
usage: result.usage,
|
||
processingTimeMs,
|
||
};
|
||
} catch (err: any) {
|
||
await saveHistory(input.agentId, {
|
||
userMessage: input.message,
|
||
agentResponse: null,
|
||
conversationId: input.conversationId,
|
||
status: "error",
|
||
});
|
||
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);
|
||
}),
|
||
}),
|
||
|
||
/**
|
||
* Nodes — Docker Swarm / standalone Docker monitoring via Go Gateway
|
||
*/
|
||
nodes: router({
|
||
/**
|
||
* List all Swarm nodes (or standalone Docker host if Swarm not active).
|
||
* Returns node info: hostname, role, status, resources, labels, etc.
|
||
*/
|
||
list: publicProcedure.query(async () => {
|
||
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;
|
||
}),
|
||
|
||
/**
|
||
* 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;
|