mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
feat: add AI assistant to dokploy
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import { TemplateGenerator } from "@/components/dashboard/project/ai/template-generator";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
export const AddAiAssistant = ({ projectId }: Props) => {
|
||||
return <TemplateGenerator projectId={projectId} />;
|
||||
};
|
||||
92
apps/dokploy/components/dashboard/project/ai/step-four.tsx
Normal file
92
apps/dokploy/components/dashboard/project/ai/step-four.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import dynamic from "next/dynamic";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export function StepFour({
|
||||
prevStep,
|
||||
templateInfo,
|
||||
setOpen,
|
||||
setTemplateInfo,
|
||||
}: any) {
|
||||
const handleSubmit = () => {
|
||||
setTemplateInfo(templateInfo); // Update the template info
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-grow">
|
||||
<div className="space-y-6 pb-20">
|
||||
<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}
|
||||
</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>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-md font-semibold">Docker Compose</h3>
|
||||
<MonacoEditor
|
||||
height="200px"
|
||||
language="yaml"
|
||||
theme="vs-dark"
|
||||
value={templateInfo.details.dockerCompose}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 14,
|
||||
lineNumbers: "on",
|
||||
readOnly: true,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-md font-semibold">Environment Variables</h3>
|
||||
<ul className="list-disc pl-5">
|
||||
{templateInfo.details.envVariables.map(
|
||||
(
|
||||
env: {
|
||||
name: string;
|
||||
value: string;
|
||||
},
|
||||
index: number,
|
||||
) => (
|
||||
<li key={index}>
|
||||
<strong>{env.name}</strong>:
|
||||
<span className="ml-2 font-mono">{env.value}</span>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sticky bottom-0 bg-background pt-2 border-t">
|
||||
<div className="flex justify-between">
|
||||
<Button onClick={prevStep} variant="outline">
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>Create</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
apps/dokploy/components/dashboard/project/ai/step-one.tsx
Normal file
69
apps/dokploy/components/dashboard/project/ai/step-one.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useState } from "react";
|
||||
|
||||
const examples = [
|
||||
"Make a personal blog",
|
||||
"Add a photo studio portfolio",
|
||||
"Create a personal ad blocker",
|
||||
"Build a social media dashboard",
|
||||
"Sendgrid service opensource analogue",
|
||||
];
|
||||
|
||||
export function StepOne({ nextStep, setTemplateInfo, templateInfo }: any) {
|
||||
const [userInput, setUserInput] = useState(templateInfo.userInput);
|
||||
|
||||
const handleNext = () => {
|
||||
setTemplateInfo({ ...templateInfo, userInput });
|
||||
nextStep();
|
||||
};
|
||||
|
||||
const handleExampleClick = (example: string) => {
|
||||
setUserInput(example);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-grow overflow-auto">
|
||||
<div className="space-y-4 pb-20">
|
||||
<h2 className="text-lg font-semibold">Step 1: Describe Your Needs</h2>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-needs">Describe your template needs</Label>
|
||||
<Textarea
|
||||
id="user-needs"
|
||||
placeholder="Describe the type of template you need, its purpose, and any specific features you'd like to include."
|
||||
value={userInput}
|
||||
onChange={(e) => setUserInput(e.target.value)}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Examples:</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{examples.map((example, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExampleClick(example)}
|
||||
>
|
||||
{example}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sticky bottom-0 bg-background pt-2 border-t">
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleNext} disabled={!userInput.trim()}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
apps/dokploy/components/dashboard/project/ai/step-three.tsx
Normal file
84
apps/dokploy/components/dashboard/project/ai/step-three.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
import { useState } from "react";
|
||||
|
||||
export function StepThree({
|
||||
nextStep,
|
||||
prevStep,
|
||||
templateInfo,
|
||||
setTemplateInfo,
|
||||
}: any) {
|
||||
const [name, setName] = useState(templateInfo.name);
|
||||
const [server, setServer] = useState(templateInfo.server);
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
|
||||
const handleNext = () => {
|
||||
const updatedInfo = { ...templateInfo, name };
|
||||
if (server?.trim()) {
|
||||
updatedInfo.server = server;
|
||||
}
|
||||
setTemplateInfo(updatedInfo);
|
||||
nextStep();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-grow overflow-auto">
|
||||
<div className="space-y-4 pb-20">
|
||||
<h2 className="text-lg font-semibold">Step 3: Additional Details</h2>
|
||||
<div>
|
||||
<Label htmlFor="name">App Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter app name"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="server">Server Attachment (Optional)</Label>
|
||||
<Select value={server} onValueChange={setServer}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a Server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{servers?.map((server) => (
|
||||
<SelectItem key={server.serverId} value={server.serverId}>
|
||||
{server.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sticky bottom-0 bg-background pt-2 border-t">
|
||||
<div className="flex justify-between">
|
||||
<Button onClick={prevStep} variant="outline">
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleNext} disabled={!name.trim()}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
326
apps/dokploy/components/dashboard/project/ai/step-two.tsx
Normal file
326
apps/dokploy/components/dashboard/project/ai/step-two.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { api } from "@/utils/api";
|
||||
import { Bot, Eye, EyeOff, PlusCircle, Trash2 } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
interface EnvVariable {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface TemplateInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
shortDescription: string;
|
||||
description: string;
|
||||
dockerCompose: string;
|
||||
envVariables: EnvVariable[];
|
||||
}
|
||||
|
||||
export function StepTwo({
|
||||
nextStep,
|
||||
prevStep,
|
||||
templateInfo,
|
||||
setTemplateInfo,
|
||||
}: any) {
|
||||
const [suggestions, setSuggestions] = useState<Array<TemplateInfo>>([]);
|
||||
const [selectedVariant, setSelectedVariant] = useState("");
|
||||
const [dockerCompose, setDockerCompose] = useState("");
|
||||
const [envVariables, setEnvVariables] = useState<Array<EnvVariable>>([]);
|
||||
const [showValues, setShowValues] = useState<Record<string, boolean>>({});
|
||||
|
||||
const { mutateAsync, isLoading } = api.ai.suggest.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
mutateAsync(templateInfo.userInput)
|
||||
.then((data) => {
|
||||
setSuggestions(data);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating AI settings");
|
||||
});
|
||||
}, [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) {
|
||||
setTemplateInfo({
|
||||
...templateInfo,
|
||||
type: selectedVariant,
|
||||
details: {
|
||||
...selected,
|
||||
dockerCompose,
|
||||
envVariables,
|
||||
},
|
||||
});
|
||||
}
|
||||
nextStep();
|
||||
};
|
||||
|
||||
const handleEnvVariableChange = (
|
||||
index: number,
|
||||
field: string,
|
||||
value: string,
|
||||
) => {
|
||||
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 removeEnvVariable = (index: number) => {
|
||||
const updatedEnvVariables = envVariables.filter((_, i) => i !== index);
|
||||
setEnvVariables(updatedEnvVariables);
|
||||
};
|
||||
|
||||
const toggleShowValue = (name: string) => {
|
||||
setShowValues((prev) => ({ ...prev, [name]: !prev[name] }));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full space-y-4">
|
||||
<Bot className="w-16 h-16 text-primary animate-pulse" />
|
||||
<h2 className="text-2xl font-semibold animate-pulse">
|
||||
AI is processing your request
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Generating template suggestions based on your input...
|
||||
</p>
|
||||
<pre>{templateInfo.userInput}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<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}
|
||||
className="space-y-4"
|
||||
>
|
||||
{suggestions.map((suggestion) => (
|
||||
<div
|
||||
key={suggestion.id}
|
||||
className="flex items-start space-x-3"
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={suggestion.id}
|
||||
id={suggestion.id}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor={suggestion.id} className="font-medium">
|
||||
{suggestion.name}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{suggestion.shortDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
{selectedVariant && (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xl font-bold">{selectedTemplate?.name}</h3>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{selectedTemplate?.shortDescription}
|
||||
</p>
|
||||
</div>
|
||||
<ScrollArea className="h-[400px] p-5">
|
||||
<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>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="docker-compose">
|
||||
<AccordionTrigger>Docker Compose</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="h-[400px] w-full rounded-md border overflow-hidden">
|
||||
<MonacoEditor
|
||||
height="100%"
|
||||
language="yaml"
|
||||
theme="vs-dark"
|
||||
value={dockerCompose}
|
||||
onChange={(value) =>
|
||||
setDockerCompose(value as string)
|
||||
}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 14,
|
||||
lineNumbers: "on",
|
||||
readOnly: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="env-variables">
|
||||
<AccordionTrigger>Environment Variables</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ScrollArea className="h-[300px] w-full rounded-md border">
|
||||
<div className="p-4 space-y-4">
|
||||
{envVariables.map((env, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Input
|
||||
value={env.name}
|
||||
onChange={(e) =>
|
||||
handleEnvVariableChange(
|
||||
index,
|
||||
"name",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Variable Name"
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
type={
|
||||
showValues[env.name] ? "text" : "password"
|
||||
}
|
||||
value={env.value}
|
||||
onChange={(e) =>
|
||||
handleEnvVariableChange(
|
||||
index,
|
||||
"value",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Variable Value"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2"
|
||||
onClick={() => toggleShowValue(env.name)}
|
||||
>
|
||||
{showValues[env.name] ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeEnvVariable(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={addEnvVariable}
|
||||
>
|
||||
<PlusCircle className="h-4 w-4 mr-2" />
|
||||
Add Variable
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sticky bottom-0 bg-background pt-2 border-t">
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
onClick={() =>
|
||||
selectedVariant ? setSelectedVariant("") : prevStep()
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
{selectedVariant ? "Change Variant" : "Back"}
|
||||
</Button>
|
||||
<Button onClick={handleNext} disabled={!selectedVariant}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { api } from "@/utils/api";
|
||||
import { AlertCircle, 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";
|
||||
|
||||
const emptyState = {
|
||||
userInput: "",
|
||||
type: "",
|
||||
details: {
|
||||
id: "",
|
||||
dockerCompose: "",
|
||||
envVariables: [],
|
||||
shortDescription: "",
|
||||
},
|
||||
name: "",
|
||||
server: undefined,
|
||||
description: "",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
export function TemplateGenerator({ projectId }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [step, setStep] = useState(1);
|
||||
const { data: aiSettings } = api.ai.get.useQuery();
|
||||
const { mutateAsync } = api.ai.deploy.useMutation();
|
||||
const [templateInfo, setTemplateInfo] = useState(emptyState);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const totalSteps = 4;
|
||||
|
||||
const nextStep = () => setStep((prev) => Math.min(prev + 1, totalSteps));
|
||||
const prevStep = () => setStep((prev) => Math.max(prev - 1, 1));
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setOpen(newOpen);
|
||||
if (!newOpen) {
|
||||
// Reset to the first step when closing the dialog
|
||||
setStep(1);
|
||||
setTemplateInfo(emptyState);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild className="w-full">
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<Bot className="size-4 text-muted-foreground" />
|
||||
<span>AI Assistant</span>
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[800px] w-full max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI Assistant</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a custom template based on your needs
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<StepTwo
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
templateInfo={templateInfo}
|
||||
setTemplateInfo={setTemplateInfo}
|
||||
/>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<StepThree
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
templateInfo={templateInfo}
|
||||
setTemplateInfo={setTemplateInfo}
|
||||
/>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<StepFour
|
||||
prevStep={prevStep}
|
||||
templateInfo={templateInfo}
|
||||
setTemplateInfo={async (data: any) => {
|
||||
console.log("Submitting template:", data);
|
||||
setTemplateInfo(data);
|
||||
await mutateAsync({
|
||||
projectId,
|
||||
id: templateInfo.details?.id,
|
||||
name: templateInfo.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,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Compose Created");
|
||||
setOpen(false);
|
||||
await utils.project.one.invalidate({
|
||||
projectId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error creating the compose");
|
||||
});
|
||||
}}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
276
apps/dokploy/components/dashboard/settings/ai-form.tsx
Normal file
276
apps/dokploy/components/dashboard/settings/ai-form.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
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 { 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;
|
||||
}
|
||||
|
||||
export function 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");
|
||||
});
|
||||
};
|
||||
|
||||
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") && (
|
||||
<>
|
||||
<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...
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -103,6 +103,12 @@ export const SettingsLayout = ({ children }: Props) => {
|
||||
icon: Server,
|
||||
href: "/dashboard/settings/servers",
|
||||
},
|
||||
{
|
||||
title: "AI",
|
||||
label: "",
|
||||
icon: Sparkles,
|
||||
href: "/dashboard/settings/ai",
|
||||
},
|
||||
...(isCloud
|
||||
? [
|
||||
{
|
||||
@@ -152,13 +158,12 @@ import {
|
||||
Database,
|
||||
GalleryVerticalEnd,
|
||||
GitBranch,
|
||||
KeyIcon,
|
||||
KeyRound,
|
||||
ListMusic,
|
||||
type LucideIcon,
|
||||
Route,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
User2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
15
apps/dokploy/components/ui/skeleton.tsx
Normal file
15
apps/dokploy/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
7
apps/dokploy/drizzle/0054_cooing_typhoid_mary.sql
Normal file
7
apps/dokploy/drizzle/0054_cooing_typhoid_mary.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
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
|
||||
);
|
||||
4294
apps/dokploy/drizzle/meta/0054_snapshot.json
Normal file
4294
apps/dokploy/drizzle/meta/0054_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -379,6 +379,13 @@
|
||||
"when": 1735118844878,
|
||||
"tag": "0053_broken_kulan_gath",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 54,
|
||||
"version": "6",
|
||||
"when": 1736354168869,
|
||||
"tag": "0054_cooing_typhoid_mary",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -35,6 +35,7 @@
|
||||
"test": "vitest --config __test__/vitest.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^1.0.12",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-yaml": "^6.1.1",
|
||||
"@codemirror/language": "^6.10.1",
|
||||
@@ -42,22 +43,23 @@
|
||||
"@codemirror/view": "6.29.0",
|
||||
"@dokploy/server": "workspace:*",
|
||||
"@dokploy/trpc-openapi": "0.0.4",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@octokit/webhooks": "^13.2.7",
|
||||
"@radix-ui/react-accordion": "1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-progress": "^1.0.3",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-radio-group": "^1.2.0",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toggle": "^1.0.3",
|
||||
@@ -75,6 +77,7 @@
|
||||
"@xterm/addon-attach": "0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"adm-zip": "^0.5.14",
|
||||
"ai": "^4.0.23",
|
||||
"bcrypt": "5.1.1",
|
||||
"bullmq": "5.4.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
@@ -106,11 +109,12 @@
|
||||
"react": "18.2.0",
|
||||
"react-confetti-explosion": "2.1.2",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.49.3",
|
||||
"react-hook-form": "^7.52.1",
|
||||
"react-i18next": "^15.1.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"recharts": "^2.12.7",
|
||||
"slugify": "^1.6.6",
|
||||
"sonner": "^1.4.0",
|
||||
"sonner": "^1.5.0",
|
||||
"ssh2": "1.15.0",
|
||||
"stripe": "17.2.0",
|
||||
"superjson": "^2.2.1",
|
||||
@@ -125,6 +129,7 @@
|
||||
"zod-form-data": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/adm-zip": "^0.5.5",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
PostgresqlIcon,
|
||||
RedisIcon,
|
||||
} from "@/components/icons/data-tools-icons";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { ProjectLayout } from "@/components/layouts/project-layout";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
import { AddAiAssistant } from "@/components/dashboard/project/add-ai-assistant";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -229,6 +229,10 @@ const Project = (
|
||||
<AddDatabase projectId={projectId} projectName={data?.name} />
|
||||
<AddCompose projectId={projectId} projectName={data?.name} />
|
||||
<AddTemplate projectId={projectId} />
|
||||
<AddAiAssistant
|
||||
projectId={projectId}
|
||||
projectName={data?.name}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
71
apps/dokploy/pages/dashboard/settings/ai.tsx
Normal file
71
apps/dokploy/pages/dashboard/settings/ai.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
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";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import React, { type ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<AiForm />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => {
|
||||
return (
|
||||
<DashboardLayout tab={"settings"} metaName="AI">
|
||||
<SettingsLayout>{page}</SettingsLayout>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||
) {
|
||||
const { req, res } = ctx;
|
||||
const { user, session } = await validateRequest(req, res);
|
||||
const locale = getLocale(req.cookies);
|
||||
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: req as any,
|
||||
res: res as any,
|
||||
db: null as any,
|
||||
session: session,
|
||||
user: user,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
|
||||
await helpers.settings.isCloud.prefetch();
|
||||
|
||||
await helpers.auth.get.prefetch();
|
||||
if (user?.rol === "user") {
|
||||
await helpers.user.byAuthId.prefetch({
|
||||
authId: user.authId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
...(await serverSideTranslations(locale, ["settings"])),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { authRouter } from "@/server/api/routers/auth";
|
||||
import { createTRPCRouter } from "../api/trpc";
|
||||
import { adminRouter } from "./routers/admin";
|
||||
import { aiRouter } from "./routers/ai";
|
||||
import { applicationRouter } from "./routers/application";
|
||||
import { backupRouter } from "./routers/backup";
|
||||
import { bitbucketRouter } from "./routers/bitbucket";
|
||||
@@ -75,6 +76,7 @@ export const appRouter = createTRPCRouter({
|
||||
server: serverRouter,
|
||||
stripe: stripeRouter,
|
||||
swarm: swarmRouter,
|
||||
ai: aiRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
71
apps/dokploy/server/api/routers/ai.ts
Normal file
71
apps/dokploy/server/api/routers/ai.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { slugify } from "@/lib/slug";
|
||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
import { generatePassword } from "@/templates/utils";
|
||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||
import {
|
||||
apiAiSettingsSchema,
|
||||
deploySuggestionSchema,
|
||||
} from "@dokploy/server/db/schema/ai";
|
||||
import {
|
||||
getAiSettingsByAuthId,
|
||||
saveAiSettings,
|
||||
suggestVariants,
|
||||
} from "@dokploy/server/services/ai";
|
||||
import { createComposeByTemplate } from "@dokploy/server/services/compose";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
import {
|
||||
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");
|
||||
}
|
||||
|
||||
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;
|
||||
}),
|
||||
});
|
||||
@@ -87,7 +87,11 @@ const config = {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate"), require("fancy-ansi/plugin")],
|
||||
plugins: [
|
||||
require("tailwindcss-animate"),
|
||||
require("fancy-ansi/plugin"),
|
||||
require("@tailwindcss/typography"),
|
||||
],
|
||||
} satisfies Config;
|
||||
|
||||
export default config;
|
||||
|
||||
Reference in New Issue
Block a user