mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
54248
openapi.json
54248
openapi.json
File diff suppressed because it is too large
Load Diff
398
packages/server/src/utils/ai/api-tool.ts
Normal file
398
packages/server/src/utils/ai/api-tool.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
import { dynamicTool } from "ai";
|
||||
import { z } from "zod";
|
||||
import type { ChatContext } from "./chat-tools";
|
||||
|
||||
interface OpenApiSpec {
|
||||
paths: Record<
|
||||
string,
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
operationId?: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
deprecated?: boolean;
|
||||
parameters?: {
|
||||
name: string;
|
||||
in: string;
|
||||
required?: boolean;
|
||||
schema?: { type?: string };
|
||||
}[];
|
||||
requestBody?: {
|
||||
content: Record<
|
||||
string,
|
||||
{ schema: { properties?: Record<string, any>; required?: string[] } }
|
||||
>;
|
||||
};
|
||||
}
|
||||
>
|
||||
>;
|
||||
}
|
||||
|
||||
interface ToolConfig {
|
||||
baseUrl: string;
|
||||
cookie: string;
|
||||
}
|
||||
|
||||
interface EndpointInfo {
|
||||
method: string;
|
||||
path: string;
|
||||
operationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a compact one-line-per-endpoint catalog for the system prompt.
|
||||
* Example line: "application-deploy (POST, applicationId*, title?, description?) — Deploy an application"
|
||||
*/
|
||||
const EXCLUDED_TAGS = new Set([
|
||||
"notification",
|
||||
"sso",
|
||||
"stripe",
|
||||
"auditLog",
|
||||
"ai",
|
||||
"customRole",
|
||||
"whitelabeling",
|
||||
]);
|
||||
|
||||
/** Minimal shared tags — only project/environment for navigation */
|
||||
const SHARED_TAGS = ["project", "environment"];
|
||||
|
||||
/** Tags allowed per context type (on top of SHARED_TAGS) */
|
||||
const CONTEXT_TAGS: Record<ChatContext["type"], string[]> = {
|
||||
application: [
|
||||
"application",
|
||||
"deployment",
|
||||
"domain",
|
||||
"docker",
|
||||
"mounts",
|
||||
"port",
|
||||
"security",
|
||||
"redirects",
|
||||
"registry",
|
||||
"sshKey",
|
||||
"backup",
|
||||
"volumeBackups",
|
||||
"rollback",
|
||||
"schedule",
|
||||
"patch",
|
||||
"previewDeployment",
|
||||
"bitbucket",
|
||||
"gitea",
|
||||
"github",
|
||||
"gitlab",
|
||||
"gitProvider",
|
||||
"destination",
|
||||
"tag",
|
||||
],
|
||||
compose: [
|
||||
"compose",
|
||||
"deployment",
|
||||
"domain",
|
||||
"docker",
|
||||
"backup",
|
||||
"patch",
|
||||
"sshKey",
|
||||
"bitbucket",
|
||||
"gitea",
|
||||
"github",
|
||||
"gitlab",
|
||||
"gitProvider",
|
||||
"tag",
|
||||
],
|
||||
postgres: ["postgres", "backup", "docker", "destination"],
|
||||
mysql: ["mysql", "backup", "docker", "destination"],
|
||||
redis: ["redis", "docker"],
|
||||
mongo: ["mongo", "backup", "docker", "destination"],
|
||||
mariadb: ["mariadb", "backup", "docker", "destination"],
|
||||
libsql: ["libsql", "docker"],
|
||||
project: [
|
||||
"application",
|
||||
"compose",
|
||||
"postgres",
|
||||
"mysql",
|
||||
"redis",
|
||||
"mongo",
|
||||
"mariadb",
|
||||
"libsql",
|
||||
"domain",
|
||||
"deployment",
|
||||
"docker",
|
||||
"tag",
|
||||
],
|
||||
server: [
|
||||
"server",
|
||||
"docker",
|
||||
"cluster",
|
||||
"swarm",
|
||||
"certificates",
|
||||
"registry",
|
||||
"settings",
|
||||
],
|
||||
general: [], // empty = allow all non-excluded tags
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the set of allowed tags for a given context type.
|
||||
* Returns null for "general" context (no filtering, allow all).
|
||||
*/
|
||||
function getAllowedTags(contextType: ChatContext["type"]): Set<string> | null {
|
||||
if (contextType === "general") return null;
|
||||
const contextSpecific = CONTEXT_TAGS[contextType];
|
||||
return new Set([...SHARED_TAGS, ...contextSpecific]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract enum values from a JSON Schema property (handles anyOf wrappers).
|
||||
*/
|
||||
function extractEnum(prop: any): string[] | null {
|
||||
if (prop?.enum) return prop.enum;
|
||||
if (Array.isArray(prop?.anyOf)) {
|
||||
for (const variant of prop.anyOf) {
|
||||
if (variant?.enum) return variant.enum;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Human-readable description for each tag group in the catalog */
|
||||
const TAG_DESCRIPTIONS: Record<string, string> = {
|
||||
application: "Manage application services — create, update, deploy, start, stop, and configure applications",
|
||||
compose: "Manage Docker Compose/Stack services — create, update, deploy, and configure compose files",
|
||||
postgres: "Manage PostgreSQL database services",
|
||||
mysql: "Manage MySQL database services",
|
||||
redis: "Manage Redis database services",
|
||||
mongo: "Manage MongoDB database services",
|
||||
mariadb: "Manage MariaDB database services",
|
||||
libsql: "Manage LibSQL database services",
|
||||
deployment: "View deployment history, build logs, and manage deployment lifecycle",
|
||||
domain: "Manage domains, SSL certificates, and routing for services",
|
||||
docker: "Interact with Docker containers — inspect, restart, remove, and view logs",
|
||||
backup: "Create and manage database backups, run manual backups, and restore from backups",
|
||||
patch: "Browse and modify source code files in a service's cloned repository — read directories, read files, and create file patches",
|
||||
mounts: "Manage persistent volume mounts for services",
|
||||
port: "Manage exposed port mappings for services",
|
||||
security: "Manage HTTP basic auth security rules for services",
|
||||
redirects: "Manage HTTP redirect rules for domains",
|
||||
registry: "Manage Docker registries for pulling private images",
|
||||
sshKey: "Manage SSH keys for Git repository access",
|
||||
rollback: "Rollback a service to a previous deployment",
|
||||
schedule: "Create and manage scheduled tasks (cron jobs) for services",
|
||||
previewDeployment: "Manage preview deployments for pull requests",
|
||||
volumeBackups: "Create and manage volume-level backups and restores",
|
||||
project: "Manage projects — create, update, delete, and list projects",
|
||||
environment: "Manage environments within projects — create, duplicate, and configure",
|
||||
server: "Manage servers — configure, monitor, and connect remote servers",
|
||||
settings: "View and update global Dokploy settings",
|
||||
destination: "Manage S3/storage destinations for backups",
|
||||
tag: "Manage tags for organizing and labeling services",
|
||||
cluster: "Manage Docker Swarm cluster nodes",
|
||||
swarm: "Manage Docker Swarm settings and configuration",
|
||||
certificates: "Manage SSL/TLS certificates",
|
||||
gitProvider: "Manage Git provider integrations",
|
||||
github: "Manage GitHub provider connections and repositories",
|
||||
gitlab: "Manage GitLab provider connections and repositories",
|
||||
bitbucket: "Manage Bitbucket provider connections and repositories",
|
||||
gitea: "Manage Gitea provider connections and repositories",
|
||||
user: "Manage user accounts and permissions",
|
||||
};
|
||||
|
||||
export interface CatalogResult {
|
||||
catalog: string;
|
||||
count: number;
|
||||
operationIds: Set<string>;
|
||||
}
|
||||
|
||||
export function buildEndpointCatalog(
|
||||
spec: OpenApiSpec,
|
||||
contextType: ChatContext["type"] = "general",
|
||||
): CatalogResult {
|
||||
const operationIds = new Set<string>();
|
||||
const allowedTags = getAllowedTags(contextType);
|
||||
const groups = new Map<string, string[]>();
|
||||
|
||||
for (const methods of Object.values(spec.paths)) {
|
||||
for (const op of Object.values(methods)) {
|
||||
if (!op.operationId || op.deprecated) continue;
|
||||
if (op.tags?.some((t) => EXCLUDED_TAGS.has(t))) continue;
|
||||
if (allowedTags && !op.tags?.some((t) => allowedTags.has(t))) continue;
|
||||
|
||||
operationIds.add(op.operationId);
|
||||
|
||||
const requiredParams: string[] = [];
|
||||
const optionalParams: string[] = [];
|
||||
|
||||
if (op.parameters) {
|
||||
for (const p of op.parameters) {
|
||||
if (p.in === "header") continue;
|
||||
if (p.required) {
|
||||
requiredParams.push(`${p.name}*`);
|
||||
} else {
|
||||
optionalParams.push(`${p.name}?`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (op.requestBody?.content?.["application/json"]?.schema) {
|
||||
const schema = op.requestBody.content["application/json"].schema;
|
||||
const requiredSet = new Set(schema.required ?? []);
|
||||
if (schema.properties) {
|
||||
for (const [key, prop] of Object.entries(
|
||||
schema.properties as Record<string, any>,
|
||||
)) {
|
||||
const enumVals = extractEnum(prop);
|
||||
const suffix = enumVals
|
||||
? `[${enumVals.join("|")}]`
|
||||
: "";
|
||||
if (requiredSet.has(key)) {
|
||||
requiredParams.push(`${key}*${suffix}`);
|
||||
} else {
|
||||
optionalParams.push(`${key}?${suffix}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allParams = [...requiredParams, ...optionalParams];
|
||||
const paramStr =
|
||||
allParams.length > 0 ? `(${allParams.join(", ")})` : "";
|
||||
const summary = op.summary ? ` — ${op.summary}` : "";
|
||||
const desc = op.description ? `\n ${op.description}` : "";
|
||||
const line = `${op.operationId}${paramStr}${summary}${desc}`;
|
||||
|
||||
const tag = op.tags?.[0] ?? "other";
|
||||
if (!groups.has(tag)) groups.set(tag, []);
|
||||
groups.get(tag)!.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Order sections: context-specific tags first (in CONTEXT_TAGS order), then shared, then rest
|
||||
const contextOrder = CONTEXT_TAGS[contextType];
|
||||
const sharedOrder = SHARED_TAGS;
|
||||
const orderedTags: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const t of contextOrder) {
|
||||
if (groups.has(t) && !seen.has(t)) { orderedTags.push(t); seen.add(t); }
|
||||
}
|
||||
for (const t of sharedOrder) {
|
||||
if (groups.has(t) && !seen.has(t)) { orderedTags.push(t); seen.add(t); }
|
||||
}
|
||||
for (const t of groups.keys()) {
|
||||
if (!seen.has(t)) { orderedTags.push(t); seen.add(t); }
|
||||
}
|
||||
|
||||
const sections: string[] = [];
|
||||
for (const tag of orderedTags) {
|
||||
const lines = groups.get(tag)!;
|
||||
const tagDesc = TAG_DESCRIPTIONS[tag];
|
||||
const header = tagDesc ? `## ${tag} — ${tagDesc}` : `## ${tag}`;
|
||||
sections.push(`${header}\n${lines.join("\n")}`);
|
||||
}
|
||||
|
||||
return {
|
||||
catalog: sections.join("\n\n"),
|
||||
count: operationIds.size,
|
||||
operationIds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a lookup map from operationId to endpoint info for execution.
|
||||
*/
|
||||
function buildEndpointMap(
|
||||
spec: OpenApiSpec,
|
||||
): Map<string, EndpointInfo> {
|
||||
const map = new Map<string, EndpointInfo>();
|
||||
for (const [path, methods] of Object.entries(spec.paths)) {
|
||||
for (const [method, op] of Object.entries(methods)) {
|
||||
if (!op.operationId) continue;
|
||||
map.set(op.operationId, { method, path, operationId: op.operationId });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a single "call_api" tool that only allows endpoints present in allowedOperationIds.
|
||||
*/
|
||||
export function createApiTool(
|
||||
spec: OpenApiSpec,
|
||||
config: ToolConfig,
|
||||
allowedOperationIds?: Set<string>,
|
||||
maxResponseSize = 4000,
|
||||
) {
|
||||
const endpointMap = buildEndpointMap(spec);
|
||||
|
||||
return {
|
||||
call_api: dynamicTool({
|
||||
description:
|
||||
"Call a Dokploy API endpoint. Use the operationId from the endpoint catalog and pass the required parameters.",
|
||||
inputSchema: z.object({
|
||||
operationId: z
|
||||
.string()
|
||||
.describe("The operationId from the endpoint catalog"),
|
||||
params: z
|
||||
.record(z.string(), z.any())
|
||||
.optional()
|
||||
.describe("Parameters for the endpoint (* = required)"),
|
||||
}),
|
||||
execute: async (rawInput: unknown) => {
|
||||
const { operationId, params } = rawInput as {
|
||||
operationId: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
if (allowedOperationIds && !allowedOperationIds.has(operationId)) {
|
||||
return `Error: "${operationId}" is not available in the current context. Only use operationIds from the ENDPOINT CATALOG.`;
|
||||
}
|
||||
|
||||
const endpoint = endpointMap.get(operationId);
|
||||
if (!endpoint) {
|
||||
return `Error: Unknown endpoint "${operationId}". Check the endpoint catalog for valid operationIds.`;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: config.cookie,
|
||||
};
|
||||
|
||||
let url = `${config.baseUrl}${endpoint.path}`;
|
||||
|
||||
if (endpoint.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}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: endpoint.method.toUpperCase(),
|
||||
headers,
|
||||
...(endpoint.method !== "get" && params
|
||||
? { body: JSON.stringify(params) }
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return `API error (${response.status}): ${errorText.slice(0, 500)}`;
|
||||
}
|
||||
|
||||
const json = JSON.stringify(await response.json(), null, 2);
|
||||
if (json.length > maxResponseSize) {
|
||||
return `${json.slice(0, maxResponseSize)}\n\n... [Truncated — ${json.length} chars total]`;
|
||||
}
|
||||
return json;
|
||||
} catch (error) {
|
||||
return `Error: ${error instanceof Error ? error.message : "Unknown error"}`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -3,9 +3,22 @@ import { dynamicTool } from "ai";
|
||||
import { z } from "zod";
|
||||
import { findDeploymentById } from "../../services/deployment";
|
||||
|
||||
export type ServiceType =
|
||||
| "application"
|
||||
| "compose"
|
||||
| "postgres"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "mongo"
|
||||
| "mariadb"
|
||||
| "libsql";
|
||||
|
||||
export interface ChatContext {
|
||||
type: "application" | "compose" | "project" | "server" | "general";
|
||||
type: ServiceType | "project" | "server" | "general";
|
||||
id: string;
|
||||
projectId?: string;
|
||||
environmentId?: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
interface ToolConfig {
|
||||
|
||||
317
packages/server/src/utils/ai/openapi-tools.ts
Normal file
317
packages/server/src/utils/ai/openapi-tools.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { dynamicTool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Converts an OpenAPI spec into AI SDK tool definitions automatically.
|
||||
*
|
||||
* Each endpoint becomes a tool that the agent can call. The tool name
|
||||
* is the operationId, the description comes from the endpoint's
|
||||
* summary/description, and the input schema is derived from the
|
||||
* request body or query parameters.
|
||||
*/
|
||||
|
||||
interface OpenApiSpec {
|
||||
paths: Record<string, Record<string, OpenApiOperation>>;
|
||||
}
|
||||
|
||||
interface OpenApiOperation {
|
||||
operationId: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
deprecated?: boolean;
|
||||
parameters?: OpenApiParameter[];
|
||||
requestBody?: {
|
||||
required?: boolean;
|
||||
content: Record<
|
||||
string,
|
||||
{
|
||||
schema: JsonSchema;
|
||||
}
|
||||
>;
|
||||
};
|
||||
}
|
||||
|
||||
interface OpenApiParameter {
|
||||
name: string;
|
||||
in: "query" | "path" | "header";
|
||||
required?: boolean;
|
||||
schema: JsonSchema;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface JsonSchema {
|
||||
type?: string;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
required?: string[];
|
||||
items?: JsonSchema;
|
||||
enum?: unknown[];
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
nullable?: boolean;
|
||||
anyOf?: JsonSchema[];
|
||||
oneOf?: JsonSchema[];
|
||||
allOf?: JsonSchema[];
|
||||
format?: string;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
interface ToolConfig {
|
||||
baseUrl: string;
|
||||
cookie: string;
|
||||
}
|
||||
|
||||
interface GenerateToolsOptions {
|
||||
/** Only include tools whose tag matches one of these */
|
||||
tags?: string[];
|
||||
/** Only include these specific operationIds */
|
||||
operationIds?: string[];
|
||||
/** Exclude these operationIds */
|
||||
exclude?: string[];
|
||||
/** Max response size in chars before truncating (default: 15000) */
|
||||
maxResponseSize?: number;
|
||||
}
|
||||
|
||||
// ─── JSON Schema → Zod conversion ──────────────────────────────
|
||||
|
||||
function jsonSchemaToZod(schema: JsonSchema): z.ZodTypeAny {
|
||||
if (!schema || !schema.type) {
|
||||
// anyOf / oneOf / allOf — just accept anything
|
||||
if (schema?.anyOf || schema?.oneOf || schema?.allOf) {
|
||||
return z.any();
|
||||
}
|
||||
return z.any();
|
||||
}
|
||||
|
||||
switch (schema.type) {
|
||||
case "string": {
|
||||
let s = z.string();
|
||||
if (schema.enum) {
|
||||
return z.enum(schema.enum as [string, ...string[]]);
|
||||
}
|
||||
if (schema.minLength) s = s.min(schema.minLength);
|
||||
if (schema.maxLength) s = s.max(schema.maxLength);
|
||||
if (schema.description) s = s.describe(schema.description);
|
||||
return s;
|
||||
}
|
||||
case "number":
|
||||
case "integer": {
|
||||
let n = z.number();
|
||||
if (schema.minimum !== undefined) n = n.min(schema.minimum);
|
||||
if (schema.maximum !== undefined) n = n.max(schema.maximum);
|
||||
if (schema.description) n = n.describe(schema.description);
|
||||
return n;
|
||||
}
|
||||
case "boolean":
|
||||
return z.boolean();
|
||||
case "array": {
|
||||
const itemSchema = schema.items
|
||||
? jsonSchemaToZod(schema.items)
|
||||
: z.any();
|
||||
return z.array(itemSchema);
|
||||
}
|
||||
case "object": {
|
||||
if (!schema.properties) {
|
||||
return z.object({});
|
||||
}
|
||||
const shape: Record<string, z.ZodTypeAny> = {};
|
||||
const required = new Set(schema.required ?? []);
|
||||
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
||||
const zodProp = jsonSchemaToZod(propSchema);
|
||||
shape[key] = required.has(key) ? zodProp : zodProp.optional();
|
||||
}
|
||||
return z.object(shape);
|
||||
}
|
||||
default:
|
||||
return z.any();
|
||||
}
|
||||
}
|
||||
|
||||
function buildInputSchema(
|
||||
operation: OpenApiOperation,
|
||||
): z.ZodObject<z.ZodRawShape> {
|
||||
const shape: Record<string, z.ZodTypeAny> = {};
|
||||
|
||||
// Query/path parameters → flat keys
|
||||
if (operation.parameters) {
|
||||
for (const param of operation.parameters) {
|
||||
if (param.in === "header") continue;
|
||||
const zodParam = jsonSchemaToZod(param.schema);
|
||||
const described = param.description
|
||||
? zodParam.describe(param.description)
|
||||
: zodParam;
|
||||
shape[param.name] = param.required ? described : described.optional();
|
||||
}
|
||||
}
|
||||
|
||||
// Request body → merge properties into the same object
|
||||
if (operation.requestBody) {
|
||||
const content = operation.requestBody.content;
|
||||
const jsonContent = content["application/json"];
|
||||
if (jsonContent?.schema) {
|
||||
const bodySchema = jsonContent.schema;
|
||||
if (bodySchema.properties) {
|
||||
const required = new Set(bodySchema.required ?? []);
|
||||
for (const [key, propSchema] of Object.entries(
|
||||
bodySchema.properties,
|
||||
)) {
|
||||
const zodProp = jsonSchemaToZod(propSchema);
|
||||
shape[key] = required.has(key) ? zodProp : zodProp.optional();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return z.object(shape);
|
||||
}
|
||||
|
||||
// ─── API caller ─────────────────────────────────────────────────
|
||||
|
||||
async function callApi(
|
||||
config: ToolConfig,
|
||||
method: string,
|
||||
path: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
maxResponseSize: number,
|
||||
): Promise<string> {
|
||||
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: method.toUpperCase(),
|
||||
headers,
|
||||
...(method !== "get" && params
|
||||
? { body: JSON.stringify(params) }
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`API error (${response.status}): ${errorText.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = JSON.stringify(await response.json(), null, 2);
|
||||
if (json.length > maxResponseSize) {
|
||||
return `${json.slice(0, maxResponseSize)}\n\n... [Truncated — ${json.length} chars total]`;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
// ─── Main conversion ────────────────────────────────────────────
|
||||
|
||||
export function openApiToTools(
|
||||
spec: OpenApiSpec,
|
||||
config: ToolConfig,
|
||||
options: GenerateToolsOptions = {},
|
||||
) {
|
||||
const {
|
||||
tags,
|
||||
operationIds,
|
||||
exclude,
|
||||
maxResponseSize = 15000,
|
||||
} = options;
|
||||
|
||||
const tagSet = tags ? new Set(tags) : null;
|
||||
const idSet = operationIds ? new Set(operationIds) : null;
|
||||
const excludeSet = exclude ? new Set(exclude) : null;
|
||||
const tools: Record<string, ReturnType<typeof dynamicTool>> = {};
|
||||
|
||||
for (const [path, methods] of Object.entries(spec.paths)) {
|
||||
for (const [method, operation] of Object.entries(methods)) {
|
||||
const opId = operation.operationId;
|
||||
if (!opId) continue;
|
||||
if (operation.deprecated) continue;
|
||||
|
||||
// Filtering
|
||||
if (excludeSet?.has(opId)) continue;
|
||||
if (idSet && !idSet.has(opId)) continue;
|
||||
if (tagSet && !operation.tags?.some((t) => tagSet.has(t))) continue;
|
||||
|
||||
const description = [operation.summary, operation.description]
|
||||
.filter(Boolean)
|
||||
.join(". ");
|
||||
|
||||
const inputSchema = buildInputSchema(operation);
|
||||
|
||||
const isWriteAction = method !== "get";
|
||||
|
||||
tools[opId] = dynamicTool({
|
||||
description: description || `Call ${method.toUpperCase()} ${path}`,
|
||||
inputSchema,
|
||||
needsApproval: isWriteAction,
|
||||
execute: async (rawInput: unknown) => {
|
||||
const input = (rawInput ?? {}) as Record<string, unknown>;
|
||||
const params =
|
||||
Object.keys(input).length > 0 ? input : undefined;
|
||||
return callApi(config, method, path, params, maxResponseSize);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a summary of all available tools (name + description).
|
||||
* Useful for debugging or for the system prompt.
|
||||
*/
|
||||
export function getToolsSummary(
|
||||
spec: OpenApiSpec,
|
||||
options: GenerateToolsOptions = {},
|
||||
): { name: string; description: string; tag: string; method: string }[] {
|
||||
const { tags, operationIds, exclude } = options;
|
||||
|
||||
const tagSet = tags ? new Set(tags) : null;
|
||||
const idSet = operationIds ? new Set(operationIds) : null;
|
||||
const excludeSet = exclude ? new Set(exclude) : null;
|
||||
const summary: {
|
||||
name: string;
|
||||
description: string;
|
||||
tag: string;
|
||||
method: string;
|
||||
}[] = [];
|
||||
|
||||
for (const [_path, methods] of Object.entries(spec.paths)) {
|
||||
for (const [method, operation] of Object.entries(methods)) {
|
||||
const opId = operation.operationId;
|
||||
if (!opId) continue;
|
||||
if (operation.deprecated) continue;
|
||||
if (excludeSet?.has(opId)) continue;
|
||||
if (idSet && !idSet.has(opId)) continue;
|
||||
if (tagSet && !operation.tags?.some((t) => tagSet.has(t))) continue;
|
||||
|
||||
summary.push({
|
||||
name: opId,
|
||||
description:
|
||||
[operation.summary, operation.description]
|
||||
.filter(Boolean)
|
||||
.join(". ") || "",
|
||||
tag: operation.tags?.[0] ?? "default",
|
||||
method: method.toUpperCase(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
56
pnpm-lock.yaml
generated
56
pnpm-lock.yaml
generated
@@ -118,10 +118,10 @@ importers:
|
||||
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))
|
||||
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(219be630f7f6fef2e235cef94eaddc25))
|
||||
'@better-auth/sso':
|
||||
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))(better-call@2.0.2(zod@4.3.6))
|
||||
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(219be630f7f6fef2e235cef94eaddc25))(better-call@2.0.2(zod@4.3.6))
|
||||
'@codemirror/autocomplete':
|
||||
specifier: ^6.18.6
|
||||
version: 6.20.0
|
||||
@@ -280,7 +280,7 @@ importers:
|
||||
version: 5.1.1
|
||||
better-auth:
|
||||
specifier: 1.5.4
|
||||
version: 1.5.4(febde88eaf587188179e6ecc47119e50)
|
||||
version: 1.5.4(219be630f7f6fef2e235cef94eaddc25)
|
||||
bl:
|
||||
specifier: 6.0.11
|
||||
version: 6.0.11
|
||||
@@ -539,10 +539,10 @@ importers:
|
||||
version: 5.9.3
|
||||
vite-tsconfig-paths:
|
||||
specifier: 4.3.2
|
||||
version: 4.3.2(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1))
|
||||
version: 4.3.2(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1))
|
||||
vitest:
|
||||
specifier: ^4.0.18
|
||||
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)
|
||||
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)
|
||||
|
||||
apps/schedules:
|
||||
dependencies:
|
||||
@@ -630,10 +630,10 @@ importers:
|
||||
version: 2.0.30(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(334901c35c1fcda64bb596793b2e4934))
|
||||
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(8933545d763d3f096150f97f9213a424))
|
||||
'@better-auth/sso':
|
||||
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(334901c35c1fcda64bb596793b2e4934))(better-call@2.0.2(zod@4.3.6))
|
||||
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(8933545d763d3f096150f97f9213a424))(better-call@2.0.2(zod@4.3.6))
|
||||
'@better-auth/utils':
|
||||
specifier: 0.3.1
|
||||
version: 0.3.1
|
||||
@@ -672,7 +672,7 @@ importers:
|
||||
version: 5.1.1
|
||||
better-auth:
|
||||
specifier: 1.5.4
|
||||
version: 1.5.4(334901c35c1fcda64bb596793b2e4934)
|
||||
version: 1.5.4(8933545d763d3f096150f97f9213a424)
|
||||
better-call:
|
||||
specifier: 2.0.2
|
||||
version: 2.0.2(zod@4.3.6)
|
||||
@@ -775,7 +775,7 @@ importers:
|
||||
devDependencies:
|
||||
'@better-auth/cli':
|
||||
specifier: 1.4.21
|
||||
version: 1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(drizzle-kit@0.31.9)(jose@6.1.3)(kysely@0.28.11)(mongodb@7.1.0)(mysql2@3.15.3)(nanostores@1.1.1)(next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1))
|
||||
version: 1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(drizzle-kit@0.31.9)(jose@6.1.3)(kysely@0.28.11)(mongodb@7.1.0)(mysql2@3.15.3)(nanostores@1.1.1)(next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1))
|
||||
'@types/adm-zip':
|
||||
specifier: ^0.5.7
|
||||
version: 0.5.7
|
||||
@@ -8661,21 +8661,21 @@ snapshots:
|
||||
|
||||
'@balena/dockerignore@1.0.2': {}
|
||||
|
||||
'@better-auth/api-key@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(334901c35c1fcda64bb596793b2e4934))':
|
||||
'@better-auth/api-key@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(219be630f7f6fef2e235cef94eaddc25))':
|
||||
dependencies:
|
||||
'@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(334901c35c1fcda64bb596793b2e4934)
|
||||
better-auth: 1.5.4(219be630f7f6fef2e235cef94eaddc25)
|
||||
zod: 4.3.6
|
||||
|
||||
'@better-auth/api-key@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))':
|
||||
'@better-auth/api-key@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(8933545d763d3f096150f97f9213a424))':
|
||||
dependencies:
|
||||
'@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)
|
||||
better-auth: 1.5.4(8933545d763d3f096150f97f9213a424)
|
||||
zod: 4.3.6
|
||||
|
||||
'@better-auth/cli@1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(drizzle-kit@0.31.9)(jose@6.1.3)(kysely@0.28.11)(mongodb@7.1.0)(mysql2@3.15.3)(nanostores@1.1.1)(next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1))':
|
||||
'@better-auth/cli@1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(better-call@2.0.2(zod@4.3.6))(drizzle-kit@0.31.9)(jose@6.1.3)(kysely@0.28.11)(mongodb@7.1.0)(mysql2@3.15.3)(nanostores@1.1.1)(next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/preset-react': 7.28.5(@babel/core@7.29.0)
|
||||
@@ -8687,7 +8687,7 @@ snapshots:
|
||||
'@mrleebo/prisma-ast': 0.13.1
|
||||
'@prisma/client': 5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3))
|
||||
'@types/pg': 8.16.0
|
||||
better-auth: 1.4.21(b1bc00b9e18c5e6af4e13b03fc4304ac)
|
||||
better-auth: 1.4.21(db78b83f9b5449d160708cdf9d272aa3)
|
||||
better-sqlite3: 12.6.2
|
||||
c12: 3.3.3
|
||||
chalk: 5.6.2
|
||||
@@ -8821,24 +8821,24 @@ snapshots:
|
||||
'@prisma/client': 5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3))
|
||||
prisma: 7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)
|
||||
|
||||
'@better-auth/sso@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(334901c35c1fcda64bb596793b2e4934))(better-call@2.0.2(zod@4.3.6))':
|
||||
'@better-auth/sso@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(219be630f7f6fef2e235cef94eaddc25))(better-call@2.0.2(zod@4.3.6))':
|
||||
dependencies:
|
||||
'@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-fetch/fetch': 1.1.21
|
||||
better-auth: 1.5.4(334901c35c1fcda64bb596793b2e4934)
|
||||
better-auth: 1.5.4(219be630f7f6fef2e235cef94eaddc25)
|
||||
better-call: 2.0.2(zod@4.3.6)
|
||||
fast-xml-parser: 5.5.1
|
||||
jose: 6.1.3
|
||||
samlify: 2.10.2
|
||||
zod: 4.3.6
|
||||
|
||||
'@better-auth/sso@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))(better-call@2.0.2(zod@4.3.6))':
|
||||
'@better-auth/sso@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(8933545d763d3f096150f97f9213a424))(better-call@2.0.2(zod@4.3.6))':
|
||||
dependencies:
|
||||
'@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-fetch/fetch': 1.1.21
|
||||
better-auth: 1.5.4(febde88eaf587188179e6ecc47119e50)
|
||||
better-auth: 1.5.4(8933545d763d3f096150f97f9213a424)
|
||||
better-call: 2.0.2(zod@4.3.6)
|
||||
fast-xml-parser: 5.5.1
|
||||
jose: 6.1.3
|
||||
@@ -12304,6 +12304,7 @@ snapshots:
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)
|
||||
optional: true
|
||||
|
||||
'@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1))':
|
||||
dependencies:
|
||||
@@ -12312,7 +12313,6 @@ snapshots:
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@4.0.18':
|
||||
dependencies:
|
||||
@@ -12525,7 +12525,7 @@ snapshots:
|
||||
|
||||
before-after-hook@2.2.3: {}
|
||||
|
||||
better-auth@1.4.21(b1bc00b9e18c5e6af4e13b03fc4304ac):
|
||||
better-auth@1.4.21(db78b83f9b5449d160708cdf9d272aa3):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
|
||||
'@better-auth/telemetry': 1.4.21(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@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))
|
||||
@@ -12551,9 +12551,9 @@ snapshots:
|
||||
prisma: 7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)
|
||||
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)
|
||||
|
||||
better-auth@1.5.4(334901c35c1fcda64bb596793b2e4934):
|
||||
better-auth@1.5.4(219be630f7f6fef2e235cef94eaddc25):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
|
||||
'@better-auth/drizzle-adapter': 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)(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.18.0)(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)))
|
||||
@@ -12588,7 +12588,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@cloudflare/workers-types'
|
||||
|
||||
better-auth@1.5.4(febde88eaf587188179e6ecc47119e50):
|
||||
better-auth@1.5.4(8933545d763d3f096150f97f9213a424):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.4(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
|
||||
'@better-auth/drizzle-adapter': 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)(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.18.0)(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)))
|
||||
@@ -16354,13 +16354,13 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite-tsconfig-paths@4.3.2(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)):
|
||||
vite-tsconfig-paths@4.3.2(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
globrex: 0.1.2
|
||||
tsconfck: 3.1.6(typescript@5.9.3)
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)
|
||||
vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- typescript
|
||||
@@ -16379,6 +16379,7 @@ snapshots:
|
||||
jiti: 1.21.7
|
||||
tsx: 4.16.2
|
||||
yaml: 2.8.1
|
||||
optional: true
|
||||
|
||||
vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1):
|
||||
dependencies:
|
||||
@@ -16394,7 +16395,6 @@ snapshots:
|
||||
jiti: 2.6.1
|
||||
tsx: 4.16.2
|
||||
yaml: 2.8.1
|
||||
optional: true
|
||||
|
||||
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1):
|
||||
dependencies:
|
||||
@@ -16433,6 +16433,7 @@ snapshots:
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
optional: true
|
||||
|
||||
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1):
|
||||
dependencies:
|
||||
@@ -16471,7 +16472,6 @@ snapshots:
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
optional: true
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user