diff --git a/apps/webapp/app/routes/agents.$agentId.status.tsx b/apps/webapp/app/routes/agents.$agentId.status.tsx new file mode 100644 index 00000000000..e4a821a04c5 --- /dev/null +++ b/apps/webapp/app/routes/agents.$agentId.status.tsx @@ -0,0 +1,189 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { json } from "@remix-run/node"; +import { useLoaderData } from "@remix-run/react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { Header1, Header2 } from "~/components/primitives/Headers"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { prisma } from "~/db.server"; +import { requireUser } from "~/services/auth.server"; +import { + Table, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const user = await requireUser(request); + const { agentId } = params; + + if (!agentId) { + throw new Response("Not found", { status: 404 }); + } + + // Get agent config + const agentConfig = await prisma.agentConfig.findUnique({ + where: { id: agentId }, + include: { + executions: { + orderBy: { createdAt: "desc" }, + take: 10, + }, + healthChecks: { + orderBy: { createdAt: "desc" }, + take: 5, + }, + }, + }); + + if (!agentConfig || agentConfig.userId !== user.id) { + throw new Response("Not found", { status: 404 }); + } + + return typedjson({ + agentConfig, + }); +}; + +function getStatusColor(status: string) { + switch (status) { + case "healthy": + return "text-green-600 bg-green-50"; + case "unhealthy": + return "text-red-600 bg-red-50"; + case "provisioning": + return "text-yellow-600 bg-yellow-50"; + default: + return "text-gray-600 bg-gray-50"; + } +} + +export default function AgentStatus() { + const { agentConfig } = useTypedLoaderData(); + + return ( + + + {agentConfig.name} + +
+ {/* Basic Info */} +
+ Configuration +
+
+ Status: + + {agentConfig.status} + +
+
+ Model: + {agentConfig.model} +
+
+ Platform: + {agentConfig.messagingPlatform} +
+
+ Container: + + {agentConfig.containerName && agentConfig.containerPort + ? `${agentConfig.containerName}:${agentConfig.containerPort}` + : "Not provisioned"} + +
+
+ Created: + {new Date(agentConfig.createdAt).toLocaleString()} +
+
+
+ + {/* Tools */} +
+ Tools +
+ {Array.isArray(agentConfig.tools) && agentConfig.tools.length > 0 ? ( +
    + {(agentConfig.tools as string[]).map((tool) => ( +
  • + ✓ {tool} +
  • + ))} +
+ ) : ( + No tools configured + )} +
+
+
+ + {/* Recent Executions */} + {agentConfig.executions.length > 0 && ( +
+ Recent Executions + + + + Message + Response + Time (ms) + Date + + + + {agentConfig.executions.map((exec) => ( + + {exec.message} + {exec.response} + {exec.executionTimeMs}ms + {new Date(exec.createdAt).toLocaleString()} + + ))} + +
+
+ )} + + {/* Health History */} + {agentConfig.healthChecks.length > 0 && ( +
+ Health Checks + + + + Status + Response Time + Date + + + + {agentConfig.healthChecks.map((check) => ( + + + + {check.isHealthy ? "✓ Healthy" : "✗ Unhealthy"} + + + {check.responseTimeMs}ms + {new Date(check.createdAt).toLocaleString()} + + ))} + +
+
+ )} +
+
+ ); +} diff --git a/apps/webapp/app/routes/agents.setup.tsx b/apps/webapp/app/routes/agents.setup.tsx new file mode 100644 index 00000000000..efc6d70171c --- /dev/null +++ b/apps/webapp/app/routes/agents.setup.tsx @@ -0,0 +1,246 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; +import { json, redirect } from "@remix-run/node"; +import { Form, useActionData, useNavigation } from "@remix-run/react"; +import { useState } from "react"; +import { z } from "zod"; +import { Button } from "~/components/primitives/Buttons"; +import { Header1, Header2 } from "~/components/primitives/Headers"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { prisma } from "~/db.server"; +import { requireUser } from "~/services/auth.server"; +import { logger } from "~/services/logger.server"; + +const SetupSchema = z.object({ + agentName: z.string().min(1, "Agent name is required"), + model: z.enum(["claude-3.5-sonnet", "claude-3-opus", "gpt-4-turbo"]), + messagingPlatform: z.enum(["slack", "discord", "telegram"]), + tools: z.string(), // JSON string array + slackWorkspaceId: z.string().optional(), + slackWebhookToken: z.string().optional(), +}); + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const user = await requireUser(request); + return json({ user }); +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + if (request.method !== "POST") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + const user = await requireUser(request); + const formData = await request.formData(); + + try { + const data = SetupSchema.parse({ + agentName: formData.get("agentName"), + model: formData.get("model"), + messagingPlatform: formData.get("messagingPlatform"), + tools: formData.get("tools"), + slackWorkspaceId: formData.get("slackWorkspaceId"), + slackWebhookToken: formData.get("slackWebhookToken"), + }); + + // Parse tools JSON + const tools = JSON.parse(data.tools || "[]"); + + // Create agent config in database + const agentConfig = await prisma.agentConfig.create({ + data: { + name: data.agentName, + model: data.model, + messagingPlatform: data.messagingPlatform, + tools: tools, + slackWorkspaceId: data.slackWorkspaceId || null, + slackWebhookToken: data.slackWebhookToken || null, + userId: user.id, + status: "provisioning", + }, + }); + + logger.info("Agent created", { + agentId: agentConfig.id, + userId: user.id, + name: data.agentName, + }); + + // Trigger provisioning endpoint to spin up container + try { + const provisionResponse = await fetch("http://localhost:3000/api/agents/provision", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agentId: agentConfig.id }), + }); + + if (!provisionResponse.ok) { + logger.error("Provisioning failed", { + agentId: agentConfig.id, + status: provisionResponse.status, + }); + } + } catch (error) { + logger.error("Failed to call provisioning endpoint", { error }); + } + + return redirect(`/agents/${agentConfig.id}/status`); + } catch (error) { + logger.error("Failed to create agent", { error, userId: user.id }); + return json( + { error: error instanceof Error ? error.message : "Failed to create agent" }, + { status: 400 } + ); + } +}; + +export default function AgentSetup() { + const navigation = useNavigation(); + const actionData = useActionData(); + const [selectedTools, setSelectedTools] = useState([]); + + const toolOptions = [ + { id: "web-search", label: "Web Search" }, + { id: "code-execution", label: "Code Execution" }, + { id: "file-operations", label: "File Operations" }, + { id: "api-calls", label: "API Calls" }, + ]; + + const handleToolChange = (toolId: string, checked: boolean) => { + if (checked) { + setSelectedTools([...selectedTools, toolId]); + } else { + setSelectedTools(selectedTools.filter((t) => t !== toolId)); + } + }; + + return ( + + + Create a New Agent + Set up your AI agent with model, messaging, and tools + +
+ {actionData?.error && ( +
+ {actionData.error} +
+ )} + + {/* Agent Name */} +
+ + +
+ + {/* Model Selection */} +
+ + +
+ + {/* Messaging Platform */} +
+ + +
+ + {/* Tools Selection */} +
+ Select Tools +
+ {toolOptions.map((tool) => ( + + ))} +
+ +
+ + {/* Slack Integration (conditional) */} + {/* This would be conditional based on messagingPlatform selection */} +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+ ); +} diff --git a/apps/webapp/app/routes/api.agents.provision.ts b/apps/webapp/app/routes/api.agents.provision.ts new file mode 100644 index 00000000000..c3ce6e2968d --- /dev/null +++ b/apps/webapp/app/routes/api.agents.provision.ts @@ -0,0 +1,91 @@ +import type { ActionFunctionArgs } from "@remix-run/node"; +import { json } from "@remix-run/node"; +import { prisma } from "~/db.server"; +import { logger } from "~/services/logger.server"; + +/** + * POST /api/agents/provision + * Provisions an OpenClaw container for a given agent config + */ +export const action = async ({ request }: ActionFunctionArgs) => { + if (request.method !== "POST") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + const { agentId } = (await request.json()) as { agentId: string }; + + if (!agentId) { + return json({ error: "agentId is required" }, { status: 400 }); + } + + try { + // Get agent config + const agentConfig = await prisma.agentConfig.findUnique({ + where: { id: agentId }, + }); + + if (!agentConfig) { + return json({ error: "Agent not found" }, { status: 404 }); + } + + // Find the next available port (starting at 8001) + const lastAgent = await prisma.agentConfig.findFirst({ + where: { + containerPort: { not: null }, + }, + orderBy: { containerPort: "desc" }, + }); + + const nextPort = (lastAgent?.containerPort || 8000) + 1; + + // Generate container name + const containerName = `openclaw-${agentConfig.userId.slice(0, 8)}-${agentConfig.id.slice(0, 8)}`; + + logger.info("Provisioning OpenClaw container", { + agentId, + containerName, + port: nextPort, + }); + + // TODO: Implement actual Docker provisioning + // For now, just update the database with port info + // Production would SSH to VPS and run: docker run -d --name $containerName -p $nextPort:8000 openclaw:latest + + const updatedAgent = await prisma.agentConfig.update({ + where: { id: agentId }, + data: { + containerName, + containerPort: nextPort, + status: "provisioning", + }, + }); + + // Log provisioning start (not health check yet since container doesn't exist) + await prisma.agentHealthCheck.create({ + data: { + agentId, + isHealthy: false, + errorMessage: "Container provisioning started - awaiting actual deployment", + }, + }); + + logger.info("Agent provisioned successfully", { + agentId, + containerName, + port: nextPort, + }); + + return json({ + success: true, + agentId, + containerName, + containerPort: nextPort, + }); + } catch (error) { + logger.error("Failed to provision agent", { error, agentId }); + return json( + { error: error instanceof Error ? error.message : "Provisioning failed" }, + { status: 500 } + ); + } +}; diff --git a/apps/webapp/app/routes/webhooks.slack.ts b/apps/webapp/app/routes/webhooks.slack.ts new file mode 100644 index 00000000000..b5167792f71 --- /dev/null +++ b/apps/webapp/app/routes/webhooks.slack.ts @@ -0,0 +1,140 @@ +import type { ActionFunctionArgs } from "@remix-run/node"; +import { json } from "@remix-run/node"; +import { prisma } from "~/db.server"; +import { logger } from "~/services/logger.server"; + +/** + * POST /webhooks/slack + * Receives Slack messages and routes them to the correct OpenClaw agent + */ +export const action = async ({ request }: ActionFunctionArgs) => { + if (request.method !== "POST") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + try { + const event = (await request.json()) as any; + + // Handle Slack URL verification + if (event.type === "url_verification") { + return json({ challenge: event.challenge }); + } + + // Handle message events + if (event.type === "event_callback" && event.event.type === "message") { + const slackEvent = event.event; + const workspaceId = event.team_id; + const channel = slackEvent.channel; + const text = slackEvent.text; + const userId = slackEvent.user; + + logger.info("Received Slack message", { + workspaceId, + channel, + userId, + text: text?.substring(0, 100), + }); + + // Find the agent for this workspace + const agent = await prisma.agentConfig.findFirst({ + where: { + slackWorkspaceId: workspaceId, + messagingPlatform: "slack", + status: "healthy", + }, + }); + + if (!agent) { + logger.warn("No agent found for workspace", { workspaceId }); + return json({ ok: true }); // Don't error, just ignore + } + + if (!agent.containerPort) { + logger.warn("Agent has no container port", { agentId: agent.id }); + return json({ ok: true }); + } + + // Route message to OpenClaw container (on VPS) + const containerUrl = `http://178.128.150.129:${agent.containerPort}`; + + try { + const containerResponse = await fetch(`${containerUrl}/api/message`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text, + userId, + channel, + metadata: { + slackUserId: userId, + slackChannel: channel, + timestamp: new Date().toISOString(), + }, + }), + }); + + const containerData = await containerResponse.json(); + const agentResponse = containerData?.response || "I couldn't process that"; + + // Log execution + await prisma.agentExecution.create({ + data: { + agentId: agent.id, + message: text, + response: agentResponse, + executionTimeMs: 0, // TODO: Measure actual execution time + inputTokens: containerData?.inputTokens, + outputTokens: containerData?.outputTokens, + }, + }); + + // Send response back to Slack + if (agent.slackWebhookToken) { + await fetch(`https://hooks.slack.com/services/${agent.slackWebhookToken}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + channel, + text: agentResponse, + reply_broadcast: false, + thread_ts: slackEvent.thread_ts || slackEvent.ts, + }), + }); + } + + logger.info("Message processed successfully", { + agentId: agent.id, + responseLength: agentResponse.length, + }); + } catch (containerError) { + logger.error("Failed to route message to container", { + agentId: agent.id, + containerPort: agent.containerPort, + error: containerError, + }); + + // Mark agent as unhealthy + await prisma.agentConfig.update({ + where: { id: agent.id }, + data: { status: "unhealthy" }, + }); + + // Log health check failure + await prisma.agentHealthCheck.create({ + data: { + agentId: agent.id, + isHealthy: false, + errorMessage: containerError instanceof Error ? containerError.message : "Unknown error", + }, + }); + + return json({ ok: true }); // Don't fail the webhook, just mark agent unhealthy + } + } + + return json({ ok: true }); + } catch (error) { + logger.error("Webhook processing error", { error }); + return json({ ok: true }, { status: 200 }); // Always return 200 to Slack + } +}; diff --git a/internal-packages/database/prisma/migrations/20260325122458_add_openclaw_agents/migration.sql b/internal-packages/database/prisma/migrations/20260325122458_add_openclaw_agents/migration.sql new file mode 100644 index 00000000000..77bb2bf985b --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260325122458_add_openclaw_agents/migration.sql @@ -0,0 +1,62 @@ +-- CreateTable "AgentConfig" +CREATE TABLE "AgentConfig" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "model" TEXT NOT NULL, + "messagingPlatform" TEXT NOT NULL, + "tools" TEXT, + "containerName" TEXT, + "containerPort" INTEGER, + "slackWorkspaceId" TEXT, + "slackWebhookToken" TEXT, + "status" TEXT NOT NULL DEFAULT 'provisioning', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "AgentConfig_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable "AgentExecution" +CREATE TABLE "AgentExecution" ( + "id" TEXT NOT NULL PRIMARY KEY, + "agentId" TEXT NOT NULL, + "message" TEXT NOT NULL, + "response" TEXT NOT NULL, + "executionTimeMs" INTEGER NOT NULL, + "inputTokens" INTEGER, + "outputTokens" INTEGER, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AgentExecution_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "AgentConfig" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable "AgentHealthCheck" +CREATE TABLE "AgentHealthCheck" ( + "id" TEXT NOT NULL PRIMARY KEY, + "agentId" TEXT NOT NULL, + "isHealthy" BOOLEAN NOT NULL, + "responseTimeMs" INTEGER, + "errorMessage" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AgentHealthCheck_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "AgentConfig" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "AgentConfig_userId_idx" ON "AgentConfig"("userId"); + +-- CreateIndex +CREATE INDEX "AgentConfig_slackWorkspaceId_idx" ON "AgentConfig"("slackWorkspaceId"); + +-- CreateIndex +CREATE INDEX "AgentConfig_status_idx" ON "AgentConfig"("status"); + +-- CreateIndex +CREATE INDEX "AgentExecution_agentId_idx" ON "AgentExecution"("agentId"); + +-- CreateIndex +CREATE INDEX "AgentExecution_createdAt_idx" ON "AgentExecution"("createdAt"); + +-- CreateIndex +CREATE INDEX "AgentHealthCheck_agentId_idx" ON "AgentHealthCheck"("agentId"); + +-- CreateIndex +CREATE INDEX "AgentHealthCheck_createdAt_idx" ON "AgentHealthCheck"("createdAt"); diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index f6986be42c0..69a3d285b1e 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -65,6 +65,7 @@ model User { impersonationsReceived ImpersonationAuditLog[] @relation("ImpersonationTarget") customerQueries CustomerQuery[] metricsDashboards MetricsDashboard[] + agentConfigs AgentConfig[] } model MfaBackupCode { @@ -2570,10 +2571,79 @@ model MetricsDashboard { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - /// JSON that defines the config, queries, layout and config of all widgets. + /// JSON that defines the config, queries, layout and config of all widgets. /// There will be a version field for the format. layout String /// Fast lookup for the list @@index([projectId, createdAt(sort: Desc)]) } + +/// Agent configuration for OpenClaw instances +model AgentConfig { + id String @id @default(cuid()) + + name String + model String // claude-3.5-sonnet, claude-3-opus, gpt-4-turbo + messagingPlatform String // slack, discord, telegram + tools Json // Array of tool IDs + + // Container info + containerName String? + containerPort Int? + + // Slack integration + slackWorkspaceId String? + slackWebhookToken String? + + status String @default("provisioning") // provisioning, healthy, unhealthy + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + executions AgentExecution[] + healthChecks AgentHealthCheck[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([status]) + @@index([slackWorkspaceId]) +} + +/// Log of agent executions (messages and responses) +model AgentExecution { + id String @id @default(cuid()) + + agentId String + agent AgentConfig @relation(fields: [agentId], references: [id], onDelete: Cascade) + + message String + response String + executionTimeMs Int @default(0) + inputTokens Int? + outputTokens Int? + + createdAt DateTime @default(now()) + + @@index([agentId]) + @@index([createdAt(sort: Desc)]) +} + +/// Health check records for agents +model AgentHealthCheck { + id String @id @default(cuid()) + + agentId String + agent AgentConfig @relation(fields: [agentId], references: [id], onDelete: Cascade) + + isHealthy Boolean + responseTimeMs Int @default(0) + errorMessage String? + + createdAt DateTime @default(now()) + + @@index([agentId]) + @@index([createdAt(sort: Desc)]) +}