mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
feat: implement AI chat panel and logging features
- Added a new ChatPanel component for AI interactions, allowing users to chat with an AI assistant for infrastructure management. - Integrated the ChatPanel into the dashboard layout for easy access. - Created a new API endpoint for AI chat functionality, enabling dynamic interactions based on user context. - Implemented log reading capabilities across various services (application, compose, libsql, mariadb, mongo, mysql, postgres, redis) to enhance troubleshooting. - Introduced utility functions for fetching container logs, improving the overall user experience in managing deployments. This feature enriches the user interface by providing real-time AI assistance and log analysis, streamlining operational workflows.
This commit is contained in:
410
apps/dokploy/components/dashboard/ai-chat/chat-panel.tsx
Normal file
410
apps/dokploy/components/dashboard/ai-chat/chat-panel.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
"use client";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import type { ChatContext } from "@dokploy/server/utils/ai/chat-tools";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
Send,
|
||||
Trash2,
|
||||
Wrench,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
function useChatContext(): ChatContext {
|
||||
const router = useRouter();
|
||||
const { query, pathname } = router;
|
||||
|
||||
return useMemo(() => {
|
||||
if (query.applicationId && typeof query.applicationId === "string") {
|
||||
return { type: "application" as const, id: query.applicationId };
|
||||
}
|
||||
if (query.composeId && typeof query.composeId === "string") {
|
||||
return { type: "compose" as const, id: query.composeId };
|
||||
}
|
||||
if (query.projectId && typeof query.projectId === "string") {
|
||||
return { type: "project" as const, id: query.projectId };
|
||||
}
|
||||
return { type: "general" as const, id: "" };
|
||||
}, [query.applicationId, query.composeId, query.projectId, pathname]);
|
||||
}
|
||||
|
||||
export function ChatPanel() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [aiId, setAiId] = useState<string>("");
|
||||
const [input, setInput] = useState("");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const context = useChatContext();
|
||||
const aiIdRef = useRef(aiId);
|
||||
const contextRef = useRef(context);
|
||||
aiIdRef.current = aiId;
|
||||
contextRef.current = context;
|
||||
|
||||
const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const enabledProviders = providers ?? [];
|
||||
|
||||
const { messages, sendMessage, status, setMessages } = useChat({
|
||||
id: "dokploy-chat",
|
||||
transport: new DefaultChatTransport({
|
||||
api: "/api/ai/chat",
|
||||
body: () => ({ aiId: aiIdRef.current, context: contextRef.current }),
|
||||
}),
|
||||
});
|
||||
|
||||
const isLoading = status === "streaming" || status === "submitted";
|
||||
|
||||
useEffect(() => {
|
||||
if (!aiId && enabledProviders.length > 0 && enabledProviders[0]) {
|
||||
setAiId(enabledProviders[0].aiId);
|
||||
}
|
||||
}, [enabledProviders, aiId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, status]);
|
||||
|
||||
if (enabledProviders.length === 0) return null;
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || !aiId || isLoading) return;
|
||||
sendMessage({ text: input });
|
||||
setInput("");
|
||||
};
|
||||
|
||||
const contextLabel =
|
||||
context.type === "general"
|
||||
? "General"
|
||||
: context.type;
|
||||
|
||||
// Check if the AI is currently in a tool-calling phase (no text yet, just tools)
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const isThinking =
|
||||
isLoading &&
|
||||
lastMessage?.role === "assistant" &&
|
||||
lastMessage.parts.every(
|
||||
(p) => p.type !== "text" || !(p as { text?: string }).text?.trim(),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed bottom-6 right-6 z-50 h-12 w-12 rounded-full shadow-lg"
|
||||
size="icon"
|
||||
>
|
||||
<Bot className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-full sm:w-[480px] p-0 flex flex-col"
|
||||
>
|
||||
<SheetHeader className="px-4 py-3 border-b shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
<SheetTitle className="text-base">AI Assistant</SheetTitle>
|
||||
{isLoading && (
|
||||
<Badge variant="secondary" className="text-xs animate-pulse">
|
||||
thinking...
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SheetDescription className="sr-only">
|
||||
Chat with AI to manage your infrastructure
|
||||
</SheetDescription>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Select value={aiId} onValueChange={setAiId}>
|
||||
<SelectTrigger className="h-8 text-xs flex-1">
|
||||
<SelectValue placeholder="Select provider..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enabledProviders.map((p) => (
|
||||
<SelectItem key={p.aiId} value={p.aiId}>
|
||||
{p.name} ({p.model})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Badge variant="outline" className="text-xs shrink-0 capitalize">
|
||||
{contextLabel}
|
||||
</Badge>
|
||||
{messages.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setMessages([])}
|
||||
title="Clear chat"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto p-4 space-y-3"
|
||||
>
|
||||
{messages.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
|
||||
<Bot className="h-10 w-10 opacity-50" />
|
||||
<p className="text-sm text-center">
|
||||
Ask me anything about your{" "}
|
||||
{context.type === "general"
|
||||
? "infrastructure"
|
||||
: context.type}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5 justify-center">
|
||||
{(context.type === "application"
|
||||
? [
|
||||
"What's the status of this app?",
|
||||
"Why did the last build fail?",
|
||||
"Show me recent deployments",
|
||||
"Redeploy this app",
|
||||
]
|
||||
: context.type === "project"
|
||||
? [
|
||||
"How many services do I have?",
|
||||
"Show me all environments",
|
||||
"Which services are failing?",
|
||||
]
|
||||
: [
|
||||
"List all my projects",
|
||||
"Show project overview",
|
||||
]
|
||||
).map((suggestion) => (
|
||||
<Button
|
||||
key={suggestion}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7"
|
||||
onClick={() => setInput(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((message) => {
|
||||
if (message.role === "user") {
|
||||
return (
|
||||
<div key={message.id} className="flex justify-end">
|
||||
<div className="max-w-[85%] rounded-lg px-3 py-2 text-sm bg-primary text-primary-foreground">
|
||||
<p className="whitespace-pre-wrap">
|
||||
{message.parts
|
||||
.filter(
|
||||
(p): p is { type: "text"; text: string } =>
|
||||
p.type === "text",
|
||||
)
|
||||
.map((p) => p.text)
|
||||
.join("")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Assistant message
|
||||
const toolParts = message.parts.filter(
|
||||
(p) => p.type === "dynamic-tool",
|
||||
);
|
||||
const textParts = message.parts.filter(
|
||||
(p) => p.type === "text" && (p as { text?: string }).text?.trim(),
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={message.id} className="flex justify-start">
|
||||
<div className="max-w-[90%] space-y-2">
|
||||
{/* Tool calls section */}
|
||||
{toolParts.length > 0 && (
|
||||
<div className="rounded-lg border border-dashed px-3 py-2 space-y-1">
|
||||
{toolParts.map((part) => {
|
||||
if (part.type !== "dynamic-tool") return null;
|
||||
return (
|
||||
<ToolCallDisplay
|
||||
key={part.toolCallId}
|
||||
toolName={part.toolName}
|
||||
state={part.state}
|
||||
output={
|
||||
part.state === "output-available"
|
||||
? part.output
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text response */}
|
||||
{textParts.map((part, i) => {
|
||||
if (part.type !== "text") return null;
|
||||
const text = (part as { text: string }).text;
|
||||
if (!text.trim()) return null;
|
||||
return (
|
||||
<div
|
||||
key={`text-${message.id}-${i}`}
|
||||
className="rounded-lg bg-muted px-3 py-2 text-sm prose prose-sm dark:prose-invert max-w-none break-words"
|
||||
>
|
||||
<ReactMarkdown>{text}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Loading indicator when waiting for first response */}
|
||||
{isLoading &&
|
||||
lastMessage?.role === "user" && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-lg border border-dashed px-3 py-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Investigating...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t p-3 shrink-0 flex gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={
|
||||
aiId ? "Ask anything..." : "Select a provider first..."
|
||||
}
|
||||
disabled={!aiId || isLoading}
|
||||
className="min-h-[40px] max-h-[120px] resize-none text-sm"
|
||||
rows={1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
disabled={!aiId || !input.trim() || isLoading}
|
||||
className="shrink-0 h-10 w-10"
|
||||
onClick={handleSend}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallDisplay({
|
||||
toolName,
|
||||
state,
|
||||
output,
|
||||
}: {
|
||||
toolName: string;
|
||||
state: string;
|
||||
output?: unknown;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isRunning =
|
||||
state === "input-streaming" || state === "input-available";
|
||||
const isDone = state === "output-available";
|
||||
const isError = state === "output-error";
|
||||
|
||||
const outputText = output
|
||||
? typeof output === "string"
|
||||
? output
|
||||
: JSON.stringify(output, null, 2)
|
||||
: null;
|
||||
|
||||
// Format tool name for display: "application-one" → "Application One"
|
||||
const displayName = toolName
|
||||
.split("-")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-1.5 text-xs">
|
||||
{isRunning ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground mt-0.5 shrink-0" />
|
||||
) : isDone ? (
|
||||
<Check className="h-3 w-3 text-green-500 mt-0.5 shrink-0" />
|
||||
) : isError ? (
|
||||
<X className="h-3 w-3 text-destructive mt-0.5 shrink-0" />
|
||||
) : (
|
||||
<Wrench className="h-3 w-3 text-muted-foreground mt-0.5 shrink-0" />
|
||||
)}
|
||||
|
||||
{outputText ? (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>{displayName}</span>
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre className="mt-1 p-2 bg-background rounded text-[10px] overflow-x-auto max-h-[150px] overflow-y-auto leading-tight">
|
||||
{outputText.length > 2000
|
||||
? `${outputText.slice(0, 2000)}\n... (truncated)`
|
||||
: outputText}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{isRunning ? `Calling ${displayName}...` : displayName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api } from "@/utils/api";
|
||||
import { ChatPanel } from "../dashboard/ai-chat/chat-panel";
|
||||
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
|
||||
import { HubSpotWidget } from "../shared/HubSpotWidget";
|
||||
import Page from "./side";
|
||||
@@ -23,6 +24,7 @@ export const DashboardLayout = ({ children }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<Page>{children}</Page>
|
||||
<ChatPanel />
|
||||
{isChatEnabled && (
|
||||
<>
|
||||
<HubSpotWidget />
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"@ai-sdk/mistral": "^3.0.20",
|
||||
"@ai-sdk/openai": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@ai-sdk/react": "^3.0.156",
|
||||
"@better-auth/api-key": "1.5.4",
|
||||
"@better-auth/sso": "1.5.4",
|
||||
"@codemirror/autocomplete": "^6.18.6",
|
||||
|
||||
104
apps/dokploy/pages/api/ai/chat.ts
Normal file
104
apps/dokploy/pages/api/ai/chat.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { validateRequest } from "@dokploy/server";
|
||||
import { getAiSettingById } from "@dokploy/server/services/ai";
|
||||
import {
|
||||
type ChatContext,
|
||||
getAllTools,
|
||||
getReadTools,
|
||||
} from "@dokploy/server/utils/ai/chat-tools";
|
||||
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
|
||||
import { convertToModelMessages, stepCountIs, streamText } from "ai";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
function buildSystemPrompt(context: ChatContext) {
|
||||
return `You are an autonomous DevOps agent inside Dokploy, a self-hosted PaaS that uses Docker.
|
||||
|
||||
YOU ARE AN AGENT — act autonomously:
|
||||
- NEVER ask the user for IDs, parameters, or information you can find yourself with tools
|
||||
- NEVER respond without calling tools first — always investigate before answering
|
||||
- Chain multiple tool calls: get info → analyze → act → verify
|
||||
- If one tool gives you data you need for another tool, call the next tool immediately
|
||||
|
||||
${context.type !== "general" ? `CURRENT CONTEXT: You are on a ${context.type} page. The ${context.type}Id is "${context.id}" — all tools already use this ID automatically. You do NOT need to pass it.` : ""}
|
||||
|
||||
Dokploy data model:
|
||||
- Project → Environment(s) → Services (applications, compose, postgres, mysql, redis, mongo, mariadb, libsql)
|
||||
- Each application/compose has deployments with status (done/error/running/cancelled)
|
||||
- To investigate a failed build: call list-deployments → find the one with status "error" → call read-deployment-logs with that deploymentId → analyze the error
|
||||
|
||||
Guidelines:
|
||||
- Be concise — summarize findings, don't dump raw JSON
|
||||
- Before destructive actions (stop, delete), explain what you'll do first
|
||||
- When updating env vars, ALWAYS get current ones first (from get-application-info) and include ALL existing vars plus the new ones
|
||||
- If a tool errors, try a different approach`;
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { session, user } = await validateRequest(req);
|
||||
if (!user || !session) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const body = req.body;
|
||||
const messages = body.messages;
|
||||
const aiId = body.aiId;
|
||||
const context = (body.context as ChatContext) || { type: "general" as const, id: "" };
|
||||
|
||||
if (!aiId || !messages) {
|
||||
return res.status(400).json({ error: "Missing aiId or messages" });
|
||||
}
|
||||
|
||||
const aiSettings = await getAiSettingById(aiId);
|
||||
if (!aiSettings || !aiSettings.isEnabled) {
|
||||
return res.status(400).json({ error: "AI provider not enabled" });
|
||||
}
|
||||
|
||||
const provider = selectAIProvider(aiSettings);
|
||||
const model = provider(aiSettings.model);
|
||||
|
||||
const protocol = req.headers["x-forwarded-proto"] || "http";
|
||||
const host = req.headers.host || "localhost:3000";
|
||||
const toolConfig = {
|
||||
baseUrl: `${protocol}://${host}/api`,
|
||||
cookie: req.headers.cookie || "",
|
||||
};
|
||||
|
||||
// All tools (read + write) — prepareStep controls which are active
|
||||
const allTools = getAllTools(context, toolConfig);
|
||||
const readToolNames = Object.keys(getReadTools(context, toolConfig));
|
||||
|
||||
const modelMessages = await convertToModelMessages(messages);
|
||||
|
||||
const result = streamText({
|
||||
model,
|
||||
system: buildSystemPrompt(context),
|
||||
messages: modelMessages,
|
||||
tools: allTools,
|
||||
prepareStep: ({ steps }) => {
|
||||
// First 3 steps: only read tools (investigate first)
|
||||
// After that: all tools (can take action)
|
||||
if (steps.length < 3) {
|
||||
return {
|
||||
activeTools: readToolNames as (keyof typeof allTools)[],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
stopWhen: stepCountIs(10),
|
||||
});
|
||||
|
||||
result.pipeUIMessageStreamToResponse(res);
|
||||
} catch (error) {
|
||||
console.error("AI chat error:", error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : "Internal server error",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
findGitProviderById,
|
||||
findProjectById,
|
||||
getApplicationStats,
|
||||
getContainerLogs,
|
||||
IS_CLOUD,
|
||||
mechanizeDockerContainer,
|
||||
readConfig,
|
||||
@@ -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,43 @@ 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,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
findEnvironmentById,
|
||||
findLibsqlById,
|
||||
findProjectById,
|
||||
getAccessibleServerIds,
|
||||
getContainerLogs,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
removeLibsqlById,
|
||||
@@ -15,7 +17,6 @@ import {
|
||||
stopService,
|
||||
stopServiceRemote,
|
||||
updateLibsqlById,
|
||||
getAccessibleServerIds,
|
||||
} from "@dokploy/server";
|
||||
import {
|
||||
addNewService,
|
||||
@@ -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,7 @@ import {
|
||||
findEnvironmentById,
|
||||
findMariadbById,
|
||||
findProjectById,
|
||||
getContainerLogs,
|
||||
getServiceContainerCommand,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
@@ -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,8 @@ import {
|
||||
findEnvironmentById,
|
||||
findPostgresById,
|
||||
findProjectById,
|
||||
getAccessibleServerIds,
|
||||
getContainerLogs,
|
||||
getMountPath,
|
||||
getServiceContainerCommand,
|
||||
IS_CLOUD,
|
||||
@@ -20,7 +22,6 @@ import {
|
||||
stopService,
|
||||
stopServiceRemote,
|
||||
updatePostgresById,
|
||||
getAccessibleServerIds,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -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,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -354,6 +354,41 @@ export const getContainersByAppLabel = async (
|
||||
return [];
|
||||
};
|
||||
|
||||
export const getContainerLogs = async (
|
||||
appName: string,
|
||||
tail = 100,
|
||||
since = "all",
|
||||
search?: string,
|
||||
serverId?: string | null,
|
||||
): Promise<string> => {
|
||||
const sinceFlag = since === "all" ? "" : `--since ${since}`;
|
||||
const baseCommand = `docker container logs --timestamps --tail ${tail} ${sinceFlag} ${appName}`;
|
||||
|
||||
const escapedSearch = search?.replace(/'/g, "'\\''") ?? "";
|
||||
const command = search
|
||||
? `${baseCommand} 2>&1 | grep -iF '${escapedSearch}'`
|
||||
: `${baseCommand} 2>&1`;
|
||||
|
||||
try {
|
||||
const result = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(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(
|
||||
|
||||
306
packages/server/src/utils/ai/chat-tools.ts
Normal file
306
packages/server/src/utils/ai/chat-tools.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dynamicTool } from "ai";
|
||||
import { z } from "zod";
|
||||
import { findDeploymentById } from "../../services/deployment";
|
||||
|
||||
export interface ChatContext {
|
||||
type: "application" | "compose" | "project" | "server" | "general";
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface ToolConfig {
|
||||
baseUrl: string;
|
||||
cookie: string;
|
||||
}
|
||||
|
||||
async function callApi(
|
||||
config: ToolConfig,
|
||||
method: "GET" | "POST",
|
||||
path: string,
|
||||
params?: Record<string, unknown>,
|
||||
) {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: config.cookie,
|
||||
};
|
||||
|
||||
let url = `${config.baseUrl}${path}`;
|
||||
|
||||
if (method === "GET" && params) {
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
}
|
||||
const qs = searchParams.toString();
|
||||
if (qs) url += `?${qs}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
...(method === "POST" && params ? { body: JSON.stringify(params) } : {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`API error (${response.status}): ${errorText.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function makeTool(
|
||||
description: string,
|
||||
inputSchema: z.ZodObject<z.ZodRawShape>,
|
||||
executeFn: (input: Record<string, unknown>) => Promise<unknown>,
|
||||
) {
|
||||
return dynamicTool({
|
||||
description,
|
||||
inputSchema,
|
||||
execute: async (rawInput: unknown) => {
|
||||
try {
|
||||
const input = (rawInput ?? {}) as Record<string, unknown>;
|
||||
const result = await executeFn(input);
|
||||
const json = JSON.stringify(result, null, 2);
|
||||
// Truncate very large responses
|
||||
if (json.length > 15000) {
|
||||
return `${json.slice(0, 15000)}\n\n... [Truncated — ${json.length} chars total]`;
|
||||
}
|
||||
return json;
|
||||
} catch (error) {
|
||||
return `Error: ${error instanceof Error ? error.message : "Unknown error"}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── READ TOOLS ──────────────────────────────────────────────
|
||||
|
||||
function readTools(context: ChatContext, config: ToolConfig) {
|
||||
const tools: Record<string, ReturnType<typeof dynamicTool>> = {};
|
||||
|
||||
if (context.type === "application") {
|
||||
tools["get-application-info"] = makeTool(
|
||||
"Get the full configuration of the current application: name, status, build type, source, env vars, resource limits, and more. Call this first to understand the app state.",
|
||||
z.object({}),
|
||||
() => callApi(config, "GET", "/application.one", { applicationId: context.id }),
|
||||
);
|
||||
|
||||
tools["list-deployments"] = makeTool(
|
||||
"List the 10 most recent deployments for this application. Each deployment has a status (done/error/running), title, error message, and timestamps. Use this to find failed builds.",
|
||||
z.object({}),
|
||||
() => callApi(config, "GET", "/deployment.allByType", { id: context.id, type: "application" }),
|
||||
);
|
||||
|
||||
tools["list-domains"] = makeTool(
|
||||
"List all domains configured for this application.",
|
||||
z.object({}),
|
||||
() => callApi(config, "GET", "/domain.byApplicationId", { applicationId: context.id }),
|
||||
);
|
||||
|
||||
tools["get-containers"] = makeTool(
|
||||
"List running Docker containers for this application. Shows container state, status, and names.",
|
||||
z.object({}),
|
||||
async () => {
|
||||
const app = await callApi(config, "GET", "/application.one", { applicationId: context.id });
|
||||
return callApi(config, "GET", "/docker.getContainersByAppNameMatch", {
|
||||
appName: (app as { appName: string }).appName,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (context.type === "compose") {
|
||||
tools["get-compose-info"] = makeTool(
|
||||
"Get the full configuration of the current compose service: name, status, compose file content, env vars, and more.",
|
||||
z.object({}),
|
||||
() => callApi(config, "GET", "/compose.one", { composeId: context.id }),
|
||||
);
|
||||
|
||||
tools["list-deployments"] = makeTool(
|
||||
"List the 10 most recent deployments for this compose service.",
|
||||
z.object({}),
|
||||
() => callApi(config, "GET", "/deployment.allByType", { id: context.id, type: "compose" }),
|
||||
);
|
||||
|
||||
tools["list-domains"] = makeTool(
|
||||
"List all domains configured for this compose service.",
|
||||
z.object({}),
|
||||
() => callApi(config, "GET", "/domain.byComposeId", { composeId: context.id }),
|
||||
);
|
||||
}
|
||||
|
||||
if (context.type === "project") {
|
||||
tools["get-project-info"] = makeTool(
|
||||
"Get the full project details including ALL environments and ALL services (applications, compose, databases). Use this to count services, see what's deployed, and find failing services.",
|
||||
z.object({}),
|
||||
() => callApi(config, "GET", "/project.one", { projectId: context.id }),
|
||||
);
|
||||
}
|
||||
|
||||
if (context.type === "general") {
|
||||
tools["list-projects"] = makeTool(
|
||||
"List all projects in the organization.",
|
||||
z.object({}),
|
||||
() => callApi(config, "GET", "/project.all"),
|
||||
);
|
||||
}
|
||||
|
||||
// Available in both application and compose contexts
|
||||
if (context.type === "application" || context.type === "compose") {
|
||||
tools["read-deployment-logs"] = dynamicTool({
|
||||
description:
|
||||
"Read the build/deployment logs for a specific deployment. ALWAYS call list-deployments first to find the deploymentId. This reads the actual log file content to diagnose build failures.",
|
||||
inputSchema: z.object({
|
||||
deploymentId: z.string().describe("The deployment ID from list-deployments"),
|
||||
}),
|
||||
execute: async (rawInput: unknown) => {
|
||||
const { deploymentId } = rawInput as { deploymentId: string };
|
||||
try {
|
||||
const deployment = await findDeploymentById(deploymentId);
|
||||
const content = await readFile(deployment.logPath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
const last200 = lines.slice(-200).join("\n");
|
||||
return `Deployment status: ${deployment.status}\nError message: ${deployment.errorMessage || "none"}\n\nLast 200 lines of build log:\n${last200}`;
|
||||
} catch {
|
||||
return "Could not read deployment logs — the log file may not exist.";
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
tools["read-runtime-logs"] = makeTool(
|
||||
"Read the runtime/container logs (stdout/stderr) of this application. Shows the last N lines of the running application output. Use this to diagnose runtime errors, crashes, or check if the app is working.",
|
||||
z.object({
|
||||
tail: z.number().optional().describe("Number of lines to read (default 200, max 500)"),
|
||||
}),
|
||||
(input) => {
|
||||
const tail = Math.min((input.tail as number) || 200, 500);
|
||||
const endpoint = context.type === "compose" ? "/compose.readLogs" : "/application.readLogs";
|
||||
const idKey = context.type === "compose" ? "composeId" : "applicationId";
|
||||
return callApi(config, "GET", endpoint, {
|
||||
[idKey]: context.id,
|
||||
tail,
|
||||
since: "all",
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
// ─── WRITE TOOLS ─────────────────────────────────────────────
|
||||
|
||||
function writeTools(context: ChatContext, config: ToolConfig) {
|
||||
const tools: Record<string, ReturnType<typeof dynamicTool>> = {};
|
||||
|
||||
if (context.type === "application") {
|
||||
tools["update-env-vars"] = makeTool(
|
||||
"Update the environment variables for this application. Pass the FULL env string (KEY=VALUE format, one per line). This REPLACES all existing env vars, so include the ones you want to keep.",
|
||||
z.object({
|
||||
env: z.string().describe("Full environment variables, one KEY=VALUE per line"),
|
||||
}),
|
||||
(input) =>
|
||||
callApi(config, "POST", "/application.saveEnvironment", {
|
||||
applicationId: context.id,
|
||||
env: input.env,
|
||||
}),
|
||||
);
|
||||
|
||||
tools["deploy-application"] = makeTool(
|
||||
"Trigger a new deployment/build for this application. The build will run in the background.",
|
||||
z.object({}),
|
||||
() =>
|
||||
callApi(config, "POST", "/application.deploy", {
|
||||
applicationId: context.id,
|
||||
title: "AI-triggered deployment",
|
||||
description: "Deployed via AI Assistant",
|
||||
}),
|
||||
);
|
||||
|
||||
tools["redeploy-application"] = makeTool(
|
||||
"Redeploy the application using the existing build (no new build). Faster than deploy.",
|
||||
z.object({}),
|
||||
() =>
|
||||
callApi(config, "POST", "/application.redeploy", {
|
||||
applicationId: context.id,
|
||||
title: "AI-triggered redeployment",
|
||||
description: "Redeployed via AI Assistant",
|
||||
}),
|
||||
);
|
||||
|
||||
tools["stop-application"] = makeTool(
|
||||
"Stop the application. This will stop all containers.",
|
||||
z.object({}),
|
||||
() => callApi(config, "POST", "/application.stop", { applicationId: context.id }),
|
||||
);
|
||||
|
||||
tools["start-application"] = makeTool(
|
||||
"Start a stopped application.",
|
||||
z.object({}),
|
||||
() => callApi(config, "POST", "/application.start", { applicationId: context.id }),
|
||||
);
|
||||
|
||||
tools["restart-container"] = makeTool(
|
||||
"Restart a specific Docker container. Use get-containers first to find the container ID.",
|
||||
z.object({
|
||||
containerId: z.string().describe("The container ID from get-containers"),
|
||||
}),
|
||||
(input) => callApi(config, "POST", "/docker.restartContainer", { containerId: input.containerId }),
|
||||
);
|
||||
}
|
||||
|
||||
if (context.type === "compose") {
|
||||
tools["update-compose-env"] = makeTool(
|
||||
"Update the environment variables for this compose service. Pass the FULL env string.",
|
||||
z.object({
|
||||
env: z.string().describe("Full environment variables, one KEY=VALUE per line"),
|
||||
}),
|
||||
(input) =>
|
||||
callApi(config, "POST", "/compose.update", {
|
||||
composeId: context.id,
|
||||
env: input.env,
|
||||
}),
|
||||
);
|
||||
|
||||
tools["deploy-compose"] = makeTool(
|
||||
"Trigger a new deployment for this compose service.",
|
||||
z.object({}),
|
||||
() =>
|
||||
callApi(config, "POST", "/compose.deploy", {
|
||||
composeId: context.id,
|
||||
title: "AI-triggered deployment",
|
||||
description: "Deployed via AI Assistant",
|
||||
}),
|
||||
);
|
||||
|
||||
tools["stop-compose"] = makeTool(
|
||||
"Stop the compose service.",
|
||||
z.object({}),
|
||||
() => callApi(config, "POST", "/compose.stop", { composeId: context.id }),
|
||||
);
|
||||
|
||||
tools["start-compose"] = makeTool(
|
||||
"Start a stopped compose service.",
|
||||
z.object({}),
|
||||
() => callApi(config, "POST", "/compose.start", { composeId: context.id }),
|
||||
);
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
// ─── PUBLIC API ──────────────────────────────────────────────
|
||||
|
||||
export function getReadTools(context: ChatContext, config: ToolConfig) {
|
||||
return readTools(context, config);
|
||||
}
|
||||
|
||||
export function getAllTools(context: ChatContext, config: ToolConfig) {
|
||||
return {
|
||||
...readTools(context, config),
|
||||
...writeTools(context, config),
|
||||
};
|
||||
}
|
||||
76
pnpm-lock.yaml
generated
76
pnpm-lock.yaml
generated
@@ -113,6 +113,9 @@ importers:
|
||||
'@ai-sdk/openai-compatible':
|
||||
specifier: ^2.0.30
|
||||
version: 2.0.30(zod@4.3.6)
|
||||
'@ai-sdk/react':
|
||||
specifier: ^3.0.156
|
||||
version: 3.0.156(react@18.2.0)(zod@4.3.6)
|
||||
'@better-auth/api-key':
|
||||
specifier: 1.5.4
|
||||
version: 1.5.4(@better-auth/core@1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@2.0.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(better-auth@1.5.4(febde88eaf587188179e6ecc47119e50))
|
||||
@@ -878,6 +881,12 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/gateway@3.0.94':
|
||||
resolution: {integrity: sha512-uDDwLZhCkvC89crVS3S90D5L7AcVN8WriGuYVNYgVAaVcvy3Mthy3R9ICfzG75BObhz6pm2FWnhxDfNRK+t69Q==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/mistral@3.0.20':
|
||||
resolution: {integrity: sha512-oZcx2pE6nJ+Qj/U6HFV5mJ52jXJPBSpvki/NtIocZkI/rKxphKBaecOH1h0Y7yK3HIbBxsMqefB1pb72cAHGVg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -902,10 +911,22 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/provider-utils@4.0.23':
|
||||
resolution: {integrity: sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/provider@3.0.8':
|
||||
resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@ai-sdk/react@3.0.156':
|
||||
resolution: {integrity: sha512-/6rmGxOJlCNS6wJBUNsO49aeSK740fS2wVcA3Xn8IOBRFFz3hWm6auQTMoA0nHKu4hnH6ivA6hog6Ul+1Bv4Rg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1
|
||||
|
||||
'@alloc/quick-lru@5.2.0':
|
||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -4241,6 +4262,12 @@ packages:
|
||||
peerDependencies:
|
||||
ai: ^6.0.89
|
||||
|
||||
ai@6.0.154:
|
||||
resolution: {integrity: sha512-HfKJKCTJsDZxqrIUDSVnBQ7DpQlx5WI4ExqtLd7Bl70epLmvkpc/HYMzU1hP9W+g9VEAcvZo4fbMqc3v5D+9gQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
ai@6.0.97:
|
||||
resolution: {integrity: sha512-eZIAcBymwGhBwncRH/v9pillZNMeRCDkc4BwcvwXerXd7sxjVxRis3ZNCNCpP02pVH4NLs81ljm4cElC4vbNcQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -7707,6 +7734,11 @@ packages:
|
||||
react: '>=16.8.0 <20'
|
||||
react-dom: '>=16.8.0 <20'
|
||||
|
||||
swr@2.4.1:
|
||||
resolution: {integrity: sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==}
|
||||
peerDependencies:
|
||||
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
symbol-observable@1.2.0:
|
||||
resolution: {integrity: sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -7764,6 +7796,10 @@ packages:
|
||||
thread-stream@3.1.0:
|
||||
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
|
||||
|
||||
throttleit@2.1.0:
|
||||
resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
@@ -8329,6 +8365,13 @@ snapshots:
|
||||
'@vercel/oidc': 3.1.0
|
||||
zod: 4.3.6
|
||||
|
||||
'@ai-sdk/gateway@3.0.94(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.8
|
||||
'@ai-sdk/provider-utils': 4.0.23(zod@4.3.6)
|
||||
'@vercel/oidc': 3.1.0
|
||||
zod: 4.3.6
|
||||
|
||||
'@ai-sdk/mistral@3.0.20(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.8
|
||||
@@ -8354,10 +8397,27 @@ snapshots:
|
||||
eventsource-parser: 3.0.6
|
||||
zod: 4.3.6
|
||||
|
||||
'@ai-sdk/provider-utils@4.0.23(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.8
|
||||
'@standard-schema/spec': 1.1.0
|
||||
eventsource-parser: 3.0.6
|
||||
zod: 4.3.6
|
||||
|
||||
'@ai-sdk/provider@3.0.8':
|
||||
dependencies:
|
||||
json-schema: 0.4.0
|
||||
|
||||
'@ai-sdk/react@3.0.156(react@18.2.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider-utils': 4.0.23(zod@4.3.6)
|
||||
ai: 6.0.154(zod@4.3.6)
|
||||
react: 18.2.0
|
||||
swr: 2.4.1(react@18.2.0)
|
||||
throttleit: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- zod
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@authenio/xml-encryption@2.0.2':
|
||||
@@ -12337,6 +12397,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- zod
|
||||
|
||||
ai@6.0.154(zod@4.3.6):
|
||||
dependencies:
|
||||
'@ai-sdk/gateway': 3.0.94(zod@4.3.6)
|
||||
'@ai-sdk/provider': 3.0.8
|
||||
'@ai-sdk/provider-utils': 4.0.23(zod@4.3.6)
|
||||
'@opentelemetry/api': 1.9.0
|
||||
zod: 4.3.6
|
||||
|
||||
ai@6.0.97(zod@4.3.6):
|
||||
dependencies:
|
||||
'@ai-sdk/gateway': 3.0.53(zod@4.3.6)
|
||||
@@ -15935,6 +16003,12 @@ snapshots:
|
||||
- '@types/react'
|
||||
- debug
|
||||
|
||||
swr@2.4.1(react@18.2.0):
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
react: 18.2.0
|
||||
use-sync-external-store: 1.6.0(react@18.2.0)
|
||||
|
||||
symbol-observable@1.2.0: {}
|
||||
|
||||
tailwind-merge@2.6.1: {}
|
||||
@@ -16025,6 +16099,8 @@ snapshots:
|
||||
dependencies:
|
||||
real-require: 0.2.0
|
||||
|
||||
throttleit@2.1.0: {}
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tiny-warning@1.0.3: {}
|
||||
|
||||
Reference in New Issue
Block a user