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 { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import type { StepProps } from "./step-two";
export const StepFour = ({ export const StepFour = ({
prevStep, prevStep,
templateInfo, templateInfo,
setOpen, setOpen,
setTemplateInfo, setTemplateInfo,
}: any) => { }: StepProps) => {
const handleSubmit = () => { const handleSubmit = () => {
setTemplateInfo(templateInfo); // Update the template info setTemplateInfo(templateInfo); // Update the template info
setOpen(false); setOpen(false);
@@ -17,36 +18,41 @@ export const StepFour = ({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex-grow"> <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> <h2 className="text-lg font-semibold">Step 4: Review and Finalize</h2>
<ScrollArea className="h-[400px] p-5"> <ScrollArea className="h-[400px] p-5">
<div className="space-y-4"> <div className="space-y-4">
<div className="p-4"> <div>
<ReactMarkdown className="prose dark:prose-invert"> <h3 className="text-sm font-semibold">Name</h3>
{templateInfo.details.description} <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> </ReactMarkdown>
</div> </div>
<div>
<h3 className="text-md font-semibold">Name</h3>
<p>{templateInfo.name}</p>
</div>
<div> <div>
<h3 className="text-md font-semibold">Server</h3> <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>
<div> <div>
<h3 className="text-md font-semibold">Docker Compose</h3> <h3 className="text-sm font-semibold">Docker Compose</h3>
<CodeEditor <CodeEditor
lineWrapping lineWrapping
value={templateInfo.details.dockerCompose} value={templateInfo?.details?.dockerCompose}
disabled disabled
className="font-mono" className="font-mono"
/> />
</div> </div>
<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"> <ul className="list-disc pl-5">
{templateInfo.details.envVariables.map( {templateInfo?.details?.envVariables.map(
( (
env: { env: {
name: string; name: string;
@@ -55,8 +61,13 @@ export const StepFour = ({
index: number, index: number,
) => ( ) => (
<li key={index}> <li key={index}>
<strong>{env.name}</strong>: <strong className="text-sm font-semibold">
<span className="ml-2 font-mono">{env.value}</span> {env.name}
</strong>
:
<span className="text-sm ml-2 text-muted-foreground">
{env.value}
</span>
</li> </li>
), ),
)} )}

View File

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

View File

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

View File

@@ -8,27 +8,65 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { AlertCircle, Bot } from "lucide-react"; import { Bot } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { StepFour } from "./step-four"; import { StepFour } from "./step-four";
import { StepOne } from "./step-one"; import { StepOne } from "./step-one";
import { StepThree } from "./step-three"; import { StepThree } from "./step-three";
import { StepTwo } from "./step-two"; 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: "", userInput: "",
type: "", type: "",
details: { details: {
id: "", id: "",
name: "",
description: "",
dockerCompose: "", dockerCompose: "",
envVariables: [], envVariables: [],
shortDescription: "", shortDescription: "",
domains: [],
}, },
name: "",
server: undefined,
description: "",
}; };
interface Props { interface Props {
@@ -39,9 +77,10 @@ interface Props {
export const TemplateGenerator = ({ projectId }: Props) => { export const TemplateGenerator = ({ projectId }: Props) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [step, setStep] = useState(1); 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 { mutateAsync } = api.ai.deploy.useMutation();
const [templateInfo, setTemplateInfo] = useState(emptyState); const [templateInfo, setTemplateInfo] =
useState<TemplateInfo>(defaultTemplateInfo);
const utils = api.useUtils(); const utils = api.useUtils();
const totalSteps = 4; const totalSteps = 4;
@@ -52,12 +91,15 @@ export const TemplateGenerator = ({ projectId }: Props) => {
const handleOpenChange = (newOpen: boolean) => { const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen); setOpen(newOpen);
if (!newOpen) { if (!newOpen) {
// Reset to the first step when closing the dialog
setStep(1); setStep(1);
setTemplateInfo(emptyState); setTemplateInfo(defaultTemplateInfo);
} }
}; };
const haveAtleasOneProviderEnabled = aiSettings?.some(
(ai) => ai.isEnabled === true,
);
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild className="w-full"> <DialogTrigger asChild className="w-full">
@@ -69,7 +111,7 @@ export const TemplateGenerator = ({ projectId }: Props) => {
<span>AI Assistant</span> <span>AI Assistant</span>
</DropdownMenuItem> </DropdownMenuItem>
</DialogTrigger> </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> <DialogHeader>
<DialogTitle>AI Assistant</DialogTitle> <DialogTitle>AI Assistant</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -79,29 +121,65 @@ export const TemplateGenerator = ({ projectId }: Props) => {
<div className="mt-4 flex-grow overflow-auto"> <div className="mt-4 flex-grow overflow-auto">
{step === 1 && ( {step === 1 && (
<> <>
{(!aiSettings || !aiSettings?.isEnabled) && ( {!haveAtleasOneProviderEnabled && (
<Alert variant="destructive" className="mb-4"> <AlertBlock type="warning">
<AlertCircle className="h-4 w-4" /> <div className="flex flex-col w-full">
<AlertTitle>AI features are not enabled</AlertTitle> <span>AI features are not enabled</span>
<AlertDescription> <span>
To use AI-powered template generation, please{" "} To use AI-powered template generation, please{" "}
<a <Link
href="/dashboard/settings/ai" href="/dashboard/settings/ai"
className="font-medium underline underline-offset-4" className="font-medium underline underline-offset-4"
> >
enable AI in your settings enable AI in your settings
</a> </Link>
. .
</AlertDescription> </span>
</Alert> </div>
)} </AlertBlock>
{!!aiSettings && !!aiSettings?.isEnabled && (
<StepOne
nextStep={nextStep}
setTemplateInfo={setTemplateInfo}
templateInfo={templateInfo}
/>
)} )}
{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 && ( {step === 2 && (
@@ -129,14 +207,14 @@ export const TemplateGenerator = ({ projectId }: Props) => {
setTemplateInfo(data); setTemplateInfo(data);
await mutateAsync({ await mutateAsync({
projectId, projectId,
id: templateInfo.details?.id, id: templateInfo.details?.id || "",
name: templateInfo.name, name: templateInfo?.details?.name || "",
description: data.details.shortDescription, description: data?.details?.shortDescription || "",
dockerCompose: data.details.dockerCompose, dockerCompose: data?.details?.dockerCompose || "",
envVariables: (data.details?.envVariables || []) envVariables: (data?.details?.envVariables || [])
.map((env: any) => `${env.name}=${env.value}`) .map((env: any) => `${env.name}=${env.value}`)
.join("\n"), .join("\n"),
serverId: data.server, serverId: data.server || "",
}) })
.then(async () => { .then(async () => {
toast.success("Compose Created"); toast.success("Compose Created");

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -9,268 +8,99 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } 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 { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { BotIcon, Loader2, Trash2 } from "lucide-react";
import { AlertCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { HandleAi } from "./handle-ai";
import { DialogAction } from "@/components/shared/dialog-action";
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 const AiForm = () => { export const AiForm = () => {
const [models, setModels] = useState<Model[]>([]); const { data: aiConfigs, refetch, isLoading } = api.ai.getAll.useQuery();
const [isLoadingModels, setIsLoadingModels] = useState(false); const { mutateAsync, isLoading: isRemoving } = api.ai.delete.useMutation();
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 ( return (
<Card className="bg-transparent"> <div className="w-full">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center"> <Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<CardTitle className="text-xl">AI Settings</CardTitle> <div className="rounded-xl bg-background shadow-md ">
<CardDescription> <CardHeader className="flex flex-row gap-2 justify-between">
Configure your AI model settings here. <div>
</CardDescription> <CardTitle className="text-xl flex flex-row gap-2">
</CardHeader> <BotIcon className="size-6 text-muted-foreground self-center" />
<CardContent className="space-y-2"> AI Settings
<Form {...form}> </CardTitle>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <CardDescription>Manage your AI configurations</CardDescription>
<FormField </div>
control={form.control} {aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
name="isEnabled" </CardHeader>
render={({ field }) => ( <CardContent className="space-y-2 py-8 border-t">
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4"> {isLoading ? (
<div className="space-y-0.5"> <div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<FormLabel className="text-base">Enable AI</FormLabel> <span>Loading...</span>
<FormDescription> <Loader2 className="animate-spin size-4" />
Turn on or off AI functionality </div>
</FormDescription> ) : (
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{!!form.watch("isEnabled") && (
<> <>
<FormField {aiConfigs?.length === 0 ? (
control={form.control} <div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
name="apiUrl" <BotIcon className="size-8 self-center text-muted-foreground" />
render={({ field }) => ( <span className="text-base text-muted-foreground text-center">
<FormItem> You don't have any AI configurations
<FormLabel>API URL</FormLabel> </span>
<FormControl> <HandleAi />
<Input </div>
placeholder="https://api.openai.com/v1" ) : (
{...field} <div className="flex flex-col gap-4 rounded-lg min-h-[25vh]">
/> {aiConfigs?.map((config) => (
</FormControl> <div
<FormMessage /> key={config.aiId}
<p className="text-sm text-muted-foreground mt-1"> className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
By default, the OpenAI API URL is used. Only change this >
if you're using a different API. <div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
</p> <div>
</FormItem> <span className="text-sm font-medium">
)} {config.name}
/> </span>
<FormField <CardDescription>{config.model}</CardDescription>
control={form.control} </div>
name="apiKey" <div className="flex justify-between items-center">
render={({ field }) => ( <HandleAi aiId={config.aiId} />
<FormItem> <DialogAction
<FormLabel>API Key</FormLabel> title="Delete AI"
<FormControl> description="Are you sure you want to delete this AI?"
<Input type="destructive"
type="password" onClick={async () => {
placeholder="Enter your API key" await mutateAsync({
{...field} aiId: config.aiId,
/> })
</FormControl> .then(() => {
<FormMessage /> toast.success("AI deleted successfully");
{form.watch("apiUrl") === "https://api.openai.com/v1" && ( refetch();
<p className="text-sm text-muted-foreground mt-1"> })
You can find your API key on the{" "} .catch(() => {
<a toast.error("Error deleting AI");
href="https://platform.openai.com/settings/organization/api-keys" });
target="_blank" }}
rel="noopener noreferrer" >
className="underline hover:text-primary" <Button
> variant="ghost"
OpenAI account page size="icon"
</a> className="group hover:bg-red-500/10 "
. isLoading={isRemoving}
</p> >
)} <Trash2 className="size-4 text-primary group-hover:text-red-500" />
</FormItem> </Button>
)} </DialogAction>
/> </div>
{!isLoadingModels && models.length > 0 && ( </div>
<FormField </div>
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> </div>
)}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)} )}
</> </>
)} )}
<Button </CardContent>
type="submit" </div>
isLoading={isLoading} </Card>
disabled={!!error || isLoadingModels || !form.watch("model")} </div>
>
Save
</Button>
</form>
</Form>
</CardContent>
</Card>
); );
}; };

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 Add SSH Key
</Button> </Button>
)} )}
{/* {children} */}
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl"> <DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader> <DialogHeader>

View File

@@ -19,6 +19,7 @@ import {
GitBranch, GitBranch,
HeartIcon, HeartIcon,
KeyRound, KeyRound,
BotIcon,
type LucideIcon, type LucideIcon,
Package, Package,
PieChart, PieChart,
@@ -249,7 +250,13 @@ const data = {
isSingle: true, isSingle: true,
isActive: false, isActive: false,
}, },
{
title: "AI",
icon: BotIcon,
url: "/dashboard/settings/ai",
isSingle: true,
isActive: false,
},
{ {
title: "Git", title: "Git",
url: "/dashboard/settings/git-providers", 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", "prevId": "24787a88-0754-437a-b077-03a3265b8ef5",
"version": "6", "version": "6",
"dialect": "postgresql", "dialect": "postgresql",
@@ -4144,12 +4144,18 @@
"name": "ai", "name": "ai",
"schema": "", "schema": "",
"columns": { "columns": {
"authId": { "aiId": {
"name": "authId", "name": "aiId",
"type": "text", "type": "text",
"primaryKey": true, "primaryKey": true,
"notNull": true "notNull": true
}, },
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"apiUrl": { "apiUrl": {
"name": "apiUrl", "name": "apiUrl",
"type": "text", "type": "text",
@@ -4174,10 +4180,36 @@
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"default": true "default": true
},
"adminId": {
"name": "adminId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "text",
"primaryKey": false,
"notNull": true
} }
}, },
"indexes": {}, "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": {}, "compositePrimaryKeys": {},
"uniqueConstraints": {} "uniqueConstraints": {}
} }

View File

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

View File

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

View File

@@ -1,71 +1,127 @@
import { slugify } from "@/lib/slug"; 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 { generatePassword } from "@/templates/utils";
import { IS_CLOUD } from "@dokploy/server/constants"; import { IS_CLOUD } from "@dokploy/server/constants";
import { import {
apiAiSettingsSchema, apiCreateAi,
deploySuggestionSchema, apiUpdateAi,
deploySuggestionSchema,
} from "@dokploy/server/db/schema/ai"; } from "@dokploy/server/db/schema/ai";
import { import {
getAiSettingsByAuthId, getAiSettingsByAdminId,
saveAiSettings, getAiSettingById,
suggestVariants, saveAiSettings,
deleteAiSettings,
suggestVariants,
} from "@dokploy/server/services/ai"; } from "@dokploy/server/services/ai";
import { createComposeByTemplate } from "@dokploy/server/services/compose"; import { createComposeByTemplate } from "@dokploy/server/services/compose";
import { findProjectById } from "@dokploy/server/services/project"; import { findProjectById } from "@dokploy/server/services/project";
import { import {
addNewService, addNewService,
checkServiceAccess, checkServiceAccess,
} from "@dokploy/server/services/user"; } from "@dokploy/server/services/user";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
export const aiRouter = createTRPCRouter({ export const aiRouter = createTRPCRouter({
save: protectedProcedure one: protectedProcedure
.input(apiAiSettingsSchema) .input(z.object({ aiId: z.string() }))
.mutation(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
return await saveAiSettings(ctx.user.authId, input); const aiSetting = await getAiSettingById(input.aiId);
}), if (aiSetting.adminId !== ctx.user.adminId) {
get: protectedProcedure.query(async ({ ctx }) => { throw new TRPCError({
return await getAiSettingsByAuthId(ctx.user.authId); code: "UNAUTHORIZED",
}), message: "You don't have access to this AI configuration",
suggest: protectedProcedure });
.input(z.string()) }
.mutation(async ({ ctx, input }) => { return aiSetting;
return await suggestVariants(ctx.user.authId, input); }),
}), create: adminProcedure.input(apiCreateAi).mutation(async ({ ctx, input }) => {
deploy: protectedProcedure return await saveAiSettings(ctx.user.adminId, input);
.input(deploySuggestionSchema) }),
.mutation(async ({ ctx, input }) => {
if (ctx.user.rol === "user") {
await checkServiceAccess(ctx.user.authId, input.projectId, "create");
}
if (IS_CLOUD && !input.serverId) { update: protectedProcedure
throw new TRPCError({ .input(apiUpdateAi)
code: "UNAUTHORIZED", .mutation(async ({ ctx, input }) => {
message: "You need to use a server to create a compose", 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({ delete: protectedProcedure
...input, .input(z.object({ aiId: z.string() }))
composeFile: input.dockerCompose, .mutation(async ({ ctx, input }) => {
env: input.envVariables, const aiSetting = await getAiSettingById(input.aiId);
serverId: input.serverId, if (aiSetting.adminId !== ctx.user.adminId) {
name: input.name, throw new TRPCError({
sourceType: "raw", code: "UNAUTHORIZED",
appName: `${projectName}-${generatePassword(6)}`, message: "You don't have access to this AI configuration",
}); });
}
return await deleteAiSettings(input.aiId);
}),
if (ctx.user.rol === "user") { suggest: protectedProcedure
await addNewService(ctx.user.authId, compose.composeId); .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 { certificateType } from "./shared";
import { sshKeys } from "./ssh-key"; import { sshKeys } from "./ssh-key";
import { users } from "./user"; import { users } from "./user";
import { ai } from "./ai";
export const admins = pgTable("admin", { export const admins = pgTable("admin", {
adminId: text("adminId") adminId: text("adminId")
.notNull() .notNull()
.primaryKey() .primaryKey()
.$defaultFn(() => nanoid()), .$defaultFn(() => nanoid()),
serverIp: text("serverIp"), serverIp: text("serverIp"),
certificateType: certificateType("certificateType").notNull().default("none"), certificateType: certificateType("certificateType").notNull().default("none"),
host: text("host"), host: text("host"),
letsEncryptEmail: text("letsEncryptEmail"), letsEncryptEmail: text("letsEncryptEmail"),
sshPrivateKey: text("sshPrivateKey"), sshPrivateKey: text("sshPrivateKey"),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false), enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
enableLogRotation: boolean("enableLogRotation").notNull().default(false), enableLogRotation: boolean("enableLogRotation").notNull().default(false),
authId: text("authId") authId: text("authId")
.notNull() .notNull()
.references(() => auth.id, { onDelete: "cascade" }), .references(() => auth.id, { onDelete: "cascade" }),
createdAt: text("createdAt") createdAt: text("createdAt")
.notNull() .notNull()
.$defaultFn(() => new Date().toISOString()), .$defaultFn(() => new Date().toISOString()),
stripeCustomerId: text("stripeCustomerId"), stripeCustomerId: text("stripeCustomerId"),
stripeSubscriptionId: text("stripeSubscriptionId"), stripeSubscriptionId: text("stripeSubscriptionId"),
serversQuantity: integer("serversQuantity").notNull().default(0), serversQuantity: integer("serversQuantity").notNull().default(0),
}); });
export const adminsRelations = relations(admins, ({ one, many }) => ({ export const adminsRelations = relations(admins, ({ one, many }) => ({
auth: one(auth, { auth: one(auth, {
fields: [admins.authId], fields: [admins.authId],
references: [auth.id], references: [auth.id],
}), }),
users: many(users), users: many(users),
registry: many(registry), registry: many(registry),
sshKeys: many(sshKeys), sshKeys: many(sshKeys),
certificates: many(certificates), certificates: many(certificates),
ai: many(ai),
})); }));
const createSchema = createInsertSchema(admins, { const createSchema = createInsertSchema(admins, {
adminId: z.string(), adminId: z.string(),
enableDockerCleanup: z.boolean().optional(), enableDockerCleanup: z.boolean().optional(),
sshPrivateKey: z.string().optional(), sshPrivateKey: z.string().optional(),
certificateType: z.enum(["letsencrypt", "none"]).default("none"), certificateType: z.enum(["letsencrypt", "none"]).default("none"),
serverIp: z.string().optional(), serverIp: z.string().optional(),
letsEncryptEmail: z.string().optional(), letsEncryptEmail: z.string().optional(),
}); });
export const apiUpdateAdmin = createSchema.partial(); export const apiUpdateAdmin = createSchema.partial();
export const apiSaveSSHKey = createSchema export const apiSaveSSHKey = createSchema
.pick({ .pick({
sshPrivateKey: true, sshPrivateKey: true,
}) })
.required(); .required();
export const apiAssignDomain = createSchema export const apiAssignDomain = createSchema
.pick({ .pick({
host: true, host: true,
certificateType: true, certificateType: true,
letsEncryptEmail: true, letsEncryptEmail: true,
}) })
.required() .required()
.partial({ .partial({
letsEncryptEmail: true, letsEncryptEmail: true,
}); });
export const apiUpdateDockerCleanup = createSchema export const apiUpdateDockerCleanup = createSchema
.pick({ .pick({
enableDockerCleanup: true, enableDockerCleanup: true,
}) })
.required() .required()
.extend({ .extend({
serverId: z.string().optional(), serverId: z.string().optional(),
}); });
export const apiTraefikConfig = z.object({ export const apiTraefikConfig = z.object({
traefikConfig: z.string().min(1), traefikConfig: z.string().min(1),
}); });
export const apiModifyTraefikConfig = z.object({ export const apiModifyTraefikConfig = z.object({
path: z.string().min(1), path: z.string().min(1),
traefikConfig: z.string().min(1), traefikConfig: z.string().min(1),
serverId: z.string().optional(), serverId: z.string().optional(),
}); });
export const apiReadTraefikConfig = z.object({ export const apiReadTraefikConfig = z.object({
path: z.string().min(1), path: z.string().min(1),
serverId: z.string().optional(), serverId: z.string().optional(),
}); });
export const apiEnableDashboard = z.object({ export const apiEnableDashboard = z.object({
enableDashboard: z.boolean().optional(), enableDashboard: z.boolean().optional(),
serverId: z.string().optional(), serverId: z.string().optional(),
}); });
export const apiServerSchema = z export const apiServerSchema = z
.object({ .object({
serverId: z.string().optional(), serverId: z.string().optional(),
}) })
.optional(); .optional();
export const apiReadStatsLogs = z.object({ export const apiReadStatsLogs = z.object({
page: z page: z
.object({ .object({
pageIndex: z.number(), pageIndex: z.number(),
pageSize: z.number(), pageSize: z.number(),
}) })
.optional(), .optional(),
status: z.string().array().optional(), status: z.string().array().optional(),
search: z.string().optional(), search: z.string().optional(),
sort: z.object({ id: z.string(), desc: z.boolean() }).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 { boolean, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod"; import { createInsertSchema } from "drizzle-zod";
import { z } from "zod"; import { z } from "zod";
import { admins } from "./admin";
import { relations } from "drizzle-orm";
import { nanoid } from "nanoid";
export const ai = pgTable("ai", { export const ai = pgTable("ai", {
authId: text("authId").notNull().primaryKey(), aiId: text("aiId")
apiUrl: text("apiUrl").notNull(), .notNull()
apiKey: text("apiKey").notNull(), .primaryKey()
model: text("model").notNull(), .$defaultFn(() => nanoid()),
isEnabled: boolean("isEnabled").notNull().default(true), 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, { const createSchema = createInsertSchema(ai, {
apiUrl: z.string().url({ message: "Please enter a valid URL" }), name: z.string().min(1, { message: "Name is required" }),
apiKey: z.string().min(1, { message: "API Key is required" }), apiUrl: z.string().url({ message: "Please enter a valid URL" }),
model: z.string().min(1, { message: "Model is required" }), apiKey: z.string().min(1, { message: "API Key is required" }),
isEnabled: z.boolean().optional(), model: z.string().min(1, { message: "Model is required" }),
isEnabled: z.boolean().optional(),
}); });
export const apiAiSettingsSchema = createSchema export const apiCreateAi = createSchema
.pick({ .pick({
apiUrl: true, name: true,
apiKey: true, apiUrl: true,
model: true, apiKey: true,
isEnabled: true, model: true,
}) isEnabled: true,
.required(); })
.required();
export const apiUpdateAi = createSchema
.partial()
.extend({
aiId: z.string().min(1),
})
.omit({ adminId: true });
export const deploySuggestionSchema = z.object({ export const deploySuggestionSchema = z.object({
projectId: z.string().min(1), projectId: z.string().min(1),
id: z.string().min(1), id: z.string().min(1),
dockerCompose: z.string().min(1), dockerCompose: z.string().min(1),
envVariables: z.string(), envVariables: z.string(),
serverId: z.string().optional(), serverId: z.string().optional(),
name: z.string().min(1), name: z.string().min(1),
description: z.string(), 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 { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { generateObject } from "ai"; import { generateObject } from "ai";
import { eq } from "drizzle-orm"; import { desc, eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { IS_CLOUD } from "../constants";
import { findAdminById } from "./admin";
export const getAiSettingsByAuthId = async (authId: string) => { export const getAiSettingsByAdminId = async (adminId: string) => {
const aiSettings = await db.query.ai.findFirst({ const aiSettings = await db.query.ai.findMany({
where: eq(ai.authId, authId), where: eq(ai.adminId, adminId),
}); orderBy: desc(ai.createdAt),
if (!aiSettings) { });
throw new TRPCError({ return aiSettings;
code: "NOT_FOUND",
message: "AI settings not found for the user",
});
}
return aiSettings;
}; };
export const saveAiSettings = async (authId: string, settings: any) => { export const getAiSettingById = async (aiId: string) => {
return db const aiSetting = await db.query.ai.findFirst({
.insert(ai) where: eq(ai.aiId, aiId),
.values({ });
authId, if (!aiSetting) {
...settings, throw new TRPCError({
}) code: "NOT_FOUND",
.onConflictDoUpdate({ message: "AI settings not found",
target: ai.authId, });
set: { }
...settings, return aiSetting;
},
});
}; };
export const suggestVariants = async (authId: string, input: string) => { export const saveAiSettings = async (adminId: string, settings: any) => {
const aiSettings = await getAiSettingsByAuthId(authId); const aiId = settings.aiId;
if (!aiSettings || !aiSettings.isEnabled) {
throw new TRPCError({
code: "NOT_FOUND",
message: "AI features are not enabled",
});
}
const provider = selectAIProvider(aiSettings); return db
const model = provider(aiSettings.model); .insert(ai)
const { object } = await generateObject({ .values({
model, aiId,
output: "array", adminId,
schema: z.object({ ...settings,
id: z.string(), })
name: z.string(), .onConflictDoUpdate({
shortDescription: z.string(), target: ai.aiId,
description: z.string(), set: {
}), ...settings,
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:
export const deleteAiSettings = async (aiId: string) => {
${input} return db.delete(ai).where(eq(ai.aiId, aiId));
`, };
});
export const suggestVariants = async (
if (object?.length) { adminId: string,
const result = []; aiId: string,
for (const suggestion of object) { input: string
const { object: docker } = await generateObject({ ) => {
model, try {
output: "object", const aiSettings = await getAiSettingById(aiId);
schema: z.object({ if (!aiSettings || !aiSettings.isEnabled) {
dockerCompose: z.string(), throw new TRPCError({
envVariables: z.array( code: "NOT_FOUND",
z.object({ message: "AI features are not enabled for this configuration",
name: z.string(), });
value: z.string(), }
}),
), const provider = selectAIProvider(aiSettings);
}), const model = provider(aiSettings.model);
prompt: `
Act as advanced DevOps engineer and generate docker compose with environment variables needed to install the following project, let ip = "";
use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker compose. Use complex values for passwords/secrets variables. if (!IS_CLOUD) {
Don\'t set container_name field in services. Don\'t set version field in the docker compose. const admin = await findAdminById(adminId);
ip = admin?.serverIp || "";
Project details: }
${suggestion?.description}
`, const { object } = await generateObject({
}); model,
if (!!docker && !!docker.dockerCompose) { output: "array",
result.push({ schema: z.object({
...suggestion, id: z.string(),
...docker, name: z.string(),
}); shortDescription: z.string(),
} description: z.string(),
} }),
return result; 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.
throw new TRPCError({ 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:
code: "NOT_FOUND",
message: "No suggestions found", ${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;
}
}; };