mirror of
https://github.com/LukeHagar/dokploy.git
synced 2025-12-06 12:27:49 +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,
|
icon: Server,
|
||||||
href: "/dashboard/settings/servers",
|
href: "/dashboard/settings/servers",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "AI",
|
||||||
|
label: "",
|
||||||
|
icon: Sparkles,
|
||||||
|
href: "/dashboard/settings/ai",
|
||||||
|
},
|
||||||
...(isCloud
|
...(isCloud
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -152,13 +158,12 @@ import {
|
|||||||
Database,
|
Database,
|
||||||
GalleryVerticalEnd,
|
GalleryVerticalEnd,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
KeyIcon,
|
|
||||||
KeyRound,
|
KeyRound,
|
||||||
ListMusic,
|
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
Route,
|
Route,
|
||||||
Server,
|
Server,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
Sparkles,
|
||||||
User2,
|
User2,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} 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,
|
"when": 1735118844878,
|
||||||
"tag": "0053_broken_kulan_gath",
|
"tag": "0053_broken_kulan_gath",
|
||||||
"breakpoints": true
|
"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"
|
"test": "vitest --config __test__/vitest.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/openai": "^1.0.12",
|
||||||
"@codemirror/lang-json": "^6.0.1",
|
"@codemirror/lang-json": "^6.0.1",
|
||||||
"@codemirror/lang-yaml": "^6.1.1",
|
"@codemirror/lang-yaml": "^6.1.1",
|
||||||
"@codemirror/language": "^6.10.1",
|
"@codemirror/language": "^6.10.1",
|
||||||
@@ -42,22 +43,23 @@
|
|||||||
"@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",
|
||||||
|
"@monaco-editor/react": "^4.6.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",
|
||||||
"@radix-ui/react-avatar": "^1.0.4",
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
"@radix-ui/react-checkbox": "^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-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.0.3",
|
"@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-switch": "^1.0.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@radix-ui/react-toggle": "^1.0.3",
|
"@radix-ui/react-toggle": "^1.0.3",
|
||||||
@@ -75,6 +77,7 @@
|
|||||||
"@xterm/addon-attach": "0.10.0",
|
"@xterm/addon-attach": "0.10.0",
|
||||||
"@xterm/xterm": "^5.4.0",
|
"@xterm/xterm": "^5.4.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",
|
||||||
@@ -106,11 +109,12 @@
|
|||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-confetti-explosion": "2.1.2",
|
"react-confetti-explosion": "2.1.2",
|
||||||
"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",
|
||||||
@@ -125,6 +129,7 @@
|
|||||||
"zod-form-data": "^2.0.2"
|
"zod-form-data": "^2.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@types/adm-zip": "^0.5.5",
|
"@types/adm-zip": "^0.5.5",
|
||||||
"@types/bcrypt": "5.0.2",
|
"@types/bcrypt": "5.0.2",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
PostgresqlIcon,
|
PostgresqlIcon,
|
||||||
RedisIcon,
|
RedisIcon,
|
||||||
} from "@/components/icons/data-tools-icons";
|
} from "@/components/icons/data-tools-icons";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
|
||||||
import { ProjectLayout } from "@/components/layouts/project-layout";
|
import { ProjectLayout } from "@/components/layouts/project-layout";
|
||||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||||
@@ -22,6 +21,7 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
import { AddAiAssistant } from "@/components/dashboard/project/add-ai-assistant";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -229,6 +229,10 @@ const Project = (
|
|||||||
<AddDatabase projectId={projectId} projectName={data?.name} />
|
<AddDatabase projectId={projectId} projectName={data?.name} />
|
||||||
<AddCompose projectId={projectId} projectName={data?.name} />
|
<AddCompose projectId={projectId} projectName={data?.name} />
|
||||||
<AddTemplate projectId={projectId} />
|
<AddTemplate projectId={projectId} />
|
||||||
|
<AddAiAssistant
|
||||||
|
projectId={projectId}
|
||||||
|
projectName={data?.name}
|
||||||
|
/>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</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 { 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// 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;
|
} satisfies Config;
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -28,13 +28,20 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"rotating-file-stream": "3.2.3",
|
"@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",
|
||||||
"@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",
|
||||||
@@ -53,22 +60,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",
|
||||||
@@ -81,11 +86,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.21.1",
|
"drizzle-kit": "^0.21.1",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
37
packages/server/src/db/schema/ai.ts
Normal file
37
packages/server/src/db/schema/ai.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { boolean, pgTable, text } from "drizzle-orm/pg-core";
|
||||||
|
import { createInsertSchema } from "drizzle-zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const ai = pgTable("ai", {
|
||||||
|
authId: text("authId").notNull().primaryKey(),
|
||||||
|
apiUrl: text("apiUrl").notNull(),
|
||||||
|
apiKey: text("apiKey").notNull(),
|
||||||
|
model: text("model").notNull(),
|
||||||
|
isEnabled: boolean("isEnabled").notNull().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createSchema = createInsertSchema(ai, {
|
||||||
|
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
|
||||||
|
apiKey: z.string().min(1, { message: "API Key is required" }),
|
||||||
|
model: z.string().min(1, { message: "Model is required" }),
|
||||||
|
isEnabled: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiAiSettingsSchema = createSchema
|
||||||
|
.pick({
|
||||||
|
apiUrl: true,
|
||||||
|
apiKey: true,
|
||||||
|
model: true,
|
||||||
|
isEnabled: true,
|
||||||
|
})
|
||||||
|
.required();
|
||||||
|
|
||||||
|
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(),
|
||||||
|
});
|
||||||
@@ -30,3 +30,4 @@ 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";
|
||||||
|
|||||||
104
packages/server/src/services/ai.ts
Normal file
104
packages/server/src/services/ai.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
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 { eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const getAiSettingsByAuthId = async (authId: string) => {
|
||||||
|
const aiSettings = await db.query.ai.findFirst({
|
||||||
|
where: eq(ai.authId, authId),
|
||||||
|
});
|
||||||
|
if (!aiSettings) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "AI settings not found for the user",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return aiSettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveAiSettings = async (authId: string, settings: any) => {
|
||||||
|
return db
|
||||||
|
.insert(ai)
|
||||||
|
.values({
|
||||||
|
authId,
|
||||||
|
...settings,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: ai.authId,
|
||||||
|
set: {
|
||||||
|
...settings,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const suggestVariants = async (authId: string, input: string) => {
|
||||||
|
const aiSettings = await getAiSettingsByAuthId(authId);
|
||||||
|
if (!aiSettings || !aiSettings.isEnabled) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "AI features are not enabled",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = selectAIProvider(aiSettings);
|
||||||
|
const model = provider(aiSettings.model);
|
||||||
|
const { object } = await generateObject({
|
||||||
|
model,
|
||||||
|
output: "array",
|
||||||
|
schema: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
shortDescription: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
}),
|
||||||
|
prompt: `
|
||||||
|
Act as advanced DevOps engineer and generate a list of open source projects what can cover users needs(up to 3 items), the suggestion
|
||||||
|
should include id, name, shortDescription, and description. Use slug of title for id. The description should be in markdown format with full description of suggested stack. The shortDescription should be in plain text and have short information about used technologies.
|
||||||
|
User wants to create a new project with the following details, it should be installable in docker and can be docker compose generated for it:
|
||||||
|
|
||||||
|
${input}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (object?.length) {
|
||||||
|
const result = [];
|
||||||
|
for (const suggestion of object) {
|
||||||
|
const { object: docker } = await generateObject({
|
||||||
|
model,
|
||||||
|
output: "object",
|
||||||
|
schema: z.object({
|
||||||
|
dockerCompose: z.string(),
|
||||||
|
envVariables: z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
prompt: `
|
||||||
|
Act as advanced DevOps engineer and generate docker compose with environment variables needed to install the following project,
|
||||||
|
use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker compose. Use complex values for passwords/secrets variables.
|
||||||
|
Don\'t set container_name field in services. Don\'t set version field in the docker compose.
|
||||||
|
|
||||||
|
Project details:
|
||||||
|
${suggestion?.description}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
if (!!docker && !!docker.dockerCompose) {
|
||||||
|
result.push({
|
||||||
|
...suggestion,
|
||||||
|
...docker,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "No suggestions found",
|
||||||
|
});
|
||||||
|
};
|
||||||
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
1122
pnpm-lock.yaml
generated
1122
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user