feat: add AI assistant to dokploy

This commit is contained in:
Andrey Kucherenko
2025-01-10 08:18:43 +01:00
parent 123605dc0d
commit b58b6636e3
25 changed files with 6842 additions and 50 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,92 @@
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import dynamic from "next/dynamic";
import ReactMarkdown from "react-markdown";
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), {
ssr: false,
});
export function StepFour({
prevStep,
templateInfo,
setOpen,
setTemplateInfo,
}: any) {
const handleSubmit = () => {
setTemplateInfo(templateInfo); // Update the template info
setOpen(false);
};
return (
<div className="flex flex-col h-full">
<div className="flex-grow">
<div className="space-y-6 pb-20">
<h2 className="text-lg font-semibold">Step 4: Review and Finalize</h2>
<ScrollArea className="h-[400px] p-5">
<div className="space-y-4">
<div className="p-4">
<ReactMarkdown className="prose dark:prose-invert">
{templateInfo.details.description}
</ReactMarkdown>
</div>
<div>
<h3 className="text-md font-semibold">Name</h3>
<p>{templateInfo.name}</p>
</div>
<div>
<h3 className="text-md font-semibold">Server</h3>
<p>{templateInfo.server || "localhost"}</p>
</div>
<div>
<h3 className="text-md font-semibold">Docker Compose</h3>
<MonacoEditor
height="200px"
language="yaml"
theme="vs-dark"
value={templateInfo.details.dockerCompose}
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 14,
lineNumbers: "on",
readOnly: true,
wordWrap: "on",
automaticLayout: true,
}}
/>
</div>
<div>
<h3 className="text-md 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>{env.name}</strong>:
<span className="ml-2 font-mono">{env.value}</span>
</li>
),
)}
</ul>
</div>
</div>
</ScrollArea>
</div>
</div>
<div className="sticky bottom-0 bg-background pt-2 border-t">
<div className="flex justify-between">
<Button onClick={prevStep} variant="outline">
Back
</Button>
<Button onClick={handleSubmit}>Create</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
"use client";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
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 function StepOne({ nextStep, setTemplateInfo, templateInfo }: any) {
const [userInput, setUserInput] = useState(templateInfo.userInput);
const handleNext = () => {
setTemplateInfo({ ...templateInfo, userInput });
nextStep();
};
const handleExampleClick = (example: string) => {
setUserInput(example);
};
return (
<div className="flex flex-col h-full">
<div className="flex-grow overflow-auto">
<div className="space-y-4 pb-20">
<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={userInput}
onChange={(e) => setUserInput(e.target.value)}
className="min-h-[100px]"
/>
</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 className="sticky bottom-0 bg-background pt-2 border-t">
<div className="flex justify-end">
<Button onClick={handleNext} disabled={!userInput.trim()}>
Next
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { useState } from "react";
export function StepThree({
nextStep,
prevStep,
templateInfo,
setTemplateInfo,
}: any) {
const [name, setName] = useState(templateInfo.name);
const [server, setServer] = useState(templateInfo.server);
const { data: servers } = api.server.withSSHKey.useQuery();
const handleNext = () => {
const updatedInfo = { ...templateInfo, name };
if (server?.trim()) {
updatedInfo.server = server;
}
setTemplateInfo(updatedInfo);
nextStep();
};
return (
<div className="flex flex-col h-full">
<div className="flex-grow overflow-auto">
<div className="space-y-4 pb-20">
<h2 className="text-lg font-semibold">Step 3: Additional Details</h2>
<div>
<Label htmlFor="name">App Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter app name"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="server">Server Attachment (Optional)</Label>
<Select value={server} onValueChange={setServer}>
<SelectTrigger>
<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>
</div>
<div className="sticky bottom-0 bg-background pt-2 border-t">
<div className="flex justify-between">
<Button onClick={prevStep} variant="outline">
Back
</Button>
<Button onClick={handleNext} disabled={!name.trim()}>
Next
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,326 @@
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 dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import { toast } from "sonner";
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), {
ssr: false,
});
interface EnvVariable {
name: string;
value: string;
}
interface TemplateInfo {
id: string;
name: string;
shortDescription: string;
description: string;
dockerCompose: string;
envVariables: EnvVariable[];
}
export function StepTwo({
nextStep,
prevStep,
templateInfo,
setTemplateInfo,
}: any) {
const [suggestions, setSuggestions] = useState<Array<TemplateInfo>>([]);
const [selectedVariant, setSelectedVariant] = useState("");
const [dockerCompose, setDockerCompose] = useState("");
const [envVariables, setEnvVariables] = useState<Array<EnvVariable>>([]);
const [showValues, setShowValues] = useState<Record<string, boolean>>({});
const { mutateAsync, isLoading } = api.ai.suggest.useMutation();
useEffect(() => {
mutateAsync(templateInfo.userInput)
.then((data) => {
setSuggestions(data);
})
.catch(() => {
toast.error("Error updating AI settings");
});
}, [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<string, boolean>, env) => {
acc[env.name] = false;
return acc;
}, {}),
);
}
}
}, [selectedVariant, suggestions]);
const handleNext = () => {
const selected = suggestions.find(
(s: { id: string }) => s.id === selectedVariant,
);
if (selected) {
setTemplateInfo({
...templateInfo,
type: selectedVariant,
details: {
...selected,
dockerCompose,
envVariables,
},
});
}
nextStep();
};
const handleEnvVariableChange = (
index: number,
field: string,
value: string,
) => {
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 removeEnvVariable = (index: number) => {
const updatedEnvVariables = envVariables.filter((_, i) => i !== index);
setEnvVariables(updatedEnvVariables);
};
const toggleShowValue = (name: string) => {
setShowValues((prev) => ({ ...prev, [name]: !prev[name] }));
};
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>
);
}
const selectedTemplate = suggestions.find(
(s: { id: string }) => s.id === selectedVariant,
);
return (
<div className="flex flex-col h-full">
<div className="flex-grow overflow-auto">
<div className="space-y-6 pb-20">
<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={setSelectedVariant}
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">{selectedTemplate?.name}</h3>
<p className="text-muted-foreground mt-2">
{selectedTemplate?.shortDescription}
</p>
</div>
<ScrollArea className="h-[400px] p-5">
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="description">
<AccordionTrigger>Description</AccordionTrigger>
<AccordionContent>
<ScrollArea className="h-[300px] w-full rounded-md border">
<div className="p-4">
<ReactMarkdown className="prose dark:prose-invert">
{selectedTemplate?.description}
</ReactMarkdown>
</div>
</ScrollArea>
</AccordionContent>
</AccordionItem>
<AccordionItem value="docker-compose">
<AccordionTrigger>Docker Compose</AccordionTrigger>
<AccordionContent>
<div className="h-[400px] w-full rounded-md border overflow-hidden">
<MonacoEditor
height="100%"
language="yaml"
theme="vs-dark"
value={dockerCompose}
onChange={(value) =>
setDockerCompose(value as string)
}
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 14,
lineNumbers: "on",
readOnly: false,
wordWrap: "on",
automaticLayout: true,
}}
/>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="env-variables">
<AccordionTrigger>Environment Variables</AccordionTrigger>
<AccordionContent>
<ScrollArea className="h-[300px] w-full rounded-md border">
<div className="p-4 space-y-4">
{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>
</Accordion>
</ScrollArea>
</>
)}
</div>
</div>
<div className="sticky bottom-0 bg-background pt-2 border-t">
<div className="flex justify-between">
<Button
onClick={() =>
selectedVariant ? setSelectedVariant("") : prevStep()
}
variant="outline"
>
{selectedVariant ? "Change Variant" : "Back"}
</Button>
<Button onClick={handleNext} disabled={!selectedVariant}>
Next
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,159 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { api } from "@/utils/api";
import { AlertCircle, Bot } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { StepFour } from "./step-four";
import { StepOne } from "./step-one";
import { StepThree } from "./step-three";
import { StepTwo } from "./step-two";
const emptyState = {
userInput: "",
type: "",
details: {
id: "",
dockerCompose: "",
envVariables: [],
shortDescription: "",
},
name: "",
server: undefined,
description: "",
};
interface Props {
projectId: string;
projectName?: string;
}
export function TemplateGenerator({ projectId }: Props) {
const [open, setOpen] = useState(false);
const [step, setStep] = useState(1);
const { data: aiSettings } = api.ai.get.useQuery();
const { mutateAsync } = api.ai.deploy.useMutation();
const [templateInfo, setTemplateInfo] = useState(emptyState);
const utils = api.useUtils();
const totalSteps = 4;
const nextStep = () => setStep((prev) => Math.min(prev + 1, totalSteps));
const prevStep = () => setStep((prev) => Math.max(prev - 1, 1));
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
if (!newOpen) {
// Reset to the first step when closing the dialog
setStep(1);
setTemplateInfo(emptyState);
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<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-w-[800px] w-full max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>AI Assistant</DialogTitle>
<DialogDescription>
Create a custom template based on your needs
</DialogDescription>
</DialogHeader>
<div className="mt-4 flex-grow overflow-auto">
{step === 1 && (
<>
{(!aiSettings || !aiSettings?.isEnabled) && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>AI features are not enabled</AlertTitle>
<AlertDescription>
To use AI-powered template generation, please{" "}
<a
href="/dashboard/settings/ai"
className="font-medium underline underline-offset-4"
>
enable AI in your settings
</a>
.
</AlertDescription>
</Alert>
)}
{!!aiSettings && !!aiSettings?.isEnabled && (
<StepOne
nextStep={nextStep}
setTemplateInfo={setTemplateInfo}
templateInfo={templateInfo}
/>
)}
</>
)}
{step === 2 && (
<StepTwo
nextStep={nextStep}
prevStep={prevStep}
templateInfo={templateInfo}
setTemplateInfo={setTemplateInfo}
/>
)}
{step === 3 && (
<StepThree
nextStep={nextStep}
prevStep={prevStep}
templateInfo={templateInfo}
setTemplateInfo={setTemplateInfo}
/>
)}
{step === 4 && (
<StepFour
prevStep={prevStep}
templateInfo={templateInfo}
setTemplateInfo={async (data: any) => {
console.log("Submitting template:", data);
setTemplateInfo(data);
await mutateAsync({
projectId,
id: templateInfo.details?.id,
name: templateInfo.name,
description: data.details.shortDescription,
dockerCompose: data.details.dockerCompose,
envVariables: (data.details?.envVariables || [])
.map((env: any) => `${env.name}=${env.value}`)
.join("\n"),
serverId: data.server,
})
.then(async () => {
toast.success("Compose Created");
setOpen(false);
await utils.project.one.invalidate({
projectId,
});
})
.catch(() => {
toast.error("Error creating the compose");
});
}}
setOpen={setOpen}
/>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,276 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
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 { AlertCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const aiSettingsSchema = z.object({
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
apiKey: z.string().min(1, { message: "API Key is required" }),
model: z.string().optional(),
isEnabled: z.boolean(),
});
type AISettings = z.infer<typeof aiSettingsSchema>;
interface Model {
id: string;
object: string;
created: number;
owned_by: string;
}
export function AiForm() {
const [models, setModels] = useState<Model[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data, refetch } = api.ai.get.useQuery();
const { mutateAsync, isLoading } = api.ai.save.useMutation();
const form = useForm<AISettings>({
resolver: zodResolver(aiSettingsSchema),
defaultValues: {
apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
apiKey: data?.apiKey ?? "",
model: data?.model ?? "",
},
});
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 o1-mini if present
const defaultModel = res.data.find(
(model: Model) => model.id === "gpt-4o",
);
if (defaultModel) {
form.setValue("model", defaultModel.id);
}
} catch (error) {
setError("Failed to fetch models. Please check your API URL and Key.");
setModels([]);
} finally {
setIsLoadingModels(false);
}
};
useEffect(() => {
if (data) {
form.reset({
apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
apiKey: data?.apiKey ?? "",
model: data?.model ?? "",
isEnabled: !!data.isEnabled,
});
}
form.reset();
}, [form, form.reset, data]);
useEffect(() => {
const apiUrl = form.watch("apiUrl");
const apiKey = form.watch("apiKey");
if (apiUrl && apiKey) {
form.setValue("model", undefined); // Reset model when API URL or Key changes
fetchModels(apiUrl, apiKey);
}
}, [form.watch("apiUrl"), form.watch("apiKey")]);
const onSubmit = async (values: AISettings) => {
await mutateAsync({
apiUrl: values.apiUrl,
apiKey: values.apiKey,
model: values.model || "",
isEnabled: !!values.isEnabled,
})
.then(async () => {
await refetch();
toast.success("AI Settings Updated");
})
.catch(() => {
toast.error("Error updating AI settings");
});
};
return (
<Card className="bg-transparent">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<CardTitle className="text-xl">AI Settings</CardTitle>
<CardDescription>
Configure your AI model settings here.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<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</FormLabel>
<FormDescription>
Turn on or off AI functionality
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{!!form.watch("isEnabled") && (
<>
<FormField
control={form.control}
name="apiUrl"
render={({ field }) => (
<FormItem>
<FormLabel>API URL</FormLabel>
<FormControl>
<Input
placeholder="https://api.openai.com/v1"
{...field}
/>
</FormControl>
<FormMessage />
<p className="text-sm text-muted-foreground mt-1">
By default, the OpenAI API URL is used. Only change this
if you're using a different API.
</p>
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your API key"
{...field}
/>
</FormControl>
<FormMessage />
{form.watch("apiUrl") === "https://api.openai.com/v1" && (
<p className="text-sm text-muted-foreground mt-1">
You can find your API key on the{" "}
<a
href="https://platform.openai.com/settings/organization/api-keys"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-primary"
>
OpenAI account page
</a>
.
</p>
)}
</FormItem>
)}
/>
{!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>
<FormMessage />
</FormItem>
)}
/>
)}
{isLoadingModels && (
<div className="text-sm text-muted-foreground">
Loading models...
</div>
)}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</>
)}
<Button
type="submit"
isLoading={isLoading}
disabled={!!error || isLoadingModels || !form.watch("model")}
>
Save
</Button>
</form>
</Form>
</CardContent>
</Card>
);
}

View File

@@ -103,6 +103,12 @@ export const SettingsLayout = ({ children }: Props) => {
icon: Server,
href: "/dashboard/settings/servers",
},
{
title: "AI",
label: "",
icon: Sparkles,
href: "/dashboard/settings/ai",
},
...(isCloud
? [
{
@@ -152,13 +158,12 @@ import {
Database,
GalleryVerticalEnd,
GitBranch,
KeyIcon,
KeyRound,
ListMusic,
type LucideIcon,
Route,
Server,
ShieldCheck,
Sparkles,
User2,
Users,
} from "lucide-react";

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -0,0 +1,7 @@
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
);

File diff suppressed because it is too large Load Diff

View File

@@ -379,6 +379,13 @@
"when": 1735118844878,
"tag": "0053_broken_kulan_gath",
"breakpoints": true
},
{
"idx": 54,
"version": "6",
"when": 1736354168869,
"tag": "0054_cooing_typhoid_mary",
"breakpoints": true
}
]
}

View File

@@ -35,6 +35,7 @@
"test": "vitest --config __test__/vitest.config.ts"
},
"dependencies": {
"@ai-sdk/openai": "^1.0.12",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.1",
"@codemirror/language": "^6.10.1",
@@ -42,22 +43,23 @@
"@codemirror/view": "6.29.0",
"@dokploy/server": "workspace:*",
"@dokploy/trpc-openapi": "0.0.4",
"@hookform/resolvers": "^3.3.4",
"@hookform/resolvers": "^3.9.0",
"@monaco-editor/react": "^4.6.0",
"@octokit/webhooks": "^13.2.7",
"@radix-ui/react-accordion": "1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@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.0.3",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toggle": "^1.0.3",
@@ -75,6 +77,7 @@
"@xterm/addon-attach": "0.10.0",
"@xterm/xterm": "^5.4.0",
"adm-zip": "^0.5.14",
"ai": "^4.0.23",
"bcrypt": "5.1.1",
"bullmq": "5.4.2",
"class-variance-authority": "^0.7.0",
@@ -106,11 +109,12 @@
"react": "18.2.0",
"react-confetti-explosion": "2.1.2",
"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",
@@ -125,6 +129,7 @@
"zod-form-data": "^2.0.2"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.15",
"@types/adm-zip": "^0.5.5",
"@types/bcrypt": "5.0.2",
"@types/js-cookie": "^3.0.6",

View File

@@ -10,7 +10,6 @@ import {
PostgresqlIcon,
RedisIcon,
} from "@/components/icons/data-tools-icons";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { ProjectLayout } from "@/components/layouts/project-layout";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { StatusTooltip } from "@/components/shared/status-tooltip";
@@ -22,6 +21,7 @@ import {
import { Button } from "@/components/ui/button";
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { AddAiAssistant } from "@/components/dashboard/project/add-ai-assistant";
import {
DropdownMenu,
DropdownMenuContent,
@@ -229,6 +229,10 @@ const Project = (
<AddDatabase projectId={projectId} projectName={data?.name} />
<AddCompose projectId={projectId} projectName={data?.name} />
<AddTemplate projectId={projectId} />
<AddAiAssistant
projectId={projectId}
projectName={data?.name}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@@ -0,0 +1,71 @@
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";
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 tab={"settings"} metaName="AI">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req, res);
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,
user: user,
},
transformer: superjson,
});
await helpers.settings.isCloud.prefetch();
await helpers.auth.get.prefetch();
if (user?.rol === "user") {
await helpers.user.byAuthId.prefetch({
authId: user.authId,
});
}
if (!user) {
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,
});
// export type definition of API

View File

@@ -0,0 +1,71 @@
import { slugify } from "@/lib/slug";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { generatePassword } from "@/templates/utils";
import { IS_CLOUD } from "@dokploy/server/constants";
import {
apiAiSettingsSchema,
deploySuggestionSchema,
} from "@dokploy/server/db/schema/ai";
import {
getAiSettingsByAuthId,
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({
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");
}
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;
}),
});

View File

@@ -87,7 +87,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,13 +28,20 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"rotating-file-stream": "3.2.3",
"@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",
"@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",
@@ -53,22 +60,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",
@@ -81,11 +86,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.21.1",
"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,37 @@
import { boolean, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod";
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),
});
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(),
});
export const apiAiSettingsSchema = createSchema
.pick({
apiUrl: true,
apiKey: true,
model: true,
isEnabled: true,
})
.required();
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(),
});

View File

@@ -30,3 +30,4 @@ export * from "./gitlab";
export * from "./server";
export * from "./utils";
export * from "./preview-deployments";
export * from "./ai";

View File

@@ -0,0 +1,104 @@
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 { eq } from "drizzle-orm";
import { z } from "zod";
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 saveAiSettings = async (authId: string, settings: any) => {
return db
.insert(ai)
.values({
authId,
...settings,
})
.onConflictDoUpdate({
target: ai.authId,
set: {
...settings,
},
});
};
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",
});
}
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",
});
};

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}`);
}
}

1122
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff