From 08ab18eebf65fd4561b7b9446589728400db49ce Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Sat, 18 Jan 2025 21:35:03 -0600 Subject: [PATCH] refactor: add many AI providers & improve prompt --- .../dashboard/project/ai/step-four.tsx | 43 ++- .../dashboard/project/ai/step-three.tsx | 25 +- .../dashboard/project/ai/step-two.tsx | 255 ++++++++----- .../project/ai/template-generator.tsx | 154 ++++++-- .../components/dashboard/settings/ai-form.tsx | 336 +++++------------- .../dashboard/settings/handle-ai.tsx | 305 ++++++++++++++++ .../settings/ssh-keys/handle-ssh-keys.tsx | 1 - apps/dokploy/components/layouts/side.tsx | 9 +- apps/dokploy/drizzle/0057_damp_prism.sql | 16 + .../drizzle/0057_mature_thaddeus_ross.sql | 7 - apps/dokploy/drizzle/meta/0057_snapshot.json | 40 ++- apps/dokploy/drizzle/meta/_journal.json | 4 +- apps/dokploy/pages/dashboard/settings/ai.tsx | 7 +- apps/dokploy/server/api/routers/ai.ts | 154 +++++--- packages/server/src/db/schema/admin.ts | 152 ++++---- packages/server/src/db/schema/ai.ts | 77 ++-- packages/server/src/services/ai.ts | 240 ++++++++----- 17 files changed, 1158 insertions(+), 667 deletions(-) create mode 100644 apps/dokploy/components/dashboard/settings/handle-ai.tsx create mode 100644 apps/dokploy/drizzle/0057_damp_prism.sql delete mode 100644 apps/dokploy/drizzle/0057_mature_thaddeus_ross.sql diff --git a/apps/dokploy/components/dashboard/project/ai/step-four.tsx b/apps/dokploy/components/dashboard/project/ai/step-four.tsx index 2c43a6175..18ec238a8 100644 --- a/apps/dokploy/components/dashboard/project/ai/step-four.tsx +++ b/apps/dokploy/components/dashboard/project/ai/step-four.tsx @@ -2,13 +2,14 @@ import { CodeEditor } from "@/components/shared/code-editor"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import ReactMarkdown from "react-markdown"; +import type { StepProps } from "./step-two"; export const StepFour = ({ prevStep, templateInfo, setOpen, setTemplateInfo, -}: any) => { +}: StepProps) => { const handleSubmit = () => { setTemplateInfo(templateInfo); // Update the template info setOpen(false); @@ -17,36 +18,41 @@ export const StepFour = ({ return (
-
+

Step 4: Review and Finalize

-
- - {templateInfo.details.description} +
+

Name

+

+ {templateInfo?.details?.name} +

+
+
+

Description

+ + {templateInfo?.details?.description}
-
-

Name

-

{templateInfo.name}

-

Server

-

{templateInfo.server || "localhost"}

+

+ {templateInfo?.serverId || "Dokploy Server"} +

-

Docker Compose

+

Docker Compose

-

Environment Variables

+

Environment Variables

    - {templateInfo.details.envVariables.map( + {templateInfo?.details?.envVariables.map( ( env: { name: string; @@ -55,8 +61,13 @@ export const StepFour = ({ index: number, ) => (
  • - {env.name}: - {env.value} + + {env.name} + + : + + {env.value} +
  • ), )} diff --git a/apps/dokploy/components/dashboard/project/ai/step-three.tsx b/apps/dokploy/components/dashboard/project/ai/step-three.tsx index 246a0dc34..b41c98caa 100644 --- a/apps/dokploy/components/dashboard/project/ai/step-three.tsx +++ b/apps/dokploy/components/dashboard/project/ai/step-three.tsx @@ -14,21 +14,21 @@ import { } from "@/components/ui/select"; import { api } from "@/utils/api"; import { useState } from "react"; +import type { StepProps } from "./step-two"; export const StepThree = ({ nextStep, prevStep, templateInfo, setTemplateInfo, -}: any) => { - const [name, setName] = useState(templateInfo.name); - const [server, setServer] = useState(templateInfo.server); +}: StepProps) => { + const [server, setServer] = useState(templateInfo.serverId); const { data: servers } = api.server.withSSHKey.useQuery(); const handleNext = () => { const updatedInfo = { ...templateInfo, name }; if (server?.trim()) { - updatedInfo.server = server; + updatedInfo.serverId = server; } setTemplateInfo(updatedInfo); nextStep(); @@ -43,8 +43,16 @@ export const StepThree = ({ setName(e.target.value)} + value={templateInfo?.details?.name || ""} + onChange={(e) => { + setTemplateInfo({ + ...templateInfo, + details: { + ...templateInfo?.details, + name: e.target.value, + }, + }); + }} placeholder="Enter app name" className="mt-1" /> @@ -74,7 +82,10 @@ export const StepThree = ({ -
diff --git a/apps/dokploy/components/dashboard/project/ai/step-two.tsx b/apps/dokploy/components/dashboard/project/ai/step-two.tsx index be7352d29..ded7ba652 100644 --- a/apps/dokploy/components/dashboard/project/ai/step-two.tsx +++ b/apps/dokploy/components/dashboard/project/ai/step-two.tsx @@ -15,19 +15,13 @@ import { Bot, Eye, EyeOff, PlusCircle, Trash2 } from "lucide-react"; import { useEffect, useState } from "react"; import ReactMarkdown from "react-markdown"; import { toast } from "sonner"; +import type { TemplateInfo } from "./template-generator"; -interface EnvVariable { - name: string; - value: string; -} - -interface TemplateInfo { - id: string; - name: string; - shortDescription: string; - description: string; - dockerCompose: string; - envVariables: EnvVariable[]; +export interface StepProps { + nextStep: () => void; + prevStep: () => void; + templateInfo: TemplateInfo; + setTemplateInfo: React.Dispatch>; } export const StepTwo = ({ @@ -35,56 +29,34 @@ export const StepTwo = ({ prevStep, templateInfo, setTemplateInfo, -}: any) => { - const [suggestions, setSuggestions] = useState>([]); - const [selectedVariant, setSelectedVariant] = useState(""); - const [dockerCompose, setDockerCompose] = useState(""); - const [envVariables, setEnvVariables] = useState>([]); +}: StepProps) => { + const [suggestions, setSuggestions] = useState(); + const [selectedVariant, setSelectedVariant] = + useState(); const [showValues, setShowValues] = useState>({}); const { mutateAsync, isLoading } = api.ai.suggest.useMutation(); useEffect(() => { - mutateAsync(templateInfo.userInput) + mutateAsync({ + aiId: templateInfo.aiId, + prompt: templateInfo.userInput, + }) .then((data) => { + console.log(data); setSuggestions(data); }) - .catch(() => { - toast.error("Error updating AI settings"); + .catch((error) => { + console.error("Error details:", error); + toast.error("Error generating suggestions"); }); }, [templateInfo.userInput]); - useEffect(() => { - if (selectedVariant) { - const selected = suggestions.find( - (s: { id: string }) => s.id === selectedVariant, - ); - if (selected) { - setDockerCompose(selected.dockerCompose); - setEnvVariables(selected.envVariables); - setShowValues( - selected.envVariables.reduce((acc: Record, env) => { - acc[env.name] = false; - return acc; - }, {}), - ); - } - } - }, [selectedVariant, suggestions]); - const handleNext = () => { - const selected = suggestions.find( - (s: { id: string }) => s.id === selectedVariant, - ); - if (selected) { + if (selectedVariant) { setTemplateInfo({ ...templateInfo, - type: selectedVariant, - details: { - ...selected, - dockerCompose, - envVariables, - }, + details: selectedVariant, }); } nextStep(); @@ -95,25 +67,49 @@ export const StepTwo = ({ field: string, value: string, ) => { - const updatedEnvVariables = [...envVariables]; - if (updatedEnvVariables[index]) { - updatedEnvVariables[index] = { - ...updatedEnvVariables[index], - [field]: value, - }; - setEnvVariables(updatedEnvVariables); - } + // const updatedEnvVariables = [...envVariables]; + // if (updatedEnvVariables[index]) { + // updatedEnvVariables[index] = { + // ...updatedEnvVariables[index], + // [field]: value, + // }; + // setEnvVariables(updatedEnvVariables); + // } }; - const addEnvVariable = () => { - setEnvVariables([...envVariables, { name: "", value: "" }]); - setShowValues((prev) => ({ ...prev, "": false })); - }; + // const addEnvVariable = () => { + // setEnvVariables([...envVariables, { name: "", value: "" }]); + // setShowValues((prev) => ({ ...prev, "": false })); + // }; - const removeEnvVariable = (index: number) => { - const updatedEnvVariables = envVariables.filter((_, i) => i !== index); - setEnvVariables(updatedEnvVariables); - }; + // const removeEnvVariable = (index: number) => { + // const updatedEnvVariables = envVariables.filter((_, i) => i !== index); + // setEnvVariables(updatedEnvVariables); + // }; + + // const handleDomainChange = ( + // index: number, + // field: string, + // value: string | number, + // ) => { + // const updatedDomains = [...domains]; + // if (updatedDomains[index]) { + // updatedDomains[index] = { + // ...updatedDomains[index], + // [field]: value, + // }; + // setDomains(updatedDomains); + // } + // }; + + // const addDomain = () => { + // setDomains([...domains, { host: "", port: 0, serviceName: "" }]); + // }; + + // const removeDomain = (index: number) => { + // const updatedDomains = domains.filter((_, i) => i !== index); + // setDomains(updatedDomains); + // }; const toggleShowValue = (name: string) => { setShowValues((prev) => ({ ...prev, [name]: !prev[name] })); @@ -134,39 +130,38 @@ export const StepTwo = ({ ); } - const selectedTemplate = suggestions.find( - (s: { id: string }) => s.id === selectedVariant, - ); - return (
-
-
+
+

Step 2: Choose a Variant

{!selectedVariant && (
Based on your input, we suggest the following variants:
{ + const element = suggestions?.find((s) => s?.id === value); + setSelectedVariant(element); + }} className="space-y-4" > - {suggestions.map((suggestion) => ( + {suggestions?.map((suggestion) => (
-
@@ -177,22 +172,20 @@ export const StepTwo = ({ {selectedVariant && ( <>
-

{selectedTemplate?.name}

+

{selectedVariant?.name}

- {selectedTemplate?.shortDescription} + {selectedVariant?.shortDescription}

- + Description - -
- - {selectedTemplate?.description} - -
+ + + {selectedVariant?.description} +
@@ -200,9 +193,14 @@ export const StepTwo = ({ Docker Compose setDockerCompose(value)} + onChange={(value) => { + setSelectedVariant({ + ...selectedVariant, + dockerCompose: value, + }); + }} /> @@ -211,7 +209,7 @@ export const StepTwo = ({
- {envVariables.map((env, index) => ( + {selectedVariant?.envVariables.map((env, index) => (
Add Variable @@ -281,17 +279,88 @@ export const StepTwo = ({ + + Domains + + +
+ {selectedVariant?.domains.map((domain, index) => ( +
+ + handleDomainChange( + index, + "host", + e.target.value, + ) + } + placeholder="Domain Host" + className="flex-1" + /> + + handleDomainChange( + index, + "port", + parseInt(e.target.value), + ) + } + placeholder="Port" + className="w-24" + /> + + handleDomainChange( + index, + "serviceName", + e.target.value, + ) + } + placeholder="Service Name" + className="flex-1" + /> + +
+ ))} + +
+
+
+
)}
-
+
+ +
+
+
+ ))}
- )} - {error && ( - - - Error - {error} - )} )} - - - - - + +
+ +
); }; diff --git a/apps/dokploy/components/dashboard/settings/handle-ai.tsx b/apps/dokploy/components/dashboard/settings/handle-ai.tsx new file mode 100644 index 000000000..9c0501166 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/handle-ai.tsx @@ -0,0 +1,305 @@ +"use client"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { PenBoxIcon, PlusIcon } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { useEffect, useState } from "react"; +import { api } from "@/utils/api"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; + +const Schema = z.object({ + name: z.string().min(1, { message: "Name is required" }), + apiUrl: z.string().url({ message: "Please enter a valid URL" }), + apiKey: z.string().min(1, { message: "API Key is required" }), + model: z.string().min(1, { message: "Model is required" }), + isEnabled: z.boolean(), +}); + +type Schema = z.infer; + +interface Model { + id: string; + object: string; + created: number; + owned_by: string; +} + +interface Props { + aiId?: string; +} + +export const HandleAi = ({ aiId }: Props) => { + const [models, setModels] = useState([]); + const utils = api.useUtils(); + const [isLoadingModels, setIsLoadingModels] = useState(false); + const [error, setError] = useState(null); + const [open, setOpen] = useState(false); + const { data, refetch } = api.ai.one.useQuery( + { + aiId: aiId || "", + }, + { + enabled: !!aiId, + }, + ); + const { mutateAsync, isLoading } = aiId + ? api.ai.update.useMutation() + : api.ai.create.useMutation(); + const form = useForm({ + resolver: zodResolver(Schema), + defaultValues: { + name: "", + apiUrl: "", + apiKey: "", + model: "gpt-3.5-turbo", + isEnabled: true, + }, + }); + + useEffect(() => { + form.reset({ + name: data?.name ?? "", + apiUrl: data?.apiUrl ?? "https://api.openai.com/v1", + apiKey: data?.apiKey ?? "", + model: data?.model ?? "gpt-3.5-turbo", + isEnabled: data?.isEnabled ?? true, + }); + }, [aiId, form, data]); + + const fetchModels = async (apiUrl: string, apiKey: string) => { + setIsLoadingModels(true); + setError(null); + try { + const response = await fetch(`${apiUrl}/models`, { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + if (!response.ok) { + throw new Error("Failed to fetch models"); + } + const res = await response.json(); + setModels(res.data); + + // Set default model to gpt-4 if present + const defaultModel = res.data.find( + (model: Model) => model.id === "gpt-4", + ); + if (defaultModel) { + form.setValue("model", defaultModel.id); + return defaultModel.id; + } + } catch (error) { + setError("Failed to fetch models. Please check your API URL and Key."); + setModels([]); + } finally { + setIsLoadingModels(false); + } + }; + + useEffect(() => { + const apiUrl = form.watch("apiUrl"); + const apiKey = form.watch("apiKey"); + if (apiUrl && apiKey) { + form.setValue("model", ""); + fetchModels(apiUrl, apiKey); + } + }, [form.watch("apiUrl"), form.watch("apiKey")]); + + const onSubmit = async (data: Schema) => { + try { + console.log("Form data:", data); + console.log("Current model value:", form.getValues("model")); + await mutateAsync({ + ...data, + aiId: aiId || "", + }); + + utils.ai.getAll.invalidate(); + toast.success("AI settings saved successfully"); + refetch(); + setOpen(false); + } catch (error) { + console.error("Submit error:", error); + toast.error("Failed to save AI settings"); + } + }; + + return ( + + + {aiId ? ( + + ) : ( + + )} + + + + {aiId ? "Edit AI" : "Add AI"} + + Configure your AI provider settings + + +
+ {error && {error}} + + ( + + Name + + + + + A name to identify this configuration + + + + )} + /> + + ( + + API URL + + + + + The base URL for your AI provider's API + + + + )} + /> + + ( + + API Key + + + + + Your API key for authentication + + + + )} + /> + + {isLoadingModels && ( + + Loading models... + + )} + + {!isLoadingModels && models.length > 0 && ( + ( + + Model + + Select an AI model to use + + + )} + /> + )} + + ( + +
+ + Enable AI Features + + + Turn on/off AI functionality + +
+ + + +
+ )} + /> + +
+ +
+ + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/ssh-keys/handle-ssh-keys.tsx b/apps/dokploy/components/dashboard/settings/ssh-keys/handle-ssh-keys.tsx index f100979e3..04c471aaa 100644 --- a/apps/dokploy/components/dashboard/settings/ssh-keys/handle-ssh-keys.tsx +++ b/apps/dokploy/components/dashboard/settings/ssh-keys/handle-ssh-keys.tsx @@ -128,7 +128,6 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => { Add SSH Key )} - {/* {children} */} diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 7e2754309..c751e8ceb 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -19,6 +19,7 @@ import { GitBranch, HeartIcon, KeyRound, + BotIcon, type LucideIcon, Package, PieChart, @@ -249,7 +250,13 @@ const data = { isSingle: true, isActive: false, }, - + { + title: "AI", + icon: BotIcon, + url: "/dashboard/settings/ai", + isSingle: true, + isActive: false, + }, { title: "Git", url: "/dashboard/settings/git-providers", diff --git a/apps/dokploy/drizzle/0057_damp_prism.sql b/apps/dokploy/drizzle/0057_damp_prism.sql new file mode 100644 index 000000000..363c2a9f4 --- /dev/null +++ b/apps/dokploy/drizzle/0057_damp_prism.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS "ai" ( + "aiId" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "apiUrl" text NOT NULL, + "apiKey" text NOT NULL, + "model" text NOT NULL, + "isEnabled" boolean DEFAULT true NOT NULL, + "adminId" text NOT NULL, + "createdAt" text NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "ai" ADD CONSTRAINT "ai_adminId_admin_adminId_fk" FOREIGN KEY ("adminId") REFERENCES "public"."admin"("adminId") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/apps/dokploy/drizzle/0057_mature_thaddeus_ross.sql b/apps/dokploy/drizzle/0057_mature_thaddeus_ross.sql deleted file mode 100644 index 1054dc600..000000000 --- a/apps/dokploy/drizzle/0057_mature_thaddeus_ross.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE IF NOT EXISTS "ai" ( - "authId" text PRIMARY KEY NOT NULL, - "apiUrl" text NOT NULL, - "apiKey" text NOT NULL, - "model" text NOT NULL, - "isEnabled" boolean DEFAULT true NOT NULL -); diff --git a/apps/dokploy/drizzle/meta/0057_snapshot.json b/apps/dokploy/drizzle/meta/0057_snapshot.json index da1f90e67..8ddfc3b7d 100644 --- a/apps/dokploy/drizzle/meta/0057_snapshot.json +++ b/apps/dokploy/drizzle/meta/0057_snapshot.json @@ -1,5 +1,5 @@ { - "id": "03ce8887-3824-49b9-ad47-12c25aa4c090", + "id": "841960d7-0573-41e4-8529-fd9960f726d5", "prevId": "24787a88-0754-437a-b077-03a3265b8ef5", "version": "6", "dialect": "postgresql", @@ -4144,12 +4144,18 @@ "name": "ai", "schema": "", "columns": { - "authId": { - "name": "authId", + "aiId": { + "name": "aiId", "type": "text", "primaryKey": true, "notNull": true }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, "apiUrl": { "name": "apiUrl", "type": "text", @@ -4174,10 +4180,36 @@ "primaryKey": false, "notNull": true, "default": true + }, + "adminId": { + "name": "adminId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true } }, "indexes": {}, - "foreignKeys": {}, + "foreignKeys": { + "ai_adminId_admin_adminId_fk": { + "name": "ai_adminId_admin_adminId_fk", + "tableFrom": "ai", + "tableTo": "admin", + "columnsFrom": [ + "adminId" + ], + "columnsTo": [ + "adminId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, "compositePrimaryKeys": {}, "uniqueConstraints": {} } diff --git a/apps/dokploy/drizzle/meta/_journal.json b/apps/dokploy/drizzle/meta/_journal.json index 5e48d9d7f..7943d6939 100644 --- a/apps/dokploy/drizzle/meta/_journal.json +++ b/apps/dokploy/drizzle/meta/_journal.json @@ -404,8 +404,8 @@ { "idx": 57, "version": "6", - "when": 1737246538368, - "tag": "0057_mature_thaddeus_ross", + "when": 1737251708859, + "tag": "0057_damp_prism", "breakpoints": true } ] diff --git a/apps/dokploy/pages/dashboard/settings/ai.tsx b/apps/dokploy/pages/dashboard/settings/ai.tsx index b4bbbb894..b41dc8945 100644 --- a/apps/dokploy/pages/dashboard/settings/ai.tsx +++ b/apps/dokploy/pages/dashboard/settings/ai.tsx @@ -1,6 +1,5 @@ import { AiForm } from "@/components/dashboard/settings/ai-form"; import { DashboardLayout } from "@/components/layouts/dashboard-layout"; -import { SettingsLayout } from "@/components/layouts/settings-layout"; import { appRouter } from "@/server/api/root"; import { getLocale, serverSideTranslations } from "@/utils/i18n"; import { validateRequest } from "@dokploy/server"; @@ -20,11 +19,7 @@ const Page = () => { export default Page; Page.getLayout = (page: ReactElement) => { - return ( - - {page} - - ); + return {page}; }; export async function getServerSideProps( ctx: GetServerSidePropsContext<{ serviceId: string }>, diff --git a/apps/dokploy/server/api/routers/ai.ts b/apps/dokploy/server/api/routers/ai.ts index 190e70cb4..74931e973 100644 --- a/apps/dokploy/server/api/routers/ai.ts +++ b/apps/dokploy/server/api/routers/ai.ts @@ -1,71 +1,127 @@ import { slugify } from "@/lib/slug"; -import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { + adminProcedure, + createTRPCRouter, + protectedProcedure, +} from "@/server/api/trpc"; import { generatePassword } from "@/templates/utils"; import { IS_CLOUD } from "@dokploy/server/constants"; import { - apiAiSettingsSchema, - deploySuggestionSchema, + apiCreateAi, + apiUpdateAi, + deploySuggestionSchema, } from "@dokploy/server/db/schema/ai"; import { - getAiSettingsByAuthId, - saveAiSettings, - suggestVariants, + getAiSettingsByAdminId, + getAiSettingById, + saveAiSettings, + deleteAiSettings, + suggestVariants, } from "@dokploy/server/services/ai"; import { createComposeByTemplate } from "@dokploy/server/services/compose"; import { findProjectById } from "@dokploy/server/services/project"; import { - addNewService, - checkServiceAccess, + addNewService, + checkServiceAccess, } from "@dokploy/server/services/user"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; export const aiRouter = createTRPCRouter({ - save: protectedProcedure - .input(apiAiSettingsSchema) - .mutation(async ({ ctx, input }) => { - return await saveAiSettings(ctx.user.authId, input); - }), - get: protectedProcedure.query(async ({ ctx }) => { - return await getAiSettingsByAuthId(ctx.user.authId); - }), - suggest: protectedProcedure - .input(z.string()) - .mutation(async ({ ctx, input }) => { - return await suggestVariants(ctx.user.authId, input); - }), - deploy: protectedProcedure - .input(deploySuggestionSchema) - .mutation(async ({ ctx, input }) => { - if (ctx.user.rol === "user") { - await checkServiceAccess(ctx.user.authId, input.projectId, "create"); - } + one: protectedProcedure + .input(z.object({ aiId: z.string() })) + .query(async ({ ctx, input }) => { + const aiSetting = await getAiSettingById(input.aiId); + if (aiSetting.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to this AI configuration", + }); + } + return aiSetting; + }), + create: adminProcedure.input(apiCreateAi).mutation(async ({ ctx, input }) => { + return await saveAiSettings(ctx.user.adminId, input); + }), - if (IS_CLOUD && !input.serverId) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You need to use a server to create a compose", - }); - } + update: protectedProcedure + .input(apiUpdateAi) + .mutation(async ({ ctx, input }) => { + return await saveAiSettings(ctx.user.adminId, input); + }), - const project = await findProjectById(input.projectId); + getAll: adminProcedure.query(async ({ ctx }) => { + return await getAiSettingsByAdminId(ctx.user.adminId); + }), - const projectName = slugify(`${project.name} ${input.id}`); + get: protectedProcedure + .input(z.object({ aiId: z.string() })) + .query(async ({ ctx, input }) => { + const aiSetting = await getAiSettingById(input.aiId); + if (aiSetting.adminId !== ctx.user.authId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to this AI configuration", + }); + } + return aiSetting; + }), - const compose = await createComposeByTemplate({ - ...input, - composeFile: input.dockerCompose, - env: input.envVariables, - serverId: input.serverId, - name: input.name, - sourceType: "raw", - appName: `${projectName}-${generatePassword(6)}`, - }); + delete: protectedProcedure + .input(z.object({ aiId: z.string() })) + .mutation(async ({ ctx, input }) => { + const aiSetting = await getAiSettingById(input.aiId); + if (aiSetting.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to this AI configuration", + }); + } + return await deleteAiSettings(input.aiId); + }), - if (ctx.user.rol === "user") { - await addNewService(ctx.user.authId, compose.composeId); - } + suggest: protectedProcedure + .input( + z.object({ + aiId: z.string(), + prompt: z.string(), + }) + ) + .mutation(async ({ ctx, input }) => { + return await suggestVariants(ctx.user.adminId, input.aiId, input.prompt); + }), + deploy: protectedProcedure + .input(deploySuggestionSchema) + .mutation(async ({ ctx, input }) => { + if (ctx.user.rol === "user") { + await checkServiceAccess(ctx.user.adminId, input.projectId, "create"); + } - return null; - }), + if (IS_CLOUD && !input.serverId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You need to use a server to create a compose", + }); + } + + const project = await findProjectById(input.projectId); + + const projectName = slugify(`${project.name} ${input.id}`); + + const compose = await createComposeByTemplate({ + ...input, + composeFile: input.dockerCompose, + env: input.envVariables, + serverId: input.serverId, + name: input.name, + sourceType: "raw", + appName: `${projectName}-${generatePassword(6)}`, + }); + + if (ctx.user.rol === "user") { + await addNewService(ctx.user.authId, compose.composeId); + } + + return null; + }), }); diff --git a/packages/server/src/db/schema/admin.ts b/packages/server/src/db/schema/admin.ts index 222fb16c8..1f49c843c 100644 --- a/packages/server/src/db/schema/admin.ts +++ b/packages/server/src/db/schema/admin.ts @@ -9,111 +9,113 @@ import { registry } from "./registry"; import { certificateType } from "./shared"; import { sshKeys } from "./ssh-key"; import { users } from "./user"; +import { ai } from "./ai"; export const admins = pgTable("admin", { - adminId: text("adminId") - .notNull() - .primaryKey() - .$defaultFn(() => nanoid()), - serverIp: text("serverIp"), - certificateType: certificateType("certificateType").notNull().default("none"), - host: text("host"), - letsEncryptEmail: text("letsEncryptEmail"), - sshPrivateKey: text("sshPrivateKey"), - enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false), - enableLogRotation: boolean("enableLogRotation").notNull().default(false), - authId: text("authId") - .notNull() - .references(() => auth.id, { onDelete: "cascade" }), - createdAt: text("createdAt") - .notNull() - .$defaultFn(() => new Date().toISOString()), - stripeCustomerId: text("stripeCustomerId"), - stripeSubscriptionId: text("stripeSubscriptionId"), - serversQuantity: integer("serversQuantity").notNull().default(0), + adminId: text("adminId") + .notNull() + .primaryKey() + .$defaultFn(() => nanoid()), + serverIp: text("serverIp"), + certificateType: certificateType("certificateType").notNull().default("none"), + host: text("host"), + letsEncryptEmail: text("letsEncryptEmail"), + sshPrivateKey: text("sshPrivateKey"), + enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false), + enableLogRotation: boolean("enableLogRotation").notNull().default(false), + authId: text("authId") + .notNull() + .references(() => auth.id, { onDelete: "cascade" }), + createdAt: text("createdAt") + .notNull() + .$defaultFn(() => new Date().toISOString()), + stripeCustomerId: text("stripeCustomerId"), + stripeSubscriptionId: text("stripeSubscriptionId"), + serversQuantity: integer("serversQuantity").notNull().default(0), }); export const adminsRelations = relations(admins, ({ one, many }) => ({ - auth: one(auth, { - fields: [admins.authId], - references: [auth.id], - }), - users: many(users), - registry: many(registry), - sshKeys: many(sshKeys), - certificates: many(certificates), + auth: one(auth, { + fields: [admins.authId], + references: [auth.id], + }), + users: many(users), + registry: many(registry), + sshKeys: many(sshKeys), + certificates: many(certificates), + ai: many(ai), })); const createSchema = createInsertSchema(admins, { - adminId: z.string(), - enableDockerCleanup: z.boolean().optional(), - sshPrivateKey: z.string().optional(), - certificateType: z.enum(["letsencrypt", "none"]).default("none"), - serverIp: z.string().optional(), - letsEncryptEmail: z.string().optional(), + adminId: z.string(), + enableDockerCleanup: z.boolean().optional(), + sshPrivateKey: z.string().optional(), + certificateType: z.enum(["letsencrypt", "none"]).default("none"), + serverIp: z.string().optional(), + letsEncryptEmail: z.string().optional(), }); export const apiUpdateAdmin = createSchema.partial(); export const apiSaveSSHKey = createSchema - .pick({ - sshPrivateKey: true, - }) - .required(); + .pick({ + sshPrivateKey: true, + }) + .required(); export const apiAssignDomain = createSchema - .pick({ - host: true, - certificateType: true, - letsEncryptEmail: true, - }) - .required() - .partial({ - letsEncryptEmail: true, - }); + .pick({ + host: true, + certificateType: true, + letsEncryptEmail: true, + }) + .required() + .partial({ + letsEncryptEmail: true, + }); export const apiUpdateDockerCleanup = createSchema - .pick({ - enableDockerCleanup: true, - }) - .required() - .extend({ - serverId: z.string().optional(), - }); + .pick({ + enableDockerCleanup: true, + }) + .required() + .extend({ + serverId: z.string().optional(), + }); export const apiTraefikConfig = z.object({ - traefikConfig: z.string().min(1), + traefikConfig: z.string().min(1), }); export const apiModifyTraefikConfig = z.object({ - path: z.string().min(1), - traefikConfig: z.string().min(1), - serverId: z.string().optional(), + path: z.string().min(1), + traefikConfig: z.string().min(1), + serverId: z.string().optional(), }); export const apiReadTraefikConfig = z.object({ - path: z.string().min(1), - serverId: z.string().optional(), + path: z.string().min(1), + serverId: z.string().optional(), }); export const apiEnableDashboard = z.object({ - enableDashboard: z.boolean().optional(), - serverId: z.string().optional(), + enableDashboard: z.boolean().optional(), + serverId: z.string().optional(), }); export const apiServerSchema = z - .object({ - serverId: z.string().optional(), - }) - .optional(); + .object({ + serverId: z.string().optional(), + }) + .optional(); export const apiReadStatsLogs = z.object({ - page: z - .object({ - pageIndex: z.number(), - pageSize: z.number(), - }) - .optional(), - status: z.string().array().optional(), - search: z.string().optional(), - sort: z.object({ id: z.string(), desc: z.boolean() }).optional(), + page: z + .object({ + pageIndex: z.number(), + pageSize: z.number(), + }) + .optional(), + status: z.string().array().optional(), + search: z.string().optional(), + sort: z.object({ id: z.string(), desc: z.boolean() }).optional(), }); diff --git a/packages/server/src/db/schema/ai.ts b/packages/server/src/db/schema/ai.ts index a3704662b..a13e4128e 100644 --- a/packages/server/src/db/schema/ai.ts +++ b/packages/server/src/db/schema/ai.ts @@ -1,37 +1,66 @@ import { boolean, pgTable, text } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { z } from "zod"; +import { admins } from "./admin"; +import { relations } from "drizzle-orm"; +import { nanoid } from "nanoid"; export const ai = pgTable("ai", { - authId: text("authId").notNull().primaryKey(), - apiUrl: text("apiUrl").notNull(), - apiKey: text("apiKey").notNull(), - model: text("model").notNull(), - isEnabled: boolean("isEnabled").notNull().default(true), + aiId: text("aiId") + .notNull() + .primaryKey() + .$defaultFn(() => nanoid()), + name: text("name").notNull(), + apiUrl: text("apiUrl").notNull(), + apiKey: text("apiKey").notNull(), + model: text("model").notNull(), + isEnabled: boolean("isEnabled").notNull().default(true), + adminId: text("adminId") + .notNull() + .references(() => admins.adminId, { onDelete: "cascade" }), // Admin ID who created the AI settings + createdAt: text("createdAt") + .notNull() + .$defaultFn(() => new Date().toISOString()), }); +export const aiRelations = relations(ai, ({ one }) => ({ + admin: one(admins, { + fields: [ai.adminId], + references: [admins.adminId], + }), +})); + const createSchema = createInsertSchema(ai, { - apiUrl: z.string().url({ message: "Please enter a valid URL" }), - apiKey: z.string().min(1, { message: "API Key is required" }), - model: z.string().min(1, { message: "Model is required" }), - isEnabled: z.boolean().optional(), + name: z.string().min(1, { message: "Name is required" }), + apiUrl: z.string().url({ message: "Please enter a valid URL" }), + apiKey: z.string().min(1, { message: "API Key is required" }), + model: z.string().min(1, { message: "Model is required" }), + isEnabled: z.boolean().optional(), }); -export const apiAiSettingsSchema = createSchema - .pick({ - apiUrl: true, - apiKey: true, - model: true, - isEnabled: true, - }) - .required(); +export const apiCreateAi = createSchema + .pick({ + name: true, + apiUrl: true, + apiKey: true, + model: true, + isEnabled: true, + }) + .required(); + +export const apiUpdateAi = createSchema + .partial() + .extend({ + aiId: z.string().min(1), + }) + .omit({ adminId: true }); export const deploySuggestionSchema = z.object({ - projectId: z.string().min(1), - id: z.string().min(1), - dockerCompose: z.string().min(1), - envVariables: z.string(), - serverId: z.string().optional(), - name: z.string().min(1), - description: z.string(), + projectId: z.string().min(1), + id: z.string().min(1), + dockerCompose: z.string().min(1), + envVariables: z.string(), + serverId: z.string().optional(), + name: z.string().min(1), + description: z.string(), }); diff --git a/packages/server/src/services/ai.ts b/packages/server/src/services/ai.ts index 94d7a5bd0..d835b9c57 100644 --- a/packages/server/src/services/ai.ts +++ b/packages/server/src/services/ai.ts @@ -3,102 +3,160 @@ import { ai } from "@dokploy/server/db/schema"; import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider"; import { TRPCError } from "@trpc/server"; import { generateObject } from "ai"; -import { eq } from "drizzle-orm"; +import { desc, eq } from "drizzle-orm"; import { z } from "zod"; +import { IS_CLOUD } from "../constants"; +import { findAdminById } from "./admin"; -export const getAiSettingsByAuthId = async (authId: string) => { - const aiSettings = await db.query.ai.findFirst({ - where: eq(ai.authId, authId), - }); - if (!aiSettings) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "AI settings not found for the user", - }); - } - return aiSettings; +export const getAiSettingsByAdminId = async (adminId: string) => { + const aiSettings = await db.query.ai.findMany({ + where: eq(ai.adminId, adminId), + orderBy: desc(ai.createdAt), + }); + return aiSettings; }; -export const saveAiSettings = async (authId: string, settings: any) => { - return db - .insert(ai) - .values({ - authId, - ...settings, - }) - .onConflictDoUpdate({ - target: ai.authId, - set: { - ...settings, - }, - }); +export const getAiSettingById = async (aiId: string) => { + const aiSetting = await db.query.ai.findFirst({ + where: eq(ai.aiId, aiId), + }); + if (!aiSetting) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "AI settings not found", + }); + } + return aiSetting; }; -export const suggestVariants = async (authId: string, input: string) => { - const aiSettings = await getAiSettingsByAuthId(authId); - if (!aiSettings || !aiSettings.isEnabled) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "AI features are not enabled", - }); - } +export const saveAiSettings = async (adminId: string, settings: any) => { + const aiId = settings.aiId; - const provider = selectAIProvider(aiSettings); - const model = provider(aiSettings.model); - const { object } = await generateObject({ - model, - output: "array", - schema: z.object({ - id: z.string(), - name: z.string(), - shortDescription: z.string(), - description: z.string(), - }), - prompt: ` - Act as advanced DevOps engineer and generate a list of open source projects what can cover users needs(up to 3 items), the suggestion - should include id, name, shortDescription, and description. Use slug of title for id. The description should be in markdown format with full description of suggested stack. The shortDescription should be in plain text and have short information about used technologies. - User wants to create a new project with the following details, it should be installable in docker and can be docker compose generated for it: - - ${input} - `, - }); - - if (object?.length) { - const result = []; - for (const suggestion of object) { - const { object: docker } = await generateObject({ - model, - output: "object", - schema: z.object({ - dockerCompose: z.string(), - envVariables: z.array( - z.object({ - name: z.string(), - value: z.string(), - }), - ), - }), - prompt: ` - Act as advanced DevOps engineer and generate docker compose with environment variables needed to install the following project, - use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker compose. Use complex values for passwords/secrets variables. - Don\'t set container_name field in services. Don\'t set version field in the docker compose. - - Project details: - ${suggestion?.description} - `, - }); - if (!!docker && !!docker.dockerCompose) { - result.push({ - ...suggestion, - ...docker, - }); - } - } - return result; - } - - throw new TRPCError({ - code: "NOT_FOUND", - message: "No suggestions found", - }); + return db + .insert(ai) + .values({ + aiId, + adminId, + ...settings, + }) + .onConflictDoUpdate({ + target: ai.aiId, + set: { + ...settings, + }, + }); +}; + +export const deleteAiSettings = async (aiId: string) => { + return db.delete(ai).where(eq(ai.aiId, aiId)); +}; + +export const suggestVariants = async ( + adminId: string, + aiId: string, + input: string +) => { + try { + const aiSettings = await getAiSettingById(aiId); + if (!aiSettings || !aiSettings.isEnabled) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "AI features are not enabled for this configuration", + }); + } + + const provider = selectAIProvider(aiSettings); + const model = provider(aiSettings.model); + + let ip = ""; + if (!IS_CLOUD) { + const admin = await findAdminById(adminId); + ip = admin?.serverIp || ""; + } + + const { object } = await generateObject({ + model, + output: "array", + schema: z.object({ + id: z.string(), + name: z.string(), + shortDescription: z.string(), + description: z.string(), + }), + prompt: ` + Act as advanced DevOps engineer and generate a list of open source projects what can cover users needs(up to 3 items), the suggestion + should include id, name, shortDescription, and description. Use slug of title for id. The description should be in markdown format with full description of suggested stack. The shortDescription should be in plain text and have short information about used technologies. + User wants to create a new project with the following details, it should be installable in docker and can be docker compose generated for it: + + ${input} + `, + }); + + if (object?.length) { + const result = []; + for (const suggestion of object) { + try { + const { object: docker } = await generateObject({ + model, + output: "object", + schema: 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(), + }) + ), + }), + prompt: ` + Act as advanced DevOps engineer and generate docker compose with environment variables and domain configurations needed to install the following project. + Return the docker compose as a YAML string. Follow these rules: + 1. Use placeholder like \${VARIABLE_NAME-default} for generated variables + 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. Use dokploy-network in all services + 7. Add dokploy-network at the end and mark it as external: true + + For each service that needs to be exposed to the internet: + 1. Define a domain configuration with: + - host: the domain name for the service + - 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 in the docker-compose to work with the specified port + + Project details: + ${suggestion?.description} + `, + }); + if (!!docker && !!docker.dockerCompose) { + result.push({ + ...suggestion, + ...docker, + }); + } + } catch (error) { + console.error("Error in docker compose generation:", error); + console.error("Error details:", error.cause?.issues || error); + } + } + return result; + } + + throw new TRPCError({ + code: "NOT_FOUND", + message: "No suggestions found", + }); + } catch (error) { + console.error("Error in suggestVariants:", error); + throw error; + } };