feat: enhance AI chat functionality and API integration

- Updated the AI chat panel to support multiple service types, including applications, databases, and more, improving context handling.
- Implemented local storage for chat messages, allowing users to retain their chat history.
- Enhanced API integration by adding new endpoints for reading deployment build logs and creating tools from OpenAPI specifications.
- Improved error handling and user experience in the chat interface, ensuring smoother interactions.

These changes significantly enhance the AI chat capabilities and overall user experience within the Dokploy platform.
This commit is contained in:
Mauricio Siu
2026-04-11 22:15:58 -06:00
parent e0b4a13340
commit cee2e9f002
8 changed files with 33003 additions and 22671 deletions

View File

@@ -45,17 +45,68 @@ function useChatContext(): ChatContext {
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 };
const projectId =
typeof query.projectId === "string" ? query.projectId : undefined;
const environmentId =
typeof query.environmentId === "string"
? query.environmentId
: undefined;
const serverId =
typeof query.serverId === "string" ? query.serverId : undefined;
const serviceParams = [
{ key: "applicationId", type: "application" },
{ key: "composeId", type: "compose" },
{ key: "postgresId", type: "postgres" },
{ key: "mysqlId", type: "mysql" },
{ key: "redisId", type: "redis" },
{ key: "mongoId", type: "mongo" },
{ key: "mariadbId", type: "mariadb" },
{ key: "libsqlId", type: "libsql" },
] as const;
for (const { key, type } of serviceParams) {
if (query[key] && typeof query[key] === "string") {
return {
type,
id: query[key] as string,
projectId,
environmentId,
serverId,
};
}
}
if (query.projectId && typeof query.projectId === "string") {
return { type: "project" as const, id: query.projectId };
return {
type: "project" as const,
id: query.projectId,
projectId,
environmentId,
serverId,
};
}
return { type: "general" as const, id: "" };
}, [query.applicationId, query.composeId, query.projectId, pathname]);
return {
type: "general" as const,
id: "",
projectId,
environmentId,
serverId,
};
}, [
query.applicationId,
query.composeId,
query.postgresId,
query.mysqlId,
query.redisId,
query.mongoId,
query.mariadbId,
query.libsqlId,
query.projectId,
query.environmentId,
query.serverId,
pathname,
]);
}
export function ChatPanel() {
@@ -63,33 +114,65 @@ export function ChatPanel() {
const [aiId, setAiId] = useState<string>("");
const [input, setInput] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const context = useChatContext();
const aiIdRef = useRef(aiId);
const contextRef = useRef(context);
aiIdRef.current = aiId;
contextRef.current = context;
const { data: isCloud } = api.settings.isCloud.useQuery(undefined, {
refetchOnWindowFocus: false,
});
const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, {
refetchOnWindowFocus: false,
enabled: !isCloud,
});
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 STORAGE_KEY = "dokploy-chat-messages";
const { messages, sendMessage, status, setMessages, addToolApprovalResponse } = useChat({
id: "dokploy-chat",
transport: new DefaultChatTransport({
api: "/api/ai/chat",
body: () => ({
...(isCloud ? {} : { aiId: aiIdRef.current }),
context: contextRef.current,
}),
}),
initialMessages: () => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
},
});
const isLoading = status === "streaming" || status === "submitted";
// Persist messages to localStorage
useEffect(() => {
if (!aiId && enabledProviders.length > 0 && enabledProviders[0]) {
if (messages.length > 0) {
try {
// Keep only last 50 messages to avoid localStorage bloat
const toStore = messages.slice(-50);
localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore));
} catch {
// localStorage full or unavailable — ignore
}
}
}, [messages]);
useEffect(() => {
if (!isCloud && !aiId && enabledProviders.length > 0 && enabledProviders[0]) {
setAiId(enabledProviders[0].aiId);
}
}, [enabledProviders, aiId]);
}, [enabledProviders, aiId, isCloud]);
useEffect(() => {
if (scrollRef.current) {
@@ -97,33 +180,27 @@ export function ChatPanel() {
}
}, [messages, status]);
if (enabledProviders.length === 0) return null;
if (!isCloud && enabledProviders.length === 0) return null;
const handleSend = () => {
if (!input.trim() || !aiId || isLoading) return;
if (!input.trim() || isLoading) return;
if (!isCloud && !aiId) return;
sendMessage({ text: input });
setInput("");
setTimeout(() => inputRef.current?.focus(), 0);
};
const contextLabel =
context.type === "general"
? "General"
: context.type;
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"
variant="outline"
className="fixed bottom-6 right-6 z-50 h-11 w-11 rounded-full shadow-md border"
size="icon"
>
<Bot className="h-5 w-5" />
@@ -132,17 +209,19 @@ export function ChatPanel() {
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
className="w-full sm:w-[480px] p-0 flex flex-col"
className="w-full sm:w-[480px] p-0 flex flex-col border-l outline-none"
>
<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>
<Bot className="h-4 w-4 text-muted-foreground" />
<SheetTitle className="text-sm font-medium">
{isCloud ? "Dokploy Agent" : "AI Assistant"}
</SheetTitle>
{isLoading && (
<Badge variant="secondary" className="text-xs animate-pulse">
thinking...
</Badge>
<span className="text-xs text-muted-foreground animate-pulse">
working...
</span>
)}
</div>
</div>
@@ -150,19 +229,24 @@ export function ChatPanel() {
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">
{!isCloud && (
<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 font-normal"
>
{contextLabel}
</Badge>
{messages.length > 0 && (
@@ -170,7 +254,7 @@ export function ChatPanel() {
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setMessages([])}
onClick={() => { setMessages([]); localStorage.removeItem(STORAGE_KEY); }}
title="Clear chat"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -185,7 +269,7 @@ export function ChatPanel() {
>
{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" />
<Bot className="h-8 w-8 opacity-30" />
<p className="text-sm text-center">
Ask me anything about your{" "}
{context.type === "general"
@@ -200,22 +284,42 @@ export function ChatPanel() {
"Show me recent deployments",
"Redeploy this app",
]
: context.type === "project"
: context.type === "compose"
? [
"How many services do I have?",
"Show me all environments",
"Which services are failing?",
]
: [
"List all my projects",
"Show project overview",
"Show compose service status",
"Why did the last deploy fail?",
"Show me the domains",
"Redeploy this service",
]
: context.type === "postgres" ||
context.type === "mysql" ||
context.type === "redis" ||
context.type === "mongo" ||
context.type === "mariadb" ||
context.type === "libsql"
? [
`Show ${context.type} status`,
"What's the connection info?",
"Show recent deployments",
"Restart this database",
]
: context.type === "project"
? [
"How many services do I have?",
"Show me all environments",
"Which services are failing?",
]
: [
"List all my projects",
"Show project overview",
"What servers do I have?",
]
).map((suggestion) => (
<Button
key={suggestion}
variant="outline"
size="sm"
className="text-xs h-7"
className="text-xs h-7 font-normal"
onClick={() => setInput(suggestion)}
>
{suggestion}
@@ -229,7 +333,7 @@ export function ChatPanel() {
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">
<div className="max-w-[85%] rounded-lg px-3 py-2 text-sm bg-muted">
<p className="whitespace-pre-wrap">
{message.parts
.filter(
@@ -244,25 +348,34 @@ export function ChatPanel() {
);
}
// 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 (
{message.parts.map((part, i) => {
if (
part.type === "text" &&
(part as { text?: string }).text?.trim()
) {
return (
<div
key={`text-${message.id}-${i}`}
className="rounded-lg border px-3 py-2 text-sm prose prose-sm dark:prose-invert max-w-none break-words"
>
<ReactMarkdown>
{(part as { text: string }).text}
</ReactMarkdown>
</div>
);
}
if (part.type === "dynamic-tool") {
return (
<div
key={part.toolCallId}
className="rounded-lg border px-3 py-2"
>
<ToolCallDisplay
key={part.toolCallId}
toolCallId={part.toolCallId}
toolName={part.toolName}
state={part.state}
output={
@@ -270,51 +383,75 @@ export function ChatPanel() {
? part.output
: undefined
}
onApprove={(id) =>
addToolApprovalResponse({
id,
approved: true,
})
}
onDeny={(id) =>
addToolApprovalResponse({
id,
approved: false,
reason: "User denied",
})
}
/>
);
})}
</div>
)}
</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>
);
if (part.type === "reasoning") {
return (
<Collapsible key={`reasoning-${message.id}-${i}`}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Bot className="h-3 w-3" />
<span>Thinking...</span>
<ChevronDown className="h-3 w-3" />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-1 rounded-lg border px-3 py-2 text-xs text-muted-foreground italic">
{(part as any).text ||
(part as any).reasoning}
</div>
</CollapsibleContent>
</Collapsible>
);
}
return null;
})}
</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>
{isLoading && lastMessage?.role === "user" && (
<div className="flex justify-start">
<div className="rounded-lg border 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
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={
aiId ? "Ask anything..." : "Select a provider first..."
!isCloud && !aiId
? "Select a provider first..."
: "Ask anything..."
}
disabled={!aiId || isLoading}
disabled={(!isCloud && !aiId) || isLoading}
className="min-h-[40px] max-h-[120px] resize-none text-sm"
rows={1}
onKeyDown={(e) => {
@@ -327,7 +464,10 @@ export function ChatPanel() {
<Button
type="button"
size="icon"
disabled={!aiId || !input.trim() || isLoading}
variant="outline"
disabled={
(!isCloud && !aiId) || !input.trim() || isLoading
}
className="shrink-0 h-10 w-10"
onClick={handleSend}
>
@@ -341,19 +481,26 @@ export function ChatPanel() {
}
function ToolCallDisplay({
toolCallId,
toolName,
state,
output,
onApprove,
onDeny,
}: {
toolCallId: string;
toolName: string;
state: string;
output?: unknown;
onApprove?: (id: string) => void;
onDeny?: (id: string) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const isRunning =
state === "input-streaming" || state === "input-available";
const isDone = state === "output-available";
const isError = state === "output-error";
const needsApproval = state === "requires-approval";
const outputText = output
? typeof output === "string"
@@ -361,18 +508,48 @@ function ToolCallDisplay({
: 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(" ");
if (needsApproval) {
return (
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-1.5">
<Wrench className="h-3 w-3 text-muted-foreground shrink-0" />
<span>{displayName}</span>
</div>
<div className="flex gap-1.5 shrink-0">
<Button
variant="outline"
size="sm"
className="h-6 text-xs px-2"
onClick={() => onApprove?.(toolCallId)}
>
<Check className="h-3 w-3 mr-1" />
Approve
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs px-2"
onClick={() => onDeny?.(toolCallId)}
>
<X className="h-3 w-3 mr-1" />
Deny
</Button>
</div>
</div>
);
}
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" />
<Check className="h-3 w-3 text-muted-foreground mt-0.5 shrink-0" />
) : isError ? (
<X className="h-3 w-3 text-destructive mt-0.5 shrink-0" />
) : (
@@ -393,7 +570,7 @@ function ToolCallDisplay({
</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">
<pre className="mt-1 p-2 bg-muted/50 rounded text-[10px] overflow-x-auto max-h-[150px] overflow-y-auto leading-tight">
{outputText.length > 2000
? `${outputText.slice(0, 2000)}\n... (truncated)`
: outputText}
@@ -402,7 +579,7 @@ function ToolCallDisplay({
</Collapsible>
) : (
<span className="text-muted-foreground">
{isRunning ? `Calling ${displayName}...` : displayName}
{isRunning ? `${displayName}...` : displayName}
</span>
)}
</div>

View File

@@ -1,35 +1,125 @@
import { validateRequest } from "@dokploy/server";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { IS_CLOUD, validateRequest } from "@dokploy/server";
import { getAiSettingById } from "@dokploy/server/services/ai";
import {
type ChatContext,
getAllTools,
getReadTools,
} from "@dokploy/server/utils/ai/chat-tools";
import {
buildEndpointCatalog,
createApiTool,
} from "@dokploy/server/utils/ai/api-tool";
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
import { createAnthropic } from "@ai-sdk/anthropic";
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.
let cachedSpec: any = null;
const cachedCatalogs = new Map<string, { catalog: string; count: number; operationIds: Set<string> }>();
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
function getOpenApiSpec() {
if (!cachedSpec) {
try {
const specPath = join(process.cwd(), "../../openapi.json");
cachedSpec = JSON.parse(readFileSync(specPath, "utf-8"));
} catch {
cachedSpec = null;
}
}
return cachedSpec;
}
${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.` : ""}
function getEndpointCatalog(spec: any, contextType: ChatContext["type"]) {
const cached = cachedCatalogs.get(contextType);
if (cached) return cached;
const result = buildEndpointCatalog(spec, contextType);
cachedCatalogs.set(contextType, result);
return result;
}
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
function buildContextBlock(context: ChatContext): string {
if (context.type === "general") {
return "CONTEXT: The user is on the general dashboard (no specific resource selected). Use project-all to list their projects if needed.";
}
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`;
const lines: string[] = [];
lines.push(
`CONTEXT: The user is currently viewing a specific ${context.type}. The ${context.type}Id is "${context.id}".`,
);
lines.push(
`When the user says "this app", "this service", "this database", "add env var", etc., they ALWAYS mean this ${context.type} (ID: "${context.id}"). NEVER ask which service they mean.`,
);
if (context.projectId) {
lines.push(`- projectId: "${context.projectId}"`);
}
if (context.environmentId) {
lines.push(`- environmentId: "${context.environmentId}"`);
}
if (context.serverId) {
lines.push(`- serverId: "${context.serverId}"`);
}
lines.push(
"Use these IDs directly when calling tools — do NOT ask the user for them. You already know exactly which resource the user is talking about.",
);
return lines.join("\n");
}
function buildSystemPrompt(context: ChatContext, catalog: string | null, endpointCount?: number) {
const contextBlock = buildContextBlock(context);
return `You are an autonomous DevOps agent inside Dokploy (Docker-based PaaS). You take action immediately — you don't explain, you don't ask, you DO.
${contextBlock}
THINKING PROCESS (do this before EVERY action):
1. Scan ALL section headers (## tag — description) in the ENDPOINT CATALOG to find which sections are relevant
2. Read the endpoint descriptions in those sections to pick the right operationId
3. Call the endpoint with the correct params — use the IDs from the context above
BEHAVIOR:
- When the user asks you to do something → DO IT. Call the API right away.
- When you need information → call the endpoint to get it. Never say "I can't access" or "I don't have the ability to".
- When something fails → read the error, figure out the fix, and apply it. Don't stop to explain the error — fix it.
- EVERY capability you need is in the ENDPOINT CATALOG below. If you think you can't do something, you're wrong — scan ALL sections again.
- You already have all the IDs you need from the context above. NEVER ask the user for IDs, paths, or information you can discover by calling endpoints.
- For destructive actions only (delete, stop): briefly confirm. Everything else: just do it.
KEY PATTERN: When you need to explore files, find paths, or check repository structure → use the "patch" section endpoints to browse directories and read files. NEVER ask the user for file paths.
DATA MODEL: Project → Environment → Services (application, compose, postgres, mysql, redis, mongo, mariadb, libsql). Each service has deployments with build logs.
TOOL: You have one tool "call_api". Pass operationId + params from the catalog.
- Params: * = required, ? = optional, [a|b|c] = allowed values
- GET = read-only (auto-executed). POST/PUT/DELETE = write (user approves).
RESPONSE STYLE:
- 2-3 sentences max. No walls of text.
- Never explain limitations — find the right endpoint and act.
- Answer in the user's language.
${catalog ? `ENDPOINT CATALOG (${endpointCount} endpoints):\n${catalog}` : ""}`;
}
function getUserMessages(messages: any[]): string {
const texts: string[] = [];
for (const msg of messages) {
if (msg.role !== "user") continue;
if (typeof msg.content === "string") {
texts.push(msg.content);
} else if (Array.isArray(msg.content)) {
texts.push(
msg.content
.filter((p: any) => p.type === "text")
.map((p: any) => p.text)
.join(" "),
);
}
}
return texts.slice(-3).join(". ");
}
export default async function handler(
@@ -49,20 +139,40 @@ export default async function handler(
const body = req.body;
const messages = body.messages;
const aiId = body.aiId;
const context = (body.context as ChatContext) || { type: "general" as const, id: "" };
const context = (body.context as ChatContext) || {
type: "general" as const,
id: "",
};
if (!aiId || !messages) {
return res.status(400).json({ error: "Missing aiId or messages" });
// ─── Resolve model ────────────────────────────────────────
let model: any;
if (IS_CLOUD && process.env.CLOUD_ANTHROPIC_API_KEY) {
const anthropic = createAnthropic({
apiKey: process.env.CLOUD_ANTHROPIC_API_KEY,
});
model = anthropic("claude-haiku-4-5-20251001");
} else {
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);
model = provider(aiSettings.model);
}
const aiSettings = await getAiSettingById(aiId);
if (!aiSettings || !aiSettings.isEnabled) {
return res.status(400).json({ error: "AI provider not enabled" });
if (!messages) {
return res.status(400).json({ error: "Missing messages" });
}
const provider = selectAIProvider(aiSettings);
const model = provider(aiSettings.model);
// ─── Resolve tools ────────────────────────────────────────
const protocol = req.headers["x-forwarded-proto"] || "http";
const host = req.headers.host || "localhost:3000";
const toolConfig = {
@@ -70,35 +180,41 @@ export default async function handler(
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));
let tools: Record<string, any>;
let catalogText: string | null = null;
let endpointCount = 0;
const spec = getOpenApiSpec();
if (spec) {
const { catalog, count, operationIds } = getEndpointCatalog(spec, context.type);
catalogText = catalog;
endpointCount = count;
tools = createApiTool(spec, toolConfig, operationIds, 2000);
} else {
tools = getAllTools(context, toolConfig);
}
// ─── Stream response ──────────────────────────────────────
const modelMessages = await convertToModelMessages(messages);
const result = streamText({
model,
system: buildSystemPrompt(context),
system: buildSystemPrompt(context, catalogText, endpointCount),
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),
tools,
stopWhen: stepCountIs(12),
});
// Disable buffering for streaming
res.setHeader("X-Accel-Buffering", "no");
res.setHeader("Cache-Control", "no-cache, no-transform");
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",
error:
error instanceof Error ? error.message : "Internal server error",
});
}
}

View File

@@ -239,4 +239,43 @@ export const deploymentRouter = createTRPCRouter({
});
return result;
}),
readBuildLogs: protectedProcedure
.meta({
openapi: {
summary: "Read deployment build logs",
description:
"Reads the build/deployment log file for a specific deployment. Returns the last N lines (default 200). Works for both local and remote server deployments.",
},
})
.input(
z.object({
deploymentId: z.string().min(1),
tail: z.number().int().min(1).max(10000).default(200),
}),
)
.query(async ({ input, ctx }) => {
const deployment = await findDeploymentById(input.deploymentId);
const serviceId = deployment.applicationId || deployment.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
deployment: ["read"],
});
}
const command = `tail -n ${input.tail} ${deployment.logPath} 2>/dev/null || echo "Log file not found"`;
const { stdout } = deployment.serverId
? await execAsyncRemote(deployment.serverId, command)
: await execAsync(command);
return {
deploymentId: deployment.deploymentId,
status: deployment.status,
errorMessage: deployment.errorMessage || null,
title: deployment.title,
createdAt: deployment.createdAt,
logs: stdout,
};
}),
});