diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx index 4285f04c4..9f078f9d2 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx @@ -1,6 +1,7 @@ import copy from "copy-to-clipboard"; import { Check, Copy, Loader2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { AnalyzeLogs } from "@/components/dashboard/docker/logs/analyze-logs"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -165,6 +166,7 @@ export const ShowDeployment = ({ )} + {serverId && (
diff --git a/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx b/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx new file mode 100644 index 000000000..267735eac --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx @@ -0,0 +1,189 @@ +"use client"; +import { Bot, Loader2, RotateCcw, Settings, X } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import ReactMarkdown from "react-markdown"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/utils/api"; +import type { LogLine } from "./utils"; + +interface Props { + logs: LogLine[]; + context: "build" | "runtime"; +} + +const MAX_LOG_LINES = 200; + +export function AnalyzeLogs({ logs, context }: Props) { + const [open, setOpen] = useState(false); + const [aiId, setAiId] = useState(""); + const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, { + enabled: open, + }); + const { mutate, isPending, data, reset } = api.ai.analyzeLogs.useMutation({ + onError: (error) => { + toast.error("Analysis failed", { + description: error.message, + }); + }, + }); + + const handleAnalyze = () => { + if (!aiId || logs.length === 0) return; + + const logsText = logs + .slice(-MAX_LOG_LINES) + .map((l) => l.message) + .join("\n"); + + mutate({ aiId, logs: logsText, context }); + }; + + return ( + { + setOpen(isOpen); + if (!isOpen) { + reset(); + setAiId(""); + } + }} + > + + + + +
+
+ + Log Analysis +
+ +
+
+ {!data?.analysis ? ( + providers && providers.length === 0 ? ( +
+

+ No AI providers configured. Set up a provider to start + analyzing logs. +

+ +
+ ) : ( + <> + + + + ) + ) : ( + <> +
+
+ {data.analysis} +
+
+
+ + +
+ + )} +
+
+
+ ); +} diff --git a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx index 59b939008..8d8842ac0 100644 --- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx @@ -12,6 +12,7 @@ import { AlertBlock } from "@/components/shared/alert-block"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { api } from "@/utils/api"; +import { AnalyzeLogs } from "./analyze-logs"; import { LineCountFilter } from "./line-count-filter"; import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter"; import { StatusLogsFilter } from "./status-logs-filter"; @@ -377,6 +378,7 @@ export const DockerLogsId: React.FC = ({ Download logs +
{isPaused && ( diff --git a/apps/dokploy/components/dashboard/project/ai/template-generator.tsx b/apps/dokploy/components/dashboard/project/ai/template-generator.tsx index 76a491aa0..64d4b0776 100644 --- a/apps/dokploy/components/dashboard/project/ai/template-generator.tsx +++ b/apps/dokploy/components/dashboard/project/ai/template-generator.tsx @@ -298,7 +298,19 @@ export const TemplateGenerator = ({ environmentId }: Props) => {
+ + + - {field.value - ? (selectedModel?.id ?? field.value) - : "Select a model"} - - - - - - - + + + + {modelSearch ? ( + + ) : ( + "No models found." + )} + + {displayModels.map((model) => { + const isSelected = field.value === model.id; + return ( + { + field.onChange(model.id); + setModelPopoverOpen(false); + setModelSearch(""); + }} + > + + {model.id} + + ); + })} + + + + + ) : ( + + - - No models found. - {displayModels.map((model) => { - const isSelected = field.value === model.id; - return ( - { - field.onChange(model.id); - setModelPopoverOpen(false); - setModelSearch(""); - }} - > - - {model.id} - - ); - })} - - - - - - Select an AI model to use - - - - ); - }} - /> - )} + + )} +
+
+ + Select a model from the list or type a custom model name + + + + ); + }} + /> { )} /> -
+
+ @@ -383,3 +475,42 @@ export const HandleAi = ({ aiId }: Props) => { ); }; + +function TestConnectionButton({ + apiUrl, + apiKey, + model, +}: { + apiUrl: string; + apiKey: string; + model: string; +}) { + const { mutate, isPending } = api.ai.testConnection.useMutation({ + onSuccess: () => { + toast.success("Connection successful"); + }, + onError: (error) => { + toast.error("Connection failed", { + description: error.message, + }); + }, + }); + + const isDisabled = !apiUrl || !model; + + return ( + + ); +} diff --git a/apps/dokploy/server/api/routers/ai.ts b/apps/dokploy/server/api/routers/ai.ts index a4527497d..3a299235a 100644 --- a/apps/dokploy/server/api/routers/ai.ts +++ b/apps/dokploy/server/api/routers/ai.ts @@ -25,9 +25,11 @@ import { findProjectById } from "@dokploy/server/services/project"; import { getProviderHeaders, getProviderName, + selectAIProvider, type Model, } from "@dokploy/server/utils/ai/select-ai-provider"; import { TRPCError } from "@trpc/server"; +import { generateText } from "ai"; import { z } from "zod"; import { slugify } from "@/lib/slug"; import { @@ -95,6 +97,30 @@ export const aiRouter = createTRPCRouter({ owned_by: "perplexity", }, ] as Model[]; + case "zai": + return [ + { + id: "glm-5", + object: "model", + created: Date.now(), + owned_by: "zai", + }, + { + id: "glm-4.7", + object: "model", + created: Date.now(), + owned_by: "zai", + }, + ] as Model[]; + case "minimax": + return [ + { + id: "MiniMax-M2.7", + object: "model", + created: Date.now(), + owned_by: "minimax", + }, + ] as Model[]; default: if (!input.apiKey) throw new TRPCError({ @@ -174,6 +200,107 @@ export const aiRouter = createTRPCRouter({ return await deleteAiSettings(input.aiId); }), + getEnabledProviders: protectedProcedure.query(async ({ ctx }) => { + const settings = await getAiSettingsByOrganizationId( + ctx.session.activeOrganizationId, + ); + return settings + .filter((s) => s.isEnabled) + .map((s) => ({ aiId: s.aiId, name: s.name, model: s.model })); + }), + + analyzeLogs: protectedProcedure + .input( + z.object({ + aiId: z.string().min(1), + logs: z.string().min(1), + context: z.enum(["build", "runtime"]), + }), + ) + .mutation(async ({ input, ctx }) => { + try { + const aiSettings = await getAiSettingById(input.aiId); + if (!aiSettings?.isEnabled) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "AI provider is not enabled", + }); + } + + if (aiSettings.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Access denied", + }); + } + + const provider = selectAIProvider(aiSettings); + const model = provider(aiSettings.model); + + const contextLabel = + input.context === "build" ? "build/deployment" : "runtime/container"; + + const result = await generateText({ + model, + prompt: `You are a DevOps engineer analyzing ${contextLabel} logs. Analyze the following logs and provide: + +1. **Summary**: A brief summary of what's happening +2. **Issues Found**: Any errors, warnings, or problems detected +3. **Root Cause**: The most likely root cause if there are errors +4. **Suggested Fix**: Actionable steps to resolve the issues + +Be concise and practical. Focus on the most important issues. If the logs look healthy, say so briefly. + +Logs: +${input.logs}`, + }); + + return { analysis: result.text }; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error + ? error.message + : `Analysis failed: ${error}`, + }); + } + }), + + testConnection: protectedProcedure + .input( + z.object({ + apiUrl: z.string().min(1), + apiKey: z.string(), + model: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + try { + const provider = selectAIProvider({ + apiUrl: input.apiUrl, + apiKey: input.apiKey, + }); + const model = provider(input.model); + const result = await generateText({ + model, + prompt: "Reply with 'ok'", + }); + if (!result.text) { + throw new Error("No response received from the model"); + } + return { success: true, message: "Connection successful" }; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error + ? error.message + : `Connection failed: ${error}`, + }); + } + }), + suggest: protectedProcedure .input( z.object({ diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index c00514715..228c98656 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -6,7 +6,9 @@ import { findEnvironmentById, findGitProviderById, findProjectById, + getAccessibleServerIds, getApplicationStats, + getContainerLogs, IS_CLOUD, mechanizeDockerContainer, readConfig, @@ -26,7 +28,6 @@ import { updateDeploymentStatus, writeConfig, writeConfigRemote, - getAccessibleServerIds, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { @@ -1101,4 +1102,39 @@ export const applicationRouter = createTRPCRouter({ total: countResult[0]?.count ?? 0, }; }), + + readLogs: protectedProcedure + .input( + apiFindOneApplication.extend({ + tail: z.number().int().min(1).max(10000).default(100), + since: z + .string() + .regex(/^(all|\d+[smhd])$/, "Invalid since format") + .default("all"), + search: z + .string() + .regex(/^[a-zA-Z0-9 ._-]{0,500}$/) + .optional(), + }), + ) + .query(async ({ input, ctx }) => { + await checkServiceAccess(ctx, input.applicationId, "read"); + const application = await findApplicationById(input.applicationId); + if ( + application.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this application", + }); + } + return await getContainerLogs( + application.appName, + input.tail, + input.since, + input.search, + application.serverId, + ); + }), }); diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index be5a836d7..d395bdffc 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -16,7 +16,9 @@ import { findGitProviderById, findProjectById, findServerById, + getAccessibleServerIds, getComposeContainer, + getContainerLogs, getWebServerSettings, IS_CLOUD, loadServices, @@ -30,7 +32,6 @@ import { stopCompose, updateCompose, updateDeploymentStatus, - getAccessibleServerIds, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { @@ -1130,4 +1131,44 @@ export const composeRouter = createTRPCRouter({ total: countResult[0]?.count ?? 0, }; }), + + readLogs: protectedProcedure + .input( + apiFindCompose.extend({ + containerId: z + .string() + .min(1) + .regex(/^[a-zA-Z0-9.\-_]+$/, "Invalid container id."), + tail: z.number().int().min(1).max(10000).default(100), + since: z + .string() + .regex(/^(all|\d+[smhd])$/, "Invalid since format") + .default("all"), + search: z + .string() + .regex(/^[a-zA-Z0-9 ._-]{0,500}$/) + .optional(), + }), + ) + .query(async ({ input, ctx }) => { + await checkServiceAccess(ctx, input.composeId, "read"); + const compose = await findComposeById(input.composeId); + if ( + compose.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this compose", + }); + } + return await getContainerLogs( + input.containerId, + input.tail, + input.since, + input.search, + compose.serverId, + true, + ); + }), }); diff --git a/apps/dokploy/server/api/routers/libsql.ts b/apps/dokploy/server/api/routers/libsql.ts index 65052097e..47798393e 100644 --- a/apps/dokploy/server/api/routers/libsql.ts +++ b/apps/dokploy/server/api/routers/libsql.ts @@ -6,6 +6,7 @@ import { findEnvironmentById, findLibsqlById, findProjectById, + getContainerLogs, IS_CLOUD, rebuildDatabase, removeLibsqlById, @@ -466,4 +467,39 @@ export const libsqlRouter = createTRPCRouter({ }); return true; }), + + readLogs: protectedProcedure + .input( + apiFindOneLibsql.extend({ + tail: z.number().int().min(1).max(10000).default(100), + since: z + .string() + .regex(/^(all|\d+[smhd])$/, "Invalid since format") + .default("all"), + search: z + .string() + .regex(/^[a-zA-Z0-9 ._-]{0,500}$/) + .optional(), + }), + ) + .query(async ({ input, ctx }) => { + await checkServiceAccess(ctx, input.libsqlId, "read"); + const libsql = await findLibsqlById(input.libsqlId); + if ( + libsql.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this LibSQL", + }); + } + return await getContainerLogs( + libsql.appName, + input.tail, + input.since, + input.search, + libsql.serverId, + ); + }), }); diff --git a/apps/dokploy/server/api/routers/mariadb.ts b/apps/dokploy/server/api/routers/mariadb.ts index a31554bec..a58a33a9c 100644 --- a/apps/dokploy/server/api/routers/mariadb.ts +++ b/apps/dokploy/server/api/routers/mariadb.ts @@ -9,6 +9,8 @@ import { findEnvironmentById, findMariadbById, findProjectById, + getAccessibleServerIds, + getContainerLogs, getServiceContainerCommand, IS_CLOUD, rebuildDatabase, @@ -19,7 +21,6 @@ import { stopService, stopServiceRemote, updateMariadbById, - getAccessibleServerIds, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { @@ -590,4 +591,39 @@ export const mariadbRouter = createTRPCRouter({ ]); return { items, total: countResult[0]?.count ?? 0 }; }), + + readLogs: protectedProcedure + .input( + apiFindOneMariaDB.extend({ + tail: z.number().int().min(1).max(10000).default(100), + since: z + .string() + .regex(/^(all|\d+[smhd])$/, "Invalid since format") + .default("all"), + search: z + .string() + .regex(/^[a-zA-Z0-9 ._-]{0,500}$/) + .optional(), + }), + ) + .query(async ({ input, ctx }) => { + await checkServiceAccess(ctx, input.mariadbId, "read"); + const mariadb = await findMariadbById(input.mariadbId); + if ( + mariadb.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this MariaDB", + }); + } + return await getContainerLogs( + mariadb.appName, + input.tail, + input.since, + input.search, + mariadb.serverId, + ); + }), }); diff --git a/apps/dokploy/server/api/routers/mongo.ts b/apps/dokploy/server/api/routers/mongo.ts index f0f5c593a..222917b9f 100644 --- a/apps/dokploy/server/api/routers/mongo.ts +++ b/apps/dokploy/server/api/routers/mongo.ts @@ -10,6 +10,7 @@ import { findMongoById, findProjectById, getAccessibleServerIds, + getContainerLogs, getServiceContainerCommand, IS_CLOUD, rebuildDatabase, @@ -601,4 +602,39 @@ export const mongoRouter = createTRPCRouter({ ]); return { items, total: countResult[0]?.count ?? 0 }; }), + + readLogs: protectedProcedure + .input( + apiFindOneMongo.extend({ + tail: z.number().int().min(1).max(10000).default(100), + since: z + .string() + .regex(/^(all|\d+[smhd])$/, "Invalid since format") + .default("all"), + search: z + .string() + .regex(/^[a-zA-Z0-9 ._-]{0,500}$/) + .optional(), + }), + ) + .query(async ({ input, ctx }) => { + await checkServiceAccess(ctx, input.mongoId, "read"); + const mongo = await findMongoById(input.mongoId); + if ( + mongo.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this MongoDB", + }); + } + return await getContainerLogs( + mongo.appName, + input.tail, + input.since, + input.search, + mongo.serverId, + ); + }), }); diff --git a/apps/dokploy/server/api/routers/mysql.ts b/apps/dokploy/server/api/routers/mysql.ts index f2a750c04..263fa53f0 100644 --- a/apps/dokploy/server/api/routers/mysql.ts +++ b/apps/dokploy/server/api/routers/mysql.ts @@ -9,6 +9,7 @@ import { findEnvironmentById, findMySqlById, findProjectById, + getContainerLogs, getServiceContainerCommand, IS_CLOUD, rebuildDatabase, @@ -604,4 +605,39 @@ export const mysqlRouter = createTRPCRouter({ ]); return { items, total: countResult[0]?.count ?? 0 }; }), + + readLogs: protectedProcedure + .input( + apiFindOneMySql.extend({ + tail: z.number().int().min(1).max(10000).default(100), + since: z + .string() + .regex(/^(all|\d+[smhd])$/, "Invalid since format") + .default("all"), + search: z + .string() + .regex(/^[a-zA-Z0-9 ._-]{0,500}$/) + .optional(), + }), + ) + .query(async ({ input, ctx }) => { + await checkServiceAccess(ctx, input.mysqlId, "read"); + const mysql = await findMySqlById(input.mysqlId); + if ( + mysql.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this MySQL", + }); + } + return await getContainerLogs( + mysql.appName, + input.tail, + input.since, + input.search, + mysql.serverId, + ); + }), }); diff --git a/apps/dokploy/server/api/routers/postgres.ts b/apps/dokploy/server/api/routers/postgres.ts index 0baeb3f0e..33d8fd3f4 100644 --- a/apps/dokploy/server/api/routers/postgres.ts +++ b/apps/dokploy/server/api/routers/postgres.ts @@ -9,6 +9,7 @@ import { findEnvironmentById, findPostgresById, findProjectById, + getContainerLogs, getMountPath, getServiceContainerCommand, IS_CLOUD, @@ -614,4 +615,39 @@ export const postgresRouter = createTRPCRouter({ ]); return { items, total: countResult[0]?.count ?? 0 }; }), + + readLogs: protectedProcedure + .input( + apiFindOnePostgres.extend({ + tail: z.number().int().min(1).max(10000).default(100), + since: z + .string() + .regex(/^(all|\d+[smhd])$/, "Invalid since format") + .default("all"), + search: z + .string() + .regex(/^[a-zA-Z0-9 ._-]{0,500}$/) + .optional(), + }), + ) + .query(async ({ input, ctx }) => { + await checkServiceAccess(ctx, input.postgresId, "read"); + const postgres = await findPostgresById(input.postgresId); + if ( + postgres.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this Postgres", + }); + } + return await getContainerLogs( + postgres.appName, + input.tail, + input.since, + input.search, + postgres.serverId, + ); + }), }); diff --git a/apps/dokploy/server/api/routers/redis.ts b/apps/dokploy/server/api/routers/redis.ts index 2d7b20674..a1e912e0b 100644 --- a/apps/dokploy/server/api/routers/redis.ts +++ b/apps/dokploy/server/api/routers/redis.ts @@ -8,6 +8,7 @@ import { findEnvironmentById, findProjectById, findRedisById, + getContainerLogs, getServiceContainerCommand, IS_CLOUD, rebuildDatabase, @@ -587,4 +588,39 @@ export const redisRouter = createTRPCRouter({ ]); return { items, total: countResult[0]?.count ?? 0 }; }), + + readLogs: protectedProcedure + .input( + apiFindOneRedis.extend({ + tail: z.number().int().min(1).max(10000).default(100), + since: z + .string() + .regex(/^(all|\d+[smhd])$/, "Invalid since format") + .default("all"), + search: z + .string() + .regex(/^[a-zA-Z0-9 ._-]{0,500}$/) + .optional(), + }), + ) + .query(async ({ input, ctx }) => { + await checkServiceAccess(ctx, input.redisId, "read"); + const redis = await findRedisById(input.redisId); + if ( + redis.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this Redis", + }); + } + return await getContainerLogs( + redis.appName, + input.tail, + input.since, + input.search, + redis.serverId, + ); + }), }); diff --git a/packages/server/src/services/ai.ts b/packages/server/src/services/ai.ts index ccfb31fc1..6e90d82d0 100644 --- a/packages/server/src/services/ai.ts +++ b/packages/server/src/services/ai.ts @@ -108,22 +108,45 @@ export const suggestVariants = async ({ ip = "127.0.0.1"; } - const suggestionsSchema = z.object({ + const fullSchema = z.object({ suggestions: z.array( z.object({ id: z.string(), name: z.string(), shortDescription: z.string(), description: z.string(), + dockerCompose: z.string(), + envVariables: z.array( + z.object({ + name: z.string(), + value: z.string(), + }), + ), + domains: z.array( + z.object({ + host: z.string(), + port: z.number(), + serviceName: z.string(), + }), + ), + configFiles: z + .array( + z.object({ + content: z.string(), + filePath: z.string(), + }), + ) + .optional(), }), ), }); - const suggestionsResult = await generateText({ + + const result = await generateText({ model, // @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation - output: Output.object({ schema: suggestionsSchema }), + output: Output.object({ schema: fullSchema }), prompt: ` - Act as advanced DevOps engineer and analyze the user's request to determine the appropriate suggestions (up to 3 items). + Act as advanced DevOps engineer. Analyze the user's request and generate up to 3 deployment suggestions, each with a complete docker compose configuration. CRITICAL - Read the user's request carefully and follow the appropriate strategy: @@ -139,163 +162,94 @@ export const suggestVariants = async ({ - Example: For "personal blog" → "WordPress", "Ghost", "Hugo with Nginx" - The name should be the actual project name - Return your response as a JSON object with the following structure: + Return your response as a JSON object with this structure: { "suggestions": [ { "id": "project-or-variant-slug", "name": "Project Name or Variant Name", "shortDescription": "Brief one-line description", - "description": "Detailed description" + "description": "Detailed description of the project/variant", + "dockerCompose": "yaml string here", + "envVariables": [{"name": "VAR_NAME", "value": "example_value"}], + "domains": [{"host": "domain.com", "port": 3000, "serviceName": "service"}], + "configFiles": [{"content": "file content", "filePath": "path/to/file"}] } ] } - Important rules for the response: + Suggestion Rules: 1. Use slug format for the id field (lowercase, hyphenated) - 2. Determine which strategy to use based on whether the user specified a particular application or described a general need - 3. For Strategy A (specific app): The name must include the app name and describe the variant configuration - 4. For Strategy B (general need): The name should be the actual project/tool name that fulfills the need - 5. The description field should ONLY contain a plain text description of the project or variant, its features, and use cases - 6. Do NOT include any code snippets, configuration examples, or installation instructions in the description - 7. The shortDescription should be a single-line summary focusing on key technologies or differentiators - 8. All suggestions should be installable in docker and have docker compose support - 9. Provide variety in your suggestions - different complexity levels, tech stacks, or approaches + 2. The description field should ONLY contain plain text — no code snippets or installation instructions + 3. The shortDescription should be a single-line summary focusing on key technologies or differentiators + 4. All suggestions should be installable in docker and have docker compose support + 5. Provide variety in your suggestions - different complexity levels, tech stacks, or approaches - User wants to create a new project with the following details: + Docker Compose Rules: + 1. Use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker-compose.yml + 2. Use complex values for passwords/secrets variables + 3. Don't set container_name field in services + 4. Don't set version field in the docker compose + 5. Don't set ports like 'ports: 3000:3000', use 'ports: "3000"' instead + 6. If a service depends on a database or other service, INCLUDE that service in the docker-compose + 7. Make sure all required services are defined in the docker-compose - ${input} + Docker Image Rules (CRITICAL): + 1. ALWAYS use 'image:' field, NEVER use 'build:' field + 2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles + 3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io) + 4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.) + 5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible + 6. Examples of correct image usage: + - image: sendingtk/chatwoot:develop + - image: postgres:16-alpine + - image: redis:7-alpine + 7. Examples of INCORRECT usage (DO NOT USE): + - build: . + - build: ./app + - build: + context: . + dockerfile: Dockerfile + + Volume Mounting and Configuration Rules: + 1. DO NOT create configuration files unless the service CANNOT work without them + 2. Most services can work with just environment variables - USE THEM FIRST + 3. If and ONLY IF a config file is absolutely required: + - Keep it minimal with only critical settings + - Use "../files/" prefix for all mounts + - Format: "../files/folder:/container/path" + 4. DO NOT add configuration files for default configs, env-configurable settings, or proxy/routing configs + + Environment Variables Rules: + 1. For the envVariables array, provide ACTUAL example values, not placeholders + 2. Use realistic example values (e.g., "admin@example.com" for emails, "mypassword123" for passwords) + 3. DO NOT use \${VARIABLE_NAME-default} syntax in the envVariables values + 4. ONLY include environment variables that are actually used in the docker-compose + 5. Every environment variable referenced in the docker-compose MUST have a corresponding entry in envVariables + + Domain Rules - For each service that needs to be exposed to the internet: + 1. Define a domain with: + - host: {service-name}-{random-3-chars-hex}-${ip ? ip.replaceAll(".", "-") : ""}.traefik.me + - port: the internal port the service runs on + - serviceName: the name of the service in the docker-compose + 2. Make sure the service is properly configured to work with the specified port + + User's request: ${input} `, }); - const object = suggestionsResult.output as SuggestionsOutput | undefined; - if (object?.suggestions?.length) { - const dockerSchema = z.object({ - dockerCompose: z.string(), - envVariables: z.array( - z.object({ - name: z.string(), - value: z.string(), - }), - ), - domains: z.array( - z.object({ - host: z.string(), - port: z.number(), - serviceName: z.string(), - }), - ), - configFiles: z - .array( - z.object({ - content: z.string(), - filePath: z.string(), - }), - ) - .optional(), + const output = result.output as + | { suggestions: (SuggestionItem & DockerOutput)[] } + | undefined; + + if (!output?.suggestions?.length) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "No suggestions found", }); - const result = []; - for (const suggestion of object.suggestions) { - try { - const dockerResult = await generateText({ - model, - // @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation - output: Output.object({ schema: dockerSchema }), - prompt: ` - Act as advanced DevOps engineer and generate docker compose with environment variables and domain configurations needed to install the following project. - - Return your response as a JSON object with this structure: - { - "dockerCompose": "yaml string here", - "envVariables": [{"name": "VAR_NAME", "value": "example_value"}], - "domains": [{"host": "domain.com", "port": 3000, "serviceName": "service"}], - "configFiles": [{"content": "file content", "filePath": "path/to/file"}] - } - - Note: configFiles is optional - only include it if configuration files are absolutely required. - - Follow these rules: - - Docker Compose Rules: - 1. Use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker-compose.yml - 2. Use complex values for passwords/secrets variables - 3. Don't set container_name field in services - 4. Don't set version field in the docker compose - 5. Don't set ports like 'ports: 3000:3000', use 'ports: "3000"' instead - 6. If a service depends on a database or other service, INCLUDE that service in the docker-compose - 7. Make sure all required services are defined in the docker-compose - - Docker Image Rules (CRITICAL): - 1. ALWAYS use 'image:' field, NEVER use 'build:' field - 2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles - 3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io) - 4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.) - 5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible - 6. Examples of correct image usage: - - image: sendingtk/chatwoot:develop - - image: postgres:16-alpine - - image: redis:7-alpine - - image: chatwoot/chatwoot:latest - 7. Examples of INCORRECT usage (DO NOT USE): - - build: . - - build: ./app - - build: - context: . - dockerfile: Dockerfile - - Volume Mounting and Configuration Rules: - 1. DO NOT create configuration files unless the service CANNOT work without them - 2. Most services can work with just environment variables - USE THEM FIRST - 3. Ask yourself: "Can this be configured with an environment variable instead?" - 4. If and ONLY IF a config file is absolutely required: - - Keep it minimal with only critical settings - - Use "../files/" prefix for all mounts - - Format: "../files/folder:/container/path" - 5. DO NOT add configuration files for: - - Default configurations that work out of the box - - Settings that can be handled by environment variables - - Proxy or routing configurations (these are handled elsewhere) - - Environment Variables Rules: - 1. For the envVariables array, provide ACTUAL example values, not placeholders - 2. Use realistic example values (e.g., "admin@example.com" for emails, "mypassword123" for passwords) - 3. DO NOT use \${VARIABLE_NAME-default} syntax in the envVariables values - 4. ONLY include environment variables that are actually used in the docker-compose - 5. Every environment variable referenced in the docker-compose MUST have a corresponding entry in envVariables - 6. Do not include environment variables for services that don't exist in the docker-compose - - For each service that needs to be exposed to the internet: - 1. Define a domain configuration with: - - host: the domain name for the service in format: {service-name}-{random-3-chars-hex}-${ip ? ip.replaceAll(".", "-") : ""}.traefik.me - - port: the internal port the service runs on - - serviceName: the name of the service in the docker-compose - 2. Make sure the service is properly configured to work with the specified port - - User's original request: ${input} - - Project details: - ${suggestion?.description} - `, - }); - const docker = dockerResult.output as DockerOutput | undefined; - if (docker?.dockerCompose) { - result.push({ - ...suggestion, - ...docker, - }); - } - } catch (error) { - console.error("Error in docker compose generation:", error); - } - } - - return result; } - throw new TRPCError({ - code: "NOT_FOUND", - message: "No suggestions found", - }); + return output.suggestions.filter((s) => s.dockerCompose); } catch (error) { console.error("Error in suggestVariants:", error); throw error; diff --git a/packages/server/src/services/docker.ts b/packages/server/src/services/docker.ts index 1205e62c2..eb218b1e1 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -354,6 +354,69 @@ export const getContainersByAppLabel = async ( return []; }; +export const getContainerLogs = async ( + appNameOrId: string, + tail = 100, + since = "all", + search?: string, + serverId?: string | null, + useContainerIdDirectly = false, +): Promise => { + const exec = (cmd: string) => + serverId ? execAsyncRemote(serverId, cmd) : execAsync(cmd); + + let target = appNameOrId; + let isService = false; + + if (!useContainerIdDirectly) { + // Find the real container ID by appName filter + const findResult = await exec( + `docker ps -q --filter "name=^${appNameOrId}" | head -1`, + ); + const containerId = findResult.stdout.trim(); + + if (!containerId) { + // Fallback: try as a swarm service + const svcResult = await exec( + `docker service ls -q --filter "name=${appNameOrId}" | head -1`, + ); + const serviceId = svcResult.stdout.trim(); + if (!serviceId) { + throw new Error(`No container or service found for: ${appNameOrId}`); + } + isService = true; + } else { + target = containerId; + } + } + + const sinceFlag = since === "all" ? "" : `--since ${since}`; + const baseCommand = isService + ? `docker service logs --timestamps --raw --tail ${tail} ${sinceFlag} ${target}` + : `docker container logs --timestamps --tail ${tail} ${sinceFlag} ${target}`; + + const escapedSearch = search?.replace(/'/g, "'\\''") ?? ""; + const command = search + ? `${baseCommand} 2>&1 | grep -iF '${escapedSearch}'` + : `${baseCommand} 2>&1`; + + try { + const result = await exec(command); + return result.stdout; + } catch (error: unknown) { + if ( + error && + typeof error === "object" && + "stdout" in error && + typeof (error as { stdout: string }).stdout === "string" && + (error as { stdout: string }).stdout.length > 0 + ) { + return (error as { stdout: string }).stdout; + } + throw error; + } +}; + export const containerRestart = async (containerId: string) => { try { const { stdout, stderr } = await execAsync( diff --git a/packages/server/src/utils/ai/select-ai-provider.ts b/packages/server/src/utils/ai/select-ai-provider.ts index b6477c573..52d7c6224 100644 --- a/packages/server/src/utils/ai/select-ai-provider.ts +++ b/packages/server/src/utils/ai/select-ai-provider.ts @@ -17,6 +17,9 @@ export function getProviderName(apiUrl: string) { if (apiUrl.includes(":11434") || apiUrl.includes("ollama")) return "ollama"; if (apiUrl.includes("api.deepinfra.com")) return "deepinfra"; if (apiUrl.includes("generativelanguage.googleapis.com")) return "gemini"; + if (apiUrl.includes("openrouter.ai")) return "openrouter"; + if (apiUrl.includes("api.z.ai")) return "zai"; + if (apiUrl.includes("api.minimax.io")) return "minimax"; return "custom"; } @@ -87,6 +90,30 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) { Authorization: `Bearer ${config.apiKey}`, }, }); + case "openrouter": + return createOpenAICompatible({ + name: "openrouter", + baseURL: config.apiUrl, + headers: { + Authorization: `Bearer ${config.apiKey}`, + }, + }); + case "zai": + return createOpenAICompatible({ + name: "zai", + baseURL: config.apiUrl, + headers: { + Authorization: `Bearer ${config.apiKey}`, + }, + }); + case "minimax": + return createOpenAICompatible({ + name: "minimax", + baseURL: config.apiUrl, + headers: { + Authorization: `Bearer ${config.apiKey}`, + }, + }); case "custom": return createOpenAICompatible({ name: "custom",