refactor: add many AI providers & improve prompt

This commit is contained in:
Mauricio Siu 2025-01-18 21:35:03 -06:00
parent ad642ab4e0
commit 08ab18eebf
17 changed files with 1158 additions and 667 deletions

View File

@ -2,13 +2,14 @@ import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import ReactMarkdown from "react-markdown";
import type { StepProps } from "./step-two";
export const StepFour = ({
prevStep,
templateInfo,
setOpen,
setTemplateInfo,
}: any) => {
}: StepProps) => {
const handleSubmit = () => {
setTemplateInfo(templateInfo); // Update the template info
setOpen(false);
@ -17,36 +18,41 @@ export const StepFour = ({
return (
<div className="flex flex-col h-full">
<div className="flex-grow">
<div className="space-y-6 pb-20">
<div className="space-y-6">
<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}
<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">Name</h3>
<p>{templateInfo.name}</p>
</div>
<div>
<h3 className="text-md font-semibold">Server</h3>
<p>{templateInfo.server || "localhost"}</p>
<p className="text-sm text-muted-foreground">
{templateInfo?.serverId || "Dokploy Server"}
</p>
</div>
<div>
<h3 className="text-md font-semibold">Docker Compose</h3>
<h3 className="text-sm font-semibold">Docker Compose</h3>
<CodeEditor
lineWrapping
value={templateInfo.details.dockerCompose}
value={templateInfo?.details?.dockerCompose}
disabled
className="font-mono"
/>
</div>
<div>
<h3 className="text-md font-semibold">Environment Variables</h3>
<h3 className="text-sm font-semibold">Environment Variables</h3>
<ul className="list-disc pl-5">
{templateInfo.details.envVariables.map(
{templateInfo?.details?.envVariables.map(
(
env: {
name: string;
@ -55,8 +61,13 @@ export const StepFour = ({
index: number,
) => (
<li key={index}>
<strong>{env.name}</strong>:
<span className="ml-2 font-mono">{env.value}</span>
<strong className="text-sm font-semibold">
{env.name}
</strong>
:
<span className="text-sm ml-2 text-muted-foreground">
{env.value}
</span>
</li>
),
)}

View File

@ -14,21 +14,21 @@ import {
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { useState } from "react";
import type { StepProps } from "./step-two";
export const StepThree = ({
nextStep,
prevStep,
templateInfo,
setTemplateInfo,
}: any) => {
const [name, setName] = useState(templateInfo.name);
const [server, setServer] = useState(templateInfo.server);
}: StepProps) => {
const [server, setServer] = useState(templateInfo.serverId);
const { data: servers } = api.server.withSSHKey.useQuery();
const handleNext = () => {
const updatedInfo = { ...templateInfo, name };
if (server?.trim()) {
updatedInfo.server = server;
updatedInfo.serverId = server;
}
setTemplateInfo(updatedInfo);
nextStep();
@ -43,8 +43,16 @@ export const StepThree = ({
<Label htmlFor="name">App Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
value={templateInfo?.details?.name || ""}
onChange={(e) => {
setTemplateInfo({
...templateInfo,
details: {
...templateInfo?.details,
name: e.target.value,
},
});
}}
placeholder="Enter app name"
className="mt-1"
/>
@ -74,7 +82,10 @@ export const StepThree = ({
<Button onClick={prevStep} variant="outline">
Back
</Button>
<Button onClick={handleNext} disabled={!name.trim()}>
<Button
onClick={handleNext}
disabled={!templateInfo?.details?.name?.trim()}
>
Next
</Button>
</div>

View File

@ -15,19 +15,13 @@ import { Bot, Eye, EyeOff, PlusCircle, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import { toast } from "sonner";
import type { TemplateInfo } from "./template-generator";
interface EnvVariable {
name: string;
value: string;
}
interface TemplateInfo {
id: string;
name: string;
shortDescription: string;
description: string;
dockerCompose: string;
envVariables: EnvVariable[];
export interface StepProps {
nextStep: () => void;
prevStep: () => void;
templateInfo: TemplateInfo;
setTemplateInfo: React.Dispatch<React.SetStateAction<TemplateInfo>>;
}
export const StepTwo = ({
@ -35,56 +29,34 @@ export const StepTwo = ({
prevStep,
templateInfo,
setTemplateInfo,
}: any) => {
const [suggestions, setSuggestions] = useState<Array<TemplateInfo>>([]);
const [selectedVariant, setSelectedVariant] = useState("");
const [dockerCompose, setDockerCompose] = useState("");
const [envVariables, setEnvVariables] = useState<Array<EnvVariable>>([]);
}: StepProps) => {
const [suggestions, setSuggestions] = useState<TemplateInfo["details"][]>();
const [selectedVariant, setSelectedVariant] =
useState<TemplateInfo["details"]>();
const [showValues, setShowValues] = useState<Record<string, boolean>>({});
const { mutateAsync, isLoading } = api.ai.suggest.useMutation();
useEffect(() => {
mutateAsync(templateInfo.userInput)
mutateAsync({
aiId: templateInfo.aiId,
prompt: templateInfo.userInput,
})
.then((data) => {
console.log(data);
setSuggestions(data);
})
.catch(() => {
toast.error("Error updating AI settings");
.catch((error) => {
console.error("Error details:", error);
toast.error("Error generating suggestions");
});
}, [templateInfo.userInput]);
useEffect(() => {
if (selectedVariant) {
const selected = suggestions.find(
(s: { id: string }) => s.id === selectedVariant,
);
if (selected) {
setDockerCompose(selected.dockerCompose);
setEnvVariables(selected.envVariables);
setShowValues(
selected.envVariables.reduce((acc: Record<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) {
if (selectedVariant) {
setTemplateInfo({
...templateInfo,
type: selectedVariant,
details: {
...selected,
dockerCompose,
envVariables,
},
details: selectedVariant,
});
}
nextStep();
@ -95,25 +67,49 @@ export const StepTwo = ({
field: string,
value: string,
) => {
const updatedEnvVariables = [...envVariables];
if (updatedEnvVariables[index]) {
updatedEnvVariables[index] = {
...updatedEnvVariables[index],
[field]: value,
};
setEnvVariables(updatedEnvVariables);
}
// const updatedEnvVariables = [...envVariables];
// if (updatedEnvVariables[index]) {
// updatedEnvVariables[index] = {
// ...updatedEnvVariables[index],
// [field]: value,
// };
// setEnvVariables(updatedEnvVariables);
// }
};
const addEnvVariable = () => {
setEnvVariables([...envVariables, { name: "", value: "" }]);
setShowValues((prev) => ({ ...prev, "": false }));
};
// const addEnvVariable = () => {
// setEnvVariables([...envVariables, { name: "", value: "" }]);
// setShowValues((prev) => ({ ...prev, "": false }));
// };
const removeEnvVariable = (index: number) => {
const updatedEnvVariables = envVariables.filter((_, i) => i !== index);
setEnvVariables(updatedEnvVariables);
};
// const removeEnvVariable = (index: number) => {
// const updatedEnvVariables = envVariables.filter((_, i) => i !== index);
// setEnvVariables(updatedEnvVariables);
// };
// const handleDomainChange = (
// index: number,
// field: string,
// value: string | number,
// ) => {
// const updatedDomains = [...domains];
// if (updatedDomains[index]) {
// updatedDomains[index] = {
// ...updatedDomains[index],
// [field]: value,
// };
// setDomains(updatedDomains);
// }
// };
// const addDomain = () => {
// setDomains([...domains, { host: "", port: 0, serviceName: "" }]);
// };
// const removeDomain = (index: number) => {
// const updatedDomains = domains.filter((_, i) => i !== index);
// setDomains(updatedDomains);
// };
const toggleShowValue = (name: string) => {
setShowValues((prev) => ({ ...prev, [name]: !prev[name] }));
@ -134,39 +130,38 @@ export const StepTwo = ({
);
}
const selectedTemplate = suggestions.find(
(s: { id: string }) => s.id === selectedVariant,
);
return (
<div className="flex flex-col h-full">
<div className="flex-grow overflow-auto">
<div className="space-y-6 pb-20">
<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={setSelectedVariant}
onValueChange={(value) => {
const element = suggestions?.find((s) => s?.id === value);
setSelectedVariant(element);
}}
className="space-y-4"
>
{suggestions.map((suggestion) => (
{suggestions?.map((suggestion) => (
<div
key={suggestion.id}
key={suggestion?.id}
className="flex items-start space-x-3"
>
<RadioGroupItem
value={suggestion.id}
id={suggestion.id}
value={suggestion?.id || ""}
id={suggestion?.id}
className="mt-1"
/>
<div>
<Label htmlFor={suggestion.id} className="font-medium">
{suggestion.name}
<Label htmlFor={suggestion?.id} className="font-medium">
{suggestion?.name}
</Label>
<p className="text-sm text-muted-foreground">
{suggestion.shortDescription}
{suggestion?.shortDescription}
</p>
</div>
</div>
@ -177,22 +172,20 @@ export const StepTwo = ({
{selectedVariant && (
<>
<div className="mb-6">
<h3 className="text-xl font-bold">{selectedTemplate?.name}</h3>
<h3 className="text-xl font-bold">{selectedVariant?.name}</h3>
<p className="text-muted-foreground mt-2">
{selectedTemplate?.shortDescription}
{selectedVariant?.shortDescription}
</p>
</div>
<ScrollArea className="h-[400px] p-5">
<ScrollArea>
<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 className=" w-full rounded-md border p-4">
<ReactMarkdown className="text-muted-foreground text-sm">
{selectedVariant?.description}
</ReactMarkdown>
</ScrollArea>
</AccordionContent>
</AccordionItem>
@ -200,9 +193,14 @@ export const StepTwo = ({
<AccordionTrigger>Docker Compose</AccordionTrigger>
<AccordionContent>
<CodeEditor
value={dockerCompose}
value={selectedVariant?.dockerCompose}
className="font-mono"
onChange={(value) => setDockerCompose(value)}
onChange={(value) => {
setSelectedVariant({
...selectedVariant,
dockerCompose: value,
});
}}
/>
</AccordionContent>
</AccordionItem>
@ -211,7 +209,7 @@ export const StepTwo = ({
<AccordionContent>
<ScrollArea className="h-[300px] w-full rounded-md border">
<div className="p-4 space-y-4">
{envVariables.map((env, index) => (
{selectedVariant?.envVariables.map((env, index) => (
<div
key={index}
className="flex items-center space-x-2"
@ -272,7 +270,7 @@ export const StepTwo = ({
variant="outline"
size="sm"
className="mt-2"
onClick={addEnvVariable}
// onClick={addEnvVariable}
>
<PlusCircle className="h-4 w-4 mr-2" />
Add Variable
@ -281,17 +279,88 @@ export const StepTwo = ({
</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",
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>
</Accordion>
</ScrollArea>
</>
)}
</div>
</div>
<div className="sticky bottom-0 bg-background pt-2 border-t">
<div className="sticky bottom-0 bg-background pt-5 border-t">
<div className="flex justify-between">
<Button
onClick={() =>
selectedVariant ? setSelectedVariant("") : prevStep()
selectedVariant ? setSelectedVariant(undefined) : prevStep()
}
variant="outline"
>

View File

@ -8,27 +8,65 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { AlertCircle, Bot } from "lucide-react";
import { 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";
import { AlertBlock } from "@/components/shared/alert-block";
import Link from "next/link";
const emptyState = {
interface EnvVariable {
name: string;
value: string;
}
interface Domain {
host: string;
port: number;
serviceName: string;
}
export interface TemplateInfo {
id: string;
userInput: string;
type: string;
details?: {
name: string;
id: string;
description: string;
dockerCompose: string;
envVariables: EnvVariable[];
shortDescription: string;
domains: Domain[];
};
serverId?: string;
aiId: string;
}
const defaultTemplateInfo: TemplateInfo = {
id: "",
aiId: "",
userInput: "",
type: "",
details: {
id: "",
name: "",
description: "",
dockerCompose: "",
envVariables: [],
shortDescription: "",
domains: [],
},
name: "",
server: undefined,
description: "",
};
interface Props {
@ -39,9 +77,10 @@ interface Props {
export const TemplateGenerator = ({ projectId }: Props) => {
const [open, setOpen] = useState(false);
const [step, setStep] = useState(1);
const { data: aiSettings } = api.ai.get.useQuery();
const { data: aiSettings } = api.ai.getAll.useQuery();
const { mutateAsync } = api.ai.deploy.useMutation();
const [templateInfo, setTemplateInfo] = useState(emptyState);
const [templateInfo, setTemplateInfo] =
useState<TemplateInfo>(defaultTemplateInfo);
const utils = api.useUtils();
const totalSteps = 4;
@ -52,12 +91,15 @@ export const TemplateGenerator = ({ projectId }: Props) => {
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
if (!newOpen) {
// Reset to the first step when closing the dialog
setStep(1);
setTemplateInfo(emptyState);
setTemplateInfo(defaultTemplateInfo);
}
};
const haveAtleasOneProviderEnabled = aiSettings?.some(
(ai) => ai.isEnabled === true,
);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild className="w-full">
@ -69,7 +111,7 @@ export const TemplateGenerator = ({ projectId }: Props) => {
<span>AI Assistant</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="max-w-[800px] w-full max-h-[90vh] flex flex-col">
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-4xl w-full flex flex-col">
<DialogHeader>
<DialogTitle>AI Assistant</DialogTitle>
<DialogDescription>
@ -79,29 +121,65 @@ export const TemplateGenerator = ({ projectId }: Props) => {
<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}
/>
{!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
nextStep={nextStep}
setTemplateInfo={setTemplateInfo}
templateInfo={templateInfo}
/>
)}
</div>
)}
</>
)}
{step === 2 && (
@ -129,14 +207,14 @@ export const TemplateGenerator = ({ projectId }: Props) => {
setTemplateInfo(data);
await mutateAsync({
projectId,
id: templateInfo.details?.id,
name: templateInfo.name,
description: data.details.shortDescription,
dockerCompose: data.details.dockerCompose,
envVariables: (data.details?.envVariables || [])
id: templateInfo.details?.id || "",
name: templateInfo?.details?.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,
serverId: data.server || "",
})
.then(async () => {
toast.success("Compose Created");

View File

@ -1,6 +1,5 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
@ -9,268 +8,99 @@ import {
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 { BotIcon, Loader2, Trash2 } from "lucide-react";
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;
}
import { HandleAi } from "./handle-ai";
import { DialogAction } from "@/components/shared/dialog-action";
export const 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");
});
};
const { data: aiConfigs, refetch, isLoading } = api.ai.getAll.useQuery();
const { mutateAsync, isLoading: isRemoving } = api.ai.delete.useMutation();
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") && (
<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>
) : (
<>
<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...
{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>
)}
{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>
</CardContent>
</div>
</Card>
</div>
);
};

View File

@ -0,0 +1,305 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useEffect, useState } from "react";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
const Schema = z.object({
name: z.string().min(1, { message: "Name is required" }),
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
apiKey: z.string().min(1, { message: "API Key is required" }),
model: z.string().min(1, { message: "Model is required" }),
isEnabled: z.boolean(),
});
type Schema = z.infer<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

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

@ -19,6 +19,7 @@ import {
GitBranch,
HeartIcon,
KeyRound,
BotIcon,
type LucideIcon,
Package,
PieChart,
@ -249,7 +250,13 @@ const data = {
isSingle: true,
isActive: false,
},
{
title: "AI",
icon: BotIcon,
url: "/dashboard/settings/ai",
isSingle: true,
isActive: false,
},
{
title: "Git",
url: "/dashboard/settings/git-providers",

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

@ -1,7 +0,0 @@
CREATE TABLE IF NOT EXISTS "ai" (
"authId" text PRIMARY KEY NOT NULL,
"apiUrl" text NOT NULL,
"apiKey" text NOT NULL,
"model" text NOT NULL,
"isEnabled" boolean DEFAULT true NOT NULL
);

View File

@ -1,5 +1,5 @@
{
"id": "03ce8887-3824-49b9-ad47-12c25aa4c090",
"id": "841960d7-0573-41e4-8529-fd9960f726d5",
"prevId": "24787a88-0754-437a-b077-03a3265b8ef5",
"version": "6",
"dialect": "postgresql",
@ -4144,12 +4144,18 @@
"name": "ai",
"schema": "",
"columns": {
"authId": {
"name": "authId",
"aiId": {
"name": "aiId",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"apiUrl": {
"name": "apiUrl",
"type": "text",
@ -4174,10 +4180,36 @@
"primaryKey": false,
"notNull": true,
"default": true
},
"adminId": {
"name": "adminId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"foreignKeys": {
"ai_adminId_admin_adminId_fk": {
"name": "ai_adminId_admin_adminId_fk",
"tableFrom": "ai",
"tableTo": "admin",
"columnsFrom": [
"adminId"
],
"columnsTo": [
"adminId"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}

View File

@ -404,8 +404,8 @@
{
"idx": 57,
"version": "6",
"when": 1737246538368,
"tag": "0057_mature_thaddeus_ross",
"when": 1737251708859,
"tag": "0057_damp_prism",
"breakpoints": true
}
]

View File

@ -1,6 +1,5 @@
import { AiForm } from "@/components/dashboard/settings/ai-form";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { SettingsLayout } from "@/components/layouts/settings-layout";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
import { validateRequest } from "@dokploy/server";
@ -20,11 +19,7 @@ const Page = () => {
export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"} metaName="AI">
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);
return <DashboardLayout>{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,

View File

@ -1,71 +1,127 @@
import { slugify } from "@/lib/slug";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
} from "@/server/api/trpc";
import { generatePassword } from "@/templates/utils";
import { IS_CLOUD } from "@dokploy/server/constants";
import {
apiAiSettingsSchema,
deploySuggestionSchema,
apiCreateAi,
apiUpdateAi,
deploySuggestionSchema,
} from "@dokploy/server/db/schema/ai";
import {
getAiSettingsByAuthId,
saveAiSettings,
suggestVariants,
getAiSettingsByAdminId,
getAiSettingById,
saveAiSettings,
deleteAiSettings,
suggestVariants,
} from "@dokploy/server/services/ai";
import { createComposeByTemplate } from "@dokploy/server/services/compose";
import { findProjectById } from "@dokploy/server/services/project";
import {
addNewService,
checkServiceAccess,
addNewService,
checkServiceAccess,
} from "@dokploy/server/services/user";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
export const aiRouter = createTRPCRouter({
save: protectedProcedure
.input(apiAiSettingsSchema)
.mutation(async ({ ctx, input }) => {
return await saveAiSettings(ctx.user.authId, input);
}),
get: protectedProcedure.query(async ({ ctx }) => {
return await getAiSettingsByAuthId(ctx.user.authId);
}),
suggest: protectedProcedure
.input(z.string())
.mutation(async ({ ctx, input }) => {
return await suggestVariants(ctx.user.authId, input);
}),
deploy: protectedProcedure
.input(deploySuggestionSchema)
.mutation(async ({ ctx, input }) => {
if (ctx.user.rol === "user") {
await checkServiceAccess(ctx.user.authId, input.projectId, "create");
}
one: protectedProcedure
.input(z.object({ aiId: z.string() }))
.query(async ({ ctx, input }) => {
const aiSetting = await getAiSettingById(input.aiId);
if (aiSetting.adminId !== ctx.user.adminId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this AI configuration",
});
}
return aiSetting;
}),
create: adminProcedure.input(apiCreateAi).mutation(async ({ ctx, input }) => {
return await saveAiSettings(ctx.user.adminId, input);
}),
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create a compose",
});
}
update: protectedProcedure
.input(apiUpdateAi)
.mutation(async ({ ctx, input }) => {
return await saveAiSettings(ctx.user.adminId, input);
}),
const project = await findProjectById(input.projectId);
getAll: adminProcedure.query(async ({ ctx }) => {
return await getAiSettingsByAdminId(ctx.user.adminId);
}),
const projectName = slugify(`${project.name} ${input.id}`);
get: protectedProcedure
.input(z.object({ aiId: z.string() }))
.query(async ({ ctx, input }) => {
const aiSetting = await getAiSettingById(input.aiId);
if (aiSetting.adminId !== ctx.user.authId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this AI configuration",
});
}
return aiSetting;
}),
const compose = await createComposeByTemplate({
...input,
composeFile: input.dockerCompose,
env: input.envVariables,
serverId: input.serverId,
name: input.name,
sourceType: "raw",
appName: `${projectName}-${generatePassword(6)}`,
});
delete: protectedProcedure
.input(z.object({ aiId: z.string() }))
.mutation(async ({ ctx, input }) => {
const aiSetting = await getAiSettingById(input.aiId);
if (aiSetting.adminId !== ctx.user.adminId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this AI configuration",
});
}
return await deleteAiSettings(input.aiId);
}),
if (ctx.user.rol === "user") {
await addNewService(ctx.user.authId, compose.composeId);
}
suggest: protectedProcedure
.input(
z.object({
aiId: z.string(),
prompt: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
return await suggestVariants(ctx.user.adminId, input.aiId, input.prompt);
}),
deploy: protectedProcedure
.input(deploySuggestionSchema)
.mutation(async ({ ctx, input }) => {
if (ctx.user.rol === "user") {
await checkServiceAccess(ctx.user.adminId, input.projectId, "create");
}
return null;
}),
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create a compose",
});
}
const project = await findProjectById(input.projectId);
const projectName = slugify(`${project.name} ${input.id}`);
const compose = await createComposeByTemplate({
...input,
composeFile: input.dockerCompose,
env: input.envVariables,
serverId: input.serverId,
name: input.name,
sourceType: "raw",
appName: `${projectName}-${generatePassword(6)}`,
});
if (ctx.user.rol === "user") {
await addNewService(ctx.user.authId, compose.composeId);
}
return null;
}),
});

View File

@ -9,111 +9,113 @@ import { registry } from "./registry";
import { certificateType } from "./shared";
import { sshKeys } from "./ssh-key";
import { users } from "./user";
import { ai } from "./ai";
export const admins = pgTable("admin", {
adminId: text("adminId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
serverIp: text("serverIp"),
certificateType: certificateType("certificateType").notNull().default("none"),
host: text("host"),
letsEncryptEmail: text("letsEncryptEmail"),
sshPrivateKey: text("sshPrivateKey"),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
enableLogRotation: boolean("enableLogRotation").notNull().default(false),
authId: text("authId")
.notNull()
.references(() => auth.id, { onDelete: "cascade" }),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
stripeCustomerId: text("stripeCustomerId"),
stripeSubscriptionId: text("stripeSubscriptionId"),
serversQuantity: integer("serversQuantity").notNull().default(0),
adminId: text("adminId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
serverIp: text("serverIp"),
certificateType: certificateType("certificateType").notNull().default("none"),
host: text("host"),
letsEncryptEmail: text("letsEncryptEmail"),
sshPrivateKey: text("sshPrivateKey"),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
enableLogRotation: boolean("enableLogRotation").notNull().default(false),
authId: text("authId")
.notNull()
.references(() => auth.id, { onDelete: "cascade" }),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
stripeCustomerId: text("stripeCustomerId"),
stripeSubscriptionId: text("stripeSubscriptionId"),
serversQuantity: integer("serversQuantity").notNull().default(0),
});
export const adminsRelations = relations(admins, ({ one, many }) => ({
auth: one(auth, {
fields: [admins.authId],
references: [auth.id],
}),
users: many(users),
registry: many(registry),
sshKeys: many(sshKeys),
certificates: many(certificates),
auth: one(auth, {
fields: [admins.authId],
references: [auth.id],
}),
users: many(users),
registry: many(registry),
sshKeys: many(sshKeys),
certificates: many(certificates),
ai: many(ai),
}));
const createSchema = createInsertSchema(admins, {
adminId: z.string(),
enableDockerCleanup: z.boolean().optional(),
sshPrivateKey: z.string().optional(),
certificateType: z.enum(["letsencrypt", "none"]).default("none"),
serverIp: z.string().optional(),
letsEncryptEmail: z.string().optional(),
adminId: z.string(),
enableDockerCleanup: z.boolean().optional(),
sshPrivateKey: z.string().optional(),
certificateType: z.enum(["letsencrypt", "none"]).default("none"),
serverIp: z.string().optional(),
letsEncryptEmail: z.string().optional(),
});
export const apiUpdateAdmin = createSchema.partial();
export const apiSaveSSHKey = createSchema
.pick({
sshPrivateKey: true,
})
.required();
.pick({
sshPrivateKey: true,
})
.required();
export const apiAssignDomain = createSchema
.pick({
host: true,
certificateType: true,
letsEncryptEmail: true,
})
.required()
.partial({
letsEncryptEmail: true,
});
.pick({
host: true,
certificateType: true,
letsEncryptEmail: true,
})
.required()
.partial({
letsEncryptEmail: true,
});
export const apiUpdateDockerCleanup = createSchema
.pick({
enableDockerCleanup: true,
})
.required()
.extend({
serverId: z.string().optional(),
});
.pick({
enableDockerCleanup: true,
})
.required()
.extend({
serverId: z.string().optional(),
});
export const apiTraefikConfig = z.object({
traefikConfig: z.string().min(1),
traefikConfig: z.string().min(1),
});
export const apiModifyTraefikConfig = z.object({
path: z.string().min(1),
traefikConfig: z.string().min(1),
serverId: z.string().optional(),
path: z.string().min(1),
traefikConfig: z.string().min(1),
serverId: z.string().optional(),
});
export const apiReadTraefikConfig = z.object({
path: z.string().min(1),
serverId: z.string().optional(),
path: z.string().min(1),
serverId: z.string().optional(),
});
export const apiEnableDashboard = z.object({
enableDashboard: z.boolean().optional(),
serverId: z.string().optional(),
enableDashboard: z.boolean().optional(),
serverId: z.string().optional(),
});
export const apiServerSchema = z
.object({
serverId: z.string().optional(),
})
.optional();
.object({
serverId: z.string().optional(),
})
.optional();
export const apiReadStatsLogs = z.object({
page: z
.object({
pageIndex: z.number(),
pageSize: z.number(),
})
.optional(),
status: z.string().array().optional(),
search: z.string().optional(),
sort: z.object({ id: z.string(), desc: z.boolean() }).optional(),
page: z
.object({
pageIndex: z.number(),
pageSize: z.number(),
})
.optional(),
status: z.string().array().optional(),
search: z.string().optional(),
sort: z.object({ id: z.string(), desc: z.boolean() }).optional(),
});

View File

@ -1,37 +1,66 @@
import { boolean, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod";
import { admins } from "./admin";
import { relations } from "drizzle-orm";
import { nanoid } from "nanoid";
export const ai = pgTable("ai", {
authId: text("authId").notNull().primaryKey(),
apiUrl: text("apiUrl").notNull(),
apiKey: text("apiKey").notNull(),
model: text("model").notNull(),
isEnabled: boolean("isEnabled").notNull().default(true),
aiId: text("aiId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
apiUrl: text("apiUrl").notNull(),
apiKey: text("apiKey").notNull(),
model: text("model").notNull(),
isEnabled: boolean("isEnabled").notNull().default(true),
adminId: text("adminId")
.notNull()
.references(() => admins.adminId, { onDelete: "cascade" }), // Admin ID who created the AI settings
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
});
export const aiRelations = relations(ai, ({ one }) => ({
admin: one(admins, {
fields: [ai.adminId],
references: [admins.adminId],
}),
}));
const createSchema = createInsertSchema(ai, {
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
apiKey: z.string().min(1, { message: "API Key is required" }),
model: z.string().min(1, { message: "Model is required" }),
isEnabled: z.boolean().optional(),
name: z.string().min(1, { message: "Name is required" }),
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
apiKey: z.string().min(1, { message: "API Key is required" }),
model: z.string().min(1, { message: "Model is required" }),
isEnabled: z.boolean().optional(),
});
export const apiAiSettingsSchema = createSchema
.pick({
apiUrl: true,
apiKey: true,
model: true,
isEnabled: true,
})
.required();
export const apiCreateAi = createSchema
.pick({
name: true,
apiUrl: true,
apiKey: true,
model: true,
isEnabled: true,
})
.required();
export const apiUpdateAi = createSchema
.partial()
.extend({
aiId: z.string().min(1),
})
.omit({ adminId: true });
export const deploySuggestionSchema = z.object({
projectId: z.string().min(1),
id: z.string().min(1),
dockerCompose: z.string().min(1),
envVariables: z.string(),
serverId: z.string().optional(),
name: z.string().min(1),
description: z.string(),
projectId: z.string().min(1),
id: z.string().min(1),
dockerCompose: z.string().min(1),
envVariables: z.string(),
serverId: z.string().optional(),
name: z.string().min(1),
description: z.string(),
});

View File

@ -3,102 +3,160 @@ import { ai } from "@dokploy/server/db/schema";
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
import { TRPCError } from "@trpc/server";
import { generateObject } from "ai";
import { eq } from "drizzle-orm";
import { desc, eq } from "drizzle-orm";
import { z } from "zod";
import { IS_CLOUD } from "../constants";
import { findAdminById } from "./admin";
export const getAiSettingsByAuthId = async (authId: string) => {
const aiSettings = await db.query.ai.findFirst({
where: eq(ai.authId, authId),
});
if (!aiSettings) {
throw new TRPCError({
code: "NOT_FOUND",
message: "AI settings not found for the user",
});
}
return aiSettings;
export const getAiSettingsByAdminId = async (adminId: string) => {
const aiSettings = await db.query.ai.findMany({
where: eq(ai.adminId, adminId),
orderBy: desc(ai.createdAt),
});
return aiSettings;
};
export const saveAiSettings = async (authId: string, settings: any) => {
return db
.insert(ai)
.values({
authId,
...settings,
})
.onConflictDoUpdate({
target: ai.authId,
set: {
...settings,
},
});
export const getAiSettingById = async (aiId: string) => {
const aiSetting = await db.query.ai.findFirst({
where: eq(ai.aiId, aiId),
});
if (!aiSetting) {
throw new TRPCError({
code: "NOT_FOUND",
message: "AI settings not found",
});
}
return aiSetting;
};
export const suggestVariants = async (authId: string, input: string) => {
const aiSettings = await getAiSettingsByAuthId(authId);
if (!aiSettings || !aiSettings.isEnabled) {
throw new TRPCError({
code: "NOT_FOUND",
message: "AI features are not enabled",
});
}
export const saveAiSettings = async (adminId: string, settings: any) => {
const aiId = settings.aiId;
const provider = selectAIProvider(aiSettings);
const model = provider(aiSettings.model);
const { object } = await generateObject({
model,
output: "array",
schema: z.object({
id: z.string(),
name: z.string(),
shortDescription: z.string(),
description: z.string(),
}),
prompt: `
Act as advanced DevOps engineer and generate a list of open source projects what can cover users needs(up to 3 items), the suggestion
should include id, name, shortDescription, and description. Use slug of title for id. The description should be in markdown format with full description of suggested stack. The shortDescription should be in plain text and have short information about used technologies.
User wants to create a new project with the following details, it should be installable in docker and can be docker compose generated for it:
${input}
`,
});
if (object?.length) {
const result = [];
for (const suggestion of object) {
const { object: docker } = await generateObject({
model,
output: "object",
schema: z.object({
dockerCompose: z.string(),
envVariables: z.array(
z.object({
name: z.string(),
value: z.string(),
}),
),
}),
prompt: `
Act as advanced DevOps engineer and generate docker compose with environment variables needed to install the following project,
use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker compose. Use complex values for passwords/secrets variables.
Don\'t set container_name field in services. Don\'t set version field in the docker compose.
Project details:
${suggestion?.description}
`,
});
if (!!docker && !!docker.dockerCompose) {
result.push({
...suggestion,
...docker,
});
}
}
return result;
}
throw new TRPCError({
code: "NOT_FOUND",
message: "No suggestions found",
});
return db
.insert(ai)
.values({
aiId,
adminId,
...settings,
})
.onConflictDoUpdate({
target: ai.aiId,
set: {
...settings,
},
});
};
export const deleteAiSettings = async (aiId: string) => {
return db.delete(ai).where(eq(ai.aiId, aiId));
};
export const suggestVariants = async (
adminId: string,
aiId: string,
input: string
) => {
try {
const aiSettings = await getAiSettingById(aiId);
if (!aiSettings || !aiSettings.isEnabled) {
throw new TRPCError({
code: "NOT_FOUND",
message: "AI features are not enabled for this configuration",
});
}
const provider = selectAIProvider(aiSettings);
const model = provider(aiSettings.model);
let ip = "";
if (!IS_CLOUD) {
const admin = await findAdminById(adminId);
ip = admin?.serverIp || "";
}
const { object } = await generateObject({
model,
output: "array",
schema: z.object({
id: z.string(),
name: z.string(),
shortDescription: z.string(),
description: z.string(),
}),
prompt: `
Act as advanced DevOps engineer and generate a list of open source projects what can cover users needs(up to 3 items), the suggestion
should include id, name, shortDescription, and description. Use slug of title for id. The description should be in markdown format with full description of suggested stack. The shortDescription should be in plain text and have short information about used technologies.
User wants to create a new project with the following details, it should be installable in docker and can be docker compose generated for it:
${input}
`,
});
if (object?.length) {
const result = [];
for (const suggestion of object) {
try {
const { object: docker } = await generateObject({
model,
output: "object",
schema: z.object({
dockerCompose: z.string(),
envVariables: z.array(
z.object({
name: z.string(),
value: z.string(),
})
),
domains: z.array(
z.object({
host: z.string(),
port: z.number(),
serviceName: z.string(),
})
),
}),
prompt: `
Act as advanced DevOps engineer and generate docker compose with environment variables and domain configurations needed to install the following project.
Return the docker compose as a YAML string. Follow these rules:
1. Use placeholder like \${VARIABLE_NAME-default} for generated variables
2. Use complex values for passwords/secrets variables
3. Don't set container_name field in services
4. Don't set version field in the docker compose
5. Don't set ports like 'ports: 3000:3000', use 'ports: ["3000"]' instead
6. Use dokploy-network in all services
7. Add dokploy-network at the end and mark it as external: true
For each service that needs to be exposed to the internet:
1. Define a domain configuration with:
- host: the domain name for the service
- port: the internal port the service runs on
- serviceName: the name of the service in the docker-compose
2. Make sure the service is properly configured in the docker-compose to work with the specified port
Project details:
${suggestion?.description}
`,
});
if (!!docker && !!docker.dockerCompose) {
result.push({
...suggestion,
...docker,
});
}
} catch (error) {
console.error("Error in docker compose generation:", error);
console.error("Error details:", error.cause?.issues || error);
}
}
return result;
}
throw new TRPCError({
code: "NOT_FOUND",
message: "No suggestions found",
});
} catch (error) {
console.error("Error in suggestVariants:", error);
throw error;
}
};