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 && (
{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 <StepOne
nextStep={nextStep} nextStep={nextStep}
setTemplateInfo={setTemplateInfo} setTemplateInfo={setTemplateInfo}
templateInfo={templateInfo} 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">
<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> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2 py-8 border-t">
<Form {...form}> {isLoading ? (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<FormField <span>Loading...</span>
control={form.control} <Loader2 className="animate-spin size-4" />
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> </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
type="submit"
isLoading={isLoading}
disabled={!!error || isLoadingModels || !form.watch("model")}
>
Save
</Button>
</form>
</Form>
</CardContent> </CardContent>
</div>
</Card> </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 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,14 +1,21 @@
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,
apiUpdateAi,
deploySuggestionSchema, deploySuggestionSchema,
} from "@dokploy/server/db/schema/ai"; } from "@dokploy/server/db/schema/ai";
import { import {
getAiSettingsByAuthId, getAiSettingsByAdminId,
getAiSettingById,
saveAiSettings, saveAiSettings,
deleteAiSettings,
suggestVariants, 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";
@@ -21,24 +28,73 @@ 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() }))
.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);
}),
update: protectedProcedure
.input(apiUpdateAi)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
return await saveAiSettings(ctx.user.authId, input); return await saveAiSettings(ctx.user.adminId, input);
}), }),
get: protectedProcedure.query(async ({ ctx }) => {
return await getAiSettingsByAuthId(ctx.user.authId); getAll: adminProcedure.query(async ({ ctx }) => {
return await getAiSettingsByAdminId(ctx.user.adminId);
}), }),
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;
}),
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);
}),
suggest: protectedProcedure suggest: protectedProcedure
.input(z.string()) .input(
z.object({
aiId: z.string(),
prompt: z.string(),
})
)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
return await suggestVariants(ctx.user.authId, input); return await suggestVariants(ctx.user.adminId, input.aiId, input.prompt);
}), }),
deploy: protectedProcedure deploy: protectedProcedure
.input(deploySuggestionSchema) .input(deploySuggestionSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
if (ctx.user.rol === "user") { if (ctx.user.rol === "user") {
await checkServiceAccess(ctx.user.authId, input.projectId, "create"); await checkServiceAccess(ctx.user.adminId, input.projectId, "create");
} }
if (IS_CLOUD && !input.serverId) { if (IS_CLOUD && !input.serverId) {

View File

@@ -9,6 +9,7 @@ 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")
@@ -42,6 +43,7 @@ export const adminsRelations = relations(admins, ({ one, many }) => ({
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, {

View File

@@ -1,24 +1,46 @@
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")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
apiUrl: text("apiUrl").notNull(), apiUrl: text("apiUrl").notNull(),
apiKey: text("apiKey").notNull(), apiKey: text("apiKey").notNull(),
model: text("model").notNull(), model: text("model").notNull(),
isEnabled: boolean("isEnabled").notNull().default(true), 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, {
name: z.string().min(1, { message: "Name is required" }),
apiUrl: z.string().url({ message: "Please enter a valid URL" }), apiUrl: z.string().url({ message: "Please enter a valid URL" }),
apiKey: z.string().min(1, { message: "API Key is required" }), apiKey: z.string().min(1, { message: "API Key is required" }),
model: z.string().min(1, { message: "Model is required" }), model: z.string().min(1, { message: "Model is required" }),
isEnabled: z.boolean().optional(), isEnabled: z.boolean().optional(),
}); });
export const apiAiSettingsSchema = createSchema export const apiCreateAi = createSchema
.pick({ .pick({
name: true,
apiUrl: true, apiUrl: true,
apiKey: true, apiKey: true,
model: true, model: true,
@@ -26,6 +48,13 @@ export const apiAiSettingsSchema = createSchema
}) })
.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),

View File

@@ -3,48 +3,77 @@ 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({
code: "NOT_FOUND",
message: "AI settings not found for the user",
});
}
return aiSettings; return aiSettings;
}; };
export const saveAiSettings = async (authId: string, settings: any) => { export const getAiSettingById = async (aiId: string) => {
const aiSetting = await db.query.ai.findFirst({
where: eq(ai.aiId, aiId),
});
if (!aiSetting) {
throw new TRPCError({
code: "NOT_FOUND",
message: "AI settings not found",
});
}
return aiSetting;
};
export const saveAiSettings = async (adminId: string, settings: any) => {
const aiId = settings.aiId;
return db return db
.insert(ai) .insert(ai)
.values({ .values({
authId, aiId,
adminId,
...settings, ...settings,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: ai.authId, target: ai.aiId,
set: { set: {
...settings, ...settings,
}, },
}); });
}; };
export const suggestVariants = async (authId: string, input: string) => { export const deleteAiSettings = async (aiId: string) => {
const aiSettings = await getAiSettingsByAuthId(authId); 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) { if (!aiSettings || !aiSettings.isEnabled) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "AI features are not enabled", message: "AI features are not enabled for this configuration",
}); });
} }
const provider = selectAIProvider(aiSettings); const provider = selectAIProvider(aiSettings);
const model = provider(aiSettings.model); const model = provider(aiSettings.model);
let ip = "";
if (!IS_CLOUD) {
const admin = await findAdminById(adminId);
ip = admin?.serverIp || "";
}
const { object } = await generateObject({ const { object } = await generateObject({
model, model,
output: "array", output: "array",
@@ -66,6 +95,7 @@ export const suggestVariants = async (authId: string, input: string) => {
if (object?.length) { if (object?.length) {
const result = []; const result = [];
for (const suggestion of object) { for (const suggestion of object) {
try {
const { object: docker } = await generateObject({ const { object: docker } = await generateObject({
model, model,
output: "object", output: "object",
@@ -75,13 +105,33 @@ export const suggestVariants = async (authId: string, input: string) => {
z.object({ z.object({
name: z.string(), name: z.string(),
value: z.string(), value: z.string(),
}), })
),
domains: z.array(
z.object({
host: z.string(),
port: z.number(),
serviceName: z.string(),
})
), ),
}), }),
prompt: ` prompt: `
Act as advanced DevOps engineer and generate docker compose with environment variables needed to install the following project, Act as advanced DevOps engineer and generate docker compose with environment variables and domain configurations 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. Return the docker compose as a YAML string. Follow these rules:
Don\'t set container_name field in services. Don\'t set version field in the docker compose. 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: Project details:
${suggestion?.description} ${suggestion?.description}
@@ -93,6 +143,10 @@ export const suggestVariants = async (authId: string, input: string) => {
...docker, ...docker,
}); });
} }
} catch (error) {
console.error("Error in docker compose generation:", error);
console.error("Error details:", error.cause?.issues || error);
}
} }
return result; return result;
} }
@@ -101,4 +155,8 @@ export const suggestVariants = async (authId: string, input: string) => {
code: "NOT_FOUND", code: "NOT_FOUND",
message: "No suggestions found", message: "No suggestions found",
}); });
} catch (error) {
console.error("Error in suggestVariants:", error);
throw error;
}
}; };