mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-09 05:25:18 +00:00
- 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.
517 lines
14 KiB
TypeScript
517 lines
14 KiB
TypeScript
"use client";
|
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
|
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";
|
|
import { z } from "zod";
|
|
import { AlertBlock } from "@/components/shared/alert-block";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from "@/components/ui/form";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Popover,
|
|
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" }),
|
|
apiKey: z.string(),
|
|
model: z.string().min(1, { message: "Model is required" }),
|
|
isEnabled: z.boolean(),
|
|
});
|
|
|
|
type Schema = z.infer<typeof Schema>;
|
|
|
|
interface Props {
|
|
aiId?: string;
|
|
}
|
|
|
|
export const HandleAi = ({ aiId }: Props) => {
|
|
const utils = api.useUtils();
|
|
const [open, setOpen] = useState(false);
|
|
const [modelPopoverOpen, setModelPopoverOpen] = useState(false);
|
|
const [modelSearch, setModelSearch] = useState("");
|
|
const { data, refetch } = api.ai.one.useQuery(
|
|
{
|
|
aiId: aiId || "",
|
|
},
|
|
{
|
|
enabled: !!aiId,
|
|
},
|
|
);
|
|
const { mutateAsync, isPending } = aiId
|
|
? api.ai.update.useMutation()
|
|
: api.ai.create.useMutation();
|
|
|
|
const form = useForm<Schema>({
|
|
resolver: zodResolver(Schema),
|
|
defaultValues: {
|
|
name: "",
|
|
apiUrl: "",
|
|
apiKey: "",
|
|
model: "",
|
|
isEnabled: true,
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (data) {
|
|
form.reset({
|
|
name: data?.name ?? "",
|
|
apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
|
|
apiKey: data?.apiKey ?? "",
|
|
model: data?.model ?? "",
|
|
isEnabled: data?.isEnabled ?? true,
|
|
});
|
|
}
|
|
setModelSearch("");
|
|
setModelPopoverOpen(false);
|
|
}, [aiId, form, data]);
|
|
|
|
const apiUrl = form.watch("apiUrl");
|
|
const apiKey = form.watch("apiKey");
|
|
|
|
const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama");
|
|
const {
|
|
data: models,
|
|
isFetching: isLoadingServerModels,
|
|
error: modelsError,
|
|
} = api.ai.getModels.useQuery(
|
|
{
|
|
apiUrl: apiUrl ?? "",
|
|
apiKey: apiKey ?? "",
|
|
},
|
|
{
|
|
enabled: !!apiUrl && (isOllama || !!apiKey),
|
|
},
|
|
);
|
|
|
|
const onSubmit = async (data: Schema) => {
|
|
try {
|
|
await mutateAsync({
|
|
...data,
|
|
aiId: aiId || "",
|
|
});
|
|
|
|
utils.ai.getAll.invalidate();
|
|
toast.success("AI settings saved successfully");
|
|
refetch();
|
|
setOpen(false);
|
|
} catch (error) {
|
|
toast.error("Failed to save AI settings", {
|
|
description: error instanceof Error ? error.message : "Unknown error",
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(isOpen) => {
|
|
setOpen(isOpen);
|
|
if (!isOpen) {
|
|
setModelSearch("");
|
|
setModelPopoverOpen(false);
|
|
}
|
|
}}
|
|
>
|
|
<DialogTrigger className="" asChild>
|
|
{aiId ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="group hover:bg-blue-500/10 "
|
|
>
|
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
|
</Button>
|
|
) : (
|
|
<Button className="cursor-pointer space-x-3">
|
|
<PlusIcon className="h-4 w-4" />
|
|
Add AI
|
|
</Button>
|
|
)}
|
|
</DialogTrigger>
|
|
<DialogContent className="sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>{aiId ? "Edit AI" : "Add AI"}</DialogTitle>
|
|
<DialogDescription>
|
|
Configure your AI provider settings
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<Form {...form}>
|
|
{modelsError && (
|
|
<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"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Name</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder="My OpenAI Config" {...field} />
|
|
</FormControl>
|
|
<FormDescription>
|
|
A name to identify this configuration
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="apiUrl"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>API URL</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder="https://api.openai.com/v1"
|
|
{...field}
|
|
onChange={(e) => {
|
|
field.onChange(e);
|
|
// Reset model when user changes API URL
|
|
if (form.getValues("model")) {
|
|
form.setValue("model", "");
|
|
}
|
|
}}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
The base URL for your AI provider's API
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
{!isOllama && (
|
|
<FormField
|
|
control={form.control}
|
|
name="apiKey"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>API Key</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
type="password"
|
|
placeholder="sk-..."
|
|
autoComplete="one-time-code"
|
|
{...field}
|
|
onChange={(e) => {
|
|
field.onChange(e);
|
|
// Reset model when user changes API Key
|
|
if (form.getValues("model")) {
|
|
form.setValue("model", "");
|
|
}
|
|
}}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
Your API key for authentication
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{isLoadingServerModels && (
|
|
<span className="text-sm text-muted-foreground">
|
|
Loading models...
|
|
</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()),
|
|
);
|
|
|
|
const displayModels =
|
|
field.value &&
|
|
!filteredModels.find((m) => m.id === field.value) &&
|
|
selectedModel
|
|
? [selectedModel, ...filteredModels]
|
|
: filteredModels;
|
|
|
|
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"
|
|
>
|
|
<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}
|
|
/>
|
|
</FormControl>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<FormDescription>
|
|
Select a model from the list or type a custom model name
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
);
|
|
}}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="isEnabled"
|
|
render={({ field }) => (
|
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
|
<div className="space-y-0.5">
|
|
<FormLabel className="text-base">
|
|
Enable AI Features
|
|
</FormLabel>
|
|
<FormDescription>
|
|
Turn on/off AI functionality
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<div className="flex justify-end gap-2 pt-4">
|
|
<TestConnectionButton
|
|
apiUrl={apiUrl}
|
|
apiKey={apiKey}
|
|
model={form.watch("model")}
|
|
/>
|
|
<Button type="submit" isLoading={isPending}>
|
|
{aiId ? "Update" : "Create"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Form>
|
|
</DialogContent>
|
|
</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>
|
|
);
|
|
}
|