Merge pull request #1384 from Dokploy/mauricio/feature

Feat/AI
This commit is contained in:
Mauricio Siu
2025-03-02 02:31:24 -06:00
committed by GitHub
27 changed files with 8372 additions and 53 deletions

View File

@@ -0,0 +1,10 @@
import { TemplateGenerator } from "@/components/dashboard/project/ai/template-generator";
interface Props {
projectId: string;
projectName?: string;
}
export const AddAiAssistant = ({ projectId }: Props) => {
return <TemplateGenerator projectId={projectId} />;
};

View File

@@ -0,0 +1,102 @@
"use client";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { useState } from "react";
const examples = [
"Make a personal blog",
"Add a photo studio portfolio",
"Create a personal ad blocker",
"Build a social media dashboard",
"Sendgrid service opensource analogue",
];
export const StepOne = ({ nextStep, setTemplateInfo, templateInfo }: any) => {
// Get servers from the API
const { data: servers } = api.server.withSSHKey.useQuery();
const handleExampleClick = (example: string) => {
setTemplateInfo({ ...templateInfo, userInput: example });
};
return (
<div className="flex flex-col h-full gap-4">
<div className="">
<div className="space-y-4 ">
<h2 className="text-lg font-semibold">Step 1: Describe Your Needs</h2>
<div className="space-y-2">
<Label htmlFor="user-needs">Describe your template needs</Label>
<Textarea
id="user-needs"
placeholder="Describe the type of template you need, its purpose, and any specific features you'd like to include."
value={templateInfo?.userInput}
onChange={(e) =>
setTemplateInfo({ ...templateInfo, userInput: e.target.value })
}
className="min-h-[100px]"
/>
</div>
<div className="space-y-2">
<Label htmlFor="server-deploy">
Select the server where you want to deploy (optional)
</Label>
<Select
value={templateInfo.server?.serverId}
onValueChange={(value) => {
const server = servers?.find((s) => s.serverId === value);
if (server) {
setTemplateInfo({
...templateInfo,
server: server,
});
}
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a server" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{servers?.map((server) => (
<SelectItem key={server.serverId} value={server.serverId}>
{server.name}
</SelectItem>
))}
<SelectLabel>Servers ({servers?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Examples:</Label>
<div className="flex flex-wrap gap-2">
{examples.map((example, index) => (
<Button
key={index}
variant="outline"
size="sm"
onClick={() => handleExampleClick(example)}
>
{example}
</Button>
))}
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,109 @@
import { CodeEditor } from "@/components/shared/code-editor";
import ReactMarkdown from "react-markdown";
import type { StepProps } from "./step-two";
export const StepThree = ({ templateInfo }: StepProps) => {
return (
<div className="flex flex-col h-full">
<div className="flex-grow">
<div className="space-y-6">
<h2 className="text-lg font-semibold">Step 3: Review and Finalize</h2>
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold">Name</h3>
<p className="text-sm text-muted-foreground">
{templateInfo?.details?.name}
</p>
</div>
<div>
<h3 className="text-sm font-semibold">Description</h3>
<ReactMarkdown className="text-sm text-muted-foreground">
{templateInfo?.details?.description}
</ReactMarkdown>
</div>
<div>
<h3 className="text-md font-semibold">Server</h3>
<p className="text-sm text-muted-foreground">
{templateInfo?.server?.name || "Dokploy Server"}
</p>
</div>
<div className="space-y-2">
<h3 className="text-sm font-semibold">Docker Compose</h3>
<CodeEditor
lineWrapping
value={templateInfo?.details?.dockerCompose}
disabled
className="font-mono"
/>
</div>
<div>
<h3 className="text-sm font-semibold">Environment Variables</h3>
<ul className="list-disc pl-5">
{templateInfo?.details?.envVariables.map(
(
env: {
name: string;
value: string;
},
index: number,
) => (
<li key={index}>
<strong className="text-sm font-semibold">
{env.name}
</strong>
:
<span className="text-sm ml-2 text-muted-foreground">
{env.value}
</span>
</li>
),
)}
</ul>
</div>
<div>
<h3 className="text-sm font-semibold">Domains</h3>
<ul className="list-disc pl-5">
{templateInfo?.details?.domains.map(
(
domain: {
host: string;
port: number;
serviceName: string;
},
index: number,
) => (
<li key={index}>
<strong className="text-sm font-semibold">
{domain.host}
</strong>
:
<span className="text-sm ml-2 text-muted-foreground">
{domain.port} - {domain.serviceName}
</span>
</li>
),
)}
</ul>
</div>
<div>
<h3 className="text-sm font-semibold">Configuration Files</h3>
<ul className="list-disc pl-5">
{templateInfo?.details?.configFiles.map((file, index) => (
<li key={index}>
<strong className="text-sm font-semibold">
{file.filePath}
</strong>
:
<span className="text-sm ml-2 text-muted-foreground">
{file.content}
</span>
</li>
))}
</ul>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,530 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { ScrollArea } from "@/components/ui/scroll-area";
import { api } from "@/utils/api";
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";
export interface StepProps {
stepper?: any;
templateInfo: TemplateInfo;
setTemplateInfo: React.Dispatch<React.SetStateAction<TemplateInfo>>;
}
export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
const suggestions = templateInfo.suggestions || [];
const selectedVariant = templateInfo.details;
const [showValues, setShowValues] = useState<Record<string, boolean>>({});
const { mutateAsync, isLoading, error, isError } =
api.ai.suggest.useMutation();
useEffect(() => {
if (suggestions?.length > 0) {
return;
}
mutateAsync({
aiId: templateInfo.aiId,
serverId: templateInfo.server?.serverId || "",
input: templateInfo.userInput,
})
.then((data) => {
setTemplateInfo({
...templateInfo,
suggestions: data,
});
})
.catch((error) => {
toast.error("Error generating suggestions", {
description: error.message,
});
});
}, [templateInfo.userInput]);
const toggleShowValue = (name: string) => {
setShowValues((prev) => ({ ...prev, [name]: !prev[name] }));
};
const handleEnvVariableChange = (
index: number,
field: "name" | "value",
value: string,
) => {
if (!selectedVariant) return;
const updatedEnvVariables = [...selectedVariant.envVariables];
// @ts-ignore
updatedEnvVariables[index] = {
...updatedEnvVariables[index],
[field]: value,
};
setTemplateInfo({
...templateInfo,
...(templateInfo.details && {
details: {
...templateInfo.details,
envVariables: updatedEnvVariables,
},
}),
});
};
const removeEnvVariable = (index: number) => {
if (!selectedVariant) return;
const updatedEnvVariables = selectedVariant.envVariables.filter(
(_, i) => i !== index,
);
setTemplateInfo({
...templateInfo,
...(templateInfo.details && {
details: {
...templateInfo.details,
envVariables: updatedEnvVariables,
},
}),
});
};
const handleDomainChange = (
index: number,
field: "host" | "port" | "serviceName",
value: string | number,
) => {
if (!selectedVariant) return;
const updatedDomains = [...selectedVariant.domains];
// @ts-ignore
updatedDomains[index] = {
...updatedDomains[index],
[field]: value,
};
setTemplateInfo({
...templateInfo,
...(templateInfo.details && {
details: {
...templateInfo.details,
domains: updatedDomains,
},
}),
});
};
const removeDomain = (index: number) => {
if (!selectedVariant) return;
const updatedDomains = selectedVariant.domains.filter(
(_, i) => i !== index,
);
setTemplateInfo({
...templateInfo,
...(templateInfo.details && {
details: {
...templateInfo.details,
domains: updatedDomains,
},
}),
});
};
const addEnvVariable = () => {
if (!selectedVariant) return;
setTemplateInfo({
...templateInfo,
...(templateInfo.details && {
details: {
...templateInfo.details,
envVariables: [
...selectedVariant.envVariables,
{ name: "", value: "" },
],
},
}),
});
};
const addDomain = () => {
if (!selectedVariant) return;
setTemplateInfo({
...templateInfo,
...(templateInfo.details && {
details: {
...templateInfo.details,
domains: [
...selectedVariant.domains,
{ host: "", port: 80, serviceName: "" },
],
},
}),
});
};
if (isError) {
return (
<div className="flex flex-col items-center justify-center h-full space-y-4">
<Bot className="w-16 h-16 text-primary animate-pulse" />
<h2 className="text-2xl font-semibold animate-pulse">Error</h2>
<AlertBlock type="error">
{error?.message || "Error generating suggestions"}
</AlertBlock>
</div>
);
}
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center h-full space-y-4">
<Bot className="w-16 h-16 text-primary animate-pulse" />
<h2 className="text-2xl font-semibold animate-pulse">
AI is processing your request
</h2>
<p className="text-muted-foreground">
Generating template suggestions based on your input...
</p>
<pre>{templateInfo.userInput}</pre>
</div>
);
}
return (
<div className="flex flex-col h-full gap-6">
<div className="flex-grow overflow-auto pb-8">
<div className="space-y-6">
<h2 className="text-lg font-semibold">Step 2: Choose a Variant</h2>
{!selectedVariant && (
<div className="space-y-4">
<div>Based on your input, we suggest the following variants:</div>
<RadioGroup
// value={selectedVariant?.}
onValueChange={(value) => {
const element = suggestions?.find((s) => s?.id === value);
setTemplateInfo({
...templateInfo,
details: element,
});
}}
className="space-y-4"
>
{suggestions?.map((suggestion) => (
<div
key={suggestion?.id}
className="flex items-start space-x-3"
>
<RadioGroupItem
value={suggestion?.id || ""}
id={suggestion?.id}
className="mt-1"
/>
<div>
<Label htmlFor={suggestion?.id} className="font-medium">
{suggestion?.name}
</Label>
<p className="text-sm text-muted-foreground">
{suggestion?.shortDescription}
</p>
</div>
</div>
))}
</RadioGroup>
</div>
)}
{selectedVariant && (
<>
<div className="mb-6">
<h3 className="text-xl font-bold">{selectedVariant?.name}</h3>
<p className="text-muted-foreground mt-2">
{selectedVariant?.shortDescription}
</p>
</div>
<ScrollArea>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="description">
<AccordionTrigger>Description</AccordionTrigger>
<AccordionContent>
<ScrollArea className=" w-full rounded-md border p-4">
<ReactMarkdown className="text-muted-foreground text-sm">
{selectedVariant?.description}
</ReactMarkdown>
</ScrollArea>
</AccordionContent>
</AccordionItem>
<AccordionItem value="docker-compose">
<AccordionTrigger>Docker Compose</AccordionTrigger>
<AccordionContent>
<CodeEditor
value={selectedVariant?.dockerCompose}
className="font-mono"
onChange={(value) => {
setTemplateInfo({
...templateInfo,
...(templateInfo?.details && {
details: {
...templateInfo.details,
dockerCompose: value,
},
}),
});
}}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="env-variables">
<AccordionTrigger>Environment Variables</AccordionTrigger>
<AccordionContent>
<ScrollArea className=" w-full rounded-md border">
<div className="p-4 space-y-4">
{selectedVariant?.envVariables.map((env, index) => (
<div
key={index}
className="flex items-center space-x-2"
>
<Input
value={env.name}
onChange={(e) =>
handleEnvVariableChange(
index,
"name",
e.target.value,
)
}
placeholder="Variable Name"
className="flex-1"
/>
<div className="flex-1 relative">
<Input
type={
showValues[env.name] ? "text" : "password"
}
value={env.value}
onChange={(e) =>
handleEnvVariableChange(
index,
"value",
e.target.value,
)
}
placeholder="Variable Value"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 transform -translate-y-1/2"
onClick={() => toggleShowValue(env.name)}
>
{showValues[env.name] ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeEnvVariable(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="mt-2"
onClick={addEnvVariable}
>
<PlusCircle className="h-4 w-4 mr-2" />
Add Variable
</Button>
</div>
</ScrollArea>
</AccordionContent>
</AccordionItem>
<AccordionItem value="domains">
<AccordionTrigger>Domains</AccordionTrigger>
<AccordionContent>
<ScrollArea className=" w-full rounded-md border">
<div className="p-4 space-y-4">
{selectedVariant?.domains.map((domain, index) => (
<div
key={index}
className="flex items-center space-x-2"
>
<Input
value={domain.host}
onChange={(e) =>
handleDomainChange(
index,
"host",
e.target.value,
)
}
placeholder="Domain Host"
className="flex-1"
/>
<Input
type="number"
value={domain.port}
onChange={(e) =>
handleDomainChange(
index,
"port",
Number.parseInt(e.target.value),
)
}
placeholder="Port"
className="w-24"
/>
<Input
value={domain.serviceName}
onChange={(e) =>
handleDomainChange(
index,
"serviceName",
e.target.value,
)
}
placeholder="Service Name"
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeDomain(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="mt-2"
onClick={addDomain}
>
<PlusCircle className="h-4 w-4 mr-2" />
Add Domain
</Button>
</div>
</ScrollArea>
</AccordionContent>
</AccordionItem>
<AccordionItem value="mounts">
<AccordionTrigger>Configuration Files</AccordionTrigger>
<AccordionContent>
<ScrollArea className="w-full rounded-md border">
<div className="p-4 space-y-4">
{selectedVariant?.configFiles?.length > 0 ? (
<>
<div className="text-sm text-muted-foreground mb-4">
This template requires the following
configuration files to be mounted:
</div>
{selectedVariant.configFiles.map(
(config, index) => (
<div
key={index}
className="space-y-2 border rounded-lg p-4"
>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-primary">
{config.filePath}
</Label>
<p className="text-xs text-muted-foreground">
Will be mounted as: ../files
{config.filePath}
</p>
</div>
</div>
<CodeEditor
value={config.content}
className="font-mono"
onChange={(value) => {
if (!selectedVariant?.configFiles)
return;
const updatedConfigFiles = [
...selectedVariant.configFiles,
];
updatedConfigFiles[index] = {
filePath: config.filePath,
content: value,
};
setTemplateInfo({
...templateInfo,
...(templateInfo.details && {
details: {
...templateInfo.details,
configFiles: updatedConfigFiles,
},
}),
});
}}
/>
</div>
),
)}
</>
) : (
<div className="text-center text-muted-foreground py-8">
<p>
This template doesn't require any configuration
files.
</p>
<p className="text-sm mt-2">
All necessary configurations are handled through
environment variables.
</p>
</div>
)}
</div>
</ScrollArea>
</AccordionContent>
</AccordionItem>
</Accordion>
</ScrollArea>
</>
)}
</div>
</div>
<div className="">
<div className="flex justify-between">
{selectedVariant && (
<Button
onClick={() => {
const { details, ...rest } = templateInfo;
setTemplateInfo(rest);
}}
variant="outline"
>
Change Variant
</Button>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,338 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { api } from "@/utils/api";
import { defineStepper } from "@stepperize/react";
import { Bot } from "lucide-react";
import Link from "next/link";
import React, { useState } from "react";
import { toast } from "sonner";
import { StepOne } from "./step-one";
import { StepThree } from "./step-three";
import { StepTwo } from "./step-two";
interface EnvVariable {
name: string;
value: string;
}
interface Domain {
host: string;
port: number;
serviceName: string;
}
interface Details {
name: string;
id: string;
description: string;
dockerCompose: string;
envVariables: EnvVariable[];
shortDescription: string;
domains: Domain[];
configFiles: Mount[];
}
interface Mount {
filePath: string;
content: string;
}
export interface TemplateInfo {
userInput: string;
details?: Details | null;
suggestions?: Details[];
server?: {
serverId: string;
name: string;
};
aiId: string;
}
const defaultTemplateInfo: TemplateInfo = {
aiId: "",
userInput: "",
server: undefined,
details: null,
suggestions: [],
};
export const { useStepper, steps, Scoped } = defineStepper(
{
id: "needs",
title: "Describe your needs",
},
{
id: "variant",
title: "Choose a Variant",
},
{
id: "review",
title: "Review and Finalize",
},
);
interface Props {
projectId: string;
projectName?: string;
}
export const TemplateGenerator = ({ projectId }: Props) => {
const [open, setOpen] = useState(false);
const stepper = useStepper();
const { data: aiSettings } = api.ai.getAll.useQuery();
const { mutateAsync } = api.ai.deploy.useMutation();
const [templateInfo, setTemplateInfo] =
useState<TemplateInfo>(defaultTemplateInfo);
const utils = api.useUtils();
const haveAtleasOneProviderEnabled = aiSettings?.some(
(ai) => ai.isEnabled === true,
);
const isDisabled = () => {
if (stepper.current.id === "needs") {
return !templateInfo.aiId || !templateInfo.userInput;
}
if (stepper.current.id === "variant") {
return !templateInfo?.details?.id;
}
return false;
};
const onSubmit = async () => {
await mutateAsync({
projectId,
id: templateInfo.details?.id || "",
name: templateInfo?.details?.name || "",
description: templateInfo?.details?.shortDescription || "",
dockerCompose: templateInfo?.details?.dockerCompose || "",
envVariables: (templateInfo?.details?.envVariables || [])
.map((env: any) => `${env.name}=${env.value}`)
.join("\n"),
domains: templateInfo?.details?.domains || [],
...(templateInfo.server?.serverId && {
serverId: templateInfo.server?.serverId || "",
}),
configFiles: templateInfo?.details?.configFiles || [],
})
.then(async () => {
toast.success("Compose Created");
setOpen(false);
await utils.project.one.invalidate({
projectId,
});
})
.catch(() => {
toast.error("Error creating the compose");
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild className="w-full">
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
<Bot className="size-4 text-muted-foreground" />
<span>AI Assistant</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-4xl w-full flex flex-col">
<DialogHeader>
<DialogTitle>AI Assistant</DialogTitle>
<DialogDescription>
Create a custom template based on your needs
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="flex justify-between">
<h2 className="text-lg font-semibold">Steps</h2>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Step {stepper.current.index + 1} of {steps.length}
</span>
<div />
</div>
</div>
<Scoped>
<nav aria-label="Checkout Steps" className="group my-4">
<ol
className="flex items-center justify-between gap-2"
aria-orientation="horizontal"
>
{stepper.all.map((step, index, array) => (
<React.Fragment key={step.id}>
<li className="flex items-center gap-4 flex-shrink-0">
<Button
type="button"
role="tab"
variant={
index <= stepper.current.index ? "secondary" : "ghost"
}
aria-current={
stepper.current.id === step.id ? "step" : undefined
}
aria-posinset={index + 1}
aria-setsize={steps.length}
aria-selected={stepper.current.id === step.id}
className="flex size-10 items-center justify-center rounded-full border-2 border-border"
>
{index + 1}
</Button>
<span className="text-sm font-medium">{step.title}</span>
</li>
{index < array.length - 1 && (
<Separator
className={`flex-1 ${
index < stepper.current.index
? "bg-primary"
: "bg-muted"
}`}
/>
)}
</React.Fragment>
))}
</ol>
</nav>
{stepper.switch({
needs: () => (
<>
{!haveAtleasOneProviderEnabled && (
<AlertBlock type="warning">
<div className="flex flex-col w-full">
<span>AI features are not enabled</span>
<span>
To use AI-powered template generation, please{" "}
<Link
href="/dashboard/settings/ai"
className="font-medium underline underline-offset-4"
>
enable AI in your settings
</Link>
.
</span>
</div>
</AlertBlock>
)}
{haveAtleasOneProviderEnabled &&
aiSettings &&
aiSettings?.length > 0 && (
<div className="space-y-4">
<div className="flex flex-col gap-2">
<label
htmlFor="user-needs"
className="text-sm font-medium"
>
Select AI Provider
</label>
<Select
value={templateInfo.aiId}
onValueChange={(value) =>
setTemplateInfo((prev) => ({
...prev,
aiId: value,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Select an AI provider" />
</SelectTrigger>
<SelectContent>
{aiSettings.map((ai) => (
<SelectItem key={ai.aiId} value={ai.aiId}>
{ai.name} ({ai.model})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{templateInfo.aiId && (
<StepOne
setTemplateInfo={setTemplateInfo}
templateInfo={templateInfo}
/>
)}
</div>
)}
</>
),
variant: () => (
<StepTwo
templateInfo={templateInfo}
setTemplateInfo={setTemplateInfo}
/>
),
review: () => (
<StepThree
templateInfo={templateInfo}
setTemplateInfo={setTemplateInfo}
/>
),
})}
</Scoped>
</div>
<DialogFooter>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 w-full justify-end">
<Button
onClick={stepper.prev}
disabled={stepper.isFirst}
variant="secondary"
>
Back
</Button>
<Button
disabled={isDisabled()}
onClick={async () => {
if (stepper.current.id === "needs") {
setTemplateInfo((prev) => ({
...prev,
suggestions: [],
details: null,
}));
}
if (stepper.isLast) {
await onSubmit();
return;
}
stepper.next();
// if (stepper.isLast) {
// // setIsOpen(false);
// // push("/dashboard/projects");
// } else {
// stepper.next();
// }
}}
>
{stepper.isLast ? "Create" : "Next"}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,106 @@
"use client";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { BotIcon, Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { HandleAi } from "./handle-ai";
export const AiForm = () => {
const { data: aiConfigs, refetch, isLoading } = api.ai.getAll.useQuery();
const { mutateAsync, isLoading: isRemoving } = api.ai.delete.useMutation();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 justify-between">
<div>
<CardTitle className="text-xl flex flex-row gap-2">
<BotIcon className="size-6 text-muted-foreground self-center" />
AI Settings
</CardTitle>
<CardDescription>Manage your AI configurations</CardDescription>
</div>
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
{aiConfigs?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<BotIcon className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
You don't have any AI configurations
</span>
<HandleAi />
</div>
) : (
<div className="flex flex-col gap-4 rounded-lg min-h-[25vh]">
{aiConfigs?.map((config) => (
<div
key={config.aiId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div>
<span className="text-sm font-medium">
{config.name}
</span>
<CardDescription>{config.model}</CardDescription>
</div>
<div className="flex justify-between items-center">
<HandleAi aiId={config.aiId} />
<DialogAction
title="Delete AI"
description="Are you sure you want to delete this AI?"
type="destructive"
onClick={async () => {
await mutateAsync({
aiId: config.aiId,
})
.then(() => {
toast.success("AI deleted successfully");
refetch();
})
.catch(() => {
toast.error("Error deleting AI");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
</div>
))}
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};

View File

@@ -0,0 +1,305 @@
"use client";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
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 { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
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<typeof Schema>;
interface Model {
id: string;
object: string;
created: number;
owned_by: string;
}
interface Props {
aiId?: string;
}
export const HandleAi = ({ aiId }: Props) => {
const [models, setModels] = useState<Model[]>([]);
const utils = api.useUtils();
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [error, setError] = useState<string | null>(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<Schema>({
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="" asChild>
{aiId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button className="cursor-pointer space-x-3">
<PlusIcon className="h-4 w-4" />
Add AI
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{aiId ? "Edit AI" : "Add AI"}</DialogTitle>
<DialogDescription>
Configure your AI provider settings
</DialogDescription>
</DialogHeader>
<Form {...form}>
{error && <AlertBlock type="error">{error}</AlertBlock>}
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="My OpenAI Config" {...field} />
</FormControl>
<FormDescription>
A name to identify this configuration
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiUrl"
render={({ field }) => (
<FormItem>
<FormLabel>API URL</FormLabel>
<FormControl>
<Input placeholder="https://api.openai.com/v1" {...field} />
</FormControl>
<FormDescription>
The base URL for your AI provider's API
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input type="password" placeholder="sk-..." {...field} />
</FormControl>
<FormDescription>
Your API key for authentication
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{isLoadingModels && (
<span className="text-sm text-muted-foreground">
Loading models...
</span>
)}
{!isLoadingModels && models.length > 0 && (
<FormField
control={form.control}
name="model"
render={({ field }) => (
<FormItem>
<FormLabel>Model</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
</FormControl>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.id}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>Select an AI model to use</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="isEnabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Enable AI Features
</FormLabel>
<FormDescription>
Turn on/off AI functionality
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex justify-end gap-2 pt-4">
<Button type="submit" isLoading={isLoading}>
{aiId ? "Update" : "Create"}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -149,7 +149,6 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => {
Add SSH Key
</Button>
)}
{/* {children} */}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>

View File

@@ -5,6 +5,7 @@ import {
Bell,
BlocksIcon,
BookIcon,
BotIcon,
Boxes,
ChevronRight,
ChevronsUpDown,
@@ -296,6 +297,12 @@ const MENU: Menu = {
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.canAccessToSSHKeys),
},
{
title: "AI",
icon: BotIcon,
url: "/dashboard/settings/ai",
isSingle: true,
},
{
isSingle: true,
title: "Git",

View File

@@ -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 $$;

View File

@@ -0,0 +1,12 @@
CREATE TABLE "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,
"organizationId" text NOT NULL,
"createdAt" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "ai" ADD CONSTRAINT "ai_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -477,6 +477,13 @@
"when": 1740892043121,
"tag": "0067_condemned_sugar_man",
"breakpoints": true
},
{
"idx": 68,
"version": "7",
"when": 1740897756774,
"tag": "0068_complex_rhino",
"breakpoints": true
}
]
}

View File

@@ -36,6 +36,7 @@
"test": "vitest --config __test__/vitest.config.ts"
},
"dependencies": {
"@ai-sdk/openai": "^1.0.12",
"better-auth": "1.2.0",
"bl": "6.0.11",
"rotating-file-stream": "3.2.3",
@@ -56,7 +57,7 @@
"@codemirror/view": "6.29.0",
"@dokploy/server": "workspace:*",
"@dokploy/trpc-openapi": "0.0.4",
"@hookform/resolvers": "^3.3.4",
"@hookform/resolvers": "^3.9.0",
"@octokit/webhooks": "^13.2.7",
"@radix-ui/react-accordion": "1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
@@ -65,12 +66,12 @@
"@radix-ui/react-collapsible": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.0.3",
@@ -91,6 +92,7 @@
"@xterm/xterm": "^5.4.0",
"@xterm/addon-clipboard": "0.1.0",
"adm-zip": "^0.5.14",
"ai": "^4.0.23",
"bcrypt": "5.1.1",
"bullmq": "5.4.2",
"class-variance-authority": "^0.7.0",
@@ -123,11 +125,12 @@
"react-confetti-explosion": "2.1.2",
"react-day-picker": "8.10.1",
"react-dom": "18.2.0",
"react-hook-form": "^7.49.3",
"react-hook-form": "^7.52.1",
"react-i18next": "^15.1.0",
"react-markdown": "^9.0.1",
"recharts": "^2.12.7",
"slugify": "^1.6.6",
"sonner": "^1.4.0",
"sonner": "^1.5.0",
"ssh2": "1.15.0",
"stripe": "17.2.0",
"superjson": "^2.2.1",
@@ -140,7 +143,8 @@
"xterm-addon-fit": "^0.8.0",
"zod": "^3.23.4",
"zod-form-data": "^2.0.2",
"@faker-js/faker": "^8.4.1"
"@faker-js/faker": "^8.4.1",
"@tailwindcss/typography": "0.5.16"
},
"devDependencies": {
"@types/qrcode": "^1.5.5",

View File

@@ -16,6 +16,8 @@ import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Button } from "@/components/ui/button";
import { AddAiAssistant } from "@/components/dashboard/project/add-ai-assistant";
import {
Card,
CardContent,
@@ -361,6 +363,10 @@ const Project = (
projectName={data?.name}
/>
<AddTemplate projectId={projectId} />
<AddAiAssistant
projectId={projectId}
projectName={data?.name}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@@ -0,0 +1,61 @@
import { AiForm } from "@/components/dashboard/settings/ai-form";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import React, { type ReactElement } from "react";
import superjson from "superjson";
const Page = () => {
return (
<div className="flex flex-col gap-4 w-full">
<AiForm />
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout>{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
const locale = getLocale(req.cookies);
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.settings.isCloud.prefetch();
await helpers.user.get.prefetch();
if (!user || user.role === "member") {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -1,6 +1,7 @@
import { authRouter } from "@/server/api/routers/auth";
import { createTRPCRouter } from "../api/trpc";
import { adminRouter } from "./routers/admin";
import { aiRouter } from "./routers/ai";
import { applicationRouter } from "./routers/application";
import { backupRouter } from "./routers/backup";
import { bitbucketRouter } from "./routers/bitbucket";
@@ -75,6 +76,7 @@ export const appRouter = createTRPCRouter({
server: serverRouter,
stripe: stripeRouter,
swarm: swarmRouter,
ai: aiRouter,
organization: organizationRouter,
});

View File

@@ -0,0 +1,173 @@
import { slugify } from "@/lib/slug";
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
} from "@/server/api/trpc";
import { generatePassword } from "@/templates/utils";
import { IS_CLOUD } from "@dokploy/server/constants";
import {
apiCreateAi,
apiUpdateAi,
deploySuggestionSchema,
} from "@dokploy/server/db/schema/ai";
import { createDomain, createMount } from "@dokploy/server/index";
import {
deleteAiSettings,
getAiSettingById,
getAiSettingsByOrganizationId,
saveAiSettings,
suggestVariants,
} from "@dokploy/server/services/ai";
import { createComposeByTemplate } from "@dokploy/server/services/compose";
import { findProjectById } from "@dokploy/server/services/project";
import {
addNewService,
checkServiceAccess,
} from "@dokploy/server/services/user";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
export const aiRouter = createTRPCRouter({
one: protectedProcedure
.input(z.object({ aiId: z.string() }))
.query(async ({ ctx, input }) => {
const aiSetting = await getAiSettingById(input.aiId);
if (aiSetting.organizationId !== ctx.session.activeOrganizationId) {
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.session.activeOrganizationId, input);
}),
update: protectedProcedure
.input(apiUpdateAi)
.mutation(async ({ ctx, input }) => {
return await saveAiSettings(ctx.session.activeOrganizationId, input);
}),
getAll: adminProcedure.query(async ({ ctx }) => {
return await getAiSettingsByOrganizationId(
ctx.session.activeOrganizationId,
);
}),
get: protectedProcedure
.input(z.object({ aiId: z.string() }))
.query(async ({ ctx, input }) => {
const aiSetting = await getAiSettingById(input.aiId);
if (aiSetting.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this AI configuration",
});
}
return aiSetting;
}),
delete: protectedProcedure
.input(z.object({ aiId: z.string() }))
.mutation(async ({ ctx, input }) => {
const aiSetting = await getAiSettingById(input.aiId);
if (aiSetting.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this AI configuration",
});
}
return await deleteAiSettings(input.aiId);
}),
suggest: protectedProcedure
.input(
z.object({
aiId: z.string(),
input: z.string(),
serverId: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
try {
return await suggestVariants({
...input,
organizationId: ctx.session.activeOrganizationId,
});
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error instanceof Error ? error?.message : `Error: ${error}`,
});
}
}),
deploy: protectedProcedure
.input(deploySuggestionSchema)
.mutation(async ({ ctx, input }) => {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.session.activeOrganizationId,
input.projectId,
"create",
);
}
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)}`,
isolatedDeployment: true,
});
if (input.domains && input.domains?.length > 0) {
for (const domain of input.domains) {
await createDomain({
...domain,
domainType: "compose",
certificateType: "none",
composeId: compose.composeId,
});
}
}
if (input.configFiles && input.configFiles?.length > 0) {
for (const mount of input.configFiles) {
await createMount({
filePath: mount.filePath,
mountPath: "",
content: mount.content,
serviceId: compose.composeId,
serviceType: "compose",
type: "file",
});
}
}
if (ctx.user.rol === "member") {
await addNewService(
ctx.session.activeOrganizationId,
ctx.user.ownerId,
compose.composeId,
);
}
return null;
}),
});

View File

@@ -111,7 +111,11 @@ const config = {
},
},
},
plugins: [require("tailwindcss-animate"), require("fancy-ansi/plugin")],
plugins: [
require("tailwindcss-animate"),
require("fancy-ansi/plugin"),
require("@tailwindcss/typography"),
],
} satisfies Config;
export default config;

View File

@@ -28,18 +28,25 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@ai-sdk/anthropic": "^1.0.6",
"@ai-sdk/azure": "^1.0.15",
"@ai-sdk/cohere": "^1.0.6",
"@ai-sdk/deepinfra": "^0.0.4",
"@ai-sdk/mistral": "^1.0.6",
"@ai-sdk/openai": "^1.0.12",
"@ai-sdk/openai-compatible": "^0.0.13",
"@better-auth/utils":"0.2.3",
"@oslojs/encoding":"1.1.0",
"@oslojs/crypto":"1.0.1",
"drizzle-dbml-generator":"0.10.0",
"better-auth":"1.2.0",
"rotating-file-stream": "3.2.3",
"@faker-js/faker": "^8.4.1",
"@lucia-auth/adapter-drizzle": "1.0.7",
"@octokit/auth-app": "^6.0.4",
"@react-email/components": "^0.0.21",
"@trpc/server": "^10.43.6",
"adm-zip": "^0.5.14",
"ai": "^4.0.23",
"bcrypt": "5.1.1",
"bl": "6.0.11",
"boxen": "^7.1.1",
@@ -58,22 +65,20 @@
"node-schedule": "2.1.1",
"nodemailer": "6.9.14",
"octokit": "3.1.2",
"ollama-ai-provider": "^1.1.0",
"otpauth": "^9.2.3",
"postgres": "3.4.4",
"public-ip": "6.0.2",
"qrcode": "^1.5.3",
"react": "18.2.0",
"react-dom": "18.2.0",
"rotating-file-stream": "3.2.3",
"slugify": "^1.6.6",
"ssh2": "1.15.0",
"ws": "8.16.0",
"zod": "^3.23.4",
"ssh2": "1.15.0"
"zod": "^3.23.4"
},
"devDependencies": {
"esbuild-plugin-alias": "0.2.1",
"tailwindcss": "^3.4.1",
"tsx": "^4.7.1",
"tsc-alias": "1.8.10",
"@types/adm-zip": "^0.5.5",
"@types/bcrypt": "5.0.2",
"@types/dockerode": "3.3.23",
@@ -86,11 +91,15 @@
"@types/qrcode": "^1.5.5",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@types/ssh2": "1.15.1",
"@types/ws": "8.5.10",
"drizzle-kit": "^0.30.4",
"esbuild": "0.20.2",
"esbuild-plugin-alias": "0.2.1",
"postcss": "^8.4.31",
"typescript": "^5.4.2",
"@types/ssh2": "1.15.1"
"tailwindcss": "^3.4.1",
"tsc-alias": "1.8.10",
"tsx": "^4.7.1",
"typescript": "^5.4.2"
}
}

View File

@@ -0,0 +1,82 @@
import { relations } from "drizzle-orm";
import { boolean, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
export const ai = pgTable("ai", {
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),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }), // Admin ID who created the AI settings
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
});
export const aiRelations = relations(ai, ({ one }) => ({
organization: one(organization, {
fields: [ai.organizationId],
references: [organization.id],
}),
}));
const createSchema = createInsertSchema(ai, {
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 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({ organizationId: 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(),
domains: z
.array(
z.object({
host: z.string().min(1),
port: z.number().min(1),
serviceName: z.string().min(1),
}),
)
.optional(),
configFiles: z
.array(
z.object({
filePath: z.string().min(1),
content: z.string().min(1),
}),
)
.optional(),
});

View File

@@ -28,4 +28,5 @@ export * from "./gitlab";
export * from "./server";
export * from "./utils";
export * from "./preview-deployments";
export * from "./ai";
export * from "./account";

View File

@@ -28,6 +28,9 @@ export const findUserById = async (userId: string) => {
export const findOrganizationById = async (organizationId: string) => {
const organizationResult = await db.query.organization.findFirst({
where: eq(organization.id, organizationId),
with: {
owner: true,
},
});
return organizationResult;
};

View File

@@ -0,0 +1,212 @@
import { db } from "@dokploy/server/db";
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 { desc, eq } from "drizzle-orm";
import { z } from "zod";
import { IS_CLOUD } from "../constants";
import { findServerById } from "./server";
import { findOrganizationById } from "./admin";
export const getAiSettingsByOrganizationId = async (organizationId: string) => {
const aiSettings = await db.query.ai.findMany({
where: eq(ai.organizationId, organizationId),
orderBy: desc(ai.createdAt),
});
return aiSettings;
};
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 saveAiSettings = async (organizationId: string, settings: any) => {
const aiId = settings.aiId;
return db
.insert(ai)
.values({
aiId,
organizationId,
...settings,
})
.onConflictDoUpdate({
target: ai.aiId,
set: {
...settings,
},
});
};
export const deleteAiSettings = async (aiId: string) => {
return db.delete(ai).where(eq(ai.aiId, aiId));
};
interface Props {
organizationId: string;
aiId: string;
input: string;
serverId?: string | undefined;
}
export const suggestVariants = async ({
organizationId,
aiId,
input,
serverId,
}: Props) => {
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 organization = await findOrganizationById(organizationId);
ip = organization?.owner.serverIp || "";
}
if (serverId) {
const server = await findServerById(serverId);
ip = server.ipAddress;
} else if (process.env.NODE_ENV === "development") {
ip = "127.0.0.1";
}
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.
Important rules for the response:
1. The description field should ONLY contain a plain text description of the project, its features, and use cases
2. Do NOT include any code snippets, configuration examples, or installation instructions in the description
3. The shortDescription should be a single-line summary focusing on the main 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(),
}),
),
configFiles: z.array(
z.object({
content: z.string(),
filePath: 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 and environment variables configuration. 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
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
Project details:
${suggestion?.description}
`,
});
if (!!docker && !!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",
});
} catch (error) {
console.error("Error in suggestVariants:", error);
throw error;
}
};

View File

@@ -0,0 +1 @@
export * from "./select-ai-provider";

View File

@@ -0,0 +1,73 @@
import { createAnthropic } from "@ai-sdk/anthropic";
import { createAzure } from "@ai-sdk/azure";
import { createCohere } from "@ai-sdk/cohere";
import { createDeepInfra } from "@ai-sdk/deepinfra";
import { createMistral } from "@ai-sdk/mistral";
import { createOpenAI } from "@ai-sdk/openai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { createOllama } from "ollama-ai-provider";
function getProviderName(apiUrl: string) {
if (apiUrl.includes("api.openai.com")) return "openai";
if (apiUrl.includes("azure.com")) return "azure";
if (apiUrl.includes("api.anthropic.com")) return "anthropic";
if (apiUrl.includes("api.cohere.ai")) return "cohere";
if (apiUrl.includes("api.perplexity.ai")) return "perplexity";
if (apiUrl.includes("api.mistral.ai")) return "mistral";
if (apiUrl.includes("localhost:11434") || apiUrl.includes("ollama"))
return "ollama";
if (apiUrl.includes("api.deepinfra.com")) return "deepinfra";
throw new Error(`Unsupported AI provider for URL: ${apiUrl}`);
}
export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
const providerName = getProviderName(config.apiUrl);
switch (providerName) {
case "openai":
return createOpenAI({
apiKey: config.apiKey,
baseURL: config.apiUrl,
});
case "azure":
return createAzure({
apiKey: config.apiKey,
baseURL: config.apiUrl,
});
case "anthropic":
return createAnthropic({
apiKey: config.apiKey,
baseURL: config.apiUrl,
});
case "cohere":
return createCohere({
baseURL: config.apiUrl,
apiKey: config.apiKey,
});
case "perplexity":
return createOpenAICompatible({
name: "perplexity",
baseURL: config.apiUrl,
headers: {
Authorization: `Bearer ${config.apiKey}`,
},
});
case "mistral":
return createMistral({
baseURL: config.apiUrl,
apiKey: config.apiKey,
});
case "ollama":
return createOllama({
// optional settings, e.g.
baseURL: config.apiUrl,
});
case "deepinfra":
return createDeepInfra({
baseURL: config.apiUrl,
apiKey: config.apiKey,
});
default:
throw new Error(`Unsupported AI provider: ${providerName}`);
}
}

1097
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff