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:
Mauricio Siu
2026-04-09 11:19:42 -06:00
parent 090c0226ed
commit e508f3143f
15 changed files with 1229 additions and 3 deletions

View 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>
);
}

View File

@@ -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 />

View File

@@ -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",

View 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",
});
}
}

View File

@@ -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,
);
}),
});

View File

@@ -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,
);
}),
});

View File

@@ -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,
);
}),
});

View File

@@ -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,
);
}),
});

View File

@@ -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,
);
}),
});

View File

@@ -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,
);
}),
});

View File

@@ -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,
);
}),
});

View File

@@ -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,
);
}),
});

View File

@@ -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(

View 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
View File

@@ -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: {}