+
@@ -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",