mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
Merge pull request #4183 from Dokploy/feat/ai-improvements
feat: add AI log analysis component and integrate into deployment views
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { Check, Copy, Loader2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AnalyzeLogs } from "@/components/dashboard/docker/logs/analyze-logs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -165,6 +166,7 @@ export const ShowDeployment = ({
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<AnalyzeLogs logs={filteredLogs} context="build" />
|
||||
|
||||
{serverId && (
|
||||
<div className="flex items-center space-x-2">
|
||||
|
||||
189
apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx
Normal file
189
apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
import { Bot, Loader2, RotateCcw, Settings, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
import type { LogLine } from "./utils";
|
||||
|
||||
interface Props {
|
||||
logs: LogLine[];
|
||||
context: "build" | "runtime";
|
||||
}
|
||||
|
||||
const MAX_LOG_LINES = 200;
|
||||
|
||||
export function AnalyzeLogs({ logs, context }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [aiId, setAiId] = useState<string>("");
|
||||
const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, {
|
||||
enabled: open,
|
||||
});
|
||||
const { mutate, isPending, data, reset } = api.ai.analyzeLogs.useMutation({
|
||||
onError: (error) => {
|
||||
toast.error("Analysis failed", {
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleAnalyze = () => {
|
||||
if (!aiId || logs.length === 0) return;
|
||||
|
||||
const logsText = logs
|
||||
.slice(-MAX_LOG_LINES)
|
||||
.map((l) => l.message)
|
||||
.join("\n");
|
||||
|
||||
mutate({ aiId, logs: logsText, context });
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
reset();
|
||||
setAiId("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
disabled={logs.length === 0}
|
||||
title="Analyze logs with AI"
|
||||
>
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
AI
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[550px] p-0" align="end">
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Log Analysis</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
{!data?.analysis ? (
|
||||
providers && providers.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-2 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No AI providers configured. Set up a provider to start
|
||||
analyzing logs.
|
||||
</p>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link href="/dashboard/settings/ai">
|
||||
<Settings className="mr-2 h-3.5 w-3.5" />
|
||||
Configure AI Provider
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select value={aiId} onValueChange={setAiId}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select AI provider..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers?.map((p) => (
|
||||
<SelectItem key={p.aiId} value={p.aiId}>
|
||||
{p.name} ({p.model})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={!aiId || isPending || logs.length === 0}
|
||||
onClick={handleAnalyze}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Analyzing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bot className="mr-2 h-3.5 w-3.5" />
|
||||
Analyze{" "}
|
||||
{logs.length > MAX_LOG_LINES
|
||||
? `last ${MAX_LOG_LINES}`
|
||||
: logs.length}{" "}
|
||||
lines
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-sm break-words">
|
||||
<ReactMarkdown>{data.analysis}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
reset();
|
||||
handleAnalyze();
|
||||
}}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||
)}
|
||||
Re-analyze
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
reset();
|
||||
setAiId("");
|
||||
}}
|
||||
title="Change provider"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { AnalyzeLogs } from "./analyze-logs";
|
||||
import { LineCountFilter } from "./line-count-filter";
|
||||
import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter";
|
||||
import { StatusLogsFilter } from "./status-logs-filter";
|
||||
@@ -377,6 +378,7 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
Download logs
|
||||
</Button>
|
||||
<AnalyzeLogs logs={filteredLogs} context="runtime" />
|
||||
</div>
|
||||
</div>
|
||||
{isPaused && (
|
||||
|
||||
@@ -298,7 +298,19 @@ export const TemplateGenerator = ({ environmentId }: Props) => {
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2 w-full justify-end">
|
||||
<Button
|
||||
onClick={stepper.prev}
|
||||
onClick={() => {
|
||||
if (
|
||||
stepper.current.id === "variant" &&
|
||||
templateInfo.details
|
||||
) {
|
||||
setTemplateInfo((prev) => ({
|
||||
...prev,
|
||||
details: null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
stepper.prev();
|
||||
}}
|
||||
disabled={stepper.isFirst}
|
||||
variant="secondary"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
"use client";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Check, ChevronDown, PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
PenBoxIcon,
|
||||
Plug,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -37,10 +44,34 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const AI_PROVIDERS = [
|
||||
{ name: "OpenAI", apiUrl: "https://api.openai.com/v1" },
|
||||
{ name: "Anthropic", apiUrl: "https://api.anthropic.com/v1" },
|
||||
{
|
||||
name: "Google Gemini",
|
||||
apiUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||
},
|
||||
{ name: "Mistral", apiUrl: "https://api.mistral.ai/v1" },
|
||||
{ name: "Cohere", apiUrl: "https://api.cohere.ai/v2" },
|
||||
{ name: "Perplexity", apiUrl: "https://api.perplexity.ai" },
|
||||
{ name: "DeepInfra", apiUrl: "https://api.deepinfra.com/v1/openai" },
|
||||
{ name: "Ollama", apiUrl: "http://localhost:11434" },
|
||||
{ name: "OpenRouter", apiUrl: "https://openrouter.ai/api/v1" },
|
||||
{ name: "Z.AI", apiUrl: "https://api.z.ai/api/paas/v4" },
|
||||
{ name: "MiniMax", apiUrl: "https://api.minimax.io/v1" },
|
||||
] as const;
|
||||
|
||||
const Schema = z.object({
|
||||
name: z.string().min(1, { message: "Name is required" }),
|
||||
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
|
||||
@@ -103,7 +134,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama");
|
||||
const {
|
||||
data: models,
|
||||
isPending: isLoadingServerModels,
|
||||
isFetching: isLoadingServerModels,
|
||||
error: modelsError,
|
||||
} = api.ai.getModels.useQuery(
|
||||
{
|
||||
@@ -172,6 +203,34 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
<AlertBlock type="error">{modelsError.message}</AlertBlock>
|
||||
)}
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>Provider</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
const provider = AI_PROVIDERS.find((p) => p.apiUrl === value);
|
||||
if (provider) {
|
||||
form.setValue("name", provider.name);
|
||||
form.setValue("apiUrl", provider.apiUrl);
|
||||
form.setValue("model", "");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider preset..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AI_PROVIDERS.map((provider) => (
|
||||
<SelectItem key={provider.apiUrl} value={provider.apiUrl}>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
Quick-fill provider name and URL, or configure manually below
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
@@ -253,101 +312,129 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isLoadingServerModels && !models?.length && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No models available
|
||||
</span>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model"
|
||||
render={({ field }) => {
|
||||
const hasModels =
|
||||
!isLoadingServerModels && models && models.length > 0;
|
||||
const selectedModel = models?.find((m) => m.id === field.value);
|
||||
const filteredModels = (models ?? []).filter((model) =>
|
||||
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
|
||||
);
|
||||
|
||||
{!isLoadingServerModels && models && models.length > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model"
|
||||
render={({ field }) => {
|
||||
const selectedModel = models.find(
|
||||
(m) => m.id === field.value,
|
||||
);
|
||||
const filteredModels = models.filter((model) =>
|
||||
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
|
||||
);
|
||||
const displayModels =
|
||||
field.value &&
|
||||
!filteredModels.find((m) => m.id === field.value) &&
|
||||
selectedModel
|
||||
? [selectedModel, ...filteredModels]
|
||||
: filteredModels;
|
||||
|
||||
// Ensure selected model is always in the filtered list
|
||||
const displayModels =
|
||||
field.value &&
|
||||
!filteredModels.find((m) => m.id === field.value) &&
|
||||
selectedModel
|
||||
? [selectedModel, ...filteredModels]
|
||||
: filteredModels;
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Model</FormLabel>
|
||||
<Popover
|
||||
open={modelPopoverOpen}
|
||||
onOpenChange={setModelPopoverOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Model</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
{hasModels ? (
|
||||
<Popover
|
||||
open={modelPopoverOpen}
|
||||
onOpenChange={setModelPopoverOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? (selectedModel?.id ?? field.value)
|
||||
: "Select a model"}
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[400px] p-0"
|
||||
align="start"
|
||||
>
|
||||
{field.value
|
||||
? (selectedModel?.id ?? field.value)
|
||||
: "Select a model"}
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search models..."
|
||||
value={modelSearch}
|
||||
onValueChange={setModelSearch}
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search or type a custom model..."
|
||||
value={modelSearch}
|
||||
onValueChange={setModelSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{modelSearch ? (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full cursor-pointer px-2 py-1.5 text-left text-sm hover:bg-accent"
|
||||
onClick={() => {
|
||||
field.onChange(modelSearch);
|
||||
setModelPopoverOpen(false);
|
||||
setModelSearch("");
|
||||
}}
|
||||
>
|
||||
Use custom model: "{modelSearch}"
|
||||
</button>
|
||||
) : (
|
||||
"No models found."
|
||||
)}
|
||||
</CommandEmpty>
|
||||
{displayModels.map((model) => {
|
||||
const isSelected = field.value === model.id;
|
||||
return (
|
||||
<CommandItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
onSelect={() => {
|
||||
field.onChange(model.id);
|
||||
setModelPopoverOpen(false);
|
||||
setModelSearch("");
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
isSelected
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{model.id}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={
|
||||
isLoadingServerModels
|
||||
? "Loading models..."
|
||||
: "Enter model name (e.g. gpt-4o)"
|
||||
}
|
||||
disabled={isLoadingServerModels}
|
||||
{...field}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No models found.</CommandEmpty>
|
||||
{displayModels.map((model) => {
|
||||
const isSelected = field.value === model.id;
|
||||
return (
|
||||
<CommandItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
onSelect={() => {
|
||||
field.onChange(model.id);
|
||||
setModelPopoverOpen(false);
|
||||
setModelSearch("");
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
isSelected
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{model.id}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormDescription>
|
||||
Select an AI model to use
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Select a model from the list or type a custom model name
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -372,7 +459,12 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<TestConnectionButton
|
||||
apiUrl={apiUrl}
|
||||
apiKey={apiKey}
|
||||
model={form.watch("model")}
|
||||
/>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{aiId ? "Update" : "Create"}
|
||||
</Button>
|
||||
@@ -383,3 +475,42 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
function TestConnectionButton({
|
||||
apiUrl,
|
||||
apiKey,
|
||||
model,
|
||||
}: {
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
}) {
|
||||
const { mutate, isPending } = api.ai.testConnection.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Connection successful");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Connection failed", {
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isDisabled = !apiUrl || !model;
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isDisabled || isPending}
|
||||
onClick={() => mutate({ apiUrl, apiKey, model })}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plug className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Test Connection
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,9 +25,11 @@ import { findProjectById } from "@dokploy/server/services/project";
|
||||
import {
|
||||
getProviderHeaders,
|
||||
getProviderName,
|
||||
selectAIProvider,
|
||||
type Model,
|
||||
} from "@dokploy/server/utils/ai/select-ai-provider";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { generateText } from "ai";
|
||||
import { z } from "zod";
|
||||
import { slugify } from "@/lib/slug";
|
||||
import {
|
||||
@@ -95,6 +97,30 @@ export const aiRouter = createTRPCRouter({
|
||||
owned_by: "perplexity",
|
||||
},
|
||||
] as Model[];
|
||||
case "zai":
|
||||
return [
|
||||
{
|
||||
id: "glm-5",
|
||||
object: "model",
|
||||
created: Date.now(),
|
||||
owned_by: "zai",
|
||||
},
|
||||
{
|
||||
id: "glm-4.7",
|
||||
object: "model",
|
||||
created: Date.now(),
|
||||
owned_by: "zai",
|
||||
},
|
||||
] as Model[];
|
||||
case "minimax":
|
||||
return [
|
||||
{
|
||||
id: "MiniMax-M2.7",
|
||||
object: "model",
|
||||
created: Date.now(),
|
||||
owned_by: "minimax",
|
||||
},
|
||||
] as Model[];
|
||||
default:
|
||||
if (!input.apiKey)
|
||||
throw new TRPCError({
|
||||
@@ -174,6 +200,107 @@ export const aiRouter = createTRPCRouter({
|
||||
return await deleteAiSettings(input.aiId);
|
||||
}),
|
||||
|
||||
getEnabledProviders: protectedProcedure.query(async ({ ctx }) => {
|
||||
const settings = await getAiSettingsByOrganizationId(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
return settings
|
||||
.filter((s) => s.isEnabled)
|
||||
.map((s) => ({ aiId: s.aiId, name: s.name, model: s.model }));
|
||||
}),
|
||||
|
||||
analyzeLogs: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
aiId: z.string().min(1),
|
||||
logs: z.string().min(1),
|
||||
context: z.enum(["build", "runtime"]),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const aiSettings = await getAiSettingById(input.aiId);
|
||||
if (!aiSettings?.isEnabled) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "AI provider is not enabled",
|
||||
});
|
||||
}
|
||||
|
||||
if (aiSettings.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Access denied",
|
||||
});
|
||||
}
|
||||
|
||||
const provider = selectAIProvider(aiSettings);
|
||||
const model = provider(aiSettings.model);
|
||||
|
||||
const contextLabel =
|
||||
input.context === "build" ? "build/deployment" : "runtime/container";
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
prompt: `You are a DevOps engineer analyzing ${contextLabel} logs. Analyze the following logs and provide:
|
||||
|
||||
1. **Summary**: A brief summary of what's happening
|
||||
2. **Issues Found**: Any errors, warnings, or problems detected
|
||||
3. **Root Cause**: The most likely root cause if there are errors
|
||||
4. **Suggested Fix**: Actionable steps to resolve the issues
|
||||
|
||||
Be concise and practical. Focus on the most important issues. If the logs look healthy, say so briefly.
|
||||
|
||||
Logs:
|
||||
${input.logs}`,
|
||||
});
|
||||
|
||||
return { analysis: result.text };
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Analysis failed: ${error}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
testConnection: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
apiUrl: z.string().min(1),
|
||||
apiKey: z.string(),
|
||||
model: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const provider = selectAIProvider({
|
||||
apiUrl: input.apiUrl,
|
||||
apiKey: input.apiKey,
|
||||
});
|
||||
const model = provider(input.model);
|
||||
const result = await generateText({
|
||||
model,
|
||||
prompt: "Reply with 'ok'",
|
||||
});
|
||||
if (!result.text) {
|
||||
throw new Error("No response received from the model");
|
||||
}
|
||||
return { success: true, message: "Connection successful" };
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Connection failed: ${error}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
suggest: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
findEnvironmentById,
|
||||
findGitProviderById,
|
||||
findProjectById,
|
||||
getAccessibleServerIds,
|
||||
getApplicationStats,
|
||||
getContainerLogs,
|
||||
IS_CLOUD,
|
||||
mechanizeDockerContainer,
|
||||
readConfig,
|
||||
@@ -26,7 +28,6 @@ import {
|
||||
updateDeploymentStatus,
|
||||
writeConfig,
|
||||
writeConfigRemote,
|
||||
getAccessibleServerIds,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -1101,4 +1102,39 @@ export const applicationRouter = createTRPCRouter({
|
||||
total: countResult[0]?.count ?? 0,
|
||||
};
|
||||
}),
|
||||
|
||||
readLogs: protectedProcedure
|
||||
.input(
|
||||
apiFindOneApplication.extend({
|
||||
tail: z.number().int().min(1).max(10000).default(100),
|
||||
since: z
|
||||
.string()
|
||||
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||
.default("all"),
|
||||
search: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
await checkServiceAccess(ctx, input.applicationId, "read");
|
||||
const application = await findApplicationById(input.applicationId);
|
||||
if (
|
||||
application.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this application",
|
||||
});
|
||||
}
|
||||
return await getContainerLogs(
|
||||
application.appName,
|
||||
input.tail,
|
||||
input.since,
|
||||
input.search,
|
||||
application.serverId,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
findGitProviderById,
|
||||
findProjectById,
|
||||
findServerById,
|
||||
getAccessibleServerIds,
|
||||
getComposeContainer,
|
||||
getContainerLogs,
|
||||
getWebServerSettings,
|
||||
IS_CLOUD,
|
||||
loadServices,
|
||||
@@ -30,7 +32,6 @@ import {
|
||||
stopCompose,
|
||||
updateCompose,
|
||||
updateDeploymentStatus,
|
||||
getAccessibleServerIds,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -1130,4 +1131,44 @@ export const composeRouter = createTRPCRouter({
|
||||
total: countResult[0]?.count ?? 0,
|
||||
};
|
||||
}),
|
||||
|
||||
readLogs: protectedProcedure
|
||||
.input(
|
||||
apiFindCompose.extend({
|
||||
containerId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[a-zA-Z0-9.\-_]+$/, "Invalid container id."),
|
||||
tail: z.number().int().min(1).max(10000).default(100),
|
||||
since: z
|
||||
.string()
|
||||
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||
.default("all"),
|
||||
search: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
await checkServiceAccess(ctx, input.composeId, "read");
|
||||
const compose = await findComposeById(input.composeId);
|
||||
if (
|
||||
compose.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this compose",
|
||||
});
|
||||
}
|
||||
return await getContainerLogs(
|
||||
input.containerId,
|
||||
input.tail,
|
||||
input.since,
|
||||
input.search,
|
||||
compose.serverId,
|
||||
true,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
findEnvironmentById,
|
||||
findLibsqlById,
|
||||
findProjectById,
|
||||
getContainerLogs,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
removeLibsqlById,
|
||||
@@ -466,4 +467,39 @@ export const libsqlRouter = createTRPCRouter({
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
|
||||
readLogs: protectedProcedure
|
||||
.input(
|
||||
apiFindOneLibsql.extend({
|
||||
tail: z.number().int().min(1).max(10000).default(100),
|
||||
since: z
|
||||
.string()
|
||||
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||
.default("all"),
|
||||
search: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
await checkServiceAccess(ctx, input.libsqlId, "read");
|
||||
const libsql = await findLibsqlById(input.libsqlId);
|
||||
if (
|
||||
libsql.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this LibSQL",
|
||||
});
|
||||
}
|
||||
return await getContainerLogs(
|
||||
libsql.appName,
|
||||
input.tail,
|
||||
input.since,
|
||||
input.search,
|
||||
libsql.serverId,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
findEnvironmentById,
|
||||
findMariadbById,
|
||||
findProjectById,
|
||||
getAccessibleServerIds,
|
||||
getContainerLogs,
|
||||
getServiceContainerCommand,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
@@ -19,7 +21,6 @@ import {
|
||||
stopService,
|
||||
stopServiceRemote,
|
||||
updateMariadbById,
|
||||
getAccessibleServerIds,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -590,4 +591,39 @@ export const mariadbRouter = createTRPCRouter({
|
||||
]);
|
||||
return { items, total: countResult[0]?.count ?? 0 };
|
||||
}),
|
||||
|
||||
readLogs: protectedProcedure
|
||||
.input(
|
||||
apiFindOneMariaDB.extend({
|
||||
tail: z.number().int().min(1).max(10000).default(100),
|
||||
since: z
|
||||
.string()
|
||||
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||
.default("all"),
|
||||
search: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
await checkServiceAccess(ctx, input.mariadbId, "read");
|
||||
const mariadb = await findMariadbById(input.mariadbId);
|
||||
if (
|
||||
mariadb.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MariaDB",
|
||||
});
|
||||
}
|
||||
return await getContainerLogs(
|
||||
mariadb.appName,
|
||||
input.tail,
|
||||
input.since,
|
||||
input.search,
|
||||
mariadb.serverId,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
findMongoById,
|
||||
findProjectById,
|
||||
getAccessibleServerIds,
|
||||
getContainerLogs,
|
||||
getServiceContainerCommand,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
@@ -601,4 +602,39 @@ export const mongoRouter = createTRPCRouter({
|
||||
]);
|
||||
return { items, total: countResult[0]?.count ?? 0 };
|
||||
}),
|
||||
|
||||
readLogs: protectedProcedure
|
||||
.input(
|
||||
apiFindOneMongo.extend({
|
||||
tail: z.number().int().min(1).max(10000).default(100),
|
||||
since: z
|
||||
.string()
|
||||
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||
.default("all"),
|
||||
search: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
await checkServiceAccess(ctx, input.mongoId, "read");
|
||||
const mongo = await findMongoById(input.mongoId);
|
||||
if (
|
||||
mongo.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MongoDB",
|
||||
});
|
||||
}
|
||||
return await getContainerLogs(
|
||||
mongo.appName,
|
||||
input.tail,
|
||||
input.since,
|
||||
input.search,
|
||||
mongo.serverId,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
findEnvironmentById,
|
||||
findMySqlById,
|
||||
findProjectById,
|
||||
getContainerLogs,
|
||||
getServiceContainerCommand,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
@@ -604,4 +605,39 @@ export const mysqlRouter = createTRPCRouter({
|
||||
]);
|
||||
return { items, total: countResult[0]?.count ?? 0 };
|
||||
}),
|
||||
|
||||
readLogs: protectedProcedure
|
||||
.input(
|
||||
apiFindOneMySql.extend({
|
||||
tail: z.number().int().min(1).max(10000).default(100),
|
||||
since: z
|
||||
.string()
|
||||
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||
.default("all"),
|
||||
search: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
await checkServiceAccess(ctx, input.mysqlId, "read");
|
||||
const mysql = await findMySqlById(input.mysqlId);
|
||||
if (
|
||||
mysql.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MySQL",
|
||||
});
|
||||
}
|
||||
return await getContainerLogs(
|
||||
mysql.appName,
|
||||
input.tail,
|
||||
input.since,
|
||||
input.search,
|
||||
mysql.serverId,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
findEnvironmentById,
|
||||
findPostgresById,
|
||||
findProjectById,
|
||||
getContainerLogs,
|
||||
getMountPath,
|
||||
getServiceContainerCommand,
|
||||
IS_CLOUD,
|
||||
@@ -614,4 +615,39 @@ export const postgresRouter = createTRPCRouter({
|
||||
]);
|
||||
return { items, total: countResult[0]?.count ?? 0 };
|
||||
}),
|
||||
|
||||
readLogs: protectedProcedure
|
||||
.input(
|
||||
apiFindOnePostgres.extend({
|
||||
tail: z.number().int().min(1).max(10000).default(100),
|
||||
since: z
|
||||
.string()
|
||||
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||
.default("all"),
|
||||
search: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
await checkServiceAccess(ctx, input.postgresId, "read");
|
||||
const postgres = await findPostgresById(input.postgresId);
|
||||
if (
|
||||
postgres.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this Postgres",
|
||||
});
|
||||
}
|
||||
return await getContainerLogs(
|
||||
postgres.appName,
|
||||
input.tail,
|
||||
input.since,
|
||||
input.search,
|
||||
postgres.serverId,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
findEnvironmentById,
|
||||
findProjectById,
|
||||
findRedisById,
|
||||
getContainerLogs,
|
||||
getServiceContainerCommand,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
@@ -587,4 +588,39 @@ export const redisRouter = createTRPCRouter({
|
||||
]);
|
||||
return { items, total: countResult[0]?.count ?? 0 };
|
||||
}),
|
||||
|
||||
readLogs: protectedProcedure
|
||||
.input(
|
||||
apiFindOneRedis.extend({
|
||||
tail: z.number().int().min(1).max(10000).default(100),
|
||||
since: z
|
||||
.string()
|
||||
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||
.default("all"),
|
||||
search: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
await checkServiceAccess(ctx, input.redisId, "read");
|
||||
const redis = await findRedisById(input.redisId);
|
||||
if (
|
||||
redis.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this Redis",
|
||||
});
|
||||
}
|
||||
return await getContainerLogs(
|
||||
redis.appName,
|
||||
input.tail,
|
||||
input.since,
|
||||
input.search,
|
||||
redis.serverId,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -108,22 +108,45 @@ export const suggestVariants = async ({
|
||||
ip = "127.0.0.1";
|
||||
}
|
||||
|
||||
const suggestionsSchema = z.object({
|
||||
const fullSchema = z.object({
|
||||
suggestions: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
shortDescription: z.string(),
|
||||
description: z.string(),
|
||||
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(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
const suggestionsResult = await generateText({
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
|
||||
output: Output.object({ schema: suggestionsSchema }),
|
||||
output: Output.object({ schema: fullSchema }),
|
||||
prompt: `
|
||||
Act as advanced DevOps engineer and analyze the user's request to determine the appropriate suggestions (up to 3 items).
|
||||
Act as advanced DevOps engineer. Analyze the user's request and generate up to 3 deployment suggestions, each with a complete docker compose configuration.
|
||||
|
||||
CRITICAL - Read the user's request carefully and follow the appropriate strategy:
|
||||
|
||||
@@ -139,163 +162,94 @@ export const suggestVariants = async ({
|
||||
- Example: For "personal blog" → "WordPress", "Ghost", "Hugo with Nginx"
|
||||
- The name should be the actual project name
|
||||
|
||||
Return your response as a JSON object with the following structure:
|
||||
Return your response as a JSON object with this structure:
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"id": "project-or-variant-slug",
|
||||
"name": "Project Name or Variant Name",
|
||||
"shortDescription": "Brief one-line description",
|
||||
"description": "Detailed description"
|
||||
"description": "Detailed description of the project/variant",
|
||||
"dockerCompose": "yaml string here",
|
||||
"envVariables": [{"name": "VAR_NAME", "value": "example_value"}],
|
||||
"domains": [{"host": "domain.com", "port": 3000, "serviceName": "service"}],
|
||||
"configFiles": [{"content": "file content", "filePath": "path/to/file"}]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Important rules for the response:
|
||||
Suggestion Rules:
|
||||
1. Use slug format for the id field (lowercase, hyphenated)
|
||||
2. Determine which strategy to use based on whether the user specified a particular application or described a general need
|
||||
3. For Strategy A (specific app): The name must include the app name and describe the variant configuration
|
||||
4. For Strategy B (general need): The name should be the actual project/tool name that fulfills the need
|
||||
5. The description field should ONLY contain a plain text description of the project or variant, its features, and use cases
|
||||
6. Do NOT include any code snippets, configuration examples, or installation instructions in the description
|
||||
7. The shortDescription should be a single-line summary focusing on key technologies or differentiators
|
||||
8. All suggestions should be installable in docker and have docker compose support
|
||||
9. Provide variety in your suggestions - different complexity levels, tech stacks, or approaches
|
||||
2. The description field should ONLY contain plain text — no code snippets or installation instructions
|
||||
3. The shortDescription should be a single-line summary focusing on key technologies or differentiators
|
||||
4. All suggestions should be installable in docker and have docker compose support
|
||||
5. Provide variety in your suggestions - different complexity levels, tech stacks, or approaches
|
||||
|
||||
User wants to create a new project with the following details:
|
||||
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
|
||||
|
||||
${input}
|
||||
Docker Image Rules (CRITICAL):
|
||||
1. ALWAYS use 'image:' field, NEVER use 'build:' field
|
||||
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
|
||||
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
|
||||
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
|
||||
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
|
||||
6. Examples of correct image usage:
|
||||
- image: sendingtk/chatwoot:develop
|
||||
- image: postgres:16-alpine
|
||||
- image: redis:7-alpine
|
||||
7. Examples of INCORRECT usage (DO NOT USE):
|
||||
- build: .
|
||||
- build: ./app
|
||||
- build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
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. 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"
|
||||
4. DO NOT add configuration files for default configs, env-configurable settings, or proxy/routing configs
|
||||
|
||||
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
|
||||
|
||||
Domain Rules - For each service that needs to be exposed to the internet:
|
||||
1. Define a domain with:
|
||||
- host: {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
|
||||
|
||||
User's request: ${input}
|
||||
`,
|
||||
});
|
||||
const object = suggestionsResult.output as SuggestionsOutput | undefined;
|
||||
|
||||
if (object?.suggestions?.length) {
|
||||
const dockerSchema = 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(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
const output = result.output as
|
||||
| { suggestions: (SuggestionItem & DockerOutput)[] }
|
||||
| undefined;
|
||||
|
||||
if (!output?.suggestions?.length) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "No suggestions found",
|
||||
});
|
||||
const result = [];
|
||||
for (const suggestion of object.suggestions) {
|
||||
try {
|
||||
const dockerResult = await generateText({
|
||||
model,
|
||||
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
|
||||
output: Output.object({ schema: dockerSchema }),
|
||||
prompt: `
|
||||
Act as advanced DevOps engineer and generate docker compose with environment variables and domain configurations needed to install the following project.
|
||||
|
||||
Return your response as a JSON object with this structure:
|
||||
{
|
||||
"dockerCompose": "yaml string here",
|
||||
"envVariables": [{"name": "VAR_NAME", "value": "example_value"}],
|
||||
"domains": [{"host": "domain.com", "port": 3000, "serviceName": "service"}],
|
||||
"configFiles": [{"content": "file content", "filePath": "path/to/file"}]
|
||||
}
|
||||
|
||||
Note: configFiles is optional - only include it if configuration files are absolutely required.
|
||||
|
||||
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
|
||||
|
||||
Docker Image Rules (CRITICAL):
|
||||
1. ALWAYS use 'image:' field, NEVER use 'build:' field
|
||||
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
|
||||
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
|
||||
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
|
||||
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
|
||||
6. Examples of correct image usage:
|
||||
- image: sendingtk/chatwoot:develop
|
||||
- image: postgres:16-alpine
|
||||
- image: redis:7-alpine
|
||||
- image: chatwoot/chatwoot:latest
|
||||
7. Examples of INCORRECT usage (DO NOT USE):
|
||||
- build: .
|
||||
- build: ./app
|
||||
- build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
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
|
||||
|
||||
User's original request: ${input}
|
||||
|
||||
Project details:
|
||||
${suggestion?.description}
|
||||
`,
|
||||
});
|
||||
const docker = dockerResult.output as DockerOutput | undefined;
|
||||
if (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",
|
||||
});
|
||||
return output.suggestions.filter((s) => s.dockerCompose);
|
||||
} catch (error) {
|
||||
console.error("Error in suggestVariants:", error);
|
||||
throw error;
|
||||
|
||||
@@ -354,6 +354,69 @@ export const getContainersByAppLabel = async (
|
||||
return [];
|
||||
};
|
||||
|
||||
export const getContainerLogs = async (
|
||||
appNameOrId: string,
|
||||
tail = 100,
|
||||
since = "all",
|
||||
search?: string,
|
||||
serverId?: string | null,
|
||||
useContainerIdDirectly = false,
|
||||
): Promise<string> => {
|
||||
const exec = (cmd: string) =>
|
||||
serverId ? execAsyncRemote(serverId, cmd) : execAsync(cmd);
|
||||
|
||||
let target = appNameOrId;
|
||||
let isService = false;
|
||||
|
||||
if (!useContainerIdDirectly) {
|
||||
// Find the real container ID by appName filter
|
||||
const findResult = await exec(
|
||||
`docker ps -q --filter "name=^${appNameOrId}" | head -1`,
|
||||
);
|
||||
const containerId = findResult.stdout.trim();
|
||||
|
||||
if (!containerId) {
|
||||
// Fallback: try as a swarm service
|
||||
const svcResult = await exec(
|
||||
`docker service ls -q --filter "name=${appNameOrId}" | head -1`,
|
||||
);
|
||||
const serviceId = svcResult.stdout.trim();
|
||||
if (!serviceId) {
|
||||
throw new Error(`No container or service found for: ${appNameOrId}`);
|
||||
}
|
||||
isService = true;
|
||||
} else {
|
||||
target = containerId;
|
||||
}
|
||||
}
|
||||
|
||||
const sinceFlag = since === "all" ? "" : `--since ${since}`;
|
||||
const baseCommand = isService
|
||||
? `docker service logs --timestamps --raw --tail ${tail} ${sinceFlag} ${target}`
|
||||
: `docker container logs --timestamps --tail ${tail} ${sinceFlag} ${target}`;
|
||||
|
||||
const escapedSearch = search?.replace(/'/g, "'\\''") ?? "";
|
||||
const command = search
|
||||
? `${baseCommand} 2>&1 | grep -iF '${escapedSearch}'`
|
||||
: `${baseCommand} 2>&1`;
|
||||
|
||||
try {
|
||||
const result = await exec(command);
|
||||
return result.stdout;
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"stdout" in error &&
|
||||
typeof (error as { stdout: string }).stdout === "string" &&
|
||||
(error as { stdout: string }).stdout.length > 0
|
||||
) {
|
||||
return (error as { stdout: string }).stdout;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const containerRestart = async (containerId: string) => {
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(
|
||||
|
||||
@@ -17,6 +17,9 @@ export function getProviderName(apiUrl: string) {
|
||||
if (apiUrl.includes(":11434") || apiUrl.includes("ollama")) return "ollama";
|
||||
if (apiUrl.includes("api.deepinfra.com")) return "deepinfra";
|
||||
if (apiUrl.includes("generativelanguage.googleapis.com")) return "gemini";
|
||||
if (apiUrl.includes("openrouter.ai")) return "openrouter";
|
||||
if (apiUrl.includes("api.z.ai")) return "zai";
|
||||
if (apiUrl.includes("api.minimax.io")) return "minimax";
|
||||
return "custom";
|
||||
}
|
||||
|
||||
@@ -87,6 +90,30 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
},
|
||||
});
|
||||
case "openrouter":
|
||||
return createOpenAICompatible({
|
||||
name: "openrouter",
|
||||
baseURL: config.apiUrl,
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
},
|
||||
});
|
||||
case "zai":
|
||||
return createOpenAICompatible({
|
||||
name: "zai",
|
||||
baseURL: config.apiUrl,
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
},
|
||||
});
|
||||
case "minimax":
|
||||
return createOpenAICompatible({
|
||||
name: "minimax",
|
||||
baseURL: config.apiUrl,
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
},
|
||||
});
|
||||
case "custom":
|
||||
return createOpenAICompatible({
|
||||
name: "custom",
|
||||
|
||||
Reference in New Issue
Block a user