mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
feat: add AI log analysis component and integrate into deployment views
- Introduced the AnalyzeLogs component for analyzing logs using AI, allowing users to select AI providers and view analysis results. - Integrated AnalyzeLogs into the ShowDeployment and DockerLogsId components, enabling log analysis for both build and runtime contexts. - Updated the AI router to include a new endpoint for log analysis, which processes logs and returns structured insights. - Enhanced the AI provider selection logic to support new providers, including Z.AI and MiniMax. This feature enhances the user experience by providing actionable insights from logs, improving troubleshooting and operational efficiency.
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">
|
||||
|
||||
170
apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx
Normal file
170
apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
import { Bot, Loader2, RotateCcw, X } from "lucide-react";
|
||||
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 ? (
|
||||
<>
|
||||
<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,96 @@ 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 }) => {
|
||||
try {
|
||||
const aiSettings = await getAiSettingById(input.aiId);
|
||||
if (!aiSettings?.isEnabled) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "AI provider is not enabled",
|
||||
});
|
||||
}
|
||||
|
||||
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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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