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:
@@ -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;
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user