mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
@@ -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} />;
|
||||||
|
};
|
||||||
102
apps/dokploy/components/dashboard/project/ai/step-one.tsx
Normal file
102
apps/dokploy/components/dashboard/project/ai/step-one.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
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 const StepOne = ({ nextStep, setTemplateInfo, templateInfo }: any) => {
|
||||||
|
// Get servers from the API
|
||||||
|
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||||
|
|
||||||
|
const handleExampleClick = (example: string) => {
|
||||||
|
setTemplateInfo({ ...templateInfo, userInput: example });
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full gap-4">
|
||||||
|
<div className="">
|
||||||
|
<div className="space-y-4 ">
|
||||||
|
<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={templateInfo?.userInput}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTemplateInfo({ ...templateInfo, userInput: e.target.value })
|
||||||
|
}
|
||||||
|
className="min-h-[100px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-deploy">
|
||||||
|
Select the server where you want to deploy (optional)
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={templateInfo.server?.serverId}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const server = servers?.find((s) => s.serverId === value);
|
||||||
|
if (server) {
|
||||||
|
setTemplateInfo({
|
||||||
|
...templateInfo,
|
||||||
|
server: server,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<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 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
109
apps/dokploy/components/dashboard/project/ai/step-three.tsx
Normal file
109
apps/dokploy/components/dashboard/project/ai/step-three.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import type { StepProps } from "./step-two";
|
||||||
|
|
||||||
|
export const StepThree = ({ templateInfo }: StepProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex-grow">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-semibold">Step 3: Review and Finalize</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<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">Server</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{templateInfo?.server?.name || "Dokploy Server"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold">Docker Compose</h3>
|
||||||
|
<CodeEditor
|
||||||
|
lineWrapping
|
||||||
|
value={templateInfo?.details?.dockerCompose}
|
||||||
|
disabled
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm 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 className="text-sm font-semibold">
|
||||||
|
{env.name}
|
||||||
|
</strong>
|
||||||
|
:
|
||||||
|
<span className="text-sm ml-2 text-muted-foreground">
|
||||||
|
{env.value}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">Domains</h3>
|
||||||
|
<ul className="list-disc pl-5">
|
||||||
|
{templateInfo?.details?.domains.map(
|
||||||
|
(
|
||||||
|
domain: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
serviceName: string;
|
||||||
|
},
|
||||||
|
index: number,
|
||||||
|
) => (
|
||||||
|
<li key={index}>
|
||||||
|
<strong className="text-sm font-semibold">
|
||||||
|
{domain.host}
|
||||||
|
</strong>
|
||||||
|
:
|
||||||
|
<span className="text-sm ml-2 text-muted-foreground">
|
||||||
|
{domain.port} - {domain.serviceName}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">Configuration Files</h3>
|
||||||
|
<ul className="list-disc pl-5">
|
||||||
|
{templateInfo?.details?.configFiles.map((file, index) => (
|
||||||
|
<li key={index}>
|
||||||
|
<strong className="text-sm font-semibold">
|
||||||
|
{file.filePath}
|
||||||
|
</strong>
|
||||||
|
:
|
||||||
|
<span className="text-sm ml-2 text-muted-foreground">
|
||||||
|
{file.content}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
530
apps/dokploy/components/dashboard/project/ai/step-two.tsx
Normal file
530
apps/dokploy/components/dashboard/project/ai/step-two.tsx
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
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 { useEffect, useState } from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { TemplateInfo } from "./template-generator";
|
||||||
|
|
||||||
|
export interface StepProps {
|
||||||
|
stepper?: any;
|
||||||
|
templateInfo: TemplateInfo;
|
||||||
|
setTemplateInfo: React.Dispatch<React.SetStateAction<TemplateInfo>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
||||||
|
const suggestions = templateInfo.suggestions || [];
|
||||||
|
const selectedVariant = templateInfo.details;
|
||||||
|
const [showValues, setShowValues] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading, error, isError } =
|
||||||
|
api.ai.suggest.useMutation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (suggestions?.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mutateAsync({
|
||||||
|
aiId: templateInfo.aiId,
|
||||||
|
serverId: templateInfo.server?.serverId || "",
|
||||||
|
input: templateInfo.userInput,
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
setTemplateInfo({
|
||||||
|
...templateInfo,
|
||||||
|
suggestions: data,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toast.error("Error generating suggestions", {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [templateInfo.userInput]);
|
||||||
|
|
||||||
|
const toggleShowValue = (name: string) => {
|
||||||
|
setShowValues((prev) => ({ ...prev, [name]: !prev[name] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnvVariableChange = (
|
||||||
|
index: number,
|
||||||
|
field: "name" | "value",
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
if (!selectedVariant) return;
|
||||||
|
|
||||||
|
const updatedEnvVariables = [...selectedVariant.envVariables];
|
||||||
|
// @ts-ignore
|
||||||
|
updatedEnvVariables[index] = {
|
||||||
|
...updatedEnvVariables[index],
|
||||||
|
[field]: value,
|
||||||
|
};
|
||||||
|
|
||||||
|
setTemplateInfo({
|
||||||
|
...templateInfo,
|
||||||
|
...(templateInfo.details && {
|
||||||
|
details: {
|
||||||
|
...templateInfo.details,
|
||||||
|
envVariables: updatedEnvVariables,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEnvVariable = (index: number) => {
|
||||||
|
if (!selectedVariant) return;
|
||||||
|
|
||||||
|
const updatedEnvVariables = selectedVariant.envVariables.filter(
|
||||||
|
(_, i) => i !== index,
|
||||||
|
);
|
||||||
|
|
||||||
|
setTemplateInfo({
|
||||||
|
...templateInfo,
|
||||||
|
...(templateInfo.details && {
|
||||||
|
details: {
|
||||||
|
...templateInfo.details,
|
||||||
|
envVariables: updatedEnvVariables,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDomainChange = (
|
||||||
|
index: number,
|
||||||
|
field: "host" | "port" | "serviceName",
|
||||||
|
value: string | number,
|
||||||
|
) => {
|
||||||
|
if (!selectedVariant) return;
|
||||||
|
|
||||||
|
const updatedDomains = [...selectedVariant.domains];
|
||||||
|
// @ts-ignore
|
||||||
|
updatedDomains[index] = {
|
||||||
|
...updatedDomains[index],
|
||||||
|
[field]: value,
|
||||||
|
};
|
||||||
|
|
||||||
|
setTemplateInfo({
|
||||||
|
...templateInfo,
|
||||||
|
...(templateInfo.details && {
|
||||||
|
details: {
|
||||||
|
...templateInfo.details,
|
||||||
|
domains: updatedDomains,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDomain = (index: number) => {
|
||||||
|
if (!selectedVariant) return;
|
||||||
|
|
||||||
|
const updatedDomains = selectedVariant.domains.filter(
|
||||||
|
(_, i) => i !== index,
|
||||||
|
);
|
||||||
|
|
||||||
|
setTemplateInfo({
|
||||||
|
...templateInfo,
|
||||||
|
...(templateInfo.details && {
|
||||||
|
details: {
|
||||||
|
...templateInfo.details,
|
||||||
|
domains: updatedDomains,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEnvVariable = () => {
|
||||||
|
if (!selectedVariant) return;
|
||||||
|
|
||||||
|
setTemplateInfo({
|
||||||
|
...templateInfo,
|
||||||
|
|
||||||
|
...(templateInfo.details && {
|
||||||
|
details: {
|
||||||
|
...templateInfo.details,
|
||||||
|
envVariables: [
|
||||||
|
...selectedVariant.envVariables,
|
||||||
|
{ name: "", value: "" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDomain = () => {
|
||||||
|
if (!selectedVariant) return;
|
||||||
|
|
||||||
|
setTemplateInfo({
|
||||||
|
...templateInfo,
|
||||||
|
...(templateInfo.details && {
|
||||||
|
details: {
|
||||||
|
...templateInfo.details,
|
||||||
|
domains: [
|
||||||
|
...selectedVariant.domains,
|
||||||
|
{ host: "", port: 80, serviceName: "" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (isError) {
|
||||||
|
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">Error</h2>
|
||||||
|
<AlertBlock type="error">
|
||||||
|
{error?.message || "Error generating suggestions"}
|
||||||
|
</AlertBlock>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full gap-6">
|
||||||
|
<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={(value) => {
|
||||||
|
const element = suggestions?.find((s) => s?.id === value);
|
||||||
|
setTemplateInfo({
|
||||||
|
...templateInfo,
|
||||||
|
details: element,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
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">{selectedVariant?.name}</h3>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
{selectedVariant?.shortDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ScrollArea>
|
||||||
|
<Accordion type="single" collapsible className="w-full">
|
||||||
|
<AccordionItem value="description">
|
||||||
|
<AccordionTrigger>Description</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<ScrollArea className=" w-full rounded-md border p-4">
|
||||||
|
<ReactMarkdown className="text-muted-foreground text-sm">
|
||||||
|
{selectedVariant?.description}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</ScrollArea>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem value="docker-compose">
|
||||||
|
<AccordionTrigger>Docker Compose</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<CodeEditor
|
||||||
|
value={selectedVariant?.dockerCompose}
|
||||||
|
className="font-mono"
|
||||||
|
onChange={(value) => {
|
||||||
|
setTemplateInfo({
|
||||||
|
...templateInfo,
|
||||||
|
...(templateInfo?.details && {
|
||||||
|
details: {
|
||||||
|
...templateInfo.details,
|
||||||
|
dockerCompose: value,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem value="env-variables">
|
||||||
|
<AccordionTrigger>Environment Variables</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<ScrollArea className=" w-full rounded-md border">
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{selectedVariant?.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>
|
||||||
|
<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",
|
||||||
|
Number.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>
|
||||||
|
<AccordionItem value="mounts">
|
||||||
|
<AccordionTrigger>Configuration Files</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<ScrollArea className="w-full rounded-md border">
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{selectedVariant?.configFiles?.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm text-muted-foreground mb-4">
|
||||||
|
This template requires the following
|
||||||
|
configuration files to be mounted:
|
||||||
|
</div>
|
||||||
|
{selectedVariant.configFiles.map(
|
||||||
|
(config, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="space-y-2 border rounded-lg p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-primary">
|
||||||
|
{config.filePath}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Will be mounted as: ../files
|
||||||
|
{config.filePath}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CodeEditor
|
||||||
|
value={config.content}
|
||||||
|
className="font-mono"
|
||||||
|
onChange={(value) => {
|
||||||
|
if (!selectedVariant?.configFiles)
|
||||||
|
return;
|
||||||
|
const updatedConfigFiles = [
|
||||||
|
...selectedVariant.configFiles,
|
||||||
|
];
|
||||||
|
updatedConfigFiles[index] = {
|
||||||
|
filePath: config.filePath,
|
||||||
|
content: value,
|
||||||
|
};
|
||||||
|
setTemplateInfo({
|
||||||
|
...templateInfo,
|
||||||
|
...(templateInfo.details && {
|
||||||
|
details: {
|
||||||
|
...templateInfo.details,
|
||||||
|
configFiles: updatedConfigFiles,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-muted-foreground py-8">
|
||||||
|
<p>
|
||||||
|
This template doesn't require any configuration
|
||||||
|
files.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm mt-2">
|
||||||
|
All necessary configurations are handled through
|
||||||
|
environment variables.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</ScrollArea>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
{selectedVariant && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const { details, ...rest } = templateInfo;
|
||||||
|
setTemplateInfo(rest);
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Change Variant
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { defineStepper } from "@stepperize/react";
|
||||||
|
import { Bot } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { StepOne } from "./step-one";
|
||||||
|
import { StepThree } from "./step-three";
|
||||||
|
import { StepTwo } from "./step-two";
|
||||||
|
|
||||||
|
interface EnvVariable {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Domain {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
serviceName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Details {
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
dockerCompose: string;
|
||||||
|
envVariables: EnvVariable[];
|
||||||
|
shortDescription: string;
|
||||||
|
domains: Domain[];
|
||||||
|
configFiles: Mount[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Mount {
|
||||||
|
filePath: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateInfo {
|
||||||
|
userInput: string;
|
||||||
|
details?: Details | null;
|
||||||
|
suggestions?: Details[];
|
||||||
|
server?: {
|
||||||
|
serverId: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
aiId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTemplateInfo: TemplateInfo = {
|
||||||
|
aiId: "",
|
||||||
|
userInput: "",
|
||||||
|
server: undefined,
|
||||||
|
details: null,
|
||||||
|
suggestions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const { useStepper, steps, Scoped } = defineStepper(
|
||||||
|
{
|
||||||
|
id: "needs",
|
||||||
|
title: "Describe your needs",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "variant",
|
||||||
|
title: "Choose a Variant",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "review",
|
||||||
|
title: "Review and Finalize",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
projectName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TemplateGenerator = ({ projectId }: Props) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const stepper = useStepper();
|
||||||
|
const { data: aiSettings } = api.ai.getAll.useQuery();
|
||||||
|
const { mutateAsync } = api.ai.deploy.useMutation();
|
||||||
|
const [templateInfo, setTemplateInfo] =
|
||||||
|
useState<TemplateInfo>(defaultTemplateInfo);
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const haveAtleasOneProviderEnabled = aiSettings?.some(
|
||||||
|
(ai) => ai.isEnabled === true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isDisabled = () => {
|
||||||
|
if (stepper.current.id === "needs") {
|
||||||
|
return !templateInfo.aiId || !templateInfo.userInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepper.current.id === "variant") {
|
||||||
|
return !templateInfo?.details?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
await mutateAsync({
|
||||||
|
projectId,
|
||||||
|
id: templateInfo.details?.id || "",
|
||||||
|
name: templateInfo?.details?.name || "",
|
||||||
|
description: templateInfo?.details?.shortDescription || "",
|
||||||
|
dockerCompose: templateInfo?.details?.dockerCompose || "",
|
||||||
|
envVariables: (templateInfo?.details?.envVariables || [])
|
||||||
|
.map((env: any) => `${env.name}=${env.value}`)
|
||||||
|
.join("\n"),
|
||||||
|
domains: templateInfo?.details?.domains || [],
|
||||||
|
...(templateInfo.server?.serverId && {
|
||||||
|
serverId: templateInfo.server?.serverId || "",
|
||||||
|
}),
|
||||||
|
configFiles: templateInfo?.details?.configFiles || [],
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Compose Created");
|
||||||
|
setOpen(false);
|
||||||
|
await utils.project.one.invalidate({
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error creating the compose");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<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-h-screen overflow-y-auto sm:max-w-4xl w-full flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>AI Assistant</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a custom template based on your needs
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Steps</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Step {stepper.current.index + 1} of {steps.length}
|
||||||
|
</span>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Scoped>
|
||||||
|
<nav aria-label="Checkout Steps" className="group my-4">
|
||||||
|
<ol
|
||||||
|
className="flex items-center justify-between gap-2"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
>
|
||||||
|
{stepper.all.map((step, index, array) => (
|
||||||
|
<React.Fragment key={step.id}>
|
||||||
|
<li className="flex items-center gap-4 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
variant={
|
||||||
|
index <= stepper.current.index ? "secondary" : "ghost"
|
||||||
|
}
|
||||||
|
aria-current={
|
||||||
|
stepper.current.id === step.id ? "step" : undefined
|
||||||
|
}
|
||||||
|
aria-posinset={index + 1}
|
||||||
|
aria-setsize={steps.length}
|
||||||
|
aria-selected={stepper.current.id === step.id}
|
||||||
|
className="flex size-10 items-center justify-center rounded-full border-2 border-border"
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm font-medium">{step.title}</span>
|
||||||
|
</li>
|
||||||
|
{index < array.length - 1 && (
|
||||||
|
<Separator
|
||||||
|
className={`flex-1 ${
|
||||||
|
index < stepper.current.index
|
||||||
|
? "bg-primary"
|
||||||
|
: "bg-muted"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
{stepper.switch({
|
||||||
|
needs: () => (
|
||||||
|
<>
|
||||||
|
{!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
|
||||||
|
setTemplateInfo={setTemplateInfo}
|
||||||
|
templateInfo={templateInfo}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
variant: () => (
|
||||||
|
<StepTwo
|
||||||
|
templateInfo={templateInfo}
|
||||||
|
setTemplateInfo={setTemplateInfo}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
review: () => (
|
||||||
|
<StepThree
|
||||||
|
templateInfo={templateInfo}
|
||||||
|
setTemplateInfo={setTemplateInfo}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</Scoped>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="flex items-center gap-2 w-full justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={stepper.prev}
|
||||||
|
disabled={stepper.isFirst}
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={isDisabled()}
|
||||||
|
onClick={async () => {
|
||||||
|
if (stepper.current.id === "needs") {
|
||||||
|
setTemplateInfo((prev) => ({
|
||||||
|
...prev,
|
||||||
|
suggestions: [],
|
||||||
|
details: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepper.isLast) {
|
||||||
|
await onSubmit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stepper.next();
|
||||||
|
// if (stepper.isLast) {
|
||||||
|
// // setIsOpen(false);
|
||||||
|
// // push("/dashboard/projects");
|
||||||
|
// } else {
|
||||||
|
// stepper.next();
|
||||||
|
// }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stepper.isLast ? "Create" : "Next"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
106
apps/dokploy/components/dashboard/settings/ai-form.tsx
Normal file
106
apps/dokploy/components/dashboard/settings/ai-form.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { BotIcon, Loader2, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { HandleAi } from "./handle-ai";
|
||||||
|
|
||||||
|
export const AiForm = () => {
|
||||||
|
const { data: aiConfigs, refetch, isLoading } = api.ai.getAll.useQuery();
|
||||||
|
const { mutateAsync, isLoading: isRemoving } = api.ai.delete.useMutation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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 { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
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 { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -149,7 +149,6 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => {
|
|||||||
Add SSH Key
|
Add SSH Key
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* {children} */}
|
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Bell,
|
Bell,
|
||||||
BlocksIcon,
|
BlocksIcon,
|
||||||
BookIcon,
|
BookIcon,
|
||||||
|
BotIcon,
|
||||||
Boxes,
|
Boxes,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
@@ -296,6 +297,12 @@ const MENU: Menu = {
|
|||||||
isEnabled: ({ auth }) =>
|
isEnabled: ({ auth }) =>
|
||||||
!!(auth?.role === "owner" || auth?.canAccessToSSHKeys),
|
!!(auth?.role === "owner" || auth?.canAccessToSSHKeys),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "AI",
|
||||||
|
icon: BotIcon,
|
||||||
|
url: "/dashboard/settings/ai",
|
||||||
|
isSingle: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
title: "Git",
|
title: "Git",
|
||||||
|
|||||||
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 $$;
|
||||||
12
apps/dokploy/drizzle/0068_complex_rhino.sql
Normal file
12
apps/dokploy/drizzle/0068_complex_rhino.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE "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,
|
||||||
|
"organizationId" text NOT NULL,
|
||||||
|
"createdAt" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "ai" ADD CONSTRAINT "ai_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
5118
apps/dokploy/drizzle/meta/0068_snapshot.json
Normal file
5118
apps/dokploy/drizzle/meta/0068_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -477,6 +477,13 @@
|
|||||||
"when": 1740892043121,
|
"when": 1740892043121,
|
||||||
"tag": "0067_condemned_sugar_man",
|
"tag": "0067_condemned_sugar_man",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 68,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1740897756774,
|
||||||
|
"tag": "0068_complex_rhino",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
"test": "vitest --config __test__/vitest.config.ts"
|
"test": "vitest --config __test__/vitest.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/openai": "^1.0.12",
|
||||||
"better-auth": "1.2.0",
|
"better-auth": "1.2.0",
|
||||||
"bl": "6.0.11",
|
"bl": "6.0.11",
|
||||||
"rotating-file-stream": "3.2.3",
|
"rotating-file-stream": "3.2.3",
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
"@codemirror/view": "6.29.0",
|
"@codemirror/view": "6.29.0",
|
||||||
"@dokploy/server": "workspace:*",
|
"@dokploy/server": "workspace:*",
|
||||||
"@dokploy/trpc-openapi": "0.0.4",
|
"@dokploy/trpc-openapi": "0.0.4",
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@octokit/webhooks": "^13.2.7",
|
"@octokit/webhooks": "^13.2.7",
|
||||||
"@radix-ui/react-accordion": "1.1.2",
|
"@radix-ui/react-accordion": "1.1.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||||
@@ -65,12 +66,12 @@
|
|||||||
"@radix-ui/react-collapsible": "^1.1.1",
|
"@radix-ui/react-collapsible": "^1.1.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.1",
|
"@radix-ui/react-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"@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-popover": "^1.0.7",
|
||||||
"@radix-ui/react-progress": "^1.0.3",
|
"@radix-ui/react-progress": "^1.0.3",
|
||||||
"@radix-ui/react-radio-group": "^1.1.3",
|
"@radix-ui/react-radio-group": "^1.2.0",
|
||||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||||
"@radix-ui/react-select": "^2.0.0",
|
"@radix-ui/react-select": "^2.1.1",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
@@ -91,6 +92,7 @@
|
|||||||
"@xterm/xterm": "^5.4.0",
|
"@xterm/xterm": "^5.4.0",
|
||||||
"@xterm/addon-clipboard": "0.1.0",
|
"@xterm/addon-clipboard": "0.1.0",
|
||||||
"adm-zip": "^0.5.14",
|
"adm-zip": "^0.5.14",
|
||||||
|
"ai": "^4.0.23",
|
||||||
"bcrypt": "5.1.1",
|
"bcrypt": "5.1.1",
|
||||||
"bullmq": "5.4.2",
|
"bullmq": "5.4.2",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
@@ -123,11 +125,12 @@
|
|||||||
"react-confetti-explosion": "2.1.2",
|
"react-confetti-explosion": "2.1.2",
|
||||||
"react-day-picker": "8.10.1",
|
"react-day-picker": "8.10.1",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "^7.49.3",
|
"react-hook-form": "^7.52.1",
|
||||||
"react-i18next": "^15.1.0",
|
"react-i18next": "^15.1.0",
|
||||||
|
"react-markdown": "^9.0.1",
|
||||||
"recharts": "^2.12.7",
|
"recharts": "^2.12.7",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"sonner": "^1.4.0",
|
"sonner": "^1.5.0",
|
||||||
"ssh2": "1.15.0",
|
"ssh2": "1.15.0",
|
||||||
"stripe": "17.2.0",
|
"stripe": "17.2.0",
|
||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
@@ -140,7 +143,8 @@
|
|||||||
"xterm-addon-fit": "^0.8.0",
|
"xterm-addon-fit": "^0.8.0",
|
||||||
"zod": "^3.23.4",
|
"zod": "^3.23.4",
|
||||||
"zod-form-data": "^2.0.2",
|
"zod-form-data": "^2.0.2",
|
||||||
"@faker-js/faker": "^8.4.1"
|
"@faker-js/faker": "^8.4.1",
|
||||||
|
"@tailwindcss/typography": "0.5.16"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { DateTooltip } from "@/components/shared/date-tooltip";
|
|||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
import { AddAiAssistant } from "@/components/dashboard/project/add-ai-assistant";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -361,6 +363,10 @@ const Project = (
|
|||||||
projectName={data?.name}
|
projectName={data?.name}
|
||||||
/>
|
/>
|
||||||
<AddTemplate projectId={projectId} />
|
<AddTemplate projectId={projectId} />
|
||||||
|
<AddAiAssistant
|
||||||
|
projectId={projectId}
|
||||||
|
projectName={data?.name}
|
||||||
|
/>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
61
apps/dokploy/pages/dashboard/settings/ai.tsx
Normal file
61
apps/dokploy/pages/dashboard/settings/ai.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { AiForm } from "@/components/dashboard/settings/ai-form";
|
||||||
|
import { DashboardLayout } from "@/components/layouts/dashboard-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>{page}</DashboardLayout>;
|
||||||
|
};
|
||||||
|
export async function getServerSideProps(
|
||||||
|
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||||
|
) {
|
||||||
|
const { req, res } = ctx;
|
||||||
|
const { user, session } = await validateRequest(req);
|
||||||
|
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 as any,
|
||||||
|
user: user as any,
|
||||||
|
},
|
||||||
|
transformer: superjson,
|
||||||
|
});
|
||||||
|
|
||||||
|
await helpers.settings.isCloud.prefetch();
|
||||||
|
|
||||||
|
await helpers.user.get.prefetch();
|
||||||
|
|
||||||
|
if (!user || user.role === "member") {
|
||||||
|
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 { authRouter } from "@/server/api/routers/auth";
|
||||||
import { createTRPCRouter } from "../api/trpc";
|
import { createTRPCRouter } from "../api/trpc";
|
||||||
import { adminRouter } from "./routers/admin";
|
import { adminRouter } from "./routers/admin";
|
||||||
|
import { aiRouter } from "./routers/ai";
|
||||||
import { applicationRouter } from "./routers/application";
|
import { applicationRouter } from "./routers/application";
|
||||||
import { backupRouter } from "./routers/backup";
|
import { backupRouter } from "./routers/backup";
|
||||||
import { bitbucketRouter } from "./routers/bitbucket";
|
import { bitbucketRouter } from "./routers/bitbucket";
|
||||||
@@ -75,6 +76,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
server: serverRouter,
|
server: serverRouter,
|
||||||
stripe: stripeRouter,
|
stripe: stripeRouter,
|
||||||
swarm: swarmRouter,
|
swarm: swarmRouter,
|
||||||
|
ai: aiRouter,
|
||||||
organization: organizationRouter,
|
organization: organizationRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
173
apps/dokploy/server/api/routers/ai.ts
Normal file
173
apps/dokploy/server/api/routers/ai.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { slugify } from "@/lib/slug";
|
||||||
|
import {
|
||||||
|
adminProcedure,
|
||||||
|
createTRPCRouter,
|
||||||
|
protectedProcedure,
|
||||||
|
} from "@/server/api/trpc";
|
||||||
|
import { generatePassword } from "@/templates/utils";
|
||||||
|
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||||
|
import {
|
||||||
|
apiCreateAi,
|
||||||
|
apiUpdateAi,
|
||||||
|
deploySuggestionSchema,
|
||||||
|
} from "@dokploy/server/db/schema/ai";
|
||||||
|
import { createDomain, createMount } from "@dokploy/server/index";
|
||||||
|
import {
|
||||||
|
deleteAiSettings,
|
||||||
|
getAiSettingById,
|
||||||
|
getAiSettingsByOrganizationId,
|
||||||
|
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({
|
||||||
|
one: protectedProcedure
|
||||||
|
.input(z.object({ aiId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const aiSetting = await getAiSettingById(input.aiId);
|
||||||
|
if (aiSetting.organizationId !== ctx.session.activeOrganizationId) {
|
||||||
|
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.session.activeOrganizationId, input);
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: protectedProcedure
|
||||||
|
.input(apiUpdateAi)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return await saveAiSettings(ctx.session.activeOrganizationId, input);
|
||||||
|
}),
|
||||||
|
|
||||||
|
getAll: adminProcedure.query(async ({ ctx }) => {
|
||||||
|
return await getAiSettingsByOrganizationId(
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
get: protectedProcedure
|
||||||
|
.input(z.object({ aiId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const aiSetting = await getAiSettingById(input.aiId);
|
||||||
|
if (aiSetting.organizationId !== ctx.session.activeOrganizationId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You don't have access to this AI configuration",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return aiSetting;
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: protectedProcedure
|
||||||
|
.input(z.object({ aiId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const aiSetting = await getAiSettingById(input.aiId);
|
||||||
|
if (aiSetting.organizationId !== ctx.session.activeOrganizationId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You don't have access to this AI configuration",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await deleteAiSettings(input.aiId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
suggest: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
aiId: z.string(),
|
||||||
|
input: z.string(),
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
try {
|
||||||
|
return await suggestVariants({
|
||||||
|
...input,
|
||||||
|
organizationId: ctx.session.activeOrganizationId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: error instanceof Error ? error?.message : `Error: ${error}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
deploy: protectedProcedure
|
||||||
|
.input(deploySuggestionSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
if (ctx.user.rol === "member") {
|
||||||
|
await checkServiceAccess(
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
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)}`,
|
||||||
|
isolatedDeployment: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.domains && input.domains?.length > 0) {
|
||||||
|
for (const domain of input.domains) {
|
||||||
|
await createDomain({
|
||||||
|
...domain,
|
||||||
|
domainType: "compose",
|
||||||
|
certificateType: "none",
|
||||||
|
composeId: compose.composeId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.configFiles && input.configFiles?.length > 0) {
|
||||||
|
for (const mount of input.configFiles) {
|
||||||
|
await createMount({
|
||||||
|
filePath: mount.filePath,
|
||||||
|
mountPath: "",
|
||||||
|
content: mount.content,
|
||||||
|
serviceId: compose.composeId,
|
||||||
|
serviceType: "compose",
|
||||||
|
type: "file",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.user.rol === "member") {
|
||||||
|
await addNewService(
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
ctx.user.ownerId,
|
||||||
|
compose.composeId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -111,7 +111,11 @@ const config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate"), require("fancy-ansi/plugin")],
|
plugins: [
|
||||||
|
require("tailwindcss-animate"),
|
||||||
|
require("fancy-ansi/plugin"),
|
||||||
|
require("@tailwindcss/typography"),
|
||||||
|
],
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -28,18 +28,25 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/anthropic": "^1.0.6",
|
||||||
|
"@ai-sdk/azure": "^1.0.15",
|
||||||
|
"@ai-sdk/cohere": "^1.0.6",
|
||||||
|
"@ai-sdk/deepinfra": "^0.0.4",
|
||||||
|
"@ai-sdk/mistral": "^1.0.6",
|
||||||
|
"@ai-sdk/openai": "^1.0.12",
|
||||||
|
"@ai-sdk/openai-compatible": "^0.0.13",
|
||||||
"@better-auth/utils":"0.2.3",
|
"@better-auth/utils":"0.2.3",
|
||||||
"@oslojs/encoding":"1.1.0",
|
"@oslojs/encoding":"1.1.0",
|
||||||
"@oslojs/crypto":"1.0.1",
|
"@oslojs/crypto":"1.0.1",
|
||||||
"drizzle-dbml-generator":"0.10.0",
|
"drizzle-dbml-generator":"0.10.0",
|
||||||
"better-auth":"1.2.0",
|
"better-auth":"1.2.0",
|
||||||
"rotating-file-stream": "3.2.3",
|
|
||||||
"@faker-js/faker": "^8.4.1",
|
"@faker-js/faker": "^8.4.1",
|
||||||
"@lucia-auth/adapter-drizzle": "1.0.7",
|
"@lucia-auth/adapter-drizzle": "1.0.7",
|
||||||
"@octokit/auth-app": "^6.0.4",
|
"@octokit/auth-app": "^6.0.4",
|
||||||
"@react-email/components": "^0.0.21",
|
"@react-email/components": "^0.0.21",
|
||||||
"@trpc/server": "^10.43.6",
|
"@trpc/server": "^10.43.6",
|
||||||
"adm-zip": "^0.5.14",
|
"adm-zip": "^0.5.14",
|
||||||
|
"ai": "^4.0.23",
|
||||||
"bcrypt": "5.1.1",
|
"bcrypt": "5.1.1",
|
||||||
"bl": "6.0.11",
|
"bl": "6.0.11",
|
||||||
"boxen": "^7.1.1",
|
"boxen": "^7.1.1",
|
||||||
@@ -58,22 +65,20 @@
|
|||||||
"node-schedule": "2.1.1",
|
"node-schedule": "2.1.1",
|
||||||
"nodemailer": "6.9.14",
|
"nodemailer": "6.9.14",
|
||||||
"octokit": "3.1.2",
|
"octokit": "3.1.2",
|
||||||
|
"ollama-ai-provider": "^1.1.0",
|
||||||
"otpauth": "^9.2.3",
|
"otpauth": "^9.2.3",
|
||||||
"postgres": "3.4.4",
|
"postgres": "3.4.4",
|
||||||
"public-ip": "6.0.2",
|
"public-ip": "6.0.2",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
|
"rotating-file-stream": "3.2.3",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
|
"ssh2": "1.15.0",
|
||||||
"ws": "8.16.0",
|
"ws": "8.16.0",
|
||||||
"zod": "^3.23.4",
|
"zod": "^3.23.4"
|
||||||
"ssh2": "1.15.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"esbuild-plugin-alias": "0.2.1",
|
|
||||||
"tailwindcss": "^3.4.1",
|
|
||||||
"tsx": "^4.7.1",
|
|
||||||
"tsc-alias": "1.8.10",
|
|
||||||
"@types/adm-zip": "^0.5.5",
|
"@types/adm-zip": "^0.5.5",
|
||||||
"@types/bcrypt": "5.0.2",
|
"@types/bcrypt": "5.0.2",
|
||||||
"@types/dockerode": "3.3.23",
|
"@types/dockerode": "3.3.23",
|
||||||
@@ -86,11 +91,15 @@
|
|||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/react": "^18.2.37",
|
"@types/react": "^18.2.37",
|
||||||
"@types/react-dom": "^18.2.15",
|
"@types/react-dom": "^18.2.15",
|
||||||
|
"@types/ssh2": "1.15.1",
|
||||||
"@types/ws": "8.5.10",
|
"@types/ws": "8.5.10",
|
||||||
"drizzle-kit": "^0.30.4",
|
"drizzle-kit": "^0.30.4",
|
||||||
"esbuild": "0.20.2",
|
"esbuild": "0.20.2",
|
||||||
|
"esbuild-plugin-alias": "0.2.1",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
"typescript": "^5.4.2",
|
"tailwindcss": "^3.4.1",
|
||||||
"@types/ssh2": "1.15.1"
|
"tsc-alias": "1.8.10",
|
||||||
|
"tsx": "^4.7.1",
|
||||||
|
"typescript": "^5.4.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
82
packages/server/src/db/schema/ai.ts
Normal file
82
packages/server/src/db/schema/ai.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
import { boolean, pgTable, text } from "drizzle-orm/pg-core";
|
||||||
|
import { createInsertSchema } from "drizzle-zod";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { organization } from "./account";
|
||||||
|
export const ai = pgTable("ai", {
|
||||||
|
aiId: text("aiId")
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
apiUrl: text("apiUrl").notNull(),
|
||||||
|
apiKey: text("apiKey").notNull(),
|
||||||
|
model: text("model").notNull(),
|
||||||
|
isEnabled: boolean("isEnabled").notNull().default(true),
|
||||||
|
organizationId: text("organizationId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => organization.id, { onDelete: "cascade" }), // Admin ID who created the AI settings
|
||||||
|
createdAt: text("createdAt")
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date().toISOString()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiRelations = relations(ai, ({ one }) => ({
|
||||||
|
organization: one(organization, {
|
||||||
|
fields: [ai.organizationId],
|
||||||
|
references: [organization.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createSchema = createInsertSchema(ai, {
|
||||||
|
name: z.string().min(1, { message: "Name is required" }),
|
||||||
|
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
|
||||||
|
apiKey: z.string().min(1, { message: "API Key is required" }),
|
||||||
|
model: z.string().min(1, { message: "Model is required" }),
|
||||||
|
isEnabled: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiCreateAi = createSchema
|
||||||
|
.pick({
|
||||||
|
name: true,
|
||||||
|
apiUrl: true,
|
||||||
|
apiKey: true,
|
||||||
|
model: true,
|
||||||
|
isEnabled: true,
|
||||||
|
})
|
||||||
|
.required();
|
||||||
|
|
||||||
|
export const apiUpdateAi = createSchema
|
||||||
|
.partial()
|
||||||
|
.extend({
|
||||||
|
aiId: z.string().min(1),
|
||||||
|
})
|
||||||
|
.omit({ organizationId: true });
|
||||||
|
|
||||||
|
export const deploySuggestionSchema = z.object({
|
||||||
|
projectId: z.string().min(1),
|
||||||
|
id: z.string().min(1),
|
||||||
|
dockerCompose: z.string().min(1),
|
||||||
|
envVariables: z.string(),
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
name: z.string().min(1),
|
||||||
|
description: z.string(),
|
||||||
|
domains: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
host: z.string().min(1),
|
||||||
|
port: z.number().min(1),
|
||||||
|
serviceName: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
configFiles: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
filePath: z.string().min(1),
|
||||||
|
content: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
@@ -28,4 +28,5 @@ export * from "./gitlab";
|
|||||||
export * from "./server";
|
export * from "./server";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
export * from "./preview-deployments";
|
export * from "./preview-deployments";
|
||||||
|
export * from "./ai";
|
||||||
export * from "./account";
|
export * from "./account";
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export const findUserById = async (userId: string) => {
|
|||||||
export const findOrganizationById = async (organizationId: string) => {
|
export const findOrganizationById = async (organizationId: string) => {
|
||||||
const organizationResult = await db.query.organization.findFirst({
|
const organizationResult = await db.query.organization.findFirst({
|
||||||
where: eq(organization.id, organizationId),
|
where: eq(organization.id, organizationId),
|
||||||
|
with: {
|
||||||
|
owner: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return organizationResult;
|
return organizationResult;
|
||||||
};
|
};
|
||||||
|
|||||||
212
packages/server/src/services/ai.ts
Normal file
212
packages/server/src/services/ai.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { db } from "@dokploy/server/db";
|
||||||
|
import { ai } from "@dokploy/server/db/schema";
|
||||||
|
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { generateObject } from "ai";
|
||||||
|
import { desc, eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { IS_CLOUD } from "../constants";
|
||||||
|
import { findServerById } from "./server";
|
||||||
|
import { findOrganizationById } from "./admin";
|
||||||
|
|
||||||
|
export const getAiSettingsByOrganizationId = async (organizationId: string) => {
|
||||||
|
const aiSettings = await db.query.ai.findMany({
|
||||||
|
where: eq(ai.organizationId, organizationId),
|
||||||
|
orderBy: desc(ai.createdAt),
|
||||||
|
});
|
||||||
|
return aiSettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAiSettingById = async (aiId: string) => {
|
||||||
|
const aiSetting = await db.query.ai.findFirst({
|
||||||
|
where: eq(ai.aiId, aiId),
|
||||||
|
});
|
||||||
|
if (!aiSetting) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "AI settings not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return aiSetting;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveAiSettings = async (organizationId: string, settings: any) => {
|
||||||
|
const aiId = settings.aiId;
|
||||||
|
|
||||||
|
return db
|
||||||
|
.insert(ai)
|
||||||
|
.values({
|
||||||
|
aiId,
|
||||||
|
organizationId,
|
||||||
|
...settings,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: ai.aiId,
|
||||||
|
set: {
|
||||||
|
...settings,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteAiSettings = async (aiId: string) => {
|
||||||
|
return db.delete(ai).where(eq(ai.aiId, aiId));
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
organizationId: string;
|
||||||
|
aiId: string;
|
||||||
|
input: string;
|
||||||
|
serverId?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const suggestVariants = async ({
|
||||||
|
organizationId,
|
||||||
|
aiId,
|
||||||
|
input,
|
||||||
|
serverId,
|
||||||
|
}: Props) => {
|
||||||
|
try {
|
||||||
|
const aiSettings = await getAiSettingById(aiId);
|
||||||
|
if (!aiSettings || !aiSettings.isEnabled) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "AI features are not enabled for this configuration",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = selectAIProvider(aiSettings);
|
||||||
|
const model = provider(aiSettings.model);
|
||||||
|
|
||||||
|
let ip = "";
|
||||||
|
if (!IS_CLOUD) {
|
||||||
|
const organization = await findOrganizationById(organizationId);
|
||||||
|
ip = organization?.owner.serverIp || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
const server = await findServerById(serverId);
|
||||||
|
ip = server.ipAddress;
|
||||||
|
} else if (process.env.NODE_ENV === "development") {
|
||||||
|
ip = "127.0.0.1";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { object } = await generateObject({
|
||||||
|
model,
|
||||||
|
output: "array",
|
||||||
|
schema: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
shortDescription: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
}),
|
||||||
|
prompt: `
|
||||||
|
Act as advanced DevOps engineer and generate a list of open source projects what can cover users needs(up to 3 items), the suggestion
|
||||||
|
should include id, name, shortDescription, and description. Use slug of title for id.
|
||||||
|
|
||||||
|
Important rules for the response:
|
||||||
|
1. The description field should ONLY contain a plain text description of the project, its features, and use cases
|
||||||
|
2. Do NOT include any code snippets, configuration examples, or installation instructions in the description
|
||||||
|
3. The shortDescription should be a single-line summary focusing on the main technologies
|
||||||
|
|
||||||
|
User wants to create a new project with the following details, it should be installable in docker and can be docker compose generated for it:
|
||||||
|
|
||||||
|
${input}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (object?.length) {
|
||||||
|
const result = [];
|
||||||
|
for (const suggestion of object) {
|
||||||
|
try {
|
||||||
|
const { object: docker } = await generateObject({
|
||||||
|
model,
|
||||||
|
output: "object",
|
||||||
|
schema: z.object({
|
||||||
|
dockerCompose: z.string(),
|
||||||
|
envVariables: z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
domains: z.array(
|
||||||
|
z.object({
|
||||||
|
host: z.string(),
|
||||||
|
port: z.number(),
|
||||||
|
serviceName: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
configFiles: z.array(
|
||||||
|
z.object({
|
||||||
|
content: z.string(),
|
||||||
|
filePath: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
prompt: `
|
||||||
|
Act as advanced DevOps engineer and generate docker compose with environment variables and domain configurations needed to install the following project.
|
||||||
|
Return the docker compose as a YAML string and environment variables configuration. Follow these rules:
|
||||||
|
|
||||||
|
Docker Compose Rules:
|
||||||
|
1. Use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker-compose.yml
|
||||||
|
2. Use complex values for passwords/secrets variables
|
||||||
|
3. Don't set container_name field in services
|
||||||
|
4. Don't set version field in the docker compose
|
||||||
|
5. Don't set ports like 'ports: 3000:3000', use 'ports: "3000"' instead
|
||||||
|
6. If a service depends on a database or other service, INCLUDE that service in the docker-compose
|
||||||
|
7. Make sure all required services are defined in the docker-compose
|
||||||
|
|
||||||
|
Volume Mounting and Configuration Rules:
|
||||||
|
1. DO NOT create configuration files unless the service CANNOT work without them
|
||||||
|
2. Most services can work with just environment variables - USE THEM FIRST
|
||||||
|
3. Ask yourself: "Can this be configured with an environment variable instead?"
|
||||||
|
4. If and ONLY IF a config file is absolutely required:
|
||||||
|
- Keep it minimal with only critical settings
|
||||||
|
- Use "../files/" prefix for all mounts
|
||||||
|
- Format: "../files/folder:/container/path"
|
||||||
|
5. DO NOT add configuration files for:
|
||||||
|
- Default configurations that work out of the box
|
||||||
|
- Settings that can be handled by environment variables
|
||||||
|
- Proxy or routing configurations (these are handled elsewhere)
|
||||||
|
|
||||||
|
Environment Variables Rules:
|
||||||
|
1. For the envVariables array, provide ACTUAL example values, not placeholders
|
||||||
|
2. Use realistic example values (e.g., "admin@example.com" for emails, "mypassword123" for passwords)
|
||||||
|
3. DO NOT use \${VARIABLE_NAME-default} syntax in the envVariables values
|
||||||
|
4. ONLY include environment variables that are actually used in the docker-compose
|
||||||
|
5. Every environment variable referenced in the docker-compose MUST have a corresponding entry in envVariables
|
||||||
|
6. Do not include environment variables for services that don't exist in the docker-compose
|
||||||
|
|
||||||
|
For each service that needs to be exposed to the internet:
|
||||||
|
1. Define a domain configuration with:
|
||||||
|
- host: the domain name for the service in format: {service-name}-{random-3-chars-hex}-${ip ? ip.replaceAll(".", "-") : ""}.traefik.me
|
||||||
|
- port: the internal port the service runs on
|
||||||
|
- serviceName: the name of the service in the docker-compose
|
||||||
|
2. Make sure the service is properly configured to work with the specified port
|
||||||
|
|
||||||
|
Project details:
|
||||||
|
${suggestion?.description}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
if (!!docker && !!docker.dockerCompose) {
|
||||||
|
result.push({
|
||||||
|
...suggestion,
|
||||||
|
...docker,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in docker compose generation:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "No suggestions found",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in suggestVariants:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
1
packages/server/src/utils/ai/index.ts
Normal file
1
packages/server/src/utils/ai/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./select-ai-provider";
|
||||||
73
packages/server/src/utils/ai/select-ai-provider.ts
Normal file
73
packages/server/src/utils/ai/select-ai-provider.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { createAnthropic } from "@ai-sdk/anthropic";
|
||||||
|
import { createAzure } from "@ai-sdk/azure";
|
||||||
|
import { createCohere } from "@ai-sdk/cohere";
|
||||||
|
import { createDeepInfra } from "@ai-sdk/deepinfra";
|
||||||
|
import { createMistral } from "@ai-sdk/mistral";
|
||||||
|
import { createOpenAI } from "@ai-sdk/openai";
|
||||||
|
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||||
|
import { createOllama } from "ollama-ai-provider";
|
||||||
|
|
||||||
|
function getProviderName(apiUrl: string) {
|
||||||
|
if (apiUrl.includes("api.openai.com")) return "openai";
|
||||||
|
if (apiUrl.includes("azure.com")) return "azure";
|
||||||
|
if (apiUrl.includes("api.anthropic.com")) return "anthropic";
|
||||||
|
if (apiUrl.includes("api.cohere.ai")) return "cohere";
|
||||||
|
if (apiUrl.includes("api.perplexity.ai")) return "perplexity";
|
||||||
|
if (apiUrl.includes("api.mistral.ai")) return "mistral";
|
||||||
|
if (apiUrl.includes("localhost:11434") || apiUrl.includes("ollama"))
|
||||||
|
return "ollama";
|
||||||
|
if (apiUrl.includes("api.deepinfra.com")) return "deepinfra";
|
||||||
|
throw new Error(`Unsupported AI provider for URL: ${apiUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
|
||||||
|
const providerName = getProviderName(config.apiUrl);
|
||||||
|
|
||||||
|
switch (providerName) {
|
||||||
|
case "openai":
|
||||||
|
return createOpenAI({
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
baseURL: config.apiUrl,
|
||||||
|
});
|
||||||
|
case "azure":
|
||||||
|
return createAzure({
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
baseURL: config.apiUrl,
|
||||||
|
});
|
||||||
|
case "anthropic":
|
||||||
|
return createAnthropic({
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
baseURL: config.apiUrl,
|
||||||
|
});
|
||||||
|
case "cohere":
|
||||||
|
return createCohere({
|
||||||
|
baseURL: config.apiUrl,
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
});
|
||||||
|
case "perplexity":
|
||||||
|
return createOpenAICompatible({
|
||||||
|
name: "perplexity",
|
||||||
|
baseURL: config.apiUrl,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${config.apiKey}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
case "mistral":
|
||||||
|
return createMistral({
|
||||||
|
baseURL: config.apiUrl,
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
});
|
||||||
|
case "ollama":
|
||||||
|
return createOllama({
|
||||||
|
// optional settings, e.g.
|
||||||
|
baseURL: config.apiUrl,
|
||||||
|
});
|
||||||
|
case "deepinfra":
|
||||||
|
return createDeepInfra({
|
||||||
|
baseURL: config.apiUrl,
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported AI provider: ${providerName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
1097
pnpm-lock.yaml
generated
1097
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user