mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
refactor: add many AI providers & improve prompt
This commit is contained in:
parent
ad642ab4e0
commit
08ab18eebf
@ -2,13 +2,14 @@ import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import type { StepProps } from "./step-two";
|
||||
|
||||
export const StepFour = ({
|
||||
prevStep,
|
||||
templateInfo,
|
||||
setOpen,
|
||||
setTemplateInfo,
|
||||
}: any) => {
|
||||
}: StepProps) => {
|
||||
const handleSubmit = () => {
|
||||
setTemplateInfo(templateInfo); // Update the template info
|
||||
setOpen(false);
|
||||
@ -17,36 +18,41 @@ export const StepFour = ({
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-grow">
|
||||
<div className="space-y-6 pb-20">
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold">Step 4: Review and Finalize</h2>
|
||||
<ScrollArea className="h-[400px] p-5">
|
||||
<div className="space-y-4">
|
||||
<div className="p-4">
|
||||
<ReactMarkdown className="prose dark:prose-invert">
|
||||
{templateInfo.details.description}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Name</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{templateInfo?.details?.name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Description</h3>
|
||||
<ReactMarkdown className="text-sm text-muted-foreground">
|
||||
{templateInfo?.details?.description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-md font-semibold">Name</h3>
|
||||
<p>{templateInfo.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-md font-semibold">Server</h3>
|
||||
<p>{templateInfo.server || "localhost"}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{templateInfo?.serverId || "Dokploy Server"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-md font-semibold">Docker Compose</h3>
|
||||
<h3 className="text-sm font-semibold">Docker Compose</h3>
|
||||
<CodeEditor
|
||||
lineWrapping
|
||||
value={templateInfo.details.dockerCompose}
|
||||
value={templateInfo?.details?.dockerCompose}
|
||||
disabled
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-md font-semibold">Environment Variables</h3>
|
||||
<h3 className="text-sm font-semibold">Environment Variables</h3>
|
||||
<ul className="list-disc pl-5">
|
||||
{templateInfo.details.envVariables.map(
|
||||
{templateInfo?.details?.envVariables.map(
|
||||
(
|
||||
env: {
|
||||
name: string;
|
||||
@ -55,8 +61,13 @@ export const StepFour = ({
|
||||
index: number,
|
||||
) => (
|
||||
<li key={index}>
|
||||
<strong>{env.name}</strong>:
|
||||
<span className="ml-2 font-mono">{env.value}</span>
|
||||
<strong className="text-sm font-semibold">
|
||||
{env.name}
|
||||
</strong>
|
||||
:
|
||||
<span className="text-sm ml-2 text-muted-foreground">
|
||||
{env.value}
|
||||
</span>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
|
@ -14,21 +14,21 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
import { useState } from "react";
|
||||
import type { StepProps } from "./step-two";
|
||||
|
||||
export const StepThree = ({
|
||||
nextStep,
|
||||
prevStep,
|
||||
templateInfo,
|
||||
setTemplateInfo,
|
||||
}: any) => {
|
||||
const [name, setName] = useState(templateInfo.name);
|
||||
const [server, setServer] = useState(templateInfo.server);
|
||||
}: StepProps) => {
|
||||
const [server, setServer] = useState(templateInfo.serverId);
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
|
||||
const handleNext = () => {
|
||||
const updatedInfo = { ...templateInfo, name };
|
||||
if (server?.trim()) {
|
||||
updatedInfo.server = server;
|
||||
updatedInfo.serverId = server;
|
||||
}
|
||||
setTemplateInfo(updatedInfo);
|
||||
nextStep();
|
||||
@ -43,8 +43,16 @@ export const StepThree = ({
|
||||
<Label htmlFor="name">App Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
value={templateInfo?.details?.name || ""}
|
||||
onChange={(e) => {
|
||||
setTemplateInfo({
|
||||
...templateInfo,
|
||||
details: {
|
||||
...templateInfo?.details,
|
||||
name: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
placeholder="Enter app name"
|
||||
className="mt-1"
|
||||
/>
|
||||
@ -74,7 +82,10 @@ export const StepThree = ({
|
||||
<Button onClick={prevStep} variant="outline">
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleNext} disabled={!name.trim()}>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={!templateInfo?.details?.name?.trim()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -15,19 +15,13 @@ import { Bot, Eye, EyeOff, PlusCircle, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { toast } from "sonner";
|
||||
import type { TemplateInfo } from "./template-generator";
|
||||
|
||||
interface EnvVariable {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface TemplateInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
shortDescription: string;
|
||||
description: string;
|
||||
dockerCompose: string;
|
||||
envVariables: EnvVariable[];
|
||||
export interface StepProps {
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
templateInfo: TemplateInfo;
|
||||
setTemplateInfo: React.Dispatch<React.SetStateAction<TemplateInfo>>;
|
||||
}
|
||||
|
||||
export const StepTwo = ({
|
||||
@ -35,56 +29,34 @@ export const StepTwo = ({
|
||||
prevStep,
|
||||
templateInfo,
|
||||
setTemplateInfo,
|
||||
}: any) => {
|
||||
const [suggestions, setSuggestions] = useState<Array<TemplateInfo>>([]);
|
||||
const [selectedVariant, setSelectedVariant] = useState("");
|
||||
const [dockerCompose, setDockerCompose] = useState("");
|
||||
const [envVariables, setEnvVariables] = useState<Array<EnvVariable>>([]);
|
||||
}: StepProps) => {
|
||||
const [suggestions, setSuggestions] = useState<TemplateInfo["details"][]>();
|
||||
const [selectedVariant, setSelectedVariant] =
|
||||
useState<TemplateInfo["details"]>();
|
||||
const [showValues, setShowValues] = useState<Record<string, boolean>>({});
|
||||
|
||||
const { mutateAsync, isLoading } = api.ai.suggest.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
mutateAsync(templateInfo.userInput)
|
||||
mutateAsync({
|
||||
aiId: templateInfo.aiId,
|
||||
prompt: templateInfo.userInput,
|
||||
})
|
||||
.then((data) => {
|
||||
console.log(data);
|
||||
setSuggestions(data);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating AI settings");
|
||||
.catch((error) => {
|
||||
console.error("Error details:", error);
|
||||
toast.error("Error generating suggestions");
|
||||
});
|
||||
}, [templateInfo.userInput]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedVariant) {
|
||||
const selected = suggestions.find(
|
||||
(s: { id: string }) => s.id === selectedVariant,
|
||||
);
|
||||
if (selected) {
|
||||
setDockerCompose(selected.dockerCompose);
|
||||
setEnvVariables(selected.envVariables);
|
||||
setShowValues(
|
||||
selected.envVariables.reduce((acc: Record<string, boolean>, env) => {
|
||||
acc[env.name] = false;
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [selectedVariant, suggestions]);
|
||||
|
||||
const handleNext = () => {
|
||||
const selected = suggestions.find(
|
||||
(s: { id: string }) => s.id === selectedVariant,
|
||||
);
|
||||
if (selected) {
|
||||
if (selectedVariant) {
|
||||
setTemplateInfo({
|
||||
...templateInfo,
|
||||
type: selectedVariant,
|
||||
details: {
|
||||
...selected,
|
||||
dockerCompose,
|
||||
envVariables,
|
||||
},
|
||||
details: selectedVariant,
|
||||
});
|
||||
}
|
||||
nextStep();
|
||||
@ -95,25 +67,49 @@ export const StepTwo = ({
|
||||
field: string,
|
||||
value: string,
|
||||
) => {
|
||||
const updatedEnvVariables = [...envVariables];
|
||||
if (updatedEnvVariables[index]) {
|
||||
updatedEnvVariables[index] = {
|
||||
...updatedEnvVariables[index],
|
||||
[field]: value,
|
||||
};
|
||||
setEnvVariables(updatedEnvVariables);
|
||||
}
|
||||
// const updatedEnvVariables = [...envVariables];
|
||||
// if (updatedEnvVariables[index]) {
|
||||
// updatedEnvVariables[index] = {
|
||||
// ...updatedEnvVariables[index],
|
||||
// [field]: value,
|
||||
// };
|
||||
// setEnvVariables(updatedEnvVariables);
|
||||
// }
|
||||
};
|
||||
|
||||
const addEnvVariable = () => {
|
||||
setEnvVariables([...envVariables, { name: "", value: "" }]);
|
||||
setShowValues((prev) => ({ ...prev, "": false }));
|
||||
};
|
||||
// const addEnvVariable = () => {
|
||||
// setEnvVariables([...envVariables, { name: "", value: "" }]);
|
||||
// setShowValues((prev) => ({ ...prev, "": false }));
|
||||
// };
|
||||
|
||||
const removeEnvVariable = (index: number) => {
|
||||
const updatedEnvVariables = envVariables.filter((_, i) => i !== index);
|
||||
setEnvVariables(updatedEnvVariables);
|
||||
};
|
||||
// const removeEnvVariable = (index: number) => {
|
||||
// const updatedEnvVariables = envVariables.filter((_, i) => i !== index);
|
||||
// setEnvVariables(updatedEnvVariables);
|
||||
// };
|
||||
|
||||
// const handleDomainChange = (
|
||||
// index: number,
|
||||
// field: string,
|
||||
// value: string | number,
|
||||
// ) => {
|
||||
// const updatedDomains = [...domains];
|
||||
// if (updatedDomains[index]) {
|
||||
// updatedDomains[index] = {
|
||||
// ...updatedDomains[index],
|
||||
// [field]: value,
|
||||
// };
|
||||
// setDomains(updatedDomains);
|
||||
// }
|
||||
// };
|
||||
|
||||
// const addDomain = () => {
|
||||
// setDomains([...domains, { host: "", port: 0, serviceName: "" }]);
|
||||
// };
|
||||
|
||||
// const removeDomain = (index: number) => {
|
||||
// const updatedDomains = domains.filter((_, i) => i !== index);
|
||||
// setDomains(updatedDomains);
|
||||
// };
|
||||
|
||||
const toggleShowValue = (name: string) => {
|
||||
setShowValues((prev) => ({ ...prev, [name]: !prev[name] }));
|
||||
@ -134,39 +130,38 @@ export const StepTwo = ({
|
||||
);
|
||||
}
|
||||
|
||||
const selectedTemplate = suggestions.find(
|
||||
(s: { id: string }) => s.id === selectedVariant,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-grow overflow-auto">
|
||||
<div className="space-y-6 pb-20">
|
||||
<div className="flex-grow overflow-auto pb-8">
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold">Step 2: Choose a Variant</h2>
|
||||
{!selectedVariant && (
|
||||
<div className="space-y-4">
|
||||
<div>Based on your input, we suggest the following variants:</div>
|
||||
<RadioGroup
|
||||
value={selectedVariant}
|
||||
onValueChange={setSelectedVariant}
|
||||
onValueChange={(value) => {
|
||||
const element = suggestions?.find((s) => s?.id === value);
|
||||
setSelectedVariant(element);
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
{suggestions.map((suggestion) => (
|
||||
{suggestions?.map((suggestion) => (
|
||||
<div
|
||||
key={suggestion.id}
|
||||
key={suggestion?.id}
|
||||
className="flex items-start space-x-3"
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={suggestion.id}
|
||||
id={suggestion.id}
|
||||
value={suggestion?.id || ""}
|
||||
id={suggestion?.id}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor={suggestion.id} className="font-medium">
|
||||
{suggestion.name}
|
||||
<Label htmlFor={suggestion?.id} className="font-medium">
|
||||
{suggestion?.name}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{suggestion.shortDescription}
|
||||
{suggestion?.shortDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -177,22 +172,20 @@ export const StepTwo = ({
|
||||
{selectedVariant && (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xl font-bold">{selectedTemplate?.name}</h3>
|
||||
<h3 className="text-xl font-bold">{selectedVariant?.name}</h3>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{selectedTemplate?.shortDescription}
|
||||
{selectedVariant?.shortDescription}
|
||||
</p>
|
||||
</div>
|
||||
<ScrollArea className="h-[400px] p-5">
|
||||
<ScrollArea>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="description">
|
||||
<AccordionTrigger>Description</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ScrollArea className="h-[300px] w-full rounded-md border">
|
||||
<div className="p-4">
|
||||
<ReactMarkdown className="prose dark:prose-invert">
|
||||
{selectedTemplate?.description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<ScrollArea className=" w-full rounded-md border p-4">
|
||||
<ReactMarkdown className="text-muted-foreground text-sm">
|
||||
{selectedVariant?.description}
|
||||
</ReactMarkdown>
|
||||
</ScrollArea>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@ -200,9 +193,14 @@ export const StepTwo = ({
|
||||
<AccordionTrigger>Docker Compose</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<CodeEditor
|
||||
value={dockerCompose}
|
||||
value={selectedVariant?.dockerCompose}
|
||||
className="font-mono"
|
||||
onChange={(value) => setDockerCompose(value)}
|
||||
onChange={(value) => {
|
||||
setSelectedVariant({
|
||||
...selectedVariant,
|
||||
dockerCompose: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@ -211,7 +209,7 @@ export const StepTwo = ({
|
||||
<AccordionContent>
|
||||
<ScrollArea className="h-[300px] w-full rounded-md border">
|
||||
<div className="p-4 space-y-4">
|
||||
{envVariables.map((env, index) => (
|
||||
{selectedVariant?.envVariables.map((env, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center space-x-2"
|
||||
@ -272,7 +270,7 @@ export const StepTwo = ({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={addEnvVariable}
|
||||
// onClick={addEnvVariable}
|
||||
>
|
||||
<PlusCircle className="h-4 w-4 mr-2" />
|
||||
Add Variable
|
||||
@ -281,17 +279,88 @@ export const StepTwo = ({
|
||||
</ScrollArea>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="domains">
|
||||
<AccordionTrigger>Domains</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ScrollArea className=" w-full rounded-md border">
|
||||
<div className="p-4 space-y-4">
|
||||
{selectedVariant?.domains.map((domain, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Input
|
||||
value={domain.host}
|
||||
onChange={(e) =>
|
||||
handleDomainChange(
|
||||
index,
|
||||
"host",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Domain Host"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={domain.port}
|
||||
onChange={(e) =>
|
||||
handleDomainChange(
|
||||
index,
|
||||
"port",
|
||||
parseInt(e.target.value),
|
||||
)
|
||||
}
|
||||
placeholder="Port"
|
||||
className="w-24"
|
||||
/>
|
||||
<Input
|
||||
value={domain.serviceName}
|
||||
onChange={(e) =>
|
||||
handleDomainChange(
|
||||
index,
|
||||
"serviceName",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Service Name"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeDomain(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
// onClick={addDomain}
|
||||
>
|
||||
<PlusCircle className="h-4 w-4 mr-2" />
|
||||
Add Domain
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sticky bottom-0 bg-background pt-2 border-t">
|
||||
<div className="sticky bottom-0 bg-background pt-5 border-t">
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
onClick={() =>
|
||||
selectedVariant ? setSelectedVariant("") : prevStep()
|
||||
selectedVariant ? setSelectedVariant(undefined) : prevStep()
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
|
@ -8,27 +8,65 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
import { AlertCircle, Bot } from "lucide-react";
|
||||
import { Bot } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { StepFour } from "./step-four";
|
||||
import { StepOne } from "./step-one";
|
||||
import { StepThree } from "./step-three";
|
||||
import { StepTwo } from "./step-two";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import Link from "next/link";
|
||||
|
||||
const emptyState = {
|
||||
interface EnvVariable {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Domain {
|
||||
host: string;
|
||||
port: number;
|
||||
serviceName: string;
|
||||
}
|
||||
export interface TemplateInfo {
|
||||
id: string;
|
||||
userInput: string;
|
||||
type: string;
|
||||
details?: {
|
||||
name: string;
|
||||
id: string;
|
||||
description: string;
|
||||
dockerCompose: string;
|
||||
envVariables: EnvVariable[];
|
||||
shortDescription: string;
|
||||
domains: Domain[];
|
||||
};
|
||||
serverId?: string;
|
||||
aiId: string;
|
||||
}
|
||||
|
||||
const defaultTemplateInfo: TemplateInfo = {
|
||||
id: "",
|
||||
aiId: "",
|
||||
userInput: "",
|
||||
type: "",
|
||||
details: {
|
||||
id: "",
|
||||
name: "",
|
||||
description: "",
|
||||
dockerCompose: "",
|
||||
envVariables: [],
|
||||
shortDescription: "",
|
||||
domains: [],
|
||||
},
|
||||
name: "",
|
||||
server: undefined,
|
||||
description: "",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
@ -39,9 +77,10 @@ interface Props {
|
||||
export const TemplateGenerator = ({ projectId }: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [step, setStep] = useState(1);
|
||||
const { data: aiSettings } = api.ai.get.useQuery();
|
||||
const { data: aiSettings } = api.ai.getAll.useQuery();
|
||||
const { mutateAsync } = api.ai.deploy.useMutation();
|
||||
const [templateInfo, setTemplateInfo] = useState(emptyState);
|
||||
const [templateInfo, setTemplateInfo] =
|
||||
useState<TemplateInfo>(defaultTemplateInfo);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const totalSteps = 4;
|
||||
@ -52,12 +91,15 @@ export const TemplateGenerator = ({ projectId }: Props) => {
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setOpen(newOpen);
|
||||
if (!newOpen) {
|
||||
// Reset to the first step when closing the dialog
|
||||
setStep(1);
|
||||
setTemplateInfo(emptyState);
|
||||
setTemplateInfo(defaultTemplateInfo);
|
||||
}
|
||||
};
|
||||
|
||||
const haveAtleasOneProviderEnabled = aiSettings?.some(
|
||||
(ai) => ai.isEnabled === true,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild className="w-full">
|
||||
@ -69,7 +111,7 @@ export const TemplateGenerator = ({ projectId }: Props) => {
|
||||
<span>AI Assistant</span>
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[800px] w-full max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-4xl w-full flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI Assistant</DialogTitle>
|
||||
<DialogDescription>
|
||||
@ -79,29 +121,65 @@ export const TemplateGenerator = ({ projectId }: Props) => {
|
||||
<div className="mt-4 flex-grow overflow-auto">
|
||||
{step === 1 && (
|
||||
<>
|
||||
{(!aiSettings || !aiSettings?.isEnabled) && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>AI features are not enabled</AlertTitle>
|
||||
<AlertDescription>
|
||||
To use AI-powered template generation, please{" "}
|
||||
<a
|
||||
href="/dashboard/settings/ai"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
enable AI in your settings
|
||||
</a>
|
||||
.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{!!aiSettings && !!aiSettings?.isEnabled && (
|
||||
<StepOne
|
||||
nextStep={nextStep}
|
||||
setTemplateInfo={setTemplateInfo}
|
||||
templateInfo={templateInfo}
|
||||
/>
|
||||
{!haveAtleasOneProviderEnabled && (
|
||||
<AlertBlock type="warning">
|
||||
<div className="flex flex-col w-full">
|
||||
<span>AI features are not enabled</span>
|
||||
<span>
|
||||
To use AI-powered template generation, please{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/ai"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
enable AI in your settings
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
{haveAtleasOneProviderEnabled &&
|
||||
aiSettings &&
|
||||
aiSettings?.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="user-needs"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Select AI Provider
|
||||
</label>
|
||||
<Select
|
||||
value={templateInfo.aiId}
|
||||
onValueChange={(value) =>
|
||||
setTemplateInfo((prev) => ({
|
||||
...prev,
|
||||
aiId: value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an AI provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{aiSettings.map((ai) => (
|
||||
<SelectItem key={ai.aiId} value={ai.aiId}>
|
||||
{ai.name} ({ai.model})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{templateInfo.aiId && (
|
||||
<StepOne
|
||||
nextStep={nextStep}
|
||||
setTemplateInfo={setTemplateInfo}
|
||||
templateInfo={templateInfo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{step === 2 && (
|
||||
@ -129,14 +207,14 @@ export const TemplateGenerator = ({ projectId }: Props) => {
|
||||
setTemplateInfo(data);
|
||||
await mutateAsync({
|
||||
projectId,
|
||||
id: templateInfo.details?.id,
|
||||
name: templateInfo.name,
|
||||
description: data.details.shortDescription,
|
||||
dockerCompose: data.details.dockerCompose,
|
||||
envVariables: (data.details?.envVariables || [])
|
||||
id: templateInfo.details?.id || "",
|
||||
name: templateInfo?.details?.name || "",
|
||||
description: data?.details?.shortDescription || "",
|
||||
dockerCompose: data?.details?.dockerCompose || "",
|
||||
envVariables: (data?.details?.envVariables || [])
|
||||
.map((env: any) => `${env.name}=${env.value}`)
|
||||
.join("\n"),
|
||||
serverId: data.server,
|
||||
serverId: data.server || "",
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Compose Created");
|
||||
|
@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@ -9,268 +8,99 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { BotIcon, Loader2, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const aiSettingsSchema = z.object({
|
||||
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
|
||||
apiKey: z.string().min(1, { message: "API Key is required" }),
|
||||
model: z.string().optional(),
|
||||
isEnabled: z.boolean(),
|
||||
});
|
||||
|
||||
type AISettings = z.infer<typeof aiSettingsSchema>;
|
||||
|
||||
interface Model {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
owned_by: string;
|
||||
}
|
||||
import { HandleAi } from "./handle-ai";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
|
||||
export const AiForm = () => {
|
||||
const [models, setModels] = useState<Model[]>([]);
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data, refetch } = api.ai.get.useQuery();
|
||||
const { mutateAsync, isLoading } = api.ai.save.useMutation();
|
||||
|
||||
const form = useForm<AISettings>({
|
||||
resolver: zodResolver(aiSettingsSchema),
|
||||
defaultValues: {
|
||||
apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
|
||||
apiKey: data?.apiKey ?? "",
|
||||
model: data?.model ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const fetchModels = async (apiUrl: string, apiKey: string) => {
|
||||
setIsLoadingModels(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/models`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch models");
|
||||
}
|
||||
const res = await response.json();
|
||||
setModels(res.data);
|
||||
|
||||
// Set default model to o1-mini if present
|
||||
const defaultModel = res.data.find(
|
||||
(model: Model) => model.id === "gpt-4o",
|
||||
);
|
||||
if (defaultModel) {
|
||||
form.setValue("model", defaultModel.id);
|
||||
}
|
||||
} catch (error) {
|
||||
setError("Failed to fetch models. Please check your API URL and Key.");
|
||||
setModels([]);
|
||||
} finally {
|
||||
setIsLoadingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
|
||||
apiKey: data?.apiKey ?? "",
|
||||
model: data?.model ?? "",
|
||||
isEnabled: !!data.isEnabled,
|
||||
});
|
||||
}
|
||||
form.reset();
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
useEffect(() => {
|
||||
const apiUrl = form.watch("apiUrl");
|
||||
const apiKey = form.watch("apiKey");
|
||||
if (apiUrl && apiKey) {
|
||||
form.setValue("model", undefined); // Reset model when API URL or Key changes
|
||||
fetchModels(apiUrl, apiKey);
|
||||
}
|
||||
}, [form.watch("apiUrl"), form.watch("apiKey")]);
|
||||
|
||||
const onSubmit = async (values: AISettings) => {
|
||||
await mutateAsync({
|
||||
apiUrl: values.apiUrl,
|
||||
apiKey: values.apiKey,
|
||||
model: values.model || "",
|
||||
isEnabled: !!values.isEnabled,
|
||||
})
|
||||
.then(async () => {
|
||||
await refetch();
|
||||
toast.success("AI Settings Updated");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating AI settings");
|
||||
});
|
||||
};
|
||||
const { data: aiConfigs, refetch, isLoading } = api.ai.getAll.useQuery();
|
||||
const { mutateAsync, isLoading: isRemoving } = api.ai.delete.useMutation();
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
|
||||
<CardTitle className="text-xl">AI Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your AI model settings here.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Enable AI</FormLabel>
|
||||
<FormDescription>
|
||||
Turn on or off AI functionality
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{!!form.watch("isEnabled") && (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row gap-2 justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<BotIcon className="size-6 text-muted-foreground self-center" />
|
||||
AI Settings
|
||||
</CardTitle>
|
||||
<CardDescription>Manage your AI configurations</CardDescription>
|
||||
</div>
|
||||
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://api.openai.com/v1"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
By default, the OpenAI API URL is used. Only change this
|
||||
if you're using a different API.
|
||||
</p>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your API key"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
{form.watch("apiUrl") === "https://api.openai.com/v1" && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You can find your API key on the{" "}
|
||||
<a
|
||||
href="https://platform.openai.com/settings/organization/api-keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-primary"
|
||||
>
|
||||
OpenAI account page
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{!isLoadingModels && models.length > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Model</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || ""}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{isLoadingModels && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading models...
|
||||
{aiConfigs?.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<BotIcon className="size-8 self-center text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
You don't have any AI configurations
|
||||
</span>
|
||||
<HandleAi />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 rounded-lg min-h-[25vh]">
|
||||
{aiConfigs?.map((config) => (
|
||||
<div
|
||||
key={config.aiId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div>
|
||||
<span className="text-sm font-medium">
|
||||
{config.name}
|
||||
</span>
|
||||
<CardDescription>{config.model}</CardDescription>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<HandleAi aiId={config.aiId} />
|
||||
<DialogAction
|
||||
title="Delete AI"
|
||||
description="Are you sure you want to delete this AI?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
aiId: config.aiId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("AI deleted successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting AI");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isLoading}
|
||||
disabled={!!error || isLoadingModels || !form.watch("model")}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
305
apps/dokploy/components/dashboard/settings/handle-ai.tsx
Normal file
305
apps/dokploy/components/dashboard/settings/handle-ai.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -128,7 +128,6 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => {
|
||||
Add SSH Key
|
||||
</Button>
|
||||
)}
|
||||
{/* {children} */}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
|
@ -19,6 +19,7 @@ import {
|
||||
GitBranch,
|
||||
HeartIcon,
|
||||
KeyRound,
|
||||
BotIcon,
|
||||
type LucideIcon,
|
||||
Package,
|
||||
PieChart,
|
||||
@ -249,7 +250,13 @@ const data = {
|
||||
isSingle: true,
|
||||
isActive: false,
|
||||
},
|
||||
|
||||
{
|
||||
title: "AI",
|
||||
icon: BotIcon,
|
||||
url: "/dashboard/settings/ai",
|
||||
isSingle: true,
|
||||
isActive: false,
|
||||
},
|
||||
{
|
||||
title: "Git",
|
||||
url: "/dashboard/settings/git-providers",
|
||||
|
16
apps/dokploy/drizzle/0057_damp_prism.sql
Normal file
16
apps/dokploy/drizzle/0057_damp_prism.sql
Normal 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 $$;
|
@ -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
|
||||
);
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "03ce8887-3824-49b9-ad47-12c25aa4c090",
|
||||
"id": "841960d7-0573-41e4-8529-fd9960f726d5",
|
||||
"prevId": "24787a88-0754-437a-b077-03a3265b8ef5",
|
||||
"version": "6",
|
||||
"dialect": "postgresql",
|
||||
@ -4144,12 +4144,18 @@
|
||||
"name": "ai",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"authId": {
|
||||
"name": "authId",
|
||||
"aiId": {
|
||||
"name": "aiId",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"apiUrl": {
|
||||
"name": "apiUrl",
|
||||
"type": "text",
|
||||
@ -4174,10 +4180,36 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"adminId": {
|
||||
"name": "adminId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"foreignKeys": {
|
||||
"ai_adminId_admin_adminId_fk": {
|
||||
"name": "ai_adminId_admin_adminId_fk",
|
||||
"tableFrom": "ai",
|
||||
"tableTo": "admin",
|
||||
"columnsFrom": [
|
||||
"adminId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"adminId"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
|
@ -404,8 +404,8 @@
|
||||
{
|
||||
"idx": 57,
|
||||
"version": "6",
|
||||
"when": 1737246538368,
|
||||
"tag": "0057_mature_thaddeus_ross",
|
||||
"when": 1737251708859,
|
||||
"tag": "0057_damp_prism",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { AiForm } from "@/components/dashboard/settings/ai-form";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { SettingsLayout } from "@/components/layouts/settings-layout";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
||||
import { validateRequest } from "@dokploy/server";
|
||||
@ -20,11 +19,7 @@ const Page = () => {
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => {
|
||||
return (
|
||||
<DashboardLayout tab={"settings"} metaName="AI">
|
||||
<SettingsLayout>{page}</SettingsLayout>
|
||||
</DashboardLayout>
|
||||
);
|
||||
return <DashboardLayout>{page}</DashboardLayout>;
|
||||
};
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||
|
@ -1,71 +1,127 @@
|
||||
import { slugify } from "@/lib/slug";
|
||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
import {
|
||||
adminProcedure,
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
} from "@/server/api/trpc";
|
||||
import { generatePassword } from "@/templates/utils";
|
||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||
import {
|
||||
apiAiSettingsSchema,
|
||||
deploySuggestionSchema,
|
||||
apiCreateAi,
|
||||
apiUpdateAi,
|
||||
deploySuggestionSchema,
|
||||
} from "@dokploy/server/db/schema/ai";
|
||||
import {
|
||||
getAiSettingsByAuthId,
|
||||
saveAiSettings,
|
||||
suggestVariants,
|
||||
getAiSettingsByAdminId,
|
||||
getAiSettingById,
|
||||
saveAiSettings,
|
||||
deleteAiSettings,
|
||||
suggestVariants,
|
||||
} from "@dokploy/server/services/ai";
|
||||
import { createComposeByTemplate } from "@dokploy/server/services/compose";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
import {
|
||||
addNewService,
|
||||
checkServiceAccess,
|
||||
addNewService,
|
||||
checkServiceAccess,
|
||||
} from "@dokploy/server/services/user";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
export const aiRouter = createTRPCRouter({
|
||||
save: protectedProcedure
|
||||
.input(apiAiSettingsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await saveAiSettings(ctx.user.authId, input);
|
||||
}),
|
||||
get: protectedProcedure.query(async ({ ctx }) => {
|
||||
return await getAiSettingsByAuthId(ctx.user.authId);
|
||||
}),
|
||||
suggest: protectedProcedure
|
||||
.input(z.string())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await suggestVariants(ctx.user.authId, input);
|
||||
}),
|
||||
deploy: protectedProcedure
|
||||
.input(deploySuggestionSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (ctx.user.rol === "user") {
|
||||
await checkServiceAccess(ctx.user.authId, input.projectId, "create");
|
||||
}
|
||||
one: protectedProcedure
|
||||
.input(z.object({ aiId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const aiSetting = await getAiSettingById(input.aiId);
|
||||
if (aiSetting.adminId !== ctx.user.adminId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this AI configuration",
|
||||
});
|
||||
}
|
||||
return aiSetting;
|
||||
}),
|
||||
create: adminProcedure.input(apiCreateAi).mutation(async ({ ctx, input }) => {
|
||||
return await saveAiSettings(ctx.user.adminId, input);
|
||||
}),
|
||||
|
||||
if (IS_CLOUD && !input.serverId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You need to use a server to create a compose",
|
||||
});
|
||||
}
|
||||
update: protectedProcedure
|
||||
.input(apiUpdateAi)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await saveAiSettings(ctx.user.adminId, input);
|
||||
}),
|
||||
|
||||
const project = await findProjectById(input.projectId);
|
||||
getAll: adminProcedure.query(async ({ ctx }) => {
|
||||
return await getAiSettingsByAdminId(ctx.user.adminId);
|
||||
}),
|
||||
|
||||
const projectName = slugify(`${project.name} ${input.id}`);
|
||||
get: protectedProcedure
|
||||
.input(z.object({ aiId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const aiSetting = await getAiSettingById(input.aiId);
|
||||
if (aiSetting.adminId !== ctx.user.authId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this AI configuration",
|
||||
});
|
||||
}
|
||||
return aiSetting;
|
||||
}),
|
||||
|
||||
const compose = await createComposeByTemplate({
|
||||
...input,
|
||||
composeFile: input.dockerCompose,
|
||||
env: input.envVariables,
|
||||
serverId: input.serverId,
|
||||
name: input.name,
|
||||
sourceType: "raw",
|
||||
appName: `${projectName}-${generatePassword(6)}`,
|
||||
});
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ aiId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const aiSetting = await getAiSettingById(input.aiId);
|
||||
if (aiSetting.adminId !== ctx.user.adminId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this AI configuration",
|
||||
});
|
||||
}
|
||||
return await deleteAiSettings(input.aiId);
|
||||
}),
|
||||
|
||||
if (ctx.user.rol === "user") {
|
||||
await addNewService(ctx.user.authId, compose.composeId);
|
||||
}
|
||||
suggest: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
aiId: z.string(),
|
||||
prompt: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await suggestVariants(ctx.user.adminId, input.aiId, input.prompt);
|
||||
}),
|
||||
deploy: protectedProcedure
|
||||
.input(deploySuggestionSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (ctx.user.rol === "user") {
|
||||
await checkServiceAccess(ctx.user.adminId, input.projectId, "create");
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
if (IS_CLOUD && !input.serverId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You need to use a server to create a compose",
|
||||
});
|
||||
}
|
||||
|
||||
const project = await findProjectById(input.projectId);
|
||||
|
||||
const projectName = slugify(`${project.name} ${input.id}`);
|
||||
|
||||
const compose = await createComposeByTemplate({
|
||||
...input,
|
||||
composeFile: input.dockerCompose,
|
||||
env: input.envVariables,
|
||||
serverId: input.serverId,
|
||||
name: input.name,
|
||||
sourceType: "raw",
|
||||
appName: `${projectName}-${generatePassword(6)}`,
|
||||
});
|
||||
|
||||
if (ctx.user.rol === "user") {
|
||||
await addNewService(ctx.user.authId, compose.composeId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
});
|
||||
|
@ -9,111 +9,113 @@ import { registry } from "./registry";
|
||||
import { certificateType } from "./shared";
|
||||
import { sshKeys } from "./ssh-key";
|
||||
import { users } from "./user";
|
||||
import { ai } from "./ai";
|
||||
|
||||
export const admins = pgTable("admin", {
|
||||
adminId: text("adminId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
serverIp: text("serverIp"),
|
||||
certificateType: certificateType("certificateType").notNull().default("none"),
|
||||
host: text("host"),
|
||||
letsEncryptEmail: text("letsEncryptEmail"),
|
||||
sshPrivateKey: text("sshPrivateKey"),
|
||||
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
|
||||
enableLogRotation: boolean("enableLogRotation").notNull().default(false),
|
||||
authId: text("authId")
|
||||
.notNull()
|
||||
.references(() => auth.id, { onDelete: "cascade" }),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
stripeCustomerId: text("stripeCustomerId"),
|
||||
stripeSubscriptionId: text("stripeSubscriptionId"),
|
||||
serversQuantity: integer("serversQuantity").notNull().default(0),
|
||||
adminId: text("adminId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
serverIp: text("serverIp"),
|
||||
certificateType: certificateType("certificateType").notNull().default("none"),
|
||||
host: text("host"),
|
||||
letsEncryptEmail: text("letsEncryptEmail"),
|
||||
sshPrivateKey: text("sshPrivateKey"),
|
||||
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
|
||||
enableLogRotation: boolean("enableLogRotation").notNull().default(false),
|
||||
authId: text("authId")
|
||||
.notNull()
|
||||
.references(() => auth.id, { onDelete: "cascade" }),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
stripeCustomerId: text("stripeCustomerId"),
|
||||
stripeSubscriptionId: text("stripeSubscriptionId"),
|
||||
serversQuantity: integer("serversQuantity").notNull().default(0),
|
||||
});
|
||||
|
||||
export const adminsRelations = relations(admins, ({ one, many }) => ({
|
||||
auth: one(auth, {
|
||||
fields: [admins.authId],
|
||||
references: [auth.id],
|
||||
}),
|
||||
users: many(users),
|
||||
registry: many(registry),
|
||||
sshKeys: many(sshKeys),
|
||||
certificates: many(certificates),
|
||||
auth: one(auth, {
|
||||
fields: [admins.authId],
|
||||
references: [auth.id],
|
||||
}),
|
||||
users: many(users),
|
||||
registry: many(registry),
|
||||
sshKeys: many(sshKeys),
|
||||
certificates: many(certificates),
|
||||
ai: many(ai),
|
||||
}));
|
||||
|
||||
const createSchema = createInsertSchema(admins, {
|
||||
adminId: z.string(),
|
||||
enableDockerCleanup: z.boolean().optional(),
|
||||
sshPrivateKey: z.string().optional(),
|
||||
certificateType: z.enum(["letsencrypt", "none"]).default("none"),
|
||||
serverIp: z.string().optional(),
|
||||
letsEncryptEmail: z.string().optional(),
|
||||
adminId: z.string(),
|
||||
enableDockerCleanup: z.boolean().optional(),
|
||||
sshPrivateKey: z.string().optional(),
|
||||
certificateType: z.enum(["letsencrypt", "none"]).default("none"),
|
||||
serverIp: z.string().optional(),
|
||||
letsEncryptEmail: z.string().optional(),
|
||||
});
|
||||
|
||||
export const apiUpdateAdmin = createSchema.partial();
|
||||
|
||||
export const apiSaveSSHKey = createSchema
|
||||
.pick({
|
||||
sshPrivateKey: true,
|
||||
})
|
||||
.required();
|
||||
.pick({
|
||||
sshPrivateKey: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiAssignDomain = createSchema
|
||||
.pick({
|
||||
host: true,
|
||||
certificateType: true,
|
||||
letsEncryptEmail: true,
|
||||
})
|
||||
.required()
|
||||
.partial({
|
||||
letsEncryptEmail: true,
|
||||
});
|
||||
.pick({
|
||||
host: true,
|
||||
certificateType: true,
|
||||
letsEncryptEmail: true,
|
||||
})
|
||||
.required()
|
||||
.partial({
|
||||
letsEncryptEmail: true,
|
||||
});
|
||||
|
||||
export const apiUpdateDockerCleanup = createSchema
|
||||
.pick({
|
||||
enableDockerCleanup: true,
|
||||
})
|
||||
.required()
|
||||
.extend({
|
||||
serverId: z.string().optional(),
|
||||
});
|
||||
.pick({
|
||||
enableDockerCleanup: true,
|
||||
})
|
||||
.required()
|
||||
.extend({
|
||||
serverId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const apiTraefikConfig = z.object({
|
||||
traefikConfig: z.string().min(1),
|
||||
traefikConfig: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiModifyTraefikConfig = z.object({
|
||||
path: z.string().min(1),
|
||||
traefikConfig: z.string().min(1),
|
||||
serverId: z.string().optional(),
|
||||
path: z.string().min(1),
|
||||
traefikConfig: z.string().min(1),
|
||||
serverId: z.string().optional(),
|
||||
});
|
||||
export const apiReadTraefikConfig = z.object({
|
||||
path: z.string().min(1),
|
||||
serverId: z.string().optional(),
|
||||
path: z.string().min(1),
|
||||
serverId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const apiEnableDashboard = z.object({
|
||||
enableDashboard: z.boolean().optional(),
|
||||
serverId: z.string().optional(),
|
||||
enableDashboard: z.boolean().optional(),
|
||||
serverId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const apiServerSchema = z
|
||||
.object({
|
||||
serverId: z.string().optional(),
|
||||
})
|
||||
.optional();
|
||||
.object({
|
||||
serverId: z.string().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const apiReadStatsLogs = z.object({
|
||||
page: z
|
||||
.object({
|
||||
pageIndex: z.number(),
|
||||
pageSize: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
status: z.string().array().optional(),
|
||||
search: z.string().optional(),
|
||||
sort: z.object({ id: z.string(), desc: z.boolean() }).optional(),
|
||||
page: z
|
||||
.object({
|
||||
pageIndex: z.number(),
|
||||
pageSize: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
status: z.string().array().optional(),
|
||||
search: z.string().optional(),
|
||||
sort: z.object({ id: z.string(), desc: z.boolean() }).optional(),
|
||||
});
|
||||
|
@ -1,37 +1,66 @@
|
||||
import { boolean, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { z } from "zod";
|
||||
import { admins } from "./admin";
|
||||
import { relations } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export const ai = pgTable("ai", {
|
||||
authId: text("authId").notNull().primaryKey(),
|
||||
apiUrl: text("apiUrl").notNull(),
|
||||
apiKey: text("apiKey").notNull(),
|
||||
model: text("model").notNull(),
|
||||
isEnabled: boolean("isEnabled").notNull().default(true),
|
||||
aiId: text("aiId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
apiUrl: text("apiUrl").notNull(),
|
||||
apiKey: text("apiKey").notNull(),
|
||||
model: text("model").notNull(),
|
||||
isEnabled: boolean("isEnabled").notNull().default(true),
|
||||
adminId: text("adminId")
|
||||
.notNull()
|
||||
.references(() => admins.adminId, { onDelete: "cascade" }), // Admin ID who created the AI settings
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
});
|
||||
|
||||
export const aiRelations = relations(ai, ({ one }) => ({
|
||||
admin: one(admins, {
|
||||
fields: [ai.adminId],
|
||||
references: [admins.adminId],
|
||||
}),
|
||||
}));
|
||||
|
||||
const createSchema = createInsertSchema(ai, {
|
||||
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
|
||||
apiKey: z.string().min(1, { message: "API Key is required" }),
|
||||
model: z.string().min(1, { message: "Model is required" }),
|
||||
isEnabled: z.boolean().optional(),
|
||||
name: z.string().min(1, { message: "Name is required" }),
|
||||
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
|
||||
apiKey: z.string().min(1, { message: "API Key is required" }),
|
||||
model: z.string().min(1, { message: "Model is required" }),
|
||||
isEnabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiAiSettingsSchema = createSchema
|
||||
.pick({
|
||||
apiUrl: true,
|
||||
apiKey: true,
|
||||
model: true,
|
||||
isEnabled: true,
|
||||
})
|
||||
.required();
|
||||
export const apiCreateAi = createSchema
|
||||
.pick({
|
||||
name: true,
|
||||
apiUrl: true,
|
||||
apiKey: true,
|
||||
model: true,
|
||||
isEnabled: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiUpdateAi = createSchema
|
||||
.partial()
|
||||
.extend({
|
||||
aiId: z.string().min(1),
|
||||
})
|
||||
.omit({ adminId: true });
|
||||
|
||||
export const deploySuggestionSchema = z.object({
|
||||
projectId: z.string().min(1),
|
||||
id: z.string().min(1),
|
||||
dockerCompose: z.string().min(1),
|
||||
envVariables: z.string(),
|
||||
serverId: z.string().optional(),
|
||||
name: z.string().min(1),
|
||||
description: z.string(),
|
||||
projectId: z.string().min(1),
|
||||
id: z.string().min(1),
|
||||
dockerCompose: z.string().min(1),
|
||||
envVariables: z.string(),
|
||||
serverId: z.string().optional(),
|
||||
name: z.string().min(1),
|
||||
description: z.string(),
|
||||
});
|
||||
|
@ -3,102 +3,160 @@ import { ai } from "@dokploy/server/db/schema";
|
||||
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { generateObject } from "ai";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { IS_CLOUD } from "../constants";
|
||||
import { findAdminById } from "./admin";
|
||||
|
||||
export const getAiSettingsByAuthId = async (authId: string) => {
|
||||
const aiSettings = await db.query.ai.findFirst({
|
||||
where: eq(ai.authId, authId),
|
||||
});
|
||||
if (!aiSettings) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "AI settings not found for the user",
|
||||
});
|
||||
}
|
||||
return aiSettings;
|
||||
export const getAiSettingsByAdminId = async (adminId: string) => {
|
||||
const aiSettings = await db.query.ai.findMany({
|
||||
where: eq(ai.adminId, adminId),
|
||||
orderBy: desc(ai.createdAt),
|
||||
});
|
||||
return aiSettings;
|
||||
};
|
||||
|
||||
export const saveAiSettings = async (authId: string, settings: any) => {
|
||||
return db
|
||||
.insert(ai)
|
||||
.values({
|
||||
authId,
|
||||
...settings,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: ai.authId,
|
||||
set: {
|
||||
...settings,
|
||||
},
|
||||
});
|
||||
export const getAiSettingById = async (aiId: string) => {
|
||||
const aiSetting = await db.query.ai.findFirst({
|
||||
where: eq(ai.aiId, aiId),
|
||||
});
|
||||
if (!aiSetting) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "AI settings not found",
|
||||
});
|
||||
}
|
||||
return aiSetting;
|
||||
};
|
||||
|
||||
export const suggestVariants = async (authId: string, input: string) => {
|
||||
const aiSettings = await getAiSettingsByAuthId(authId);
|
||||
if (!aiSettings || !aiSettings.isEnabled) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "AI features are not enabled",
|
||||
});
|
||||
}
|
||||
export const saveAiSettings = async (adminId: string, settings: any) => {
|
||||
const aiId = settings.aiId;
|
||||
|
||||
const provider = selectAIProvider(aiSettings);
|
||||
const model = provider(aiSettings.model);
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
output: "array",
|
||||
schema: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
shortDescription: z.string(),
|
||||
description: z.string(),
|
||||
}),
|
||||
prompt: `
|
||||
Act as advanced DevOps engineer and generate a list of open source projects what can cover users needs(up to 3 items), the suggestion
|
||||
should include id, name, shortDescription, and description. Use slug of title for id. The description should be in markdown format with full description of suggested stack. The shortDescription should be in plain text and have short information about used technologies.
|
||||
User wants to create a new project with the following details, it should be installable in docker and can be docker compose generated for it:
|
||||
|
||||
${input}
|
||||
`,
|
||||
});
|
||||
|
||||
if (object?.length) {
|
||||
const result = [];
|
||||
for (const suggestion of object) {
|
||||
const { object: docker } = await generateObject({
|
||||
model,
|
||||
output: "object",
|
||||
schema: z.object({
|
||||
dockerCompose: z.string(),
|
||||
envVariables: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
prompt: `
|
||||
Act as advanced DevOps engineer and generate docker compose with environment variables needed to install the following project,
|
||||
use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker compose. Use complex values for passwords/secrets variables.
|
||||
Don\'t set container_name field in services. Don\'t set version field in the docker compose.
|
||||
|
||||
Project details:
|
||||
${suggestion?.description}
|
||||
`,
|
||||
});
|
||||
if (!!docker && !!docker.dockerCompose) {
|
||||
result.push({
|
||||
...suggestion,
|
||||
...docker,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "No suggestions found",
|
||||
});
|
||||
return db
|
||||
.insert(ai)
|
||||
.values({
|
||||
aiId,
|
||||
adminId,
|
||||
...settings,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: ai.aiId,
|
||||
set: {
|
||||
...settings,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteAiSettings = async (aiId: string) => {
|
||||
return db.delete(ai).where(eq(ai.aiId, aiId));
|
||||
};
|
||||
|
||||
export const suggestVariants = async (
|
||||
adminId: string,
|
||||
aiId: string,
|
||||
input: string
|
||||
) => {
|
||||
try {
|
||||
const aiSettings = await getAiSettingById(aiId);
|
||||
if (!aiSettings || !aiSettings.isEnabled) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "AI features are not enabled for this configuration",
|
||||
});
|
||||
}
|
||||
|
||||
const provider = selectAIProvider(aiSettings);
|
||||
const model = provider(aiSettings.model);
|
||||
|
||||
let ip = "";
|
||||
if (!IS_CLOUD) {
|
||||
const admin = await findAdminById(adminId);
|
||||
ip = admin?.serverIp || "";
|
||||
}
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
output: "array",
|
||||
schema: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
shortDescription: z.string(),
|
||||
description: z.string(),
|
||||
}),
|
||||
prompt: `
|
||||
Act as advanced DevOps engineer and generate a list of open source projects what can cover users needs(up to 3 items), the suggestion
|
||||
should include id, name, shortDescription, and description. Use slug of title for id. The description should be in markdown format with full description of suggested stack. The shortDescription should be in plain text and have short information about used technologies.
|
||||
User wants to create a new project with the following details, it should be installable in docker and can be docker compose generated for it:
|
||||
|
||||
${input}
|
||||
`,
|
||||
});
|
||||
|
||||
if (object?.length) {
|
||||
const result = [];
|
||||
for (const suggestion of object) {
|
||||
try {
|
||||
const { object: docker } = await generateObject({
|
||||
model,
|
||||
output: "object",
|
||||
schema: z.object({
|
||||
dockerCompose: z.string(),
|
||||
envVariables: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
})
|
||||
),
|
||||
domains: z.array(
|
||||
z.object({
|
||||
host: z.string(),
|
||||
port: z.number(),
|
||||
serviceName: z.string(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
prompt: `
|
||||
Act as advanced DevOps engineer and generate docker compose with environment variables and domain configurations needed to install the following project.
|
||||
Return the docker compose as a YAML string. Follow these rules:
|
||||
1. Use placeholder like \${VARIABLE_NAME-default} for generated variables
|
||||
2. Use complex values for passwords/secrets variables
|
||||
3. Don't set container_name field in services
|
||||
4. Don't set version field in the docker compose
|
||||
5. Don't set ports like 'ports: 3000:3000', use 'ports: ["3000"]' instead
|
||||
6. Use dokploy-network in all services
|
||||
7. Add dokploy-network at the end and mark it as external: true
|
||||
|
||||
For each service that needs to be exposed to the internet:
|
||||
1. Define a domain configuration with:
|
||||
- host: the domain name for the service
|
||||
- port: the internal port the service runs on
|
||||
- serviceName: the name of the service in the docker-compose
|
||||
2. Make sure the service is properly configured in the docker-compose to work with the specified port
|
||||
|
||||
Project details:
|
||||
${suggestion?.description}
|
||||
`,
|
||||
});
|
||||
if (!!docker && !!docker.dockerCompose) {
|
||||
result.push({
|
||||
...suggestion,
|
||||
...docker,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in docker compose generation:", error);
|
||||
console.error("Error details:", error.cause?.issues || error);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "No suggestions found",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in suggestVariants:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user