Commit inicial - upload de todos os arquivos da pasta

This commit is contained in:
2026-01-04 16:14:31 -03:00
commit e3ed1a734b
310 changed files with 62749 additions and 0 deletions

View File

@@ -0,0 +1,506 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/AgentCard.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Folder } from "@/services/agentService";
import { Agent, AgentType } from "@/types/agent";
import { MCPServer } from "@/types/mcpServer";
import {
ArrowRight,
Bot,
BookOpenCheck,
ChevronDown,
ChevronUp,
Code,
ExternalLink,
GitBranch,
MoveRight,
Pencil,
RefreshCw,
Settings,
Share2,
Trash2,
Workflow,
TextSelect,
Download,
FlaskConical,
} from "lucide-react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import { exportAsJson } from "@/lib/utils";
interface AgentCardProps {
agent: Agent;
onEdit: (agent: Agent) => void;
onDelete: (agent: Agent) => void;
onMove: (agent: Agent) => void;
onShare?: (agent: Agent) => void;
onWorkflow?: (agentId: string) => void;
availableMCPs?: MCPServer[];
getApiKeyNameById?: (id: string | undefined) => string | null;
getAgentNameById?: (id: string) => string;
folders?: Folder[];
agents: Agent[];
}
export function AgentCard({
agent,
onEdit,
onDelete,
onMove,
onShare,
onWorkflow,
availableMCPs = [],
getApiKeyNameById = () => null,
getAgentNameById = (id) => id,
folders = [],
agents,
}: AgentCardProps) {
const [expanded, setExpanded] = useState(false);
const router = useRouter();
const getAgentTypeInfo = (type: AgentType) => {
const types: Record<
string,
{
label: string;
icon: React.ElementType;
color: string;
bgColor: string;
badgeClass: string;
}
> = {
llm: {
label: "LLM Agent",
icon: Code,
color: "#00cc7d",
bgColor: "bg-green-500/10",
badgeClass:
"bg-green-900/30 text-green-400 border-green-600/30 hover:bg-green-900/40",
},
a2a: {
label: "A2A Agent",
icon: ExternalLink,
color: "#6366f1",
bgColor: "bg-indigo-500/10",
badgeClass:
"bg-indigo-900/30 text-indigo-400 border-indigo-600/30 hover:bg-indigo-900/40",
},
sequential: {
label: "Sequential Agent",
icon: ArrowRight,
color: "#f59e0b",
bgColor: "bg-yellow-500/10",
badgeClass:
"bg-yellow-900/30 text-yellow-400 border-yellow-600/30 hover:bg-yellow-900/40",
},
parallel: {
label: "Parallel Agent",
icon: GitBranch,
color: "#8b5cf6",
bgColor: "bg-purple-500/10",
badgeClass:
"bg-purple-900/30 text-purple-400 border-purple-600/30 hover:bg-purple-900/40",
},
loop: {
label: "Loop Agent",
icon: RefreshCw,
color: "#ec4899",
bgColor: "bg-pink-500/10",
badgeClass:
"bg-orange-900/30 text-orange-400 border-orange-600/30 hover:bg-orange-900/40",
},
workflow: {
label: "Workflow Agent",
icon: Workflow,
color: "#3b82f6",
bgColor: "bg-blue-500/10",
badgeClass:
"bg-blue-900/30 text-blue-400 border-blue-700/40 hover:bg-blue-900/40",
},
task: {
label: "Task Agent",
icon: BookOpenCheck,
color: "#ef4444",
bgColor: "bg-red-500/10",
badgeClass:
"bg-red-900/30 text-red-400 border-red-600/30 hover:bg-red-900/40",
},
};
return (
types[type] || {
label: type,
icon: Bot,
color: "#94a3b8",
bgColor: "bg-slate-500/10",
badgeClass:
"bg-slate-900/30 text-slate-400 border-slate-600/30 hover:bg-slate-900/40",
}
);
};
const getAgentTypeIcon = (type: AgentType) => {
const typeInfo = getAgentTypeInfo(type);
const IconComponent = typeInfo.icon;
return (
<IconComponent className="h-5 w-5" style={{ color: typeInfo.color }} />
);
};
const getAgentTypeName = (type: AgentType) => {
return getAgentTypeInfo(type).label;
};
const getAgentTypeBgColor = (type: AgentType) => {
return getAgentTypeInfo(type).bgColor;
};
const getAgentTypeBadgeClass = (type: AgentType) => {
return getAgentTypeInfo(type).badgeClass;
};
const getFolderNameById = (id: string) => {
const folder = folders?.find((f) => f.id === id);
return folder?.name || id;
};
const getTotalTools = () => {
if (agent.type === "llm" && agent.config?.mcp_servers) {
return agent.config.mcp_servers.reduce(
(total, mcp) => total + (mcp.tools?.length || 0),
0
);
}
return 0;
};
const getCreatedAtFormatted = () => {
return new Date(agent.created_at).toLocaleDateString();
};
// Function to export the agent as JSON
const handleExportAgent = () => {
try {
exportAsJson(
agent,
`agent-${agent.name
.replace(/\s+/g, "-")
.toLowerCase()}-${agent.id.substring(0, 8)}`,
true,
agents
);
} catch (error) {
console.error("Error exporting agent:", error);
}
};
// Function to test the A2A agent in the lab
const handleTestA2A = () => {
// Use the agent card URL as base for A2A tests
const agentUrl = agent.agent_card_url?.replace(
"/.well-known/agent.json",
""
);
// Use the API key directly from the agent config
const apiKey = agent.config?.api_key;
// Build the URL with parameters for the lab tests
const params = new URLSearchParams();
if (agentUrl) {
params.set("agent_url", agentUrl);
}
if (apiKey) {
params.set("api_key", apiKey);
}
// Redirect to the lab tests in the "lab" tab
const testUrl = `/documentation?${params.toString()}#lab`;
router.push(testUrl);
};
return (
<Card className="w-full overflow-hidden border border-zinc-800 shadow-lg bg-gradient-to-br from-zinc-800 to-zinc-900">
<div
className={cn(
"p-4 flex justify-between items-center border-b border-zinc-800",
getAgentTypeBgColor(agent.type)
)}
>
<div className="flex items-center gap-2">
{getAgentTypeIcon(agent.type)}
<h3 className="font-medium text-white">{agent.name}</h3>
</div>
<Badge
variant="outline"
className={cn("border", getAgentTypeBadgeClass(agent.type))}
>
{getAgentTypeName(agent.type)}
</Badge>
</div>
<CardContent className="p-0">
<div className="p-4 border-b border-zinc-800">
<p className="text-sm text-zinc-300">
{agent.description && agent.description.length > 100
? `${agent.description.substring(0, 100)}...`
: agent.description}
</p>
</div>
<div
className={cn(
"p-4 flex justify-between items-center",
getAgentTypeBgColor(agent.type),
"bg-opacity-20"
)}
>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500">Model:</span>
<span className="text-xs font-medium text-zinc-300">
{agent.type === "llm" ? agent.model : "N/A"}
</span>
</div>
<Button
variant="ghost"
size="sm"
className={cn("p-0 h-auto", {
"text-green-400 hover:text-green-300": agent.type === "llm",
"text-indigo-400 hover:text-indigo-300": agent.type === "a2a",
"text-yellow-400 hover:text-yellow-300":
agent.type === "sequential",
"text-purple-400 hover:text-purple-300":
agent.type === "parallel",
"text-orange-400 hover:text-orange-300": agent.type === "loop",
"text-blue-400 hover:text-blue-300": agent.type === "workflow",
"text-red-400 hover:text-red-300": agent.type === "task",
"text-zinc-400 hover:text-white": ![
"llm",
"a2a",
"sequential",
"parallel",
"loop",
"workflow",
"task",
].includes(agent.type),
})}
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
<span className="ml-1 text-xs">{expanded ? "Less" : "More"}</span>
</Button>
</div>
{expanded && (
<div className="p-4 bg-zinc-950 text-xs space-y-3 animate-in fade-in-50 duration-200">
{agent.folder_id && (
<div className="flex justify-between items-center">
<span className="text-zinc-500">Folder:</span>
<Badge
variant="outline"
className={cn(
"h-5 px-2 bg-transparent",
getAgentTypeBadgeClass(agent.type)
)}
>
{getFolderNameById(agent.folder_id)}
</Badge>
</div>
)}
{agent.type === "llm" && agent.api_key_id && (
<div className="flex justify-between items-center">
<span className="text-zinc-500">API Key:</span>
<Badge
variant="outline"
className={cn(
"h-5 px-2 bg-transparent",
getAgentTypeBadgeClass(agent.type)
)}
>
{getApiKeyNameById(agent.api_key_id)}
</Badge>
</div>
)}
{getTotalTools() > 0 && (
<div className="flex justify-between items-center">
<span className="text-zinc-500">Tools:</span>
<span className="text-zinc-300">{getTotalTools()}</span>
</div>
)}
{agent.config?.sub_agents && agent.config.sub_agents.length > 0 && (
<div className="flex justify-between items-center">
<span className="text-zinc-500">Sub-agents:</span>
<span className="text-zinc-300">
{agent.config.sub_agents.length}
</span>
</div>
)}
{agent.type === "workflow" && agent.config?.workflow && (
<div className="flex justify-between items-center">
<span className="text-zinc-500">Elements:</span>
<span className="text-zinc-300">
{agent.config.workflow.nodes?.length || 0} nodes,{" "}
{agent.config.workflow.edges?.length || 0} connections
</span>
</div>
)}
<div className="flex justify-between items-center">
<span className="text-zinc-500">Created at:</span>
<span className="text-zinc-300">{getCreatedAtFormatted()}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-zinc-500">ID:</span>
<span className="text-zinc-300 text-[10px]">{agent.id}</span>
</div>
</div>
)}
<div className="flex border-t border-zinc-800">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex-1 rounded-none h-12 text-zinc-400 hover:text-white hover:bg-zinc-800 focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none"
>
<Settings className="h-4 w-4 mr-2" />
Configure
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="bg-zinc-900 border-zinc-700"
side="bottom"
align="end"
>
<DropdownMenuItem
className="text-white hover:bg-zinc-800 cursor-pointer"
onClick={handleTestA2A}
>
<FlaskConical className="h-4 w-4 mr-2 text-emerald-400" />
Test A2A
</DropdownMenuItem>
<DropdownMenuItem
className="text-white hover:bg-zinc-800 cursor-pointer"
onClick={() => onEdit(agent)}
>
<Pencil className="h-4 w-4 mr-2 text-emerald-400" />
Edit Agent
</DropdownMenuItem>
<DropdownMenuItem
className="text-white hover:bg-zinc-800 cursor-pointer"
onClick={() => onMove(agent)}
>
<MoveRight className="h-4 w-4 mr-2 text-yellow-400" />
Move Agent
</DropdownMenuItem>
{onWorkflow && agent.type === "workflow" && (
<DropdownMenuItem
className="text-white hover:bg-zinc-800 cursor-pointer"
onClick={() => onWorkflow(agent.id)}
>
<Workflow className="h-4 w-4 mr-2 text-blue-400" />
Open Workflow
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-white hover:bg-zinc-800 cursor-pointer"
onClick={handleExportAgent}
>
<Download className="h-4 w-4 mr-2 text-purple-400" />
Export as JSON
</DropdownMenuItem>
{onShare && (
<DropdownMenuItem
className="text-white hover:bg-zinc-800 cursor-pointer"
onClick={() => onShare(agent)}
>
<Share2 className="h-4 w-4 mr-2 text-blue-400" />
Share Agent
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-red-500 hover:bg-zinc-800 cursor-pointer"
onClick={() => onDelete(agent)}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="w-px bg-zinc-800" />
<a
href={agent.agent_card_url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"flex-1 flex items-center justify-center rounded-none h-12 hover:bg-zinc-800",
{
"text-green-400 hover:text-green-300": agent.type === "llm",
"text-indigo-400 hover:text-indigo-300": agent.type === "a2a",
"text-yellow-400 hover:text-yellow-300":
agent.type === "sequential",
"text-purple-400 hover:text-purple-300":
agent.type === "parallel",
"text-orange-400 hover:text-orange-300": agent.type === "loop",
"text-blue-400 hover:text-blue-300": agent.type === "workflow",
"text-red-400 hover:text-red-300": agent.type === "task",
}
)}
>
<ExternalLink className="h-4 w-4 mr-2" />
Agent Card
</a>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,130 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/AgentList.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { Agent } from "@/types/agent";
import { MCPServer } from "@/types/mcpServer";
import { AgentCard } from "./AgentCard";
import { EmptyState } from "./EmptyState";
import { ApiKey, Folder } from "@/services/agentService";
interface AgentListProps {
agents: Agent[];
isLoading: boolean;
searchTerm: string;
selectedFolderId: string | null;
availableMCPs: MCPServer[];
getApiKeyNameById: (id: string | undefined) => string | null;
getAgentNameById: (id: string) => string;
onEdit: (agent: Agent) => void;
onDelete: (agent: Agent) => void;
onMove: (agent: Agent) => void;
onShare?: (agent: Agent) => void;
onWorkflow?: (agentId: string) => void;
onClearSearch?: () => void;
onCreateAgent?: () => void;
apiKeys: ApiKey[];
folders: Folder[];
}
export function AgentList({
agents,
isLoading,
searchTerm,
selectedFolderId,
availableMCPs,
getApiKeyNameById,
getAgentNameById,
onEdit,
onDelete,
onMove,
onShare,
onWorkflow,
onClearSearch,
onCreateAgent,
apiKeys,
folders,
}: AgentListProps) {
if (isLoading) {
return (
<div className="flex items-center justify-center h-[60vh]">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-emerald-400"></div>
</div>
);
}
if (agents.length === 0) {
if (searchTerm) {
return (
<EmptyState
type="search-no-results"
searchTerm={searchTerm}
onAction={onClearSearch}
/>
);
} else if (selectedFolderId) {
return (
<EmptyState
type="empty-folder"
onAction={onCreateAgent}
actionLabel="Create Agent"
/>
);
} else {
return (
<EmptyState
type="no-agents"
onAction={onCreateAgent}
actionLabel="Create Agent"
/>
);
}
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{agents.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
onEdit={onEdit}
onDelete={onDelete}
onMove={onMove}
onShare={onShare}
onWorkflow={onWorkflow}
availableMCPs={availableMCPs}
getApiKeyNameById={getApiKeyNameById}
getAgentNameById={getAgentNameById}
folders={folders}
agents={agents}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,186 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/AgentSidebar.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Folder,
FolderPlus,
Home,
X,
CircleEllipsis,
Edit,
Trash2,
} from "lucide-react";
interface AgentFolder {
id: string;
name: string;
description: string;
}
interface AgentSidebarProps {
visible: boolean;
folders: AgentFolder[];
selectedFolderId: string | null;
onSelectFolder: (id: string | null) => void;
onAddFolder: () => void;
onEditFolder: (folder: AgentFolder) => void;
onDeleteFolder: (folder: AgentFolder) => void;
onClose: () => void;
}
export function AgentSidebar({
visible,
folders,
selectedFolderId,
onSelectFolder,
onAddFolder,
onEditFolder,
onDeleteFolder,
onClose,
}: AgentSidebarProps) {
return (
<>
{visible && (
<button
onClick={onClose}
className="absolute right-4 top-4 z-40 bg-[#222] p-2 rounded-md text-emerald-400 hover:bg-[#333] hover:text-emerald-400 shadow-md transition-all"
aria-label="Hide folders"
>
<X className="h-5 w-5" />
</button>
)}
<div
className={`fixed top-0 z-30 h-full w-64 bg-[#1a1a1a] p-4 shadow-xl transition-all duration-300 ease-in-out ${
visible ? "left-64 translate-x-0" : "left-0 -translate-x-full pointer-events-none"
}`}
>
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-white flex items-center">
<Folder className="h-5 w-5 mr-2 text-emerald-400" />
Folders
</h2>
<div className="flex space-x-1">
<Button
variant="ghost"
className="h-8 w-8 p-0 text-neutral-400 hover:text-emerald-400 hover:bg-[#222]"
onClick={onAddFolder}
>
<FolderPlus className="h-5 w-5" />
</Button>
<Button
variant="ghost"
className="h-8 w-8 p-0 text-neutral-400 hover:text-emerald-400 hover:bg-[#222]"
onClick={onClose}
>
<X className="h-5 w-5" />
</Button>
</div>
</div>
<div className="space-y-1">
<button
className={`w-full text-left px-3 py-2 rounded-md flex items-center ${
selectedFolderId === null
? "bg-[#333] text-emerald-400"
: "text-neutral-300 hover:bg-[#222] hover:text-white"
}`}
onClick={() => onSelectFolder(null)}
>
<Home className="h-4 w-4 mr-2" />
<span>All agents</span>
</button>
{folders.map((folder) => (
<div key={folder.id} className="flex items-center group">
<button
className={`flex-1 text-left px-3 py-2 rounded-md flex items-center ${
selectedFolderId === folder.id
? "bg-[#333] text-emerald-400"
: "text-neutral-300 hover:bg-[#222] hover:text-white"
}`}
onClick={() => onSelectFolder(folder.id)}
>
<Folder className="h-4 w-4 mr-2" />
<span className="truncate">{folder.name}</span>
</button>
<div className="opacity-0 group-hover:opacity-100">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 p-0 text-neutral-400 hover:text-white hover:bg-[#222]"
>
<CircleEllipsis className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-[#222] border-[#333] text-white"
>
<DropdownMenuItem
className="cursor-pointer hover:bg-[#333] focus:bg-[#333]"
onClick={(e) => {
e.stopPropagation();
onEditFolder(folder);
}}
>
<Edit className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer text-red-500 hover:bg-[#333] hover:text-red-400 focus:bg-[#333]"
onClick={(e) => {
e.stopPropagation();
onDeleteFolder(folder);
}}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,96 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/AgentTypeSelector.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { AgentType } from "@/types/agent";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Code,
ExternalLink,
GitBranch,
RefreshCw,
Workflow,
Users,
BookOpenCheck,
} from "lucide-react";
interface AgentTypeSelectorProps {
value: AgentType;
onValueChange: (value: AgentType) => void;
className?: string;
}
export const agentTypes = [
{ value: "llm", label: "LLM Agent", icon: Code },
{ value: "a2a", label: "A2A Agent", icon: ExternalLink },
{ value: "sequential", label: "Sequential Agent", icon: Workflow },
{ value: "parallel", label: "Parallel Agent", icon: GitBranch },
{ value: "loop", label: "Loop Agent", icon: RefreshCw },
{ value: "workflow", label: "Workflow Agent", icon: Workflow },
{ value: "task", label: "Task Agent", icon: BookOpenCheck },
];
export function AgentTypeSelector({
value,
onValueChange,
className = "",
}: AgentTypeSelectorProps) {
return (
<Select
value={value}
onValueChange={(value: AgentType) => onValueChange(value)}
>
<SelectTrigger
className={`bg-[#222] border-[#444] text-white ${className}`}
>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent className="bg-[#222] border-[#444] text-white">
{agentTypes.map((type) => (
<SelectItem
key={type.value}
value={type.value}
className="data-[selected]:bg-[#333] data-[highlighted]:bg-[#333] text-white focus:!text-white hover:text-emerald-400 data-[selected]:!text-emerald-400"
>
<div className="flex items-center gap-2">
<type.icon className="h-4 w-4 text-emerald-400" />
{type.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View File

@@ -0,0 +1,107 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/EmptyState.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { Button } from "@/components/ui/button";
import { Folder, Plus, Search, Server } from "lucide-react";
interface EmptyStateProps {
type: "no-agents" | "empty-folder" | "search-no-results";
searchTerm?: string;
onAction?: () => void;
actionLabel?: string;
}
export function EmptyState({
type,
searchTerm = "",
onAction,
actionLabel = "Create Agent",
}: EmptyStateProps) {
const getIcon = () => {
switch (type) {
case "empty-folder":
return <Folder className="h-16 w-16 text-emerald-400" />;
case "search-no-results":
return <Search className="h-16 w-16 text-emerald-400" />;
case "no-agents":
default:
return <Server className="h-16 w-16 text-emerald-400" />;
}
};
const getTitle = () => {
switch (type) {
case "empty-folder":
return "Empty folder";
case "search-no-results":
return "No agents found";
case "no-agents":
default:
return "No agents found";
}
};
const getMessage = () => {
switch (type) {
case "empty-folder":
return "This folder is empty. Add agents or create a new one.";
case "search-no-results":
return `We couldn't find any agents that match your search: "${searchTerm}"`;
case "no-agents":
default:
return "You don't have any agents configured. Create your first agent to start!";
}
};
return (
<div className="flex flex-col items-center justify-center h-[60vh] text-center">
<div className="mb-6 p-8 rounded-full bg-[#1a1a1a] border border-[#333]">
{getIcon()}
</div>
<h2 className="text-2xl font-semibold text-white mb-3">{getTitle()}</h2>
<p className="text-neutral-300 mb-6 max-w-md">{getMessage()}</p>
{onAction && (
<Button
onClick={onAction}
className={
type === "search-no-results"
? "bg-[#222] text-white hover:bg-[#333]"
: "bg-emerald-400 text-black hover:bg-[#00cc7d] px-6 py-2 hover:shadow-[0_0_15px_rgba(0,255,157,0.2)]"
}
>
{type === "search-no-results" ? null : (
<Plus className="mr-2 h-5 w-5" />
)}
{type === "search-no-results" ? "Clear search" : actionLabel}
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,153 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/SearchInput.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Search, X, Filter } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
selectedAgentType?: string | null;
onAgentTypeChange?: (type: string | null) => void;
agentTypes?: string[];
}
// Using "all" as a special value to represent no filter
const ANY_TYPE_VALUE = "all";
export function SearchInput({
value,
onChange,
placeholder = "Search agents...",
className = "",
selectedAgentType = null,
onAgentTypeChange,
agentTypes = [],
}: SearchInputProps) {
const [isFilterOpen, setIsFilterOpen] = useState(false);
const handleTypeChange = (value: string) => {
if (onAgentTypeChange) {
onAgentTypeChange(value === ANY_TYPE_VALUE ? null : value);
}
};
return (
<div className={`relative flex items-center gap-2 ${className}`}>
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-400" />
<Input
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
autoComplete="off"
className="pl-10 w-full bg-[#222] border-[#444] text-white focus:border-emerald-400 focus:ring-emerald-400/10"
/>
{value && (
<button
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-white"
onClick={() => onChange("")}
>
<X className="h-4 w-4" />
</button>
)}
</div>
{agentTypes.length > 0 && onAgentTypeChange && (
<Popover open={isFilterOpen} onOpenChange={setIsFilterOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={`px-3 py-2 bg-[#222] border-[#444] hover:bg-[#333] ${
selectedAgentType ? "text-emerald-400" : "text-neutral-400"
}`}
>
<Filter className="h-4 w-4 mr-1" />
{selectedAgentType ? "Filtered" : "Filter"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-2 bg-[#222] border-[#444]">
<div className="space-y-3">
<div className="text-xs font-medium text-neutral-300">
Filter by type
</div>
<Select
value={selectedAgentType ? selectedAgentType : ANY_TYPE_VALUE}
onValueChange={handleTypeChange}
>
<SelectTrigger className="bg-[#333] border-[#444] text-white">
<SelectValue placeholder="Any type" />
</SelectTrigger>
<SelectContent className="bg-[#333] border-[#444] text-white">
<SelectItem value={ANY_TYPE_VALUE}>Any type</SelectItem>
{agentTypes.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedAgentType && (
<Button
variant="ghost"
size="sm"
className="w-full text-xs text-neutral-300 hover:text-white"
onClick={() => {
onAgentTypeChange(null);
setIsFilterOpen(false);
}}
>
Clear filter
</Button>
)}
</div>
</PopoverContent>
</Popover>
)}
</div>
);
}

View File

@@ -0,0 +1,73 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/config/A2AAgentConfig.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface A2AAgentConfigProps {
values: {
agent_card_url?: string;
};
onChange: (values: any) => void;
}
export function A2AAgentConfig({ values, onChange }: A2AAgentConfigProps) {
return (
<div className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="agent_card_url" className="text-right text-neutral-300">
Agent Card URL
</Label>
<Input
id="agent_card_url"
value={values.agent_card_url || ""}
onChange={(e) =>
onChange({
...values,
agent_card_url: e.target.value,
})
}
placeholder="https://example.com/.well-known/agent-card.json"
className="col-span-3 bg-[#222] border-[#444] text-white"
/>
</div>
<div className="pl-[25%] text-sm text-neutral-400">
<p>
Provide the full URL for the JSON file of the Agent Card that describes
this agent.
</p>
<p className="mt-2">
Agent Cards contain metadata, capabilities descriptions and supported
protocols.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,367 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/config/LLMAgentConfig.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ApiKey } from "@/services/agentService";
import { Plus, Maximize2, Save } from "lucide-react";
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
interface ModelOption {
value: string;
label: string;
provider: string;
}
interface LLMAgentConfigProps {
apiKeys: ApiKey[];
availableModels: ModelOption[];
values: {
model?: string;
api_key_id?: string;
instruction?: string;
role?: string;
goal?: string;
};
onChange: (values: any) => void;
onOpenApiKeysDialog: () => void;
}
export function LLMAgentConfig({
apiKeys,
availableModels,
values,
onChange,
onOpenApiKeysDialog,
}: LLMAgentConfigProps) {
const [instructionText, setInstructionText] = useState(values.instruction || "");
const [isInstructionModalOpen, setIsInstructionModalOpen] = useState(false);
const [expandedInstructionText, setExpandedInstructionText] = useState("");
useEffect(() => {
setInstructionText(values.instruction || "");
}, [values.instruction]);
const handleInstructionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
setInstructionText(newValue);
onChange({
...values,
instruction: newValue,
});
};
const handleExpandInstruction = () => {
setExpandedInstructionText(instructionText);
setIsInstructionModalOpen(true);
};
const handleSaveExpandedInstruction = () => {
setInstructionText(expandedInstructionText);
onChange({
...values,
instruction: expandedInstructionText,
});
setIsInstructionModalOpen(false);
};
return (
<div className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="role" className="text-right text-neutral-300">
Role
</Label>
<div className="col-span-3">
<Input
id="role"
value={values.role || ""}
onChange={(e) =>
onChange({
...values,
role: e.target.value,
})
}
placeholder="Ex: Research Assistant, Customer Support, etc."
className="bg-[#222] border-[#444] text-white"
/>
<div className="mt-1 text-xs text-neutral-400">
<span className="inline-block h-3 w-3 mr-1"></span>
<span>Define the role or persona that the agent will assume</span>
</div>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="goal" className="text-right text-neutral-300">
Goal
</Label>
<div className="col-span-3">
<Input
id="goal"
value={values.goal || ""}
onChange={(e) =>
onChange({
...values,
goal: e.target.value,
})
}
placeholder="Ex: Find and organize information, Assist customers with inquiries, etc."
className="bg-[#222] border-[#444] text-white"
/>
<div className="mt-1 text-xs text-neutral-400">
<span className="inline-block h-3 w-3 mr-1"></span>
<span>Define the main objective or purpose of this agent</span>
</div>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="api_key" className="text-right text-neutral-300">
API Key
</Label>
<div className="col-span-3 space-y-4">
<div className="flex items-center">
<Select
value={values.api_key_id || ""}
onValueChange={(value) =>
onChange({
...values,
api_key_id: value,
})
}
>
<SelectTrigger className="flex-1 bg-[#222] border-[#444] text-white">
<SelectValue placeholder="Select an API key" />
</SelectTrigger>
<SelectContent className="bg-[#222] border-[#444] text-white">
{apiKeys.length > 0 ? (
apiKeys
.filter((key) => key.is_active !== false)
.map((key) => (
<SelectItem
key={key.id}
value={key.id}
className="data-[selected]:bg-[#333] data-[highlighted]:bg-[#333] !text-white focus:!text-white hover:text-emerald-400 data-[selected]:!text-emerald-400"
>
<div className="flex items-center">
<span>{key.name}</span>
<Badge className="ml-2 bg-[#333] text-emerald-400 text-xs">
{key.provider}
</Badge>
</div>
</SelectItem>
))
) : (
<div className="text-neutral-500 px-2 py-1.5 pl-8">
No API keys available
</div>
)}
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
onClick={onOpenApiKeysDialog}
className="ml-2 bg-[#222] text-emerald-400 hover:bg-[#333]"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{apiKeys.length === 0 && (
<div className="flex items-center text-xs text-neutral-400">
<span className="inline-block h-3 w-3 mr-1 text-neutral-400">i</span>
<span>
You need to{" "}
<Button
variant="link"
onClick={onOpenApiKeysDialog}
className="h-auto p-0 text-xs text-emerald-400"
>
register API keys
</Button>{" "}
before creating an agent.
</span>
</div>
)}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="model" className="text-right text-neutral-300">
Model
</Label>
<Select
value={values.model}
onValueChange={(value) =>
onChange({
...values,
model: value,
})
}
>
<SelectTrigger className="col-span-3 bg-[#222] border-[#444] text-white">
<SelectValue placeholder="Select the model" />
</SelectTrigger>
<SelectContent className="bg-[#222] border-[#444] text-white p-0">
<div className="sticky top-0 z-10 p-2 bg-[#222] border-b border-[#444]">
<Input
placeholder="Search models..."
className="bg-[#333] border-[#444] text-white h-8"
onChange={(e) => {
const searchQuery = e.target.value.toLowerCase();
const items = document.querySelectorAll('[data-model-item="true"]');
items.forEach((item) => {
const text = item.textContent?.toLowerCase() || '';
if (text.includes(searchQuery)) {
(item as HTMLElement).style.display = 'flex';
} else {
(item as HTMLElement).style.display = 'none';
}
});
}}
/>
</div>
<div className="max-h-[200px] overflow-y-auto py-1">
{availableModels
.filter((model) => {
if (!values.api_key_id) return true;
const selectedKey = apiKeys.find(
(key) => key.id === values.api_key_id
);
if (!selectedKey) return true;
return model.provider === selectedKey.provider;
})
.map((model) => (
<SelectItem
key={model.value}
value={model.value}
className="data-[selected]:bg-[#333] data-[highlighted]:bg-[#333] !text-white focus:!text-white hover:text-emerald-400 data-[selected]:!text-emerald-400"
data-model-item="true"
>
{model.label}
</SelectItem>
))}
</div>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="instruction" className="text-right text-neutral-300">
Instructions
</Label>
<div className="col-span-3">
<div className="relative">
<Textarea
id="instruction"
value={instructionText}
onChange={handleInstructionChange}
className="w-full bg-[#222] border-[#444] text-white pr-10"
rows={4}
onClick={handleExpandInstruction}
/>
<button
type="button"
className="absolute top-3 right-5 text-neutral-400 hover:text-emerald-400 focus:outline-none"
onClick={handleExpandInstruction}
>
<Maximize2 className="h-4 w-4" />
</button>
</div>
<div className="mt-1 text-xs text-neutral-400">
<span className="inline-block h-3 w-3 mr-1"></span>
<span>
Characters like {"{"} and {"}"} or {"{{"} and {"}}"} are automatically escaped to avoid errors in Python.
<span className="ml-2 text-emerald-400">Click to expand editor.</span>
</span>
</div>
</div>
</div>
{/* Expanded Instruction Modal */}
<Dialog open={isInstructionModalOpen} onOpenChange={setIsInstructionModalOpen}>
<DialogContent className="sm:max-w-[1200px] max-h-[90vh] bg-[#1a1a1a] border-[#333] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="text-white">Agent Instructions</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col min-h-[60vh]">
<Textarea
value={expandedInstructionText}
onChange={(e) => setExpandedInstructionText(e.target.value)}
className="flex-1 min-h-full bg-[#222] border-[#444] text-white p-4 focus:border-emerald-400 focus:ring-emerald-400 focus:ring-opacity-50 resize-none"
placeholder="Enter detailed instructions for the agent..."
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsInstructionModalOpen(false)}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Cancel
</Button>
<Button
onClick={handleSaveExpandedInstruction}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
>
<Save className="h-4 w-4 mr-2" />
Save Instructions
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,133 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/config/LoopAgentConfig.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Agent } from "@/types/agent";
import { ArrowRight } from "lucide-react";
interface LoopAgentConfigProps {
values: {
config?: {
sub_agents?: string[];
max_iterations?: number;
};
};
onChange: (values: any) => void;
agents: Agent[];
getAgentNameById: (id: string) => string;
}
export function LoopAgentConfig({
values,
onChange,
agents,
getAgentNameById,
}: LoopAgentConfigProps) {
const handleMaxIterationsChange = (value: string) => {
const maxIterations = parseInt(value);
onChange({
...values,
config: {
...values.config,
max_iterations: isNaN(maxIterations) ? undefined : maxIterations,
},
});
};
return (
<div className="space-y-6">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="max_iterations" className="text-right text-neutral-300">
Max. Iterations
</Label>
<Input
id="max_iterations"
type="number"
min={1}
max={100}
value={values.config?.max_iterations || ""}
onChange={(e) => handleMaxIterationsChange(e.target.value)}
className="col-span-3 bg-[#222] border-[#444] text-white"
/>
</div>
<div className="border border-[#444] rounded-md p-4 bg-[#222]">
<h3 className="text-sm font-medium text-white mb-4">
Execution Order of Agents
</h3>
{values.config?.sub_agents && values.config.sub_agents.length > 0 ? (
<div className="space-y-3">
{values.config.sub_agents.map((agentId, index) => (
<div
key={agentId}
className="flex items-center space-x-2 bg-[#2a2a2a] p-3 rounded-md"
>
<div className="flex-1">
<div className="font-medium text-white">
{getAgentNameById(agentId)}
</div>
<div className="text-sm text-neutral-400">
Executed on{" "}
<Badge className="bg-[#333] text-emerald-400 border-none">
Position {index + 1}
</Badge>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-6 text-neutral-400">
Add agents in the "Sub-Agents" tab to define the execution order
</div>
)}
<div className="mt-3 text-sm text-neutral-400">
<p>
The agents will be executed sequentially in the order listed above.
The output of each agent will be provided as input to the next
agent in the sequence.
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,125 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/config/ParallelAgentConfig.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Agent } from "@/types/agent";
import { GitBranch } from "lucide-react";
interface ParallelAgentConfigProps {
values: {
config?: {
sub_agents?: string[];
aggregation_method?: string;
timeout_seconds?: number;
custom_mcp_servers?: any[];
wait_for_all?: boolean;
};
};
onChange: (values: any) => void;
agents: Agent[];
getAgentNameById: (id: string) => string;
}
export function ParallelAgentConfig({
values,
onChange,
agents,
getAgentNameById,
}: ParallelAgentConfigProps) {
const aggregationMethods = [
{ value: "merge", label: "Merge all responses" },
{ value: "first", label: "Use only the first response" },
{ value: "last", label: "Use only the last response" },
{ value: "custom", label: "Custom aggregation" },
];
const handleTimeoutChange = (value: string) => {
const timeout = parseInt(value);
onChange({
...values,
config: {
...values.config,
timeout_seconds: isNaN(timeout) ? undefined : timeout,
},
});
};
return (
<div className="space-y-6">
<div className="border border-[#444] rounded-md p-4 bg-[#222]">
<h3 className="text-sm font-medium text-white mb-4">
Agents in Parallel
</h3>
{values.config?.sub_agents && values.config.sub_agents.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{values.config.sub_agents.map((agentId) => (
<div
key={agentId}
className="flex items-center space-x-2 bg-[#2a2a2a] p-3 rounded-md"
>
<GitBranch className="h-5 w-5 text-emerald-400" />
<div className="flex-1">
<div className="font-medium text-white truncate">
{getAgentNameById(agentId)}
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-6 text-neutral-400">
Add agents in the "Sub-Agents" tab to execute in parallel
</div>
)}
<div className="mt-3 text-sm text-neutral-400">
<p>
All listed agents will be executed simultaneously with the same
input. The responses will be aggregated according to the selected
method.
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/config/SequentialAgentConfig.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Agent } from "@/types/agent";
import { ArrowRight } from "lucide-react";
interface SequentialAgentConfigProps {
values: {
config?: {
sub_agents?: string[];
max_iterations?: number;
custom_mcp_servers?: any[];
};
};
onChange: (values: any) => void;
agents: Agent[];
getAgentNameById: (id: string) => string;
}
export function SequentialAgentConfig({
values,
onChange,
agents,
getAgentNameById,
}: SequentialAgentConfigProps) {
const handleMaxIterationsChange = (value: string) => {
const maxIterations = parseInt(value);
onChange({
...values,
config: {
...values.config,
max_iterations: isNaN(maxIterations) ? undefined : maxIterations,
},
});
};
return (
<div className="space-y-6">
<div className="border border-[#444] rounded-md p-4 bg-[#222]">
<h3 className="text-sm font-medium text-white mb-4">
Execution Order of Agents
</h3>
{values.config?.sub_agents && values.config.sub_agents.length > 0 ? (
<div className="space-y-3">
{values.config.sub_agents.map((agentId, index) => (
<div
key={agentId}
className="flex items-center space-x-2 bg-[#2a2a2a] p-3 rounded-md"
>
<div className="flex-1">
<div className="font-medium text-white">
{getAgentNameById(agentId)}
</div>
<div className="text-sm text-neutral-400">
Executed on{" "}
<Badge className="bg-[#333] text-emerald-400 border-none">
Position {index + 1}
</Badge>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-6 text-neutral-400">
Add agents in the "Sub-Agents" tab to define the execution order
</div>
)}
<div className="mt-3 text-sm text-neutral-400">
<p>
The agents will be executed sequentially in the order listed above.
The output of each agent will be provided as input to the next
agent in the sequence.
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,801 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/config/TaskAgentConfig.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { Agent, TaskConfig } from "@/types/agent";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
Maximize2,
Save,
X,
ArrowDown,
List,
Search,
Edit,
PenTool,
} from "lucide-react";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
interface TaskAgentConfigProps {
values: Partial<Agent>;
onChange: (values: Partial<Agent>) => void;
agents: Agent[];
getAgentNameById: (id: string) => string;
singleTask?: boolean;
}
const getAgentTypeLabel = (type: string): string => {
const typeMap: Record<string, string> = {
llm: "LLM",
a2a: "A2A",
sequential: "Sequential",
parallel: "Parallel",
loop: "Loop",
workflow: "Workflow",
task: "Task",
};
return typeMap[type] || type;
};
const getAgentTypeColor = (type: string): string => {
const colorMap: Record<string, string> = {
llm: "bg-blue-800 text-white",
a2a: "bg-purple-800 text-white",
sequential: "bg-orange-800 text-white",
parallel: "bg-green-800 text-white",
loop: "bg-pink-800 text-white",
workflow: "bg-yellow-800 text-black",
task: "bg-green-800 text-white",
};
return colorMap[type] || "bg-neutral-800 text-white";
};
export function TaskAgentConfig({
values,
onChange,
agents,
getAgentNameById,
singleTask = false,
}: TaskAgentConfigProps) {
const [newTask, setNewTask] = useState<TaskConfig>({
agent_id: "",
description: "",
expected_output: "",
enabled_tools: [],
});
const [taskAgentSearchQuery, setTaskAgentSearchQuery] = useState<string>("");
const [filteredTaskAgents, setFilteredTaskAgents] = useState<Agent[]>([]);
const [isDescriptionModalOpen, setIsDescriptionModalOpen] = useState(false);
const [expandedDescription, setExpandedDescription] = useState("");
const [editingTaskIndex, setEditingTaskIndex] = useState<number | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [toolSearchQuery, setToolSearchQuery] = useState<string>("");
const [filteredTools, setFilteredTools] = useState<{id: string, name: string}[]>([]);
const [isToolsModalOpen, setIsToolsModalOpen] = useState(false);
const [tempSelectedTools, setTempSelectedTools] = useState<string[]>([]);
useEffect(() => {
if (isToolsModalOpen) {
if (isEditing && editingTaskIndex !== null && values.config?.tasks) {
setTempSelectedTools(
values.config.tasks[editingTaskIndex]?.enabled_tools || []
);
} else {
setTempSelectedTools([...newTask.enabled_tools || []]);
}
}
}, [isToolsModalOpen]);
const getAvailableTaskAgents = (currentTaskAgentId?: string) =>
agents.filter(
(agent) =>
agent.id !== values.id &&
(!values.config?.tasks?.some((task) => task.agent_id === agent.id) ||
agent.id === currentTaskAgentId)
);
useEffect(() => {
const currentTaskAgentId =
isEditing && editingTaskIndex !== null && values.config?.tasks
? values.config.tasks[editingTaskIndex].agent_id
: undefined;
const availableAgents = getAvailableTaskAgents(currentTaskAgentId);
if (taskAgentSearchQuery.trim() === "") {
setFilteredTaskAgents(availableAgents);
} else {
const query = taskAgentSearchQuery.toLowerCase();
setFilteredTaskAgents(
availableAgents.filter(
(agent) =>
agent.name.toLowerCase().includes(query) ||
(agent.description?.toLowerCase() || "").includes(query)
)
);
}
}, [
taskAgentSearchQuery,
agents,
values.config?.tasks,
isEditing,
editingTaskIndex,
]);
useEffect(() => {
// Reset editing state when values change externally
if (!isEditing) {
const currentTaskAgentId =
editingTaskIndex !== null && values.config?.tasks
? values.config.tasks[editingTaskIndex]?.agent_id
: undefined;
setFilteredTaskAgents(getAvailableTaskAgents(currentTaskAgentId));
}
}, [agents, values.config?.tasks]);
const getAvailableTools = () => {
if (!values.config?.tasks || values.config.tasks.length === 0) {
return [];
}
const taskAgentIds = values.config.tasks.map(task => task.agent_id);
const toolsList: {id: string, name: string}[] = [];
const toolsMap: Record<string, boolean> = {};
taskAgentIds.forEach(agentId => {
const agent = agents.find(a => a.id === agentId);
if (agent?.type === "llm" && agent.config?.tools) {
agent.config.tools.forEach(tool => {
if (!toolsMap[tool.id]) {
toolsList.push({ id: tool.id, name: tool.id });
toolsMap[tool.id] = true;
}
});
}
if (agent?.type === "llm" && agent.config?.mcp_servers) {
agent.config.mcp_servers.forEach(mcp => {
if (mcp.tools) {
mcp.tools.forEach(toolId => {
if (!toolsMap[toolId]) {
toolsList.push({ id: toolId, name: toolId });
toolsMap[toolId] = true;
}
});
}
});
}
});
return toolsList;
};
useEffect(() => {
const availableTools = getAvailableTools();
if (toolSearchQuery.trim() === "") {
setFilteredTools(availableTools);
} else {
const query = toolSearchQuery.toLowerCase();
setFilteredTools(
availableTools.filter(
(tool) =>
tool.name.toLowerCase().includes(query) ||
tool.id.toLowerCase().includes(query)
)
);
}
}, [toolSearchQuery, values.config?.tasks, agents]);
const handleAddTask = () => {
if (!newTask.agent_id || !newTask.description) {
return;
}
if (isEditing && editingTaskIndex !== null) {
const tasks = [...(values.config?.tasks || [])];
tasks[editingTaskIndex] = { ...newTask };
onChange({
...values,
config: {
...(values.config || {}),
tasks,
},
});
setIsEditing(false);
setEditingTaskIndex(null);
} else {
const tasks = [...(values.config?.tasks || [])];
if (singleTask) {
tasks.splice(0, tasks.length, newTask);
} else {
tasks.push(newTask);
}
onChange({
...values,
config: {
...(values.config || {}),
tasks,
},
});
}
setNewTask({
agent_id: "",
description: "",
expected_output: "",
enabled_tools: [],
});
};
const handleEditTask = (index: number) => {
const task = values.config?.tasks?.[index];
if (task) {
setNewTask({ ...task });
setIsEditing(true);
setEditingTaskIndex(index);
}
};
const handleCancelEdit = () => {
setNewTask({
agent_id: "",
description: "",
expected_output: "",
enabled_tools: [],
});
setIsEditing(false);
setEditingTaskIndex(null);
};
const handleRemoveTask = (index: number) => {
if (editingTaskIndex === index) {
handleCancelEdit();
}
const tasks = [...(values.config?.tasks || [])];
tasks.splice(index, 1);
onChange({
...values,
config: {
...(values.config || {}),
tasks,
},
});
};
const handleDescriptionChange = (
e: React.ChangeEvent<HTMLTextAreaElement>
) => {
const newValue = e.target.value;
setNewTask({
...newTask,
description: newValue,
});
};
const handleExpandDescription = () => {
setExpandedDescription(newTask.description);
setIsDescriptionModalOpen(true);
};
const handleSaveExpandedDescription = () => {
setNewTask({
...newTask,
description: expandedDescription,
});
setIsDescriptionModalOpen(false);
};
const handleToggleTool = (toolId: string) => {
const index = tempSelectedTools.indexOf(toolId);
if (index > -1) {
setTempSelectedTools(tempSelectedTools.filter(id => id !== toolId));
} else {
setTempSelectedTools([...tempSelectedTools, toolId]);
}
};
const isToolEnabled = (toolId: string) => {
return tempSelectedTools.includes(toolId);
};
const handleSaveTools = () => {
if (isEditing && editingTaskIndex !== null && values.config?.tasks) {
const tasks = [...(values.config?.tasks || [])];
const updatedTask = {
...tasks[editingTaskIndex],
enabled_tools: [...tempSelectedTools]
};
tasks[editingTaskIndex] = updatedTask;
const newConfig = {
...(values.config || {}),
tasks: tasks
};
onChange({
...values,
config: newConfig
});
} else if (newTask.agent_id) {
const updatedNewTask = {
...newTask,
enabled_tools: [...tempSelectedTools]
};
setNewTask(updatedNewTask);
}
setIsToolsModalOpen(false);
};
const renderAgentTypeBadge = (agentId: string) => {
const agent = agents.find((a) => a.id === agentId);
if (!agent) {
return null;
}
return (
<Badge className={`ml-2 ${getAgentTypeColor(agent.type)} text-xs`}>
{getAgentTypeLabel(agent.type)}
</Badge>
);
};
return (
<div className="space-y-8">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-white flex items-center">
<List className="mr-2 h-5 w-5 text-emerald-400" />
{singleTask ? "Task" : "Tasks"}
</h3>
</div>
<div className="border border-[#444] rounded-md p-4 bg-[#222]">
<p className="text-sm text-neutral-400 mb-4">
{singleTask
? "Configure the task that will be executed by the agent."
: "Configure the sequential tasks that will be executed by the team of agents."}
</p>
{values.config?.tasks && values.config.tasks.length > 0 ? (
<div className="space-y-4 mb-4">
{values.config.tasks.map((task, index) => (
<div
key={index}
className={`border border-[#333] rounded-md p-3 ${
editingTaskIndex === index ? "bg-[#1e3a3a]" : "bg-[#2a2a2a]"
}`}
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<div className="flex items-center">
<span className="inline-flex items-center justify-center rounded-full bg-[#333] px-2 py-1 text-xs text-white mr-2">
{index + 1}
</span>
<h4 className="font-medium text-white flex items-center">
{getAgentNameById(task.agent_id)}
{renderAgentTypeBadge(task.agent_id)}
</h4>
</div>
<p className="text-sm text-neutral-300 mt-1">
{task.description}
</p>
{task.expected_output && (
<div className="mt-2">
<span className="text-xs text-neutral-400">
Expected output:
</span>
<Badge
variant="outline"
className="ml-2 bg-[#333] text-emerald-400 border-emerald-400/30"
>
{task.expected_output}
</Badge>
</div>
)}
{task.enabled_tools && task.enabled_tools.length > 0 && (
<div className="mt-2">
<span className="text-xs text-neutral-400">
Enabled tools:
</span>
<div className="flex flex-wrap gap-1 mt-1">
{task.enabled_tools.map((toolId) => (
<Badge
key={toolId}
className="bg-[#333] text-emerald-400 border border-emerald-400/30 text-xs"
>
{toolId}
</Badge>
))}
</div>
</div>
)}
</div>
<div className="flex">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditTask(index)}
className="text-neutral-400 hover:text-emerald-400 hover:bg-[#333] mr-1"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveTask(index)}
className="text-red-500 hover:text-red-400 hover:bg-[#333]"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{!singleTask &&
index < (values.config?.tasks?.length || 0) - 1 && (
<div className="flex justify-center my-2">
<ArrowDown className="h-4 w-4 text-neutral-400" />
</div>
)}
</div>
))}
</div>
) : (
<div className="text-center py-4 mb-4 bg-[#2a2a2a] rounded-md">
<p className="text-neutral-400">No tasks configured</p>
<p className="text-xs text-neutral-500">
{singleTask
? "Add a task to define the agent's behavior"
: "Add tasks to define the workflow of the team"}
</p>
</div>
)}
{(!singleTask ||
!values.config?.tasks ||
values.config.tasks.length === 0 ||
isEditing) && (
<div className="space-y-3 border-t border-[#333] pt-4">
<h4 className="text-sm font-medium text-white flex items-center justify-between">
<span>
{isEditing
? "Edit task"
: `Add ${singleTask ? "one" : "new"} task`}
</span>
{isEditing && (
<Button
variant="ghost"
size="sm"
onClick={handleCancelEdit}
className="text-neutral-400 hover:text-emerald-400 hover:bg-[#333]"
>
<X className="h-4 w-4 mr-1" /> Cancel
</Button>
)}
</h4>
<div className="grid grid-cols-3 gap-3">
<div>
<Label
htmlFor="agent_id"
className="text-xs text-neutral-400 mb-1 block"
>
Agent
</Label>
<Select
value={newTask.agent_id}
onValueChange={(value) =>
setNewTask({ ...newTask, agent_id: value })
}
>
<SelectTrigger className="bg-[#2a2a2a] border-[#444] text-white">
<SelectValue placeholder="Select agent" />
</SelectTrigger>
<SelectContent className="bg-[#2a2a2a] border-[#444] text-white p-0">
<div className="sticky top-0 z-10 p-2 bg-[#2a2a2a] border-b border-[#444]">
<div className="relative">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
placeholder="Search agents..."
className="bg-[#333] border-[#444] text-white h-8 pl-8"
value={taskAgentSearchQuery}
onChange={(e) =>
setTaskAgentSearchQuery(e.target.value)
}
/>
</div>
</div>
<div className="max-h-[200px] overflow-y-auto py-1">
{filteredTaskAgents.length > 0 ? (
filteredTaskAgents.map((agent) => (
<SelectItem
key={agent.id}
value={agent.id}
className="hover:bg-[#333] focus:bg-[#333] flex items-center justify-between px-2"
data-agent-item="true"
>
<div className="flex items-center">
<span className="mr-2">{agent.name}</span>
<Badge
className={`${getAgentTypeColor(
agent.type
)} text-xs`}
>
{getAgentTypeLabel(agent.type)}
</Badge>
</div>
</SelectItem>
))
) : (
<div className="text-neutral-500 px-4 py-2 text-center">
No agents found
</div>
)}
</div>
</SelectContent>
</Select>
</div>
<div className="col-span-2">
<Label
htmlFor="description"
className="text-xs text-neutral-400 mb-1 block"
>
Task description
</Label>
<div className="relative">
<Textarea
id="description"
value={newTask.description}
onChange={handleDescriptionChange}
className="w-full bg-[#2a2a2a] border-[#444] text-white pr-10"
rows={3}
onClick={handleExpandDescription}
/>
<button
type="button"
className="absolute top-3 right-5 text-neutral-400 hover:text-emerald-400 focus:outline-none"
onClick={handleExpandDescription}
>
<Maximize2 className="h-4 w-4" />
</button>
</div>
<div className="mt-1 text-xs text-neutral-400">
<span className="inline-block h-3 w-3 mr-1"></span>
<span>
Use {"{"}content{"}"} to insert the user's input.
<span className="ml-2 text-emerald-400">
Click to expand editor.
</span>
</span>
</div>
</div>
</div>
<div>
<Label
htmlFor="expected_output"
className="text-xs text-neutral-400 mb-1 block"
>
Expected output (optional)
</Label>
<Input
id="expected_output"
placeholder="Ex: JSON report, List of recommendations, etc."
value={newTask.expected_output}
onChange={(e) =>
setNewTask({ ...newTask, expected_output: e.target.value })
}
className="bg-[#2a2a2a] border-[#444] text-white"
/>
</div>
{newTask.enabled_tools && newTask.enabled_tools.length > 0 && (
<div className="mt-3">
<Label className="text-xs text-neutral-400 mb-1 block">
Selected tools:
</Label>
<div className="flex flex-wrap gap-1">
{newTask.enabled_tools.map((toolId) => (
<Badge
key={toolId}
className="bg-[#333] text-emerald-400 border border-emerald-400/30"
>
{toolId}
</Badge>
))}
</div>
</div>
)}
<div className="flex items-center justify-between mt-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
if (newTask.agent_id) setIsToolsModalOpen(true);
}}
disabled={!newTask.agent_id}
className="border-emerald-400 text-emerald-400 hover:bg-emerald-400/10 px-3"
>
<PenTool className="h-4 w-4 mr-2" />
Configure tools
</Button>
<Button
onClick={handleAddTask}
disabled={!newTask.agent_id || !newTask.description}
className="bg-[#222] text-emerald-400 border border-emerald-400 hover:bg-emerald-400/10"
>
<Save className="h-4 w-4 mr-1" />{" "}
{isEditing ? "Update task" : "Add task"}
</Button>
</div>
</div>
)}
</div>
</div>
<Dialog
open={isDescriptionModalOpen}
onOpenChange={setIsDescriptionModalOpen}
>
<DialogContent className="sm:max-w-[1200px] max-h-[90vh] bg-[#1a1a1a] border-[#333] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="text-white">Task Description</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col min-h-[60vh]">
<Textarea
value={expandedDescription}
onChange={(e) => setExpandedDescription(e.target.value)}
className="flex-1 min-h-full bg-[#222] border-[#444] text-white p-4 focus:border-emerald-400 focus:ring-emerald-400 focus:ring-opacity-50 resize-none"
placeholder="Enter detailed description for the task..."
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDescriptionModalOpen(false)}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Cancel
</Button>
<Button
onClick={handleSaveExpandedDescription}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
>
<Save className="h-4 w-4 mr-2" />
Save description
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isToolsModalOpen} onOpenChange={setIsToolsModalOpen}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] bg-[#1a1a1a] border-[#333] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="text-white">
Available tools
</DialogTitle>
</DialogHeader>
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
placeholder="Search tools..."
className="bg-[#222] border-[#444] text-white pl-9"
value={toolSearchQuery}
onChange={(e) => setToolSearchQuery(e.target.value)}
/>
</div>
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
{filteredTools.length > 0 ? (
filteredTools.map((tool) => (
<div
key={tool.id}
className="flex items-center space-x-2 p-2 rounded-md hover:bg-[#333] transition duration-150"
>
<Checkbox
id={tool.id}
checked={isToolEnabled(tool.id)}
onCheckedChange={() => handleToggleTool(tool.id)}
className="border-[#444] data-[state=checked]:bg-emerald-400 data-[state=checked]:text-black"
/>
<Label
htmlFor={tool.id}
className="cursor-pointer text-white flex-1"
>
{tool.name}
</Label>
<Badge className="bg-[#333] text-emerald-400">{tool.id}</Badge>
</div>
))
) : (
<div className="text-center py-8">
<p className="text-neutral-400">No tools available</p>
<p className="text-xs text-neutral-500">
The tools are obtained from the selected agents in the tasks.
</p>
</div>
)}
</div>
<DialogFooter>
<Button
onClick={handleSaveTools}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
>
<Save className="h-4 w-4 mr-2" />
Save settings
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,184 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/dialogs/AgentToolDialog.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Agent } from "@/types/agent";
import { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { listAgents } from "@/services/agentService";
import { Loader2 } from "lucide-react";
interface AgentToolDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (tool: { id: string; envs: Record<string, string> }) => void;
currentAgentId?: string;
folderId?: string | null;
clientId: string;
}
export function AgentToolDialog({
open,
onOpenChange,
onSave,
currentAgentId,
folderId,
clientId,
}: AgentToolDialogProps) {
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
const [search, setSearch] = useState("");
const [agents, setAgents] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (open) {
setSelectedAgentId("");
setSearch("");
loadAgents();
}
}, [open, folderId, clientId]);
const loadAgents = async () => {
if (!clientId) return;
setIsLoading(true);
try {
const res = await listAgents(
clientId,
0,
100,
folderId || undefined
);
// Filter out the current agent to avoid self-reference
const filteredAgents = res.data.filter(agent =>
agent.id !== currentAgentId
);
setAgents(filteredAgents);
} catch (error) {
console.error("Error loading agents:", error);
} finally {
setIsLoading(false);
}
};
const handleSave = () => {
if (!selectedAgentId) return;
onSave({ id: selectedAgentId, envs: {} });
onOpenChange(false);
};
const filteredAgents = agents.filter((agent) =>
agent.name.toLowerCase().includes(search.toLowerCase())
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[420px] max-h-[90vh] overflow-hidden flex flex-col bg-[#1a1a1a] border-[#333]">
<DialogHeader>
<DialogTitle className="text-white">Add Agent Tool</DialogTitle>
<DialogDescription className="text-neutral-400">
Select an agent to add as a tool.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto px-2 pb-2 space-y-4">
<Input
placeholder="Search agent by name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="mb-2 bg-[#222] border-[#444] text-white placeholder:text-neutral-400"
/>
<div className="space-y-2 max-h-60 overflow-y-auto pr-1">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-6">
<Loader2 className="h-6 w-6 text-emerald-400 animate-spin" />
<div className="mt-2 text-sm text-neutral-400">Loading agents...</div>
</div>
) : filteredAgents.length === 0 ? (
<div className="text-neutral-400 text-sm text-center py-6">
{search ? `No agents found matching "${search}"` : "No agents found in this folder."}
</div>
) : (
filteredAgents.map((agent) => (
<button
key={agent.id}
type="button"
onClick={() => setSelectedAgentId(agent.id)}
className={cn(
"w-full flex items-start gap-3 p-3 rounded-md border border-[#333] bg-[#232323] hover:bg-[#222] transition text-left cursor-pointer",
selectedAgentId === agent.id && "border-emerald-400 bg-[#1a1a1a] shadow-md"
)}
>
<div className="flex-1">
<div className="font-medium text-white text-base">{agent.name}</div>
<div className="text-xs text-neutral-400 mt-1">
{agent.description || "No description"}
</div>
<div className="text-[10px] text-neutral-500 mt-1">ID: {agent.id}</div>
</div>
{selectedAgentId === agent.id && (
<span className="ml-2 text-emerald-400 font-bold">Selected</span>
)}
</button>
))
)}
</div>
</div>
<DialogFooter className="p-4 pt-2 border-t border-[#333]">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Cancel
</Button>
<Button
onClick={handleSave}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={!selectedAgentId || isLoading}
>
Add Tool
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,445 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/dialogs/ApiKeysDialog.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ConfirmationDialog } from "./ConfirmationDialog";
import { ApiKey } from "@/services/agentService";
import { Edit, Eye, Key, Plus, Trash2, X } from "lucide-react";
import { useState, useEffect } from "react";
import { availableModelProviders } from "@/types/aiModels";
interface ApiKeysDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
apiKeys: ApiKey[];
isLoading: boolean;
onAddApiKey: (apiKey: {
name: string;
provider: string;
key_value: string;
}) => Promise<void>;
onUpdateApiKey: (
id: string,
apiKey: {
name: string;
provider: string;
key_value?: string;
is_active: boolean;
}
) => Promise<void>;
onDeleteApiKey: (id: string) => Promise<void>;
}
export function ApiKeysDialog({
open,
onOpenChange,
apiKeys,
isLoading,
onAddApiKey,
onUpdateApiKey,
onDeleteApiKey,
}: ApiKeysDialogProps) {
const [isAddingApiKey, setIsAddingApiKey] = useState(false);
const [isEditingApiKey, setIsEditingApiKey] = useState(false);
const [currentApiKey, setCurrentApiKey] = useState<
Partial<ApiKey & { key_value?: string }>
>({});
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [apiKeyToDelete, setApiKeyToDelete] = useState<ApiKey | null>(null);
const [isApiKeyVisible, setIsApiKeyVisible] = useState(false);
const handleAddClick = () => {
setCurrentApiKey({});
setIsAddingApiKey(true);
setIsEditingApiKey(false);
};
const handleEditClick = (apiKey: ApiKey) => {
setCurrentApiKey({ ...apiKey, key_value: "" });
setIsAddingApiKey(true);
setIsEditingApiKey(true);
};
const handleDeleteClick = (apiKey: ApiKey) => {
setApiKeyToDelete(apiKey);
setIsDeleteDialogOpen(true);
};
const handleSaveApiKey = async () => {
if (
!currentApiKey.name ||
!currentApiKey.provider ||
(!isEditingApiKey && !currentApiKey.key_value)
) {
return;
}
try {
if (currentApiKey.id) {
await onUpdateApiKey(currentApiKey.id, {
name: currentApiKey.name,
provider: currentApiKey.provider,
key_value: currentApiKey.key_value,
is_active: currentApiKey.is_active !== false,
});
} else {
await onAddApiKey({
name: currentApiKey.name,
provider: currentApiKey.provider,
key_value: currentApiKey.key_value!,
});
}
setCurrentApiKey({});
setIsAddingApiKey(false);
setIsEditingApiKey(false);
} catch (error) {
console.error("Error saving API key:", error);
}
};
const handleDeleteConfirm = async () => {
if (!apiKeyToDelete) return;
try {
await onDeleteApiKey(apiKeyToDelete.id);
setApiKeyToDelete(null);
setIsDeleteDialogOpen(false);
} catch (error) {
console.error("Error deleting API key:", error);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-hidden flex flex-col bg-[#1a1a1a] border-[#333]">
<DialogHeader>
<DialogTitle className="text-white">Manage API Keys</DialogTitle>
<DialogDescription className="text-neutral-400">
Add and manage API keys for use in your agents
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto p-1">
{isAddingApiKey ? (
<div className="space-y-4 p-4 bg-[#222] rounded-md">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-white">
{isEditingApiKey ? "Edit Key" : "New Key"}
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => {
setIsAddingApiKey(false);
setIsEditingApiKey(false);
setCurrentApiKey({});
}}
className="text-neutral-400 hover:text-white"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right text-neutral-300">
Name
</Label>
<Input
id="name"
value={currentApiKey.name || ""}
onChange={(e) =>
setCurrentApiKey({
...currentApiKey,
name: e.target.value,
})
}
className="col-span-3 bg-[#333] border-[#444] text-white"
placeholder="OpenAI GPT-4"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label
htmlFor="provider"
className="text-right text-neutral-300"
>
Provider
</Label>
<Select
value={currentApiKey.provider}
onValueChange={(value) =>
setCurrentApiKey({
...currentApiKey,
provider: value,
})
}
>
<SelectTrigger className="col-span-3 bg-[#333] border-[#444] text-white">
<SelectValue placeholder="Select Provider" />
</SelectTrigger>
<SelectContent className="bg-[#222] border-[#444] text-white">
{availableModelProviders.map((provider) => (
<SelectItem
key={provider.value}
value={provider.value}
className="data-[selected]:bg-[#333] data-[highlighted]:bg-[#333] !text-white focus:!text-white hover:text-emerald-400 data-[selected]:!text-emerald-400"
>
{provider.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label
htmlFor="key_value"
className="text-right text-neutral-300"
>
Key Value
</Label>
<div className="col-span-3 relative">
<Input
id="key_value"
value={currentApiKey.key_value || ""}
onChange={(e) =>
setCurrentApiKey({
...currentApiKey,
key_value: e.target.value,
})
}
className="bg-[#333] border-[#444] text-white pr-10"
type={isApiKeyVisible ? "text" : "password"}
placeholder={
isEditingApiKey
? "Leave blank to keep the current value"
: "sk-..."
}
/>
<Button
variant="ghost"
size="sm"
type="button"
className="absolute right-2 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0 text-neutral-400 hover:text-white"
onClick={() => setIsApiKeyVisible(!isApiKeyVisible)}
>
<Eye className="h-4 w-4" />
</Button>
</div>
</div>
{isEditingApiKey && (
<div className="grid grid-cols-4 items-center gap-4">
<Label
htmlFor="is_active"
className="text-right text-neutral-300"
>
Status
</Label>
<div className="col-span-3 flex items-center">
<Checkbox
id="is_active"
checked={currentApiKey.is_active !== false}
onCheckedChange={(checked) =>
setCurrentApiKey({
...currentApiKey,
is_active: !!checked,
})
}
className="mr-2 data-[state=checked]:bg-emerald-400 data-[state=checked]:border-emerald-400"
/>
<Label htmlFor="is_active" className="text-neutral-300">
Active
</Label>
</div>
</div>
)}
</div>
<div className="flex justify-end gap-2 mt-4">
<Button
variant="outline"
onClick={() => {
setIsAddingApiKey(false);
setIsEditingApiKey(false);
setCurrentApiKey({});
}}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Cancel
</Button>
<Button
onClick={handleSaveApiKey}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={isLoading}
>
{isLoading && (
<div className="animate-spin h-4 w-4 border-2 border-black border-t-transparent rounded-full mr-1"></div>
)}
{isEditingApiKey ? "Update" : "Add"}
</Button>
</div>
</div>
) : (
<>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-white">
Available Keys
</h3>
<Button
onClick={handleAddClick}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
>
<Plus className="mr-2 h-4 w-4" />
New Key
</Button>
</div>
{isLoading ? (
<div className="flex items-center justify-center h-40">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-emerald-400"></div>
</div>
) : apiKeys.length > 0 ? (
<div className="space-y-2">
{apiKeys.map((apiKey) => (
<div
key={apiKey.id}
className="flex items-center justify-between p-3 bg-[#222] rounded-md border border-[#333] hover:border-emerald-400/30"
>
<div>
<p className="font-medium text-white">{apiKey.name}</p>
<div className="flex items-center gap-2 mt-1">
<Badge
variant="outline"
className="bg-[#333] text-emerald-400 border-emerald-400/30"
>
{apiKey.provider.toUpperCase()}
</Badge>
<p className="text-xs text-neutral-400">
Created on{" "}
{new Date(apiKey.created_at).toLocaleDateString()}
</p>
{!apiKey.is_active && (
<Badge
variant="outline"
className="bg-[#333] text-red-400 border-red-400/30"
>
Inactive
</Badge>
)}
</div>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditClick(apiKey)}
className="text-neutral-300 hover:text-emerald-400 hover:bg-[#333]"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteClick(apiKey)}
className="text-red-500 hover:text-red-400 hover:bg-[#333]"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-10 border border-dashed border-[#333] rounded-md bg-[#222] text-neutral-400">
<Key className="mx-auto h-10 w-10 text-neutral-500 mb-3" />
<p>You don't have any API keys registered</p>
<p className="text-sm mt-1">
Add your API keys to use them in your agents
</p>
<Button
onClick={handleAddClick}
className="mt-4 bg-[#333] text-emerald-400 hover:bg-[#444]"
>
<Plus className="mr-2 h-4 w-4" />
Add Key
</Button>
</div>
)}
</>
)}
</div>
<DialogFooter className="border-t border-[#333] pt-4">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Close
</Button>
</DialogFooter>
</DialogContent>
<ConfirmationDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
title="Confirm Delete"
description={`Are you sure you want to delete the key "${apiKeyToDelete?.name}"? This action cannot be undone.`}
confirmText="Delete"
confirmVariant="destructive"
onConfirm={handleDeleteConfirm}
/>
</Dialog>
);
}

View File

@@ -0,0 +1,93 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/dialogs/ConfirmationDialog.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface ConfirmationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
cancelText?: string;
confirmText?: string;
confirmVariant?: "default" | "destructive";
onConfirm: () => void;
}
export function ConfirmationDialog({
open,
onOpenChange,
title,
description,
cancelText = "Cancel",
confirmText = "Confirm",
confirmVariant = "default",
onConfirm,
}: ConfirmationDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="bg-[#1a1a1a] border-[#333] text-white">
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription className="text-neutral-400">
{description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white">
{cancelText}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
onConfirm();
}}
className={
confirmVariant === "destructive"
? "bg-red-600 text-white hover:bg-red-700"
: "bg-emerald-400 text-black hover:bg-[#00cc7d]"
}
>
{confirmText}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,237 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/dialogs/CustomMCPDialog.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { X, Plus } from "lucide-react";
import { useState, useEffect } from "react";
interface CustomMCPHeader {
id: string;
key: string;
value: string;
}
interface CustomMCPServer {
url: string;
headers: Record<string, string>;
}
interface CustomMCPDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (customMCP: CustomMCPServer) => void;
initialCustomMCP?: CustomMCPServer | null;
clientId: string;
}
export function CustomMCPDialog({
open,
onOpenChange,
onSave,
initialCustomMCP = null,
clientId,
}: CustomMCPDialogProps) {
const [customMCP, setCustomMCP] = useState<Partial<CustomMCPServer>>({
url: "",
headers: {},
});
const [headersList, setHeadersList] = useState<CustomMCPHeader[]>([]);
useEffect(() => {
if (open) {
if (initialCustomMCP) {
setCustomMCP(initialCustomMCP);
const headersList = Object.entries(initialCustomMCP.headers || {}).map(
([key, value], index) => ({
id: `header-${index}`,
key,
value,
})
);
setHeadersList(headersList);
} else {
setCustomMCP({ url: "", headers: {} });
setHeadersList([]);
}
}
}, [open, initialCustomMCP]);
const handleAddHeader = () => {
setHeadersList([
...headersList,
{ id: `header-${Date.now()}`, key: "", value: "" },
]);
};
const handleRemoveHeader = (id: string) => {
setHeadersList(headersList.filter((header) => header.id !== id));
};
const handleHeaderChange = (
id: string,
field: "key" | "value",
value: string
) => {
setHeadersList(
headersList.map((header) =>
header.id === id ? { ...header, [field]: value } : header
)
);
};
const handleSave = () => {
if (!customMCP.url) return;
const headersObject: Record<string, string> = {};
headersList.forEach((header) => {
if (header.key.trim()) {
headersObject[header.key] = header.value;
}
});
onSave({
url: customMCP.url,
headers: headersObject,
});
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-hidden flex flex-col bg-[#1a1a1a] border-[#333]">
<DialogHeader>
<DialogTitle className="text-white">
Configure Custom MCP
</DialogTitle>
<DialogDescription className="text-neutral-400">
Configure the URL and HTTP headers for the custom MCP.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto p-4">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="custom-mcp-url" className="text-neutral-300">
MCP URL
</Label>
<Input
id="custom-mcp-url"
value={customMCP.url || ""}
onChange={(e) =>
setCustomMCP({
...customMCP,
url: e.target.value,
})
}
className="bg-[#222] border-[#444] text-white"
placeholder="https://meu-servidor-mcp.com/api"
/>
</div>
<div className="space-y-3">
<h3 className="text-sm font-medium text-white">HTTP Headers</h3>
<div className="border border-[#444] rounded-md p-3 bg-[#222]">
{headersList.map((header) => (
<div
key={header.id}
className="grid grid-cols-5 items-center gap-2 mb-2"
>
<Input
value={header.key}
onChange={(e) =>
handleHeaderChange(header.id, "key", e.target.value)
}
className="col-span-2 bg-[#333] border-[#444] text-white"
placeholder="Header Name"
/>
<Input
value={header.value}
onChange={(e) =>
handleHeaderChange(header.id, "value", e.target.value)
}
className="col-span-2 bg-[#333] border-[#444] text-white"
placeholder="Header Value"
/>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveHeader(header.id)}
className="col-span-1 h-8 text-red-500 hover:text-red-400 hover:bg-[#444]"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={handleAddHeader}
className="w-full mt-2 border-emerald-400 text-emerald-400 hover:bg-emerald-400/10 bg-[#222] hover:text-emerald-400"
>
<Plus className="h-4 w-4 mr-1" /> Add Header
</Button>
</div>
</div>
</div>
</div>
<DialogFooter className="p-4 pt-2 border-t border-[#333]">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Cancel
</Button>
<Button
onClick={handleSave}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={!customMCP.url}
>
{initialCustomMCP?.url ? "Save Custom MCP" : "Add Custom MCP"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,864 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/dialogs/CustomToolDialog.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
X,
Plus,
Info,
Trash,
Globe,
FileJson,
LayoutList,
Settings,
Database,
Code,
Server,
Wand
} from "lucide-react";
import { useState, useEffect } from "react";
import { HTTPTool, HTTPToolParameter } from "@/types/agent";
import { sanitizeAgentName } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
interface CustomToolDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (tool: HTTPTool) => void;
initialTool?: HTTPTool | null;
}
export function CustomToolDialog({
open,
onOpenChange,
onSave,
initialTool = null,
}: CustomToolDialogProps) {
const [tool, setTool] = useState<Partial<HTTPTool>>({
name: "",
method: "GET",
endpoint: "",
description: "",
headers: {},
values: {},
parameters: {},
error_handling: {
timeout: 30,
retry_count: 0,
fallback_response: {},
},
});
const [headersList, setHeadersList] = useState<{ id: string; key: string; value: string }[]>([]);
const [bodyParams, setBodyParams] = useState<{ id: string; key: string; param: HTTPToolParameter }[]>([]);
const [pathParams, setPathParams] = useState<{ id: string; key: string; desc: string }[]>([]);
const [queryParams, setQueryParams] = useState<{ id: string; key: string; value: string }[]>([]);
const [valuesList, setValuesList] = useState<{ id: string; key: string; value: string }[]>([]);
const [timeout, setTimeout] = useState<number>(30);
const [fallbackError, setFallbackError] = useState<string>("");
const [fallbackMessage, setFallbackMessage] = useState<string>("");
const [activeTab, setActiveTab] = useState("basics");
useEffect(() => {
if (open) {
if (initialTool) {
setTool(initialTool);
setHeadersList(
Object.entries(initialTool.headers || {}).map(([key, value], idx) => ({
id: `header-${idx}`,
key,
value,
}))
);
setBodyParams(
Object.entries(initialTool.parameters?.body_params || {}).map(([key, param], idx) => ({
id: `body-${idx}`,
key,
param,
}))
);
setPathParams(
Object.entries(initialTool.parameters?.path_params || {}).map(([key, desc], idx) => ({
id: `path-${idx}`,
key,
desc: desc as string,
}))
);
setQueryParams(
Object.entries(initialTool.parameters?.query_params || {}).map(([key, value], idx) => ({
id: `query-${idx}`,
key,
value: value as string,
}))
);
setValuesList(
Object.entries(initialTool.values || {}).map(([key, value], idx) => ({
id: `val-${idx}`,
key,
value: value as string,
}))
);
setTimeout(initialTool.error_handling?.timeout || 30);
setFallbackError(initialTool.error_handling?.fallback_response?.error || "");
setFallbackMessage(initialTool.error_handling?.fallback_response?.message || "");
} else {
setTool({
name: "",
method: "GET",
endpoint: "",
description: "",
headers: {},
values: {},
parameters: {},
error_handling: {
timeout: 30,
retry_count: 0,
fallback_response: {},
},
});
setHeadersList([]);
setBodyParams([]);
setPathParams([]);
setQueryParams([]);
setValuesList([]);
setTimeout(30);
setFallbackError("");
setFallbackMessage("");
}
setActiveTab("basics");
}
}, [open, initialTool]);
const handleAddHeader = () => {
setHeadersList([...headersList, { id: `header-${Date.now()}`, key: "", value: "" }]);
};
const handleRemoveHeader = (id: string) => {
setHeadersList(headersList.filter((h) => h.id !== id));
};
const handleHeaderChange = (id: string, field: "key" | "value", value: string) => {
setHeadersList(headersList.map((h) => (h.id === id ? { ...h, [field]: value } : h)));
};
const handleAddBodyParam = () => {
setBodyParams([
...bodyParams,
{
id: `body-${Date.now()}`,
key: "",
param: { type: "string", required: false, description: "" },
},
]);
};
const handleRemoveBodyParam = (id: string) => {
setBodyParams(bodyParams.filter((p) => p.id !== id));
};
const handleBodyParamChange = (id: string, field: "key" | keyof HTTPToolParameter, value: string | boolean) => {
setBodyParams(
bodyParams.map((p) =>
p.id === id
? field === "key"
? { ...p, key: value as string }
: { ...p, param: { ...p.param, [field]: value } }
: p
)
);
};
// Path Params
const handleAddPathParam = () => {
setPathParams([...pathParams, { id: `path-${Date.now()}`, key: "", desc: "" }]);
};
const handleRemovePathParam = (id: string) => {
setPathParams(pathParams.filter((p) => p.id !== id));
};
const handlePathParamChange = (id: string, field: "key" | "desc", value: string) => {
setPathParams(pathParams.map((p) => (p.id === id ? { ...p, [field]: value } : p)));
};
// Query Params
const handleAddQueryParam = () => {
setQueryParams([...queryParams, { id: `query-${Date.now()}`, key: "", value: "" }]);
};
const handleRemoveQueryParam = (id: string) => {
setQueryParams(queryParams.filter((q) => q.id !== id));
};
const handleQueryParamChange = (id: string, field: "key" | "value", value: string) => {
setQueryParams(queryParams.map((q) => (q.id === id ? { ...q, [field]: value } : q)));
};
// Values
const handleAddValue = () => {
setValuesList([...valuesList, { id: `val-${Date.now()}`, key: "", value: "" }]);
};
const handleRemoveValue = (id: string) => {
setValuesList(valuesList.filter((v) => v.id !== id));
};
const handleValueChange = (id: string, field: "key" | "value", value: string) => {
setValuesList(valuesList.map((v) => (v.id === id ? { ...v, [field]: value } : v)));
};
const handleSave = () => {
if (!tool.name || !tool.endpoint) return;
const headersObject: Record<string, string> = {};
headersList.forEach((h) => {
if (h.key.trim()) headersObject[h.key] = h.value;
});
const bodyParamsObject: Record<string, HTTPToolParameter> = {};
bodyParams.forEach((p) => {
if (p.key.trim()) bodyParamsObject[p.key] = p.param;
});
const pathParamsObject: Record<string, string> = {};
pathParams.forEach((p) => {
if (p.key.trim()) pathParamsObject[p.key] = p.desc;
});
const queryParamsObject: Record<string, string> = {};
queryParams.forEach((q) => {
if (q.key.trim()) queryParamsObject[q.key] = q.value;
});
const valuesObject: Record<string, string> = {};
valuesList.forEach((v) => {
if (v.key.trim()) valuesObject[v.key] = v.value;
});
// Sanitize the tool name
const sanitizedName = sanitizeAgentName(tool.name);
onSave({
...(tool as HTTPTool),
name: sanitizedName,
headers: headersObject,
values: valuesObject,
parameters: {
...tool.parameters,
body_params: bodyParamsObject,
path_params: pathParamsObject,
query_params: queryParamsObject,
},
error_handling: {
timeout,
retry_count: tool.error_handling?.retry_count ?? 0,
fallback_response: {
error: fallbackError,
message: fallbackMessage,
},
},
} as HTTPTool);
onOpenChange(false);
};
const ParamField = ({
children,
label,
tooltip
}: {
children: React.ReactNode,
label: string,
tooltip?: string
}) => (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Label className="text-sm font-medium text-neutral-200">
{label}
</Label>
{tooltip && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-neutral-400 cursor-help" />
</TooltipTrigger>
<TooltipContent className="bg-neutral-800 border-neutral-700 text-white p-3 max-w-sm">
<p className="text-xs">{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{children}
</div>
);
const FieldList = <T extends Record<string, any>>({
items,
onAdd,
onRemove,
onChange,
fields,
addText,
emptyText,
icon
}: {
items: T[],
onAdd: () => void,
onRemove: (id: string) => void,
onChange: (id: string, field: string, value: any) => void,
fields: { name: string, field: string, placeholder: string, width: number, type?: string }[],
addText: string,
emptyText: string,
icon: React.ReactNode
}) => (
<div className="border border-neutral-700 rounded-md p-3 bg-neutral-800/50">
{items.length > 0 ? (
<div className="space-y-2 mb-3">
{items.map((item) => (
<div key={item.id} className="flex items-center gap-2 group">
<div className="flex-1 flex gap-2 w-full">
{fields.map(field => {
// Calculate percentage width based on the field's width value
const widthPercent = (field.width / 12) * 100;
return (
<div
key={field.name}
className="flex-shrink-0"
style={{ width: `${widthPercent}%` }}
>
{field.type === 'select' ? (
<select
value={(field.field.includes('.')
? item.param?.[field.field.split('.')[1]]
: item[field.field]) || ''}
onChange={(e) => onChange(
item.id,
field.field,
e.target.value
)}
className="w-full h-9 px-3 py-1 rounded-md bg-neutral-900 border border-neutral-700 text-white text-sm"
>
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
</select>
) : field.type === 'checkbox' ? (
<div className="flex items-center h-9 w-full justify-center">
<label className="flex items-center gap-1.5">
<input
type="checkbox"
checked={item.param?.required || false}
onChange={(e) => onChange(
item.id,
field.field,
(e.target as HTMLInputElement).checked
)}
className="accent-emerald-400 rounded"
/>
<span className="text-xs text-neutral-300">Required</span>
</label>
</div>
) : (
<Input
value={(field.field.includes('.')
? item.param?.[field.field.split('.')[1]]
: item[field.field]) || ''}
onChange={(e) => onChange(
item.id,
field.field,
e.target.value
)}
className="h-9 w-full bg-neutral-900 border-neutral-700 text-white placeholder:text-neutral-500 text-sm"
placeholder={field.placeholder}
/>
)}
</div>
);
})}
</div>
<Button
variant="ghost"
size="icon"
onClick={() => onRemove(item.id)}
className="h-9 w-9 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-red-400 hover:text-red-300 hover:bg-red-900/20"
>
<Trash className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<div className="py-6 flex flex-col items-center justify-center text-center">
{icon}
<p className="mt-2 text-neutral-400 text-sm">{emptyText}</p>
</div>
)}
<Button
variant="outline"
size="sm"
onClick={onAdd}
className="w-full border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/10 bg-neutral-800/30"
>
<Plus className="h-4 w-4 mr-1.5" /> {addText}
</Button>
</div>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[850px] max-h-[90vh] overflow-hidden flex flex-col bg-neutral-900 border-neutral-700 p-0">
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header Area */}
<div className="flex items-start justify-between p-6 border-b border-neutral-800">
<div>
<h2 className="text-xl font-semibold text-white flex items-center gap-2">
<Wand className="h-5 w-5 text-emerald-400" />
{initialTool ? "Edit Custom Tool" : "Create Custom Tool"}
</h2>
<p className="text-neutral-400 text-sm mt-1">
Configure an HTTP tool for your agent to interact with external APIs
</p>
</div>
<div className="flex items-center gap-3">
<Badge variant="outline" className="bg-neutral-800 text-emerald-400 border-emerald-500/30 uppercase text-xs font-semibold px-2 py-0.5">
{tool.method || "GET"}
</Badge>
</div>
</div>
{/* Main Content Area */}
<div className="flex-1 flex overflow-hidden">
{/* Left Side - Navigation */}
<div className="w-[200px] bg-neutral-800/50 border-r border-neutral-800 flex-shrink-0">
<div className="py-4">
<nav className="space-y-1 px-2">
{[
{ id: 'basics', label: 'Basic Info', icon: <Info className="h-4 w-4" /> },
{ id: 'endpoint', label: 'Endpoint', icon: <Globe className="h-4 w-4" /> },
{ id: 'headers', label: 'Headers', icon: <Server className="h-4 w-4" /> },
{ id: 'body', label: 'Body Params', icon: <FileJson className="h-4 w-4" /> },
{ id: 'path', label: 'Path Params', icon: <Code className="h-4 w-4" /> },
{ id: 'query', label: 'Query Params', icon: <LayoutList className="h-4 w-4" /> },
{ id: 'defaults', label: 'Default Values', icon: <Database className="h-4 w-4" /> },
{ id: 'error', label: 'Error Handling', icon: <Settings className="h-4 w-4" /> },
].map(item => (
<button
key={item.id}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-sm rounded-md transition-colors",
activeTab === item.id
? "bg-emerald-500/10 text-emerald-400"
: "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
)}
onClick={() => setActiveTab(item.id)}
>
{item.icon}
<span>{item.label}</span>
{(
(item.id === 'headers' && headersList.length > 0) ||
(item.id === 'body' && bodyParams.length > 0) ||
(item.id === 'path' && pathParams.length > 0) ||
(item.id === 'query' && queryParams.length > 0) ||
(item.id === 'defaults' && valuesList.length > 0)
) && (
<span className="ml-auto bg-emerald-500/20 text-emerald-400 text-xs rounded-full px-1.5 py-0.5 min-w-[18px]">
{item.id === 'headers' && headersList.length}
{item.id === 'body' && bodyParams.length}
{item.id === 'path' && pathParams.length}
{item.id === 'query' && queryParams.length}
{item.id === 'defaults' && valuesList.length}
</span>
)}
</button>
))}
</nav>
</div>
</div>
{/* Right Side - Content */}
<div className="flex-1 overflow-auto">
{activeTab === 'basics' && (
<div className="p-6">
<div className="space-y-6">
<ParamField
label="Tool Name"
tooltip="A unique identifier for this tool. Will be used by the agent to reference this tool."
>
<Input
value={tool.name || ""}
onChange={(e) => setTool({ ...tool, name: e.target.value })}
onBlur={(e) => {
const sanitizedName = sanitizeAgentName(e.target.value);
if (sanitizedName !== e.target.value) {
setTool({ ...tool, name: sanitizedName });
}
}}
className="bg-neutral-800 border-neutral-700 text-white"
placeholder="e.g. weatherApi, searchTool"
/>
</ParamField>
<ParamField
label="Description"
tooltip="A clear description of what this tool does. The agent will use this to determine when to use the tool."
>
<Input
value={tool.description || ""}
onChange={(e) => setTool({ ...tool, description: e.target.value })}
className="bg-neutral-800 border-neutral-700 text-white"
placeholder="Provides weather information for a given location"
/>
</ParamField>
</div>
</div>
)}
{activeTab === 'endpoint' && (
<div className="p-6">
<div className="space-y-6">
<ParamField
label="HTTP Method"
tooltip="The HTTP method to use for the request."
>
<div className="flex gap-1">
{['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].map(method => (
<Button
key={method}
type="button"
onClick={() => setTool({ ...tool, method })}
className={cn(
"px-4 py-2 rounded font-medium text-sm flex-1",
tool.method === method
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
: "bg-neutral-800 text-neutral-400 border border-neutral-700 hover:bg-neutral-700 hover:text-neutral-200"
)}
>
{method}
</Button>
))}
</div>
</ParamField>
<ParamField
label="Endpoint URL"
tooltip="The complete URL to call. Can include path parameters with the format {paramName}."
>
<div className="space-y-1">
<Input
value={tool.endpoint || ""}
onChange={(e) => setTool({ ...tool, endpoint: e.target.value })}
className="bg-neutral-800 border-neutral-700 text-white font-mono"
placeholder="https://api.example.com/v1/resource/{id}"
/>
{tool.endpoint && tool.endpoint.includes('{') && (
<p className="text-xs text-amber-400">
<Info className="h-3 w-3 inline-block mr-1" />
URL contains path variables. Don't forget to define them in the Path Parameters section.
</p>
)}
</div>
</ParamField>
<div className="p-4 bg-neutral-800/80 border border-emerald-600/20 rounded-md mt-6">
<h3 className="text-emerald-400 text-sm font-medium mb-2 flex items-center">
<Info className="h-4 w-4 mr-2" /> How to use variables in your endpoint
</h3>
<p className="text-neutral-300 text-sm mb-3">
You can use dynamic variables in your endpoint using the <code className="bg-neutral-700 px-1.5 py-0.5 rounded text-emerald-300">{"{VARIABLE_NAME}"}</code> syntax.
</p>
<div className="grid grid-cols-1 gap-4 text-xs">
<div>
<h4 className="font-medium text-white mb-1">Example:</h4>
<code className="block bg-neutral-900 text-neutral-200 p-2 rounded">
https://api.example.com/users/<span className="text-emerald-400">{"{userId}"}</span>/profile
</code>
<p className="mt-1 text-neutral-400">Define <code className="bg-neutral-700 px-1 py-0.5 rounded text-emerald-300">userId</code> as a Path Parameter</p>
</div>
</div>
</div>
</div>
</div>
)}
{activeTab === 'headers' && (
<div className="p-6">
<ParamField
label="HTTP Headers"
tooltip="Headers to send with each request. Common examples include Authorization, Content-Type, Accept, etc."
>
<FieldList
items={headersList}
onAdd={handleAddHeader}
onRemove={handleRemoveHeader}
onChange={(id, field, value) => {
handleHeaderChange(
id,
field as "key" | "value",
value as string
);
}}
fields={[
{ name: "Header Name", field: "key", placeholder: "e.g. Authorization", width: 6 },
{ name: "Header Value", field: "value", placeholder: "e.g. Bearer token123", width: 6 }
]}
addText="Add Header"
emptyText="No headers configured. Add headers to customize your HTTP requests."
icon={<Server className="h-10 w-10 text-neutral-700" />}
/>
</ParamField>
</div>
)}
{activeTab === 'body' && (
<div className="p-6">
<div className="mb-6 p-4 bg-neutral-800/80 border border-emerald-600/20 rounded-md">
<h3 className="text-emerald-400 text-sm font-medium mb-2 flex items-center">
<Info className="h-4 w-4 mr-2" /> About Body Parameters
</h3>
<p className="text-neutral-300 text-sm">
Parameters that will be sent in the request body. Only applicable for POST, PUT, and PATCH methods.
You can use variables like <code className="bg-neutral-700 px-1.5 py-0.5 rounded text-emerald-300">{"{variableName}"}</code> that will be replaced at runtime.
</p>
</div>
<FieldList
items={bodyParams}
onAdd={handleAddBodyParam}
onRemove={handleRemoveBodyParam}
onChange={(id, field, value) => {
if (field === "key") {
handleBodyParamChange(id, "key", value as string);
} else if (field.startsWith("param.")) {
const paramField = field.split('.')[1] as keyof HTTPToolParameter;
handleBodyParamChange(id, paramField,
paramField === "required" ? value as boolean : value as string
);
}
}}
fields={[
{ name: "Parameter Name", field: "key", placeholder: "e.g. userId", width: 4 },
{ name: "Type", field: "param.type", placeholder: "", width: 2, type: "select" },
{ name: "Description", field: "param.description", placeholder: "What this parameter does", width: 4 },
{ name: "Required", field: "param.required", placeholder: "", width: 2, type: "checkbox" }
]}
addText="Add Body Parameter"
emptyText="No body parameters configured. Add parameters for POST/PUT/PATCH requests."
icon={<FileJson className="h-10 w-10 text-neutral-700" />}
/>
</div>
)}
{activeTab === 'path' && (
<div className="p-6">
<div className="mb-6 p-4 bg-neutral-800/80 border border-emerald-600/20 rounded-md">
<h3 className="text-emerald-400 text-sm font-medium mb-2 flex items-center">
<Info className="h-4 w-4 mr-2" /> About Path Parameters
</h3>
<p className="text-neutral-300 text-sm">
Path parameters are placeholders in your URL path that will be replaced with actual values.
For example, in <code className="bg-neutral-700 px-1.5 py-0.5 rounded text-emerald-300">https://api.example.com/users/{"{userId}"}</code>,
<code className="bg-neutral-700 px-1 py-0.5 rounded text-emerald-300">userId</code> is a path parameter.
</p>
</div>
<FieldList
items={pathParams}
onAdd={handleAddPathParam}
onRemove={handleRemovePathParam}
onChange={(id, field, value) => {
handlePathParamChange(
id,
field as "key" | "desc",
value as string
);
}}
fields={[
{ name: "Parameter Name", field: "key", placeholder: "e.g. userId", width: 4 },
{ name: "Description", field: "desc", placeholder: "What this parameter represents", width: 6 },
{ name: "Required", field: "required", placeholder: "", width: 2, type: "checkbox" }
]}
addText="Add Path Parameter"
emptyText="No path parameters configured. Add parameters if your endpoint URL contains {placeholders}."
icon={<Code className="h-10 w-10 text-neutral-700" />}
/>
</div>
)}
{activeTab === 'query' && (
<div className="p-6">
<div className="mb-6 p-4 bg-neutral-800/80 border border-emerald-600/20 rounded-md">
<h3 className="text-emerald-400 text-sm font-medium mb-2 flex items-center">
<Info className="h-4 w-4 mr-2" /> About Query Parameters
</h3>
<p className="text-neutral-300 text-sm">
Query parameters are added to the URL after a question mark (?) to filter or customize the request.
For example: <code className="bg-neutral-700 px-1.5 py-0.5 rounded text-emerald-300">https://api.example.com/search?query=term&limit=10</code>
</p>
</div>
<FieldList
items={queryParams}
onAdd={handleAddQueryParam}
onRemove={handleRemoveQueryParam}
onChange={(id, field, value) => {
handleQueryParamChange(
id,
field as "key" | "value",
value as string
);
}}
fields={[
{ name: "Parameter Name", field: "key", placeholder: "e.g. search", width: 4 },
{ name: "Description", field: "value", placeholder: "What this parameter does", width: 6 },
{ name: "Required", field: "required", placeholder: "", width: 2, type: "checkbox" }
]}
addText="Add Query Parameter"
emptyText="No query parameters configured. Add parameters to customize your URL query string."
icon={<LayoutList className="h-10 w-10 text-neutral-700" />}
/>
</div>
)}
{activeTab === 'defaults' && (
<div className="p-6">
<div className="mb-6 p-4 bg-neutral-800/80 border border-emerald-600/20 rounded-md">
<h3 className="text-emerald-400 text-sm font-medium mb-2 flex items-center">
<Info className="h-4 w-4 mr-2" /> About Default Values
</h3>
<p className="text-neutral-300 text-sm">
Set default values for parameters you've defined in the Body, Path, or Query sections.
These values will be used when the parameter isn't explicitly provided during a tool call.
</p>
</div>
<FieldList
items={valuesList}
onAdd={handleAddValue}
onRemove={handleRemoveValue}
onChange={(id, field, value) => {
handleValueChange(
id,
field as "key" | "value",
value as string
);
}}
fields={[
{ name: "Parameter Name", field: "key", placeholder: "Name of an existing parameter", width: 5 },
{ name: "Default Value", field: "value", placeholder: "Value to use if not provided", width: 7 }
]}
addText="Add Default Value"
emptyText="No default values configured. Add default values for your previously defined parameters."
icon={<Database className="h-10 w-10 text-neutral-700" />}
/>
</div>
)}
{activeTab === 'error' && (
<div className="p-6">
<div className="mb-6 p-4 bg-neutral-800/80 border border-emerald-600/20 rounded-md">
<h3 className="text-emerald-400 text-sm font-medium mb-2 flex items-center">
<Info className="h-4 w-4 mr-2" /> About Error Handling
</h3>
<p className="text-neutral-300 text-sm">
Configure how the tool should handle errors and timeouts when making HTTP requests.
Proper error handling ensures reliable operation of your agent.
</p>
</div>
<div className="space-y-6">
<ParamField
label="Timeout (seconds)"
tooltip="Maximum time to wait for a response before considering the request failed."
>
<Input
type="number"
min={1}
value={timeout}
onChange={(e) => setTimeout(Number(e.target.value))}
className="w-32 bg-neutral-800 border-neutral-700 text-white"
/>
</ParamField>
<ParamField
label="Fallback Error Code"
tooltip="Error code to return if the request fails."
>
<Input
value={fallbackError}
onChange={(e) => setFallbackError(e.target.value)}
className="bg-neutral-800 border-neutral-700 text-white"
placeholder="e.g. API_ERROR"
/>
</ParamField>
<ParamField
label="Fallback Error Message"
tooltip="Human-readable message to return if the request fails."
>
<Input
value={fallbackMessage}
onChange={(e) => setFallbackMessage(e.target.value)}
className="bg-neutral-800 border-neutral-700 text-white"
placeholder="e.g. Failed to retrieve data from the API."
/>
</ParamField>
</div>
</div>
)}
</div>
</div>
{/* Footer Area */}
<div className="border-t border-neutral-800 p-4 flex items-center justify-between">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
className="text-neutral-400 hover:text-neutral-100 hover:bg-neutral-800"
>
Cancel
</Button>
<Button
onClick={handleSave}
className="bg-emerald-500 text-neutral-950 hover:bg-emerald-400"
disabled={!tool.name || !tool.endpoint}
>
{initialTool ? "Save Changes" : "Create Tool"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,158 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/dialogs/FolderDialog.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useState, useEffect } from "react";
interface Folder {
id: string;
name: string;
description: string;
}
interface FolderDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (folder: { name: string; description: string }) => Promise<void>;
editingFolder: Folder | null;
isLoading?: boolean;
}
export function FolderDialog({
open,
onOpenChange,
onSave,
editingFolder,
isLoading = false,
}: FolderDialogProps) {
const [folder, setFolder] = useState<{
name: string;
description: string;
}>({
name: "",
description: "",
});
useEffect(() => {
if (editingFolder) {
setFolder({
name: editingFolder.name,
description: editingFolder.description,
});
} else {
setFolder({
name: "",
description: "",
});
}
}, [editingFolder, open]);
const handleSave = async () => {
await onSave(folder);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] bg-[#1a1a1a] border-[#333] text-white">
<DialogHeader>
<DialogTitle>
{editingFolder ? "Edit Folder" : "New Folder"}
</DialogTitle>
<DialogDescription className="text-neutral-400">
{editingFolder
? "Update the existing folder information"
: "Fill in the information to create a new folder"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="folder-name" className="text-neutral-300">
Folder Name
</Label>
<Input
id="folder-name"
value={folder.name}
onChange={(e) =>
setFolder({ ...folder, name: e.target.value })
}
className="bg-[#222] border-[#444] text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="folder-description" className="text-neutral-300">
Description (optional)
</Label>
<Textarea
id="folder-description"
value={folder.description}
onChange={(e) =>
setFolder({ ...folder, description: e.target.value })
}
className="bg-[#222] border-[#444] text-white resize-none h-20"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Cancel
</Button>
<Button
onClick={handleSave}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={!folder.name || isLoading}
>
{isLoading ? (
<div className="animate-spin h-4 w-4 border-2 border-black border-t-transparent rounded-full mr-1"></div>
) : null}
{editingFolder ? "Save Changes" : "Create Folder"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,239 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/dialogs/ImportAgentDialog.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 15, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Loader2, Upload, FileJson } from "lucide-react";
import { importAgentFromJson } from "@/services/agentService";
import { useToast } from "@/hooks/use-toast";
interface ImportAgentDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
clientId: string;
folderId?: string | null;
}
export function ImportAgentDialog({
open,
onOpenChange,
onSuccess,
clientId,
folderId,
}: ImportAgentDialogProps) {
const { toast } = useToast();
const [file, setFile] = useState<File | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setDragActive(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile && droppedFile.type === "application/json") {
setFile(droppedFile);
} else {
toast({
title: "Invalid file",
description: "Please upload a JSON file",
variant: "destructive",
});
}
};
const handleSelectFile = () => {
fileInputRef.current?.click();
};
const handleImport = async () => {
if (!file || !clientId) return;
try {
setIsLoading(true);
await importAgentFromJson(file, clientId, folderId);
toast({
title: "Import successful",
description: folderId
? "Agent was imported successfully and added to the current folder"
: "Agent was imported successfully",
});
// Call the success callback to refresh the agent list
onSuccess();
// Close the dialog
onOpenChange(false);
// Reset state
setFile(null);
} catch (error) {
console.error("Error importing agent:", error);
toast({
title: "Import failed",
description: "There was an error importing the agent",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const resetState = () => {
if (!isLoading) {
setFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
return (
<Dialog
open={open}
onOpenChange={(newOpen) => {
if (!newOpen) {
resetState();
}
onOpenChange(newOpen);
}}
>
<DialogContent className="sm:max-w-[500px] bg-[#1a1a1a] border-[#333] text-white">
<DialogHeader>
<DialogTitle className="text-white">Import Agent</DialogTitle>
<DialogDescription className="text-neutral-400">
Upload a JSON file to import an agent
</DialogDescription>
</DialogHeader>
<div className="flex flex-col space-y-4 py-4">
<div
className={`h-40 border-2 border-dashed rounded-md flex flex-col items-center justify-center p-4 transition-colors cursor-pointer ${
dragActive
? "border-emerald-400 bg-emerald-900/20"
: file
? "border-emerald-600 bg-emerald-900/10"
: "border-[#444] hover:border-emerald-600/50"
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleSelectFile}
>
{file ? (
<div className="flex flex-col items-center space-y-2">
<FileJson className="h-10 w-10 text-emerald-400" />
<span className="text-emerald-400 font-medium">{file.name}</span>
<span className="text-neutral-400 text-xs">
{Math.round(file.size / 1024)} KB
</span>
</div>
) : (
<div className="flex flex-col items-center space-y-2">
<Upload className="h-10 w-10 text-neutral-500" />
<p className="text-neutral-400 text-center">
Drag & drop your JSON file here or click to browse
</p>
<span className="text-neutral-500 text-xs">
Only JSON files are accepted
</span>
</div>
)}
</div>
<input
type="file"
ref={fileInputRef}
accept=".json,application/json"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleImport}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={!file || isLoading}
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Importing...
</>
) : (
"Import Agent"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,278 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/dialogs/MCPDialog.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { MCPServer } from "@/types/mcpServer";
import { Server } from "lucide-react";
import { useState, useEffect } from "react";
interface MCPDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (mcpConfig: {
id: string;
envs: Record<string, string>;
tools: string[];
}) => void;
availableMCPs: MCPServer[];
selectedMCP?: MCPServer | null;
initialEnvs?: Record<string, string>;
initialTools?: string[];
clientId: string;
}
export function MCPDialog({
open,
onOpenChange,
onSave,
availableMCPs,
selectedMCP: initialSelectedMCP = null,
initialEnvs = {},
initialTools = [],
clientId,
}: MCPDialogProps) {
const [selectedMCP, setSelectedMCP] = useState<MCPServer | null>(null);
const [mcpEnvs, setMcpEnvs] = useState<Record<string, string>>({});
const [selectedMCPTools, setSelectedMCPTools] = useState<string[]>([]);
useEffect(() => {
if (open) {
if (initialSelectedMCP) {
setSelectedMCP(initialSelectedMCP);
setMcpEnvs(initialEnvs);
setSelectedMCPTools(initialTools);
} else {
setSelectedMCP(null);
setMcpEnvs({});
setSelectedMCPTools([]);
}
}
}, [open, initialSelectedMCP, initialEnvs, initialTools]);
const handleSelectMCP = (value: string) => {
const mcp = availableMCPs.find((m) => m.id === value);
if (mcp) {
setSelectedMCP(mcp);
const initialEnvs: Record<string, string> = {};
Object.keys(mcp.environments || {}).forEach((key) => {
initialEnvs[key] = "";
});
setMcpEnvs(initialEnvs);
setSelectedMCPTools([]);
}
};
const toggleMCPTool = (tool: string) => {
if (selectedMCPTools.includes(tool)) {
setSelectedMCPTools(selectedMCPTools.filter((t) => t !== tool));
} else {
setSelectedMCPTools([...selectedMCPTools, tool]);
}
};
const handleSave = () => {
if (!selectedMCP) return;
onSave({
id: selectedMCP.id,
envs: mcpEnvs,
tools: selectedMCPTools,
});
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-hidden flex flex-col bg-[#1a1a1a] border-[#333]">
<DialogHeader>
<DialogTitle className="text-white">
Configure MCP Server
</DialogTitle>
<DialogDescription className="text-neutral-400">
Select a MCP server and configure its tools.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto p-4">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="mcp-select" className="text-neutral-300">
MCP Server
</Label>
<Select
value={selectedMCP?.id}
onValueChange={handleSelectMCP}
>
<SelectTrigger className="bg-[#222] border-[#444] text-white">
<SelectValue placeholder="Select a MCP server" />
</SelectTrigger>
<SelectContent className="bg-[#222] border-[#444] text-white">
{availableMCPs.map((mcp) => (
<SelectItem
key={mcp.id}
value={mcp.id}
className="data-[selected]:bg-[#333] data-[highlighted]:bg-[#333] !text-white focus:!text-white hover:text-emerald-400 data-[selected]:!text-emerald-400"
>
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-emerald-400" />
{mcp.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedMCP && (
<>
<div className="border border-[#444] rounded-md p-3 bg-[#222]">
<p className="font-medium text-white">{selectedMCP.name}</p>
<p className="text-sm text-neutral-400">
{selectedMCP.description?.substring(0, 100)}...
</p>
<div className="mt-2 text-xs text-neutral-400">
<p>
<strong>Type:</strong> {selectedMCP.type}
</p>
<p>
<strong>Configuration:</strong>{" "}
{selectedMCP.config_type === "sse" ? "SSE" : "Studio"}
</p>
</div>
</div>
{selectedMCP.environments &&
Object.keys(selectedMCP.environments).length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-white">
Environment Variables
</h3>
{Object.entries(selectedMCP.environments || {}).map(
([key, value]) => (
<div
key={key}
className="grid grid-cols-3 items-center gap-4"
>
<Label
htmlFor={`env-${key}`}
className="text-right text-neutral-300"
>
{key}
</Label>
<Input
id={`env-${key}`}
value={mcpEnvs[key] || ""}
onChange={(e) =>
setMcpEnvs({
...mcpEnvs,
[key]: e.target.value,
})
}
className="col-span-2 bg-[#222] border-[#444] text-white"
placeholder={value as string}
/>
</div>
)
)}
</div>
)}
{selectedMCP.tools && selectedMCP.tools.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-white">
Available Tools
</h3>
<div className="border border-[#444] rounded-md p-3 bg-[#222]">
{selectedMCP.tools.map((tool: any) => (
<div
key={tool.id}
className="flex items-center space-x-2 py-1"
>
<Checkbox
id={`tool-${tool.id}`}
checked={selectedMCPTools.includes(tool.id)}
onCheckedChange={() => toggleMCPTool(tool.id)}
className="data-[state=checked]:bg-emerald-400 data-[state=checked]:border-emerald-400"
/>
<Label
htmlFor={`tool-${tool.id}`}
className="text-sm text-neutral-300"
>
{tool.name}
</Label>
</div>
))}
</div>
</div>
)}
</>
)}
</div>
</div>
<DialogFooter className="p-4 pt-2 border-t border-[#333]">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Cancel
</Button>
<Button
onClick={handleSave}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={!selectedMCP}
>
Add MCP
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,136 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/dialogs/MoveAgentDialog.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Agent } from "@/types/agent";
import { Folder, Home } from "lucide-react";
interface Folder {
id: string;
name: string;
description: string;
}
interface MoveAgentDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
agent: Agent | null;
folders: Folder[];
onMove: (folderId: string | null) => Promise<void>;
isLoading?: boolean;
}
export function MoveAgentDialog({
open,
onOpenChange,
agent,
folders,
onMove,
isLoading = false,
}: MoveAgentDialogProps) {
const handleMove = async (folderId: string | null) => {
await onMove(folderId);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] bg-[#1a1a1a] border-[#333] text-white">
<DialogHeader>
<DialogTitle>Move Agent</DialogTitle>
<DialogDescription className="text-neutral-400">
Choose a folder to move the agent "{agent?.name}"
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="space-y-2">
<button
className="w-full text-left px-4 py-3 rounded-md flex items-center bg-[#222] border border-[#444] hover:bg-[#333] hover:border-emerald-400/50 transition-colors"
onClick={() => handleMove(null)}
disabled={isLoading}
>
<Home className="h-5 w-5 mr-3 text-neutral-400" />
<div>
<div className="font-medium">Remove from folder</div>
<p className="text-sm text-neutral-400">
The agent will be visible in "All agents"
</p>
</div>
</button>
{folders.map((folder) => (
<button
key={folder.id}
className="w-full text-left px-4 py-3 rounded-md flex items-center bg-[#222] border border-[#444] hover:bg-[#333] hover:border-emerald-400/50 transition-colors"
onClick={() => handleMove(folder.id)}
disabled={isLoading}
>
<Folder className="h-5 w-5 mr-3 text-emerald-400" />
<div>
<div className="font-medium">{folder.name}</div>
{folder.description && (
<p className="text-sm text-neutral-400 truncate">
{folder.description}
</p>
)}
</div>
</button>
))}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Cancel
</Button>
{isLoading && (
<div className="flex items-center">
<div className="animate-spin h-4 w-4 border-2 border-emerald-400 border-t-transparent rounded-full mr-2"></div>
<span className="text-neutral-400">Moving...</span>
</div>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,172 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/dialogs/ShareAgentDialog.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Agent } from "@/types/agent";
import { Input } from "@/components/ui/input";
import { Copy, Share2, ExternalLink } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
interface ShareAgentDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
agent: Agent;
apiKey?: string;
}
export function ShareAgentDialog({
open,
onOpenChange,
agent,
apiKey,
}: ShareAgentDialogProps) {
const [copied, setCopied] = useState(false);
const [shareLink, setShareLink] = useState("");
const { toast } = useToast();
useEffect(() => {
if (open && agent && apiKey) {
const baseUrl = window.location.origin;
setShareLink(`${baseUrl}/shared-chat?agent=${agent.id}&key=${apiKey}`);
}
}, [open, agent, apiKey]);
const handleCopyLink = () => {
if (shareLink) {
navigator.clipboard.writeText(shareLink);
setCopied(true);
toast({
title: "Link copied!",
description: "The share link has been copied to the clipboard.",
});
setTimeout(() => setCopied(false), 2000);
}
};
const handleCopyApiKey = () => {
if (apiKey) {
navigator.clipboard.writeText(apiKey);
setCopied(true);
toast({
title: "API Key copied!",
description: "The API Key has been copied to the clipboard.",
});
setTimeout(() => setCopied(false), 2000);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] bg-[#1a1a1a] border-[#333] text-white">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Share2 className="h-5 w-5 text-emerald-400" />
Share Agent
</DialogTitle>
<DialogDescription className="text-neutral-400">
Share this agent with others without the need to login.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<h3 className="text-sm font-medium text-white">Share Link</h3>
<div className="flex gap-2">
<Input
readOnly
value={shareLink}
className="bg-[#222] border-[#444] text-white"
/>
<Button
variant="outline"
className="shrink-0 bg-[#222] border-[#444] hover:bg-[#333] text-emerald-400 hover:text-emerald-300"
onClick={handleCopyLink}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-neutral-400">
Any person with this link can access the agent using the included API key.
</p>
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium text-white">API Key</h3>
<div className="flex gap-2">
<Input
readOnly
value={apiKey}
type="password"
className="bg-[#222] border-[#444] text-white"
/>
<Button
variant="outline"
className="shrink-0 bg-[#222] border-[#444] hover:bg-[#333] text-emerald-400 hover:text-emerald-300"
onClick={handleCopyApiKey}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-neutral-400">
The API key allows access to the agent. Do not share with untrusted people.
</p>
</div>
</div>
<DialogFooter className="border-t border-[#333] pt-4">
<Button
variant="outline"
onClick={() => window.open(shareLink, "_blank")}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white flex gap-2"
>
<ExternalLink className="h-4 w-4" />
Open Link
</Button>
<Button
onClick={() => onOpenChange(false)}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,331 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/forms/AgentForm.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Agent } from "@/types/agent";
import { ApiKey } from "@/services/agentService";
import { MCPServer } from "@/types/mcpServer";
import { useState, useEffect } from "react";
import { BasicInfoTab } from "./BasicInfoTab";
import { ConfigurationTab } from "./ConfigurationTab";
import { SubAgentsTab } from "./SubAgentsTab";
import { MCPDialog } from "../dialogs/MCPDialog";
import { CustomMCPDialog } from "../dialogs/CustomMCPDialog";
interface ModelOption {
value: string;
label: string;
provider: string;
}
interface AgentFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
initialValues: Partial<Agent>;
apiKeys: ApiKey[];
availableModels: ModelOption[];
availableMCPs: MCPServer[];
agents: Agent[];
onOpenApiKeysDialog: () => void;
onOpenMCPDialog: (mcp?: any) => void;
onOpenCustomMCPDialog: (customMCP?: any) => void;
onSave: (values: Partial<Agent>) => Promise<void>;
isLoading?: boolean;
getAgentNameById: (id: string) => string;
clientId: string;
}
export function AgentForm({
open,
onOpenChange,
initialValues,
apiKeys,
availableModels,
availableMCPs,
agents,
onOpenApiKeysDialog,
onOpenMCPDialog: externalOnOpenMCPDialog,
onOpenCustomMCPDialog: externalOnOpenCustomMCPDialog,
onSave,
isLoading = false,
getAgentNameById,
clientId,
}: AgentFormProps) {
const [values, setValues] = useState<Partial<Agent>>(initialValues);
const [activeTab, setActiveTab] = useState("basic");
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
const [selectedMCP, setSelectedMCP] = useState<any>(null);
const [customMcpDialogOpen, setCustomMcpDialogOpen] = useState(false);
const [selectedCustomMCP, setSelectedCustomMCP] = useState<any>(null);
useEffect(() => {
if (open) {
setValues(initialValues);
setActiveTab("basic");
}
}, [open, initialValues]);
const handleOpenMCPDialog = (mcpConfig: any = null) => {
setSelectedMCP(mcpConfig);
setMcpDialogOpen(true);
};
const handleOpenCustomMCPDialog = (customMCP: any = null) => {
setSelectedCustomMCP(customMCP);
setCustomMcpDialogOpen(true);
};
const handleConfigureMCP = (mcpConfig: any) => {
handleOpenMCPDialog(mcpConfig);
};
const handleRemoveMCP = (mcpId: string) => {
setValues({
...values,
config: {
...values.config,
mcp_servers:
values.config?.mcp_servers?.filter((mcp) => mcp.id !== mcpId) || [],
},
});
};
const handleConfigureCustomMCP = (customMCP: any) => {
handleOpenCustomMCPDialog(customMCP);
};
const handleRemoveCustomMCP = (url: string) => {
setValues({
...values,
config: {
...values.config,
custom_mcp_servers:
values.config?.custom_mcp_servers?.filter(
(customMCP) => customMCP.url !== url
) || [],
},
});
};
const handleSaveMCP = (mcpConfig: any) => {
const updatedMcpServers = [...(values.config?.mcp_servers || [])];
const existingIndex = updatedMcpServers.findIndex(
(mcp) => mcp.id === mcpConfig.id
);
if (existingIndex >= 0) {
updatedMcpServers[existingIndex] = mcpConfig;
} else {
updatedMcpServers.push(mcpConfig);
}
setValues({
...values,
config: {
...(values.config || {}),
mcp_servers: updatedMcpServers,
},
});
};
const handleSaveCustomMCP = (customMCP: any) => {
const updatedCustomMCPs = [...(values.config?.custom_mcp_servers || [])];
const existingIndex = updatedCustomMCPs.findIndex(
(mcp) => mcp.url === customMCP.url
);
if (existingIndex >= 0) {
updatedCustomMCPs[existingIndex] = customMCP;
} else {
updatedCustomMCPs.push(customMCP);
}
setValues({
...values,
config: {
...(values.config || {}),
custom_mcp_servers: updatedCustomMCPs,
},
});
};
const handleSave = async () => {
const finalValues = {
...values,
client_id: clientId,
name: values.name,
};
await onSave(finalValues);
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-hidden flex flex-col bg-[#1a1a1a] border-[#333]">
<DialogHeader>
<DialogTitle className="text-white">
{initialValues.id ? "Edit Agent" : "New Agent"}
</DialogTitle>
<DialogDescription className="text-neutral-400">
{initialValues.id
? "Edit the existing agent information"
: "Fill in the information to create a new agent"}
</DialogDescription>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex-1 overflow-hidden flex flex-col"
>
<TabsList className="grid grid-cols-3 bg-[#222]">
<TabsTrigger
value="basic"
className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400"
>
Basic Information
</TabsTrigger>
<TabsTrigger
value="config"
className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400"
>
Configuration
</TabsTrigger>
<TabsTrigger
value="subagents"
className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400"
>
Sub-Agents
</TabsTrigger>
</TabsList>
<ScrollArea className="flex-1 overflow-auto">
<TabsContent value="basic" className="p-4 space-y-4">
<BasicInfoTab
values={values}
onChange={setValues}
apiKeys={apiKeys}
availableModels={availableModels}
onOpenApiKeysDialog={onOpenApiKeysDialog}
clientId={clientId}
/>
</TabsContent>
<TabsContent value="config" className="p-4 space-y-4">
<ConfigurationTab
values={values}
onChange={setValues}
agents={agents}
availableMCPs={availableMCPs}
apiKeys={apiKeys}
availableModels={availableModels}
getAgentNameById={getAgentNameById}
onOpenApiKeysDialog={onOpenApiKeysDialog}
onConfigureMCP={handleConfigureMCP}
onRemoveMCP={handleRemoveMCP}
onConfigureCustomMCP={handleConfigureCustomMCP}
onRemoveCustomMCP={handleRemoveCustomMCP}
onOpenMCPDialog={handleOpenMCPDialog}
onOpenCustomMCPDialog={handleOpenCustomMCPDialog}
clientId={clientId}
/>
</TabsContent>
<TabsContent value="subagents" className="p-4 space-y-4">
<SubAgentsTab
values={values}
onChange={setValues}
getAgentNameById={getAgentNameById}
editingAgentId={initialValues.id}
clientId={clientId}
/>
</TabsContent>
</ScrollArea>
</Tabs>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Cancel
</Button>
<Button
onClick={handleSave}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={!values.name || isLoading}
>
{isLoading && (
<div className="animate-spin h-4 w-4 border-2 border-black border-t-transparent rounded-full mr-2"></div>
)}
{initialValues.id ? "Save Changes" : "Add Agent"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* MCP Dialog */}
<MCPDialog
open={mcpDialogOpen}
onOpenChange={setMcpDialogOpen}
onSave={handleSaveMCP}
availableMCPs={availableMCPs}
selectedMCP={
availableMCPs.find((m) => selectedMCP?.id === m.id) || null
}
initialEnvs={selectedMCP?.envs || {}}
initialTools={selectedMCP?.tools || []}
clientId={clientId}
/>
{/* Custom MCP Dialog */}
<CustomMCPDialog
open={customMcpDialogOpen}
onOpenChange={setCustomMcpDialogOpen}
onSave={handleSaveCustomMCP}
initialCustomMCP={selectedCustomMCP}
clientId={clientId}
/>
</>
);
}

View File

@@ -0,0 +1,243 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/forms/BasicInfoTab.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { AgentTypeSelector } from "@/app/agents/AgentTypeSelector";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Agent, AgentType } from "@/types/agent";
import { ApiKey } from "@/services/agentService";
import { A2AAgentConfig } from "../config/A2AAgentConfig";
import { LLMAgentConfig } from "../config/LLMAgentConfig";
import { sanitizeAgentName } from "@/lib/utils";
interface ModelOption {
value: string;
label: string;
provider: string;
}
interface BasicInfoTabProps {
values: Partial<Agent>;
onChange: (values: Partial<Agent>) => void;
apiKeys: ApiKey[];
availableModels: ModelOption[];
onOpenApiKeysDialog: () => void;
clientId: string;
}
export function BasicInfoTab({
values,
onChange,
apiKeys,
availableModels,
onOpenApiKeysDialog,
}: BasicInfoTabProps) {
const handleNameBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const sanitizedName = sanitizeAgentName(e.target.value);
if (sanitizedName !== e.target.value) {
onChange({ ...values, name: sanitizedName });
}
};
const handleTypeChange = (type: AgentType) => {
let newValues: Partial<Agent> = { ...values, type };
if (type === "llm") {
newValues = {
...newValues,
model: "openai/gpt-4.1-nano",
instruction: "",
role: "",
goal: "",
agent_card_url: undefined,
config: {
tools: [],
mcp_servers: [],
custom_mcp_servers: [],
custom_tools: {
http_tools: [],
},
sub_agents: [],
},
};
} else if (type === "a2a") {
newValues = {
...newValues,
model: undefined,
instruction: undefined,
role: undefined,
goal: undefined,
agent_card_url: "",
api_key_id: undefined,
config: undefined,
};
} else if (type === "loop") {
newValues = {
...newValues,
model: undefined,
instruction: undefined,
role: undefined,
goal: undefined,
agent_card_url: undefined,
api_key_id: undefined,
config: {
sub_agents: [],
custom_mcp_servers: [],
},
};
} else if (type === "workflow") {
newValues = {
...newValues,
model: undefined,
instruction: undefined,
role: undefined,
goal: undefined,
agent_card_url: undefined,
api_key_id: undefined,
config: {
sub_agents: [],
workflow: {
nodes: [],
edges: [],
},
},
};
} else if (type === "task") {
newValues = {
...newValues,
model: undefined,
instruction: undefined,
role: undefined,
goal: undefined,
agent_card_url: undefined,
api_key_id: undefined,
config: {
tasks: [],
sub_agents: [],
},
};
} else {
newValues = {
...newValues,
model: undefined,
instruction: undefined,
role: undefined,
goal: undefined,
agent_card_url: undefined,
api_key_id: undefined,
config: {
sub_agents: [],
custom_mcp_servers: [],
},
};
}
onChange(newValues);
};
return (
<div className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="type" className="text-right text-neutral-300">
Agent Type
</Label>
<div className="col-span-3">
<AgentTypeSelector
value={values.type || "llm"}
onValueChange={handleTypeChange}
/>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right text-neutral-300">
Name
</Label>
<Input
id="name"
value={values.name || ""}
onChange={(e) => onChange({ ...values, name: e.target.value })}
onBlur={handleNameBlur}
className="col-span-3 bg-[#222] border-[#444] text-white"
/>
</div>
{values.type !== "a2a" && (
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right text-neutral-300">
Description
</Label>
<Input
id="description"
value={values.description || ""}
onChange={(e) =>
onChange({ ...values, description: e.target.value })
}
className="col-span-3 bg-[#222] border-[#444] text-white"
/>
</div>
)}
{values.type === "llm" && (
<LLMAgentConfig
apiKeys={apiKeys}
availableModels={availableModels}
values={values}
onChange={onChange}
onOpenApiKeysDialog={onOpenApiKeysDialog}
/>
)}
{values.type === "loop" && values.config?.max_iterations && (
<div className="space-y-1 text-xs text-neutral-400">
<div>
<strong>Max. Iterations:</strong> {values.config.max_iterations}
</div>
</div>
)}
{values.type === "workflow" && (
<div className="space-y-1 text-xs text-neutral-400">
<div>
<strong>Type:</strong> Visual Flow
</div>
{values.config?.workflow && (
<div>
<strong>Elements:</strong>{" "}
{values.config.workflow.nodes?.length || 0} nodes,{" "}
{values.config.workflow.edges?.length || 0} connections
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,722 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/forms/ConfigurationTab.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Agent } from "@/types/agent";
import { MCPServer } from "@/types/mcpServer";
import {
Check,
Copy,
Eye,
EyeOff,
Plus,
Server,
Settings,
X,
} from "lucide-react";
import { ParallelAgentConfig } from "../config/ParallelAgentConfig";
import { SequentialAgentConfig } from "../config/SequentialAgentConfig";
import { ApiKey } from "@/services/agentService";
import { LoopAgentConfig } from "../config/LoopAgentConfig copy";
import { A2AAgentConfig } from "../config/A2AAgentConfig";
import { TaskAgentConfig } from "../config/TaskAgentConfig";
import { useState } from "react";
import { MCPDialog } from "../dialogs/MCPDialog";
import { CustomMCPDialog } from "../dialogs/CustomMCPDialog";
import { AgentToolDialog } from "../dialogs/AgentToolDialog";
import { CustomToolDialog } from "../dialogs/CustomToolDialog";
import { useToast } from "@/hooks/use-toast";
interface ConfigurationTabProps {
values: Partial<Agent>;
onChange: (values: Partial<Agent>) => void;
agents: Agent[];
availableMCPs: MCPServer[];
apiKeys: ApiKey[];
availableModels: any[];
getAgentNameById: (id: string) => string;
onOpenApiKeysDialog: () => void;
onConfigureMCP: (mcpConfig: any) => void;
onRemoveMCP: (mcpId: string) => void;
onConfigureCustomMCP: (customMCP: any) => void;
onRemoveCustomMCP: (url: string) => void;
onOpenMCPDialog: (mcpConfig?: any) => void;
onOpenCustomMCPDialog: (customMCP?: any) => void;
clientId: string;
}
export function ConfigurationTab({
values,
onChange,
agents,
availableMCPs,
apiKeys,
availableModels,
getAgentNameById,
onOpenApiKeysDialog,
onConfigureMCP,
onRemoveMCP,
onConfigureCustomMCP,
onRemoveCustomMCP,
onOpenMCPDialog,
onOpenCustomMCPDialog,
clientId,
}: ConfigurationTabProps) {
const [agentToolDialogOpen, setAgentToolDialogOpen] = useState(false);
const [customToolDialogOpen, setCustomToolDialogOpen] = useState(false);
const [editingCustomTool, setEditingCustomTool] = useState<any>(null);
const [showApiKey, setShowApiKey] = useState(false);
const [copied, setCopied] = useState(false);
const [copiedCardUrl, setCopiedCardUrl] = useState(false);
const { toast } = useToast();
const handleAddAgentTool = (tool: { id: string }) => {
const updatedAgentTools = [...(values.config?.agent_tools || [])];
if (!updatedAgentTools.includes(tool.id)) {
updatedAgentTools.push(tool.id);
onChange({
...values,
config: {
...(values.config || {}),
agent_tools: updatedAgentTools,
},
});
}
};
const handleRemoveAgentTool = (id: string) => {
onChange({
...values,
config: {
...(values.config || {}),
agent_tools: (values.config?.agent_tools || []).filter(
(toolId) => toolId !== id
),
},
});
};
// Custom Tools handlers
const handleAddCustomTool = (tool: any) => {
const updatedTools = [...(values.config?.custom_tools?.http_tools || [])];
updatedTools.push(tool);
onChange({
...values,
config: {
...(values.config || {}),
custom_tools: {
...(values.config?.custom_tools || { http_tools: [] }),
http_tools: updatedTools,
},
},
});
};
const handleEditCustomTool = (tool: any, idx: number) => {
setEditingCustomTool({ ...tool, idx });
setCustomToolDialogOpen(true);
};
const handleSaveEditCustomTool = (tool: any) => {
const updatedTools = [...(values.config?.custom_tools?.http_tools || [])];
if (editingCustomTool && typeof editingCustomTool.idx === "number") {
updatedTools[editingCustomTool.idx] = tool;
}
onChange({
...values,
config: {
...(values.config || {}),
custom_tools: {
...(values.config?.custom_tools || { http_tools: [] }),
http_tools: updatedTools,
},
},
});
setEditingCustomTool(null);
};
const handleRemoveCustomTool = (idx: number) => {
const updatedTools = [...(values.config?.custom_tools?.http_tools || [])];
updatedTools.splice(idx, 1);
onChange({
...values,
config: {
...(values.config || {}),
custom_tools: {
...(values.config?.custom_tools || { http_tools: [] }),
http_tools: updatedTools,
},
},
});
};
const apiKeyField = (
<div className="space-y-2 mb-4">
<h3 className="text-lg font-medium text-white">Credentials</h3>
<div className="border border-[#444] rounded-md p-4 bg-[#222] flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label
className="text-sm text-neutral-400 mb-1"
htmlFor="agent-card-url"
>
Agent URL. This URL can be used to access the agent card externally.
</label>
<div className="relative flex items-center">
<input
id="agent-card-url"
type="text"
className="w-full bg-[#2a2a2a] border border-[#444] rounded-md px-3 py-2 text-white pr-12 focus:outline-none focus:ring-2 focus:ring-emerald-400/40"
value={
values?.agent_card_url?.replace(
"/.well-known/agent.json",
""
) || ""
}
disabled
autoComplete="off"
/>
<button
type="button"
className="absolute right-2 text-neutral-400 hover:text-emerald-400 px-1 py-1"
onClick={async () => {
if (values?.agent_card_url) {
await navigator.clipboard.writeText(
values.agent_card_url.replace("/.well-known/agent.json", "")
);
setCopiedCardUrl(true);
setTimeout(() => setCopiedCardUrl(false), 1200);
toast({
title: "Copied!",
description:
"The agent URL was copied to the clipboard.",
});
}
}}
tabIndex={-1}
>
{copiedCardUrl ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm text-neutral-400 mb-1" htmlFor="agent-api_key">
Configure the API Key for this agent. This key will be used for
authentication with external services.
</label>
<div className="relative flex items-center">
<input
id="agent-api_key"
type={showApiKey ? "text" : "password"}
className="w-full bg-[#2a2a2a] border border-[#444] rounded-md px-3 py-2 text-white pr-24 focus:outline-none focus:ring-2 focus:ring-emerald-400/40"
value={values.config?.api_key || ""}
onChange={(e) =>
onChange({
...values,
config: {
...(values.config || {}),
api_key: e.target.value,
},
})
}
autoComplete="off"
/>
<button
type="button"
className="absolute right-9 text-neutral-400 hover:text-emerald-400 px-1 py-1"
onClick={() => setShowApiKey((v) => !v)}
tabIndex={-1}
>
{showApiKey ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
<button
type="button"
className="absolute right-2 text-neutral-400 hover:text-emerald-400 px-1 py-1"
onClick={async () => {
if (values.config?.api_key) {
await navigator.clipboard.writeText(values.config.api_key);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
toast({
title: "Copied!",
description:
"The API key was copied to the clipboard.",
});
}
}}
tabIndex={-1}
>
{copied ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
</div>
</div>
</div>
);
if (values.type === "llm") {
return (
<div className="space-y-4">
{apiKeyField}
<div className="space-y-4">
<h3 className="text-lg font-medium text-white">MCP Servers</h3>
<div className="border border-[#444] rounded-md p-4 bg-[#222]">
<p className="text-sm text-neutral-400 mb-4">
Configure the MCP servers that this agent can use.
</p>
{values.config?.mcp_servers &&
values.config.mcp_servers.length > 0 ? (
<div className="space-y-2">
{values.config.mcp_servers.map((mcpConfig) => {
const mcpServer = availableMCPs.find(
(mcp) => mcp.id === mcpConfig.id
);
return (
<div
key={mcpConfig.id}
className="flex items-center justify-between p-2 bg-[#2a2a2a] rounded-md"
>
<div>
<p className="font-medium text-white">
{mcpServer?.name || mcpConfig.id}
</p>
<p className="text-sm text-neutral-400">
{mcpServer?.description?.substring(0, 100)}
...
</p>
{mcpConfig.tools && mcpConfig.tools.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{mcpConfig.tools.map((toolId) => (
<Badge
key={toolId}
variant="outline"
className="text-xs bg-[#333] text-emerald-400 border-emerald-400/30"
>
{toolId}
</Badge>
))}
</div>
)}
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onConfigureMCP(mcpConfig)}
className="flex items-center text-neutral-300 hover:text-emerald-400 hover:bg-[#333]"
>
<Settings className="h-4 w-4 mr-1" /> Configure
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onRemoveMCP(mcpConfig.id)}
className="text-red-500 hover:text-red-400 hover:bg-[#333]"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
<Button
variant="outline"
size="sm"
onClick={() => onOpenMCPDialog(null)}
className="w-full mt-2 border-emerald-400 text-emerald-400 hover:bg-emerald-400/10 bg-[#222] hover:text-emerald-400"
>
<Plus className="h-4 w-4 mr-1" /> Add MCP Server
</Button>
</div>
) : (
<div className="flex items-center justify-between p-2 bg-[#2a2a2a] rounded-md mb-2">
<div>
<p className="font-medium text-white">
No MCP servers configured
</p>
<p className="text-sm text-neutral-400">
Add MCP servers for this agent
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => onOpenMCPDialog(null)}
className="border-emerald-400 text-emerald-400 hover:bg-emerald-400/10 bg-[#222] hover:text-emerald-400"
>
<Plus className="h-4 w-4 mr-1" /> Add
</Button>
</div>
)}
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-medium text-white">Custom MCPs</h3>
<div className="border border-[#444] rounded-md p-4 bg-[#222]">
<p className="text-sm text-neutral-400 mb-4">
Configure custom MCPs with URL and HTTP headers.
</p>
{values.config?.custom_mcp_servers &&
values.config.custom_mcp_servers.length > 0 ? (
<div className="space-y-2">
{values.config.custom_mcp_servers.map((customMCP) => (
<div
key={customMCP.url}
className="flex items-center justify-between p-2 bg-[#2a2a2a] rounded-md"
>
<div>
<p className="font-medium text-white">{customMCP.url}</p>
<p className="text-sm text-neutral-400">
{Object.keys(customMCP.headers || {}).length > 0
? `${
Object.keys(customMCP.headers || {}).length
} headers configured`
: "No headers configured"}
</p>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onConfigureCustomMCP(customMCP)}
className="flex items-center text-neutral-300 hover:text-emerald-400 hover:bg-[#333]"
>
<Settings className="h-4 w-4 mr-1" /> Configure
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onRemoveCustomMCP(customMCP.url)}
className="text-red-500 hover:text-red-400 hover:bg-[#333]"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => onOpenCustomMCPDialog(null)}
className="w-full mt-2 border-emerald-400 text-emerald-400 hover:bg-emerald-400/10 bg-[#222] hover:text-emerald-400"
>
<Plus className="h-4 w-4 mr-1" /> Add Custom MCP
</Button>
</div>
) : (
<div className="flex items-center justify-between p-2 bg-[#2a2a2a] rounded-md mb-2">
<div>
<p className="font-medium text-white">
No custom MCPs configured
</p>
<p className="text-sm text-neutral-400">
Add custom MCPs for this agent
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => onOpenCustomMCPDialog(null)}
className="border-emerald-400 text-emerald-400 hover:bg-emerald-400/10 bg-[#222] hover:text-emerald-400"
>
<Plus className="h-4 w-4 mr-1" /> Add
</Button>
</div>
)}
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-medium text-white">Agent Tools</h3>
<div className="border border-[#444] rounded-md p-4 bg-[#222]">
<p className="text-sm text-neutral-400 mb-4">
Configure other agents as tools for this agent.
</p>
{values.config?.agent_tools &&
values.config.agent_tools.length > 0 ? (
<div className="space-y-2">
{values.config.agent_tools.map((toolId) => {
const agent = agents.find((a) => a.id === toolId);
return (
<div
key={toolId}
className="flex items-center justify-between p-2 bg-[#2a2a2a] rounded-md"
>
<div>
<p className="font-medium text-white">
{agent?.name || toolId}
</p>
<p className="text-sm text-neutral-400">
{agent?.description || "No description"}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveAgentTool(toolId)}
className="text-red-500 hover:text-red-400 hover:bg-[#333]"
>
<X className="h-4 w-4" />
</Button>
</div>
);
})}
<Button
variant="outline"
size="sm"
onClick={() => setAgentToolDialogOpen(true)}
className="w-full mt-2 border-emerald-400 text-emerald-400 hover:bg-emerald-400/10 bg-[#222] hover:text-emerald-400"
>
<Plus className="h-4 w-4 mr-1" /> Add Agent Tool
</Button>
</div>
) : (
<div className="flex items-center justify-between p-2 bg-[#2a2a2a] rounded-md mb-2">
<div>
<p className="font-medium text-white">
No agent tools configured
</p>
<p className="text-sm text-neutral-400">
Add agent tools for this agent
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setAgentToolDialogOpen(true)}
className="border-emerald-400 text-emerald-400 hover:bg-emerald-400/10 bg-[#222] hover:text-emerald-400"
>
<Plus className="h-4 w-4 mr-1" /> Add
</Button>
</div>
)}
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-medium text-white">
Custom Tools (HTTP Tools)
</h3>
<div className="border border-[#444] rounded-md p-4 bg-[#222]">
<p className="text-sm text-neutral-400 mb-4">
Configure HTTP tools for this agent.
</p>
{values.config?.custom_tools?.http_tools &&
values.config.custom_tools.http_tools.length > 0 ? (
<div className="space-y-2">
{values.config.custom_tools.http_tools.map((tool, idx) => (
<div
key={tool.name + idx}
className="flex items-center justify-between p-2 bg-[#2a2a2a] rounded-md"
>
<div>
<p className="font-medium text-white">{tool.name}</p>
<p className="text-xs text-neutral-400">
{tool.method} {tool.endpoint}
</p>
<p className="text-xs text-neutral-400">
{tool.description}
</p>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditCustomTool(tool, idx)}
className="flex items-center text-neutral-300 hover:text-emerald-400 hover:bg-[#333]"
>
<span className="mr-1">Edit</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveCustomTool(idx)}
className="text-red-500 hover:text-red-400 hover:bg-[#333]"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => {
setEditingCustomTool(null);
setCustomToolDialogOpen(true);
}}
className="w-full mt-2 border-emerald-400 text-emerald-400 hover:bg-emerald-400/10 bg-[#222] hover:text-emerald-400"
>
<Plus className="h-4 w-4 mr-1" /> Add Custom Tool
</Button>
</div>
) : (
<div className="flex items-center justify-between p-2 bg-[#2a2a2a] rounded-md mb-2">
<div>
<p className="font-medium text-white">
No custom tools configured
</p>
<p className="text-sm text-neutral-400">
Add HTTP tools for this agent
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
setEditingCustomTool(null);
setCustomToolDialogOpen(true);
}}
className="border-emerald-400 text-emerald-400 hover:bg-emerald-400/10 bg-[#222] hover:text-emerald-400"
>
<Plus className="h-4 w-4 mr-1" /> Add
</Button>
</div>
)}
</div>
</div>
<CustomToolDialog
open={customToolDialogOpen}
onOpenChange={(open) => {
setCustomToolDialogOpen(open);
if (!open) setEditingCustomTool(null);
}}
onSave={
editingCustomTool ? handleSaveEditCustomTool : handleAddCustomTool
}
initialTool={editingCustomTool}
/>
{agentToolDialogOpen && (
<AgentToolDialog
open={agentToolDialogOpen}
onOpenChange={setAgentToolDialogOpen}
onSave={handleAddAgentTool}
currentAgentId={values.id}
folderId={values.folder_id}
clientId={clientId}
/>
)}
</div>
);
}
if (values.type === "a2a") {
return (
<div className="space-y-4">
{apiKeyField}
<A2AAgentConfig values={values} onChange={onChange} />
</div>
);
}
if (values.type === "sequential") {
return (
<div className="space-y-4">
{apiKeyField}
<SequentialAgentConfig
values={values}
onChange={onChange}
agents={agents}
getAgentNameById={getAgentNameById}
/>
</div>
);
}
if (values.type === "parallel") {
return (
<div className="space-y-4">
{apiKeyField}
<ParallelAgentConfig
values={values}
onChange={onChange}
agents={agents}
getAgentNameById={getAgentNameById}
/>
</div>
);
}
if (values.type === "loop") {
return (
<div className="space-y-4">
{apiKeyField}
<LoopAgentConfig
values={values}
onChange={onChange}
agents={agents}
getAgentNameById={getAgentNameById}
/>
</div>
);
}
if (values.type === "task") {
return (
<div className="space-y-4">
{apiKeyField}
<TaskAgentConfig
values={values}
onChange={onChange}
agents={agents}
getAgentNameById={getAgentNameById}
singleTask={values.type === "task"}
/>
</div>
);
}
return (
<div className="space-y-4">
{apiKeyField}
<div className="flex items-center justify-center h-40">
<div className="text-center">
<p className="text-neutral-400">
Configure the sub-agents in the "Sub-Agents" tab
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,246 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/forms/SubAgentsTab.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { useState, useEffect } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Agent } from "@/types/agent";
import { listAgents } from "@/services/agentService";
import { Loader2, Search, X } from "lucide-react";
interface SubAgentsTabProps {
values: Partial<Agent>;
onChange: (values: Partial<Agent>) => void;
getAgentNameById: (id: string) => string;
editingAgentId?: string;
clientId: string;
}
export function SubAgentsTab({
values,
onChange,
getAgentNameById,
editingAgentId,
clientId,
}: SubAgentsTabProps) {
const [availableAgents, setAvailableAgents] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [search, setSearch] = useState("");
// Get folder ID from current agent
const folderId = values.folder_id;
useEffect(() => {
loadAgents();
}, [clientId, folderId, editingAgentId]);
const loadAgents = async () => {
if (!clientId) return;
setIsLoading(true);
try {
const res = await listAgents(
clientId,
0,
100,
folderId || undefined
);
// Filter out the current agent to avoid self-reference
const filteredAgents = res.data.filter(agent =>
agent.id !== editingAgentId
);
setAvailableAgents(filteredAgents);
} catch (error) {
console.error("Error loading agents:", error);
} finally {
setIsLoading(false);
}
};
const handleAddSubAgent = (agentId: string) => {
if (!values.config?.sub_agents?.includes(agentId)) {
onChange({
...values,
config: {
...values.config,
sub_agents: [...(values.config?.sub_agents || []), agentId],
},
});
}
};
const handleRemoveSubAgent = (agentId: string) => {
onChange({
...values,
config: {
...values.config,
sub_agents:
values.config?.sub_agents?.filter((id) => id !== agentId) || [],
},
});
};
const filteredAgents = availableAgents.filter(agent =>
agent.name.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-white">Sub-Agents</h3>
<div className="text-sm text-neutral-400">
{values.config?.sub_agents?.length || 0} sub-agents selected
</div>
</div>
<div className="border border-[#444] rounded-md p-4 bg-[#222]">
<p className="text-sm text-neutral-400 mb-4">
Select the agents that will be used as sub-agents.
</p>
{values.config?.sub_agents && values.config.sub_agents.length > 0 ? (
<div className="space-y-2 mb-4">
<h4 className="text-sm font-medium text-white">
Selected sub-agents:
</h4>
<div className="flex flex-wrap gap-2">
{values.config.sub_agents.map((agentId) => (
<Badge
key={agentId}
variant="secondary"
className="flex items-center gap-1 bg-[#333] text-emerald-400"
>
{getAgentNameById(agentId)}
<button
onClick={() => handleRemoveSubAgent(agentId)}
className="ml-1 h-4 w-4 rounded-full hover:bg-[#444] inline-flex items-center justify-center"
>
×
</button>
</Badge>
))}
</div>
</div>
) : (
<div className="text-center py-4 text-neutral-400 mb-4">
No sub-agents selected
</div>
)}
<div className="mb-4">
<h4 className="text-sm font-medium text-white mb-2">
Available agents:
</h4>
<div className="relative mb-3">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-neutral-500" />
<Input
placeholder="Search agents by name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 bg-[#1a1a1a] border-[#444] text-white"
/>
{search && (
<button
onClick={() => setSearch("")}
className="absolute right-2.5 top-2.5 text-neutral-400 hover:text-white"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
{isLoading ? (
<div className="flex flex-col items-center justify-center py-6">
<Loader2 className="h-6 w-6 text-emerald-400 animate-spin" />
<div className="mt-2 text-sm text-neutral-400">Loading agents...</div>
</div>
) : (
<div className="space-y-2 max-h-60 overflow-y-auto">
{filteredAgents.length === 0 ? (
<div className="text-center py-4 text-neutral-400">
{search ? `No agents found matching "${search}"` : "No other agents found in this folder"}
</div>
) : (
filteredAgents.map((agent) => (
<div
key={agent.id}
className="flex items-center justify-between p-2 hover:bg-[#2a2a2a] rounded-md"
>
<div className="flex items-center gap-2">
<span className="font-medium text-white">{agent.name}</span>
<Badge
variant="outline"
className="ml-2 border-[#444] text-emerald-400"
>
{agent.type === "llm"
? "LLM Agent"
: agent.type === "a2a"
? "A2A Agent"
: agent.type === "sequential"
? "Sequential Agent"
: agent.type === "parallel"
? "Parallel Agent"
: agent.type === "loop"
? "Loop Agent"
: agent.type === "workflow"
? "Workflow Agent"
: agent.type === "task"
? "Task Agent"
: agent.type}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleAddSubAgent(agent.id)}
disabled={values.config?.sub_agents?.includes(agent.id)}
className={
values.config?.sub_agents?.includes(agent.id)
? "text-neutral-500 bg-[#222] hover:bg-[#333]"
: "text-emerald-400 hover:bg-[#333] bg-[#222]"
}
>
{values.config?.sub_agents?.includes(agent.id)
? "Added"
: "Add"}
</Button>
</div>
))
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,754 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/page.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Key, Plus, Folder, Download, Upload } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { useRouter } from "next/navigation";
import { exportAsJson } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Agent, AgentCreate } from "@/types/agent";
import { Folder as AgentFolder } from "@/services/agentService";
import {
listAgents,
createAgent,
updateAgent,
deleteAgent,
listFolders,
createFolder,
updateFolder,
deleteFolder,
assignAgentToFolder,
ApiKey,
listApiKeys,
createApiKey,
updateApiKey,
deleteApiKey,
shareAgent,
importAgentFromJson,
} from "@/services/agentService";
import { listMCPServers } from "@/services/mcpServerService";
import { AgentSidebar } from "./AgentSidebar";
import { SearchInput } from "./SearchInput";
import { AgentList } from "./AgentList";
import { EmptyState } from "./EmptyState";
import { AgentForm } from "./forms/AgentForm";
import { FolderDialog } from "./dialogs/FolderDialog";
import { MoveAgentDialog } from "./dialogs/MoveAgentDialog";
import { ConfirmationDialog } from "./dialogs/ConfirmationDialog";
import { ApiKeysDialog } from "./dialogs/ApiKeysDialog";
import { ShareAgentDialog } from "./dialogs/ShareAgentDialog";
import { MCPServer } from "@/types/mcpServer";
import { availableModels } from "@/types/aiModels";
import { ImportAgentDialog } from "./dialogs/ImportAgentDialog";
export default function AgentsPage() {
const { toast } = useToast();
const router = useRouter();
const user =
typeof window !== "undefined"
? JSON.parse(localStorage.getItem("user") || "{}")
: {};
const clientId = user?.client_id || "";
const [agents, setAgents] = useState<Agent[]>([]);
const [filteredAgents, setFilteredAgents] = useState<Agent[]>([]);
const [folders, setFolders] = useState<AgentFolder[]>([]);
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [availableMCPs, setAvailableMCPs] = useState<MCPServer[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [selectedAgentType, setSelectedAgentType] = useState<string | null>(null);
const [agentTypes, setAgentTypes] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSidebarVisible, setIsSidebarVisible] = useState(false);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isFolderDialogOpen, setIsFolderDialogOpen] = useState(false);
const [isMovingDialogOpen, setIsMovingDialogOpen] = useState(false);
const [isDeleteAgentDialogOpen, setIsDeleteAgentDialogOpen] = useState(false);
const [isDeleteFolderDialogOpen, setIsDeleteFolderDialogOpen] =
useState(false);
const [isApiKeysDialogOpen, setIsApiKeysDialogOpen] = useState(false);
const [isMCPDialogOpen, setIsMCPDialogOpen] = useState(false);
const [isCustomMCPDialogOpen, setIsCustomMCPDialogOpen] = useState(false);
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
const [editingAgent, setEditingAgent] = useState<Agent | null>(null);
const [editingFolder, setEditingFolder] = useState<AgentFolder | null>(null);
const [agentToDelete, setAgentToDelete] = useState<Agent | null>(null);
const [agentToMove, setAgentToMove] = useState<Agent | null>(null);
const [agentToShare, setAgentToShare] = useState<Agent | null>(null);
const [sharedApiKey, setSharedApiKey] = useState<string>("");
const [folderToDelete, setFolderToDelete] = useState<AgentFolder | null>(null);
const [newAgent, setNewAgent] = useState<Partial<Agent>>({
client_id: clientId || "",
name: "",
description: "",
type: "llm",
model: "openai/gpt-4.1-nano",
instruction: "",
api_key_id: "",
config: {
tools: [],
mcp_servers: [],
custom_mcp_servers: [],
custom_tools: {
http_tools: [],
},
sub_agents: [],
agent_tools: [],
},
});
const fileInputRef = useRef<HTMLInputElement>(null);
const [isImporting, setIsImporting] = useState(false);
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
useEffect(() => {
if (!clientId) return;
loadAgents();
loadFolders();
loadApiKeys();
}, [clientId, selectedFolderId]);
useEffect(() => {
const loadMCPs = async () => {
try {
const res = await listMCPServers();
setAvailableMCPs(res.data);
} catch (error) {
toast({
title: "Error loading MCP servers",
variant: "destructive",
});
}
};
loadMCPs();
}, []);
const loadAgents = async () => {
setIsLoading(true);
try {
const res = await listAgents(
clientId,
0,
100,
selectedFolderId || undefined
);
setAgents(res.data);
setFilteredAgents(res.data);
// Extract unique agent types
const types = [...new Set(res.data.map(agent => agent.type))].filter(Boolean);
setAgentTypes(types);
} catch (error) {
toast({ title: "Error loading agents", variant: "destructive" });
} finally {
setIsLoading(false);
}
};
const loadFolders = async () => {
setIsLoading(true);
try {
const res = await listFolders(clientId);
setFolders(res.data);
} catch (error) {
toast({ title: "Error loading folders", variant: "destructive" });
} finally {
setIsLoading(false);
}
};
const loadApiKeys = async () => {
try {
const res = await listApiKeys(clientId);
setApiKeys(res.data);
} catch (error) {
toast({ title: "Error loading API keys", variant: "destructive" });
}
};
useEffect(() => {
// Apply both search term and type filters
let filtered = [...agents];
// Apply search term filter
if (searchTerm.trim() !== "") {
const lowercaseSearch = searchTerm.toLowerCase();
filtered = filtered.filter(
(agent) =>
agent.name.toLowerCase().includes(lowercaseSearch) ||
agent.description?.toLowerCase().includes(lowercaseSearch)
);
}
// Apply agent type filter
if (selectedAgentType) {
filtered = filtered.filter(agent => agent.type === selectedAgentType);
}
setFilteredAgents(filtered);
}, [searchTerm, selectedAgentType, agents]);
const handleAddAgent = async (agentData: Partial<Agent>) => {
try {
setIsLoading(true);
if (editingAgent) {
await updateAgent(editingAgent.id, {
...agentData,
client_id: clientId,
});
toast({
title: "Agent updated",
description: `${agentData.name} was updated successfully`,
});
} else {
const createdAgent = await createAgent({
...(agentData as AgentCreate),
client_id: clientId,
});
if (selectedFolderId && createdAgent.data.id) {
await assignAgentToFolder(
createdAgent.data.id,
selectedFolderId,
clientId
);
}
toast({
title: "Agent added",
description: `${agentData.name} was added successfully`,
});
}
loadAgents();
setIsDialogOpen(false);
resetForm();
} catch (error) {
toast({
title: "Error",
description: "Unable to save agent",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const handleDeleteAgent = async () => {
if (!agentToDelete) return;
try {
setIsLoading(true);
await deleteAgent(agentToDelete.id);
toast({
title: "Agent deleted",
description: "The agent was deleted successfully",
});
loadAgents();
setAgentToDelete(null);
setIsDeleteAgentDialogOpen(false);
} catch {
toast({
title: "Error",
description: "Unable to delete agent",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const handleEditAgent = (agent: Agent) => {
setEditingAgent(agent);
setNewAgent({ ...agent });
setIsDialogOpen(true);
};
const handleMoveAgent = async (targetFolderId: string | null) => {
if (!agentToMove) return;
try {
setIsLoading(true);
await assignAgentToFolder(agentToMove.id, targetFolderId, clientId);
toast({
title: "Agent moved",
description: targetFolderId
? `Agent moved to folder successfully`
: "Agent removed from folder successfully",
});
setIsMovingDialogOpen(false);
loadAgents();
} catch (error) {
toast({
title: "Error",
description: "Unable to move agent",
variant: "destructive",
});
} finally {
setIsLoading(false);
setAgentToMove(null);
}
};
const handleAddFolder = async (folderData: {
name: string;
description: string;
}) => {
try {
setIsLoading(true);
if (editingFolder) {
await updateFolder(editingFolder.id, folderData, clientId);
toast({
title: "Folder updated",
description: `${folderData.name} was updated successfully`,
});
} else {
await createFolder({
...folderData,
client_id: clientId,
});
toast({
title: "Folder created",
description: `${folderData.name} was created successfully`,
});
}
loadFolders();
setIsFolderDialogOpen(false);
setEditingFolder(null);
} catch (error) {
toast({
title: "Error",
description: "Unable to save folder",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const handleDeleteFolder = async () => {
if (!folderToDelete) return;
try {
setIsLoading(true);
await deleteFolder(folderToDelete.id, clientId);
toast({
title: "Folder deleted",
description: "The folder was deleted successfully",
});
loadFolders();
if (selectedFolderId === folderToDelete.id) {
setSelectedFolderId(null);
}
setFolderToDelete(null);
setIsDeleteFolderDialogOpen(false);
} catch (error) {
toast({
title: "Error",
description: "Unable to delete folder",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const handleShareAgent = async (agent: Agent) => {
try {
setIsLoading(true);
setAgentToShare(agent);
const response = await shareAgent(agent.id, clientId);
if (response.data && response.data.api_key) {
setSharedApiKey(response.data.api_key);
setIsShareDialogOpen(true);
toast({
title: "Agent shared",
description: "API key generated successfully",
});
}
} catch (error) {
toast({
title: "Error",
description: "Unable to share agent",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const resetForm = () => {
setNewAgent({
client_id: clientId || "",
name: "",
description: "",
type: "llm",
model: "openai/gpt-4.1-nano",
instruction: "",
api_key_id: "",
config: {
tools: [],
mcp_servers: [],
custom_mcp_servers: [],
custom_tools: {
http_tools: [],
},
sub_agents: [],
agent_tools: [],
},
});
setEditingAgent(null);
};
// Function to export all agents as JSON
const handleExportAllAgents = () => {
try {
// Create file name with current date
const date = new Date();
const formattedDate = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
const filename = `agents-export-${formattedDate}`;
// Use the utility function to export
// Pass agents both as the data and as allAgents parameter to properly resolve references
const result = exportAsJson({ agents: filteredAgents }, filename, true, agents);
if (result) {
toast({
title: "Export complete",
description: `${filteredAgents.length} agent(s) exported to JSON`,
});
} else {
throw new Error("Export failed");
}
} catch (error) {
console.error("Error exporting agents:", error);
toast({
title: "Export failed",
description: "There was an error exporting the agents",
variant: "destructive",
});
}
};
const handleImportAgentJSON = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file || !clientId) return;
try {
setIsImporting(true);
await importAgentFromJson(file, clientId);
toast({
title: "Import successful",
description: "Agent was imported successfully",
});
// Refresh the agent list
loadAgents();
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
} catch (error) {
console.error("Error importing agent:", error);
toast({
title: "Import failed",
description: "There was an error importing the agent",
variant: "destructive",
});
} finally {
setIsImporting(false);
}
};
return (
<div className="container mx-auto p-6 bg-[#121212] min-h-screen flex relative">
<AgentSidebar
visible={isSidebarVisible}
folders={folders}
selectedFolderId={selectedFolderId}
onSelectFolder={setSelectedFolderId}
onAddFolder={() => {
setEditingFolder(null);
setIsFolderDialogOpen(true);
}}
onEditFolder={(folder) => {
setEditingFolder(folder as AgentFolder);
setIsFolderDialogOpen(true);
}}
onDeleteFolder={(folder) => {
setFolderToDelete(folder as AgentFolder);
setIsDeleteFolderDialogOpen(true);
}}
onClose={() => setIsSidebarVisible(!isSidebarVisible)}
/>
<div
className={`w-full transition-all duration-300 ease-in-out ${
isSidebarVisible ? "pl-64" : "pl-0"
}`}
>
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center">
{!isSidebarVisible && (
<button
onClick={() => setIsSidebarVisible(true)}
className="mr-2 bg-[#222] p-2 rounded-md text-emerald-400 hover:bg-[#333] hover:text-emerald-400 shadow-md transition-all"
aria-label="Show folders"
>
<Folder className="h-5 w-5" />
</button>
)}
<h1 className="text-3xl font-bold text-white flex items-center ml-2">
{selectedFolderId
? folders.find((f) => f.id === selectedFolderId)?.name
: "Agents"}
</h1>
</div>
<div className="flex items-center gap-4">
<SearchInput
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search agents..."
selectedAgentType={selectedAgentType}
onAgentTypeChange={setSelectedAgentType}
agentTypes={agentTypes}
/>
<Button
onClick={() => setIsApiKeysDialogOpen(true)}
className="bg-[#222] text-white hover:bg-[#333] border border-[#444]"
>
<Key className="mr-2 h-4 w-4 text-emerald-400" />
API Keys
</Button>
<Button
onClick={handleExportAllAgents}
className="bg-[#222] text-white hover:bg-[#333] border border-[#444]"
title="Export all agents as JSON"
>
<Download className="mr-2 h-4 w-4 text-purple-400" />
Export All
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="bg-emerald-400 text-black hover:bg-[#00cc7d]">
<Plus className="mr-2 h-4 w-4" />
New Agent
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-zinc-900 border-zinc-700">
<DropdownMenuItem
className="text-white hover:bg-zinc-800 cursor-pointer"
onClick={() => {
resetForm();
setIsDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-2 text-emerald-400" />
New Agent
</DropdownMenuItem>
<DropdownMenuItem
className="text-white hover:bg-zinc-800 cursor-pointer"
onClick={() => setIsImportDialogOpen(true)}
>
<Upload className="h-4 w-4 mr-2 text-indigo-400" />
Import Agent JSON
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{isLoading ? (
<div className="flex items-center justify-center h-[60vh]">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-emerald-400"></div>
</div>
) : filteredAgents.length > 0 ? (
<AgentList
agents={filteredAgents}
isLoading={isLoading}
searchTerm={searchTerm}
selectedFolderId={selectedFolderId}
availableMCPs={availableMCPs}
getApiKeyNameById={(id) =>
apiKeys.find((k) => k.id === id)?.name || null
}
getAgentNameById={(id) =>
agents.find((a) => a.id === id)?.name || id
}
onEdit={handleEditAgent}
onDelete={(agent) => {
setAgentToDelete(agent);
setIsDeleteAgentDialogOpen(true);
}}
onMove={(agent) => {
setAgentToMove(agent);
setIsMovingDialogOpen(true);
}}
onShare={handleShareAgent}
onClearSearch={() => {
setSearchTerm("");
setSelectedAgentType(null);
}}
onCreateAgent={() => {
resetForm();
setIsDialogOpen(true);
}}
onWorkflow={(agentId) => {
router.push(`/agents/workflows?agentId=${agentId}`);
}}
apiKeys={apiKeys}
folders={folders}
/>
) : (
<EmptyState
type={
searchTerm || selectedAgentType
? "search-no-results"
: selectedFolderId
? "empty-folder"
: "no-agents"
}
searchTerm={searchTerm}
onAction={() => {
searchTerm || selectedAgentType
? (setSearchTerm(""), setSelectedAgentType(null))
: (resetForm(), setIsDialogOpen(true));
}}
actionLabel={searchTerm || selectedAgentType ? "Clear filters" : "Create Agent"}
/>
)}
</div>
<AgentForm
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
initialValues={newAgent}
apiKeys={apiKeys}
availableModels={availableModels}
availableMCPs={availableMCPs}
agents={agents}
onOpenApiKeysDialog={() => setIsApiKeysDialogOpen(true)}
onOpenMCPDialog={() => setIsMCPDialogOpen(true)}
onOpenCustomMCPDialog={() => setIsCustomMCPDialogOpen(true)}
onSave={handleAddAgent}
getAgentNameById={(id) => agents.find((a) => a.id === id)?.name || id}
clientId={clientId}
/>
<FolderDialog
open={isFolderDialogOpen}
onOpenChange={setIsFolderDialogOpen}
editingFolder={editingFolder}
onSave={handleAddFolder}
/>
<MoveAgentDialog
open={isMovingDialogOpen}
onOpenChange={setIsMovingDialogOpen}
agent={agentToMove}
folders={folders}
onMove={handleMoveAgent}
/>
<ConfirmationDialog
open={isDeleteAgentDialogOpen}
onOpenChange={setIsDeleteAgentDialogOpen}
title="Confirm deletion"
description={`Are you sure you want to delete the agent "${agentToDelete?.name}"? This action cannot be undone.`}
onConfirm={handleDeleteAgent}
/>
<ConfirmationDialog
open={isDeleteFolderDialogOpen}
onOpenChange={setIsDeleteFolderDialogOpen}
title="Confirm deletion"
description={`Are you sure you want to delete the folder "${folderToDelete?.name}"? This action cannot be undone.`}
confirmText="Delete"
confirmVariant="destructive"
onConfirm={handleDeleteFolder}
/>
<ApiKeysDialog
open={isApiKeysDialogOpen}
onOpenChange={setIsApiKeysDialogOpen}
apiKeys={apiKeys}
isLoading={isLoading}
onAddApiKey={async (keyData) => {
await createApiKey({ ...keyData, client_id: clientId });
loadApiKeys();
}}
onUpdateApiKey={async (id, keyData) => {
await updateApiKey(id, keyData, clientId);
loadApiKeys();
}}
onDeleteApiKey={async (id) => {
await deleteApiKey(id, clientId);
loadApiKeys();
}}
/>
<ShareAgentDialog
open={isShareDialogOpen}
onOpenChange={setIsShareDialogOpen}
agent={agentToShare || ({} as Agent)}
apiKey={sharedApiKey}
/>
<ImportAgentDialog
open={isImportDialogOpen}
onOpenChange={setIsImportDialogOpen}
onSuccess={loadAgents}
clientId={clientId}
folderId={selectedFolderId}
/>
</div>
);
}

View File

@@ -0,0 +1,689 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/workflows/Canva.tsx │
│ Developed by: Davidson Gomes │
│ Delay node integration developed by: Victor Calazans │
│ Creation date: May 13, 2025 |
│ Delay implementation date: May 17, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import {
useState,
useEffect,
useRef,
useCallback,
forwardRef,
useImperativeHandle,
} from "react";
import {
Controls,
ReactFlow,
addEdge,
useNodesState,
useEdgesState,
type OnConnect,
ConnectionMode,
ConnectionLineType,
useReactFlow,
ProOptions,
applyNodeChanges,
NodeChange,
OnNodesChange,
MiniMap,
Panel,
Background,
} from "@xyflow/react";
import { useDnD } from "@/contexts/DnDContext";
import { Edit, X, ChevronLeft, ChevronRight } from "lucide-react";
import "@xyflow/react/dist/style.css";
import "./canva.css";
import { getHelperLines } from "./utils";
import { NodePanel } from "./NodePanel";
import ContextMenu from "./ContextMenu";
import { initialEdges, edgeTypes } from "./edges";
import HelperLines from "./HelperLines";
import { initialNodes, nodeTypes } from "./nodes";
import { AgentForm } from "./nodes/components/agent/AgentForm";
import { ConditionForm } from "./nodes/components/condition/ConditionForm";
import { Agent, WorkflowData } from "@/types/agent";
import { updateAgent } from "@/services/agentService";
import { useToast } from "@/hooks/use-toast";
import { MessageForm } from "./nodes/components/message/MessageForm";
import { DelayForm } from "./nodes/components/delay/DelayForm";
import { Button } from "@/components/ui/button";
const proOptions: ProOptions = { account: "paid-pro", hideAttribution: true };
const NodeFormWrapper = ({
selectedNode,
editingLabel,
setEditingLabel,
handleUpdateNode,
setSelectedNode,
children,
}: {
selectedNode: any;
editingLabel: boolean;
setEditingLabel: (value: boolean) => void;
handleUpdateNode: (node: any) => void;
setSelectedNode: (node: any) => void;
children: React.ReactNode;
}) => {
// Handle ESC key to close the panel
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && !editingLabel) {
setSelectedNode(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [setSelectedNode, editingLabel]);
return (
<div className="flex flex-col h-full">
<div className="flex-shrink-0 sticky top-0 z-20 bg-neutral-800 shadow-md border-b border-neutral-700">
<div className="p-4 text-center relative">
<button
className="absolute right-2 top-2 text-neutral-200 hover:text-white p-1 rounded-full hover:bg-neutral-700"
onClick={() => setSelectedNode(null)}
aria-label="Close panel"
>
<X size={18} />
</button>
{!editingLabel ? (
<div className="flex items-center justify-center text-xl font-bold text-neutral-200">
<span>{selectedNode.data.label}</span>
{selectedNode.type !== "start-node" && (
<Edit
size={16}
className="ml-2 cursor-pointer hover:text-indigo-300"
onClick={() => setEditingLabel(true)}
/>
)}
</div>
) : (
<input
type="text"
value={selectedNode.data.label}
className="w-full p-2 text-center text-xl font-bold bg-neutral-800 text-neutral-200 border border-neutral-600 rounded"
onChange={(e) => {
handleUpdateNode({
...selectedNode,
data: {
...selectedNode.data,
label: e.target.value,
},
});
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
setEditingLabel(false);
}
}}
onBlur={() => setEditingLabel(false)}
autoFocus
/>
)}
</div>
</div>
<div className="flex-1 min-h-0 overflow-hidden">{children}</div>
</div>
);
};
const Canva = forwardRef(({ agent }: { agent: Agent | null }, ref) => {
const { toast } = useToast();
const [nodes, setNodes] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const { screenToFlowPosition } = useReactFlow();
const { type, setPointerEvents } = useDnD();
const [menu, setMenu] = useState<any>(null);
const localRef = useRef<any>(null);
const [selectedNode, setSelectedNode] = useState<any>(null);
const [activeExecutionNodeId, setActiveExecutionNodeId] = useState<
string | null
>(null);
const [editingLabel, setEditingLabel] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [nodePanelOpen, setNodePanelOpen] = useState(false);
useImperativeHandle(ref, () => ({
getFlowData: () => ({
nodes,
edges,
}),
setHasChanges,
setActiveExecutionNodeId,
}));
// Effect to clear the active node after a timeout
useEffect(() => {
if (activeExecutionNodeId) {
const timer = setTimeout(() => {
setActiveExecutionNodeId(null);
}, 5000); // Increase to 5 seconds to give more time to visualize
return () => clearTimeout(timer);
}
}, [activeExecutionNodeId]);
useEffect(() => {
if (
agent?.config?.workflow &&
agent.config.workflow.nodes.length > 0 &&
agent.config.workflow.edges.length > 0
) {
setNodes(
(agent.config.workflow.nodes as typeof initialNodes) || initialNodes
);
setEdges(
(agent.config.workflow.edges as typeof initialEdges) || initialEdges
);
} else {
setNodes(initialNodes);
setEdges(initialEdges);
}
}, [agent, setNodes, setEdges]);
// Update nodes when the active node changes to add visual class
useEffect(() => {
if (nodes.length > 0) {
setNodes((nds: any) =>
nds.map((node: any) => {
if (node.id === activeExecutionNodeId) {
// Add a class to highlight the active node
return {
...node,
className: "active-execution-node",
data: {
...node.data,
isExecuting: true,
},
};
} else {
// Remove the highlight class
const { isExecuting, ...restData } = node.data || {};
return {
...node,
className: "",
data: restData,
};
}
})
);
}
}, [activeExecutionNodeId, setNodes]);
useEffect(() => {
if (agent?.config?.workflow) {
const initialNodes = agent.config.workflow.nodes || [];
const initialEdges = agent.config.workflow.edges || [];
if (
JSON.stringify(nodes) !== JSON.stringify(initialNodes) ||
JSON.stringify(edges) !== JSON.stringify(initialEdges)
) {
setHasChanges(true);
} else {
setHasChanges(false);
}
}
}, [nodes, edges, agent]);
const [helperLineHorizontal, setHelperLineHorizontal] = useState<
number | undefined
>(undefined);
const [helperLineVertical, setHelperLineVertical] = useState<
number | undefined
>(undefined);
const onConnect: OnConnect = useCallback(
(connection) => {
setEdges((currentEdges) => {
if (connection.source === connection.target) {
return currentEdges;
}
return addEdge(connection, currentEdges);
});
},
[setEdges]
);
const onConnectEnd = useCallback(
(_event: any, connectionState: any) => {
setPointerEvents("none");
if (connectionState.fromHandle?.type === "target") {
return;
}
if (!connectionState.isValid) {
// Since we're using NodePanel now, we don't need to do anything here
// The panel will handle node creation through drag and drop
}
},
[setPointerEvents]
);
const onConnectStart = useCallback(() => {
setPointerEvents("auto");
}, [setPointerEvents]);
const customApplyNodeChanges = useCallback(
(changes: NodeChange[], nodes: any): any => {
// reset the helper lines (clear existing lines, if any)
setHelperLineHorizontal(undefined);
setHelperLineVertical(undefined);
// this will be true if it's a single node being dragged
// inside we calculate the helper lines and snap position for the position where the node is being moved to
if (
changes.length === 1 &&
changes[0].type === "position" &&
changes[0].dragging &&
changes[0].position
) {
const helperLines = getHelperLines(changes[0], nodes);
// if we have a helper line, we snap the node to the helper line position
// this is being done by manipulating the node position inside the change object
changes[0].position.x =
helperLines.snapPosition.x ?? changes[0].position.x;
changes[0].position.y =
helperLines.snapPosition.y ?? changes[0].position.y;
// if helper lines are returned, we set them so that they can be displayed
setHelperLineHorizontal(helperLines.horizontal);
setHelperLineVertical(helperLines.vertical);
}
return applyNodeChanges(changes, nodes);
},
[]
);
const onNodesChange: OnNodesChange = useCallback(
(changes) => {
setNodes((nodes) => customApplyNodeChanges(changes, nodes));
},
[setNodes, customApplyNodeChanges]
);
const getLabelFromNode = (type: string) => {
const order = nodes.length;
switch (type) {
case "start-node":
return "Start";
case "agent-node":
return `Agent #${order}`;
case "condition-node":
return `Condition #${order}`;
case "message-node":
return `Message #${order}`;
case "delay-node":
return `Delay #${order}`;
default:
return "Node";
}
};
const handleAddNode = useCallback(
(type: any, node: any) => {
const newNode: any = {
id: String(Date.now()),
type,
position: node.position,
data: {
label: getLabelFromNode(type),
},
};
setNodes((nodes) => [...nodes, newNode]);
if (node.targetId) {
const newEdge: any = {
source: node.targetId,
sourceHandle: node.handleId,
target: newNode.id,
type: "default",
};
const newsEdges: any = [...edges, newEdge];
setEdges(newsEdges);
}
},
[nodes, setNodes, edges, setEdges]
);
const handleUpdateNode = useCallback(
(node: any) => {
setNodes((nodes) => {
const index = nodes.findIndex((n) => n.id === node.id);
if (index !== -1) {
nodes[index] = node;
}
return [...nodes];
});
if (selectedNode && selectedNode.id === node.id) {
setSelectedNode(node);
}
},
[setNodes, selectedNode]
);
const handleDeleteEdge = useCallback(
(id: any) => {
setEdges((edges) => {
const left = edges.filter((edge: any) => edge.id !== id);
return left;
});
},
[setEdges]
);
const onDragOver = useCallback((event: any) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}, []);
const onDrop = useCallback(
(event: any) => {
event.preventDefault();
if (!type) {
return;
}
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
const newNode: any = {
id: String(Date.now()),
type,
position,
data: {
label: getLabelFromNode(type),
},
};
setNodes((nodes) => [...nodes, newNode]);
},
[screenToFlowPosition, setNodes, type, getLabelFromNode]
);
const onNodeContextMenu = useCallback(
(event: any, node: any) => {
event.preventDefault();
if (node.id === "start-node") {
return;
}
if (!localRef.current) {
return;
}
const paneBounds = localRef.current.getBoundingClientRect();
const x = event.clientX - paneBounds.left;
const y = event.clientY - paneBounds.top;
const menuWidth = 200;
const menuHeight = 200;
const left = x + menuWidth > paneBounds.width ? undefined : x;
const top = y + menuHeight > paneBounds.height ? undefined : y;
const right =
x + menuWidth > paneBounds.width ? paneBounds.width - x : undefined;
const bottom =
y + menuHeight > paneBounds.height ? paneBounds.height - y : undefined;
setMenu({
id: node.id,
left,
top,
right,
bottom,
});
},
[setMenu]
);
const onNodeClick = useCallback((event: any, node: any) => {
event.preventDefault();
if (node.type === "start-node") {
return;
}
setSelectedNode(node);
}, []);
const onPaneClick = useCallback(() => {
setMenu(null);
setSelectedNode(null);
setNodePanelOpen(false);
}, [setMenu, setSelectedNode]);
return (
<div className="h-full w-full bg-[#121212]">
<div
style={{ position: "relative", height: "100%", width: "100%" }}
ref={localRef}
className="overflow-hidden"
>
<ReactFlow
nodes={nodes}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
edges={edges}
edgeTypes={edgeTypes}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
connectionMode={ConnectionMode.Strict}
connectionLineType={ConnectionLineType.Bezier}
onDrop={onDrop}
onDragOver={onDragOver}
onPaneClick={onPaneClick}
onNodeClick={onNodeClick}
onNodeContextMenu={onNodeContextMenu}
colorMode="dark"
minZoom={0.1}
maxZoom={10}
fitView={false}
defaultViewport={{
x: 0,
y: 0,
zoom: 1,
}}
elevateEdgesOnSelect
elevateNodesOnSelect
proOptions={proOptions}
connectionLineStyle={{
stroke: "gray",
strokeWidth: 2,
strokeDashoffset: 5,
strokeDasharray: 5,
}}
defaultEdgeOptions={{
type: "default",
style: {
strokeWidth: 3,
},
data: {
handleDeleteEdge,
},
}}
>
<Background color="#334155" gap={24} size={1.5} />
<MiniMap
className="bg-neutral-800/80 border border-neutral-700 rounded-lg shadow-lg"
nodeColor={(node) => {
switch (node.type) {
case "start-node":
return "#10b981";
case "agent-node":
return "#3b82f6";
case "message-node":
return "#f97316";
case "condition-node":
return "#3b82f6";
case "delay-node":
return "#eab308";
default:
return "#64748b";
}
}}
maskColor="rgba(15, 23, 42, 0.6)"
/>
<Controls
showInteractive={true}
showFitView={true}
orientation="vertical"
position="bottom-left"
/>
<HelperLines
horizontal={helperLineHorizontal}
vertical={helperLineVertical}
/>
{menu && <ContextMenu onClick={onPaneClick} {...menu} />}
{nodePanelOpen ? (
<Panel position="top-right">
<div className="flex items-start">
<Button
variant="outline"
size="icon"
onClick={() => setNodePanelOpen(false)}
className="mr-2 h-8 w-8 rounded-full bg-slate-800 border-slate-700 text-slate-400 hover:text-white hover:bg-slate-700"
>
<ChevronRight className="h-4 w-4" />
</Button>
<NodePanel />
</div>
</Panel>
) : (
<Panel position="top-right">
<Button
variant="outline"
size="icon"
onClick={() => setNodePanelOpen(true)}
className="h-8 w-8 rounded-full bg-slate-800 border-slate-700 text-slate-400 hover:text-white hover:bg-slate-700"
>
<ChevronLeft className="h-4 w-4" />
</Button>
</Panel>
)}
</ReactFlow>
{/* Overlay when form is open on smaller screens */}
{selectedNode && (
<div
className="md:hidden fixed inset-0 bg-black bg-opacity-50 z-[5] transition-opacity duration-300"
onClick={() => setSelectedNode(null)}
/>
)}
<div
className="absolute left-0 top-0 z-10 h-full w-[350px] bg-neutral-900 shadow-lg transition-all duration-300 ease-in-out border-r border-neutral-700 flex flex-col"
style={{
transform: selectedNode ? "translateX(0)" : "translateX(-100%)",
opacity: selectedNode ? 1 : 0,
}}
>
{selectedNode ? (
<NodeFormWrapper
selectedNode={selectedNode}
editingLabel={editingLabel}
setEditingLabel={setEditingLabel}
handleUpdateNode={handleUpdateNode}
setSelectedNode={setSelectedNode}
>
{selectedNode.type === "agent-node" && (
<AgentForm
selectedNode={selectedNode}
handleUpdateNode={handleUpdateNode}
setEdges={setEdges}
setIsOpen={() => {}}
setSelectedNode={setSelectedNode}
/>
)}
{selectedNode.type === "condition-node" && (
<ConditionForm
selectedNode={selectedNode}
handleUpdateNode={handleUpdateNode}
setEdges={setEdges}
setIsOpen={() => {}}
setSelectedNode={setSelectedNode}
/>
)}
{selectedNode.type === "message-node" && (
<MessageForm
selectedNode={selectedNode}
handleUpdateNode={handleUpdateNode}
setEdges={setEdges}
setIsOpen={() => {}}
setSelectedNode={setSelectedNode}
/>
)}
{selectedNode.type === "delay-node" && (
<DelayForm
selectedNode={selectedNode}
handleUpdateNode={handleUpdateNode}
setEdges={setEdges}
setIsOpen={() => {}}
setSelectedNode={setSelectedNode}
/>
)}
</NodeFormWrapper>
) : null}
</div>
</div>
</div>
);
});
Canva.displayName = "Canva";
export default Canva;

View File

@@ -0,0 +1,118 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/workflows/ContextMenu.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
import { useReactFlow, Node, Edge } from "@xyflow/react";
import { Copy, Trash2 } from "lucide-react";
import React, { useCallback } from "react";
interface ContextMenuProps extends React.HTMLAttributes<HTMLDivElement> {
id: string;
top?: number | string;
left?: number | string;
right?: number | string;
bottom?: number | string;
}
export default function ContextMenu({
id,
top,
left,
right,
bottom,
...props
}: ContextMenuProps) {
const { getNode, setNodes, addNodes, setEdges } = useReactFlow();
const duplicateNode = useCallback(() => {
const node = getNode(id);
if (!node) {
console.error(`Node with id ${id} not found.`);
return;
}
const position = {
x: node.position.x + 50,
y: node.position.y + 50,
};
addNodes({
...node,
id: `${node.id}-copy`,
position,
selected: false,
dragging: false,
});
}, [id, getNode, addNodes]);
const deleteNode = useCallback(() => {
setNodes((nodes: Node[]) => nodes.filter((node) => node.id !== id));
setEdges((edges: Edge[]) =>
edges.filter((edge) => edge.source !== id && edge.target !== id),
);
}, [id, setNodes, setEdges]);
return (
<div
style={{
position: "absolute",
top: top !== undefined ? `${top}px` : undefined,
left: left !== undefined ? `${left}px` : undefined,
right: right !== undefined ? `${right}px` : undefined,
bottom: bottom !== undefined ? `${bottom}px` : undefined,
zIndex: 10,
}}
className="context-menu rounded-md border p-3 shadow-lg border-neutral-700 bg-neutral-800"
{...props}
>
<p className="mb-2 text-sm font-semibold text-neutral-200">
Actions
</p>
<button
onClick={duplicateNode}
className="mb-1 flex w-full flex-row items-center rounded-md px-2 py-1 text-sm hover:bg-neutral-700"
>
<Copy
size={16}
className="mr-2 flex-shrink-0 text-blue-300"
/>
<span className="text-neutral-300">Duplicate</span>
</button>
<button
onClick={deleteNode}
className="flex w-full flex-row items-center rounded-md px-2 py-1 text-sm hover:bg-neutral-700"
>
<Trash2
size={16}
className="mr-2 flex-shrink-0 text-red-300"
/>
<span className="text-neutral-300">Delete</span>
</button>
</div>
);
}

View File

@@ -0,0 +1,98 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/workflows/HelperLines.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
import { ReactFlowState, useStore } from "@xyflow/react";
import { CSSProperties, useEffect, useRef } from "react";
const canvasStyle: CSSProperties = {
width: "100%",
height: "100%",
position: "absolute",
zIndex: 10,
pointerEvents: "none",
};
const storeSelector = (state: ReactFlowState) => ({
width: state.width,
height: state.height,
transform: state.transform,
});
export type HelperLinesProps = {
horizontal?: number;
vertical?: number;
};
// a simple component to display the helper lines
// it puts a canvas on top of the React Flow pane and draws the lines using the canvas API
function HelperLinesRenderer({ horizontal, vertical }: HelperLinesProps) {
const { width, height, transform } = useStore(storeSelector);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!ctx || !canvas) {
return;
}
const dpi = window.devicePixelRatio;
canvas.width = width * dpi;
canvas.height = height * dpi;
ctx.scale(dpi, dpi);
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = "#1d5ade";
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
if (typeof vertical === "number") {
ctx.moveTo(vertical * transform[2] + transform[0], 0);
ctx.lineTo(vertical * transform[2] + transform[0], height);
ctx.stroke();
}
if (typeof horizontal === "number") {
ctx.moveTo(0, horizontal * transform[2] + transform[1]);
ctx.lineTo(width, horizontal * transform[2] + transform[1]);
ctx.stroke();
}
}, [width, height, transform, horizontal, vertical]);
return (
<canvas
ref={canvasRef}
className="react-flow__canvas"
style={canvasStyle}
/>
);
}
export default HelperLinesRenderer;

View File

@@ -0,0 +1,273 @@
"use client";
import type React from "react";
import { useState } from "react";
import {
User,
MessageSquare,
Filter,
Clock,
Plus,
MenuSquare,
Layers,
MoveRight,
} from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useDnD } from "@/contexts/DnDContext";
export function NodePanel() {
const [activeTab, setActiveTab] = useState("content");
const { setType } = useDnD();
const nodeTypes = {
content: [
{
id: "agent-node",
name: "Agent",
icon: User,
color: "text-blue-400",
bgColor: "bg-blue-950/40",
borderColor: "border-blue-500/30",
hoverColor: "group-hover:bg-blue-900/50",
glowColor: "group-hover:shadow-blue-500/20",
description: "Add an AI agent to process messages and execute tasks",
},
{
id: "message-node",
name: "Message",
icon: MessageSquare,
color: "text-orange-400",
bgColor: "bg-orange-950/40",
borderColor: "border-orange-500/30",
hoverColor: "group-hover:bg-orange-900/50",
glowColor: "group-hover:shadow-orange-500/20",
description: "Send a message to users or other nodes in the workflow",
},
],
logic: [
{
id: "condition-node",
name: "Condition",
icon: Filter,
color: "text-purple-400",
bgColor: "bg-purple-950/40",
borderColor: "border-purple-500/30",
hoverColor: "group-hover:bg-purple-900/50",
glowColor: "group-hover:shadow-purple-500/20",
description:
"Create a decision point with multiple outcomes based on conditions",
},
{
id: "delay-node",
name: "Delay",
icon: Clock,
color: "text-yellow-400",
bgColor: "bg-yellow-950/40",
borderColor: "border-yellow-500/30",
hoverColor: "group-hover:bg-yellow-900/50",
glowColor: "group-hover:shadow-yellow-500/20",
description: "Add a time delay between workflow operations",
},
],
};
const onDragStart = (event: React.DragEvent, nodeType: string) => {
event.dataTransfer.setData("application/reactflow", nodeType);
event.dataTransfer.effectAllowed = "move";
setType(nodeType);
};
const handleNodeAdd = (nodeType: string) => {
setType(nodeType);
};
return (
<div className="bg-slate-900/70 backdrop-blur-md border border-slate-700/50 rounded-xl shadow-xl w-[320px] transition-all duration-300 ease-in-out overflow-hidden">
<div className="px-4 pt-4 pb-2 border-b border-slate-700/50">
<div className="flex items-center gap-2 text-slate-200">
<Layers className="h-5 w-5 text-indigo-400" />
<h3 className="font-medium">Workflow Nodes</h3>
</div>
<p className="text-xs text-slate-400 mt-1">
Drag nodes to the canvas or click to add
</p>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<div className="px-4 pt-3">
<TabsList className="w-full bg-slate-800/50 grid grid-cols-2 p-1 rounded-lg">
<TabsTrigger
value="content"
className={cn(
"rounded-md text-xs font-medium transition-all",
"data-[state=active]:bg-gradient-to-br data-[state=active]:from-blue-900/30 data-[state=active]:to-indigo-900/30",
"data-[state=active]:text-blue-300 data-[state=active]:shadow-sm",
"data-[state=inactive]:text-slate-400"
)}
>
<MenuSquare className="h-3.5 w-3.5 mr-1.5" />
Content
</TabsTrigger>
<TabsTrigger
value="logic"
className={cn(
"rounded-md text-xs font-medium transition-all",
"data-[state=active]:bg-gradient-to-br data-[state=active]:from-yellow-900/30 data-[state=active]:to-orange-900/30",
"data-[state=active]:text-yellow-300 data-[state=active]:shadow-sm",
"data-[state=inactive]:text-slate-400"
)}
>
<Filter className="h-3.5 w-3.5 mr-1.5" />
Logic
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="content" className="p-3 space-y-2 mt-0">
{nodeTypes.content.map((node) => (
<TooltipProvider key={node.id} delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<div
draggable
onDragStart={(event) => onDragStart(event, node.id)}
className={cn(
"group flex items-center gap-3 p-3.5 border rounded-lg cursor-grab transition-all duration-300",
"backdrop-blur-sm hover:shadow-lg",
node.borderColor,
node.bgColor,
node.glowColor
)}
>
<div
className={cn(
"flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300",
"bg-slate-800/80 group-hover:scale-105",
node.hoverColor
)}
>
<node.icon className={cn("h-5 w-5", node.color)} />
</div>
<div className="flex-1 min-w-0">
<span
className={cn("font-medium block text-sm", node.color)}
>
{node.name}
</span>
<span className="text-xs text-slate-400 truncate block">
{node.description}
</span>
</div>
<div
onClick={() => handleNodeAdd(node.id)}
className={cn(
"flex items-center justify-center h-7 w-7 rounded-md bg-slate-800/60 text-slate-400",
"hover:bg-gradient-to-r hover:text-white transition-all",
node.id === "agent-node"
? "hover:from-blue-800 hover:to-blue-600"
: "hover:from-orange-800 hover:to-orange-600"
)}
>
<Plus size={16} />
</div>
</div>
</TooltipTrigger>
<TooltipContent
side="right"
className="bg-slate-900 border-slate-700 text-slate-200"
>
<div className="p-1 max-w-[200px]">
<p className="font-medium text-sm">{node.name} Node</p>
<p className="text-xs text-slate-400 mt-1">
{node.description}
</p>
<div className="flex items-center mt-2 pt-2 border-t border-slate-700/50 text-xs text-slate-400">
<MoveRight className="h-3 w-3 mr-1.5" />
<span>Drag to canvas or click + to add</span>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</TabsContent>
<TabsContent value="logic" className="p-3 space-y-2 mt-0">
{nodeTypes.logic.map((node) => (
<TooltipProvider key={node.id} delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<div
draggable
onDragStart={(event) => onDragStart(event, node.id)}
className={cn(
"group flex items-center gap-3 p-3.5 border rounded-lg cursor-grab transition-all duration-300",
"backdrop-blur-sm hover:shadow-lg",
node.borderColor,
node.bgColor,
node.glowColor
)}
>
<div
className={cn(
"flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300",
"bg-slate-800/80 group-hover:scale-105",
node.hoverColor
)}
>
<node.icon className={cn("h-5 w-5", node.color)} />
</div>
<div className="flex-1 min-w-0">
<span
className={cn("font-medium block text-sm", node.color)}
>
{node.name}
</span>
<span className="text-xs text-slate-400 truncate block">
{node.description}
</span>
</div>
<div
onClick={() => handleNodeAdd(node.id)}
className={cn(
"flex items-center justify-center h-7 w-7 rounded-md bg-slate-800/60 text-slate-400",
"hover:bg-gradient-to-r hover:text-white transition-all",
node.id === "condition-node"
? "hover:from-purple-800 hover:to-purple-600"
: "hover:from-yellow-800 hover:to-yellow-600"
)}
>
<Plus size={16} />
</div>
</div>
</TooltipTrigger>
<TooltipContent
side="right"
className="bg-slate-900 border-slate-700 text-slate-200"
>
<div className="p-1 max-w-[200px]">
<p className="font-medium text-sm">{node.name} Node</p>
<p className="text-xs text-slate-400 mt-1">
{node.description}
</p>
<div className="flex items-center mt-2 pt-2 border-t border-slate-700/50 text-xs text-slate-400">
<MoveRight className="h-3 w-3 mr-1.5" />
<span>Drag to canvas or click + to add</span>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,188 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/workflows/canva.css │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
.react-flow.dark {
--xy-background-color-default: transparent;
}
.react-flow__panel {
box-shadow: none;
padding: 5px;
}
.react-flow__controls {
background-color: rgba(30, 30, 30, 0.8);
backdrop-filter: blur(4px);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(70, 70, 70, 0.5);
padding: 4px;
}
.react-flow__controls-button {
padding: 6px;
width: 32px;
height: 32px;
margin: 4px 0;
border: none !important;
background-color: rgba(40, 40, 40, 0.8) !important;
color: #a0a0a0 !important;
border-radius: 6px !important;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.react-flow__controls-button:hover {
background-color: rgba(60, 60, 60, 0.9) !important;
color: #ffffff !important;
transform: translateY(-1px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
.react-flow__controls-button svg {
fill: currentColor;
width: 16px;
height: 16px;
}
.react-flow__controls-button[data-action="zoom-in"] {
border-bottom: 1px solid rgba(70, 70, 70, 0.5) !important;
}
.react-flow__controls-button[data-action="zoom-out"] {
border-bottom: 1px solid rgba(70, 70, 70, 0.5) !important;
}
/* Hide scrollbar for all browsers while maintaining scroll functionality */
.scrollbar-hide {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
width: 0;
height: 0;
background: transparent;
}
/* Animated dashed edge for flow edges */
.edge-dashed-animated {
stroke-dasharray: 6;
stroke-dashoffset: 0;
animation: dash 0.5s linear infinite;
}
@keyframes dash {
to {
stroke-dashoffset: -12;
}
}
/* Edges styling */
.react-flow__edge-path {
stroke: #10b981; /* Emerald color for edges */
stroke-width: 2;
}
.react-flow__edge.selected .react-flow__edge-path {
stroke: #3b82f6; /* Blue color for selected edges */
stroke-width: 3;
}
/* Node handle styling */
.react-flow__handle {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #10b981;
border: 2px solid #0d9488;
}
.react-flow__handle:hover {
background-color: #3b82f6;
border-color: #2563eb;
}
/* Nó em execução */
.active-execution-node {
animation: pulse-execution 1.5s infinite;
filter: drop-shadow(0 0 15px rgba(5, 212, 114, 0.9));
z-index: 10;
transform: scale(1.03);
transition: transform 0.3s ease-in-out;
}
@keyframes pulse-execution {
0% {
box-shadow: 0 0 0 0 rgba(5, 212, 114, 0.9);
}
70% {
box-shadow: 0 0 0 15px rgba(5, 212, 114, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(5, 212, 114, 0);
}
}
.react-flow__node[data-is-executing="true"] {
filter: drop-shadow(0 0 15px rgba(5, 212, 114, 0.9));
transform: scale(1.03);
transition: transform 0.3s ease-in-out;
}
.react-flow__node[data-is-executing="true"]::before {
content: '';
position: absolute;
top: -5px;
left: -5px;
right: -5px;
bottom: -5px;
border: 2px solid #05d472;
border-radius: 8px;
animation: pulse-border 1.5s infinite;
z-index: -1;
}
@keyframes pulse-border {
0% {
opacity: 1;
transform: scale(1);
}
70% {
opacity: 0.3;
transform: scale(1.03);
}
100% {
opacity: 1;
transform: scale(1);
}
}

View File

@@ -0,0 +1,119 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/workflows/edges/DefaultEdge.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
import {
BaseEdge,
EdgeLabelRenderer,
EdgeProps,
getSmoothStepPath,
useReactFlow,
} from "@xyflow/react";
import { Trash2 } from "lucide-react";
export default function DefaultEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
selected,
}: EdgeProps) {
const { setEdges } = useReactFlow();
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
borderRadius: 15,
});
const onEdgeClick = () => {
console.log("onEdgeClick", id);
setEdges((edges) => edges.filter((edge) => edge.id !== id));
};
return (
<>
<svg>
<defs>
<marker
id="arrowhead"
viewBox="0 0 10 16"
refX="12"
refY="8"
markerWidth="4"
markerHeight="5"
orient="auto"
>
<path d="M 0 0 L 10 8 L 0 16 z" fill="gray" />
</marker>
</defs>
</svg>
<BaseEdge
id={id}
path={edgePath}
className="edge-dashed-animated"
style={{
...style,
stroke: '#10B981',
strokeWidth: 3,
}}
markerEnd="url(#arrowhead)"
/>
{selected && (
<EdgeLabelRenderer>
<div
style={{
position: "absolute",
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
fontSize: 12,
pointerEvents: "all",
zIndex: 1000,
}}
className="nodrag nopan"
>
<button
className="rounded-full bg-white p-1 shadow-md"
onClick={onEdgeClick}
>
<Trash2 className="text-red-500" size={16} />
</button>
</div>
</EdgeLabelRenderer>
)}
</>
);
}

View File

@@ -0,0 +1,41 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/workflows/edges/index.ts │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
import type { Edge, EdgeTypes } from "@xyflow/react";
import DefaultEdge from "./DefaultEdge";
export const initialEdges = [
// { id: "a->c", source: "a", target: "c", animated: true },
// { id: "b->d", source: "b", target: "d" },
// { id: "c->d", source: "c", target: "d", animated: true },
] satisfies Edge[];
export const edgeTypes = {
default: DefaultEdge,
} satisfies EdgeTypes;

View File

@@ -0,0 +1,166 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/workflows/nodes/BaseNode.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
import { Handle, Position } from "@xyflow/react";
import React from "react";
import { cn } from "@/lib/utils";
import { useDnD } from "@/contexts/DnDContext";
export function BaseNode({
selected,
hasTarget,
children,
borderColor,
isExecuting
}: {
selected: boolean;
hasTarget: boolean;
children: React.ReactNode;
borderColor: string;
isExecuting?: boolean;
}) {
const { pointerEvents } = useDnD();
// Border and background color mapping
const colorStyles = {
blue: {
border: "border-blue-700/70 hover:border-blue-500",
gradient: "bg-gradient-to-br from-blue-950/40 to-neutral-900/90",
glow: "shadow-[0_0_15px_rgba(59,130,246,0.15)]",
selectedGlow: "shadow-[0_0_25px_rgba(59,130,246,0.3)]"
},
orange: {
border: "border-orange-700/70 hover:border-orange-500",
gradient: "bg-gradient-to-br from-orange-950/40 to-neutral-900/90",
glow: "shadow-[0_0_15px_rgba(249,115,22,0.15)]",
selectedGlow: "shadow-[0_0_25px_rgba(249,115,22,0.3)]"
},
green: {
border: "border-green-700/70 hover:border-green-500",
gradient: "bg-gradient-to-br from-green-950/40 to-neutral-900/90",
glow: "shadow-[0_0_15px_rgba(34,197,94,0.15)]",
selectedGlow: "shadow-[0_0_25px_rgba(34,197,94,0.3)]"
},
red: {
border: "border-red-700/70 hover:border-red-500",
gradient: "bg-gradient-to-br from-red-950/40 to-neutral-900/90",
glow: "shadow-[0_0_15px_rgba(239,68,68,0.15)]",
selectedGlow: "shadow-[0_0_25px_rgba(239,68,68,0.3)]"
},
yellow: {
border: "border-yellow-700/70 hover:border-yellow-500",
gradient: "bg-gradient-to-br from-yellow-950/40 to-neutral-900/90",
glow: "shadow-[0_0_15px_rgba(234,179,8,0.15)]",
selectedGlow: "shadow-[0_0_25px_rgba(234,179,8,0.3)]"
},
purple: {
border: "border-purple-700/70 hover:border-purple-500",
gradient: "bg-gradient-to-br from-purple-950/40 to-neutral-900/90",
glow: "shadow-[0_0_15px_rgba(168,85,247,0.15)]",
selectedGlow: "shadow-[0_0_25px_rgba(168,85,247,0.3)]"
},
indigo: {
border: "border-indigo-700/70 hover:border-indigo-500",
gradient: "bg-gradient-to-br from-indigo-950/40 to-neutral-900/90",
glow: "shadow-[0_0_15px_rgba(99,102,241,0.15)]",
selectedGlow: "shadow-[0_0_25px_rgba(99,102,241,0.3)]"
},
pink: {
border: "border-pink-700/70 hover:border-pink-500",
gradient: "bg-gradient-to-br from-pink-950/40 to-neutral-900/90",
glow: "shadow-[0_0_15px_rgba(236,72,153,0.15)]",
selectedGlow: "shadow-[0_0_25px_rgba(236,72,153,0.3)]"
},
emerald: {
border: "border-emerald-700/70 hover:border-emerald-500",
gradient: "bg-gradient-to-br from-emerald-950/40 to-neutral-900/90",
glow: "shadow-[0_0_15px_rgba(16,185,129,0.15)]",
selectedGlow: "shadow-[0_0_25px_rgba(16,185,129,0.3)]"
},
slate: {
border: "border-slate-700/70 hover:border-slate-500",
gradient: "bg-gradient-to-br from-slate-800/40 to-neutral-900/90",
glow: "shadow-[0_0_15px_rgba(100,116,139,0.15)]",
selectedGlow: "shadow-[0_0_25px_rgba(100,116,139,0.3)]"
},
};
// Default to blue if color not in mapping
const colorStyle = colorStyles[borderColor as keyof typeof colorStyles] || colorStyles.blue;
// Selected styles
const selectedStyle = {
border: "border-green-500/90",
glow: colorStyle.selectedGlow
};
// Executing styles
const executingStyle = {
border: "border-emerald-500",
glow: "shadow-[0_0_25px_rgba(5,212,114,0.5)]"
};
return (
<>
<div
className={cn(
"relative z-0 w-[350px] rounded-2xl p-4 border-2 backdrop-blur-sm transition-all duration-300",
"shadow-lg hover:shadow-xl",
isExecuting ? executingStyle.glow : selected ? selectedStyle.glow : colorStyle.glow,
isExecuting ? executingStyle.border : selected ? selectedStyle.border : colorStyle.border,
colorStyle.gradient,
isExecuting && "active-execution-node"
)}
style={{
backdropFilter: "blur(12px)",
}}
data-is-executing={isExecuting ? "true" : "false"}
>
{hasTarget && (
<Handle
style={{
position: "absolute",
left: "50%",
top: "50%",
width: "100%",
borderRadius: "15px",
height: "100%",
backgroundColor: "transparent",
border: "none",
pointerEvents: pointerEvents === "none" ? "none" : "auto",
}}
type="target"
position={Position.Left}
/>
)}
{children}
</div>
</>
);
}

View File

@@ -0,0 +1,347 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/workflows/nodes/components/agent/AgentChatMessageList.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 14, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import React, { useEffect, useRef } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ChevronDown, ChevronRight, Copy, Check } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useState } from "react";
import { Agent } from "@/types/agent";
import { InlineDataAttachments } from "@/app/chat/components/InlineDataAttachments";
interface FunctionMessageContent {
title: string;
content: string;
author?: string;
}
interface ChatMessage {
id: string;
content: {
parts: any[];
role: string;
};
author: string;
timestamp: number;
}
interface AgentChatMessageListProps {
messages: ChatMessage[];
agent: Agent;
expandedFunctions: Record<string, boolean>;
toggleFunctionExpansion: (messageId: string) => void;
getMessageText: (message: ChatMessage) => string | FunctionMessageContent;
containsMarkdown: (text: string) => boolean;
}
export function AgentChatMessageList({
messages,
agent,
expandedFunctions,
toggleFunctionExpansion,
getMessageText,
containsMarkdown,
}: AgentChatMessageListProps) {
const messagesEndRef = useRef<HTMLDivElement>(null);
// Scroll to bottom whenever messages change
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [messages]);
return (
<div className="space-y-6">
{messages.map((message) => {
const messageContent = getMessageText(message);
const isExpanded = expandedFunctions[message.id] || false;
const isUser = message.author === "user";
const agentColor = "bg-emerald-400";
const hasFunctionCall = message.content.parts.some(
(part) => part.functionCall || part.function_call
);
const hasFunctionResponse = message.content.parts.some(
(part) => part.functionResponse || part.function_response
);
const isFunctionMessage = hasFunctionCall || hasFunctionResponse;
const isTaskExecutor = typeof messageContent === "object" &&
"author" in messageContent &&
typeof messageContent.author === "string" &&
messageContent.author.endsWith("- Task executor");
const inlineDataParts = message.content.parts.filter(part => part.inline_data);
const hasInlineData = inlineDataParts.length > 0;
const isWorkflowNode = message.author && message.author.startsWith('workflow-node:');
const nodeId = isWorkflowNode ? message.author.split(':')[1] : null;
return (
<div
key={message.id}
className="flex w-full"
style={{
justifyContent: isUser ? "flex-end" : "flex-start"
}}
>
<div
className="flex gap-3 max-w-[90%]"
style={{
flexDirection: isUser ? "row-reverse" : "row"
}}
>
<Avatar className={isUser ? "bg-[#333]" : agentColor}>
<AvatarFallback>
{isUser ? "U" : agent.name[0] || "A"}
</AvatarFallback>
</Avatar>
<div
className={`rounded-lg p-3 ${isFunctionMessage || isTaskExecutor
? "bg-[#333] text-emerald-400 font-mono text-sm"
: isUser
? "bg-emerald-400 text-black"
: "bg-[#222] text-white"
} overflow-hidden relative group`}
style={{
wordBreak: "break-word",
maxWidth: "calc(100% - 3rem)",
width: "100%",
...(isWorkflowNode ? {
borderLeft: '3px solid #05d472',
boxShadow: '0 0 10px rgba(5, 212, 114, 0.2)'
} : {})
}}
>
{isWorkflowNode && (
<div className="text-xs text-emerald-500 mb-1 flex items-center space-x-1 bg-emerald-950/30 p-1 rounded">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-3 w-3 mr-1">
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
<span>Node {nodeId} is running</span>
</div>
)}
{isFunctionMessage || isTaskExecutor ? (
<div className="w-full">
<div
className="flex items-center gap-2 cursor-pointer hover:bg-[#444] rounded px-1 py-0.5 transition-colors"
onClick={() => toggleFunctionExpansion(message.id)}
>
{typeof messageContent === "object" &&
"title" in messageContent && (
<>
<div className="flex-1 font-semibold">
{(messageContent as FunctionMessageContent).title}
</div>
<div className="flex items-center justify-center w-5 h-5 text-emerald-400">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</>
)}
{isTaskExecutor && (
<>
<div className="flex-1 font-semibold">
Task Execution
</div>
<div className="flex items-center justify-center w-5 h-5 text-emerald-400">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</>
)}
</div>
{isExpanded && (
<div className="mt-2 pt-2 border-t border-[#555]">
{typeof messageContent === "object" &&
"content" in messageContent && (
<div className="max-w-full overflow-x-auto">
<pre className="whitespace-pre-wrap text-xs max-w-full" style={{
wordWrap: "break-word",
maxWidth: "100%",
wordBreak: "break-all"
}}>
{(messageContent as FunctionMessageContent).content}
</pre>
</div>
)}
</div>
)}
</div>
) : (
<div className="markdown-content break-words max-w-full overflow-x-auto">
{typeof messageContent === "object" &&
"author" in messageContent &&
messageContent.author !== "user" &&
!isTaskExecutor && (
<div className="text-xs text-neutral-400 mb-1">
{messageContent.author}
</div>
)}
{((typeof messageContent === "string" &&
containsMarkdown(messageContent)) ||
(typeof messageContent === "object" &&
"content" in messageContent &&
typeof messageContent.content === "string" &&
containsMarkdown(messageContent.content))) &&
!isTaskExecutor ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ ...props }) => (
<h1 className="text-xl font-bold my-4" {...props} />
),
h2: ({ ...props }) => (
<h2 className="text-lg font-bold my-3" {...props} />
),
h3: ({ ...props }) => (
<h3 className="text-base font-bold my-2" {...props} />
),
h4: ({ ...props }) => (
<h4 className="font-semibold my-2" {...props} />
),
p: ({ ...props }) => <p className="mb-3" {...props} />,
ul: ({ ...props }) => (
<ul
className="list-disc pl-6 mb-3 space-y-1"
{...props}
/>
),
ol: ({ ...props }) => (
<ol
className="list-decimal pl-6 mb-3 space-y-1"
{...props}
/>
),
li: ({ ...props }) => <li className="mb-1" {...props} />,
a: ({ ...props }) => (
<a
className="text-emerald-400 underline hover:opacity-80 transition-opacity"
target="_blank"
rel="noopener noreferrer"
{...props}
/>
),
blockquote: ({ ...props }) => (
<blockquote
className="border-l-4 border-[#444] pl-4 py-1 italic my-3 text-neutral-300"
{...props}
/>
),
code: ({ className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || "");
const isInline = !match && typeof children === "string" && !children.includes("\n");
if (isInline) {
return (
<code
className="bg-[#333] px-1.5 py-0.5 rounded text-emerald-400 text-sm font-mono"
{...props}
>
{children}
</code>
);
}
return (
<div className="my-3 relative">
<div className="bg-[#1a1a1a] rounded-t-md border-b border-[#333] p-2 text-xs text-neutral-400">
<span>{match?.[1] || "Code"}</span>
</div>
<pre className="bg-[#1a1a1a] p-3 rounded-b-md overflow-x-auto whitespace-pre text-sm">
<code {...props}>{children}</code>
</pre>
</div>
);
},
table: ({ ...props }) => (
<div className="overflow-x-auto my-3">
<table
className="min-w-full border border-[#333] rounded"
{...props}
/>
</div>
),
thead: ({ ...props }) => (
<thead className="bg-[#1a1a1a]" {...props} />
),
tbody: ({ ...props }) => <tbody {...props} />,
tr: ({ ...props }) => (
<tr
className="border-b border-[#333] last:border-0"
{...props}
/>
),
th: ({ ...props }) => (
<th
className="px-4 py-2 text-left text-xs font-semibold text-neutral-300"
{...props}
/>
),
td: ({ ...props }) => (
<td className="px-4 py-2 text-sm" {...props} />
),
}}
>
{typeof messageContent === "string"
? messageContent
: messageContent.content}
</ReactMarkdown>
) : (
<div className="whitespace-pre-wrap">
{typeof messageContent === "string"
? messageContent
: messageContent.content}
</div>
)}
{hasInlineData && (
<InlineDataAttachments parts={inlineDataParts} />
)}
</div>
)}
</div>
</div>
</div>
);
})}
{/* Empty div at the end for auto-scrolling */}
<div ref={messagesEndRef} />
</div>
);
}

View File

@@ -0,0 +1,631 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/workflows/nodes/components/agent/AgentForm.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
/* eslint-disable jsx-a11y/alt-text */
import { useEdges, useNodes } from "@xyflow/react";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { Agent } from "@/types/agent";
import { listAgents, listFolders, Folder, getAgent } from "@/services/agentService";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { User, Loader2, Search, FolderIcon, Trash2, Play, MessageSquare, PlayIcon, Plus, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { AgentForm as GlobalAgentForm } from "@/app/agents/forms/AgentForm";
import { ApiKey, listApiKeys } from "@/services/agentService";
import { listMCPServers } from "@/services/mcpServerService";
import { availableModels } from "@/types/aiModels";
import { MCPServer } from "@/types/mcpServer";
import { AgentTestChatModal } from "./AgentTestChatModal";
import { sanitizeAgentName, escapePromptBraces } from "@/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
/* eslint-disable @typescript-eslint/no-explicit-any */
const user = typeof window !== "undefined" ? JSON.parse(localStorage.getItem("user") || '{}') : {};
const clientId: string = user?.client_id ? String(user.client_id) : "";
const agentListStyles = {
scrollbarWidth: 'none', /* Firefox */
msOverflowStyle: 'none', /* IE and Edge */
'::-webkit-scrollbar': {
display: 'none' /* Chrome, Safari and Opera */
}
};
export function AgentForm({ selectedNode, handleUpdateNode, setEdges, setIsOpen, setSelectedNode }: {
selectedNode: any;
handleUpdateNode: any;
setEdges: any;
setIsOpen: any;
setSelectedNode: any;
}) {
const [node, setNode] = useState(selectedNode);
const [loading, setLoading] = useState(true);
const [loadingFolders, setLoadingFolders] = useState(true);
const [loadingCurrentAgent, setLoadingCurrentAgent] = useState(false);
const [agents, setAgents] = useState<Agent[]>([]);
const [allAgents, setAllAgents] = useState<Agent[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [folders, setFolders] = useState<Folder[]>([]);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const [selectedAgentType, setSelectedAgentType] = useState<string | null>(null);
const [agentTypes, setAgentTypes] = useState<string[]>([]);
const [agentFolderId, setAgentFolderId] = useState<string | null>(null);
const edges = useEdges();
const nodes = useNodes();
const [isAgentDialogOpen, setIsAgentDialogOpen] = useState(false);
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [availableMCPs, setAvailableMCPs] = useState<MCPServer[]>([]);
const [newAgent, setNewAgent] = useState<Partial<Agent>>({
client_id: clientId || "",
name: "",
description: "",
type: "llm",
model: "openai/gpt-4.1-nano",
instruction: "",
api_key_id: "",
config: {
tools: [],
mcp_servers: [],
custom_mcp_servers: [],
custom_tools: { http_tools: [] },
sub_agents: [],
agent_tools: [],
},
});
const [isLoading, setIsLoading] = useState(false);
const [isTestModalOpen, setIsTestModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
// Access the canvas reference from localStorage
const canvasRef = useRef<any>(null);
useEffect(() => {
// When the component is mounted, check if there is a canvas reference in the global context
if (typeof window !== "undefined") {
const workflowsPage = document.querySelector('[data-workflow-page="true"]');
if (workflowsPage) {
// If we are on the workflows page, try to access the canvas ref
const canvasElement = workflowsPage.querySelector('[data-canvas-ref="true"]');
if (canvasElement && (canvasElement as any).__reactRef) {
canvasRef.current = (canvasElement as any).__reactRef.current;
}
}
}
}, []);
const connectedNode = useMemo(() => {
const edge = edges.find((edge: any) => edge.source === selectedNode.id);
if (!edge) return null;
const node = nodes.find((node: any) => node.id === edge.target);
return node || null;
}, [edges, nodes, selectedNode.id]);
const currentAgent = typeof window !== "undefined" ?
JSON.parse(localStorage.getItem("current_workflow_agent") || '{}') : {};
const currentAgentId = currentAgent?.id;
useEffect(() => {
if (selectedNode) {
setNode(selectedNode);
}
}, [selectedNode]);
useEffect(() => {
if (!clientId) return;
setLoadingFolders(true);
listFolders(clientId)
.then((res) => {
setFolders(res.data);
})
.catch((error) => console.error("Error loading folders:", error))
.finally(() => setLoadingFolders(false));
}, [clientId]);
useEffect(() => {
if (!currentAgentId || !clientId) {
return;
}
setLoadingCurrentAgent(true);
getAgent(currentAgentId, clientId)
.then((res) => {
const agent = res.data;
if (agent.folder_id) {
setAgentFolderId(agent.folder_id);
setSelectedFolderId(agent.folder_id);
}
})
.catch((error) => console.error("Error loading current agent:", error))
.finally(() => setLoadingCurrentAgent(false));
}, [currentAgentId, clientId]);
useEffect(() => {
if (!clientId) return;
if (loadingFolders || loadingCurrentAgent) return;
setLoading(true);
listAgents(clientId, 0, 100, selectedFolderId || undefined)
.then((res) => {
const filteredAgents = res.data.filter((agent: Agent) => agent.id !== currentAgentId);
setAllAgents(filteredAgents);
setAgents(filteredAgents);
// Extract unique agent types
const types = [...new Set(filteredAgents.map(agent => agent.type))].filter(Boolean);
setAgentTypes(types);
})
.catch((error) => console.error("Error loading agents:", error))
.finally(() => setLoading(false));
}, [clientId, currentAgentId, selectedFolderId, loadingFolders, loadingCurrentAgent]);
useEffect(() => {
// Apply all filters: search, folder, and type
let filtered = allAgents;
// Search filter is applied in a separate effect
if (searchQuery.trim() !== "") {
filtered = filtered.filter((agent) =>
agent.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
agent.description?.toLowerCase().includes(searchQuery.toLowerCase())
);
}
// Apply agent type filter
if (selectedAgentType) {
filtered = filtered.filter(agent => agent.type === selectedAgentType);
}
setAgents(filtered);
}, [searchQuery, selectedAgentType, allAgents]);
useEffect(() => {
if (!clientId) return;
listApiKeys(clientId).then((res) => setApiKeys(res.data));
listMCPServers().then((res) => setAvailableMCPs(res.data));
}, [clientId]);
const handleDeleteEdge = useCallback(() => {
const id = edges.find((edge: any) => edge.source === selectedNode.id)?.id;
setEdges((edges: any) => {
const left = edges.filter((edge: any) => edge.id !== id);
return left;
});
}, [nodes, edges, selectedNode, setEdges]);
const handleSelectAgent = (agent: Agent) => {
const updatedNode = {
...node,
data: {
...node.data,
agent,
},
};
setNode(updatedNode);
handleUpdateNode(updatedNode);
};
const getAgentTypeName = (type: string) => {
const agentTypes: Record<string, string> = {
llm: "LLM Agent",
a2a: "A2A Agent",
sequential: "Sequential Agent",
parallel: "Parallel Agent",
loop: "Loop Agent",
workflow: "Workflow Agent",
task: "Task Agent",
};
return agentTypes[type] || type;
};
const handleOpenAgentDialog = () => {
setNewAgent({
client_id: clientId || "",
name: "",
description: "",
type: "llm",
model: "openai/gpt-4.1-nano",
instruction: "",
api_key_id: "",
config: {
tools: [],
mcp_servers: [],
custom_mcp_servers: [],
custom_tools: { http_tools: [] },
sub_agents: [],
agent_tools: [],
},
folder_id: selectedFolderId || undefined,
});
setIsAgentDialogOpen(true);
};
const handleSaveAgent = async (agentData: Partial<Agent>) => {
setIsLoading(true);
try {
const sanitizedData = {
...agentData,
client_id: clientId,
name: agentData.name ? sanitizeAgentName(agentData.name) : agentData.name,
instruction: agentData.instruction ? escapePromptBraces(agentData.instruction) : agentData.instruction
};
if (isEditMode && node.data.agent?.id) {
// Update existing agent
const { updateAgent } = await import("@/services/agentService");
const updated = await updateAgent(node.data.agent.id, sanitizedData as any);
// Refresh the agent list
const res = await listAgents(clientId, 0, 100, selectedFolderId || undefined);
const filteredAgents = res.data.filter((agent: Agent) => agent.id !== currentAgentId);
setAllAgents(filteredAgents);
setAgents(filteredAgents);
if (updated.data) {
handleSelectAgent(updated.data);
}
} else {
// Create new agent
const { createAgent } = await import("@/services/agentService");
const created = await createAgent(sanitizedData as any);
const res = await listAgents(clientId, 0, 100, selectedFolderId || undefined);
const filteredAgents = res.data.filter((agent: Agent) => agent.id !== currentAgentId);
setAllAgents(filteredAgents);
setAgents(filteredAgents);
if (created.data) {
handleSelectAgent(created.data);
}
}
setIsAgentDialogOpen(false);
setIsEditMode(false);
} catch (e) {
console.error("Error saving agent:", e);
setIsAgentDialogOpen(false);
setIsEditMode(false);
} finally {
setIsLoading(false);
}
};
const handleFolderChange = (value: string) => {
setSelectedFolderId(value === "all" ? null : value);
};
const handleAgentTypeChange = (value: string) => {
setSelectedAgentType(value === "all" ? null : value);
};
const getFolderNameById = (id: string) => {
const folder = folders.find((f) => f.id === id);
return folder?.name || id;
};
const handleEditAgent = () => {
if (!node.data.agent) return;
setNewAgent({
...node.data.agent,
client_id: clientId || "",
});
setIsEditMode(true);
setIsAgentDialogOpen(true);
};
return (
<>
{isAgentDialogOpen && (
<GlobalAgentForm
open={isAgentDialogOpen}
onOpenChange={(open) => {
setIsAgentDialogOpen(open);
if (!open) setIsEditMode(false);
}}
initialValues={newAgent}
apiKeys={apiKeys}
availableModels={availableModels}
availableMCPs={availableMCPs}
agents={allAgents}
onOpenApiKeysDialog={() => {}}
onOpenMCPDialog={() => {}}
onOpenCustomMCPDialog={() => {}}
onSave={handleSaveAgent}
isLoading={isLoading}
getAgentNameById={(id) => allAgents.find((a) => a.id === id)?.name || id}
clientId={clientId}
/>
)}
{/* Agent Test Chat Modal - moved outside of nested divs to render properly */}
{isTestModalOpen && node.data.agent && (
<AgentTestChatModal
open={isTestModalOpen}
onOpenChange={setIsTestModalOpen}
agent={node.data.agent}
/>
)}
<div className="flex flex-col h-full">
<div className="p-4 border-b border-neutral-700 flex-shrink-0">
<div className="mb-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-neutral-500" />
<Input
placeholder="Search agents..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 bg-neutral-800 border-neutral-700 text-neutral-200 focus-visible:ring-emerald-500"
/>
</div>
</div>
<div className="flex gap-2">
<div className="flex-1">
<Select
value={selectedFolderId ? selectedFolderId : "all"}
onValueChange={handleFolderChange}
>
<SelectTrigger className="w-full h-9 bg-neutral-800 border-neutral-700 text-neutral-200 focus:ring-emerald-500 focus:ring-offset-0">
<SelectValue placeholder="All folders" />
</SelectTrigger>
<SelectContent className="bg-neutral-800 border-neutral-700 text-neutral-200">
<SelectItem value="all">All folders</SelectItem>
{folders.map((folder) => (
<SelectItem key={folder.id} value={folder.id}>
{folder.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1">
<Select
value={selectedAgentType ? selectedAgentType : "all"}
onValueChange={handleAgentTypeChange}
>
<SelectTrigger className="w-full h-9 bg-neutral-800 border-neutral-700 text-neutral-200 focus:ring-emerald-500 focus:ring-offset-0">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent className="bg-neutral-800 border-neutral-700 text-neutral-200">
<SelectItem value="all">All types</SelectItem>
{agentTypes.map((type) => (
<SelectItem key={type} value={type}>
{getAgentTypeName(type)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="flex flex-col flex-1 min-h-0">
<div className="px-4 pt-4 pb-2 flex items-center justify-between flex-shrink-0">
<h3 className="text-md font-medium text-neutral-200">
{searchQuery ? "Search Results" : "Select an Agent"}
</h3>
<Button
variant="outline"
size="icon"
className="h-8 w-8 bg-emerald-800 hover:bg-emerald-700 border-emerald-700 text-emerald-100"
onClick={() => {
setNewAgent({
id: "",
name: "",
client_id: clientId || "",
type: "llm",
model: "",
config: {},
description: "",
});
setIsEditMode(false);
setIsAgentDialogOpen(true);
}}
aria-label="New Agent"
>
<Plus size={14} />
</Button>
</div>
<div className="flex-1 overflow-y-auto px-4 scrollbar-hide">
<div className="space-y-2 pr-2">
{agents.length > 0 ? (
agents.map((agent) => (
<div
key={agent.id}
className={`p-3 rounded-md cursor-pointer transition-colors group relative ${
node.data.agent?.id === agent.id
? "bg-emerald-800/20 border border-emerald-600/40"
: "bg-neutral-800 hover:bg-neutral-700 border border-transparent"
}`}
onClick={() => handleSelectAgent(agent)}
>
<div className="flex items-start gap-2">
<div className="bg-neutral-700 rounded-full p-1.5 flex-shrink-0">
<User size={18} className="text-neutral-300" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h3 className="font-medium text-neutral-200 truncate">{agent.name}</h3>
<div
className="ml-auto text-neutral-400 opacity-0 group-hover:opacity-100 hover:text-yellow-500 transition-colors p-1 rounded hover:bg-yellow-900/20"
onClick={(e) => {
e.stopPropagation();
setNewAgent({
...agent,
client_id: clientId || "",
});
setIsEditMode(true);
setIsAgentDialogOpen(true);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
<path d="m15 5 4 4" />
</svg>
</div>
</div>
<div className="flex items-center gap-2 mt-1">
<Badge
variant="outline"
className="text-xs bg-neutral-700 text-emerald-400 border-neutral-600"
>
{getAgentTypeName(agent.type)}
</Badge>
{agent.model && (
<span className="text-xs text-neutral-400">{agent.model}</span>
)}
</div>
{agent.description && (
<p className="text-sm text-neutral-400 mt-1.5 line-clamp-2">
{agent.description.slice(0, 30)} {agent.description.length > 30 ? "..." : ""}
</p>
)}
</div>
</div>
</div>
))
) : (
<div className="text-center py-4 text-neutral-400">
No agents found
</div>
)}
</div>
</div>
</div>
{node.data.agent && (
<div className="p-4 border-t border-neutral-700 flex-shrink-0">
<div className="flex items-center justify-between mb-2">
<h3 className="text-md font-medium text-neutral-200">Selected Agent</h3>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 bg-neutral-700 hover:bg-neutral-600 border-neutral-600 text-neutral-200"
onClick={() => {
handleUpdateNode({
...node,
data: {
...node.data,
agent: null,
},
});
}}
aria-label="Clear agent"
>
<X size={14} />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8 bg-neutral-700 hover:bg-neutral-600 border-neutral-600 text-neutral-200"
onClick={handleEditAgent}
aria-label="Edit agent"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
<path d="m15 5 4 4" />
</svg>
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8 bg-emerald-800 hover:bg-emerald-700 border-emerald-700 text-emerald-100"
onClick={() => setIsTestModalOpen(true)}
aria-label="Test agent"
>
<PlayIcon size={14} />
</Button>
</div>
</div>
<div className="p-3 rounded-md bg-emerald-800/20 border border-emerald-600/40">
<div className="flex items-start gap-2">
<div className="bg-emerald-900/50 rounded-full p-1.5 flex-shrink-0">
<User size={18} className="text-emerald-300" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-neutral-200 truncate">{node.data.agent.name}</h3>
<div className="flex items-center gap-2 mt-1">
<Badge
variant="outline"
className="text-xs bg-emerald-900/50 text-emerald-400 border-emerald-700/50"
>
{getAgentTypeName(node.data.agent.type)}
</Badge>
{node.data.agent.model && (
<span className="text-xs text-neutral-400">{node.data.agent.model}</span>
)}
</div>
{node.data.agent.description && (
<p className="text-sm text-neutral-400 mt-1.5 line-clamp-2">
{node.data.agent.description.slice(0, 30)} {node.data.agent.description.length > 30 ? "..." : ""}
</p>
)}
</div>
</div>
</div>
</div>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,176 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/workflows/nodes/components/agent/AgentNode.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
/* eslint-disable no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Handle, NodeProps, Position, useEdges } from "@xyflow/react";
import { MessageCircle, User, Code, ExternalLink, Workflow, GitBranch, RefreshCw, BookOpenCheck, ArrowRight } from "lucide-react";
import { Agent } from "@/types/agent";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { BaseNode } from "../../BaseNode";
export function AgentNode(props: NodeProps) {
const { selected, data } = props;
const edges = useEdges();
const isHandleConnected = (handleId: string) => {
return edges.some(
(edge) => edge.source === props.id && edge.sourceHandle === handleId
);
};
const isBottomHandleConnected = isHandleConnected("bottom-handle");
const agent = data.agent as Agent | undefined;
const isExecuting = data.isExecuting as boolean | undefined;
const getAgentTypeName = (type: string) => {
const agentTypes: Record<string, string> = {
llm: "LLM Agent",
a2a: "A2A Agent",
sequential: "Sequential Agent",
parallel: "Parallel Agent",
loop: "Loop Agent",
workflow: "Workflow Agent",
task: "Task Agent",
};
return agentTypes[type] || type;
};
const getAgentTypeIcon = (type: string) => {
switch (type) {
case "llm":
return <Code className="h-4 w-4 text-green-400" />;
case "a2a":
return <ExternalLink className="h-4 w-4 text-indigo-400" />;
case "sequential":
return <Workflow className="h-4 w-4 text-yellow-400" />;
case "parallel":
return <GitBranch className="h-4 w-4 text-purple-400" />;
case "loop":
return <RefreshCw className="h-4 w-4 text-orange-400" />;
case "workflow":
return <Workflow className="h-4 w-4 text-blue-400" />;
case "task":
return <BookOpenCheck className="h-4 w-4 text-red-400" />;
default:
return <User className="h-4 w-4 text-neutral-400" />;
}
};
const getModelBadgeColor = (model: string) => {
if (model?.includes('gpt-4')) return 'bg-green-900/30 text-green-400 border-green-600/30';
if (model?.includes('gpt-3')) return 'bg-yellow-900/30 text-yellow-400 border-yellow-600/30';
if (model?.includes('claude')) return 'bg-orange-900/30 text-orange-400 border-orange-600/30';
if (model?.includes('gemini')) return 'bg-blue-900/30 text-blue-400 border-blue-600/30';
return 'bg-neutral-800 text-neutral-400 border-neutral-600/50';
};
return (
<BaseNode hasTarget={true} selected={selected || false} borderColor="blue" isExecuting={isExecuting}>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-900/40 shadow-sm">
<User className="h-5 w-5 text-blue-400" />
</div>
<div>
<p className="text-lg font-medium text-blue-400">
{data.label as string}
</p>
</div>
</div>
</div>
{agent ? (
<div className="mb-3 rounded-lg border border-blue-700/40 bg-blue-950/10 p-3 transition-all duration-200 hover:border-blue-600/50 hover:bg-blue-900/10">
<div className="flex flex-col">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center">
{getAgentTypeIcon(agent.type)}
<span className="ml-1.5 font-medium text-white">{agent.name}</span>
</div>
<Badge
variant="outline"
className="px-1.5 py-0 text-xs bg-blue-900/30 text-blue-400 border-blue-700/40"
>
{getAgentTypeName(agent.type)}
</Badge>
</div>
{agent.model && (
<div className="mt-2 flex items-center">
<Badge
variant="outline"
className={cn("px-1.5 py-0 text-xs", getModelBadgeColor(agent.model))}
>
{agent.model}
</Badge>
</div>
)}
{agent.description && (
<p className="mt-2 text-xs text-neutral-400 line-clamp-2">
{agent.description.slice(0, 30)} {agent.description.length > 30 && '...'}
</p>
)}
</div>
</div>
) : (
<div className="mb-3 flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-blue-700/40 bg-blue-950/10 p-5 text-center transition-all duration-200 hover:border-blue-600/50 hover:bg-blue-900/20">
<User className="h-8 w-8 text-blue-700/50 mb-2" />
<p className="text-blue-400">Select an agent</p>
<p className="mt-1 text-xs text-neutral-500">Click to configure</p>
</div>
)}
<div className="mt-2 flex items-center justify-end text-sm text-neutral-400 transition-colors">
<div className="flex items-center space-x-1 rounded-md py-1 px-2">
<span>Next step</span>
<ArrowRight className="h-3.5 w-3.5" />
</div>
<Handle
className={cn(
"!w-3 !h-3 !rounded-full transition-all duration-300",
isBottomHandleConnected ? "!bg-blue-500 !border-blue-400" : "!bg-neutral-400 !border-neutral-500",
selected && isBottomHandleConnected && "!bg-blue-400 !border-blue-300"
)}
style={{
right: "-8px",
top: "calc(100% - 25px)",
}}
type="source"
position={Position.Right}
id="bottom-handle"
/>
</div>
</BaseNode>
);
}

View File

@@ -0,0 +1,470 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/workflows/nodes/components/agent/AgentTestChatModal.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
import { useState, useCallback, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useAgentWebSocket } from "@/hooks/use-agent-webSocket";
import { getAccessTokenFromCookie, cn } from "@/lib/utils";
import { Agent } from "@/types/agent";
import { ChatInput } from "@/app/chat/components/ChatInput";
import { ChatMessage as ChatMessageComponent } from "@/app/chat/components/ChatMessage";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ChatPart } from "@/services/sessionService";
import { FileData } from "@/lib/file-utils";
import { X, User, Bot, Zap, MessageSquare, Loader2, Code, ExternalLink, Workflow, RefreshCw } from "lucide-react";
interface FunctionMessageContent {
title: string;
content: string;
author?: string;
}
interface ChatMessage {
id: string;
content: any;
author: string;
timestamp: number;
}
interface AgentTestChatModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
agent: Agent;
canvasRef?: React.RefObject<any>;
}
export function AgentTestChatModal({ open, onOpenChange, agent, canvasRef }: AgentTestChatModalProps) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isSending, setIsSending] = useState(false);
const [expandedFunctions, setExpandedFunctions] = useState<Record<string, boolean>>({});
const [isInitializing, setIsInitializing] = useState(true);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const user = typeof window !== "undefined" ? JSON.parse(localStorage.getItem("user") || "{}") : {};
const clientId = user?.client_id || "test";
const generateExternalId = () => {
const now = new Date();
return (
now.getFullYear().toString() +
(now.getMonth() + 1).toString().padStart(2, "0") +
now.getDate().toString().padStart(2, "0") +
now.getHours().toString().padStart(2, "0") +
now.getMinutes().toString().padStart(2, "0") +
now.getSeconds().toString().padStart(2, "0") +
now.getMilliseconds().toString().padStart(3, "0")
);
};
const [externalId, setExternalId] = useState(generateExternalId());
const jwt = getAccessTokenFromCookie();
const onEvent = useCallback((event: any) => {
setMessages((prev) => [...prev, event]);
// Check if the message comes from a workflow node and highlight the node
// only if the canvasRef is available (called from Test Workflow on the main page)
if (event.author && event.author.startsWith('workflow-node:') && canvasRef?.current) {
const nodeId = event.author.split(':')[1];
canvasRef.current.setActiveExecutionNodeId(nodeId);
}
}, [canvasRef]);
const onTurnComplete = useCallback(() => {
setIsSending(false);
}, []);
const { sendMessage: wsSendMessage, disconnect } = useAgentWebSocket({
agentId: agent.id,
externalId,
jwt,
onEvent,
onTurnComplete,
});
// Handle ESC key to close the panel
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
onOpenChange(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onOpenChange, open]);
// Show initialization state for better UX
useEffect(() => {
if (open) {
setIsInitializing(true);
const timer = setTimeout(() => {
setIsInitializing(false);
}, 1200);
return () => clearTimeout(timer);
}
}, [open, externalId]);
const handleRestartChat = () => {
if (disconnect) disconnect();
setMessages([]);
setExpandedFunctions({});
setExternalId(generateExternalId());
setIsInitializing(true);
// Short delay to show the initialization status
const timer = setTimeout(() => {
setIsInitializing(false);
}, 1200);
};
const handleSendMessageWithFiles = (message: string, files?: FileData[]) => {
if ((!message.trim() && (!files || files.length === 0))) return;
setIsSending(true);
const messageParts: ChatPart[] = [];
if (message.trim()) {
messageParts.push({ text: message });
}
if (files && files.length > 0) {
files.forEach(file => {
messageParts.push({
inline_data: {
data: file.data,
mime_type: file.content_type,
metadata: {
filename: file.filename
}
}
});
});
}
setMessages((prev) => [
...prev,
{
id: `temp-${Date.now()}`,
content: {
parts: messageParts,
role: "user"
},
author: "user",
timestamp: Date.now() / 1000,
},
]);
wsSendMessage(message, files);
};
const containsMarkdown = (text: string): boolean => {
if (!text || text.length < 3) return false;
const markdownPatterns = [
/[*_]{1,2}[^*_]+[*_]{1,2}/, // bold/italic
/\[[^\]]+\]\([^)]+\)/, // links
/^#{1,6}\s/m, // headers
/^[-*+]\s/m, // unordered lists
/^[0-9]+\.\s/m, // ordered lists
/^>\s/m, // block quotes
/`[^`]+`/, // inline code
/```[\s\S]*?```/, // code blocks
/^\|(.+\|)+$/m, // tables
/!\[[^\]]*\]\([^)]+\)/, // images
];
return markdownPatterns.some((pattern) => pattern.test(text));
};
const getMessageText = (message: ChatMessage): string | FunctionMessageContent => {
const author = message.author;
const parts = message.content.parts;
if (!parts || parts.length === 0) return "Empty content";
const functionCallPart = parts.find((part: any) => part.functionCall || part.function_call);
const functionResponsePart = parts.find((part: any) => part.functionResponse || part.function_response);
const inlineDataParts = parts.filter((part: any) => part.inline_data);
if (functionCallPart) {
const funcCall = functionCallPart.functionCall || functionCallPart.function_call || {};
const args = funcCall.args || {};
const name = funcCall.name || "unknown";
const id = funcCall.id || "no-id";
return {
author,
title: `📞 Function call: ${name}`,
content: `ID: ${id}\nArgs: ${Object.keys(args).length > 0 ? `\n${JSON.stringify(args, null, 2)}` : "{}"}`,
} as FunctionMessageContent;
}
if (functionResponsePart) {
const funcResponse = functionResponsePart.functionResponse || functionResponsePart.function_response || {};
const response = funcResponse.response || {};
const name = funcResponse.name || "unknown";
const id = funcResponse.id || "no-id";
const status = response.status || "unknown";
const statusEmoji = status === "error" ? "❌" : "✅";
let resultText = "";
if (status === "error") {
resultText = `Error: ${response.error_message || "Unknown error"}`;
} else if (response.report) {
resultText = `Result: ${response.report}`;
} else if (response.result && response.result.content) {
const content = response.result.content;
if (Array.isArray(content) && content.length > 0 && content[0].text) {
try {
const textContent = content[0].text;
const parsedJson = JSON.parse(textContent);
resultText = `Result: \n${JSON.stringify(parsedJson, null, 2)}`;
} catch (e) {
resultText = `Result: ${content[0].text}`;
}
} else {
resultText = `Result:\n${JSON.stringify(response, null, 2)}`;
}
} else {
resultText = `Result:\n${JSON.stringify(response, null, 2)}`;
}
return {
author,
title: `${statusEmoji} Function response: ${name}`,
content: `ID: ${id}\n${resultText}`,
} as FunctionMessageContent;
}
if (parts.length === 1 && parts[0].text) {
return {
author,
content: parts[0].text,
title: "Message",
} as FunctionMessageContent;
}
const textParts = parts.filter((part: any) => part.text).map((part: any) => part.text).filter((text: string) => text);
if (textParts.length > 0) {
return {
author,
content: textParts.join("\n\n"),
title: "Message",
} as FunctionMessageContent;
}
try {
return JSON.stringify(parts, null, 2).replace(/\\n/g, "\n");
} catch (error) {
return "Unable to interpret message content";
}
};
const toggleFunctionExpansion = (messageId: string) => {
setExpandedFunctions((prev) => ({ ...prev, [messageId]: !prev[messageId] }));
};
const getAgentTypeIcon = (type: string) => {
switch (type) {
case "llm":
return <Code className="h-4 w-4 text-green-400" />;
case "a2a":
return <ExternalLink className="h-4 w-4 text-indigo-400" />;
case "sequential":
case "workflow":
return <Workflow className="h-4 w-4 text-blue-400" />;
default:
return <Bot className="h-4 w-4 text-emerald-400" />;
}
};
// Scroll to bottom whenever messages change
useEffect(() => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
}
}, [messages]);
if (!open) return null;
// Use React Portal to render directly to document body, bypassing all parent containers
const modalContent = (
<>
{/* Overlay for mobile */}
<div
className="md:hidden fixed inset-0 bg-black bg-opacity-50 z-[15] transition-opacity duration-300"
onClick={() => onOpenChange(false)}
/>
{/* Side panel */}
<div
className="fixed right-0 top-0 z-[1000] h-full w-[450px] bg-gradient-to-b from-neutral-900 to-neutral-950 border-l border-neutral-800 shadow-2xl flex flex-col transition-all duration-300 ease-in-out transform"
style={{
transform: open ? 'translateX(0)' : 'translateX(100%)',
boxShadow: '0 0 25px rgba(0, 0, 0, 0.3)',
}}
>
{/* Header */}
<div className="flex-shrink-0 p-5 bg-gradient-to-r from-neutral-900 to-neutral-800 border-b border-neutral-800">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center mb-1">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-emerald-600 to-emerald-900 flex items-center justify-center shadow-lg mr-3">
{getAgentTypeIcon(agent.type)}
</div>
<div>
<h2 className="text-lg font-semibold text-white">{agent.name}</h2>
<div className="flex items-center gap-2 mt-1">
<Badge
className="bg-emerald-900/40 text-emerald-400 border border-emerald-700/50 px-2"
>
{agent.type.toUpperCase()} Agent
</Badge>
{agent.model && (
<span className="text-xs text-neutral-400 bg-neutral-800/60 px-2 py-0.5 rounded-md">
{agent.model}
</span>
)}
</div>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={handleRestartChat}
className="p-1.5 rounded-full hover:bg-neutral-700/50 text-neutral-400 hover:text-white transition-colors group relative"
title="Restart chat"
disabled={isInitializing}
>
<RefreshCw size={18} className={isInitializing ? "animate-spin text-emerald-400" : ""} />
<span className="absolute -bottom-8 right-0 bg-neutral-800 text-neutral-200 text-xs rounded py-1 px-2 opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap">
Restart chat
</span>
</button>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 rounded-full hover:bg-neutral-700/50 text-neutral-400 hover:text-white transition-colors group relative"
>
<X size={18} />
<span className="absolute -bottom-8 right-0 bg-neutral-800 text-neutral-200 text-xs rounded py-1 px-2 opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap">
Close
</span>
</button>
</div>
</div>
{agent.description && (
<div className="mt-3 text-sm text-neutral-400 bg-neutral-800/30 p-3 rounded-md border border-neutral-800">
{agent.description}
</div>
)}
</div>
{/* Chat content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden p-3 bg-gradient-to-b from-neutral-900/50 to-neutral-950" ref={messagesContainerRef}>
{isInitializing ? (
<div className="flex flex-col items-center justify-center h-full">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-emerald-500 to-emerald-700 flex items-center justify-center shadow-lg mb-4 animate-pulse">
<Zap className="h-5 w-5 text-white" />
</div>
<p className="text-neutral-400 mb-2">Initializing connection...</p>
<div className="flex items-center space-x-2">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-bounce"
style={{ animationDelay: '0ms' }}></span>
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-bounce"
style={{ animationDelay: '150ms' }}></span>
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-bounce"
style={{ animationDelay: '300ms' }}></span>
</div>
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center px-6">
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-blue-500/20 to-emerald-500/20 flex items-center justify-center shadow-lg mb-5 border border-emerald-500/30">
<MessageSquare className="h-6 w-6 text-emerald-400" />
</div>
<h3 className="text-lg font-medium text-neutral-300 mb-2">Start the conversation</h3>
<p className="text-neutral-500 text-sm max-w-xs">
Type a message below to begin chatting with {agent.name}
</p>
</div>
) : (
<div className="space-y-4 w-full max-w-full">
{messages.map((message) => {
const messageContent = getMessageText(message);
const agentColor = message.author === "user" ? "bg-emerald-500" : "bg-gradient-to-br from-neutral-800 to-neutral-900";
const isExpanded = expandedFunctions[message.id] || false;
return (
<ChatMessageComponent
key={message.id}
message={message}
agentColor={agentColor}
isExpanded={isExpanded}
toggleExpansion={toggleFunctionExpansion}
containsMarkdown={containsMarkdown}
messageContent={messageContent}
/>
);
})}
{isSending && (
<div className="flex justify-start">
<div className="flex gap-3 max-w-[80%]">
<Avatar
className="bg-gradient-to-br from-purple-600 to-purple-800 shadow-md border-0"
>
<AvatarFallback className="bg-transparent">
<Bot className="h-4 w-4 text-white" />
</AvatarFallback>
</Avatar>
<div className="rounded-lg p-3 bg-gradient-to-br from-neutral-800 to-neutral-900 border border-neutral-700/50">
<div className="flex space-x-2">
<div className="h-2 w-2 rounded-full bg-emerald-400 animate-bounce"></div>
<div className="h-2 w-2 rounded-full bg-emerald-400 animate-bounce [animation-delay:0.2s]"></div>
<div className="h-2 w-2 rounded-full bg-emerald-400 animate-bounce [animation-delay:0.4s]"></div>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
{/* Message input */}
<div className="p-3 border-t border-neutral-800 bg-neutral-900">
<ChatInput
onSendMessage={handleSendMessageWithFiles}
isLoading={isSending}
placeholder="Type your message..."
autoFocus={true}
/>
</div>
</div>
</>
);
// Use createPortal to render the modal directly to the document body
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: null;
}

View File

@@ -0,0 +1,68 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/workflows/nodes/components/agent/styles.css │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
.markdown-content {
max-width: 100%;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
hyphens: auto;
}
.markdown-content pre {
white-space: pre-wrap;
overflow-x: auto;
max-width: 100%;
}
.markdown-content code {
white-space: pre-wrap;
word-break: break-all;
}
.markdown-content p {
max-width: 100%;
overflow-wrap: break-word;
}
.markdown-content table {
display: block;
overflow-x: auto;
max-width: 100%;
border-collapse: collapse;
}
.markdown-content img {
max-width: 100%;
height: auto;
}
.overflow-wrap-anywhere {
overflow-wrap: anywhere;
}

View File

@@ -0,0 +1,283 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/workflows/nodes/components/condition/ConditionDialog.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
/* eslint-disable no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useState } from "react";
import { v4 as uuidv4 } from "uuid";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from "@/components/ui/dialog";
import { ConditionType, ConditionTypeEnum } from "../../nodeFunctions";
import { Button } from "@/components/ui/button";
import { Filter, ArrowRight } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
const conditionTypes = [
{
id: "previous-output",
name: "Previous output",
description: "Validate the result returned by the previous node",
icon: <Filter className="h-5 w-5 text-blue-400" />,
color: "bg-blue-900/30 border-blue-700/50",
},
];
const operators = [
{ value: "is_defined", label: "is defined" },
{ value: "is_not_defined", label: "is not defined" },
{ value: "equals", label: "is equal to" },
{ value: "not_equals", label: "is not equal to" },
{ value: "contains", label: "contains" },
{ value: "not_contains", label: "does not contain" },
{ value: "starts_with", label: "starts with" },
{ value: "ends_with", label: "ends with" },
{ value: "greater_than", label: "is greater than" },
{ value: "greater_than_or_equal", label: "is greater than or equal to" },
{ value: "less_than", label: "is less than" },
{ value: "less_than_or_equal", label: "is less than or equal to" },
{ value: "matches", label: "matches the regex" },
{ value: "not_matches", label: "does not match the regex" },
];
const outputFields = [
{ value: "content", label: "Content" },
{ value: "status", label: "Status" },
];
function ConditionDialog({
open,
onOpenChange,
selectedNode,
handleUpdateNode,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedNode: any;
handleUpdateNode: any;
}) {
const [selectedType, setSelectedType] = useState("previous-output");
const [selectedField, setSelectedField] = useState(outputFields[0].value);
const [selectedOperator, setSelectedOperator] = useState(operators[0].value);
const [comparisonValue, setComparisonValue] = useState("");
const handleConditionSave = (condition: ConditionType) => {
const newConditions = selectedNode.data.conditions
? [...selectedNode.data.conditions]
: [];
newConditions.push(condition);
const updatedNode = {
...selectedNode,
data: {
...selectedNode.data,
conditions: newConditions,
},
};
handleUpdateNode(updatedNode);
onOpenChange(false);
};
const getOperatorLabel = (value: string) => {
return operators.find(op => op.value === value)?.label || value;
};
const getFieldLabel = (value: string) => {
return outputFields.find(field => field.value === value)?.label || value;
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-neutral-800 border-neutral-700 text-neutral-200 sm:max-w-[650px]">
<DialogHeader>
<DialogTitle>Add New Condition</DialogTitle>
</DialogHeader>
<div className="grid gap-6 py-4">
<div className="grid gap-4">
<Label className="text-sm font-medium">Condition Type</Label>
<div className="grid grid-cols-1 gap-2">
{conditionTypes.map((type) => (
<div
key={type.id}
className={`flex items-center space-x-3 rounded-md border p-3 cursor-pointer transition-all ${
selectedType === type.id
? "bg-blue-900/30 border-blue-600"
: "border-neutral-700 hover:border-blue-700/50 hover:bg-neutral-700/50"
}`}
onClick={() => setSelectedType(type.id)}
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-900/40">
{type.icon}
</div>
<div className="flex-1">
<h4 className="font-medium">{type.name}</h4>
<p className="text-xs text-neutral-400">{type.description}</p>
</div>
{selectedType === type.id && (
<Badge className="bg-blue-600 text-neutral-100">Selected</Badge>
)}
</div>
))}
</div>
</div>
<div className="grid gap-4">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Configuration</Label>
{selectedType === "previous-output" && (
<div className="flex items-center gap-2 text-sm text-neutral-400">
<span>Output field</span>
<ArrowRight className="h-3 w-3" />
<span>Operator</span>
{!["is_defined", "is_not_defined"].includes(selectedOperator) && (
<>
<ArrowRight className="h-3 w-3" />
<span>Value</span>
</>
)}
</div>
)}
</div>
{selectedType === "previous-output" && (
<div className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="field">Output Field</Label>
<Select
value={selectedField}
onValueChange={setSelectedField}
>
<SelectTrigger id="field" className="bg-neutral-700 border-neutral-600">
<SelectValue placeholder="Select field" />
</SelectTrigger>
<SelectContent className="bg-neutral-700 border-neutral-600">
{outputFields.map((field) => (
<SelectItem key={field.value} value={field.value}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="operator">Operator</Label>
<Select
value={selectedOperator}
onValueChange={setSelectedOperator}
>
<SelectTrigger id="operator" className="bg-neutral-700 border-neutral-600">
<SelectValue placeholder="Select operator" />
</SelectTrigger>
<SelectContent className="bg-neutral-700 border-neutral-600">
{operators.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!["is_defined", "is_not_defined"].includes(selectedOperator) && (
<div className="grid gap-2">
<Label htmlFor="value">Comparison Value</Label>
<Input
id="value"
value={comparisonValue}
onChange={(e) => setComparisonValue(e.target.value)}
className="bg-neutral-700 border-neutral-600"
/>
</div>
)}
<div className="rounded-md bg-neutral-700/50 border border-neutral-600 p-3 mt-4">
<div className="text-sm font-medium text-neutral-400 mb-1">Preview</div>
<div className="text-sm">
<span className="text-blue-400 font-medium">{getFieldLabel(selectedField)}</span>
{" "}
<span className="text-neutral-300">{getOperatorLabel(selectedOperator)}</span>
{" "}
{!["is_defined", "is_not_defined"].includes(selectedOperator) && (
<span className="text-emerald-400 font-medium">"{comparisonValue || "(empty)"}"</span>
)}
</div>
</div>
</div>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="border-neutral-600 text-neutral-200 hover:bg-neutral-700"
>
Cancel
</Button>
<Button
onClick={() => {
handleConditionSave({
id: uuidv4(),
type: ConditionTypeEnum.PREVIOUS_OUTPUT,
data: {
field: selectedField,
operator: selectedOperator,
value: comparisonValue,
},
});
}}
className="bg-blue-700 hover:bg-blue-600 text-white"
>
Add Condition
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { ConditionDialog };

View File

@@ -0,0 +1,290 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/workflows/nodes/components/condition/ConditionForm.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ConditionType, ConditionTypeEnum } from "../../nodeFunctions";
import { ConditionDialog } from "./ConditionDialog";
import { Button } from "@/components/ui/button";
import { Filter, Trash2, Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
/* eslint-disable @typescript-eslint/no-explicit-any */
function ConditionForm({
selectedNode,
handleUpdateNode,
}: {
selectedNode: any;
handleUpdateNode: any;
setEdges: any;
setIsOpen: any;
setSelectedNode: any;
}) {
const [node, setNode] = useState(selectedNode);
const [conditions, setConditions] = useState<ConditionType[]>(
selectedNode.data.conditions || []
);
const [open, setOpen] = useState(false);
const [deleteDialog, setDeleteDialog] = useState(false);
const [conditionToDelete, setConditionToDelete] =
useState<ConditionType | null>(null);
useEffect(() => {
if (selectedNode) {
setNode(selectedNode);
setConditions(selectedNode.data.conditions || []);
}
}, [selectedNode]);
const handleDelete = (condition: ConditionType) => {
setConditionToDelete(condition);
setDeleteDialog(true);
};
const confirmDelete = () => {
if (!conditionToDelete) return;
const newConditions = conditions.filter(
(c) => c.id !== conditionToDelete.id
);
setConditions(newConditions);
handleUpdateNode({
...node,
data: { ...node.data, conditions: newConditions },
});
setDeleteDialog(false);
setConditionToDelete(null);
};
const renderCondition = (condition: ConditionType) => {
if (condition.type === ConditionTypeEnum.PREVIOUS_OUTPUT) {
type OperatorType =
| "is_defined"
| "is_not_defined"
| "equals"
| "not_equals"
| "contains"
| "not_contains"
| "starts_with"
| "ends_with"
| "greater_than"
| "greater_than_or_equal"
| "less_than"
| "less_than_or_equal"
| "matches"
| "not_matches";
const operatorText: Record<OperatorType, string> = {
is_defined: "is defined",
is_not_defined: "is not defined",
equals: "is equal to",
not_equals: "is not equal to",
contains: "contains",
not_contains: "does not contain",
starts_with: "starts with",
ends_with: "ends with",
greater_than: "is greater than",
greater_than_or_equal: "is greater than or equal to",
less_than: "is less than",
less_than_or_equal: "is less than or equal to",
matches: "matches the regex",
not_matches: "does not match the regex",
};
return (
<div
key={condition.id}
className="p-3 rounded-md cursor-pointer transition-colors bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 mb-2 group"
>
<div className="flex items-start gap-2">
<div className="bg-blue-900/50 rounded-full p-1.5 flex-shrink-0">
<Filter size={18} className="text-blue-300" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h3 className="font-medium text-neutral-200">Condition</h3>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(condition)}
className="h-7 w-7 text-neutral-400 opacity-0 group-hover:opacity-100 hover:text-red-500 hover:bg-red-900/20"
>
<Trash2 size={14} />
</Button>
</div>
<div className="flex items-center gap-2 mt-1">
<Badge
variant="outline"
className="text-xs bg-blue-900/20 text-blue-400 border-blue-700/50"
>
Field
</Badge>
<span className="text-sm text-neutral-300 font-medium">{condition.data.field}</span>
</div>
<div className="flex flex-wrap items-center gap-1 mt-1.5">
<span className="text-sm text-neutral-400">{operatorText[condition.data.operator as OperatorType]}</span>
{!["is_defined", "is_not_defined"].includes(condition.data.operator) && (
<span className="text-sm font-medium text-emerald-400">
"{condition.data.value}"
</span>
)}
</div>
</div>
</div>
</div>
);
}
return null;
};
return (
<div className="flex flex-col h-full">
<div className="p-4 border-b border-neutral-700 flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-md font-medium text-neutral-200">Logic Type</h3>
<Badge
variant="outline"
className="text-xs bg-blue-900/20 text-blue-400 border-blue-700/50"
>
{node.data.type === "or" ? "ANY" : "ALL"}
</Badge>
</div>
<Select
value={node.data.type || "and"}
onValueChange={(value) => {
const updatedNode = {
...node,
data: {
...node.data,
type: value,
},
};
setNode(updatedNode);
handleUpdateNode(updatedNode);
}}
>
<SelectTrigger className="w-[120px] h-8 bg-neutral-800 border-neutral-700 text-neutral-200">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent className="bg-neutral-800 border-neutral-700 text-neutral-200">
<SelectItem value="and">ALL (AND)</SelectItem>
<SelectItem value="or">ANY (OR)</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-sm text-neutral-400 mt-2">
{node.data.type === "or"
? "Any of the following conditions must be true to proceed."
: "All of the following conditions must be true to proceed."}
</p>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4 min-h-0">
<div className="mb-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-md font-medium text-neutral-200">Conditions</h3>
<Button
variant="outline"
size="sm"
className="h-8 bg-blue-800/20 hover:bg-blue-700/30 border-blue-700/50 text-blue-300"
onClick={() => setOpen(true)}
>
<Plus size={14} className="mr-1" />
Add Condition
</Button>
</div>
{conditions.length > 0 ? (
<div className="space-y-2">
{conditions.map((condition) => renderCondition(condition))}
</div>
) : (
<div
onClick={() => setOpen(true)}
className="flex flex-col items-center justify-center p-6 rounded-lg border-2 border-dashed border-neutral-700 hover:border-blue-600/50 hover:bg-neutral-800/50 transition-colors cursor-pointer text-center"
>
<Filter className="h-10 w-10 text-neutral-500 mb-2" />
<p className="text-neutral-400">No conditions yet</p>
<p className="text-sm text-neutral-500 mt-1">Click to add a condition</p>
</div>
)}
</div>
</div>
<ConditionDialog
open={open}
onOpenChange={setOpen}
selectedNode={selectedNode}
handleUpdateNode={handleUpdateNode}
/>
<Dialog open={deleteDialog} onOpenChange={setDeleteDialog}>
<DialogContent className="bg-neutral-800 border-neutral-700 text-neutral-200">
<DialogHeader>
<DialogTitle>Confirm Delete</DialogTitle>
</DialogHeader>
<div className="py-4">
<p>Are you sure you want to delete this condition?</p>
</div>
<DialogFooter>
<Button
variant="outline"
className="border-neutral-600 text-neutral-300 hover:bg-neutral-700"
onClick={() => {
setDeleteDialog(false);
setConditionToDelete(null);
}}
>
Cancel
</Button>
<Button
variant="destructive"
className="bg-red-900 hover:bg-red-800 text-white"
onClick={confirmDelete}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export { ConditionForm };

View File

@@ -0,0 +1,204 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/workflows/nodes/components/condition/ConditionNode.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
/* eslint-disable no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Handle, Node, NodeProps, Position, useEdges } from "@xyflow/react";
import { FilterIcon, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { BaseNode } from "../../BaseNode";
import { ConditionType, ConditionTypeEnum } from "../../nodeFunctions";
export type ConditionNodeType = Node<
{
label?: string;
type?: "and" | "or";
conditions?: ConditionType[];
},
"condition-node"
>;
export type OperatorType =
| "is_defined"
| "is_not_defined"
| "equals"
| "not_equals"
| "contains"
| "not_contains"
| "starts_with"
| "ends_with"
| "greater_than"
| "greater_than_or_equal"
| "less_than"
| "less_than_or_equal"
| "matches"
| "not_matches";
const operatorText: Record<OperatorType, string> = {
equals: "is equal to",
not_equals: "is not equal to",
contains: "contains",
not_contains: "does not contain",
starts_with: "starts with",
ends_with: "ends with",
greater_than: "is greater than",
greater_than_or_equal: "is greater than or equal to",
less_than: "is less than",
less_than_or_equal: "is less than or equal to",
matches: "matches the pattern",
not_matches: "does not match the pattern",
is_defined: "is defined",
is_not_defined: "is not defined",
};
export function ConditionNode(props: NodeProps) {
const { selected, data } = props;
const edges = useEdges();
const isExecuting = data.isExecuting as boolean | undefined;
const typeText = {
and: "all of the following conditions",
or: "any of the following conditions",
};
const isHandleConnected = (handleId: string) => {
return edges.some(
(edge) => edge.source === props.id && edge.sourceHandle === handleId,
);
};
const isBottomHandleConnected = isHandleConnected("bottom-handle");
const conditions: ConditionType[] = data.conditions as ConditionType[];
// const statistics: StatisticType = data.statistics as StatisticType;
const renderCondition = (condition: ConditionType) => {
const isConnected = isHandleConnected(condition.id);
if (condition.type === ConditionTypeEnum.PREVIOUS_OUTPUT) {
return (
<div
className="mb-3 cursor-pointer rounded-lg border border-purple-700/40 bg-purple-950/10 p-3 text-left transition-all duration-200 hover:border-purple-600/50 hover:bg-purple-900/10"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="font-medium text-neutral-300">
O campo{" "}
<span className="font-semibold text-purple-400">
{condition.data.field}
</span>{" "}
<span className="text-neutral-400">
{operatorText[condition.data.operator as OperatorType]}
</span>{" "}
{!["is_defined", "is_not_defined"].includes(
condition.data.operator,
) && (
<span className="font-semibold text-green-400">
&quot;{condition.data.value}&quot;
</span>
)}
</p>
</div>
<Handle
className={cn(
"!rounded-full transition-all duration-300",
isConnected ? "!bg-purple-500 !border-purple-400" : "!bg-neutral-400 !border-neutral-500"
)}
style={{
top: "50%",
right: "-5px",
transform: "translateY(-50%)",
height: "14px",
position: "relative",
width: "14px",
}}
type="source"
position={Position.Right}
id={condition.id}
/>
</div>
</div>
);
}
return null;
};
return (
<BaseNode hasTarget={true} selected={selected || false} borderColor="purple" isExecuting={isExecuting}>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-purple-900/40 shadow-sm">
<FilterIcon className="h-5 w-5 text-purple-400" />
</div>
<div>
<p className="text-lg font-medium text-purple-400">
{data.label as string}
</p>
<p className="text-xs text-neutral-400">
Matches {typeText[(data.type as "and" | "or") || "and"]}
</p>
</div>
</div>
</div>
{conditions && conditions.length > 0 && Array.isArray(conditions) ? (
conditions.map((condition) => (
<div key={condition.id}>{renderCondition(condition)}</div>
))
) : (
<div className="mb-3 flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-purple-700/40 bg-purple-950/10 p-5 text-center transition-all duration-200 hover:border-purple-600/50 hover:bg-purple-900/20">
<FilterIcon className="h-8 w-8 text-purple-700/50 mb-2" />
<p className="text-purple-400">No conditions configured</p>
<p className="mt-1 text-xs text-neutral-500">Click to add a condition</p>
</div>
)}
<div className="mt-2 flex items-center justify-end text-sm text-neutral-400 transition-colors">
<div className="flex items-center space-x-1 rounded-md py-1 px-2">
<span>Next step</span>
<ArrowRight className="h-3.5 w-3.5" />
</div>
<Handle
className={cn(
"!w-3 !h-3 !rounded-full transition-all duration-300",
isBottomHandleConnected ? "!bg-purple-500 !border-purple-400" : "!bg-neutral-400 !border-neutral-500",
selected && isBottomHandleConnected && "!bg-purple-400 !border-purple-300"
)}
style={{
right: "0px",
top: "calc(100% - 25px)",
}}
type="source"
position={Position.Right}
id="bottom-handle"
/>
</div>
</BaseNode>
);
}

View File

@@ -0,0 +1,256 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @author: Victor Calazans - Implementation of Delay node form │
│ @file: /app/agents/workflows/nodes/components/delay/DelayForm.tsx │
│ Developed by: Davidson Gomes │
│ Delay form developed by: Victor Calazans │
│ Creation date: May 13, 2025 │
│ Delay form implementation date: May 17, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useState, useEffect } from "react";
import { Dispatch, SetStateAction } from "react";
import { Clock, Trash2, Save, AlertCircle, HourglassIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DelayType, DelayUnitEnum } from "../../nodeFunctions";
import { cn } from "@/lib/utils";
export function DelayForm({
selectedNode,
handleUpdateNode,
setEdges,
setIsOpen,
setSelectedNode,
}: {
selectedNode: any;
handleUpdateNode: (node: any) => void;
setEdges: any;
setIsOpen: Dispatch<SetStateAction<boolean>>;
setSelectedNode: Dispatch<SetStateAction<any>>;
}) {
const [delay, setDelay] = useState<DelayType>({
value: 1,
unit: DelayUnitEnum.SECONDS,
description: "",
});
useEffect(() => {
if (selectedNode?.data?.delay) {
setDelay(selectedNode.data.delay);
}
}, [selectedNode]);
const handleSave = () => {
handleUpdateNode({
...selectedNode,
data: {
...selectedNode.data,
delay,
},
});
};
const handleDelete = () => {
setEdges((edges: any) => {
return edges.filter(
(edge: any) => edge.source !== selectedNode.id && edge.target !== selectedNode.id
);
});
setIsOpen(false);
setSelectedNode(null);
};
const getUnitLabel = (unit: DelayUnitEnum) => {
const units = {
[DelayUnitEnum.SECONDS]: "Seconds",
[DelayUnitEnum.MINUTES]: "Minutes",
[DelayUnitEnum.HOURS]: "Hours",
[DelayUnitEnum.DAYS]: "Days",
};
return units[unit] || unit;
};
const getTimeDescription = () => {
const value = delay.value || 0;
if (value <= 0) return "Invalid time";
if (value === 1) {
return `1 ${getUnitLabel(delay.unit).slice(0, -1)}`;
}
return `${value} ${getUnitLabel(delay.unit)}`;
};
return (
<div className="flex flex-col h-full">
<div className="p-4 border-b border-neutral-700 flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-md font-medium text-neutral-200">Delay Duration</h3>
<Badge
variant="outline"
className="text-xs bg-yellow-900/20 text-yellow-400 border-yellow-700/50"
>
{getTimeDescription().toUpperCase()}
</Badge>
</div>
<Select
value={delay.unit}
onValueChange={(value) =>
setDelay({
...delay,
unit: value as DelayUnitEnum,
})
}
>
<SelectTrigger className="w-[120px] h-8 bg-neutral-800 border-neutral-700 text-neutral-200">
<SelectValue placeholder="Unit" />
</SelectTrigger>
<SelectContent className="bg-neutral-800 border-neutral-700 text-neutral-200">
<SelectItem value={DelayUnitEnum.SECONDS}>Seconds</SelectItem>
<SelectItem value={DelayUnitEnum.MINUTES}>Minutes</SelectItem>
<SelectItem value={DelayUnitEnum.HOURS}>Hours</SelectItem>
<SelectItem value={DelayUnitEnum.DAYS}>Days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 min-h-0">
<div className="grid gap-4">
<div className="p-3 rounded-md bg-yellow-900/10 border border-yellow-700/30 mb-2">
<div className="flex items-start gap-2">
<div className="bg-yellow-900/50 rounded-full p-1.5 flex-shrink-0">
<Clock size={18} className="text-yellow-300" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-neutral-200">Time Delay</h3>
<p className="text-sm text-neutral-400 mt-1">
Pause workflow execution for a specified amount of time
</p>
</div>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="delay-value">Delay Value</Label>
<Input
id="delay-value"
type="number"
min="1"
className="bg-neutral-700 border-neutral-600"
value={delay.value}
onChange={(e) =>
setDelay({
...delay,
value: parseInt(e.target.value) || 1,
})
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="delay-description">Description (optional)</Label>
<Textarea
id="delay-description"
className="bg-neutral-700 border-neutral-600 min-h-[100px] resize-none"
value={delay.description}
onChange={(e) =>
setDelay({
...delay,
description: e.target.value,
})
}
placeholder="Add a description for this delay"
/>
</div>
{delay.value > 0 ? (
<div className="rounded-md bg-neutral-700/50 border border-neutral-600 p-3 mt-2">
<div className="text-sm font-medium text-neutral-400 mb-1">Preview</div>
<div className="flex items-start gap-2 p-2 rounded-md bg-neutral-800/70">
<div className="rounded-full bg-yellow-900/30 p-1.5 mt-0.5">
<HourglassIcon size={15} className="text-yellow-400" />
</div>
<div className="flex-1">
<div className="flex flex-col">
<span className="text-sm text-yellow-400 font-medium">
{getTimeDescription()} delay
</span>
{delay.description && (
<span className="text-xs text-neutral-400 mt-1">
{delay.description}
</span>
)}
</div>
</div>
</div>
</div>
) : (
<div className="rounded-md bg-neutral-700/30 border border-neutral-600/50 p-4 flex flex-col items-center justify-center text-center">
<AlertCircle className="h-6 w-6 text-neutral-500 mb-2" />
<p className="text-neutral-400 text-sm">Please set a valid delay time</p>
</div>
)}
</div>
</div>
<div className="p-4 border-t border-neutral-700 flex-shrink-0">
<div className="flex gap-2 justify-between">
<Button
variant="outline"
className="border-red-700/50 bg-red-900/20 text-red-400 hover:bg-red-900/30 hover:text-red-300"
onClick={handleDelete}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Node
</Button>
<Button
className="bg-yellow-700 hover:bg-yellow-600 text-white flex items-center gap-2"
onClick={handleSave}
>
<Save size={16} />
Save Delay
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,146 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @author: Victor Calazans - Implementation of Delay node functionality │
│ @file: /app/agents/workflows/nodes/components/delay/DelayNode.tsx │
│ Developed by: Davidson Gomes │
│ Delay node developed by: Victor Calazans │
│ Creation date: May 13, 2025 │
│ Delay node implementation date: May 17, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
/* eslint-disable no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Handle, NodeProps, Position, useEdges } from "@xyflow/react";
import { Clock, ArrowRight, Timer } from "lucide-react";
import { DelayType } from "../../nodeFunctions";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { BaseNode } from "../../BaseNode";
export function DelayNode(props: NodeProps) {
const { selected, data } = props;
const edges = useEdges();
const isExecuting = data.isExecuting as boolean | undefined;
const isHandleConnected = (handleId: string) => {
return edges.some(
(edge) => edge.source === props.id && edge.sourceHandle === handleId
);
};
const isBottomHandleConnected = isHandleConnected("bottom-handle");
const delay = data.delay as DelayType | undefined;
const getUnitLabel = (unit: string) => {
switch (unit) {
case 'seconds':
return 'Seconds';
case 'minutes':
return 'Minutes';
case 'hours':
return 'Hours';
case 'days':
return 'Days';
default:
return unit;
}
};
return (
<BaseNode hasTarget={true} selected={selected || false} borderColor="yellow" isExecuting={isExecuting}>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-yellow-900/40 shadow-sm">
<Clock className="h-5 w-5 text-yellow-400" />
</div>
<div>
<p className="text-lg font-medium text-yellow-400">
{data.label as string}
</p>
</div>
</div>
</div>
{delay ? (
<div className="mb-3 rounded-lg border border-yellow-700/40 bg-yellow-950/10 p-3 transition-all duration-200 hover:border-yellow-600/50 hover:bg-yellow-900/10">
<div className="flex flex-col">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center">
<Timer className="h-4 w-4 text-yellow-400" />
<span className="ml-1.5 font-medium text-white">Delay</span>
</div>
<Badge
variant="outline"
className="px-1.5 py-0 text-xs bg-yellow-900/30 text-yellow-400 border-yellow-700/40"
>
{getUnitLabel(delay.unit)}
</Badge>
</div>
<div className="mt-2 flex items-center">
<span className="text-lg font-semibold text-yellow-300">{delay.value}</span>
<span className="ml-1 text-sm text-neutral-400">{delay.unit}</span>
</div>
{delay.description && (
<p className="mt-2 text-xs text-neutral-400 line-clamp-2">
{delay.description.slice(0, 80)} {delay.description.length > 80 && '...'}
</p>
)}
</div>
</div>
) : (
<div className="mb-3 flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-yellow-700/40 bg-yellow-950/10 p-5 text-center transition-all duration-200 hover:border-yellow-600/50 hover:bg-yellow-900/20">
<Clock className="h-8 w-8 text-yellow-700/50 mb-2" />
<p className="text-yellow-400">No delay configured</p>
<p className="mt-1 text-xs text-neutral-500">Click to configure</p>
</div>
)}
<div className="mt-2 flex items-center justify-end text-sm text-neutral-400 transition-colors">
<div className="flex items-center space-x-1 rounded-md py-1 px-2">
<span>Next step</span>
<ArrowRight className="h-3.5 w-3.5" />
</div>
<Handle
className={cn(
"!w-3 !h-3 !rounded-full transition-all duration-300",
isBottomHandleConnected ? "!bg-yellow-500 !border-yellow-400" : "!bg-neutral-400 !border-neutral-500",
selected && isBottomHandleConnected && "!bg-yellow-400 !border-yellow-300"
)}
style={{
right: "-8px",
top: "calc(100% - 25px)",
}}
type="source"
position={Position.Right}
id="bottom-handle"
/>
</div>
</BaseNode>
);
}

View File

@@ -0,0 +1,292 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/workflows/nodes/components/message/MessageForm.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
/* eslint-disable jsx-a11y/alt-text */
import { useEdges, useNodes } from "@xyflow/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Agent } from "@/types/agent";
import { listAgents } from "@/services/agentService";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import {
MessageSquare,
Save,
Text,
Image,
File,
Video,
AlertCircle
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
/* eslint-disable @typescript-eslint/no-explicit-any */
function MessageForm({
selectedNode,
handleUpdateNode,
setEdges,
setIsOpen,
setSelectedNode,
}: {
selectedNode: any;
handleUpdateNode: any;
setEdges: any;
setIsOpen: any;
setSelectedNode: any;
}) {
const [node, setNode] = useState(selectedNode);
const [messageType, setMessageType] = useState("text");
const [content, setContent] = useState("");
const [loading, setLoading] = useState(false);
const [agents, setAgents] = useState<Agent[]>([]);
const [allAgents, setAllAgents] = useState<Agent[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const edges = useEdges();
const nodes = useNodes();
const connectedNode = useMemo(() => {
const edge = edges.find((edge) => edge.source === selectedNode.id);
if (!edge) return null;
const node = nodes.find((node) => node.id === edge.target);
return node || null;
}, [edges, nodes, selectedNode.id]);
const user = typeof window !== "undefined" ? JSON.parse(localStorage.getItem("user") || '{}') : {};
const clientId = user?.client_id || "";
const currentAgent = typeof window !== "undefined" ?
JSON.parse(localStorage.getItem("current_workflow_agent") || '{}') : {};
const currentAgentId = currentAgent?.id;
useEffect(() => {
if (selectedNode) {
setNode(selectedNode);
setMessageType(selectedNode.data.message?.type || "text");
setContent(selectedNode.data.message?.content || "");
}
}, [selectedNode]);
useEffect(() => {
if (!clientId) return;
setLoading(true);
listAgents(clientId)
.then((res) => {
const filteredAgents = res.data.filter((agent: Agent) => agent.id !== currentAgentId);
setAllAgents(filteredAgents);
setAgents(filteredAgents);
})
.catch((error) => console.error("Error loading agents:", error))
.finally(() => setLoading(false));
}, [clientId, currentAgentId]);
useEffect(() => {
if (searchTerm.trim() === "") {
setAgents(allAgents);
} else {
const filtered = allAgents.filter((agent) =>
agent.name.toLowerCase().includes(searchTerm.toLowerCase())
);
setAgents(filtered);
}
}, [searchTerm, allAgents]);
const handleDeleteEdge = useCallback(() => {
const id = edges.find((edge: any) => edge.source === selectedNode.id)?.id;
setEdges((edges: any) => {
const left = edges.filter((edge: any) => edge.id !== id);
return left;
});
}, [nodes, edges, selectedNode, setEdges]);
const handleSelectAgent = (agent: Agent) => {
const updatedNode = {
...node,
data: {
...node.data,
agent,
},
};
setNode(updatedNode);
handleUpdateNode(updatedNode);
};
const getAgentTypeName = (type: string) => {
const agentTypes: Record<string, string> = {
llm: "LLM Agent",
a2a: "A2A Agent",
sequential: "Sequential Agent",
parallel: "Parallel Agent",
loop: "Loop Agent",
workflow: "Workflow Agent",
task: "Task Agent",
};
return agentTypes[type] || type;
};
const handleSave = () => {
const updatedNode = {
...node,
data: {
...node.data,
message: {
type: messageType,
content,
},
},
};
setNode(updatedNode);
handleUpdateNode(updatedNode);
};
const messageTypeInfo = {
text: {
icon: <Text className="h-5 w-5 text-orange-400" />,
name: "Text Message",
description: "Simple text message",
color: "bg-orange-900/30 border-orange-700/50",
},
image: {
icon: <Image className="h-5 w-5 text-blue-400" />,
name: "Image",
description: "Image URL or base64",
color: "bg-blue-900/30 border-blue-700/50",
},
file: {
icon: <File className="h-5 w-5 text-emerald-400" />,
name: "File",
description: "File URL or base64",
color: "bg-emerald-900/30 border-emerald-700/50",
},
video: {
icon: <Video className="h-5 w-5 text-purple-400" />,
name: "Video",
description: "Video URL or base64",
color: "bg-purple-900/30 border-purple-700/50",
},
};
return (
<div className="flex flex-col h-full">
<div className="p-4 border-b border-neutral-700 flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-md font-medium text-neutral-200">Message Type</h3>
<Badge
variant="outline"
className="text-xs bg-orange-900/20 text-orange-400 border-orange-700/50"
>
{messageType === "text" ? "TEXT" : messageType.toUpperCase()}
</Badge>
</div>
<Select
value={messageType}
onValueChange={setMessageType}
>
<SelectTrigger className="w-[120px] h-8 bg-neutral-800 border-neutral-700 text-neutral-200">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent className="bg-neutral-800 border-neutral-700 text-neutral-200">
<SelectItem value="text">Text</SelectItem>
{/* Other options can be enabled in the future */}
{/* <SelectItem value="image">Image</SelectItem>
<SelectItem value="file">File</SelectItem>
<SelectItem value="video">Video</SelectItem> */}
</SelectContent>
</Select>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 min-h-0">
<div className="grid gap-4">
<div className="p-3 rounded-md bg-orange-900/10 border border-orange-700/30 mb-2">
<div className="flex items-start gap-2">
<div className="bg-orange-900/50 rounded-full p-1.5 flex-shrink-0">
<MessageSquare size={18} className="text-orange-300" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-neutral-200">{messageTypeInfo.text.name}</h3>
<p className="text-sm text-neutral-400 mt-1">{messageTypeInfo.text.description}</p>
</div>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="content">Message Content</Label>
<Textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Type your message here..."
className="min-h-[150px] bg-neutral-700 border-neutral-600 resize-none"
/>
</div>
{content.trim() !== "" ? (
<div className="rounded-md bg-neutral-700/50 border border-neutral-600 p-3 mt-2">
<div className="text-sm font-medium text-neutral-400 mb-1">Preview</div>
<div className="flex items-start gap-2 p-2 rounded-md bg-neutral-800/70">
<div className="rounded-full bg-orange-900/30 p-1.5 mt-0.5">
<MessageSquare size={15} className="text-orange-400" />
</div>
<div className="text-sm text-neutral-300 whitespace-pre-wrap">{content}</div>
</div>
</div>
) : (
<div className="rounded-md bg-neutral-700/30 border border-neutral-600/50 p-4 flex flex-col items-center justify-center text-center">
<AlertCircle className="h-6 w-6 text-neutral-500 mb-2" />
<p className="text-neutral-400 text-sm">Your message will appear here</p>
</div>
)}
</div>
</div>
<div className="p-4 border-t border-neutral-700 flex-shrink-0">
<Button
className="w-full bg-orange-700 hover:bg-orange-600 text-white flex items-center gap-2 justify-center"
onClick={handleSave}
>
<Save size={16} />
Save Message
</Button>
</div>
</div>
);
}
export { MessageForm };

View File

@@ -0,0 +1,162 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/workflows/nodes/components/message/MessageNode.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
/* eslint-disable no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Handle, NodeProps, Position, useEdges } from "@xyflow/react";
import { MessageCircle, Text, Image, File, Video, ArrowRight } from "lucide-react";
import { MessageType, MessageTypeEnum } from "../../nodeFunctions";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { BaseNode } from "../../BaseNode";
export function MessageNode(props: NodeProps) {
const { selected, data } = props;
const edges = useEdges();
const isExecuting = data.isExecuting as boolean | undefined;
const isHandleConnected = (handleId: string) => {
return edges.some(
(edge) => edge.source === props.id && edge.sourceHandle === handleId
);
};
const isBottomHandleConnected = isHandleConnected("bottom-handle");
const message = data.message as MessageType | undefined;
const getMessageTypeIcon = (type: string) => {
switch (type) {
case MessageTypeEnum.TEXT:
return <Text className="h-4 w-4 text-orange-400" />;
case "image":
return <Image className="h-4 w-4 text-blue-400" />;
case "file":
return <File className="h-4 w-4 text-emerald-400" />;
case "video":
return <Video className="h-4 w-4 text-purple-400" />;
default:
return <MessageCircle className="h-4 w-4 text-orange-400" />;
}
};
const getMessageTypeColor = (type: string) => {
switch (type) {
case MessageTypeEnum.TEXT:
return 'bg-orange-900/30 text-orange-400 border-orange-700/40';
case "image":
return 'bg-blue-900/30 text-blue-400 border-blue-700/40';
case "file":
return 'bg-emerald-900/30 text-emerald-400 border-emerald-700/40';
case "video":
return 'bg-purple-900/30 text-purple-400 border-purple-700/40';
default:
return 'bg-orange-900/30 text-orange-400 border-orange-700/40';
}
};
const getMessageTypeName = (type: string) => {
const messageTypes: Record<string, string> = {
text: "Text Message",
image: "Image",
file: "File",
video: "Video",
};
return messageTypes[type] || type;
};
return (
<BaseNode hasTarget={true} selected={selected || false} borderColor="orange" isExecuting={isExecuting}>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-orange-900/40 shadow-sm">
<MessageCircle className="h-5 w-5 text-orange-400" />
</div>
<div>
<p className="text-lg font-medium text-orange-400">
{data.label as string}
</p>
</div>
</div>
</div>
{message ? (
<div className="mb-3 rounded-lg border border-orange-700/40 bg-orange-950/10 p-3 transition-all duration-200 hover:border-orange-600/50 hover:bg-orange-900/10">
<div className="flex flex-col">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center">
{getMessageTypeIcon(message.type)}
<span className="ml-1.5 font-medium text-white">{getMessageTypeName(message.type)}</span>
</div>
<Badge
variant="outline"
className={cn("px-1.5 py-0 text-xs", getMessageTypeColor(message.type))}
>
{message.type}
</Badge>
</div>
{message.content && (
<p className="mt-2 text-xs text-neutral-400 line-clamp-2">
{message.content.slice(0, 80)} {message.content.length > 80 && '...'}
</p>
)}
</div>
</div>
) : (
<div className="mb-3 flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-orange-700/40 bg-orange-950/10 p-5 text-center transition-all duration-200 hover:border-orange-600/50 hover:bg-orange-900/20">
<MessageCircle className="h-8 w-8 text-orange-700/50 mb-2" />
<p className="text-orange-400">No message configured</p>
<p className="mt-1 text-xs text-neutral-500">Click to configure</p>
</div>
)}
<div className="mt-2 flex items-center justify-end text-sm text-neutral-400 transition-colors">
<div className="flex items-center space-x-1 rounded-md py-1 px-2">
<span>Next step</span>
<ArrowRight className="h-3.5 w-3.5" />
</div>
<Handle
className={cn(
"!w-3 !h-3 !rounded-full transition-all duration-300",
isBottomHandleConnected ? "!bg-orange-500 !border-orange-400" : "!bg-neutral-400 !border-neutral-500",
selected && isBottomHandleConnected && "!bg-orange-400 !border-orange-300"
)}
style={{
right: "-8px",
top: "calc(100% - 25px)",
}}
type="source"
position={Position.Right}
id="bottom-handle"
/>
</div>
</BaseNode>
);
}

View File

@@ -0,0 +1,100 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/workflows/nodes/components/start/StartNode.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Handle, Node, NodeProps, Position, useEdges } from "@xyflow/react";
import { Zap, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { BaseNode } from "../../BaseNode";
export type StartNodeType = Node<
{
label?: string;
},
"start-node"
>;
export function StartNode(props: NodeProps) {
const { selected, data } = props;
const edges = useEdges();
const isExecuting = data.isExecuting as boolean | undefined;
const isSourceHandleConnected = edges.some(
(edge) => edge.source === props.id
);
return (
<BaseNode hasTarget={true} selected={selected || false} borderColor="emerald" isExecuting={isExecuting}>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-900/40 shadow-sm">
<Zap className="h-5 w-5 text-emerald-400" />
</div>
<div>
<p className="text-lg font-medium text-emerald-400">Start</p>
</div>
</div>
</div>
<div className="mb-3 rounded-lg border border-emerald-700/40 bg-emerald-950/10 p-3 transition-all duration-200 hover:border-emerald-600/50 hover:bg-emerald-900/10">
<div className="flex flex-col">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center">
<span className="font-medium text-white">Input: User content</span>
</div>
</div>
<p className="mt-2 text-xs text-neutral-400">
The workflow begins when a user sends a message to the agent
</p>
</div>
</div>
<div className="mt-2 flex items-center justify-end text-sm text-neutral-400 transition-colors">
<div className="flex items-center space-x-1 rounded-md py-1 px-2">
<span>Next step</span>
<ArrowRight className="h-3.5 w-3.5" />
</div>
<Handle
className={cn(
"!w-3 !h-3 !rounded-full transition-all duration-300",
isSourceHandleConnected ? "!bg-emerald-500 !border-emerald-400" : "!bg-neutral-400 !border-neutral-500",
selected && isSourceHandleConnected && "!bg-emerald-400 !border-emerald-300"
)}
style={{
right: "-8px",
top: "calc(100% - 25px)",
}}
type="source"
position={Position.Right}
/>
</div>
</BaseNode>
);
}

View File

@@ -0,0 +1,109 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @author: Victor Calazans - Implementation of Delay node type │
│ @file: /app/agents/workflows/nodes/index.ts │
│ Developed by: Davidson Gomes │
│ Delay node functionality developed by: Victor Calazans │
│ Creation date: May 13, 2025 │
│ Delay implementation date: May 17, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
import type { NodeTypes, BuiltInNode, Node } from "@xyflow/react";
import { ConditionNode } from "./components/condition/ConditionNode";
import { AgentNode } from "./components/agent/AgentNode";
import { StartNode, StartNodeType } from "./components/start/StartNode";
import { MessageNode } from "./components/message/MessageNode";
import { DelayNode } from "./components/delay/DelayNode";
import "./style.css";
import {
ConditionType,
MessageType,
DelayType,
} from "./nodeFunctions";
import { Agent } from "@/types/agent";
type AgentNodeType = Node<
{
label?: string;
agent?: Agent;
},
"agent-node"
>;
type MessageNodeType = Node<
{
label?: string;
message?: MessageType;
},
"message-node"
>;
type DelayNodeType = Node<
{
label?: string;
delay?: DelayType;
},
"delay-node"
>;
type ConditionNodeType = Node<
{
label?: string;
integration?: string;
icon?: string;
conditions?: ConditionType[];
},
"condition-node"
>;
export type AppNode =
| BuiltInNode
| StartNodeType
| AgentNodeType
| ConditionNodeType
| MessageNodeType
| DelayNodeType;
export type NodeType = AppNode["type"];
export const initialNodes: AppNode[] = [
{
id: "start-node",
type: "start-node",
position: { x: -100, y: 100 },
data: {
label: "Start",
},
},
];
export const nodeTypes = {
"start-node": StartNode,
"agent-node": AgentNode,
"message-node": MessageNode,
"condition-node": ConditionNode,
"delay-node": DelayNode,
} satisfies NodeTypes;

View File

@@ -0,0 +1,65 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @author: Victor Calazans - Implementation of Delay types │
│ @file: /app/agents/workflows/nodes/nodeFunctions.ts │
│ Developed by: Davidson Gomes │
│ Delay node functionality developed by: Victor Calazans │
│ Creation date: May 13, 2025 │
│ Delay implementation date: May 17, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-unused-vars */
export enum ConditionTypeEnum {
PREVIOUS_OUTPUT = "previous-output",
}
export enum MessageTypeEnum {
TEXT = "text",
}
export enum DelayUnitEnum {
SECONDS = "seconds",
MINUTES = "minutes",
HOURS = "hours",
DAYS = "days",
}
export type MessageType = {
type: MessageTypeEnum;
content: string;
};
export type DelayType = {
value: number;
unit: DelayUnitEnum;
description?: string;
};
export type ConditionType = {
id: string;
type: ConditionTypeEnum;
data?: any;
};

View File

@@ -0,0 +1,54 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/workflows/nodes/style.css │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
/* .react-flow__handle {
background-color: #8492A6;
border-radius: 50%;
height: 16px;
position: absolute;
width: 16px;
} */
/* .react-flow__handle-right {
right: -6px;
top: 88%;
transform: translateY(-75%);
background-color: #f5f5f5 !important;
border: 3px solid #8492A6 !important;
} */
/* .react-flow__handle-left {
left: 0px;
top: 40%;
width: 60px;
border-radius: 0;
height: 50%;
transform: translateY(-75%);
background-color: transparent;
border: none;
} */

View File

@@ -0,0 +1,218 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/workflows/page.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { useEffect, useState, useRef, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import Canva from "./Canva";
import { Agent } from "@/types/agent";
import { getAgent, updateAgent } from "@/services/agentService";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Save, Download, PlayIcon } from "lucide-react";
import Link from "next/link";
import { ReactFlowProvider } from "@xyflow/react";
import { DnDProvider } from "@/contexts/DnDContext";
import { NodeDataProvider } from "@/contexts/NodeDataContext";
import { SourceClickProvider } from "@/contexts/SourceClickContext";
import { useToast } from "@/hooks/use-toast";
import { AgentTestChatModal } from "./nodes/components/agent/AgentTestChatModal";
function WorkflowsContent() {
const searchParams = useSearchParams();
const agentId = searchParams.get("agentId");
const [agent, setAgent] = useState<Agent | null>(null);
const [loading, setLoading] = useState(false);
const canvaRef = useRef<any>(null);
const { toast } = useToast();
const [isTestModalOpen, setIsTestModalOpen] = useState(false);
const user =
typeof window !== "undefined"
? JSON.parse(localStorage.getItem("user") || "{}")
: {};
const clientId = user?.client_id || "";
useEffect(() => {
if (agentId && clientId) {
setLoading(true);
getAgent(agentId, clientId)
.then((res) => {
setAgent(res.data);
if (typeof window !== "undefined") {
localStorage.setItem(
"current_workflow_agent",
JSON.stringify(res.data)
);
}
})
.catch((err) => {
console.error("Error loading agent:", err);
})
.finally(() => {
setLoading(false);
});
}
}, [agentId, clientId]);
useEffect(() => {
return () => {
if (typeof window !== "undefined") {
localStorage.removeItem("current_workflow_agent");
}
};
}, []);
const handleSaveWorkflow = async () => {
if (!agent || !canvaRef.current) return;
try {
const { nodes, edges } = canvaRef.current.getFlowData();
const workflow = {
nodes,
edges,
};
await updateAgent(agent.id, {
...agent,
config: {
...agent.config,
workflow,
},
});
toast({
title: "Workflow saved",
description: "The changes were saved successfully",
});
canvaRef.current.setHasChanges(false);
} catch (error) {
console.error("Error saving workflow:", error);
toast({
title: "Error saving workflow",
description: "Unable to save the changes",
variant: "destructive",
});
}
};
if (loading) {
return (
<div className="w-full h-screen bg-[#121212] flex items-center justify-center">
<div className="text-white text-xl">Loading...</div>
</div>
);
}
return (
<div className="relative w-full h-screen flex flex-col" data-workflow-page="true">
{/* Header with controls */}
<div className="w-full bg-[#121212] py-4 px-6 z-10 flex items-center justify-between border-b border-neutral-800">
<div className="flex items-center gap-2">
<Link href="/agents">
<Button
variant="outline"
className="bg-neutral-800 border-neutral-700 text-neutral-200 hover:bg-neutral-700"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Agents
</Button>
</Link>
{agent && (
<div className="bg-neutral-800 px-4 py-2 rounded-md">
<h2 className="text-neutral-200 font-medium">
{agent.name} -{" "}
<span className="text-neutral-400 text-sm">{agent.type}</span>
</h2>
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="bg-neutral-800 border-neutral-700 text-neutral-200 hover:bg-neutral-700"
onClick={handleSaveWorkflow}
>
<Save className="mr-2 h-4 w-4" />
Save
</Button>
{agent && (
<Button
variant="outline"
className="bg-green-800 border-green-700 text-green-200 hover:bg-green-700"
onClick={() => setIsTestModalOpen(true)}
>
<PlayIcon className="h-4 w-4 mr-2" />
Test Workflow
</Button>
)}
</div>
</div>
{/* Main content area */}
<div className="flex-1 relative overflow-hidden">
{agent && isTestModalOpen && (
<AgentTestChatModal
open={isTestModalOpen}
onOpenChange={setIsTestModalOpen}
agent={agent}
canvasRef={canvaRef} // Pass the canvas reference to allow visualization of running nodes
/>
)}
<NodeDataProvider>
<SourceClickProvider>
<DnDProvider>
<ReactFlowProvider>
<Canva agent={agent} ref={canvaRef} data-canvas-ref="true" />
</ReactFlowProvider>
</DnDProvider>
</SourceClickProvider>
</NodeDataProvider>
</div>
</div>
);
}
export default function WorkflowsPage() {
return (
<Suspense
fallback={
<div className="w-full h-screen bg-[#121212] flex items-center justify-center">
<div className="text-white text-xl">Loading...</div>
</div>
}
>
<WorkflowsContent />
</Suspense>
);
}

View File

@@ -0,0 +1,199 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/agents/workflows/utils.ts │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
import { Node, NodePositionChange, XYPosition } from "@xyflow/react";
type GetHelperLinesResult = {
horizontal?: number;
vertical?: number;
snapPosition: Partial<XYPosition>;
};
// this utility function can be called with a position change (inside onNodesChange)
// it checks all other nodes and calculated the helper line positions and the position where the current node should snap to
export function getHelperLines(
change: NodePositionChange,
nodes: Node[],
distance = 5,
): GetHelperLinesResult {
const defaultResult = {
horizontal: undefined,
vertical: undefined,
snapPosition: { x: undefined, y: undefined },
};
const nodeA = nodes.find((node) => node.id === change.id);
if (!nodeA || !change.position) {
return defaultResult;
}
const nodeABounds = {
left: change.position.x,
right: change.position.x + (nodeA.measured?.width ?? 0),
top: change.position.y,
bottom: change.position.y + (nodeA.measured?.height ?? 0),
width: nodeA.measured?.width ?? 0,
height: nodeA.measured?.height ?? 0,
};
let horizontalDistance = distance;
let verticalDistance = distance;
return nodes
.filter((node) => node.id !== nodeA.id)
.reduce<GetHelperLinesResult>((result, nodeB) => {
const nodeBBounds = {
left: nodeB.position.x,
right: nodeB.position.x + (nodeB.measured?.width ?? 0),
top: nodeB.position.y,
bottom: nodeB.position.y + (nodeB.measured?.height ?? 0),
width: nodeB.measured?.width ?? 0,
height: nodeB.measured?.height ?? 0,
};
// |‾‾‾‾‾‾‾‾‾‾‾|
// | A |
// |___________|
// |
// |
// |‾‾‾‾‾‾‾‾‾‾‾|
// | B |
// |___________|
const distanceLeftLeft = Math.abs(nodeABounds.left - nodeBBounds.left);
if (distanceLeftLeft < verticalDistance) {
result.snapPosition.x = nodeBBounds.left;
result.vertical = nodeBBounds.left;
verticalDistance = distanceLeftLeft;
}
// |‾‾‾‾‾‾‾‾‾‾‾|
// | A |
// |___________|
// |
// |
// |‾‾‾‾‾‾‾‾‾‾‾|
// | B |
// |___________|
const distanceRightRight = Math.abs(
nodeABounds.right - nodeBBounds.right,
);
if (distanceRightRight < verticalDistance) {
result.snapPosition.x = nodeBBounds.right - nodeABounds.width;
result.vertical = nodeBBounds.right;
verticalDistance = distanceRightRight;
}
// |‾‾‾‾‾‾‾‾‾‾‾|
// | A |
// |___________|
// |
// |
// |‾‾‾‾‾‾‾‾‾‾‾|
// | B |
// |___________|
const distanceLeftRight = Math.abs(nodeABounds.left - nodeBBounds.right);
if (distanceLeftRight < verticalDistance) {
result.snapPosition.x = nodeBBounds.right;
result.vertical = nodeBBounds.right;
verticalDistance = distanceLeftRight;
}
// |‾‾‾‾‾‾‾‾‾‾‾|
// | A |
// |___________|
// |
// |
// |‾‾‾‾‾‾‾‾‾‾‾|
// | B |
// |___________|
const distanceRightLeft = Math.abs(nodeABounds.right - nodeBBounds.left);
if (distanceRightLeft < verticalDistance) {
result.snapPosition.x = nodeBBounds.left - nodeABounds.width;
result.vertical = nodeBBounds.left;
verticalDistance = distanceRightLeft;
}
// |‾‾‾‾‾‾‾‾‾‾‾|‾‾‾‾‾|‾‾‾‾‾‾‾‾‾‾‾|
// | A | | B |
// |___________| |___________|
const distanceTopTop = Math.abs(nodeABounds.top - nodeBBounds.top);
if (distanceTopTop < horizontalDistance) {
result.snapPosition.y = nodeBBounds.top;
result.horizontal = nodeBBounds.top;
horizontalDistance = distanceTopTop;
}
// |‾‾‾‾‾‾‾‾‾‾‾|
// | A |
// |___________|_________________
// | |
// | B |
// |___________|
const distanceBottomTop = Math.abs(nodeABounds.bottom - nodeBBounds.top);
if (distanceBottomTop < horizontalDistance) {
result.snapPosition.y = nodeBBounds.top - nodeABounds.height;
result.horizontal = nodeBBounds.top;
horizontalDistance = distanceBottomTop;
}
// |‾‾‾‾‾‾‾‾‾‾‾| |‾‾‾‾‾‾‾‾‾‾‾|
// | A | | B |
// |___________|_____|___________|
const distanceBottomBottom = Math.abs(
nodeABounds.bottom - nodeBBounds.bottom,
);
if (distanceBottomBottom < horizontalDistance) {
result.snapPosition.y = nodeBBounds.bottom - nodeABounds.height;
result.horizontal = nodeBBounds.bottom;
horizontalDistance = distanceBottomBottom;
}
// |‾‾‾‾‾‾‾‾‾‾‾|
// | B |
// | |
// |‾‾‾‾‾‾‾‾‾‾‾|‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
// | A |
// |___________|
const distanceTopBottom = Math.abs(nodeABounds.top - nodeBBounds.bottom);
if (distanceTopBottom < horizontalDistance) {
result.snapPosition.y = nodeBBounds.bottom;
result.horizontal = nodeBBounds.bottom;
horizontalDistance = distanceTopBottom;
}
return result;
}, defaultResult);
}

View File

@@ -0,0 +1,497 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/chat/components/AgentInfoDialog.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 14, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { useState, useEffect } from "react";
import { Agent } from "@/types/agent";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Bot,
Code,
WrenchIcon,
Layers,
Server,
TagIcon,
Share,
Edit,
Loader2,
Download,
} from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { listApiKeys, ApiKey } from "@/services/agentService";
import { listMCPServers } from "@/services/mcpServerService";
import { availableModels } from "@/types/aiModels";
import { MCPServer } from "@/types/mcpServer";
import { AgentForm } from "@/app/agents/forms/AgentForm";
import { exportAsJson } from "@/lib/utils";
interface AgentInfoDialogProps {
agent: Agent | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onAgentUpdated?: (updatedAgent: Agent) => void;
}
export function AgentInfoDialog({
agent,
open,
onOpenChange,
onAgentUpdated,
}: AgentInfoDialogProps) {
const [activeTab, setActiveTab] = useState("info");
const [isAgentFormOpen, setIsAgentFormOpen] = useState(false);
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [availableMCPs, setAvailableMCPs] = useState<MCPServer[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(false);
const user =
typeof window !== "undefined"
? JSON.parse(localStorage.getItem("user") || "{}")
: {};
const clientId = user?.client_id || "";
useEffect(() => {
if (!clientId || !open) return;
const loadData = async () => {
try {
const [apiKeysResponse, mcpServersResponse] = await Promise.all([
listApiKeys(clientId),
listMCPServers(),
]);
setApiKeys(apiKeysResponse.data);
setAvailableMCPs(mcpServersResponse.data);
} catch (error) {
console.error("Error loading data for agent form:", error);
}
};
loadData();
}, [clientId, open]);
const getAgentTypeName = (type: string) => {
const agentTypes: Record<string, string> = {
llm: "LLM Agent",
a2a: "A2A Agent",
sequential: "Sequential Agent",
parallel: "Parallel Agent",
loop: "Loop Agent",
workflow: "Workflow Agent",
task: "Task Agent",
};
return agentTypes[type] || type;
};
const handleSaveAgent = async (agentData: Partial<Agent>) => {
if (!agent?.id) return;
setIsLoading(true);
try {
const { updateAgent } = await import("@/services/agentService");
const updated = await updateAgent(agent.id, agentData as any);
if (updated.data && onAgentUpdated) {
onAgentUpdated(updated.data);
}
setIsAgentFormOpen(false);
} catch (error) {
console.error("Error updating agent:", error);
} finally {
setIsLoading(false);
}
};
// Function to export the agent as JSON
const handleExportAgent = async () => {
if (!agent) return;
try {
// First fetch all agents to properly resolve agent_tools references
const { listAgents } = await import("@/services/agentService");
const allAgentsResponse = await listAgents(clientId, 0, 1000);
const allAgents = allAgentsResponse.data || [];
exportAsJson(
agent,
`agent-${agent.name.replace(/\s+/g, "-").toLowerCase()}-${agent.id.substring(0, 8)}`,
true,
allAgents
);
} catch (error) {
console.error("Error exporting agent:", error);
}
};
if (!agent) return null;
const getToolsCount = () => {
let count = 0;
if (agent.config?.tools) count += agent.config.tools.length;
if (agent.config?.custom_tools?.http_tools)
count += agent.config.custom_tools.http_tools.length;
if (agent.config?.agent_tools)
count += agent.config.agent_tools.length;
return count;
};
const getSubAgentsCount = () => {
return agent.config?.sub_agents?.length || 0;
};
const getMCPServersCount = () => {
let count = 0;
if (agent.config?.mcp_servers) count += agent.config.mcp_servers.length;
if (agent.config?.custom_mcp_servers)
count += agent.config.custom_mcp_servers.length;
return count;
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[85vh] flex flex-col overflow-hidden bg-neutral-900 border-neutral-700">
<DialogHeader className="flex flex-row items-start justify-between pb-2">
<div>
<DialogTitle className="text-xl text-white flex items-center gap-2">
<Bot className="h-5 w-5 text-emerald-400" />
{agent.name}
</DialogTitle>
<DialogDescription className="text-neutral-400 mt-1">
{agent.description || "No description available"}
</DialogDescription>
</div>
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="bg-neutral-800 border-neutral-700 text-emerald-400"
>
{getAgentTypeName(agent.type)}
</Badge>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-full bg-neutral-800 border-neutral-700 hover:bg-emerald-900 hover:text-emerald-400"
onClick={handleExportAgent}
title="Export agent as JSON"
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-full bg-neutral-800 border-neutral-700 hover:bg-emerald-900 hover:text-emerald-400"
onClick={() => setIsAgentFormOpen(true)}
title="Edit agent"
>
<Edit className="h-4 w-4" />
</Button>
</div>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex-1 overflow-hidden flex flex-col"
>
<TabsList className="bg-neutral-800 p-1 border-b border-neutral-700">
<TabsTrigger
value="info"
className="data-[state=active]:bg-neutral-700 data-[state=active]:text-emerald-400"
>
Information
</TabsTrigger>
<TabsTrigger
value="tools"
className="data-[state=active]:bg-neutral-700 data-[state=active]:text-emerald-400"
>
Tools
</TabsTrigger>
<TabsTrigger
value="config"
className="data-[state=active]:bg-neutral-700 data-[state=active]:text-emerald-400"
>
Configuration
</TabsTrigger>
</TabsList>
<ScrollArea className="flex-1 p-4">
<TabsContent value="info" className="mt-0 space-y-4">
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<div className="bg-neutral-800 p-3 rounded-md border border-neutral-700 flex flex-col items-center justify-center text-center">
<Code className="h-5 w-5 text-emerald-400 mb-1" />
<span className="text-xs text-neutral-400">Model</span>
<span className="text-sm text-neutral-200 mt-1 font-medium">
{agent.model || "Not specified"}
</span>
</div>
<div className="bg-neutral-800 p-3 rounded-md border border-neutral-700 flex flex-col items-center justify-center text-center">
<TagIcon className="h-5 w-5 text-emerald-400 mb-1" />
<span className="text-xs text-neutral-400">Tools</span>
<span className="text-sm text-neutral-200 mt-1 font-medium">
{getToolsCount()}
</span>
</div>
<div className="bg-neutral-800 p-3 rounded-md border border-neutral-700 flex flex-col items-center justify-center text-center">
<Layers className="h-5 w-5 text-emerald-400 mb-1" />
<span className="text-xs text-neutral-400">
Sub-agents
</span>
<span className="text-sm text-neutral-200 mt-1 font-medium">
{getSubAgentsCount()}
</span>
</div>
</div>
<div className="bg-neutral-800 p-4 rounded-md border border-neutral-700">
<h3 className="text-sm font-medium text-emerald-400 mb-2">
Agent Role
</h3>
<p className="text-neutral-300 text-sm">
{agent.role || "Not specified"}
</p>
<h3 className="text-sm font-medium text-emerald-400 mb-2">
Agent Goal
</h3>
<p className="text-neutral-300 text-sm">
{agent.goal || "Not specified"}
</p>
{/* agent instructions: agent.instruction */}
<h3 className="text-sm font-medium text-emerald-400 mb-2">
Agent Instructions
</h3>
<div className="bg-neutral-900 p-3 rounded-md border border-neutral-700 max-h-[200px] overflow-y-auto">
<pre className="text-xs text-neutral-300 whitespace-pre-wrap font-mono">
{agent.instruction || "No instructions provided"}
</pre>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="tools" className="mt-0 space-y-4">
{getToolsCount() > 0 ? (
<div className="space-y-3">
{/* Built-in tools */}
{agent.config?.tools && agent.config.tools.length > 0 && (
<div className="bg-neutral-800 p-4 rounded-md border border-neutral-700">
<h3 className="text-sm font-medium text-emerald-400 mb-2">
Built-in Tools
</h3>
<div className="grid grid-cols-2 gap-2">
{agent.config.tools.map((tool, index) => (
<Badge
key={index}
variant="outline"
className="bg-neutral-900 border-neutral-700 text-neutral-300 p-2 justify-start"
>
<TagIcon className="h-3.5 w-3.5 mr-1.5 text-emerald-400" />
{typeof tool === 'string' ? tool : 'Custom Tool'}
</Badge>
))}
</div>
</div>
)}
{/* Agent Tools */}
{agent.config?.agent_tools && agent.config.agent_tools.length > 0 && (
<div className="bg-neutral-800 p-4 rounded-md border border-neutral-700">
<h3 className="text-sm font-medium text-emerald-400 mb-2">
Agent Tools
</h3>
<div className="space-y-2">
{agent.config.agent_tools.map((agentId, index) => (
<div
key={index}
className="bg-neutral-900 p-2 rounded-md border border-neutral-700 flex items-center"
>
<div className="flex items-center">
<Bot className="h-3.5 w-3.5 mr-2 text-emerald-400" />
<span className="text-sm text-neutral-300">
{agents.find(a => a.id === agentId)?.name || agentId}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Custom HTTP tools */}
{agent.config?.custom_tools?.http_tools &&
agent.config.custom_tools.http_tools.length > 0 && (
<div className="bg-neutral-800 p-4 rounded-md border border-neutral-700">
<h3 className="text-sm font-medium text-emerald-400 mb-2">
Custom HTTP Tools
</h3>
<div className="space-y-2">
{agent.config.custom_tools.http_tools.map(
(tool, index) => (
<div
key={index}
className="bg-neutral-900 p-2 rounded-md border border-neutral-700 flex items-center justify-between"
>
<div className="flex items-center">
<Server className="h-3.5 w-3.5 mr-2 text-emerald-400" />
<span className="text-sm text-neutral-300">
{tool.name}
</span>
</div>
<Badge
variant="outline"
className="text-xs bg-neutral-800 border-neutral-700 text-emerald-400"
>
{tool.method}
</Badge>
</div>
)
)}
</div>
</div>
)}
</div>
) : (
<div className="bg-neutral-800 p-6 rounded-md border border-neutral-700 text-center">
<TagIcon className="h-8 w-8 text-neutral-600 mx-auto mb-2" />
<p className="text-neutral-400">
This agent has no tools configured
</p>
</div>
)}
</TabsContent>
<TabsContent value="config" className="mt-0 space-y-4">
<div className="space-y-3">
{/* MCP Servers */}
<div className="bg-neutral-800 p-4 rounded-md border border-neutral-700">
<h3 className="text-sm font-medium text-emerald-400 mb-2">
MCP Servers
</h3>
{getMCPServersCount() > 0 ? (
<div className="space-y-2">
{agent.config?.mcp_servers &&
agent.config.mcp_servers.map((mcp, index) => (
<div
key={index}
className="bg-neutral-900 p-2 rounded-md border border-neutral-700 flex items-center"
>
<Server className="h-3.5 w-3.5 mr-2 text-emerald-400" />
<span className="text-sm text-neutral-300">
{mcp.id}
</span>
</div>
))}
{agent.config?.custom_mcp_servers &&
agent.config.custom_mcp_servers.map((mcp, index) => (
<div
key={index}
className="bg-neutral-900 p-2 rounded-md border border-neutral-700 flex items-center"
>
<Server className="h-3.5 w-3.5 mr-2 text-yellow-400" />
<span className="text-sm text-neutral-300">
{mcp.url}
</span>
</div>
))}
</div>
) : (
<p className="text-neutral-400 text-sm">
No MCP servers configured
</p>
)}
</div>
{/* API Key */}
<div className="bg-neutral-800 p-4 rounded-md border border-neutral-700">
<h3 className="text-sm font-medium text-emerald-400 mb-2">
API Key
</h3>
<p className="text-neutral-300 text-sm">
{agent.api_key_id
? `Key ID: ${agent.api_key_id}`
: "No API key configured"}
</p>
</div>
</div>
</TabsContent>
</ScrollArea>
</Tabs>
<DialogFooter className="pt-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="bg-neutral-800 hover:bg-neutral-700 border-neutral-700 text-neutral-300"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Agent Edit Form Dialog */}
{isAgentFormOpen && agent && (
<AgentForm
open={isAgentFormOpen}
onOpenChange={setIsAgentFormOpen}
initialValues={agent}
apiKeys={apiKeys}
availableModels={availableModels}
availableMCPs={availableMCPs}
agents={agents}
onOpenApiKeysDialog={() => {}}
onOpenMCPDialog={() => {}}
onOpenCustomMCPDialog={() => {}}
onSave={handleSaveAgent}
isLoading={isLoading}
getAgentNameById={(id) => id}
clientId={clientId}
/>
)}
</>
);
}

View File

@@ -0,0 +1,158 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/chat/components/AttachedFiles.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: August 24, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import React from "react";
import { formatFileSize, isImageFile } from "@/lib/file-utils";
import { File, FileText, Download, Image } from "lucide-react";
import { toast } from "@/hooks/use-toast";
interface AttachedFile {
filename: string;
content_type: string;
data?: string;
size?: number;
preview_url?: string;
}
interface AttachedFilesProps {
files: AttachedFile[];
className?: string;
}
export function AttachedFiles({ files, className = "" }: AttachedFilesProps) {
if (!files || files.length === 0) return null;
const downloadFile = (file: AttachedFile) => {
if (!file.data) {
toast({
title: "File without data for download",
description: file.filename,
});
return;
}
try {
const link = document.createElement("a");
const dataUrl = file.data.startsWith("data:")
? file.data
: `data:${file.content_type};base64,${file.data}`;
link.href = dataUrl;
link.download = file.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
toast({
title: "Error downloading file",
description: file.filename,
});
}
};
return (
<div className={`flex flex-col gap-2 mt-2 ${className}`}>
<div className="text-xs text-neutral-400 mb-1">Attached files:</div>
<div className="flex flex-wrap gap-2">
{files
.map((file, index) => {
if (!file.data) {
toast({
title: "File without data for display",
description: file.filename,
});
return null;
}
return (
<div
key={index}
className="flex flex-col bg-[#333] rounded-md overflow-hidden border border-[#444] hover:border-[#666] transition-colors"
>
{isImageFile(file.content_type) && file.data && (
<div className="w-full max-w-[200px] h-[120px] bg-black flex items-center justify-center">
<img
src={
file.preview_url ||
(file.data.startsWith("data:")
? file.data
: `data:${file.content_type};base64,${file.data}`)
}
alt={file.filename}
className="max-w-full max-h-full object-contain"
onError={(e) => {
toast({
title: "Error loading image",
description: file.filename,
});
(e.target as HTMLImageElement).src =
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZjY2NjYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0ibHVjaWRlIGx1Y2lkZS1pbWFnZS1vZmYiPjxsaW5lIHgxPSIyIiB5MT0iMiIgeDI9IjIyIiB5Mj0iMjIiLz48PHJlY3QgdyA";
}}
/>
</div>
)}
<div className="p-2 flex items-center gap-2">
<div className="flex-shrink-0">
{isImageFile(file.content_type) ? (
<Image className="h-4 w-4 text-emerald-400" />
) : file.content_type === "application/pdf" ? (
<FileText className="h-4 w-4 text-emerald-400" />
) : (
<File className="h-4 w-4 text-emerald-400" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate max-w-[150px]">
{file.filename}
</div>
{file.size && (
<div className="text-[10px] text-neutral-400">
{formatFileSize(file.size)}
</div>
)}
</div>
{file.data && (
<button
onClick={() => downloadFile(file)}
className="text-emerald-400 hover:text-white transition-colors"
title="Download"
>
<Download className="h-4 w-4" />
</button>
)}
</div>
</div>
);
})
.filter(Boolean)}
</div>
</div>
);
}

View File

@@ -0,0 +1,190 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/chat/components/ChatContainer.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 14, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import React, { useRef, useEffect, useState } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { MessageSquare, Loader2, Bot, Zap } from "lucide-react";
import { ChatMessage } from "./ChatMessage";
import { ChatInput } from "./ChatInput";
import { ChatMessage as ChatMessageType } from "@/services/sessionService";
import { cn } from "@/lib/utils";
interface FunctionMessageContent {
title: string;
content: string;
author?: string;
}
interface ChatContainerProps {
messages: ChatMessageType[];
isLoading: boolean;
onSendMessage: (message: string) => void;
agentColor: string;
expandedFunctions: Record<string, boolean>;
toggleFunctionExpansion: (messageId: string) => void;
containsMarkdown: (text: string) => boolean;
getMessageText: (message: ChatMessageType) => string | FunctionMessageContent;
agentName?: string;
containerClassName?: string;
messagesContainerClassName?: string;
inputContainerClassName?: string;
sessionId?: string;
}
export function ChatContainer({
messages,
isLoading,
onSendMessage,
agentColor,
expandedFunctions,
toggleFunctionExpansion,
containsMarkdown,
getMessageText,
agentName = "Agent",
containerClassName = "",
messagesContainerClassName = "",
inputContainerClassName = "",
sessionId,
}: ChatContainerProps) {
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [isInitializing, setIsInitializing] = useState(false);
const scrollToBottom = () => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
}
};
useEffect(() => {
if (messages.length > 0) {
setTimeout(scrollToBottom, 100);
}
}, [messages]);
// Simulate initial loading for smoother UX
useEffect(() => {
if (sessionId) {
setIsInitializing(true);
const timer = setTimeout(() => {
setIsInitializing(false);
}, 1000);
return () => clearTimeout(timer);
}
}, [sessionId]);
const isEmpty = messages.length === 0;
return (
<div className={cn(
"flex-1 flex flex-col overflow-hidden bg-gradient-to-b from-neutral-900 to-neutral-950",
containerClassName
)}>
<div
className={cn(
"flex-1 overflow-hidden p-5",
messagesContainerClassName
)}
style={{ filter: isLoading && !isInitializing ? "blur(1px)" : "none" }}
>
<ScrollArea
ref={messagesContainerRef}
className="h-full pr-4"
>
{isInitializing ? (
<div className="flex flex-col items-center justify-center h-full">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-emerald-500 to-emerald-700 flex items-center justify-center shadow-lg mb-4 animate-pulse">
<Zap className="h-5 w-5 text-white" />
</div>
<p className="text-neutral-400 mb-2">Loading conversation...</p>
<div className="flex items-center space-x-2">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-bounce"
style={{ animationDelay: '0ms' }}></span>
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-bounce"
style={{ animationDelay: '150ms' }}></span>
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-bounce"
style={{ animationDelay: '300ms' }}></span>
</div>
</div>
) : isEmpty ? (
<div className="h-full flex flex-col items-center justify-center text-center p-6">
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-blue-500/20 to-emerald-500/20 flex items-center justify-center shadow-lg mb-5 border border-emerald-500/30">
<MessageSquare className="h-6 w-6 text-emerald-400" />
</div>
<h3 className="text-lg font-medium text-neutral-300 mb-2">
{`Chat with ${agentName}`}
</h3>
<p className="text-neutral-500 text-sm max-w-md">
Type your message below to start the conversation. This chat will help you interact with the agent and explore its capabilities.
</p>
</div>
) : (
<div className="space-y-6 py-4 flex-1">
{messages.map((message) => {
const messageContent = getMessageText(message);
const isExpanded = expandedFunctions[message.id] || false;
return (
<ChatMessage
key={message.id}
message={message}
agentColor={agentColor}
isExpanded={isExpanded}
toggleExpansion={toggleFunctionExpansion}
containsMarkdown={containsMarkdown}
messageContent={messageContent}
sessionId={sessionId}
/>
);
})}
</div>
)}
</ScrollArea>
</div>
<div className={cn(
"p-3 border-t border-neutral-800 bg-neutral-900",
inputContainerClassName
)}>
{isLoading && !isInitializing && (
<div className="px-4 py-2 mb-3 rounded-lg bg-neutral-800/50 text-sm text-neutral-400 flex items-center">
<Loader2 className="h-3 w-3 mr-2 animate-spin text-emerald-400" />
Agent is thinking...
</div>
)}
<ChatInput
onSendMessage={onSendMessage}
isLoading={isLoading}
placeholder="Type your message..."
autoFocus={true}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,270 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/chat/components/ChatInput.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 14, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import React, { useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Loader2, Send, Paperclip, X, Image, FileText, File } from "lucide-react";
import { FileData, formatFileSize, isImageFile } from "@/lib/file-utils";
import { toast } from "@/hooks/use-toast";
interface ChatInputProps {
onSendMessage: (message: string, files?: FileData[]) => void;
isLoading?: boolean;
placeholder?: string;
className?: string;
buttonClassName?: string;
containerClassName?: string;
autoFocus?: boolean;
}
export function ChatInput({
onSendMessage,
isLoading = false,
placeholder = "Type your message...",
className = "",
buttonClassName = "",
containerClassName = "",
autoFocus = true,
}: ChatInputProps) {
const [messageInput, setMessageInput] = useState("");
const [selectedFiles, setSelectedFiles] = useState<FileData[]>([]);
const [resetFileUpload, setResetFileUpload] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Autofocus the textarea when the component is mounted
React.useEffect(() => {
// Small timeout to ensure focus is applied after the complete rendering
if (autoFocus) {
const timer = setTimeout(() => {
if (textareaRef.current && !isLoading) {
textareaRef.current.focus();
}
}, 100);
return () => clearTimeout(timer);
}
}, [isLoading, autoFocus]);
const handleSendMessage = (e: React.FormEvent) => {
e.preventDefault();
if (!messageInput.trim() && selectedFiles.length === 0) return;
onSendMessage(messageInput, selectedFiles.length > 0 ? selectedFiles : undefined);
setMessageInput("");
setSelectedFiles([]);
setResetFileUpload(true);
setTimeout(() => {
setResetFileUpload(false);
// Keep the focus on the textarea after sending the message
if (autoFocus && textareaRef.current) {
textareaRef.current.focus();
}
}, 100);
const textarea = document.querySelector("textarea");
if (textarea) textarea.style.height = "auto";
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage(e as unknown as React.FormEvent);
}
};
const autoResizeTextarea = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const textarea = e.target;
textarea.style.height = "auto";
const maxHeight = 10 * 24;
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
textarea.style.height = `${newHeight}px`;
setMessageInput(textarea.value);
};
const handleFilesSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || e.target.files.length === 0) return;
const newFiles = Array.from(e.target.files);
const maxFileSize = 10 * 1024 * 1024; // 10MB
if (selectedFiles.length + newFiles.length > 5) {
toast({
title: `You can only attach up to 5 files.`,
variant: "destructive",
});
return;
}
const validFiles: FileData[] = [];
for (const file of newFiles) {
if (file.size > maxFileSize) {
toast({
title: `File ${file.name} exceeds the maximum size of ${formatFileSize(maxFileSize)}.`,
variant: "destructive",
});
continue;
}
try {
const reader = new FileReader();
const readFile = new Promise<string>((resolve, reject) => {
reader.onload = () => {
const base64 = reader.result as string;
const base64Data = base64.split(',')[1];
resolve(base64Data);
};
reader.onerror = reject;
});
reader.readAsDataURL(file);
const base64Data = await readFile;
const previewUrl = URL.createObjectURL(file);
validFiles.push({
filename: file.name,
content_type: file.type,
data: base64Data,
size: file.size,
preview_url: previewUrl
});
} catch (error) {
console.error("Error processing file:", error);
toast({
title: `Error processing file ${file.name}`,
variant: "destructive",
});
}
}
if (validFiles.length > 0) {
const updatedFiles = [...selectedFiles, ...validFiles];
setSelectedFiles(updatedFiles);
}
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const openFileSelector = () => {
fileInputRef.current?.click();
};
return (
<div className={`flex flex-col w-full ${containerClassName}`}>
{selectedFiles.length > 0 && (
<div className="flex flex-wrap gap-2 px-2 mb-3 mt-1">
{selectedFiles.map((file, index) => (
<div
key={index}
className="flex items-center gap-1.5 bg-gradient-to-br from-neutral-800 to-neutral-900 text-white rounded-lg p-2 text-xs border border-neutral-700/50 shadow-sm"
>
{isImageFile(file.content_type) ? (
<Image className="h-4 w-4 text-emerald-400" />
) : file.content_type === 'application/pdf' ? (
<FileText className="h-4 w-4 text-emerald-400" />
) : (
<File className="h-4 w-4 text-emerald-400" />
)}
<span className="max-w-[120px] truncate">{file.filename}</span>
<span className="text-neutral-400">({formatFileSize(file.size)})</span>
<button
onClick={() => {
const updatedFiles = selectedFiles.filter((_, i) => i !== index);
setSelectedFiles(updatedFiles);
}}
className="ml-1 text-neutral-400 hover:text-white transition-colors bg-neutral-700/30 rounded-full p-1"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
<form
onSubmit={handleSendMessage}
className="flex w-full items-center gap-2 px-2"
>
{selectedFiles.length < 5 && (
<button
onClick={(e) => {
e.preventDefault();
openFileSelector();
}}
type="button"
className="flex items-center justify-center w-9 h-9 rounded-full hover:bg-neutral-800/60 text-neutral-400 hover:text-emerald-400 transition-all border border-neutral-700/30"
title="Attach file"
>
<Paperclip className="h-4 w-4" />
</button>
)}
<Textarea
value={messageInput}
onChange={autoResizeTextarea}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className={`flex-1 bg-neutral-800/40 border-neutral-700/50 text-white focus-visible:ring-emerald-500/50 focus-visible:border-emerald-500/50 min-h-[40px] max-h-[240px] resize-none rounded-xl ${className}`}
disabled={isLoading}
rows={1}
ref={textareaRef}
/>
<Button
type="submit"
disabled={isLoading || (!messageInput.trim() && selectedFiles.length === 0)}
className={`bg-gradient-to-r from-emerald-500 to-emerald-600 text-white hover:from-emerald-600 hover:to-emerald-700 rounded-full shadow-md h-9 w-9 p-0 ${buttonClassName}`}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
<input
type="file"
ref={fileInputRef}
onChange={handleFilesSelected}
className="hidden"
multiple
/>
</form>
</div>
);
}

View File

@@ -0,0 +1,375 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/chat/components/ChatMessage.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ChatMessage as ChatMessageType } from "@/services/sessionService";
import { ChevronDown, ChevronRight, Copy, Check, User, Bot, Terminal } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useState } from "react";
import { InlineDataAttachments } from "./InlineDataAttachments";
import { cn } from "@/lib/utils";
interface FunctionMessageContent {
title: string;
content: string;
author?: string;
}
interface AttachedFile {
filename: string;
content_type: string;
data: string;
size: number;
preview_url?: string;
}
interface ChatMessageProps {
message: ChatMessageType;
agentColor: string;
isExpanded: boolean;
toggleExpansion: (messageId: string) => void;
containsMarkdown: (text: string) => boolean;
messageContent: string | FunctionMessageContent;
sessionId?: string;
}
export function ChatMessage({
message,
agentColor,
isExpanded,
toggleExpansion,
containsMarkdown,
messageContent,
sessionId,
}: ChatMessageProps) {
const [isCopied, setIsCopied] = useState(false);
const isUser = message.author === "user";
const hasFunctionCall = message.content.parts.some(
(part) => part.functionCall || part.function_call
);
const hasFunctionResponse = message.content.parts.some(
(part) => part.functionResponse || part.function_response
);
const isFunctionMessage = hasFunctionCall || hasFunctionResponse;
const isTaskExecutor = typeof messageContent === "object" &&
"author" in messageContent &&
typeof messageContent.author === "string" &&
messageContent.author.endsWith("- Task executor");
const inlineDataParts = message.content.parts.filter(part => part.inline_data);
const hasInlineData = inlineDataParts.length > 0;
const copyToClipboard = () => {
const textToCopy = typeof messageContent === "string"
? messageContent
: messageContent.content;
navigator.clipboard.writeText(textToCopy).then(() => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
});
};
// Generate appropriate avatar content
const getAvatar = () => {
if (isUser) {
return (
<Avatar className="bg-gradient-to-br from-emerald-500 to-emerald-700 shadow-md border-0">
<AvatarFallback className="bg-transparent">
<User className="h-4 w-4 text-white" />
</AvatarFallback>
</Avatar>
);
} else {
return (
<Avatar className={`shadow-md border-0 ${
isFunctionMessage
? "bg-gradient-to-br from-emerald-600 to-emerald-800"
: "bg-gradient-to-br from-purple-600 to-purple-800"
}`}>
<AvatarFallback className="bg-transparent">
{isFunctionMessage ?
<Terminal className="h-4 w-4 text-white" /> :
<Bot className="h-4 w-4 text-white" />
}
</AvatarFallback>
</Avatar>
);
}
};
return (
<div
key={message.id}
className="flex w-full"
style={{
justifyContent: isUser ? "flex-end" : "flex-start"
}}
>
<div
className="flex gap-3 max-w-[90%]"
style={{
flexDirection: isUser ? "row-reverse" : "row"
}}
>
{getAvatar()}
<div
className={`rounded-lg p-3 overflow-hidden relative group shadow-md ${
isFunctionMessage || isTaskExecutor
? "bg-gradient-to-br from-neutral-800 to-neutral-900 border border-neutral-700/50 text-emerald-300 font-mono text-sm"
: isUser
? "bg-emerald-500 text-white"
: "bg-gradient-to-br from-neutral-800 to-neutral-900 border border-neutral-700/50 text-white"
}`}
style={{
wordBreak: "break-word",
maxWidth: "calc(100% - 3rem)",
width: "100%"
}}
>
{isFunctionMessage || isTaskExecutor ? (
<div className="w-full">
<div
className="flex items-center gap-2 cursor-pointer hover:bg-[#444] rounded px-1 py-0.5 transition-colors"
onClick={() => toggleExpansion(message.id)}
>
{typeof messageContent === "object" &&
"title" in messageContent && (
<>
<div className="flex-1 font-semibold">
{(messageContent as FunctionMessageContent).title}
</div>
<div className="flex items-center justify-center w-5 h-5 text-emerald-400">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</>
)}
{isTaskExecutor && (
<>
<div className="flex-1 font-semibold">
Task Execution
</div>
<div className="flex items-center justify-center w-5 h-5 text-emerald-400">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</>
)}
</div>
{isExpanded && (
<div className="mt-2 pt-2 border-t border-[#555]">
{typeof messageContent === "object" &&
"content" in messageContent && (
<div className="max-w-full overflow-x-auto">
<pre className="whitespace-pre-wrap text-xs max-w-full" style={{
wordWrap: "break-word",
maxWidth: "100%",
wordBreak: "break-all"
}}>
{(messageContent as FunctionMessageContent).content}
</pre>
</div>
)}
</div>
)}
</div>
) : (
<div className="markdown-content break-words max-w-full overflow-x-auto">
{typeof messageContent === "object" &&
"author" in messageContent &&
messageContent.author !== "user" &&
!isTaskExecutor && (
<div className="text-xs text-neutral-400 mb-1">
{messageContent.author}
</div>
)}
{((typeof messageContent === "string" &&
containsMarkdown(messageContent)) ||
(typeof messageContent === "object" &&
"content" in messageContent &&
typeof messageContent.content === "string" &&
containsMarkdown(messageContent.content))) &&
!isTaskExecutor ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ ...props }) => (
<h1 className="text-xl font-bold my-4" {...props} />
),
h2: ({ ...props }) => (
<h2 className="text-lg font-bold my-3" {...props} />
),
h3: ({ ...props }) => (
<h3 className="text-base font-bold my-2" {...props} />
),
h4: ({ ...props }) => (
<h4 className="font-semibold my-2" {...props} />
),
p: ({ ...props }) => <p className="mb-3" {...props} />,
ul: ({ ...props }) => (
<ul
className="list-disc pl-6 mb-3 space-y-1"
{...props}
/>
),
ol: ({ ...props }) => (
<ol
className="list-decimal pl-6 mb-3 space-y-1"
{...props}
/>
),
li: ({ ...props }) => <li className="mb-1" {...props} />,
a: ({ ...props }) => (
<a
className="text-emerald-300 underline hover:opacity-80 transition-opacity"
target="_blank"
rel="noopener noreferrer"
{...props}
/>
),
blockquote: ({ ...props }) => (
<blockquote
className="border-l-4 border-[#444] pl-4 py-1 italic my-3 text-neutral-300"
{...props}
/>
),
code: ({ className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || "");
const isInline =
!match &&
typeof children === "string" &&
!children.includes("\n");
if (isInline) {
return (
<code
className="bg-[#333] px-1.5 py-0.5 rounded text-emerald-300 text-sm font-mono"
{...props}
>
{children}
</code>
);
}
return (
<div className="my-3 relative group/code">
<div className="bg-[#1a1a1a] rounded-t-md border-b border-[#333] p-2 text-xs text-neutral-400 flex justify-between items-center">
<span>{match?.[1] || "Code"}</span>
<button
onClick={copyToClipboard}
className="text-neutral-400 hover:text-emerald-300 transition-colors"
title="Copy code"
>
{isCopied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
</div>
<pre className="bg-[#1a1a1a] p-3 rounded-b-md overflow-x-auto whitespace-pre text-sm">
<code {...props}>{children}</code>
</pre>
</div>
);
},
table: ({ ...props }) => (
<div className="overflow-x-auto my-3">
<table
className="min-w-full border border-[#333] rounded"
{...props}
/>
</div>
),
thead: ({ ...props }) => (
<thead className="bg-[#1a1a1a]" {...props} />
),
tbody: ({ ...props }) => <tbody {...props} />,
tr: ({ ...props }) => (
<tr
className="border-b border-[#333] last:border-0"
{...props}
/>
),
th: ({ ...props }) => (
<th
className="px-4 py-2 text-left text-xs font-semibold text-neutral-300"
{...props}
/>
),
td: ({ ...props }) => (
<td className="px-4 py-2 text-sm" {...props} />
),
}}
>
{typeof messageContent === "string"
? messageContent
: messageContent.content}
</ReactMarkdown>
) : (
<div className="whitespace-pre-wrap">
{typeof messageContent === "string"
? messageContent
: messageContent.content}
</div>
)}
{hasInlineData && (
<InlineDataAttachments parts={inlineDataParts} sessionId={sessionId} />
)}
</div>
)}
<button
onClick={copyToClipboard}
className="absolute top-2 right-2 p-1.5 rounded-full bg-neutral-800/80 text-neutral-400 hover:text-white opacity-0 group-hover:opacity-100 transition-all hover:bg-neutral-700/80"
title="Copy message"
>
{isCopied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,185 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/chat/components/FileUpload.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: August 24, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import React, { useState, useRef, useEffect } from "react";
import { FileData, formatFileSize, isImageFile } from "@/lib/file-utils";
import { Paperclip, X, Image, File, FileText } from "lucide-react";
import { toast } from "@/hooks/use-toast";
interface FileUploadProps {
onFilesSelected: (files: FileData[]) => void;
maxFileSize?: number;
maxFiles?: number;
className?: string;
reset?: boolean;
}
export function FileUpload({
onFilesSelected,
maxFileSize = 10 * 1024 * 1024, // 10MB
maxFiles = 5,
className = "",
reset = false, // Default false
}: FileUploadProps) {
const [selectedFiles, setSelectedFiles] = useState<FileData[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (reset && selectedFiles.length > 0) {
setSelectedFiles([]);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
}, [reset]);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || e.target.files.length === 0) return;
const newFiles = Array.from(e.target.files);
if (selectedFiles.length + newFiles.length > maxFiles) {
toast({
title: `You can only attach up to ${maxFiles} files.`,
variant: "destructive",
});
return;
}
const validFiles: FileData[] = [];
for (const file of newFiles) {
if (file.size > maxFileSize) {
toast({
title: `File ${file.name} exceeds the maximum size of ${formatFileSize(maxFileSize)}.`,
variant: "destructive",
});
continue;
}
try {
const reader = new FileReader();
const readFile = new Promise<string>((resolve, reject) => {
reader.onload = () => {
const base64 = reader.result as string;
const base64Data = base64.split(',')[1];
resolve(base64Data);
};
reader.onerror = reject;
});
reader.readAsDataURL(file);
const base64Data = await readFile;
const previewUrl = URL.createObjectURL(file);
validFiles.push({
filename: file.name,
content_type: file.type,
data: base64Data,
size: file.size,
preview_url: previewUrl
});
} catch (error) {
console.error("Error processing file:", error);
toast({
title: `Error processing file ${file.name}`,
variant: "destructive",
});
}
}
if (validFiles.length > 0) {
const updatedFiles = [...selectedFiles, ...validFiles];
setSelectedFiles(updatedFiles);
onFilesSelected(updatedFiles);
}
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const removeFile = (index: number) => {
const updatedFiles = selectedFiles.filter((_, i) => i !== index);
setSelectedFiles(updatedFiles);
onFilesSelected(updatedFiles);
};
return (
<div className={`flex gap-2 items-center ${className}`}>
{selectedFiles.length > 0 && (
<div className="flex gap-2 flex-wrap items-center flex-1">
{selectedFiles.map((file, index) => (
<div
key={index}
className="flex items-center gap-1 bg-[#333] text-white rounded-md p-1.5 text-xs group relative"
>
{isImageFile(file.content_type) ? (
<Image className="h-4 w-4 text-emerald-400" />
) : file.content_type === 'application/pdf' ? (
<FileText className="h-4 w-4 text-emerald-400" />
) : (
<File className="h-4 w-4 text-emerald-400" />
)}
<span className="max-w-[120px] truncate">{file.filename}</span>
<span className="text-neutral-400">({formatFileSize(file.size)})</span>
<button
onClick={() => removeFile(index)}
className="ml-1 text-neutral-400 hover:text-white transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
{selectedFiles.length < maxFiles && (
<button
onClick={() => fileInputRef.current?.click()}
type="button"
className="flex items-center justify-center w-8 h-8 rounded-full hover:bg-[#333] text-neutral-400 hover:text-emerald-400 transition-colors"
title="Attach file"
>
<Paperclip className="h-5 w-5" />
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
multiple
/>
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,182 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/chat/components/InlineDataAttachments.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: August 29, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import React, { useState, useEffect } from "react";
import { formatFileSize, isImageFile } from "@/lib/file-utils";
import { File, FileText, Download, Image } from "lucide-react";
import { ChatPart } from "@/services/sessionService";
interface InlineDataAttachmentsProps {
parts: ChatPart[];
className?: string;
sessionId?: string;
}
interface ProcessedFile {
filename: string;
content_type: string;
data: string;
size: number;
preview_url?: string;
}
export function InlineDataAttachments({ parts, className = "", sessionId }: InlineDataAttachmentsProps) {
const [processedFiles, setProcessedFiles] = useState<ProcessedFile[]>([]);
const [isProcessed, setIsProcessed] = useState(false);
useEffect(() => {
if (isProcessed) return;
const validParts = parts.filter(part => part.inline_data && part.inline_data.data);
if (validParts.length === 0) {
setIsProcessed(true);
return;
}
const files = validParts.map((part, index) => {
const { mime_type, data } = part.inline_data!;
const extension = mime_type.split('/')[1] || 'file';
let filename = '';
if (part.inline_data?.metadata?.filename) {
filename = part.inline_data.metadata.filename;
}
else if (part.file_data?.filename) {
filename = part.file_data.filename;
}
else {
filename = `media_${index + 1}.${extension}`;
}
let preview_url = undefined;
if (data && isImageFile(mime_type)) {
preview_url = data.startsWith('data:')
? data
: `data:${mime_type};base64,${data}`;
}
const fileData: ProcessedFile = {
filename,
content_type: mime_type,
size: data.length,
data,
preview_url
};
return fileData;
});
setProcessedFiles(files);
setIsProcessed(true);
}, [parts, isProcessed]);
if (processedFiles.length === 0) return null;
const downloadFile = (file: ProcessedFile) => {
try {
const link = document.createElement("a");
const dataUrl = file.data.startsWith('data:')
? file.data
: `data:${file.content_type};base64,${file.data}`;
link.href = dataUrl;
link.download = file.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error(`Error downloading file ${file.filename}:`, error);
}
};
const getFileUrl = (file: ProcessedFile) => {
return file.preview_url || (file.data.startsWith('data:')
? file.data
: `data:${file.content_type};base64,${file.data}`);
};
return (
<div className={`flex flex-col gap-2 mt-2 ${className}`}>
<div className="text-xs text-neutral-400 mb-1">
<span>Attached files:</span>
</div>
<div className="flex flex-wrap gap-2">
{processedFiles.map((file, index) => (
<div
key={index}
className="flex flex-col bg-[#333] rounded-md overflow-hidden border border-[#444] hover:border-[#666] transition-colors"
>
{isImageFile(file.content_type) && (
<div className="w-full max-w-[200px] h-[120px] bg-black flex items-center justify-center">
<img
src={getFileUrl(file)}
alt={file.filename}
className="max-w-full max-h-full object-contain"
onError={(e) => {
console.error(`Error loading image ${file.filename}`);
(e.target as HTMLImageElement).src = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZjY2NjYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0ibHVjaWRlIGx1Y2lkZS1pbWFnZS1vZmYiPjxsaW5lIHgxPSIyIiB5MT0iMiIgeDI9IjIyIiB5Mj0iMjIiLz48cmVjdCB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHg9IjIiIHk9IjIiIHJ4PSIyIiByeT0iMiIvPjxsaW5lIHgxPSI4IiB5MT0iMTAiIHgyPSI4IiB5Mj0iMTAiLz48bGluZSB4MT0iMTIiIHkxPSIxNCIgeDI9IjEyIiB5Mj0iMTQiLz48L3N2Zz4=";
}}
/>
</div>
)}
<div className="p-2 flex items-center gap-2">
<div className="flex-shrink-0">
{isImageFile(file.content_type) ? (
<Image className="h-4 w-4 text-emerald-400" />
) : file.content_type === "application/pdf" ? (
<FileText className="h-4 w-4 text-emerald-400" />
) : (
<File className="h-4 w-4 text-emerald-400" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate max-w-[150px]">
{file.filename}
</div>
<div className="text-[10px] text-neutral-400">
{formatFileSize(file.size)}
</div>
</div>
<button
onClick={() => downloadFile(file)}
className="text-emerald-400 hover:text-white transition-colors"
title="Download"
>
<Download className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,248 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/chat/components/SessionList.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Search, Filter, Plus, Loader2 } from "lucide-react";
import { ChatSession } from "@/services/sessionService";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface SessionListProps {
sessions: ChatSession[];
agents: any[];
selectedSession: string | null;
isLoading: boolean;
searchTerm: string;
selectedAgentFilter: string;
showAgentFilter: boolean;
setSearchTerm: (value: string) => void;
setSelectedAgentFilter: (value: string) => void;
setShowAgentFilter: (value: boolean) => void;
setSelectedSession: (value: string | null) => void;
setIsNewChatDialogOpen: (value: boolean) => void;
}
export function SessionList({
sessions,
agents,
selectedSession,
isLoading,
searchTerm,
selectedAgentFilter,
showAgentFilter,
setSearchTerm,
setSelectedAgentFilter,
setShowAgentFilter,
setSelectedSession,
setIsNewChatDialogOpen,
}: SessionListProps) {
const filteredSessions = sessions.filter((session) => {
const matchesSearchTerm = session.id
.toLowerCase()
.includes(searchTerm.toLowerCase());
if (selectedAgentFilter === "all") {
return matchesSearchTerm;
}
const sessionAgentId = session.id.split("_")[1];
return matchesSearchTerm && sessionAgentId === selectedAgentFilter;
});
const sortedSessions = [...filteredSessions].sort((a, b) => {
const updateTimeA = new Date(a.update_time).getTime();
const updateTimeB = new Date(b.update_time).getTime();
return updateTimeB - updateTimeA;
});
const formatDateTime = (dateTimeStr: string) => {
try {
const date = new Date(dateTimeStr);
const day = date.getDate().toString().padStart(2, "0");
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${day}/${month}/${year} ${hours}:${minutes}`;
} catch (error) {
return "Invalid date";
}
};
const getExternalId = (sessionId: string) => {
return sessionId.split("_")[0];
};
return (
<div className="w-64 border-r border-neutral-700 flex flex-col bg-neutral-900">
<div className="p-4 border-b border-neutral-700">
<div className="flex items-center justify-between mb-4">
<Button
onClick={() => setIsNewChatDialogOpen(true)}
className="bg-emerald-800 text-emerald-100 hover:bg-emerald-700 border-emerald-700"
size="sm"
>
<Plus className="h-4 w-4 mr-1" /> New Conversation
</Button>
</div>
<div className="space-y-2">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-neutral-500" />
<Input
placeholder="Search conversations..."
className="pl-9 bg-neutral-800 border-neutral-700 text-neutral-200 focus-visible:ring-emerald-500"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
className="text-neutral-400 hover:text-white hover:bg-neutral-800"
onClick={() => setShowAgentFilter(!showAgentFilter)}
>
<Filter className="h-4 w-4 mr-1" />
Filter
</Button>
{selectedAgentFilter !== "all" && (
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedAgentFilter("all")}
className="text-neutral-400 hover:text-white hover:bg-neutral-800"
>
Clear filter
</Button>
)}
</div>
{showAgentFilter && (
<div className="pt-1">
<Select
value={selectedAgentFilter}
onValueChange={setSelectedAgentFilter}
>
<SelectTrigger className="bg-neutral-800 border-neutral-700 text-neutral-200">
<SelectValue placeholder="Filter by agent" />
</SelectTrigger>
<SelectContent className="bg-neutral-900 border-neutral-700 text-white">
<SelectItem
value="all"
className="data-[selected]:bg-neutral-800 data-[highlighted]:bg-neutral-800 !text-white hover:text-emerald-400 data-[selected]:!text-emerald-400"
>
All agents
</SelectItem>
{agents.map((agent) => (
<SelectItem
key={agent.id}
value={agent.id}
className="data-[selected]:bg-neutral-800 data-[highlighted]:bg-neutral-800 !text-white hover:text-emerald-400 data-[selected]:!text-emerald-400"
>
{agent.name.slice(0, 15)}{" "}
{agent.name.length > 15 && "..."}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
{isLoading ? (
<div className="flex justify-center items-center h-24">
<Loader2 className="h-5 w-5 text-emerald-400 animate-spin" />
</div>
) : sortedSessions.length > 0 ? (
<div className="px-4 pt-2 space-y-2">
{sortedSessions.map((session) => {
const agentId = session.id.split("_")[1];
const agentInfo = agents.find((a) => a.id === agentId);
const externalId = getExternalId(session.id);
return (
<div
key={session.id}
className={`p-3 rounded-md cursor-pointer transition-colors group relative ${
selectedSession === session.id
? "bg-emerald-800/20 border border-emerald-600/40"
: "bg-neutral-800 hover:bg-neutral-700 border border-transparent"
}`}
onClick={() => setSelectedSession(session.id)}
>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-emerald-500 mr-2"></div>
<div className="text-neutral-200 font-medium truncate max-w-[180px]">
{externalId}
</div>
</div>
<div className="mt-1 flex items-center gap-2">
{agentInfo && (
<Badge className="bg-neutral-700 text-emerald-400 border-neutral-600 text-xs">
{agentInfo.name.slice(0, 15)}
{agentInfo.name.length > 15 && "..."}
</Badge>
)}
<div className="text-xs text-neutral-500 ml-auto">
{formatDateTime(session.update_time)}
</div>
</div>
</div>
);
})}
</div>
) : searchTerm || selectedAgentFilter !== "all" ? (
<div className="text-center py-4 text-neutral-400">
No results found
</div>
) : (
<div className="text-center py-4 text-neutral-400">
Click "New" to start
</div>
)}
</div>
</div>
);
}

881
frontend/app/chat/page.tsx Normal file
View File

@@ -0,0 +1,881 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/chat/page.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
MessageSquare,
Send,
Plus,
Search,
Loader2,
X,
Trash2,
Bot,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/hooks/use-toast";
import {
Dialog,
DialogContent,
DialogTitle,
DialogHeader,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { listAgents } from "@/services/agentService";
import {
listSessions,
getSessionMessages,
ChatMessage,
deleteSession,
ChatSession,
ChatPart
} from "@/services/sessionService";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useAgentWebSocket } from "@/hooks/use-agent-webSocket";
import { getAccessTokenFromCookie } from "@/lib/utils";
import { ChatMessage as ChatMessageComponent } from "./components/ChatMessage";
import { SessionList } from "./components/SessionList";
import { ChatInput } from "./components/ChatInput";
import { FileData } from "@/lib/file-utils";
import { AgentInfoDialog } from "./components/AgentInfoDialog";
interface FunctionMessageContent {
title: string;
content: string;
author?: string;
}
export default function Chat() {
const [isLoading, setIsLoading] = useState(true);
const [agents, setAgents] = useState<any[]>([]);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [agentSearchTerm, setAgentSearchTerm] = useState("");
const [selectedAgentFilter, setSelectedAgentFilter] = useState<string>("all");
const [messageInput, setMessageInput] = useState("");
const [selectedSession, setSelectedSession] = useState<string | null>(null);
const [currentAgentId, setCurrentAgentId] = useState<string | null>(null);
const [isSending, setIsSending] = useState(false);
const [isNewChatDialogOpen, setIsNewChatDialogOpen] = useState(false);
const [showAgentFilter, setShowAgentFilter] = useState(false);
const [expandedFunctions, setExpandedFunctions] = useState<
Record<string, boolean>
>({});
const [isAgentInfoDialogOpen, setIsAgentInfoDialogOpen] = useState(false);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const { toast } = useToast();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const user =
typeof window !== "undefined"
? JSON.parse(localStorage.getItem("user") || "{}")
: {};
const clientId = user?.client_id || "test";
const scrollToBottom = () => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop =
messagesContainerRef.current.scrollHeight;
}
};
useEffect(() => {
const loadData = async () => {
setIsLoading(true);
try {
const agentsResponse = await listAgents(clientId);
setAgents(agentsResponse.data);
const sessionsResponse = await listSessions(clientId);
setSessions(sessionsResponse.data);
} catch (error) {
console.error("Error loading data:", error);
} finally {
setIsLoading(false);
}
};
loadData();
}, [clientId]);
useEffect(() => {
if (!selectedSession) {
setMessages([]);
return;
}
const loadMessages = async () => {
try {
setIsLoading(true);
const response = await getSessionMessages(selectedSession);
setMessages(response.data);
const agentId = selectedSession.split("_")[1];
setCurrentAgentId(agentId);
setTimeout(scrollToBottom, 100);
} catch (error) {
console.error("Error loading messages:", error);
} finally {
setIsLoading(false);
}
};
loadMessages();
}, [selectedSession]);
useEffect(() => {
if (messages.length > 0) {
setTimeout(scrollToBottom, 100);
}
}, [messages]);
const filteredSessions = sessions.filter((session) => {
const matchesSearchTerm = session.id
.toLowerCase()
.includes(searchTerm.toLowerCase());
if (selectedAgentFilter === "all") {
return matchesSearchTerm;
}
const sessionAgentId = session.id.split("_")[1];
return matchesSearchTerm && sessionAgentId === selectedAgentFilter;
});
const sortedSessions = [...filteredSessions].sort((a, b) => {
const updateTimeA = new Date(a.update_time).getTime();
const updateTimeB = new Date(b.update_time).getTime();
return updateTimeB - updateTimeA;
});
const formatDateTime = (dateTimeStr: string) => {
try {
const date = new Date(dateTimeStr);
const day = date.getDate().toString().padStart(2, "0");
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${day}/${month}/${year} ${hours}:${minutes}`;
} catch (error) {
return "Invalid date";
}
};
const filteredAgents = agents.filter(
(agent) =>
agent.name.toLowerCase().includes(agentSearchTerm.toLowerCase()) ||
(agent.description &&
agent.description.toLowerCase().includes(agentSearchTerm.toLowerCase()))
);
const selectAgent = (agentId: string) => {
setCurrentAgentId(agentId);
setSelectedSession(null);
setMessages([]);
setIsNewChatDialogOpen(false);
};
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!messageInput.trim() || !currentAgentId) return;
setIsSending(true);
setMessages((prev) => [
...prev,
{
id: `temp-${Date.now()}`,
content: {
parts: [{ text: messageInput }],
role: "user",
},
author: "user",
timestamp: Date.now() / 1000,
},
]);
wsSendMessage(messageInput);
setMessageInput("");
const textarea = document.querySelector("textarea");
if (textarea) textarea.style.height = "auto";
};
const handleSendMessageWithFiles = (message: string, files?: FileData[]) => {
if ((!message.trim() && (!files || files.length === 0)) || !currentAgentId)
return;
setIsSending(true);
const messageParts: ChatPart[] = [];
if (message.trim()) {
messageParts.push({ text: message });
}
if (files && files.length > 0) {
files.forEach(file => {
messageParts.push({
inline_data: {
data: file.data,
mime_type: file.content_type,
metadata: {
filename: file.filename
}
}
});
});
}
setMessages((prev) => [
...prev,
{
id: `temp-${Date.now()}`,
content: {
parts: messageParts,
role: "user"
},
author: "user",
timestamp: Date.now() / 1000,
},
]);
wsSendMessage(message, files);
setMessageInput("");
const textarea = document.querySelector("textarea");
if (textarea) textarea.style.height = "auto";
};
const generateExternalId = () => {
const now = new Date();
return (
now.getFullYear().toString() +
(now.getMonth() + 1).toString().padStart(2, "0") +
now.getDate().toString().padStart(2, "0") +
now.getHours().toString().padStart(2, "0") +
now.getMinutes().toString().padStart(2, "0") +
now.getSeconds().toString().padStart(2, "0") +
now.getMilliseconds().toString().padStart(3, "0")
);
};
const currentAgent = agents.find((agent) => agent.id === currentAgentId);
const getCurrentSessionInfo = () => {
if (!selectedSession) return null;
const parts = selectedSession.split("_");
try {
const dateStr = parts[0];
if (dateStr.length >= 8) {
const year = dateStr.substring(0, 4);
const month = dateStr.substring(4, 6);
const day = dateStr.substring(6, 8);
return {
externalId: parts[0],
agentId: parts[1],
displayDate: `${day}/${month}/${year}`,
};
}
} catch (e) {
console.error("Error processing session ID:", e);
}
return {
externalId: parts[0],
agentId: parts[1],
displayDate: "Session",
};
};
const getExternalId = (sessionId: string) => {
return sessionId.split("_")[0];
};
const containsMarkdown = (text: string): boolean => {
if (!text || text.length < 3) return false;
const markdownPatterns = [
/[*_]{1,2}[^*_]+[*_]{1,2}/, // bold/italic
/\[[^\]]+\]\([^)]+\)/, // links
/^#{1,6}\s/m, // headers
/^[-*+]\s/m, // unordered lists
/^[0-9]+\.\s/m, // ordered lists
/^>\s/m, // block quotes
/`[^`]+`/, // inline code
/```[\s\S]*?```/, // code blocks
/^\|(.+\|)+$/m, // tables
/!\[[^\]]*\]\([^)]+\)/, // images
];
return markdownPatterns.some((pattern) => pattern.test(text));
};
const getMessageText = (
message: ChatMessage
): string | FunctionMessageContent => {
const author = message.author;
const parts = message.content.parts;
if (!parts || parts.length === 0) return "Empty content";
const functionCallPart = parts.find(
(part) => part.functionCall || part.function_call
);
const functionResponsePart = parts.find(
(part) => part.functionResponse || part.function_response
);
const inlineDataParts = parts.filter((part) => part.inline_data);
if (functionCallPart) {
const funcCall =
functionCallPart.functionCall || functionCallPart.function_call || {};
const args = funcCall.args || {};
const name = funcCall.name || "unknown";
const id = funcCall.id || "no-id";
return {
author,
title: `📞 Function call: ${name}`,
content: `ID: ${id}
Args: ${
Object.keys(args).length > 0
? `\n${JSON.stringify(args, null, 2)}`
: "{}"
}`,
} as FunctionMessageContent;
}
if (functionResponsePart) {
const funcResponse =
functionResponsePart.functionResponse ||
functionResponsePart.function_response ||
{};
const response = funcResponse.response || {};
const name = funcResponse.name || "unknown";
const id = funcResponse.id || "no-id";
const status = response.status || "unknown";
const statusEmoji = status === "error" ? "❌" : "✅";
let resultText = "";
if (status === "error") {
resultText = `Error: ${response.error_message || "Unknown error"}`;
} else if (response.report) {
resultText = `Result: ${response.report}`;
} else if (response.result && response.result.content) {
const content = response.result.content;
if (Array.isArray(content) && content.length > 0 && content[0].text) {
try {
const textContent = content[0].text;
const parsedJson = JSON.parse(textContent);
resultText = `Result: \n${JSON.stringify(parsedJson, null, 2)}`;
} catch (e) {
resultText = `Result: ${content[0].text}`;
}
} else {
resultText = `Result:\n${JSON.stringify(response, null, 2)}`;
}
} else {
resultText = `Result:\n${JSON.stringify(response, null, 2)}`;
}
return {
author,
title: `${statusEmoji} Function response: ${name}`,
content: `ID: ${id}\n${resultText}`,
} as FunctionMessageContent;
}
if (parts.length === 1 && parts[0].text) {
return {
author,
content: parts[0].text,
title: "Message",
} as FunctionMessageContent;
}
const textParts = parts
.filter((part) => part.text)
.map((part) => part.text)
.filter((text) => text);
if (textParts.length > 0) {
return {
author,
content: textParts.join("\n\n"),
title: "Message",
} as FunctionMessageContent;
}
return "Empty content";
};
const toggleFunctionExpansion = (messageId: string) => {
setExpandedFunctions((prev) => ({
...prev,
[messageId]: !prev[messageId],
}));
};
const agentColors: Record<string, string> = {
Assistant: "bg-emerald-400",
Programmer: "bg-[#00cc7d]",
Writer: "bg-[#00b8ff]",
Researcher: "bg-[#ff9d00]",
Planner: "bg-[#9d00ff]",
default: "bg-[#333]",
};
const getAgentColor = (agentName: string) => {
return agentColors[agentName] || agentColors.default;
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage(e as unknown as React.FormEvent);
}
};
const autoResizeTextarea = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const textarea = e.target;
textarea.style.height = "auto";
const maxHeight = 10 * 24;
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
textarea.style.height = `${newHeight}px`;
setMessageInput(textarea.value);
};
const handleDeleteSession = async () => {
if (!selectedSession) return;
try {
await deleteSession(selectedSession);
setSessions(sessions.filter((session) => session.id !== selectedSession));
setSelectedSession(null);
setMessages([]);
setCurrentAgentId(null);
setIsDeleteDialogOpen(false);
toast({
title: "Session deleted successfully",
});
} catch (error) {
console.error("Error deleting session:", error);
toast({
title: "Error deleting session",
variant: "destructive",
});
}
};
const onEvent = useCallback((event: any) => {
setMessages((prev) => [...prev, event]);
}, []);
const onTurnComplete = useCallback(() => {
setIsSending(false);
}, []);
const handleAgentInfoClick = () => {
setIsAgentInfoDialogOpen(true);
};
const handleAgentUpdated = (updatedAgent: any) => {
setAgents(agents.map(agent =>
agent.id === updatedAgent.id ? updatedAgent : agent
));
toast({
title: "Agent updated successfully",
description: "The agent has been updated with the new settings.",
});
};
const jwt = getAccessTokenFromCookie();
const agentId = useMemo(() => currentAgentId || "", [currentAgentId]);
const externalId = useMemo(
() =>
selectedSession ? getExternalId(selectedSession) : generateExternalId(),
[selectedSession]
);
const { sendMessage: wsSendMessage, disconnect: _ } = useAgentWebSocket({
agentId,
externalId,
jwt,
onEvent,
onTurnComplete,
});
return (
<div className="flex h-screen max-h-screen bg-[#121212]">
<SessionList
sessions={sessions}
agents={agents}
selectedSession={selectedSession}
isLoading={isLoading}
searchTerm={searchTerm}
selectedAgentFilter={selectedAgentFilter}
showAgentFilter={showAgentFilter}
setSearchTerm={setSearchTerm}
setSelectedAgentFilter={setSelectedAgentFilter}
setShowAgentFilter={setShowAgentFilter}
setSelectedSession={setSelectedSession}
setIsNewChatDialogOpen={setIsNewChatDialogOpen}
/>
<div className="flex-1 flex flex-col overflow-hidden">
{selectedSession || currentAgentId ? (
<>
<div className="p-4 border-b border-[#333] bg-neutral-900 shadow-md">
{(() => {
const sessionInfo = getCurrentSessionInfo();
return (
<div className="flex justify-between items-center">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<div className="p-1 rounded-full bg-emerald-500/20">
<MessageSquare className="h-5 w-5 text-emerald-400" />
</div>
{selectedSession
? `Session ${
sessionInfo?.externalId || selectedSession
}`
: "New Conversation"}
</h2>
<div className="flex items-center gap-2">
{currentAgent && (
<Badge
className="bg-emerald-500 text-white px-3 py-1 text-sm border-0 cursor-pointer hover:bg-emerald-600 transition-colors"
onClick={handleAgentInfoClick}
>
{currentAgent.name || currentAgentId}
</Badge>
)}
{selectedSession && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-neutral-400 hover:text-red-500 hover:bg-[#333]"
onClick={() => setIsDeleteDialogOpen(true)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
);
})()}
</div>
<div
ref={messagesContainerRef}
className="flex-1 overflow-y-auto p-4 bg-neutral-950"
>
{isLoading ? (
<div className="flex flex-col items-center justify-center h-full">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-emerald-500 to-emerald-700 flex items-center justify-center shadow-lg mb-4 relative">
<Loader2 className="h-6 w-6 text-white animate-spin" />
<div className="absolute inset-0 rounded-full blur-md bg-emerald-400/20 animate-pulse"></div>
</div>
<p className="text-neutral-400 mb-2">Loading conversation...</p>
</div>
) : messages.length === 0 ? (
<div className="flex flex-col h-full items-center justify-center text-center p-6">
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-emerald-500/20 to-emerald-700/20 flex items-center justify-center shadow-lg mb-5 border border-emerald-500/30">
<MessageSquare className="h-6 w-6 text-emerald-400" />
</div>
<h3 className="text-lg font-medium text-neutral-300 mb-2">
{currentAgent ? `Chat with ${currentAgent.name}` : "New Conversation"}
</h3>
<p className="text-neutral-500 text-sm max-w-md">
Type your message below to start the conversation. This chat will help you interact with the agent and explore its capabilities.
</p>
</div>
) : (
<div className="space-y-4 w-full max-w-full">
{messages.map((message) => {
const messageContent = getMessageText(message);
const agentColor = getAgentColor(message.author);
const isExpanded = expandedFunctions[message.id] || false;
return (
<ChatMessageComponent
key={message.id}
message={message}
agentColor={agentColor}
isExpanded={isExpanded}
toggleExpansion={toggleFunctionExpansion}
containsMarkdown={containsMarkdown}
messageContent={messageContent}
sessionId={selectedSession as string}
/>
);
})}
{isSending && (
<div className="flex justify-start">
<div className="flex gap-3 max-w-[80%]">
<Avatar
className="bg-gradient-to-br from-purple-600 to-purple-800 shadow-md border-0"
>
<AvatarFallback className="bg-transparent">
<Bot className="h-4 w-4 text-white" />
</AvatarFallback>
</Avatar>
<div className="rounded-lg p-3 bg-gradient-to-br from-neutral-800 to-neutral-900 border border-neutral-700/50 shadow-md">
<div className="flex space-x-2">
<div className="h-2 w-2 rounded-full bg-emerald-400 animate-bounce"></div>
<div className="h-2 w-2 rounded-full bg-emerald-400 animate-bounce [animation-delay:0.2s]"></div>
<div className="h-2 w-2 rounded-full bg-emerald-400 animate-bounce [animation-delay:0.4s]"></div>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
<div className="px-4 pt-4 pb-6 border-t border-[#333] bg-neutral-900 shadow-inner">
{isSending && !isLoading && (
<div className="px-4 py-2 mb-3 rounded-lg bg-neutral-800/50 border border-neutral-700/30 text-sm text-neutral-400 flex items-center shadow-sm">
<div className="mr-2 relative">
<Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-400" />
<div className="absolute inset-0 blur-sm bg-emerald-400/20 rounded-full animate-pulse"></div>
</div>
Agent is thinking...
</div>
)}
<div className="rounded-lg shadow-md bg-neutral-800/20 border border-neutral-700/30 p-1">
<ChatInput
onSendMessage={handleSendMessageWithFiles}
isLoading={isSending || isLoading}
placeholder="Type your message..."
autoFocus={true}
/>
</div>
</div>
</>
) : (
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="w-20 h-20 rounded-full bg-emerald-500/20 flex items-center justify-center shadow-lg mb-6 border border-emerald-500/30">
<MessageSquare className="h-10 w-10 text-emerald-400" />
</div>
<h2 className="text-2xl font-semibold text-white mb-3">
Select a conversation
</h2>
<p className="text-neutral-400 mb-8 max-w-md">
Choose an existing conversation or start a new one.
</p>
<Button
onClick={() => setIsNewChatDialogOpen(true)}
className="bg-emerald-500 text-white hover:bg-emerald-600 px-6 py-6 h-auto shadow-md rounded-xl"
>
<Plus className="mr-2 h-5 w-5" />
New Conversation
</Button>
</div>
)}
</div>
<Dialog open={isNewChatDialogOpen} onOpenChange={setIsNewChatDialogOpen}>
<DialogContent className="bg-neutral-900 border-neutral-800 text-white shadow-xl">
<DialogHeader>
<div className="flex items-center gap-2 mb-1">
<div className="p-1.5 rounded-full bg-emerald-500/20">
<MessageSquare className="h-5 w-5 text-emerald-400" />
</div>
<DialogTitle className="text-xl font-medium text-white">
New Conversation
</DialogTitle>
</div>
<DialogDescription className="text-neutral-400">
Select an agent to start a new conversation.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="relative">
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-500">
<Search className="h-4 w-4" />
</div>
<Input
placeholder="Search agents..."
className="pl-10 bg-neutral-800/40 border-neutral-700/50 text-white focus-visible:ring-emerald-500/50 focus-visible:border-emerald-500/50 shadow-inner rounded-xl"
value={agentSearchTerm}
onChange={(e) => setAgentSearchTerm(e.target.value)}
/>
{agentSearchTerm && (
<button
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-neutral-400 hover:text-emerald-500 transition-colors"
onClick={() => setAgentSearchTerm("")}
>
<X className="h-4 w-4" />
</button>
)}
</div>
<div className="text-sm text-neutral-300 mb-2">Choose an agent:</div>
<ScrollArea className="h-[300px] pr-2">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-8">
<div className="relative">
<Loader2 className="h-8 w-8 text-emerald-400 animate-spin" />
<div className="absolute inset-0 rounded-full blur-md bg-emerald-400/20 animate-pulse"></div>
</div>
<p className="text-neutral-400 mt-4">Loading agents...</p>
</div>
) : filteredAgents.length > 0 ? (
<div className="space-y-2">
{filteredAgents.map((agent) => (
<div
key={agent.id}
className="p-3 rounded-md cursor-pointer transition-all bg-neutral-800 hover:bg-neutral-800/90 border border-neutral-700/30 hover:border-emerald-500/30 shadow-sm hover:shadow-md group"
onClick={() => selectAgent(agent.id)}
>
<div className="flex items-center gap-2 mb-1">
<div className="p-1 rounded-full bg-emerald-500/20 group-hover:bg-emerald-500/30 transition-colors">
<MessageSquare size={14} className="text-emerald-400" />
</div>
<span className="font-medium text-white group-hover:text-emerald-50">
{agent.name}
</span>
</div>
<div className="flex items-center justify-between mt-1">
<Badge className="text-xs bg-neutral-800/60 text-emerald-400 border border-emerald-500/30">
{agent.type}
</Badge>
{agent.model && (
<span className="text-xs text-neutral-400">
{agent.model}
</span>
)}
</div>
{agent.description && (
<p className="text-xs text-neutral-300 mt-2 line-clamp-2 group-hover:text-neutral-200">
{agent.description}
</p>
)}
</div>
))}
</div>
) : agentSearchTerm ? (
<div className="text-center py-4 text-neutral-400">
No agent found with "{agentSearchTerm}"
</div>
) : (
<div className="text-center py-4 text-neutral-400">
<p>No agents available</p>
<p className="text-sm mt-2">
Create agents in the Agent Management screen
</p>
</div>
)}
</ScrollArea>
</div>
<DialogFooter>
<Button
onClick={() => setIsNewChatDialogOpen(false)}
variant="outline"
className="bg-neutral-800/40 border-neutral-700/50 text-neutral-300 hover:bg-neutral-700/50 hover:text-white hover:border-neutral-600"
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent className="bg-neutral-900 border-neutral-800 text-white shadow-xl">
<DialogHeader>
<div className="flex items-center gap-2 mb-1">
<div className="p-1.5 rounded-full bg-red-500/20">
<Trash2 className="h-5 w-5 text-red-400" />
</div>
<DialogTitle className="text-xl font-medium text-white">
Delete Session
</DialogTitle>
</div>
<DialogDescription className="text-neutral-400">
Are you sure you want to delete this session? This action cannot
be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
onClick={() => setIsDeleteDialogOpen(false)}
variant="outline"
className="bg-neutral-800/40 border-neutral-700/50 text-neutral-300 hover:bg-neutral-700/50 hover:text-white hover:border-neutral-600"
>
Cancel
</Button>
<Button
onClick={handleDeleteSession}
className="bg-red-600 hover:bg-red-700 text-white border-0 shadow-md"
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Agent Info Dialog */}
<AgentInfoDialog
agent={currentAgent}
open={isAgentInfoDialogOpen}
onOpenChange={setIsAgentInfoDialogOpen}
onAgentUpdated={handleAgentUpdated}
/>
</div>
);
}

View File

@@ -0,0 +1,52 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/client-layout.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client"
import type React from "react"
import { usePathname } from "next/navigation"
import Sidebar from "@/components/sidebar"
export default function ClientLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const isLoginPage = pathname === "/login"
const isVerifyEmailPage = pathname.startsWith("/security/verify-email")
const isResetPasswordPage = pathname.startsWith("/security/reset-password")
const isSharedChatPage = pathname.startsWith("/shared-chat")
if (isLoginPage || isVerifyEmailPage || isResetPasswordPage || isSharedChatPage) {
return children
}
return (
<div className="flex h-screen bg-[#121212]">
<Sidebar />
<main className="flex-1 overflow-auto">{children}</main>
</div>
)
}

View File

@@ -0,0 +1,31 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/clients/loading.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
export default function Loading() {
return null
}

View File

@@ -0,0 +1,462 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/clients/page.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client"
import type React from "react"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useToast } from "@/components/ui/use-toast"
import { Plus, MoreHorizontal, Edit, Trash2, Search, Users, UserPlus } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
createClient,
listClients,
getClient,
updateClient,
deleteClient,
impersonateClient,
Client,
} from "@/services/clientService"
import { useRouter } from "next/navigation"
export default function ClientsPage() {
const { toast } = useToast()
const router = useRouter()
const [isLoading, setIsLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [selectedClient, setSelectedClient] = useState<Client | null>(null)
const [clientData, setClientData] = useState({
name: "",
email: "",
})
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(1000)
const [total, setTotal] = useState(0)
const [clients, setClients] = useState<Client[]>([])
useEffect(() => {
const fetchClients = async () => {
setIsLoading(true)
try {
const res = await listClients((page - 1) * limit, limit)
setClients(res.data)
setTotal(res.data.length)
} catch (error) {
toast({
title: "Error loading clients",
description: "Unable to load clients.",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
fetchClients()
}, [page, limit])
const filteredClients = Array.isArray(clients)
? clients.filter(
(client) =>
client.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
client.email.toLowerCase().includes(searchQuery.toLowerCase()),
)
: []
const handleAddClient = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
try {
if (selectedClient) {
await updateClient(selectedClient.id, clientData)
toast({
title: "Client updated",
description: `${clientData.name} was updated successfully.`,
})
} else {
await createClient({ ...clientData, password: "Password@123" })
toast({
title: "Client added",
description: `${clientData.name} was added successfully.`,
})
}
setIsDialogOpen(false)
resetForm()
const res = await listClients((page - 1) * limit, limit)
setClients(res.data)
setTotal(res.data.length)
} catch (error) {
toast({
title: "Error",
description: "Unable to save client. Please try again.",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
const handleEditClient = async (client: Client) => {
setIsLoading(true)
try {
const res = await getClient(client.id)
setSelectedClient(res.data)
setClientData({
name: res.data.name,
email: res.data.email,
})
setIsDialogOpen(true)
} catch (error) {
toast({
title: "Error searching client",
description: "Unable to search client.",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
const confirmDeleteClient = async () => {
if (!selectedClient) return
setIsLoading(true)
try {
await deleteClient(selectedClient.id)
toast({
title: "Client deleted",
description: `${selectedClient.name} was deleted successfully.`,
})
setIsDeleteDialogOpen(false)
setSelectedClient(null)
const res = await listClients((page - 1) * limit, limit)
setClients(res.data)
setTotal(res.data.length)
} catch (error) {
toast({
title: "Error",
description: "Unable to delete client. Please try again.",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
const handleImpersonateClient = async (client: Client) => {
setIsLoading(true)
try {
const response = await impersonateClient(client.id)
const currentUser = localStorage.getItem("user")
if (currentUser) {
localStorage.setItem("adminUser", currentUser)
}
const currentToken = document.cookie.match(/access_token=([^;]+)/)?.[1]
if (currentToken) {
localStorage.setItem("adminToken", currentToken)
}
localStorage.setItem("isImpersonating", "true")
localStorage.setItem("impersonatedClient", client.name)
document.cookie = `isImpersonating=true; path=/; max-age=${60 * 60 * 24 * 7}`
document.cookie = `impersonatedClient=${encodeURIComponent(client.name)}; path=/; max-age=${60 * 60 * 24 * 7}`
document.cookie = `access_token=${response.access_token}; path=/; max-age=${60 * 60 * 24 * 7}`
const userData = {
...JSON.parse(localStorage.getItem("user") || "{}"),
is_admin: false,
client_id: client.id
}
localStorage.setItem("user", JSON.stringify(userData))
document.cookie = `user=${encodeURIComponent(JSON.stringify(userData))}; path=/; max-age=${60 * 60 * 24 * 7}`
toast({
title: "Impersonation mode activated",
description: `You are viewing as ${client.name}`,
})
router.push("/agents")
} catch (error) {
console.error("Error impersonating client:", error)
toast({
title: "Error",
description: "Unable to impersonate client",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
const resetForm = () => {
setClientData({
name: "",
email: "",
})
setSelectedClient(null)
}
return (
<div className="container mx-auto p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-white">Client Management</h1>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button onClick={resetForm} className="bg-emerald-400 text-black hover:bg-[#00cc7d]">
<Plus className="mr-2 h-4 w-4" />
New Client
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px] bg-[#1a1a1a] border-[#333]">
<form onSubmit={handleAddClient}>
<DialogHeader>
<DialogTitle className="text-white">{selectedClient ? "Edit Client" : "New Client"}</DialogTitle>
<DialogDescription className="text-neutral-400">
{selectedClient
? "Edit the existing client information."
: "Fill in the information to create a new client."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="name" className="text-neutral-300">
Name
</Label>
<Input
id="name"
value={clientData.name}
onChange={(e) => setClientData({ ...clientData, name: e.target.value })}
className="bg-[#222] border-[#444] text-white"
placeholder="Company name"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-neutral-300">
Email
</Label>
<Input
id="email"
type="email"
value={clientData.email}
onChange={(e) => setClientData({ ...clientData, email: e.target.value })}
className="bg-[#222] border-[#444] text-white"
placeholder="contact@company.com"
required
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsDialogOpen(false)}
className="border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Cancel
</Button>
<Button type="submit" className="bg-emerald-400 text-black hover:bg-[#00cc7d]" disabled={isLoading}>
{isLoading ? "Saving..." : selectedClient ? "Save Changes" : "Add Client"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent className="bg-[#1a1a1a] border-[#333] text-white">
<AlertDialogHeader>
<AlertDialogTitle>Confirm delete</AlertDialogTitle>
<AlertDialogDescription className="text-neutral-400">
Are you sure you want to delete the client "{selectedClient?.name}"? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white">
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteClient}
className="bg-red-600 text-white hover:bg-red-700"
disabled={isLoading}
>
{isLoading ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<Card className="bg-[#1a1a1a] border-[#333] mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white text-lg">Search Clients</CardTitle>
</CardHeader>
<CardContent>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-500" />
<Input
placeholder="Search by name or email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="bg-[#222] border-[#444] text-white pl-10"
/>
</div>
</CardContent>
</Card>
<Card className="bg-[#1a1a1a] border-[#333]">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="border-[#333] hover:bg-[#222]">
<TableHead className="text-neutral-300">Name</TableHead>
<TableHead className="text-neutral-300">Email</TableHead>
<TableHead className="text-neutral-300">Created At</TableHead>
<TableHead className="text-neutral-300">Users</TableHead>
<TableHead className="text-neutral-300">Agents</TableHead>
<TableHead className="text-neutral-300 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredClients.length > 0 ? (
filteredClients.map((client) => (
<TableRow key={client.id} className="border-[#333] hover:bg-[#222]">
<TableCell className="font-medium text-white">{client.name}</TableCell>
<TableCell className="text-neutral-300">{client.email}</TableCell>
<TableCell className="text-neutral-300">
{new Date(client.created_at).toLocaleDateString("pt-BR")}
</TableCell>
<TableCell className="text-neutral-300">
<div className="flex items-center">
<Users className="h-4 w-4 mr-1 text-emerald-400" />
{client.users_count ?? 0}
</div>
</TableCell>
<TableCell className="text-neutral-300">{client.agents_count ?? 0}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0 text-neutral-300 hover:bg-[#333]">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-[#222] border-[#444] text-white">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-[#444]" />
<DropdownMenuItem
className="cursor-pointer hover:bg-[#333]"
onClick={() => handleEditClient(client)}
>
<Edit className="mr-2 h-4 w-4 text-emerald-400" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer hover:bg-[#333]"
onClick={() => handleImpersonateClient(client)}
>
<UserPlus className="mr-2 h-4 w-4 text-emerald-400" />
Enter as client
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer hover:bg-[#333] text-red-500"
onClick={() => {
setSelectedClient(client)
setIsDeleteDialogOpen(true)
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center text-neutral-500">
No clients found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="flex justify-end mt-4">
<Button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1 || isLoading}>
Previous
</Button>
<span className="mx-4 text-white">Page {page} of {Math.ceil(total / limit) || 1}</span>
<Button onClick={() => setPage((p) => p + 1)} disabled={page * limit >= total || isLoading}>
Next
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,333 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/documentation/components/A2AComplianceCard.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
CheckCircle2,
Clock,
Shield,
Zap,
FileText,
Settings,
Network,
AlertCircle,
ExternalLink,
ChevronDown,
ChevronUp
} from "lucide-react";
import { useState } from "react";
export function A2AComplianceCard() {
const [showCoreFeatures, setShowCoreFeatures] = useState(false);
const [showAdvancedFeatures, setShowAdvancedFeatures] = useState(false);
const implementedFeatures = [
{
name: "JSON-RPC 2.0 Protocol",
status: "implemented",
icon: CheckCircle2,
description: "Full compliance with JSON-RPC 2.0 specification"
},
{
name: "message/send Method",
status: "implemented",
icon: CheckCircle2,
description: "Standard HTTP messaging with proper request/response format"
},
{
name: "message/stream Method",
status: "implemented",
icon: CheckCircle2,
description: "Real-time streaming via Server-Sent Events (SSE)"
},
{
name: "tasks/get Method",
status: "implemented",
icon: CheckCircle2,
description: "Task status querying and monitoring"
},
{
name: "tasks/cancel Method",
status: "implemented",
icon: CheckCircle2,
description: "Task cancellation support"
},
{
name: "agent/authenticatedExtendedCard",
status: "implemented",
icon: CheckCircle2,
description: "Agent discovery and capability enumeration"
},
{
name: "File Upload Support",
status: "implemented",
icon: CheckCircle2,
description: "Base64 file encoding with proper MIME type handling"
},
{
name: "UUID v4 Message IDs",
status: "implemented",
icon: CheckCircle2,
description: "Standards-compliant unique message identification"
},
{
name: "Authentication Methods",
status: "implemented",
icon: CheckCircle2,
description: "API Key and Bearer token authentication"
},
{
name: "Task State Management",
status: "implemented",
icon: CheckCircle2,
description: "Complete task lifecycle: submitted → working → completed/failed"
},
{
name: "Artifact Handling",
status: "implemented",
icon: CheckCircle2,
description: "Complex response data with structured artifacts"
},
{
name: "CORS Compliance",
status: "implemented",
icon: CheckCircle2,
description: "Proper cross-origin resource sharing configuration"
},
{
name: "tasks/pushNotificationConfig/set",
status: "implemented",
icon: CheckCircle2,
description: "Set push notification configuration for tasks"
},
{
name: "tasks/pushNotificationConfig/get",
status: "implemented",
icon: CheckCircle2,
description: "Get push notification configuration for tasks"
},
{
name: "tasks/resubscribe",
status: "implemented",
icon: CheckCircle2,
description: "Resubscribe to task updates and notifications"
}
];
const advancedFeatures = [
{
name: "Push Notifications",
status: "implemented",
icon: CheckCircle2,
description: "A2A pushNotificationConfig methods and webhook support"
},
{
name: "Multi-turn Conversations",
status: "implemented",
icon: CheckCircle2,
description: "Context preservation via contextId field as per A2A specification"
},
{
name: "Enhanced Error Diagnostics",
status: "implemented",
icon: AlertCircle,
description: "Comprehensive A2A error analysis and troubleshooting guidance"
}
];
const implementedCount = implementedFeatures.filter(f => f.status === 'implemented').length;
const totalFeatures = implementedFeatures.length + advancedFeatures.length;
const partialCount = advancedFeatures.filter(f => f.status === 'partial').length;
const advancedImplementedCount = advancedFeatures.filter(f => f.status === 'implemented').length;
const totalImplementedCount = implementedCount + advancedImplementedCount;
const completionPercentage = Math.round(((totalImplementedCount + (partialCount * 0.5)) / totalFeatures) * 100);
const getStatusColor = (status: string) => {
switch (status) {
case 'implemented': return 'text-green-400';
case 'partial': return 'text-yellow-400';
case 'planned': return 'text-blue-400';
default: return 'text-neutral-400';
}
};
const getStatusIcon = (status: string, IconComponent: any) => {
const colorClass = getStatusColor(status);
return <IconComponent className={`h-4 w-4 ${colorClass}`} />;
};
return (
<Card className="bg-gradient-to-br from-emerald-500/5 to-blue-500/5 border-emerald-500/20 text-white">
<CardHeader>
<CardTitle className="text-emerald-400 flex items-center">
<Network className="h-5 w-5 mr-2" />
A2A Specification Compliance
</CardTitle>
<div className="flex items-center space-x-4">
<div className="flex-1">
<div className="flex justify-between text-sm mb-1">
<span className="text-neutral-300">Implementation Progress</span>
<span className="text-emerald-400 font-medium">{completionPercentage}%</span>
</div>
<Progress value={completionPercentage} className="h-2" />
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => {
const shouldExpand = !showCoreFeatures || !showAdvancedFeatures;
setShowCoreFeatures(shouldExpand);
setShowAdvancedFeatures(shouldExpand);
}}
className="text-xs text-neutral-400 hover:text-white transition-colors px-2 py-1 rounded border border-neutral-600 hover:border-neutral-400"
>
{showCoreFeatures && showAdvancedFeatures ? 'Collapse All' : 'Expand All'}
</button>
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">
v0.2.0 Compatible
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex justify-center">
<a
href="https://google.github.io/A2A/specification"
target="_blank"
rel="noopener noreferrer"
className="flex items-center bg-blue-500/10 hover:bg-blue-500/20 px-4 py-2 rounded-lg border border-blue-500/20 transition-colors"
>
<FileText className="h-4 w-4 mr-2 text-blue-400" />
<span className="text-blue-400">View Official Specification</span>
<ExternalLink className="h-3 w-3 ml-2 text-blue-400" />
</a>
</div>
<div>
<div
className="flex items-center justify-between cursor-pointer hover:bg-[#222]/30 p-2 rounded-lg transition-colors mb-4 border border-transparent hover:border-[#333]"
onClick={() => setShowCoreFeatures(!showCoreFeatures)}
>
<h3 className="text-white font-semibold flex items-center">
<CheckCircle2 className="h-4 w-4 mr-2 text-green-400" />
Core Features
<span className="ml-2 text-green-400 text-sm">({implementedCount}/{implementedFeatures.length} implemented)</span>
</h3>
<div className="flex items-center space-x-2">
<span className="text-xs text-neutral-500">
{showCoreFeatures ? 'Hide details' : 'Show details'}
</span>
{showCoreFeatures ? (
<ChevronUp className="h-4 w-4 text-neutral-400 hover:text-white transition-colors" />
) : (
<ChevronDown className="h-4 w-4 text-neutral-400 hover:text-white transition-colors" />
)}
</div>
</div>
{showCoreFeatures && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{implementedFeatures.map((feature, index) => (
<div key={index} className="flex items-start space-x-3 bg-[#222]/50 p-3 rounded-lg border border-[#333]">
{getStatusIcon(feature.status, feature.icon)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{feature.name}</p>
<p className="text-xs text-neutral-400">{feature.description}</p>
</div>
</div>
))}
</div>
)}
</div>
<div>
<div
className="flex items-center justify-between cursor-pointer hover:bg-[#222]/30 p-2 rounded-lg transition-colors mb-4 border border-transparent hover:border-[#333]"
onClick={() => setShowAdvancedFeatures(!showAdvancedFeatures)}
>
<h3 className="text-white font-semibold flex items-center">
<Settings className="h-4 w-4 mr-2 text-blue-400" />
Advanced Features
<span className="ml-2 text-blue-400 text-sm">({advancedImplementedCount}/{advancedFeatures.length} implemented)</span>
</h3>
<div className="flex items-center space-x-2">
<span className="text-xs text-neutral-500">
{showAdvancedFeatures ? 'Hide details' : 'Show details'}
</span>
{showAdvancedFeatures ? (
<ChevronUp className="h-4 w-4 text-neutral-400 hover:text-white transition-colors" />
) : (
<ChevronDown className="h-4 w-4 text-neutral-400 hover:text-white transition-colors" />
)}
</div>
</div>
{showAdvancedFeatures && (
<div className="space-y-3">
{advancedFeatures.map((feature, index) => (
<div key={index} className="flex items-start space-x-3 bg-[#222]/50 p-3 rounded-lg border border-[#333]">
{getStatusIcon(feature.status, feature.icon)}
<div className="flex-1">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium text-white">{feature.name}</p>
<Badge
variant="outline"
className={`text-xs ${
feature.status === 'implemented' ? 'border-green-500 text-green-400' :
feature.status === 'partial' ? 'border-yellow-500 text-yellow-400' :
'border-blue-500 text-blue-400'
}`}
>
{feature.status}
</Badge>
</div>
<p className="text-xs text-neutral-400 mt-1">{feature.description}</p>
</div>
</div>
))}
</div>
)}
</div>
<div className="bg-emerald-500/10 border border-emerald-500/20 rounded-lg p-4">
<div className="flex items-start space-x-2">
<Shield className="h-4 w-4 text-emerald-400 mt-0.5" />
<div className="text-sm">
<p className="text-emerald-400 font-medium"> 100% A2A v0.2.0 Compliance Achieved</p>
<p className="text-emerald-300 mt-1">
All 8 official RPC methods implemented Complete protocol data objects Full workflow support Enterprise security ready
</p>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,78 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/documentation/components/CodeBlock.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
import React from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { dracula } from "react-syntax-highlighter/dist/cjs/styles/prism";
import { Button } from "@/components/ui/button";
import { ClipboardCopy } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
interface CodeBlockProps {
text: string;
language: string;
showLineNumbers?: boolean;
}
export function CodeBlock({ text, language, showLineNumbers = true }: CodeBlockProps) {
const { toast } = useToast();
const copyToClipboard = () => {
navigator.clipboard.writeText(text);
toast({
title: "Copied!",
description: "Code copied to clipboard",
});
};
return (
<div className="relative rounded-md overflow-hidden">
<SyntaxHighlighter
language={language}
style={dracula}
showLineNumbers={showLineNumbers}
wrapLines={true}
customStyle={{
margin: 0,
padding: "1rem",
borderRadius: "0.375rem",
}}
>
{text}
</SyntaxHighlighter>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333] opacity-80 hover:opacity-100"
onClick={copyToClipboard}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,317 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/documentation/components/CodeExamplesSection.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { CodeBlock } from "@/app/documentation/components/CodeBlock";
interface CodeExamplesSectionProps {
agentUrl: string;
apiKey: string;
jsonRpcRequest: any;
curlExample: string;
fetchExample: string;
}
export function CodeExamplesSection({
agentUrl,
apiKey,
jsonRpcRequest,
curlExample,
fetchExample
}: CodeExamplesSectionProps) {
const pythonExample = `import requests
import json
def test_a2a_agent():
url = "${agentUrl || "http://localhost:8000/api/v1/a2a/your-agent-id"}"
headers = {
"Content-Type": "application/json",
"x-api-key": "${apiKey || "your-api-key"}"
}
payload = ${JSON.stringify(jsonRpcRequest, null, 2)}
response = requests.post(url, headers=headers, json=payload)
data = response.json()
print("Agent response:", data)
return data
if __name__ == "__main__":
test_a2a_agent()`;
return (
<Card className="bg-[#1a1a1a] border-[#333] text-white">
<CardHeader>
<CardTitle className="text-emerald-400">Code Examples</CardTitle>
<CardDescription className="text-neutral-400">
Code snippets ready to use with A2A agents
</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="curl">
<TabsList className="bg-[#222] border-[#333] mb-4">
<TabsTrigger value="curl" className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400">
cURL
</TabsTrigger>
<TabsTrigger value="javascript" className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400">
JavaScript
</TabsTrigger>
<TabsTrigger value="python" className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400">
Python
</TabsTrigger>
</TabsList>
<TabsContent value="curl" className="relative">
<CodeBlock
text={curlExample}
language="bash"
/>
</TabsContent>
<TabsContent value="javascript" className="relative">
<CodeBlock
text={fetchExample}
language="javascript"
/>
</TabsContent>
<TabsContent value="python" className="relative">
<CodeBlock
text={pythonExample}
language="python"
/>
</TabsContent>
</Tabs>
<div className="mt-8">
<h3 className="text-xl font-semibold text-white mb-3">Sending files to the agent</h3>
<p className="text-neutral-400 mb-4">
You can attach files to messages sent to the agent using the A2A protocol.
The files are encoded in base64 and incorporated into the message as parts of type &quot;file&quot;.
</p>
<div className="space-y-6">
<div>
<h4 className="text-lg font-medium text-emerald-400 mb-2">Python</h4>
<CodeBlock
text={`import asyncio
import base64
import os
from uuid import uuid4
from common.client import A2ACardResolver, A2AClient
async def send_message_with_files():
# Instantiate client
card_resolver = A2ACardResolver("http://localhost:8000/api/v1/a2a/your-agent-id")
card = card_resolver.get_agent_card()
client = A2AClient(agent_card=card)
# Create session and task IDs
session_id = uuid4().hex
task_id = uuid4().hex
# Read file and encode in base64
file_path = "example.jpg"
with open(file_path, 'rb') as f:
file_content = base64.b64encode(f.read()).decode('utf-8')
file_name = os.path.basename(file_path)
# Create message with text and file
message = {
'role': 'user',
'parts': [
{
'type': 'text',
'text': 'Analyze this image for me',
},
{
'type': 'file',
'file': {
'name': file_name,
'bytes': file_content,
'mime_type': 'application/octet-stream' # Important: include the mime_type for correct file processing
},
}
],
}
# Create request payload
payload = {
'id': task_id,
'sessionId': session_id,
'acceptedOutputModes': ['text'],
'message': message,
}
# Send request
task_result = await client.send_task(payload)
print(f'\\nResponse: {task_result.model_dump_json(exclude_none=True)}')
if __name__ == '__main__':
asyncio.run(send_message_with_files())`}
language="python"
/>
</div>
<div>
<h4 className="text-lg font-medium text-emerald-400 mb-2">JavaScript/TypeScript</h4>
<CodeBlock
text={`// Function to convert file to base64
async function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
const base64 = result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
async function sendMessageWithFiles() {
// Select file (in a web application)
const fileInput = document.getElementById('fileInput');
const files = fileInput.files;
if (files.length === 0) {
console.error('No file selected');
return;
}
// Convert file to base64
const file = files[0];
const base64Data = await fileToBase64(file);
// Create session and task IDs
const sessionId = crypto.randomUUID();
const taskId = crypto.randomUUID();
const callId = crypto.randomUUID();
// Create message with text and file
const payload = {
jsonrpc: "2.0",
method: "message/send",
params: {
message: {
role: "user",
parts: [
{
type: "text",
text: "Analyze this document for me",
},
{
type: "file",
file: {
name: file.name,
bytes: base64Data,
mime_type: file.type
}
}
],
},
sessionId: sessionId,
id: taskId,
},
id: callId,
};
// Send request
const response = await fetch('http://localhost:8000/api/v1/a2a/your-agent-id', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'your-api-key',
},
body: JSON.stringify(payload),
});
const data = await response.json();
console.log('Agent response:', data);
}`}
language="javascript"
/>
</div>
<div>
<h4 className="text-lg font-medium text-emerald-400 mb-2">Curl</h4>
<CodeBlock
text={`# Convert file to base64
FILE_PATH="example.jpg"
FILE_NAME=$(basename $FILE_PATH)
BASE64_CONTENT=$(base64 -w 0 $FILE_PATH)
# Create request payload
read -r -d '' PAYLOAD << EOM
{
"jsonrpc": "2.0",
"method": "message/send",
"params": {
"message": {
"role": "user",
"parts": [
{
"type": "text",
"text": "Analyze this image for me"
},
{
"type": "file",
"file": {
"name": "$FILE_NAME",
"bytes": "$BASE64_CONTENT",
"mime_type": "$(file --mime-type -b $FILE_PATH)"
}
}
]
},
"sessionId": "session-123",
"id": "task-456"
},
"id": "call-789"
}
EOM
# Send request
curl -X POST \\
http://localhost:8000/api/v1/a2a/your-agent-id \\
-H 'Content-Type: application/json' \\
-H 'x-api-key: your-api-key' \\
-d "$PAYLOAD"`}
language="bash"
/>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,588 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/documentation/components/DocumentationSection.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
ClipboardCopy,
Info,
ExternalLink,
Users,
Shield,
Zap,
Network,
FileText,
MessageSquare,
Settings,
AlertCircle,
CheckCircle2,
Globe
} from "lucide-react";
import { CodeBlock } from "@/app/documentation/components/CodeBlock";
interface DocumentationSectionProps {
copyToClipboard: (text: string) => void;
}
export function DocumentationSection({ copyToClipboard }: DocumentationSectionProps) {
const quickStartExample = {
jsonrpc: "2.0",
id: "req-001",
method: "message/send",
params: {
message: {
role: "user",
parts: [
{
type: "text",
text: "Hello! Can you help me analyze this data?"
}
],
messageId: "6dbc13b5-bd57-4c2b-b503-24e381b6c8d6"
}
}
};
const streamingExample = {
jsonrpc: "2.0",
id: "req-002",
method: "message/stream",
params: {
message: {
role: "user",
parts: [
{
type: "text",
text: "Generate a detailed report on market trends"
}
],
messageId: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}
}
};
const fileUploadExample = {
jsonrpc: "2.0",
id: "req-003",
method: "message/send",
params: {
message: {
role: "user",
parts: [
{
type: "text",
text: "Analyze this image and highlight any faces."
},
{
type: "file",
file: {
name: "input_image.png",
mimeType: "image/png",
bytes: "iVBORw0KGgoAAAANSUhEUgAAAAUA..."
}
}
],
messageId: "8f0dc03c-4c65-4a14-9b56-7e8b9f2d1a3c"
}
}
};
return (
<div className="space-y-8">
{/* Hero Section */}
<Card className="bg-gradient-to-br from-emerald-500/10 to-blue-500/10 border-emerald-500/20 text-white">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<div className="flex items-center space-x-2 bg-emerald-500/20 px-4 py-2 rounded-full">
<Network className="h-6 w-6 text-emerald-400" />
<span className="font-bold text-emerald-400">Agent2Agent Protocol</span>
</div>
</div>
<CardTitle className="text-3xl font-bold bg-gradient-to-r from-emerald-400 to-blue-400 bg-clip-text text-transparent">
The Standard for AI Agent Communication
</CardTitle>
<p className="text-lg text-neutral-300 mt-4 max-w-3xl mx-auto">
A2A is Google's open protocol enabling seamless communication and interoperability
between AI agents across different platforms, providers, and architectures.
</p>
</CardHeader>
<CardContent>
<div className="flex flex-wrap justify-center gap-4 mt-6">
<a
href="https://google.github.io/A2A/specification"
target="_blank"
rel="noopener noreferrer"
className="flex items-center bg-emerald-500/20 hover:bg-emerald-500/30 px-4 py-2 rounded-lg transition-colors"
>
<FileText className="h-4 w-4 mr-2 text-emerald-400" />
<span className="text-emerald-400">Official Specification</span>
<ExternalLink className="h-3 w-3 ml-2 text-emerald-400" />
</a>
<a
href="https://github.com/google/A2A"
target="_blank"
rel="noopener noreferrer"
className="flex items-center bg-blue-500/20 hover:bg-blue-500/30 px-4 py-2 rounded-lg transition-colors"
>
<Globe className="h-4 w-4 mr-2 text-blue-400" />
<span className="text-blue-400">GitHub Repository</span>
<ExternalLink className="h-3 w-3 ml-2 text-blue-400" />
</a>
</div>
</CardContent>
</Card>
{/* Key Features */}
<Card className="bg-[#1a1a1a] border-[#333] text-white">
<CardHeader>
<CardTitle className="text-emerald-400 flex items-center">
<Zap className="h-5 w-5 mr-2" />
Key Features & Capabilities
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="flex items-start space-x-3">
<div className="bg-emerald-500/20 p-2 rounded-lg">
<MessageSquare className="h-5 w-5 text-emerald-400" />
</div>
<div>
<h3 className="font-semibold text-white">Multi-turn Conversations</h3>
<p className="text-sm text-neutral-400">Support for complex, contextual dialogues between agents</p>
</div>
</div>
<div className="flex items-start space-x-3">
<div className="bg-blue-500/20 p-2 rounded-lg">
<FileText className="h-5 w-5 text-blue-400" />
</div>
<div>
<h3 className="font-semibold text-white">File Exchange</h3>
<p className="text-sm text-neutral-400">Upload and download files with proper MIME type handling</p>
</div>
</div>
<div className="flex items-start space-x-3">
<div className="bg-purple-500/20 p-2 rounded-lg">
<Zap className="h-5 w-5 text-purple-400" />
</div>
<div>
<h3 className="font-semibold text-white">Real-time Streaming</h3>
<p className="text-sm text-neutral-400">Server-Sent Events for live response streaming</p>
</div>
</div>
<div className="flex items-start space-x-3">
<div className="bg-orange-500/20 p-2 rounded-lg">
<Settings className="h-5 w-5 text-orange-400" />
</div>
<div>
<h3 className="font-semibold text-white">Task Management</h3>
<p className="text-sm text-neutral-400">Track, query, and cancel long-running tasks</p>
</div>
</div>
<div className="flex items-start space-x-3">
<div className="bg-red-500/20 p-2 rounded-lg">
<Shield className="h-5 w-5 text-red-400" />
</div>
<div>
<h3 className="font-semibold text-white">Enterprise Security</h3>
<p className="text-sm text-neutral-400">Bearer tokens, API keys, and HTTPS enforcement</p>
</div>
</div>
<div className="flex items-start space-x-3">
<div className="bg-green-500/20 p-2 rounded-lg">
<Users className="h-5 w-5 text-green-400" />
</div>
<div>
<h3 className="font-semibold text-white">Agent Discovery</h3>
<p className="text-sm text-neutral-400">Standardized agent cards for capability discovery</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Protocol Methods */}
<Card className="bg-[#1a1a1a] border-[#333] text-white">
<CardHeader>
<CardTitle className="text-emerald-400">Protocol Methods</CardTitle>
<p className="text-neutral-400">A2A supports multiple RPC methods for different interaction patterns</p>
</CardHeader>
<CardContent>
<Tabs defaultValue="messaging" className="w-full">
<TabsList className="grid w-full grid-cols-3 bg-[#222] border-[#444]">
<TabsTrigger value="messaging" className="data-[state=active]:bg-emerald-500/20 data-[state=active]:text-emerald-400">
Messaging
</TabsTrigger>
<TabsTrigger value="tasks" className="data-[state=active]:bg-emerald-500/20 data-[state=active]:text-emerald-400">
Task Management
</TabsTrigger>
<TabsTrigger value="discovery" className="data-[state=active]:bg-emerald-500/20 data-[state=active]:text-emerald-400">
Discovery
</TabsTrigger>
</TabsList>
<TabsContent value="messaging" className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-[#222] p-4 rounded-lg border border-[#444]">
<div className="flex items-center space-x-2 mb-3">
<Badge variant="outline" className="border-emerald-500 text-emerald-400">message/send</Badge>
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
</div>
<h4 className="font-semibold text-white mb-2">Standard HTTP Request</h4>
<p className="text-sm text-neutral-400 mb-3">
Send a message and receive a complete response after processing is finished.
</p>
<ul className="text-xs text-neutral-400 space-y-1">
<li> Single request/response cycle</li>
<li> Best for simple queries</li>
<li> Lower complexity implementation</li>
<li> Synchronous operation</li>
</ul>
</div>
<div className="bg-[#222] p-4 rounded-lg border border-[#444]">
<div className="flex items-center space-x-2 mb-3">
<Badge variant="outline" className="border-blue-500 text-blue-400">message/stream</Badge>
<Zap className="h-4 w-4 text-blue-400" />
</div>
<h4 className="font-semibold text-white mb-2">Real-time Streaming</h4>
<p className="text-sm text-neutral-400 mb-3">
Receive partial responses in real-time via Server-Sent Events.
</p>
<ul className="text-xs text-neutral-400 space-y-1">
<li> Progressive response delivery</li>
<li> Better UX for long tasks</li>
<li> Live status updates</li>
<li> Asynchronous operation</li>
</ul>
</div>
</div>
</TabsContent>
<TabsContent value="tasks" className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-[#222] p-4 rounded-lg border border-[#444]">
<div className="flex items-center space-x-2 mb-3">
<Badge variant="outline" className="border-purple-500 text-purple-400">tasks/get</Badge>
<Settings className="h-4 w-4 text-purple-400" />
</div>
<h4 className="font-semibold text-white mb-2">Query Task Status</h4>
<p className="text-sm text-neutral-400 mb-3">
Check the status, progress, and results of a specific task.
</p>
<ul className="text-xs text-neutral-400 space-y-1">
<li> Real-time status checking</li>
<li> Progress monitoring</li>
<li> Result retrieval</li>
<li> Error diagnosis</li>
</ul>
</div>
<div className="bg-[#222] p-4 rounded-lg border border-[#444]">
<div className="flex items-center space-x-2 mb-3">
<Badge variant="outline" className="border-red-500 text-red-400">tasks/cancel</Badge>
<AlertCircle className="h-4 w-4 text-red-400" />
</div>
<h4 className="font-semibold text-white mb-2">Cancel Task</h4>
<p className="text-sm text-neutral-400 mb-3">
Terminate a running task before completion.
</p>
<ul className="text-xs text-neutral-400 space-y-1">
<li> Graceful task termination</li>
<li> Resource cleanup</li>
<li> Cost optimization</li>
<li> User control</li>
</ul>
</div>
</div>
</TabsContent>
<TabsContent value="discovery" className="space-y-4">
<div className="bg-[#222] p-4 rounded-lg border border-[#444]">
<div className="flex items-center space-x-2 mb-3">
<Badge variant="outline" className="border-green-500 text-green-400">agent/authenticatedExtendedCard</Badge>
<Users className="h-4 w-4 text-green-400" />
</div>
<h4 className="font-semibold text-white mb-2">Agent Discovery</h4>
<p className="text-sm text-neutral-400 mb-3">
Retrieve detailed information about agent capabilities, skills, and requirements.
</p>
<ul className="text-xs text-neutral-400 space-y-1">
<li> Agent capability discovery</li>
<li> Skill and tool enumeration</li>
<li> Authentication requirements</li>
<li> API version compatibility</li>
</ul>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* Code Examples */}
<Card className="bg-[#1a1a1a] border-[#333] text-white">
<CardHeader>
<CardTitle className="text-emerald-400">Quick Start Examples</CardTitle>
<p className="text-neutral-400">Ready-to-use JSON-RPC examples based on the official A2A specification</p>
</CardHeader>
<CardContent>
<Tabs defaultValue="basic" className="w-full">
<TabsList className="grid w-full grid-cols-3 bg-[#222] border-[#444]">
<TabsTrigger value="basic" className="data-[state=active]:bg-emerald-500/20 data-[state=active]:text-emerald-400">
Basic Message
</TabsTrigger>
<TabsTrigger value="streaming" className="data-[state=active]:bg-emerald-500/20 data-[state=active]:text-emerald-400">
Streaming
</TabsTrigger>
<TabsTrigger value="files" className="data-[state=active]:bg-emerald-500/20 data-[state=active]:text-emerald-400">
File Upload
</TabsTrigger>
</TabsList>
<TabsContent value="basic" className="space-y-4">
<div className="relative">
<CodeBlock
text={JSON.stringify(quickStartExample, null, 2)}
language="json"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(JSON.stringify(quickStartExample, null, 2))}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
<div className="flex items-start space-x-2">
<Info className="h-4 w-4 text-blue-400 mt-0.5" />
<div className="text-sm">
<p className="text-blue-400 font-medium">Key Points:</p>
<ul className="text-blue-300 mt-1 space-y-1">
<li> Uses <code className="bg-blue-500/20 px-1 rounded">message/send</code> for standard HTTP requests</li>
<li> <code className="bg-blue-500/20 px-1 rounded">messageId</code> must be a valid UUID v4</li>
<li> Response contains task ID, status, and artifacts</li>
</ul>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="streaming" className="space-y-4">
<div className="relative">
<CodeBlock
text={JSON.stringify(streamingExample, null, 2)}
language="json"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(JSON.stringify(streamingExample, null, 2))}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
<div className="bg-purple-500/10 border border-purple-500/20 rounded-lg p-4">
<div className="flex items-start space-x-2">
<Zap className="h-4 w-4 text-purple-400 mt-0.5" />
<div className="text-sm">
<p className="text-purple-400 font-medium">Streaming Features:</p>
<ul className="text-purple-300 mt-1 space-y-1">
<li> Real-time Server-Sent Events (SSE)</li>
<li> Progressive content delivery</li>
<li> Status updates: submitted working completed</li>
</ul>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="files" className="space-y-4">
<div className="relative">
<CodeBlock
text={JSON.stringify(fileUploadExample, null, 2)}
language="json"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(JSON.stringify(fileUploadExample, null, 2))}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-4">
<div className="flex items-start space-x-2">
<FileText className="h-4 w-4 text-green-400 mt-0.5" />
<div className="text-sm">
<p className="text-green-400 font-medium">File Handling:</p>
<ul className="text-green-300 mt-1 space-y-1">
<li> Support for multiple file types (images, documents, etc.)</li>
<li> Base64 encoding for binary data</li>
<li> Proper MIME type specification</li>
</ul>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* Security & Best Practices */}
<Card className="bg-[#1a1a1a] border-[#333] text-white">
<CardHeader>
<CardTitle className="text-emerald-400 flex items-center">
<Shield className="h-5 w-5 mr-2" />
Security & Best Practices
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 className="text-white font-semibold mb-3 flex items-center">
<Shield className="h-4 w-4 mr-2 text-emerald-400" />
Authentication Methods
</h3>
<div className="space-y-3">
<div className="bg-[#222] p-3 rounded-lg border border-[#444]">
<div className="flex items-center space-x-2 mb-2">
<code className="text-emerald-400 text-sm">x-api-key</code>
<Badge variant="outline" className="text-xs">Recommended</Badge>
</div>
<p className="text-xs text-neutral-400">Custom header for API key authentication</p>
</div>
<div className="bg-[#222] p-3 rounded-lg border border-[#444]">
<div className="flex items-center space-x-2 mb-2">
<code className="text-blue-400 text-sm">Authorization: Bearer</code>
<Badge variant="outline" className="text-xs">Standard</Badge>
</div>
<p className="text-xs text-neutral-400">OAuth 2.0 Bearer token authentication</p>
</div>
</div>
</div>
<div>
<h3 className="text-white font-semibold mb-3 flex items-center">
<AlertCircle className="h-4 w-4 mr-2 text-orange-400" />
Security Requirements
</h3>
<div className="space-y-2 text-sm">
<div className="flex items-center space-x-2">
<CheckCircle2 className="h-3 w-3 text-green-400" />
<span className="text-neutral-300">HTTPS/TLS encryption required</span>
</div>
<div className="flex items-center space-x-2">
<CheckCircle2 className="h-3 w-3 text-green-400" />
<span className="text-neutral-300">Input validation on all parameters</span>
</div>
<div className="flex items-center space-x-2">
<CheckCircle2 className="h-3 w-3 text-green-400" />
<span className="text-neutral-300">Rate limiting and resource controls</span>
</div>
<div className="flex items-center space-x-2">
<CheckCircle2 className="h-3 w-3 text-green-400" />
<span className="text-neutral-300">Proper CORS configuration</span>
</div>
</div>
</div>
</div>
<div className="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
<div className="flex items-start space-x-2">
<AlertCircle className="h-4 w-4 text-amber-400 mt-0.5" />
<div className="text-sm">
<p className="text-amber-400 font-medium">Important:</p>
<p className="text-amber-300 mt-1">
Always obtain API credentials out-of-band. Never include sensitive authentication
data in client-side code or version control systems.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* A2A vs MCP */}
<Card className="bg-[#1a1a1a] border-[#333] text-white">
<CardHeader>
<CardTitle className="text-emerald-400 flex items-center">
<Network className="h-5 w-5 mr-2" />
A2A vs Model Context Protocol (MCP)
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-[#222] border-b border-[#444]">
<th className="p-4 text-left text-neutral-300">Aspect</th>
<th className="p-4 text-left text-emerald-400">Agent2Agent (A2A)</th>
<th className="p-4 text-left text-blue-400">Model Context Protocol (MCP)</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-[#333]">
<td className="p-4 text-neutral-300 font-medium">Purpose</td>
<td className="p-4 text-neutral-300">Agent-to-agent communication</td>
<td className="p-4 text-neutral-300">Model-to-tool/resource integration</td>
</tr>
<tr className="border-b border-[#333]">
<td className="p-4 text-neutral-300 font-medium">Use Case</td>
<td className="p-4 text-neutral-300">AI agents collaborating as peers</td>
<td className="p-4 text-neutral-300">AI models accessing external capabilities</td>
</tr>
<tr className="border-b border-[#333]">
<td className="p-4 text-neutral-300 font-medium">Relationship</td>
<td className="p-4 text-neutral-300">Partner/delegate work</td>
<td className="p-4 text-neutral-300">Use specific capabilities</td>
</tr>
<tr className="border-b border-[#333]">
<td className="p-4 text-neutral-300 font-medium">Integration</td>
<td className="p-4 text-neutral-300 text-emerald-400"> Can use MCP internally</td>
<td className="p-4 text-neutral-300 text-blue-400"> Complements A2A</td>
</tr>
</tbody>
</table>
</div>
<div className="mt-4 bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
<p className="text-blue-300 text-sm">
<strong>Working Together:</strong> An A2A client agent might request an A2A server agent to perform a complex task.
The server agent, in turn, might use MCP to interact with tools, APIs, or data sources necessary to fulfill the A2A task.
</p>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,796 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/documentation/components/FrontendImplementationSection.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ClipboardCopy } from "lucide-react";
import { CodeBlock } from "@/app/documentation/components/CodeBlock";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface FrontendImplementationSectionProps {
copyToClipboard: (text: string) => void;
}
export function FrontendImplementationSection({ copyToClipboard }: FrontendImplementationSectionProps) {
return (
<Card className="bg-[#1a1a1a] border-[#333] text-white">
<CardHeader>
<CardTitle className="text-emerald-400">Frontend implementation</CardTitle>
<CardDescription className="text-neutral-400">
Practical examples for implementation in React applications
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Tabs defaultValue="standard">
<TabsList className="bg-[#222] border-[#333] mb-4">
<TabsTrigger value="standard" className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400">
Standard HTTP
</TabsTrigger>
<TabsTrigger value="streaming" className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400">
Streaming SSE
</TabsTrigger>
<TabsTrigger value="react-component" className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400">
React component
</TabsTrigger>
</TabsList>
<TabsContent value="standard">
<div>
<h3 className="text-emerald-400 text-lg font-medium mb-2">Implementation of message/send</h3>
<p className="text-neutral-300 mb-4">
Example of standard implementation in JavaScript/React:
</p>
<div className="relative">
<CodeBlock
text={`async function sendTask(agentId, message) {
// Generate unique IDs
const taskId = crypto.randomUUID();
const callId = crypto.randomUUID();
// Prepare request data
const requestData = {
jsonrpc: "2.0",
id: callId,
method: "message/send",
params: {
id: taskId,
sessionId: "session-" + Math.random().toString(36).substring(2, 9),
message: {
role: "user",
parts: [
{
type: "text",
text: message
}
]
}
}
};
try {
// Indicate loading state
setIsLoading(true);
// Send the request
const response = await fetch(\`/api/v1/a2a/\${agentId}\`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'YOUR_API_KEY_HERE'
},
body: JSON.stringify(requestData)
});
if (!response.ok) {
throw new Error(\`HTTP error: \${response.status}\`);
}
// Process the response
const data = await response.json();
// Check for errors
if (data.error) {
console.error('Error in response:', data.error);
return null;
}
// Extract the agent response
const task = data.result;
// Show response in UI
if (task.status.message && task.status.message.parts) {
const responseText = task.status.message.parts
.filter(part => part.type === 'text')
.map(part => part.text)
.join('');
// Here you would update your React state
// setResponse(responseText);
return responseText;
}
return task;
} catch (error) {
console.error('Error sending task:', error);
return null;
} finally {
// Finalize loading state
setIsLoading(false);
}
}`}
language="javascript"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(`async function sendTask(agentId, message) {
// Generate unique IDs
const taskId = crypto.randomUUID();
const callId = crypto.randomUUID();
// Prepare request data
const requestData = {
jsonrpc: "2.0",
id: callId,
method: "message/send",
params: {
id: taskId,
sessionId: "session-" + Math.random().toString(36).substring(2, 9),
message: {
role: "user",
parts: [
{
type: "text",
text: message
}
]
}
}
};
try {
// Indicate loading state
setIsLoading(true);
// Send the request
const response = await fetch(\`/api/v1/a2a/\${agentId}\`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'YOUR_API_KEY_HERE'
},
body: JSON.stringify(requestData)
});
if (!response.ok) {
throw new Error(\`HTTP error: \${response.status}\`);
}
// Process the response
const data = await response.json();
// Check for errors
if (data.error) {
console.error('Error in response:', data.error);
return null;
}
// Extract the agent response
const task = data.result;
// Show response in UI
if (task.status.message && task.status.message.parts) {
const responseText = task.status.message.parts
.filter(part => part.type === 'text')
.map(part => part.text)
.join('');
// Here you would update your React state
// setResponse(responseText);
return responseText;
}
return task;
} catch (error) {
console.error('Error sending task:', error);
return null;
} finally {
// Finalize loading state
setIsLoading(false);
}
}`)}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="streaming">
<div>
<h3 className="text-emerald-400 text-lg font-medium mb-2">Implementation of message/stream (Streaming)</h3>
<p className="text-neutral-300 mb-4">
Example of implementation of streaming with Server-Sent Events (SSE):
</p>
<div className="relative">
<CodeBlock
text={`async function initAgentStream(agentId, message, onUpdateCallback) {
// Generate unique IDs
const taskId = crypto.randomUUID();
const callId = crypto.randomUUID();
const sessionId = "session-" + Math.random().toString(36).substring(2, 9);
// Prepare request data for streaming
const requestData = {
jsonrpc: "2.0",
id: callId,
method: "message/stream",
params: {
id: taskId,
sessionId: sessionId,
message: {
role: "user",
parts: [
{
type: "text",
text: message
}
]
}
}
};
try {
// Start initial POST request
const response = await fetch(\`/api/v1/a2a/\${agentId}\`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'YOUR_API_KEY_HERE',
'Accept': 'text/event-stream' // Important for SSE
},
body: JSON.stringify(requestData)
});
if (!response.ok) {
throw new Error(\`HTTP error: \${response.status}\`);
}
// Check content type of the response
const contentType = response.headers.get('content-type');
// If the response is already SSE, use EventSource
if (contentType?.includes('text/event-stream')) {
// Use EventSource to process the stream
setupEventSource(\`/api/v1/a2a/\${agentId}/stream?taskId=\${taskId}&key=YOUR_API_KEY_HERE\`);
return;
}
// Function to configure EventSource
function setupEventSource(url) {
const eventSource = new EventSource(url);
// Handler for received messages
eventSource.onmessage = (event) => {
try {
// Process data from the event
const data = JSON.parse(event.data);
// Process the event
if (data.result) {
// Process status if available
if (data.result.status) {
const status = data.result.status;
// Extract text if available
let currentText = '';
if (status.message && status.message.parts) {
const parts = status.message.parts.filter(part => part.type === 'text');
if (parts.length > 0) {
currentText = parts.map(part => part.text).join('');
}
}
// Call callback with updates
onUpdateCallback({
text: currentText,
state: status.state,
complete: data.result.final === true
});
// If it's the final event, close the connection
if (data.result.final === true) {
eventSource.close();
onUpdateCallback({
complete: true,
state: status.state
});
}
}
// Process artifact if available
if (data.result.artifact) {
const artifact = data.result.artifact;
if (artifact.parts && artifact.parts.length > 0) {
const parts = artifact.parts.filter(part => part.type === 'text');
if (parts.length > 0) {
const artifactText = parts.map(part => part.text).join('');
// Call callback with artifact
onUpdateCallback({
text: artifactText,
state: 'artifact',
complete: artifact.lastChunk === true
});
// If it's the last chunk, close the connection
if (artifact.lastChunk === true) {
eventSource.close();
}
}
}
}
}
} catch (error) {
console.error('Error processing event:', error);
onUpdateCallback({ error: error.message });
}
};
// Handler for errors
eventSource.onerror = (error) => {
console.error('Error in EventSource:', error);
eventSource.close();
onUpdateCallback({
error: 'Connection with server interrupted',
complete: true,
state: 'error'
});
};
}
} catch (error) {
console.error('Error in streaming:', error);
// Notify error through callback
onUpdateCallback({
error: error.message,
complete: true,
state: 'error'
});
return null;
}
}`}
language="javascript"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(`async function initAgentStream(agentId, message, onUpdateCallback) {
// Generate unique IDs
const taskId = crypto.randomUUID();
const callId = crypto.randomUUID();
const sessionId = "session-" + Math.random().toString(36).substring(2, 9);
// Prepare request data for streaming
const requestData = {
jsonrpc: "2.0",
id: callId,
method: "message/stream",
params: {
id: taskId,
sessionId: sessionId,
message: {
role: "user",
parts: [
{
type: "text",
text: message
}
]
}
}
};
try {
// Start initial POST request
const response = await fetch(\`/api/v1/a2a/\${agentId}\`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'YOUR_API_KEY_HERE',
'Accept': 'text/event-stream' // Important for SSE
},
body: JSON.stringify(requestData)
});
if (!response.ok) {
throw new Error(\`HTTP error: \${response.status}\`);
}
// Check content type of the response
const contentType = response.headers.get('content-type');
// If the response is already SSE, use EventSource
if (contentType?.includes('text/event-stream')) {
// Use EventSource to process the stream
setupEventSource(\`/api/v1/a2a/\${agentId}/stream?taskId=\${taskId}&key=YOUR_API_KEY_HERE\`);
return;
}
// Function to configure EventSource
function setupEventSource(url) {
const eventSource = new EventSource(url);
// Handler for received messages
eventSource.onmessage = (event) => {
try {
// Process data from the event
const data = JSON.parse(event.data);
// Process the event
if (data.result) {
// Process status if available
if (data.result.status) {
const status = data.result.status;
// Extract text if available
let currentText = '';
if (status.message && status.message.parts) {
const parts = status.message.parts.filter(part => part.type === 'text');
if (parts.length > 0) {
currentText = parts.map(part => part.text).join('');
}
}
// Call callback with updates
onUpdateCallback({
text: currentText,
state: status.state,
complete: data.result.final === true
});
// If it's the final event, close the connection
if (data.result.final === true) {
eventSource.close();
onUpdateCallback({
complete: true,
state: status.state
});
}
}
// Process artifact if available
if (data.result.artifact) {
const artifact = data.result.artifact;
if (artifact.parts && artifact.parts.length > 0) {
const parts = artifact.parts.filter(part => part.type === 'text');
if (parts.length > 0) {
const artifactText = parts.map(part => part.text).join('');
// Call callback with artifact
onUpdateCallback({
text: artifactText,
state: 'artifact',
complete: artifact.lastChunk === true
});
// If it's the last chunk, close the connection
if (artifact.lastChunk === true) {
eventSource.close();
}
}
}
}
}
} catch (error) {
console.error('Error processing event:', error);
onUpdateCallback({ error: error.message });
}
};
// Handler for errors
eventSource.onerror = (error) => {
console.error('Error in EventSource:', error);
eventSource.close();
onUpdateCallback({
error: 'Connection with server interrupted',
complete: true,
state: 'error'
});
};
}
} catch (error) {
console.error('Error in streaming:', error);
// Notify error through callback
onUpdateCallback({
error: error.message,
complete: true,
state: 'error'
});
return null;
}
}`)}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="react-component">
<div className="mt-4">
<h4 className="font-medium text-emerald-400 mb-2">React component with streaming support:</h4>
<div className="relative">
<CodeBlock
text={`import React, { useState, useRef } from 'react';
function ChatComponentA2A() {
const [message, setMessage] = useState('');
const [response, setResponse] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [status, setStatus] = useState('');
// Reference to the agentId
const agentId = 'your-agent-id';
// Callback for streaming updates
const handleStreamUpdate = (update) => {
if (update.error) {
// Handle error
setStatus('error');
return;
}
// Update text
setResponse(update.text);
// Update status
setStatus(update.state);
// If it's complete, finish streaming
if (update.complete) {
setIsStreaming(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!message.trim()) return;
// Clear previous response
setResponse('');
setStatus('submitted');
// Start streaming
setIsStreaming(true);
try {
// Start stream with the agent
await initAgentStream(agentId, message, handleStreamUpdate);
// Clear message field after sending
setMessage('');
} catch (error) {
console.error('Error:', error);
setStatus('error');
setIsStreaming(false);
}
};
// Render status indicator based on status
const renderStatusIndicator = () => {
switch (status) {
case 'submitted':
return <span className="badge badge-info">Sent</span>;
case 'working':
return <span className="badge badge-warning">Processing</span>;
case 'completed':
return <span className="badge badge-success">Completed</span>;
case 'error':
return <span className="badge badge-danger">Error</span>;
default:
return null;
}
};
return (
<div className="chat-container">
<div className="chat-messages">
{response && (
<div className="message agent-message">
<div className="message-header">
<div className="agent-name">A2A Agent</div>
{renderStatusIndicator()}
</div>
<div className="message-content">
{response}
{status === 'working' && !response && (
<div className="typing-indicator">
<span></span><span></span><span></span>
</div>
)}
</div>
</div>
)}
</div>
<form onSubmit={handleSubmit} className="chat-input-form">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..."
disabled={isStreaming}
className="chat-input"
/>
<button
type="submit"
disabled={isStreaming || !message.trim()}
className="send-button"
>
{isStreaming ? 'Processing...' : 'Send'}
</button>
</form>
</div>
);
}`}
language="javascript"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(`import React, { useState, useRef } from 'react';
function ChatComponentA2A() {
const [message, setMessage] = useState('');
const [response, setResponse] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [status, setStatus] = useState('');
// Reference to the agentId
const agentId = 'your-agent-id';
// Callback for streaming updates
const handleStreamUpdate = (update) => {
if (update.error) {
// Handle error
setStatus('error');
return;
}
// Update text
setResponse(update.text);
// Update status
setStatus(update.state);
// If it's complete, finish streaming
if (update.complete) {
setIsStreaming(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!message.trim()) return;
// Clear previous response
setResponse('');
setStatus('submitted');
// Start streaming
setIsStreaming(true);
try {
// Start stream with the agent
await initAgentStream(agentId, message, handleStreamUpdate);
// Clear message field after sending
setMessage('');
} catch (error) {
console.error('Error:', error);
setStatus('error');
setIsStreaming(false);
}
};
// Render status indicator based on status
const renderStatusIndicator = () => {
switch (status) {
case 'submitted':
return <span className="badge badge-info">Sent</span>;
case 'working':
return <span className="badge badge-warning">Processing</span>;
case 'completed':
return <span className="badge badge-success">Completed</span>;
case 'error':
return <span className="badge badge-danger">Error</span>;
default:
return null;
}
};
return (
<div className="chat-container">
<div className="chat-messages">
{response && (
<div className="message agent-message">
<div className="message-header">
<div className="agent-name">A2A Agent</div>
{renderStatusIndicator()}
</div>
<div className="message-content">
{response}
{status === 'working' && !response && (
<div className="typing-indicator">
<span></span><span></span><span></span>
</div>
)}
</div>
</div>
)}
</div>
<form onSubmit={handleSubmit} className="chat-input-form">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..."
disabled={isStreaming}
className="chat-input"
/>
<button
type="submit"
disabled={isStreaming || !message.trim()}
className="send-button"
>
{isStreaming ? 'Processing...' : 'Send'}
</button>
</form>
</div>
);
}`)}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,523 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/documentation/components/HttpLabForm.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
import { useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Send, Paperclip, X, FileText, Image, File, RotateCcw, Trash2 } from "lucide-react";
import { toast } from "@/hooks/use-toast";
interface AttachedFile {
name: string;
type: string;
size: number;
base64: string;
}
interface HttpLabFormProps {
agentUrl: string;
setAgentUrl: (url: string) => void;
apiKey: string;
setApiKey: (key: string) => void;
message: string;
setMessage: (message: string) => void;
sessionId: string;
setSessionId: (id: string) => void;
taskId: string;
setTaskId: (id: string) => void;
callId: string;
setCallId: (id: string) => void;
sendRequest: () => Promise<void>;
isLoading: boolean;
setFiles?: (files: AttachedFile[]) => void;
a2aMethod: string;
setA2aMethod: (method: string) => void;
authMethod: string;
setAuthMethod: (method: string) => void;
generateNewIds: () => void;
currentTaskId?: string | null;
conversationHistory?: any[];
clearHistory?: () => void;
webhookUrl?: string;
setWebhookUrl?: (url: string) => void;
enableWebhooks?: boolean;
setEnableWebhooks?: (enabled: boolean) => void;
showDetailedErrors?: boolean;
setShowDetailedErrors?: (show: boolean) => void;
}
export function HttpLabForm({
agentUrl,
setAgentUrl,
apiKey,
setApiKey,
message,
setMessage,
sessionId,
setSessionId,
taskId,
setTaskId,
callId,
setCallId,
sendRequest,
isLoading,
setFiles = () => {},
a2aMethod,
setA2aMethod,
authMethod,
setAuthMethod,
generateNewIds,
currentTaskId,
conversationHistory,
clearHistory,
webhookUrl,
setWebhookUrl,
enableWebhooks,
setEnableWebhooks,
showDetailedErrors,
setShowDetailedErrors
}: HttpLabFormProps) {
const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const clearAttachedFiles = () => {
setAttachedFiles([]);
};
const handleSendRequest = async () => {
await sendRequest();
clearAttachedFiles();
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || e.target.files.length === 0) return;
const maxFileSize = 5 * 1024 * 1024; // 5MB limit
const newFiles = Array.from(e.target.files);
if (attachedFiles.length + newFiles.length > 5) {
toast({
title: "File limit exceeded",
description: "You can only attach up to 5 files.",
variant: "destructive"
});
return;
}
const filesToAdd: AttachedFile[] = [];
for (const file of newFiles) {
if (file.size > maxFileSize) {
toast({
title: "File too large",
description: `The file ${file.name} exceeds the 5MB size limit.`,
variant: "destructive"
});
continue;
}
try {
const base64 = await readFileAsBase64(file);
filesToAdd.push({
name: file.name,
type: file.type,
size: file.size,
base64: base64
});
} catch (error) {
console.error("Failed to read file:", error);
toast({
title: "Failed to read file",
description: `Could not process ${file.name}.`,
variant: "destructive"
});
}
}
if (filesToAdd.length > 0) {
const updatedFiles = [...attachedFiles, ...filesToAdd];
setAttachedFiles(updatedFiles);
setFiles(updatedFiles);
}
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const readFileAsBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const base64 = result.split(',')[1]; // Remove data URL prefix
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
const removeFile = (index: number) => {
const updatedFiles = attachedFiles.filter((_, i) => i !== index);
setAttachedFiles(updatedFiles);
setFiles(updatedFiles);
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const isImageFile = (type: string): boolean => {
return type.startsWith('image/');
};
return (
<div className="space-y-4">
{/* A2A Method and Authentication Settings */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 bg-[#1a1a1a] border border-[#333] rounded-md">
<div>
<label className="text-sm text-neutral-400 mb-2 block">A2A Method</label>
<Select value={a2aMethod} onValueChange={setA2aMethod}>
<SelectTrigger className="bg-[#222] border-[#444] text-white">
<SelectValue placeholder="Select A2A method" />
</SelectTrigger>
<SelectContent className="bg-[#222] border-[#444]">
<SelectItem value="message/send" className="text-white hover:bg-[#333]">
message/send
</SelectItem>
<SelectItem value="message/stream" className="text-white hover:bg-[#333]">
message/stream
</SelectItem>
<SelectItem value="tasks/get" className="text-white hover:bg-[#333]">
tasks/get
</SelectItem>
<SelectItem value="tasks/cancel" className="text-white hover:bg-[#333]">
tasks/cancel
</SelectItem>
<SelectItem value="tasks/pushNotificationConfig/set" className="text-white hover:bg-[#333]">
tasks/pushNotificationConfig/set
</SelectItem>
<SelectItem value="tasks/pushNotificationConfig/get" className="text-white hover:bg-[#333]">
tasks/pushNotificationConfig/get
</SelectItem>
<SelectItem value="tasks/resubscribe" className="text-white hover:bg-[#333]">
tasks/resubscribe
</SelectItem>
<SelectItem value="agent/authenticatedExtendedCard" className="text-white hover:bg-[#333]">
agent/authenticatedExtendedCard
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm text-neutral-400 mb-2 block">Authentication Method</label>
<Select value={authMethod} onValueChange={setAuthMethod}>
<SelectTrigger className="bg-[#222] border-[#444] text-white">
<SelectValue placeholder="Select auth method" />
</SelectTrigger>
<SelectContent className="bg-[#222] border-[#444]">
<SelectItem value="api-key" className="text-white hover:bg-[#333]">
API Key (x-api-key header)
</SelectItem>
<SelectItem value="bearer" className="text-white hover:bg-[#333]">
Bearer Token (Authorization header)
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Multi-turn Conversation History Controls */}
{(a2aMethod === "message/send" || a2aMethod === "message/stream") && conversationHistory && conversationHistory.length > 0 && (
<div className="p-4 bg-emerald-500/5 border border-emerald-500/20 rounded-md">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-emerald-400">
Multi-turn Conversation Active
</span>
</div>
</div>
<div className="text-xs text-emerald-300">
💬 {conversationHistory.length} messages in conversation history (contextId active)
</div>
</div>
)}
{/* Push Notifications (Webhook) Configuration */}
{(a2aMethod === "message/send" || a2aMethod === "message/stream" || a2aMethod.startsWith("tasks/")) && (
<div className="p-4 bg-blue-500/5 border border-blue-500/20 rounded-md">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="enableWebhooks"
checked={enableWebhooks}
onChange={(e) => setEnableWebhooks?.(e.target.checked)}
className="rounded bg-[#222] border-[#444] text-blue-400 focus:ring-blue-400"
/>
<label htmlFor="enableWebhooks" className="text-sm font-medium text-blue-400">
Enable Push Notifications (Webhooks)
</label>
</div>
</div>
{enableWebhooks && (
<div className="mt-3">
<label className="text-sm text-neutral-400 mb-1 block">Webhook URL</label>
<Input
value={webhookUrl}
onChange={(e) => setWebhookUrl?.(e.target.value)}
placeholder="https://your-server.com/webhook/a2a"
className="bg-[#222] border-[#444] text-white"
/>
<div className="text-xs text-blue-300 mt-1">
{a2aMethod === "tasks/pushNotificationConfig/set"
? "📡 Configure push notifications for task"
: "📡 Webhook URL for push notifications (configured via pushNotificationConfig)"
}
</div>
</div>
)}
{!enableWebhooks && (
<div className="text-xs text-neutral-400">
{a2aMethod === "tasks/pushNotificationConfig/set"
? "Push notification configuration will be set to null."
: "No push notifications will be configured for this request."
}
</div>
)}
</div>
)}
{/* Advanced Error Handling Configuration */}
<div className="p-4 bg-orange-500/5 border border-orange-500/20 rounded-md">
<div className="flex items-center space-x-2 mb-3">
<input
type="checkbox"
id="showDetailedErrors"
checked={showDetailedErrors}
onChange={(e) => setShowDetailedErrors?.(e.target.checked)}
className="rounded bg-[#222] border-[#444] text-orange-400 focus:ring-orange-400"
/>
<label htmlFor="showDetailedErrors" className="text-sm font-medium text-orange-400">
Enable Detailed Error Logging
</label>
</div>
<div className="text-xs text-neutral-400">
{showDetailedErrors
? "🔍 Detailed error information will be shown in debug logs (client-side only)."
: "⚡ Basic error handling only - minimal error information in logs."
}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm text-neutral-400 mb-1 block">Agent URL</label>
<Input
value={agentUrl}
onChange={(e) => setAgentUrl(e.target.value)}
placeholder="http://localhost:8000/api/v1/a2a/your-agent-id"
className="bg-[#222] border-[#444] text-white"
/>
</div>
<div>
<label className="text-sm text-neutral-400 mb-1 block">
{authMethod === "bearer" ? "Bearer Token" : "API Key"} (optional)
</label>
<Input
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={authMethod === "bearer" ? "Your Bearer token" : "Your API key"}
className="bg-[#222] border-[#444] text-white"
/>
</div>
</div>
{/* Show current task ID if available */}
{currentTaskId && (
<div className="p-3 bg-[#1a1a1a] border border-emerald-400/20 rounded-md">
<div className="flex items-center justify-between">
<div>
<span className="text-sm text-neutral-400">Current Task ID:</span>
<span className="ml-2 text-emerald-400 font-mono text-sm">{currentTaskId}</span>
</div>
</div>
</div>
)}
{/* Message input - only show for message methods */}
{(a2aMethod === "message/send" || a2aMethod === "message/stream") && (
<div>
<label className="text-sm text-neutral-400 mb-1 block">Message</label>
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="What is the A2A protocol?"
className="bg-[#222] border-[#444] text-white min-h-[100px]"
/>
</div>
)}
{/* File attachment - only show for message methods */}
{(a2aMethod === "message/send" || a2aMethod === "message/stream") && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-neutral-400">
Attach Files (up to 5, max 5MB each)
</label>
<Button
variant="outline"
size="sm"
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
onClick={() => fileInputRef.current?.click()}
disabled={attachedFiles.length >= 5}
>
<Paperclip className="h-4 w-4 mr-2" />
Browse Files
</Button>
<input
type="file"
ref={fileInputRef}
className="hidden"
multiple
onChange={handleFileSelect}
/>
</div>
{attachedFiles.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{attachedFiles.map((file, index) => (
<div
key={index}
className="flex items-center gap-1.5 bg-[#333] text-white rounded-md p-1.5 text-xs"
>
{isImageFile(file.type) ? (
<Image className="h-4 w-4 text-emerald-400" />
) : file.type === 'application/pdf' ? (
<FileText className="h-4 w-4 text-emerald-400" />
) : (
<File className="h-4 w-4 text-emerald-400" />
)}
<span className="max-w-[150px] truncate">{file.name}</span>
<span className="text-neutral-400">({formatFileSize(file.size)})</span>
<button
onClick={() => removeFile(index)}
className="ml-1 text-neutral-400 hover:text-white transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
)}
<Separator className="my-4 bg-[#333]" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm text-neutral-400">Session ID</label>
<Button
variant="ghost"
size="sm"
onClick={generateNewIds}
className="h-6 px-2 text-xs text-neutral-400 hover:text-white"
>
<RotateCcw className="h-3 w-3 mr-1" />
New IDs
</Button>
</div>
<Input
value={sessionId}
onChange={(e) => setSessionId(e.target.value)}
className="bg-[#222] border-[#444] text-white"
/>
</div>
<div>
<label className="text-sm text-neutral-400 mb-1 block">
{a2aMethod.startsWith("tasks/") ? "Task ID (for operation)" : "Message ID (UUID)"}
</label>
<Input
value={taskId}
onChange={(e) => setTaskId(e.target.value)}
className="bg-[#222] border-[#444] text-white"
placeholder={a2aMethod.startsWith("tasks/") ? "Task ID to query/cancel" : "UUID for message"}
/>
</div>
<div>
<label className="text-sm text-neutral-400 mb-1 block">Request ID</label>
<Input
value={callId}
onChange={(e) => setCallId(e.target.value)}
className="bg-[#222] border-[#444] text-white"
placeholder="req-123"
/>
</div>
</div>
<Button
onClick={handleSendRequest}
disabled={isLoading}
className="bg-emerald-400 text-black hover:bg-[#00cc7d] w-full mt-4"
>
{isLoading ? (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-black mr-2"></div>
Sending...
</div>
) : (
<div className="flex items-center">
<Send className="mr-2 h-4 w-4" />
{a2aMethod === "message/send" && "Send Message"}
{a2aMethod === "message/stream" && "Start Stream"}
{a2aMethod === "tasks/get" && "Get Task Status"}
{a2aMethod === "tasks/cancel" && "Cancel Task"}
{a2aMethod === "tasks/pushNotificationConfig/set" && "Set Push Config"}
{a2aMethod === "tasks/pushNotificationConfig/get" && "Get Push Config"}
{a2aMethod === "tasks/resubscribe" && "Resubscribe to Task"}
{a2aMethod === "agent/authenticatedExtendedCard" && "Get Agent Card"}
</div>
)}
</Button>
</div>
);
}

View File

@@ -0,0 +1,201 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/documentation/components/LabSection.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { ClipboardCopy } from "lucide-react";
import { HttpLabForm } from "@/app/documentation/components/HttpLabForm";
import { StreamLabForm } from "@/app/documentation/components/StreamLabForm";
import { CodeBlock } from "@/app/documentation/components/CodeBlock";
interface LabSectionProps {
agentUrl: string;
setAgentUrl: (url: string) => void;
apiKey: string;
setApiKey: (key: string) => void;
message: string;
setMessage: (message: string) => void;
sessionId: string;
setSessionId: (id: string) => void;
taskId: string;
setTaskId: (id: string) => void;
callId: string;
setCallId: (id: string) => void;
a2aMethod: string;
setA2aMethod: (method: string) => void;
authMethod: string;
setAuthMethod: (method: string) => void;
generateNewIds: () => void;
sendRequest: () => Promise<void>;
sendStreamRequestWithEventSource: () => Promise<void>;
isLoading: boolean;
isStreaming: boolean;
streamResponse: string;
streamStatus: string;
streamHistory: string[];
streamComplete: boolean;
response: string;
copyToClipboard: (text: string) => void;
renderStatusIndicator: () => JSX.Element | null;
renderTypingIndicator: () => JSX.Element | null;
}
export function LabSection({
agentUrl,
setAgentUrl,
apiKey,
setApiKey,
message,
setMessage,
sessionId,
setSessionId,
taskId,
setTaskId,
callId,
setCallId,
a2aMethod,
setA2aMethod,
authMethod,
setAuthMethod,
generateNewIds,
sendRequest,
sendStreamRequestWithEventSource,
isLoading,
isStreaming,
streamResponse,
streamStatus,
streamHistory,
streamComplete,
response,
copyToClipboard,
renderStatusIndicator,
renderTypingIndicator
}: LabSectionProps) {
const [labMode, setLabMode] = useState("http");
return (
<>
<Card className="bg-[#1a1a1a] border-[#333] text-white mb-6">
<CardHeader>
<CardTitle className="text-emerald-400">A2A Test Lab</CardTitle>
<CardDescription className="text-neutral-400">
Test your A2A agent with different communication methods
</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="http" onValueChange={setLabMode}>
<TabsList className="bg-[#222] border-[#333] mb-4">
<TabsTrigger value="http" className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400">
HTTP Request
</TabsTrigger>
<TabsTrigger value="stream" className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400">
Streaming
</TabsTrigger>
</TabsList>
<TabsContent value="http">
<HttpLabForm
agentUrl={agentUrl}
setAgentUrl={setAgentUrl}
apiKey={apiKey}
setApiKey={setApiKey}
message={message}
setMessage={setMessage}
sessionId={sessionId}
setSessionId={setSessionId}
taskId={taskId}
setTaskId={setTaskId}
callId={callId}
setCallId={setCallId}
a2aMethod={a2aMethod}
setA2aMethod={setA2aMethod}
authMethod={authMethod}
setAuthMethod={setAuthMethod}
generateNewIds={generateNewIds}
sendRequest={sendRequest}
isLoading={isLoading}
/>
</TabsContent>
<TabsContent value="stream">
<StreamLabForm
agentUrl={agentUrl}
setAgentUrl={setAgentUrl}
apiKey={apiKey}
setApiKey={setApiKey}
message={message}
setMessage={setMessage}
sessionId={sessionId}
setSessionId={setSessionId}
taskId={taskId}
setTaskId={setTaskId}
callId={callId}
setCallId={setCallId}
authMethod={authMethod}
sendStreamRequest={sendStreamRequestWithEventSource}
isStreaming={isStreaming}
streamResponse={streamResponse}
streamStatus={streamStatus}
streamHistory={streamHistory}
renderStatusIndicator={renderStatusIndicator}
renderTypingIndicator={renderTypingIndicator}
/>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{response && labMode === "http" && (
<Card className="bg-[#1a1a1a] border-[#333] text-white">
<CardHeader>
<CardTitle className="text-emerald-400">Response</CardTitle>
</CardHeader>
<CardContent>
<div className="relative">
<CodeBlock
text={response}
language="json"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(response)}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
</>
);
}

View File

@@ -0,0 +1,179 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/documentation/components/QuickStartTemplates.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
MessageSquare,
FileText,
Zap,
Settings,
Users,
Play
} from "lucide-react";
interface QuickStartTemplate {
id: string;
name: string;
description: string;
icon: any;
method: string;
message: string;
useCase: string;
}
interface QuickStartTemplatesProps {
onSelectTemplate: (template: QuickStartTemplate) => void;
}
export function QuickStartTemplates({ onSelectTemplate }: QuickStartTemplatesProps) {
const templates: QuickStartTemplate[] = [
{
id: "hello",
name: "Hello Agent",
description: "Simple greeting to test agent connectivity",
icon: MessageSquare,
method: "message/send",
message: "Hello! Can you introduce yourself and tell me what you can do?",
useCase: "Basic connectivity test"
},
{
id: "analysis",
name: "Data Analysis",
description: "Request data analysis and insights",
icon: FileText,
method: "message/send",
message: "Please analyze the current market trends in AI technology and provide key insights with recommendations.",
useCase: "Complex analytical tasks"
},
{
id: "streaming",
name: "Long Content",
description: "Generate lengthy content with streaming",
icon: Zap,
method: "message/stream",
message: "Write a comprehensive guide about implementing the Agent2Agent protocol, including technical details, best practices, and code examples.",
useCase: "Streaming responses"
},
{
id: "task-query",
name: "Task Status",
description: "Query the status of a running task",
icon: Settings,
method: "tasks/get",
message: "",
useCase: "Task management"
},
{
id: "capabilities",
name: "Agent Capabilities",
description: "Discover agent capabilities and skills",
icon: Users,
method: "agent/authenticatedExtendedCard",
message: "",
useCase: "Agent discovery"
}
];
const getMethodColor = (method: string) => {
switch (method) {
case 'message/send': return 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30';
case 'message/stream': return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
case 'tasks/get': return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
case 'tasks/cancel': return 'bg-red-500/20 text-red-400 border-red-500/30';
case 'agent/authenticatedExtendedCard': return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
default: return 'bg-neutral-500/20 text-neutral-400 border-neutral-500/30';
}
};
return (
<Card className="bg-[#1a1a1a] border-[#333] text-white mb-6">
<CardHeader>
<CardTitle className="text-emerald-400 flex items-center">
<Play className="h-5 w-5 mr-2" />
Quick Start Templates
</CardTitle>
<p className="text-neutral-400 text-sm">
Choose a template to quickly test different A2A protocol methods
</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{templates.map((template) => {
const IconComponent = template.icon;
return (
<div
key={template.id}
className="bg-[#222] border border-[#444] rounded-lg p-4 hover:border-emerald-500/50 transition-colors cursor-pointer group"
onClick={() => onSelectTemplate(template)}
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center space-x-2">
<div className="bg-emerald-500/20 p-2 rounded-lg">
<IconComponent className="h-4 w-4 text-emerald-400" />
</div>
<div>
<h3 className="font-semibold text-white text-sm">{template.name}</h3>
<p className="text-xs text-neutral-400">{template.useCase}</p>
</div>
</div>
</div>
<p className="text-xs text-neutral-300 mb-3 line-clamp-2">
{template.description}
</p>
<div className="flex items-center justify-between">
<Badge className={`text-xs ${getMethodColor(template.method)}`}>
{template.method}
</Badge>
<Button
size="sm"
variant="ghost"
className="text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10 text-xs px-2 py-1 h-auto opacity-0 group-hover:opacity-100 transition-opacity"
>
Use Template
</Button>
</div>
</div>
);
})}
</div>
<div className="mt-4 p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<p className="text-blue-300 text-xs">
💡 <strong>Tip:</strong> These templates automatically configure the correct A2A method and provide example messages.
Simply select one and customize the agent URL and authentication.
</p>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,366 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/documentation/components/StreamLabForm.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
import { useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Separator } from "@/components/ui/separator";
import { Send, Paperclip, X, FileText, Image, File } from "lucide-react";
import { toast } from "@/hooks/use-toast";
interface AttachedFile {
name: string;
type: string;
size: number;
base64: string;
}
interface StreamLabFormProps {
agentUrl: string;
setAgentUrl: (url: string) => void;
apiKey: string;
setApiKey: (key: string) => void;
message: string;
setMessage: (message: string) => void;
sessionId: string;
setSessionId: (id: string) => void;
taskId: string;
setTaskId: (id: string) => void;
callId: string;
setCallId: (id: string) => void;
sendStreamRequest: () => Promise<void>;
isStreaming: boolean;
streamResponse: string;
streamStatus: string;
streamHistory: string[];
renderStatusIndicator: () => JSX.Element | null;
renderTypingIndicator: () => JSX.Element | null;
setFiles?: (files: AttachedFile[]) => void;
authMethod: string;
currentTaskId?: string | null;
}
export function StreamLabForm({
agentUrl,
setAgentUrl,
apiKey,
setApiKey,
message,
setMessage,
sessionId,
setSessionId,
taskId,
setTaskId,
callId,
setCallId,
sendStreamRequest,
isStreaming,
streamResponse,
streamStatus,
streamHistory,
renderStatusIndicator,
renderTypingIndicator,
setFiles = () => {},
authMethod,
currentTaskId
}: StreamLabFormProps) {
const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const clearAttachedFiles = () => {
setAttachedFiles([]);
};
const handleSendStreamRequest = async () => {
await sendStreamRequest();
clearAttachedFiles();
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || e.target.files.length === 0) return;
const maxFileSize = 5 * 1024 * 1024; // 5MB limit
const newFiles = Array.from(e.target.files);
if (attachedFiles.length + newFiles.length > 5) {
toast({
title: "File limit exceeded",
description: "You can only attach up to 5 files.",
variant: "destructive"
});
return;
}
const filesToAdd: AttachedFile[] = [];
for (const file of newFiles) {
if (file.size > maxFileSize) {
toast({
title: "File too large",
description: `The file ${file.name} exceeds the 5MB size limit.`,
variant: "destructive"
});
continue;
}
try {
const base64 = await readFileAsBase64(file);
filesToAdd.push({
name: file.name,
type: file.type,
size: file.size,
base64: base64
});
} catch (error) {
console.error("Failed to read file:", error);
toast({
title: "Failed to read file",
description: `Could not process ${file.name}.`,
variant: "destructive"
});
}
}
if (filesToAdd.length > 0) {
const updatedFiles = [...attachedFiles, ...filesToAdd];
setAttachedFiles(updatedFiles);
setFiles(updatedFiles);
}
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const readFileAsBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const base64 = result.split(',')[1]; // Remove data URL prefix
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
const removeFile = (index: number) => {
const updatedFiles = attachedFiles.filter((_, i) => i !== index);
setAttachedFiles(updatedFiles);
setFiles(updatedFiles);
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const isImageFile = (type: string): boolean => {
return type.startsWith('image/');
};
return (
<div className="space-y-4">
{/* A2A Streaming Information */}
<div className="p-4 bg-[#1a1a1a] border border-[#333] rounded-md">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-emerald-400">A2A Streaming Mode</span>
<span className="text-xs text-neutral-400">Method: message/stream</span>
</div>
<div className="text-xs text-neutral-400">
Authentication: {authMethod === "bearer" ? "Bearer Token" : "API Key"} header
</div>
{currentTaskId && (
<div className="mt-2 pt-2 border-t border-[#333]">
<span className="text-xs text-neutral-400">Current Task ID: </span>
<span className="text-xs text-emerald-400 font-mono">{currentTaskId}</span>
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm text-neutral-400 mb-1 block">Agent URL</label>
<Input
value={agentUrl}
onChange={(e) => setAgentUrl(e.target.value)}
placeholder="http://localhost:8000/api/v1/a2a/your-agent-id"
className="bg-[#222] border-[#444] text-white"
disabled={isStreaming}
/>
</div>
<div>
<label className="text-sm text-neutral-400 mb-1 block">
{authMethod === "bearer" ? "Bearer Token" : "API Key"} (optional)
</label>
<Input
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={authMethod === "bearer" ? "Your Bearer token" : "Your API key"}
className="bg-[#222] border-[#444] text-white"
disabled={isStreaming}
/>
</div>
</div>
<div>
<label className="text-sm text-neutral-400 mb-1 block">Message</label>
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="What is the A2A protocol?"
className="bg-[#222] border-[#444] text-white min-h-[100px]"
disabled={isStreaming}
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-neutral-400">
Attach Files (up to 5, max 5MB each)
</label>
<Button
variant="outline"
size="sm"
className="bg-[#222] border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
onClick={() => fileInputRef.current?.click()}
disabled={attachedFiles.length >= 5 || isStreaming}
>
<Paperclip className="h-4 w-4 mr-2" />
Browse Files
</Button>
<input
type="file"
ref={fileInputRef}
className="hidden"
multiple
onChange={handleFileSelect}
disabled={isStreaming}
/>
</div>
{attachedFiles.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{attachedFiles.map((file, index) => (
<div
key={index}
className="flex items-center gap-1.5 bg-[#333] text-white rounded-md p-1.5 text-xs"
>
{isImageFile(file.type) ? (
<Image className="h-4 w-4 text-emerald-400" />
) : file.type === 'application/pdf' ? (
<FileText className="h-4 w-4 text-emerald-400" />
) : (
<File className="h-4 w-4 text-emerald-400" />
)}
<span className="max-w-[150px] truncate">{file.name}</span>
<span className="text-neutral-400">({formatFileSize(file.size)})</span>
<button
onClick={() => removeFile(index)}
className="ml-1 text-neutral-400 hover:text-white transition-colors"
disabled={isStreaming}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
<Separator className="my-4 bg-[#333]" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-sm text-neutral-400 mb-1 block">Session ID</label>
<Input
value={sessionId}
onChange={(e) => setSessionId(e.target.value)}
className="bg-[#222] border-[#444] text-white"
disabled={isStreaming}
/>
</div>
<div>
<label className="text-sm text-neutral-400 mb-1 block">Task ID</label>
<Input
value={taskId}
onChange={(e) => setTaskId(e.target.value)}
className="bg-[#222] border-[#444] text-white"
disabled={isStreaming}
/>
</div>
<div>
<label className="text-sm text-neutral-400 mb-1 block">Call ID</label>
<Input
value={callId}
onChange={(e) => setCallId(e.target.value)}
className="bg-[#222] border-[#444] text-white"
disabled={isStreaming}
/>
</div>
</div>
<Button
onClick={handleSendStreamRequest}
disabled={isStreaming}
className="bg-emerald-400 text-black hover:bg-[#00cc7d] w-full mt-4"
>
{isStreaming ? (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-black mr-2"></div>
Streaming...
</div>
) : (
<div className="flex items-center">
<Send className="mr-2 h-4 w-4" />
Start Streaming
</div>
)}
</Button>
{streamResponse && (
<div className="mt-6 rounded-md bg-[#222] border border-[#333] p-4">
<div className="flex justify-between items-center mb-2">
<h3 className="text-lg font-medium text-white">Response</h3>
{renderStatusIndicator && renderStatusIndicator()}
</div>
<div className="whitespace-pre-wrap text-sm font-mono text-neutral-300">
{streamResponse}
</div>
{renderTypingIndicator && renderTypingIndicator()}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,470 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/documentation/components/TechnicalDetailsSection.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ClipboardCopy } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import { CodeBlock } from "@/app/documentation/components/CodeBlock";
interface TechnicalDetailsSectionProps {
copyToClipboard: (text: string) => void;
}
export function TechnicalDetailsSection({ copyToClipboard }: TechnicalDetailsSectionProps) {
return (
<Card className="bg-[#1a1a1a] border-[#333] text-white">
<CardHeader>
<CardTitle className="text-emerald-400">Technical Details of the Methods</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-emerald-400 text-lg font-medium mb-2">Method message/send</h3>
<p className="text-neutral-300 mb-4">
The <code className="bg-[#333] px-1 rounded">message/send</code> method performs a standard HTTP request and waits for the complete response.
</p>
<div className="space-y-4">
<div>
<h4 className="font-medium text-white mb-2">Request:</h4>
<div className="relative">
<CodeBlock
text={JSON.stringify({
jsonrpc: "2.0",
id: "call-123",
method: "message/send",
params: {
id: "task-456",
sessionId: "session-789",
message: {
role: "user",
parts: [
{
type: "text",
text: "Your question here"
}
]
}
}
}, null, 2)}
language="json"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(JSON.stringify({
jsonrpc: "2.0",
id: "call-123",
method: "message/send",
params: {
id: "task-456",
sessionId: "session-789",
message: {
role: "user",
parts: [
{
type: "text",
text: "Your question here"
}
]
}
}
}, null, 2))}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<h4 className="font-medium text-white mb-2">Headers:</h4>
<div className="relative">
<CodeBlock
text={`Content-Type: application/json
x-api-key: YOUR_API_KEY`}
language="text"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(`Content-Type: application/json
x-api-key: YOUR_API_KEY`)}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<h4 className="font-medium text-white mb-2">Response:</h4>
<div className="relative">
<CodeBlock
text={JSON.stringify({
jsonrpc: "2.0",
result: {
status: {
state: "completed",
message: {
role: "model",
parts: [
{
type: "text",
text: "Complete agent response here."
}
]
}
}
},
id: "call-123"
}, null, 2)}
language="json"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(JSON.stringify({
jsonrpc: "2.0",
result: {
status: {
state: "completed",
message: {
role: "model",
parts: [
{
type: "text",
text: "Complete agent response here."
}
]
}
}
},
id: "call-123"
}, null, 2))}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
<Separator className="my-6 bg-[#333]" />
<div>
<h3 className="text-emerald-400 text-lg font-medium mb-2">Method message/stream</h3>
<p className="text-neutral-300 mb-4">
The <code className="bg-[#333] px-1 rounded">message/stream</code> method uses Server-Sent Events (SSE) to receive real-time updates.
</p>
<div className="space-y-4">
<div>
<h4 className="font-medium text-white mb-2">Request:</h4>
<div className="relative">
<CodeBlock
text={JSON.stringify({
jsonrpc: "2.0",
id: "call-123",
method: "message/stream",
params: {
id: "task-456",
sessionId: "session-789",
message: {
role: "user",
parts: [
{
type: "text",
text: "Your question here"
}
]
}
}
}, null, 2)}
language="json"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(JSON.stringify({
jsonrpc: "2.0",
id: "call-123",
method: "message/stream",
params: {
id: "task-456",
sessionId: "session-789",
message: {
role: "user",
parts: [
{
type: "text",
text: "Your question here"
}
]
}
}
}, null, 2))}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<h4 className="font-medium text-white mb-2">Headers:</h4>
<div className="relative">
<CodeBlock
text={`Content-Type: application/json
x-api-key: YOUR_API_KEY
Accept: text/event-stream`}
language="text"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(`Content-Type: application/json
x-api-key: YOUR_API_KEY
Accept: text/event-stream`)}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<h4 className="font-medium text-white mb-2">SSE Event Format:</h4>
<p className="text-neutral-300 mb-4">
Each event follows the standard Server-Sent Events (SSE) format, with the "data:" prefix followed by the JSON content and terminated by two newlines ("\n\n"):
</p>
<div className="relative">
<CodeBlock
text={`data: {"jsonrpc":"2.0","id":"call-123","result":{"id":"task-456","status":{"state":"working","message":{"role":"agent","parts":[{"type":"text","text":"Processing..."}]},"timestamp":"2025-05-13T18:10:37.219Z"},"final":false}}
data: {"jsonrpc":"2.0","id":"call-123","result":{"id":"task-456","status":{"state":"completed","timestamp":"2025-05-13T18:10:40.456Z"},"final":true}}
`}
language="text"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(`data: {"jsonrpc":"2.0","id":"call-123","result":{"id":"task-456","status":{"state":"working","message":{"role":"agent","parts":[{"type":"text","text":"Processing..."}]},"timestamp":"2025-05-13T18:10:37.219Z"},"final":false}}
data: {"jsonrpc":"2.0","id":"call-123","result":{"id":"task-456","status":{"state":"completed","timestamp":"2025-05-13T18:10:40.456Z"},"final":true}}
`)}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<h4 className="font-medium text-white mb-2">Event Types:</h4>
<ul className="list-disc list-inside text-neutral-300 space-y-2 mb-4">
<li><span className="text-emerald-400">Status Updates</span>: Contains the <code className="bg-[#333] px-1 rounded">status</code> field with information about the task status.</li>
<li><span className="text-emerald-400">Artifact Updates</span>: Contains the <code className="bg-[#333] px-1 rounded">artifact</code> field with the content generated by the agent.</li>
<li><span className="text-emerald-400">Ping Events</span>: Simple events with the format <code className="bg-[#333] px-1 rounded">: ping</code> to keep the connection active.</li>
</ul>
</div>
<div>
<h4 className="font-medium text-white mb-2">Client Consumption:</h4>
<p className="text-neutral-300 mb-2">
For a better experience, we recommend using the <code className="bg-[#333] px-1 rounded">EventSource</code> API to consume the events:
</p>
<div className="relative">
<CodeBlock
text={`// After receiving the initial response via POST, use EventSource to stream
const eventSource = new EventSource(\`/api/v1/a2a/\${agentId}/stream?taskId=\${taskId}&key=\${apiKey}\`);
// Process the received events
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
// Process different types of events
if (data.result) {
// 1. Process status updates
if (data.result.status) {
const state = data.result.status.state; // "working", "completed", etc.
// Check if there is a text message
if (data.result.status.message?.parts) {
const textParts = data.result.status.message.parts
.filter(part => part.type === "text")
.map(part => part.text)
.join("");
// Update UI with the text
updateUI(textParts);
}
// Check if it is the final event
if (data.result.final === true) {
eventSource.close(); // Close connection
}
}
// 2. Process the generated artifacts
if (data.result.artifact) {
const artifact = data.result.artifact;
// Extract text from the artifact
if (artifact.parts) {
const artifactText = artifact.parts
.filter(part => part.type === "text")
.map(part => part.text)
.join("");
// Update UI with the artifact
updateArtifactUI(artifactText);
}
}
}
};
// Handle errors
eventSource.onerror = (error) => {
console.error("Error in SSE:", error);
eventSource.close();
};`}
language="javascript"
/>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 text-white hover:bg-[#333]"
onClick={() => copyToClipboard(`// After receiving the initial response via POST, use EventSource to stream
const eventSource = new EventSource(\`/api/v1/a2a/\${agentId}/stream?taskId=\${taskId}&key=\${apiKey}\`);
// Process the received events
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
// Process different types of events
if (data.result) {
// 1. Process status updates
if (data.result.status) {
const state = data.result.status.state; // "working", "completed", etc.
// Check if there is a text message
if (data.result.status.message?.parts) {
const textParts = data.result.status.message.parts
.filter(part => part.type === "text")
.map(part => part.text)
.join("");
// Update UI with the text
updateUI(textParts);
}
// Check if it is the final event
if (data.result.final === true) {
eventSource.close(); // Close connection
}
}
// 2. Process the generated artifacts
if (data.result.artifact) {
const artifact = data.result.artifact;
// Extract text from the artifact
if (artifact.parts) {
const artifactText = artifact.parts
.filter(part => part.type === "text")
.map(part => part.text)
.join("");
// Update UI with the artifact
updateArtifactUI(artifactText);
}
}
}
};
// Handle errors
eventSource.onerror = (error) => {
console.error("Error in SSE:", error);
eventSource.close();
};`)}
>
<ClipboardCopy className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<h4 className="font-medium text-white mb-2">Possible task states:</h4>
<ul className="list-disc list-inside text-neutral-300 space-y-1">
<li><span className="text-emerald-400">submitted</span>: Task sent but not yet processed</li>
<li><span className="text-emerald-400">working</span>: Task being processed by the agent</li>
<li><span className="text-emerald-400">completed</span>: Task completed successfully</li>
<li><span className="text-emerald-400">input-required</span>: Agent waiting for additional user input</li>
<li><span className="text-emerald-400">failed</span>: Task failed during processing</li>
<li><span className="text-emerald-400">canceled</span>: Task was canceled</li>
</ul>
</div>
</div>
</div>
<div className="bg-[#222] p-4 rounded-md border border-[#444]">
<h4 className="font-medium text-white mb-2">Possible task states:</h4>
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2">
<li className="flex items-center">
<span className="w-3 h-3 bg-blue-500 rounded-full mr-2"></span>
<span className="text-neutral-300"><strong>submitted</strong>: Task sent</span>
</li>
<li className="flex items-center">
<span className="w-3 h-3 bg-yellow-500 rounded-full mr-2"></span>
<span className="text-neutral-300"><strong>working</strong>: Task being processed</span>
</li>
<li className="flex items-center">
<span className="w-3 h-3 bg-purple-500 rounded-full mr-2"></span>
<span className="text-neutral-300"><strong>input-required</strong>: Agent waiting for additional user input</span>
</li>
<li className="flex items-center">
<span className="w-3 h-3 bg-green-500 rounded-full mr-2"></span>
<span className="text-neutral-300"><strong>completed</strong>: Task completed successfully</span>
</li>
<li className="flex items-center">
<span className="w-3 h-3 bg-neutral-500 rounded-full mr-2"></span>
<span className="text-neutral-300"><strong>canceled</strong>: Task canceled</span>
</li>
<li className="flex items-center">
<span className="w-3 h-3 bg-red-500 rounded-full mr-2"></span>
<span className="text-neutral-300"><strong>failed</strong>: Task processing failed</span>
</li>
</ul>
</div>
</CardContent>
</Card>
);
}

File diff suppressed because it is too large Load Diff

87
frontend/app/globals.css Normal file
View File

@@ -0,0 +1,87 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/globals.css │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 142.1 70.6% 45.3%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 142.1 70.6% 45.3%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 142.1 70.6% 45.3%;
--primary-foreground: 144.9 80.4% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 142.4 71.8% 29.2%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

73
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,73 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/layout.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
import type React from "react";
import "./globals.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/toaster";
import ClientLayout from "./client-layout";
import ImpersonationBar from "@/components/ImpersonationBar";
import { PublicEnvScript } from "next-runtime-env";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Evo AI",
description: "AI Multi-Agent Platform",
icons: {
icon: "/favicon.svg",
},
generator: "v0.dev",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<PublicEnvScript />
</head>
<body className={inter.className}>
<ImpersonationBar />
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem={false}
>
<ClientLayout>{children}</ClientLayout>
<Toaster />
</ThemeProvider>
</body>
</html>
);
}

556
frontend/app/login/page.tsx Normal file
View File

@@ -0,0 +1,556 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/login/page.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import type React from "react";
import { useState, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/hooks/use-toast";
import Link from "next/link";
import { login, forgotPassword, getMe, register, resendVerification } from "@/services/authService";
import { CheckCircle2, AlertCircle } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const [activeTab, setActiveTab] = useState("login");
const [showRegisterSuccess, setShowRegisterSuccess] = useState(false);
const [showForgotSuccess, setShowForgotSuccess] = useState(false);
const [redirectSeconds, setRedirectSeconds] = useState(5);
const redirectTimer = useRef<NodeJS.Timeout | null>(null);
const [loginError, setLoginError] = useState("");
const [isEmailNotVerified, setIsEmailNotVerified] = useState(false);
const [isResendingVerification, setIsResendingVerification] = useState(false);
const [loginData, setLoginData] = useState({
email: "",
password: "",
});
const [registerData, setRegisterData] = useState({
email: "",
password: "",
confirmPassword: "",
name: "",
});
const [forgotEmail, setForgotEmail] = useState("");
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setLoginError("");
setIsEmailNotVerified(false);
try {
const response = await login({
email: loginData.email,
password: loginData.password,
});
if (response.data.access_token) {
localStorage.setItem("access_token", response.data.access_token);
document.cookie = `access_token=${
response.data.access_token
}; path=/; max-age=${60 * 60 * 24 * 7}`;
const meResponse = await getMe();
if (meResponse.data) {
localStorage.setItem("user", JSON.stringify(meResponse.data));
document.cookie = `user=${encodeURIComponent(
JSON.stringify(meResponse.data)
)}; path=/; max-age=${60 * 60 * 24 * 7}`;
}
}
router.push("/");
} catch (error: any) {
let errorDetail = "Check your credentials and try again.";
if (error?.response?.data) {
if (typeof error.response.data.detail === 'string') {
errorDetail = error.response.data.detail;
} else if (error.response.data.detail) {
errorDetail = JSON.stringify(error.response.data.detail);
}
}
if (errorDetail === "Email not verified") {
setIsEmailNotVerified(true);
}
setLoginError(errorDetail);
} finally {
setIsLoading(false);
}
};
const handleResendVerification = async () => {
if (!loginData.email) return;
setIsResendingVerification(true);
try {
await resendVerification({ email: loginData.email });
toast({
title: "Verification email sent",
description: "Please check your inbox to verify your account.",
});
} catch (error: any) {
toast({
title: "Error sending verification email",
description:
error?.response?.data?.detail ||
"Unable to send verification email. Please try again.",
variant: "destructive",
});
} finally {
setIsResendingVerification(false);
}
};
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
if (!registerData.password) {
toast({
title: "Password required",
description: "Please enter a password.",
variant: "destructive",
});
return;
}
if (registerData.password.length < 8) {
toast({
title: "Password too short",
description: "Password must be at least 8 characters long.",
variant: "destructive",
});
return;
}
if (registerData.password !== registerData.confirmPassword) {
toast({
title: "Passwords don't match",
description: "Please make sure your passwords match.",
variant: "destructive",
});
return;
}
setIsLoading(true);
try {
await register({
email: registerData.email,
password: registerData.password,
name: registerData.name,
});
toast({
title: "Registration successful",
description: "Please check your email to verify your account.",
});
setShowRegisterSuccess(true);
setRedirectSeconds(5);
if (redirectTimer.current) clearTimeout(redirectTimer.current);
redirectTimer.current = setInterval(() => {
setRedirectSeconds((s) => s - 1);
}, 1000);
} catch (error: any) {
let errorMessage = "Unable to register. Please try again.";
if (error?.response?.data) {
if (typeof error.response.data.detail === 'string') {
errorMessage = error.response.data.detail;
} else if (error.response.data.detail) {
errorMessage = JSON.stringify(error.response.data.detail);
}
}
toast({
title: "Error registering",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if ((showRegisterSuccess || showForgotSuccess) && redirectSeconds === 0) {
setShowRegisterSuccess(false);
setShowForgotSuccess(false);
setActiveTab("login");
setRedirectSeconds(5);
if (redirectTimer.current) clearTimeout(redirectTimer.current);
}
}, [showRegisterSuccess, showForgotSuccess, redirectSeconds]);
useEffect(() => {
if (!(showRegisterSuccess || showForgotSuccess) && redirectTimer.current) {
clearInterval(redirectTimer.current);
}
}, [showRegisterSuccess, showForgotSuccess]);
const handleForgotPassword = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
await forgotPassword({ email: forgotEmail });
toast({
title: "Email sent",
description: "Check your inbox to reset your password.",
});
setShowForgotSuccess(true);
setRedirectSeconds(5);
if (redirectTimer.current) clearTimeout(redirectTimer.current);
redirectTimer.current = setInterval(() => {
setRedirectSeconds((s) => s - 1);
}, 1000);
} catch (error: any) {
let errorMessage = "Unable to send the reset password email. Please try again.";
if (error?.response?.data) {
if (typeof error.response.data.detail === 'string') {
errorMessage = error.response.data.detail;
} else if (error.response.data.detail) {
errorMessage = JSON.stringify(error.response.data.detail);
}
}
toast({
title: "Error sending email",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-[#121212] p-4">
<div className="mb-8">
<Image
src="https://evolution-api.com/files/evo/logo-evo-ai.svg"
alt="Evolution API"
width={140}
height={30}
priority
/>
</div>
<Card className="w-full max-w-md bg-[#1a1a1a] border-[#333]">
{showRegisterSuccess ? (
<div className="flex flex-col items-center justify-center p-8">
<CheckCircle2 className="w-12 h-12 text-green-500 mb-4" />
<h2 className="text-xl font-semibold text-white mb-2">Registration successful!</h2>
<p className="text-neutral-300 mb-2 text-center">
Please check your email to confirm your account.<br />
Redirecting to login in {redirectSeconds} seconds...
</p>
</div>
) : showForgotSuccess ? (
<div className="flex flex-col items-center justify-center p-8">
<CheckCircle2 className="w-12 h-12 text-green-500 mb-4" />
<h2 className="text-xl font-semibold text-white mb-2">Email sent!</h2>
<p className="text-neutral-300 mb-2 text-center">
Check your inbox to reset your password.<br />
Redirecting to login in {redirectSeconds} seconds...
</p>
</div>
) : (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3 bg-[#222]">
<TabsTrigger
value="login"
className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400"
>
Login
</TabsTrigger>
<TabsTrigger
value="register"
className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400"
>
Register
</TabsTrigger>
<TabsTrigger
value="forgot"
className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400"
>
Forgot
</TabsTrigger>
</TabsList>
<TabsContent value="login">
<form onSubmit={handleLogin}>
<CardHeader>
<CardTitle className="text-white">Login</CardTitle>
<CardDescription className="text-neutral-400">
Enter your credentials to access the system.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email" className="text-neutral-300">
Email
</Label>
<Input
id="email"
type="email"
placeholder="your@email.com"
required
value={loginData.email}
onChange={(e) =>
setLoginData({ ...loginData, email: e.target.value })
}
className="bg-[#222] border-[#444] text-white"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password" className="text-neutral-300">
Password
</Label>
</div>
<Input
id="password"
type="password"
required
value={loginData.password}
onChange={(e) =>
setLoginData({ ...loginData, password: e.target.value })
}
className="bg-[#222] border-[#444] text-white"
/>
</div>
{loginError && (
<div className="text-red-500 text-sm mt-2" data-testid="login-error">
{isEmailNotVerified ? (
<div className="flex flex-col space-y-2">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
<span>{loginError}</span>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleResendVerification}
disabled={isResendingVerification}
className="text-emerald-400 border-emerald-400 hover:bg-emerald-400/10"
>
{isResendingVerification ? "Sending..." : "Resend verification email"}
</Button>
</div>
) : (
loginError
)}
</div>
)}
</CardContent>
<CardFooter>
<Button
type="submit"
className="w-full bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={isLoading}
>
{isLoading ? "Entering..." : "Enter"}
</Button>
</CardFooter>
</form>
</TabsContent>
<TabsContent value="register">
<form onSubmit={handleRegister}>
<CardHeader>
<CardTitle className="text-white">Register</CardTitle>
<CardDescription className="text-neutral-400">
Create a new account to get started.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="register-name" className="text-neutral-300">
Name
</Label>
<Input
id="register-name"
type="text"
placeholder="Your name"
required
value={registerData.name}
onChange={(e) =>
setRegisterData({ ...registerData, name: e.target.value })
}
className="bg-[#222] border-[#444] text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="register-email" className="text-neutral-300">
Email
</Label>
<Input
id="register-email"
type="email"
placeholder="your@email.com"
required
value={registerData.email}
onChange={(e) =>
setRegisterData({
...registerData,
email: e.target.value,
})
}
className="bg-[#222] border-[#444] text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="register-password" className="text-neutral-300">
Password
</Label>
<Input
id="register-password"
type="password"
required
value={registerData.password}
onChange={(e) =>
setRegisterData({
...registerData,
password: e.target.value,
})
}
className="bg-[#222] border-[#444] text-white"
/>
</div>
<div className="space-y-2">
<Label
htmlFor="register-confirm-password"
className="text-neutral-300"
>
Confirm Password
</Label>
<Input
id="register-confirm-password"
type="password"
required
value={registerData.confirmPassword}
onChange={(e) =>
setRegisterData({
...registerData,
confirmPassword: e.target.value,
})
}
className="bg-[#222] border-[#444] text-white"
/>
</div>
</CardContent>
<CardFooter>
<Button
type="submit"
className="w-full bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={isLoading}
>
{isLoading ? "Registering..." : "Register"}
</Button>
</CardFooter>
</form>
</TabsContent>
<TabsContent value="forgot">
<form onSubmit={handleForgotPassword}>
<CardHeader>
<CardTitle className="text-white">Forgot Password</CardTitle>
<CardDescription className="text-neutral-400">
Enter your email to receive a password reset link.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="forgot-email" className="text-neutral-300">
Email
</Label>
<Input
id="forgot-email"
type="email"
placeholder="your@email.com"
required
value={forgotEmail}
onChange={(e) => setForgotEmail(e.target.value)}
className="bg-[#222] border-[#444] text-white"
/>
</div>
</CardContent>
<CardFooter>
<Button
type="submit"
className="w-full bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={isLoading}
>
{isLoading ? "Sending..." : "Send Link"}
</Button>
</CardFooter>
</form>
</TabsContent>
</Tabs>
)}
</Card>
<div className="mt-4 text-center text-sm text-neutral-500">
<p>
By using this service, you agree to our{" "}
<Link href="#" className="text-emerald-400 hover:underline">
Terms of Service
</Link>{" "}
e{" "}
<Link href="#" className="text-emerald-400 hover:underline">
Privacy Policy
</Link>
.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/logout/page.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client"
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { Card, CardContent } from "@/components/ui/card"
import { LogOut } from "lucide-react"
export default function LogoutPage() {
const router = useRouter()
useEffect(() => {
const performLogout = () => {
localStorage.removeItem("access_token")
localStorage.removeItem("user")
localStorage.removeItem("impersonatedClient")
localStorage.removeItem("isImpersonating")
document.cookie = "access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"
document.cookie = "user=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"
document.cookie = "impersonatedClient=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"
document.cookie = "isImpersonating=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"
setTimeout(() => {
router.push("/login")
}, 1500)
}
performLogout()
}, [router])
return (
<div className="container mx-auto p-6 flex items-center justify-center min-h-[60vh]">
<Card className="bg-[#1a1a1a] border-[#333] w-full max-w-md p-8">
<CardContent className="flex flex-col items-center justify-center gap-4">
<div className="w-16 h-16 rounded-full bg-[#222] flex items-center justify-center animate-pulse">
<LogOut className="h-8 w-8 text-emerald-400" />
</div>
<h2 className="text-white text-xl font-medium mt-4">Logging out...</h2>
<p className="text-neutral-400 text-center">
You are being logged out of the system.
</p>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,31 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/mcp-servers/loading.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
export default function Loading() {
return null
}

View File

@@ -0,0 +1,940 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/mcp-servers/page.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client"
import type React from "react"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { useToast } from "@/components/ui/use-toast"
import { Plus, MoreHorizontal, Edit, Trash2, Search, PenToolIcon as Tool } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import {
createMCPServer,
listMCPServers,
getMCPServer,
updateMCPServer,
deleteMCPServer,
} from "@/services/mcpServerService"
import { MCPServer, MCPServerCreate, ToolConfig } from "@/types/mcpServer"
export default function MCPServersPage() {
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [selectedServer, setSelectedServer] = useState<MCPServer | null>(null)
const [activeTab, setActiveTab] = useState("basic")
const [serverData, setServerData] = useState<{
name: string
description: string
type: string
config_type: "sse" | "studio"
url: string
headers: { key: string; value: string }[]
command: string
args: string
environments: { key: string }[]
tools: ToolConfig[]
}>({
name: "",
description: "",
type: "official",
config_type: "sse",
url: "",
headers: [{ key: "x-api-key", value: "" }],
command: "npx",
args: "",
environments: [],
tools: [],
})
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(10)
const [total, setTotal] = useState(0)
const [mcpServers, setMcpServers] = useState<MCPServer[]>([])
useEffect(() => {
const fetchServers = async () => {
setIsLoading(true)
try {
const res = await listMCPServers((page - 1) * limit, limit)
setMcpServers(res.data)
setTotal(res.data.length)
} catch (error) {
toast({
title: "Error loading MCP servers",
description: "Unable to load servers.",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
fetchServers()
}, [page, limit])
// Search server by name/description (local filter)
const filteredServers = Array.isArray(mcpServers)
? mcpServers.filter(
(server) =>
server.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(server.description || "").toLowerCase().includes(searchQuery.toLowerCase()),
)
: []
const handleAddHeader = () => {
setServerData({
...serverData,
headers: [...serverData.headers, { key: "", value: "" }],
})
}
const handleRemoveHeader = (index: number) => {
const updatedHeaders = [...serverData.headers]
updatedHeaders.splice(index, 1)
setServerData({
...serverData,
headers: updatedHeaders,
})
}
const handleHeaderChange = (index: number, field: "key" | "value", value: string) => {
const updatedHeaders = [...serverData.headers]
updatedHeaders[index][field] = value
setServerData({
...serverData,
headers: updatedHeaders,
})
}
const handleAddEnvironment = () => {
setServerData({
...serverData,
environments: [...serverData.environments, { key: "" }],
})
}
const handleRemoveEnvironment = (index: number) => {
const updatedEnvironments = [...serverData.environments]
updatedEnvironments.splice(index, 1)
setServerData({
...serverData,
environments: updatedEnvironments,
})
}
const handleEnvironmentChange = (index: number, value: string) => {
const updatedEnvironments = [...serverData.environments]
updatedEnvironments[index].key = value
setServerData({
...serverData,
environments: updatedEnvironments,
})
}
const handleAddTool = () => {
const name = "new_tool";
const newTool: ToolConfig = {
id: name,
name: name,
description: "",
tags: [],
examples: [],
inputModes: ["text"],
outputModes: ["text"],
}
setServerData({
...serverData,
tools: [...serverData.tools, newTool],
})
}
const handleRemoveTool = (index: number) => {
const updatedTools = [...serverData.tools]
updatedTools.splice(index, 1)
setServerData({
...serverData,
tools: updatedTools,
})
}
const handleToolChange = (index: number, field: keyof ToolConfig, value: any) => {
const updatedTools = [...serverData.tools]
updatedTools[index] = {
...updatedTools[index],
[field]: value,
}
if (field === 'name') {
updatedTools[index].id = value;
}
setServerData({
...serverData,
tools: updatedTools,
})
}
const handleAddServer = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
try {
// Convert environments array to object
const environmentsObj: Record<string, string> = {}
serverData.environments.forEach((env) => {
if (env.key) {
environmentsObj[env.key] = `env@@${env.key}`
}
})
// Convert headers array to object
const headersObj: Record<string, string> = {}
serverData.headers.forEach((header) => {
if (header.key) {
headersObj[header.key] = header.value
}
})
let config_json: any = {}
if (serverData.config_type === "sse") {
config_json = {
url: serverData.url,
headers: headersObj,
}
} else if (serverData.config_type === "studio") {
const args = serverData.args.split("\n").filter((arg) => arg.trim() !== "")
const envObj: Record<string, string> = {}
serverData.environments.forEach((env) => {
if (env.key) {
envObj[env.key] = `env@@${env.key}`
}
})
config_json = {
command: serverData.command,
args: args,
env: envObj,
}
}
const payload: MCPServerCreate = {
name: serverData.name,
description: serverData.description,
type: serverData.type,
config_type: serverData.config_type,
config_json,
environments: environmentsObj,
tools: serverData.tools,
}
if (selectedServer) {
await updateMCPServer(selectedServer.id, payload)
toast({
title: "Server updated",
description: `${serverData.name} was updated successfully.`,
})
} else {
await createMCPServer(payload)
toast({
title: "Server added",
description: `${serverData.name} was added successfully.`,
})
}
setIsDialogOpen(false)
resetForm()
// Reload list
const res = await listMCPServers((page - 1) * limit, limit)
setMcpServers(res.data)
setTotal(res.data.length)
} catch (error) {
toast({
title: "Error",
description: "Unable to save the MCP server. Please try again.",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
const handleEditServer = async (server: MCPServer) => {
setIsLoading(true)
try {
const res = await getMCPServer(server.id)
setSelectedServer(res.data)
// Convert environments object to array
const environmentsArray = Object.keys(res.data.environments || {}).map((key) => ({ key }))
// Convert headers object to array
const headersArray = res.data.config_json.headers
? Object.entries(res.data.config_json.headers).map(([key, value]) => ({ key, value: value as string }))
: [{ key: "x-api-key", value: "" }]
if (res.data.config_type === "sse") {
setServerData({
name: res.data.name,
description: res.data.description || "",
type: res.data.type,
config_type: res.data.config_type as any,
url: res.data.config_json.url || "",
headers: headersArray,
command: "",
args: "",
environments: environmentsArray,
tools: res.data.tools,
})
} else if (res.data.config_type === "studio") {
setServerData({
name: res.data.name,
description: res.data.description || "",
type: res.data.type,
config_type: res.data.config_type as any,
url: "",
headers: [],
command: res.data.config_json.command || "npx",
args: (res.data.config_json.args || []).join("\n"),
environments: environmentsArray,
tools: res.data.tools,
})
}
setActiveTab("basic")
setIsDialogOpen(true)
} catch (error) {
toast({
title: "Error searching MCP server",
description: "Unable to search the server.",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
const handleDeleteServer = (server: MCPServer) => {
setSelectedServer(server)
setIsDeleteDialogOpen(true)
}
const confirmDeleteServer = async () => {
if (!selectedServer) return
setIsLoading(true)
try {
await deleteMCPServer(selectedServer.id)
toast({
title: "Server deleted",
description: `${selectedServer.name} was deleted successfully.`,
})
setIsDeleteDialogOpen(false)
setSelectedServer(null)
// Reload list
const res = await listMCPServers((page - 1) * limit, limit)
setMcpServers(res.data)
setTotal(res.data.length)
} catch (error) {
toast({
title: "Error",
description: "Unable to delete the server. Please try again.",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
const resetForm = () => {
setServerData({
name: "",
description: "",
type: "official",
config_type: "sse",
url: "",
headers: [{ key: "x-api-key", value: "" }],
command: "npx",
args: "",
environments: [],
tools: [],
})
setSelectedServer(null)
setActiveTab("basic")
}
return (
<div className="container mx-auto p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-white">MCP Servers Management</h1>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button onClick={resetForm} className="bg-emerald-400 text-black hover:bg-[#00cc7d]">
<Plus className="mr-2 h-4 w-4" />
New MCP Server
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-hidden flex flex-col bg-[#1a1a1a] border-[#333]">
<form onSubmit={handleAddServer}>
<DialogHeader>
<DialogTitle className="text-white">
{selectedServer ? "Edit MCP Server" : "New MCP Server"}
</DialogTitle>
<DialogDescription className="text-neutral-400">
{selectedServer
? "Edit the existing MCP server information."
: "Fill in the information to create a new MCP server."}
</DialogDescription>
</DialogHeader>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 overflow-hidden flex flex-col">
<TabsList className="grid grid-cols-3 bg-[#222]">
<TabsTrigger
value="basic"
className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400"
>
Basic Information
</TabsTrigger>
<TabsTrigger
value="environments"
className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400"
>
Environment Variables
</TabsTrigger>
<TabsTrigger
value="tools"
className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400"
>
Tools
</TabsTrigger>
</TabsList>
<div className="overflow-y-auto max-h-[60vh] p-4">
<TabsContent value="basic" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name" className="text-neutral-300">
Name
</Label>
<Input
id="name"
value={serverData.name}
onChange={(e) => setServerData({ ...serverData, name: e.target.value })}
className="bg-[#222] border-[#444] text-white"
placeholder="MCP Server Name"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description" className="text-neutral-300">
Description
</Label>
<Textarea
id="description"
value={serverData.description}
onChange={(e) => setServerData({ ...serverData, description: e.target.value })}
className="bg-[#222] border-[#444] text-white"
placeholder="MCP Server Description"
/>
</div>
<div className="space-y-2">
<Label htmlFor="type" className="text-neutral-300">
Type
</Label>
<Select
value={serverData.type}
onValueChange={(value) => setServerData({ ...serverData, type: value })}
>
<SelectTrigger id="type" className="w-full bg-[#222] border-[#444] text-white">
<SelectValue placeholder="Select the type" />
</SelectTrigger>
<SelectContent className="bg-[#222] border-[#444] text-white">
<SelectItem value="official">Official</SelectItem>
<SelectItem value="community">Community</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="config_type" className="text-neutral-300">
Configuration Type
</Label>
<Select
value={serverData.config_type}
onValueChange={(value: "sse" | "studio") =>
setServerData({ ...serverData, config_type: value })
}
>
<SelectTrigger id="config_type" className="w-full bg-[#222] border-[#444] text-white">
<SelectValue placeholder="Select the configuration type" />
</SelectTrigger>
<SelectContent className="bg-[#222] border-[#444] text-white">
<SelectItem value="sse">SSE (Server-Sent Events)</SelectItem>
<SelectItem value="studio">Studio</SelectItem>
</SelectContent>
</Select>
</div>
{/* Specific fields for SSE */}
{serverData.config_type === "sse" && (
<>
<div className="space-y-2">
<Label htmlFor="url" className="text-neutral-300">
URL
</Label>
<Input
id="url"
value={serverData.url}
onChange={(e) => setServerData({ ...serverData, url: e.target.value })}
className="bg-[#222] border-[#444] text-white"
placeholder="https://your_server.com/sse"
required={serverData.config_type === "sse"}
/>
</div>
{/* Dynamic headers */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label className="text-neutral-300">Headers</Label>
<Button
type="button"
onClick={handleAddHeader}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
size="sm"
>
<Plus className="mr-2 h-4 w-4" />
Add Header
</Button>
</div>
<div className="space-y-2">
{serverData.headers.map((header, index) => (
<div key={index} className="flex gap-2 items-start">
<div className="flex-1">
<Label htmlFor={`header-key-${index}`} className="sr-only">
Header Name
</Label>
<Input
id={`header-key-${index}`}
value={header.key}
onChange={(e) => handleHeaderChange(index, "key", e.target.value)}
className="bg-[#222] border-[#444] text-white"
placeholder="Header Name"
/>
</div>
<div className="flex-1">
<Label htmlFor={`header-value-${index}`} className="sr-only">
Header Value
</Label>
<Input
id={`header-value-${index}`}
value={header.value}
onChange={(e) => handleHeaderChange(index, "value", e.target.value)}
className="bg-[#222] border-[#444] text-white"
placeholder="Header Value"
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveHeader(index)}
className="text-red-500 hover:text-red-400 hover:bg-[#333]"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
</>
)}
{/* Specific fields for Studio */}
{serverData.config_type === "studio" && (
<>
<div className="space-y-2">
<Label htmlFor="command" className="text-neutral-300">
Command
</Label>
<Input
id="command"
value={serverData.command}
onChange={(e) => setServerData({ ...serverData, command: e.target.value })}
className="bg-[#222] border-[#444] text-white"
placeholder="npx"
required={serverData.config_type === "studio"}
/>
</div>
<div className="space-y-2">
<Label htmlFor="args" className="text-neutral-300">
Arguments (one per line)
</Label>
<Textarea
id="args"
value={serverData.args}
onChange={(e) => setServerData({ ...serverData, args: e.target.value })}
className="bg-[#222] border-[#444] text-white"
placeholder="-y
@modelcontextprotocol/server-brave-search"
required={serverData.config_type === "studio"}
/>
</div>
</>
)}
</TabsContent>
<TabsContent value="environments" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-white">Environment Variables</h3>
<Button
type="button"
onClick={handleAddEnvironment}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
size="sm"
>
<Plus className="mr-2 h-4 w-4" />
Add Environment Variable
</Button>
</div>
{serverData.environments.length === 0 ? (
<div className="text-center py-8 text-neutral-400">
No environment variables configured. Click "Add Variable" to start.
</div>
) : (
<div className="space-y-4">
{serverData.environments.map((env, index) => (
<div key={index} className="flex gap-2 items-start">
<div className="flex-1">
<Label htmlFor={`env-key-${index}`} className="sr-only">
Environment Variable Name
</Label>
<Input
id={`env-key-${index}`}
value={env.key}
onChange={(e) => handleEnvironmentChange(index, e.target.value)}
className="bg-[#222] border-[#444] text-white"
placeholder="ENV_VARIABLE_NAME"
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveEnvironment(index)}
className="text-red-500 hover:text-red-400 hover:bg-[#333]"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent value="tools" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-white">Tools</h3>
<Button
type="button"
onClick={handleAddTool}
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
size="sm"
>
<Plus className="mr-2 h-4 w-4" />
Add Tool
</Button>
</div>
{serverData.tools.length === 0 ? (
<div className="text-center py-8 text-neutral-400">
No tools configured. Click "Add Tool" to start.
</div>
) : (
<div className="space-y-6">
{serverData.tools.map((tool, index) => (
<Card key={index} className="bg-[#222] border-[#444]">
<CardHeader className="pb-2 flex flex-row justify-between items-start">
<div>
<CardTitle className="text-white text-base">Tool {index + 1}</CardTitle>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveTool(index)}
className="text-red-500 hover:text-red-400 hover:bg-[#333] h-8 w-8"
>
<Trash2 className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<Label htmlFor={`tool-name-${index}`} className="text-neutral-300">
Name
</Label>
<Input
id={`tool-name-${index}`}
value={tool.name}
onChange={(e) => handleToolChange(index, "name", e.target.value)}
className="bg-[#222] border-[#444] text-white"
placeholder="tool_name"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`tool-description-${index}`} className="text-neutral-300">
Description
</Label>
<Textarea
id={`tool-description-${index}`}
value={tool.description}
onChange={(e) => handleToolChange(index, "description", e.target.value)}
className="bg-[#222] border-[#444] text-white"
placeholder="Tool Description"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`tool-tags-${index}`} className="text-neutral-300">
Tags (separated by comma)
</Label>
<Input
id={`tool-tags-${index}`}
value={(tool.tags ?? []).join(", ")}
onChange={(e) => handleToolChange(index, "tags", e.target.value.split(", "))}
className="bg-[#222] border-[#444] text-white"
placeholder="tag1, tag2, tag3"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`tool-examples-${index}`} className="text-neutral-300">
Examples (separated by comma)
</Label>
<Textarea
id={`tool-examples-${index}`}
value={(tool.examples ?? []).join(", ")}
onChange={(e) => handleToolChange(index, "examples", e.target.value.split(", "))}
className="bg-[#222] border-[#444] text-white"
placeholder="Example 1, Example 2"
/>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
</div>
</Tabs>
<DialogFooter className="mt-4">
<Button
type="button"
variant="outline"
onClick={() => setIsDialogOpen(false)}
className="border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
>
Cancel
</Button>
<Button type="submit" className="bg-emerald-400 text-black hover:bg-[#00cc7d]" disabled={isLoading}>
{isLoading ? "Saving..." : selectedServer ? "Save Changes" : "Add Server"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent className="bg-[#1a1a1a] border-[#333] text-white">
<AlertDialogHeader>
<AlertDialogTitle>Confirm delete</AlertDialogTitle>
<AlertDialogDescription className="text-neutral-400">
Are you sure you want to delete the server "{selectedServer?.name}"? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white">
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteServer}
className="bg-red-600 text-white hover:bg-red-700"
disabled={isLoading}
>
{isLoading ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<Card className="bg-[#1a1a1a] border-[#333] mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white text-lg">Search MCP Servers</CardTitle>
</CardHeader>
<CardContent>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-500" />
<Input
placeholder="Search by name or description..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="bg-[#222] border-[#444] text-white pl-10"
/>
</div>
</CardContent>
</Card>
<Card className="bg-[#1a1a1a] border-[#333]">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="border-[#333] hover:bg-[#222]">
<TableHead className="text-neutral-300">Name</TableHead>
<TableHead className="text-neutral-300">Description</TableHead>
<TableHead className="text-neutral-300">Type</TableHead>
<TableHead className="text-neutral-300">Configuration</TableHead>
<TableHead className="text-neutral-300">Tools</TableHead>
<TableHead className="text-neutral-300 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredServers.length > 0 ? (
filteredServers.map((server) => (
<TableRow key={server.id} className="border-[#333] hover:bg-[#222]">
<TableCell className="font-medium text-white">{server.name}</TableCell>
<TableCell className="text-neutral-300">{server.description}</TableCell>
<TableCell className="text-neutral-300">
<Badge
variant="outline"
className={
server.type === "official"
? "bg-emerald-400/10 text-emerald-400 border-emerald-400/30"
: "bg-[#ff9d00]/10 text-[#ff9d00] border-[#ff9d00]/30"
}
>
{server.type === "official" ? "Official" : "Community"}
</Badge>
</TableCell>
<TableCell className="text-neutral-300">
<div className="flex flex-col gap-1">
<Badge
variant="outline"
className={
server.config_type === "sse"
? "bg-[#00b8ff]/10 text-[#00b8ff] border-[#00b8ff]/30"
: "bg-[#ff5e00]/10 text-[#ff5e00] border-[#ff5e00]/30"
}
>
{server.config_type === "sse" ? "SSE" : "Studio"}
</Badge>
{server.config_type === "sse" && (
<span className="text-xs truncate max-w-[200px]">{server.config_json.url}</span>
)}
{server.config_type === "studio" && (
<span className="text-xs truncate max-w-[200px]">
{server.config_json.command} {server.config_json.args?.join(" ")}
</span>
)}
</div>
</TableCell>
<TableCell className="text-neutral-300">
<div className="flex items-center">
<Tool className="h-4 w-4 mr-1 text-emerald-400" />
{server.tools.length}
</div>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0 text-neutral-300 hover:bg-[#333]">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-[#222] border-[#444] text-white">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-[#444]" />
<DropdownMenuItem
className="cursor-pointer hover:bg-[#333]"
onClick={() => handleEditServer(server)}
>
<Edit className="mr-2 h-4 w-4 text-emerald-400" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer hover:bg-[#333] text-red-500"
onClick={() => handleDeleteServer(server)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center text-neutral-500">
No MCP servers found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}

33
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,33 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/page.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
import { redirect } from "next/navigation"
export default function Home() {
redirect("/agents")
}

View File

@@ -0,0 +1,190 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/profile/page.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client"
import type React from "react"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { useToast } from "@/components/ui/use-toast"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { User } from "lucide-react"
import { useRouter } from "next/navigation"
export default function ProfilePage() {
const { toast } = useToast()
const router = useRouter()
const [isLoading, setIsLoading] = useState(false)
const [userData, setUserData] = useState(() => {
if (typeof window !== "undefined") {
const user = localStorage.getItem("user")
if (user) return JSON.parse(user)
}
return {
id: "",
name: "",
email: "",
is_admin: false,
email_verified: false,
created_at: "",
}
})
const [profileData, setProfileData] = useState({
name: userData.name,
email: userData.email,
})
useEffect(() => {
setProfileData({ name: userData.name, email: userData.email })
}, [userData])
const handleProfileUpdate = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
try {
await new Promise((resolve) => setTimeout(resolve, 1000))
setUserData({
...userData,
name: profileData.name,
email: profileData.email,
})
toast({
title: "Profile updated",
description: "Your information has been updated successfully.",
})
} catch (error) {
toast({
title: "Error updating profile",
description: "Unable to update your information. Please try again.",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
return (
<div className="container mx-auto p-6">
<div className="flex flex-col md:flex-row gap-6">
<div className="md:w-1/3">
<Card className="bg-[#1a1a1a] border-[#333]">
<CardHeader>
<div className="flex flex-col items-center">
<Avatar className="h-24 w-24 mb-4">
<AvatarImage src={`https://api.dicebear.com/7.x/initials/svg?seed=${userData.name}`} />
<AvatarFallback className="text-2xl bg-emerald-400 text-black">
{(userData.name || "?")
.split(" ")
.filter(Boolean)
.map((n: string) => n[0])
.join("") || "?"}
</AvatarFallback>
</Avatar>
<CardTitle className="text-white text-xl">{userData.name}</CardTitle>
<CardDescription className="text-neutral-400">{userData.email}</CardDescription>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4 text-sm">
<div className="flex justify-between text-neutral-300">
<span>ID:</span>
<span className="text-neutral-400 truncate max-w-[180px]">{userData.id}</span>
</div>
<div className="flex justify-between text-neutral-300">
<span>Account Type:</span>
<span className="text-neutral-400">{userData.is_admin ? "Administrator" : "Client"}</span>
</div>
<div className="flex justify-between text-neutral-300">
<span>Email Verified:</span>
<span className="text-neutral-400">{userData.email_verified ? "Yes" : "No"}</span>
</div>
<div className="flex justify-between text-neutral-300">
<span>Created at:</span>
<span className="text-neutral-400">{new Date(userData.created_at).toLocaleDateString("en-US")}</span>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="md:w-2/3">
<Card className="bg-[#1a1a1a] border-[#333]">
<form onSubmit={handleProfileUpdate}>
<CardHeader>
<CardTitle className="text-white">Profile Information</CardTitle>
<CardDescription className="text-neutral-400">Update your personal information.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name" className="text-neutral-300">
Name
</Label>
<Input
id="name"
value={profileData.name}
onChange={(e) => setProfileData({ ...profileData, name: e.target.value })}
className="bg-[#222] border-[#444] text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-neutral-300">
Email
</Label>
<Input
id="email"
type="email"
value={profileData.email}
onChange={(e) => setProfileData({ ...profileData, email: e.target.value })}
className="bg-[#222] border-[#444] text-white"
/>
</div>
</CardContent>
<CardFooter>
<Button
type="submit"
className="w-full bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={isLoading}
>
{isLoading ? "Saving..." : "Save Changes"}
</Button>
</CardFooter>
</form>
</Card>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,163 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/security/page.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client"
import type React from "react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { useToast } from "@/components/ui/use-toast"
import { Shield } from "lucide-react"
import { changePassword } from "@/services/authService"
export default function SecurityPage() {
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(false)
const [passwordData, setPasswordData] = useState({
currentPassword: "",
newPassword: "",
confirmPassword: "",
})
const handlePasswordUpdate = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
if (passwordData.newPassword !== passwordData.confirmPassword) {
toast({
title: "Error updating password",
description: "The passwords do not match.",
variant: "destructive",
})
setIsLoading(false)
return
}
try {
await changePassword({
current_password: passwordData.currentPassword,
new_password: passwordData.newPassword
})
// Reset password fields
setPasswordData({
currentPassword: "",
newPassword: "",
confirmPassword: "",
})
toast({
title: "Password updated",
description: "Your password has been updated successfully.",
})
} catch (error) {
toast({
title: "Error updating password",
description: error instanceof Error ? error.message : "Unable to update your password. Please try again.",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
return (
<div className="container mx-auto p-6">
<div className="max-w-2xl mx-auto">
<Card className="bg-[#1a1a1a] border-[#333]">
<CardHeader className="flex flex-row items-center gap-4">
<Shield className="w-8 h-8 text-emerald-400" />
<div>
<CardTitle className="text-white">Account Security</CardTitle>
<CardDescription className="text-neutral-400">
Manage your account security settings
</CardDescription>
</div>
</CardHeader>
<form onSubmit={handlePasswordUpdate}>
<CardContent className="space-y-4">
<div className="space-y-1">
<Label htmlFor="current-password" className="text-neutral-300">
Current Password
</Label>
<Input
id="current-password"
type="password"
value={passwordData.currentPassword}
onChange={(e) => setPasswordData({ ...passwordData, currentPassword: e.target.value })}
className="bg-[#222] border-[#444] text-white"
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="new-password" className="text-neutral-300">
New Password
</Label>
<Input
id="new-password"
type="password"
value={passwordData.newPassword}
onChange={(e) => setPasswordData({ ...passwordData, newPassword: e.target.value })}
className="bg-[#222] border-[#444] text-white"
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="confirm-password" className="text-neutral-300">
Confirm New Password
</Label>
<Input
id="confirm-password"
type="password"
value={passwordData.confirmPassword}
onChange={(e) => setPasswordData({ ...passwordData, confirmPassword: e.target.value })}
className="bg-[#222] border-[#444] text-white"
required
/>
</div>
</CardContent>
<CardFooter>
<Button
type="submit"
className="w-full bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={isLoading}
>
{isLoading ? "Updating..." : "Update Password"}
</Button>
</CardFooter>
</form>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,198 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/security/reset-password/page.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { useState, useEffect, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardFooter,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Loader2, CheckCircle2, XCircle } from "lucide-react";
import { resetPassword } from "@/services/authService";
import Link from "next/link";
export default function ResetPasswordPageWrapper() {
return (
<Suspense fallback={null}>
<ResetPasswordPage />
</Suspense>
);
}
function ResetPasswordPage() {
const router = useRouter();
const searchParams = useSearchParams();
const tokenFromUrl = searchParams.get("token") || "";
const [form, setForm] = useState({
token: tokenFromUrl,
password: "",
confirmPassword: "",
});
const [status, setStatus] = useState<
"idle" | "loading" | "success" | "error"
>("idle");
const [error, setError] = useState<string | null>(null);
const [redirectSeconds, setRedirectSeconds] = useState(5);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setForm({ ...form, [e.target.name]: e.target.value });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!form.token) {
setError("Recovery token is required.");
setStatus("error");
return;
}
if (form.password !== form.confirmPassword) {
setError("Passwords do not match.");
setStatus("error");
return;
}
setStatus("loading");
try {
await resetPassword({ token: form.token, new_password: form.password });
setStatus("success");
} catch (err: any) {
setError(err?.response?.data?.message || "Failed to reset password.");
setStatus("error");
}
};
useEffect(() => {
if (status === "success" && redirectSeconds > 0) {
const timer = setTimeout(() => {
setRedirectSeconds((s) => s - 1);
}, 1000);
return () => clearTimeout(timer);
}
if (status === "success" && redirectSeconds === 0) {
router.push("/login");
}
}, [status, redirectSeconds, router]);
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<Card className="w-full max-w-md">
<form onSubmit={handleSubmit}>
<CardHeader>
<CardTitle>Reset Password</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{status === "success" ? (
<Alert className="border-green-500">
<CheckCircle2 className="mr-2 h-4 w-4 text-green-500 inline" />
<AlertTitle>Password reset!</AlertTitle>
<AlertDescription>
Your password has been updated.
<br />
Redirecting to login in {redirectSeconds} seconds...
</AlertDescription>
</Alert>
) : (
<>
{status === "error" && (
<Alert variant="destructive">
<XCircle className="mr-2 h-4 w-4 inline" />
<AlertTitle>Reset failed</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Input
id="token"
name="token"
type="hidden"
value={form.token}
onChange={handleChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">New Password</Label>
<Input
id="password"
name="password"
type="password"
value={form.password}
onChange={handleChange}
placeholder="Enter new password"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
value={form.confirmPassword}
onChange={handleChange}
placeholder="Repeat new password"
required
/>
</div>
</>
)}
</CardContent>
{status !== "success" && (
<CardFooter>
<Button
type="submit"
className="w-full bg-emerald-400 text-black hover:bg-[#00cc7d]"
disabled={status === "loading"}
>
{status === "loading" ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin inline" />
Resetting...
</>
) : (
"Reset Password"
)}
</Button>
</CardFooter>
)}
<div className="mt-4 mb-2 text-center">
<Link href="/login" className="text-emerald-400 hover:underline">Go to Login</Link>
</div>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,119 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/security/verify-email/page.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client"
import { useEffect, useState, Suspense } from "react"
import { useSearchParams, useRouter } from "next/navigation"
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Loader2, CheckCircle2, XCircle } from "lucide-react"
import { verifyEmail } from "@/services/authService"
export default function VerifyEmailPageWrapper() {
return (
<Suspense fallback={null}>
<VerifyEmailPage />
</Suspense>
)
}
function VerifyEmailPage() {
const searchParams = useSearchParams()
const router = useRouter()
const code = searchParams.get("code")
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle")
const [error, setError] = useState<string | null>(null)
const [redirectSeconds, setRedirectSeconds] = useState(5)
useEffect(() => {
if (!code) {
setError("Verification code not found in URL.")
setStatus("error")
return
}
setStatus("loading")
verifyEmail(code)
.then(() => {
setStatus("success")
})
.catch((err) => {
setError(err?.response?.data?.message || "Failed to verify email.")
setStatus("error")
})
}, [code])
useEffect(() => {
if (status === "success" && redirectSeconds > 0) {
const timer = setTimeout(() => {
setRedirectSeconds((s) => s - 1)
}, 1000)
return () => clearTimeout(timer)
}
if (status === "success" && redirectSeconds === 0) {
router.push("/login")
}
}, [status, redirectSeconds, router])
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Email Confirmation</CardTitle>
</CardHeader>
<CardContent>
{status === "loading" && (
<Alert>
<Loader2 className="mr-2 h-4 w-4 animate-spin inline" />
<AlertTitle>Verifying your email...</AlertTitle>
</Alert>
)}
{status === "success" && (
<Alert className="border-green-500">
<CheckCircle2 className="mr-2 h-4 w-4 text-green-500 inline" />
<AlertTitle>Email verified!</AlertTitle>
<AlertDescription>
Your email has been successfully confirmed.<br />
Redirecting to login in {redirectSeconds} seconds...
</AlertDescription>
</Alert>
)}
{status === "error" && (
<Alert variant="destructive">
<XCircle className="mr-2 h-4 w-4 inline" />
<AlertTitle>Verification failed</AlertTitle>
<AlertDescription>
{error || "An error occurred while verifying your email."}
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,302 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: shared-chat/AgentInfo.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
ExternalLink,
Code,
ChevronDown,
ChevronUp,
User,
Calendar,
Tag,
Info,
ArrowRight,
Workflow,
Bot,
GitBranch,
RefreshCw,
Key,
Users,
BookOpenCheck,
} from "lucide-react";
import { Agent, AgentType } from "@/types/agent";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
interface AgentInfoProps {
agent: Agent;
isShared?: boolean;
}
export function AgentInfo({ agent, isShared = false }: AgentInfoProps) {
const [isCardDialogOpen, setIsCardDialogOpen] = useState(false);
const getAgentTypeInfo = () => {
const types: Record<
AgentType,
{ label: string; icon: React.ElementType; color: string }
> = {
llm: { label: "LLM Agent", icon: Code, color: "#00cc7d" },
a2a: { label: "A2A Agent", icon: ExternalLink, color: "#3b82f6" },
sequential: {
label: "Sequential Agent",
icon: ArrowRight,
color: "#f59e0b",
},
parallel: { label: "Parallel Agent", icon: GitBranch, color: "#8b5cf6" },
loop: { label: "Loop Agent", icon: RefreshCw, color: "#ec4899" },
workflow: { label: "Workflow Agent", icon: Workflow, color: "#14b8a6" },
task: { label: "Task Agent", icon: BookOpenCheck, color: "#00cc7d" },
};
return (
types[agent.type as AgentType] || {
label: agent.type,
icon: Bot,
color: "#00cc7d",
}
);
};
const agentTypeInfo = getAgentTypeInfo();
const IconComponent = agentTypeInfo.icon;
const getModelName = () => {
if (agent.type === "llm" && agent.model) {
return agent.model;
}
return "N/A";
};
const getCreatedAt = () => {
if (!agent.created_at) return "Unknown";
return new Date(agent.created_at).toLocaleDateString();
};
const getTotalTools = () => {
if (agent.type === "llm" && agent.config?.mcp_servers) {
return agent.config.mcp_servers.reduce(
(total, mcp) => total + (mcp.tools?.length || 0),
0
);
}
return 0;
};
const getSubAgents = () => {
if (agent.config?.sub_agents) {
return agent.config.sub_agents.length;
}
return 0;
};
const showAgentCard = () => {
if (agent.agent_card_url) {
setIsCardDialogOpen(true);
}
};
return (
<>
<div className="space-y-4">
<div
className="bg-[#182724] rounded-lg overflow-hidden border border-[#1e3a36] shadow-lg"
style={{ backgroundColor: `${agentTypeInfo.color}15` }}
>
<div
className={cn(
"flex items-center gap-3 p-4 cursor-pointer transition-colors",
"hover:bg-opacity-80"
)}
style={{ backgroundColor: `${agentTypeInfo.color}20` }}
>
<div
className="p-2 rounded-full"
style={{ backgroundColor: agentTypeInfo.color }}
>
<IconComponent className="h-5 w-5 text-white" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<h2 className="font-medium text-white text-lg">{agent.name}</h2>
</div>
<p className="text-xs text-emerald-400">{agentTypeInfo.label}</p>
</div>
</div>
<div className="p-4 space-y-4 border-t border-[#1e3a36] animate-in fade-in-50 duration-200">
{agent.description && (
<p className="text-neutral-300 text-sm leading-relaxed">
{agent.description}
</p>
)}
<div className="grid grid-cols-2 gap-3">
<div className="bg-[#141414] rounded p-3 flex flex-col justify-between">
<span className="text-xs text-neutral-500 mb-1">Model</span>
<div className="flex items-center gap-2">
<User className="h-3.5 w-3.5 text-emerald-400" />
<span className="text-sm text-white truncate">
{getModelName()}
</span>
</div>
</div>
<div className="bg-[#141414] rounded p-3 flex flex-col justify-between">
<span className="text-xs text-neutral-500 mb-1">Created at</span>
<div className="flex items-center gap-2">
<Calendar className="h-3.5 w-3.5 text-emerald-400" />
<span className="text-sm text-white">{getCreatedAt()}</span>
</div>
</div>
{getTotalTools() > 0 && (
<div className="bg-[#141414] rounded p-3 flex flex-col justify-between">
<span className="text-xs text-neutral-500 mb-1">Tools</span>
<div className="flex items-center gap-2">
<Code className="h-3.5 w-3.5 text-emerald-400" />
<span className="text-sm text-white">
{getTotalTools()}
</span>
</div>
</div>
)}
{getSubAgents() > 0 && (
<div className="bg-[#141414] rounded p-3 flex flex-col justify-between">
<span className="text-xs text-neutral-500 mb-1">Sub-agents</span>
<div className="flex items-center gap-2">
<Tag className="h-3.5 w-3.5 text-emerald-400" />
<span className="text-sm text-white">{getSubAgents()}</span>
</div>
</div>
)}
</div>
<div className="flex items-center justify-between bg-[#141414] rounded p-3">
<div className="flex items-center gap-2">
<Info className="h-3.5 w-3.5 text-emerald-400" />
<span className="text-xs text-neutral-400">Agent ID</span>
</div>
<span className="text-xs text-emerald-400 font-mono">
{agent.id}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="w-full text-emerald-400 hover:text-emerald-300 hover:bg-[#182724] mt-2 border border-[#1e3a36]"
disabled={!agent.agent_card_url}
onClick={showAgentCard}
>
<ExternalLink className="h-4 w-4 mr-2" />
View Agent Card
</Button>
</div>
</div>
<div className="bg-[#182724] rounded-lg overflow-hidden border border-[#1e3a36] p-4">
<h3 className="text-sm font-medium text-white mb-2 flex items-center gap-2">
<Key className="h-4 w-4 text-emerald-400" />
Access Information
</h3>
<div className="space-y-2">
<div className="bg-[#141414] rounded p-3">
<div className="flex justify-between items-center mb-1">
<span className="text-xs text-neutral-500">API Key</span>
<Badge className="bg-emerald-900 text-emerald-400 text-xs">
{isShared ? "Shared" : "Not Shared"}
</Badge>
</div>
<p className="text-xs font-mono text-emerald-400">
{isShared ? "Shared" : "Not Shared"}
</p>
</div>
<p className="text-xs text-neutral-500 mt-2">
This is a {isShared ? "shared" : "not shared"} API key. Be careful when sharing it with third
parties.
</p>
</div>
</div>
</div>
<Dialog open={isCardDialogOpen} onOpenChange={setIsCardDialogOpen}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto bg-neutral-900 border-neutral-800 text-white [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ExternalLink className="h-5 w-5 text-emerald-400" />
Agent Card
</DialogTitle>
</DialogHeader>
<div className="mt-4 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
{agent.agent_card_url && (
<iframe
src={agent.agent_card_url}
className="w-full h-[50vh] border border-neutral-800 rounded-md [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
style={{ scrollbarWidth: 'none' }}
/>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => window.open(agent.agent_card_url, "_blank")}
className="bg-neutral-800 border-neutral-700 text-neutral-300 hover:bg-neutral-700 hover:text-white"
>
<ExternalLink className="h-4 w-4 mr-2" />
Open in New Tab
</Button>
<Button
onClick={() => setIsCardDialogOpen(false)}
className="bg-emerald-500 text-white hover:bg-emerald-600"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,180 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/shared-chat/components/SharedChatPanel.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 14, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { useState, useEffect, useRef } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { MessageSquare, Loader2, Bot, User } from "lucide-react";
import { ChatMessage as ChatMessageType } from "@/services/sessionService";
import { ChatMessage } from "@/app/chat/components/ChatMessage";
import { FileData } from "@/lib/file-utils";
import { ChatInput } from "@/app/chat/components/ChatInput";
interface FunctionMessageContent {
title: string;
content: string;
author?: string;
}
interface SharedChatPanelProps {
messages: ChatMessageType[];
isLoading: boolean;
isSending: boolean;
agentName?: string;
onSendMessage: (message: string, files?: FileData[]) => void;
getMessageText: (message: ChatMessageType) => string | FunctionMessageContent;
containsMarkdown: (text: string) => boolean;
sessionId?: string;
}
export function SharedChatPanel({
messages,
isLoading,
isSending,
agentName = "Shared Agent",
onSendMessage,
getMessageText,
containsMarkdown,
sessionId,
}: SharedChatPanelProps) {
const [expandedFunctions, setExpandedFunctions] = useState<Record<string, boolean>>({});
const messagesContainerRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
}
};
useEffect(() => {
if (messages.length > 0) {
setTimeout(scrollToBottom, 100);
}
}, [messages]);
const toggleFunctionExpansion = (messageId: string) => {
setExpandedFunctions((prev) => ({
...prev,
[messageId]: !prev[messageId],
}));
};
return (
<div className="flex-1 flex flex-col overflow-hidden">
<div
ref={messagesContainerRef}
className="flex-1 overflow-y-auto p-4 bg-neutral-950"
>
{isLoading ? (
<div className="flex flex-col items-center justify-center h-full">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-emerald-500 to-emerald-700 flex items-center justify-center mb-4 relative">
<Loader2 className="h-6 w-6 text-white animate-spin" />
<div className="absolute inset-0 rounded-full blur-md bg-emerald-400/20 animate-pulse"></div>
</div>
<p className="text-neutral-400 mb-2">Loading conversation...</p>
</div>
) : messages.length === 0 ? (
<div className="flex flex-col h-full items-center justify-center text-center p-6">
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-emerald-500/20 to-emerald-700/20 flex items-center justify-center shadow-lg mb-5 border border-emerald-500/30">
<MessageSquare className="h-6 w-6 text-emerald-400" />
</div>
<h3 className="text-lg font-medium text-neutral-300 mb-2">
{`Chat with ${agentName}`}
</h3>
<p className="text-neutral-500 text-sm max-w-md">
Type your message below to start the conversation. This chat will help you interact with the shared agent and explore its capabilities.
</p>
</div>
) : (
<div className="space-y-4 w-full max-w-full">
{messages.map((message) => {
const messageContent = getMessageText(message);
const agentColor = message.author === "user" ? "bg-emerald-500" : "bg-gradient-to-br from-neutral-800 to-neutral-900";
const isExpanded = expandedFunctions[message.id] || false;
return (
<ChatMessage
key={message.id}
message={message}
agentColor={agentColor}
isExpanded={isExpanded}
toggleExpansion={toggleFunctionExpansion}
containsMarkdown={containsMarkdown}
messageContent={messageContent}
sessionId={sessionId}
/>
);
})}
{isSending && (
<div className="flex justify-start">
<div className="flex gap-3 max-w-[80%]">
<Avatar
className="bg-gradient-to-br from-purple-600 to-purple-800 shadow-md border-0"
>
<AvatarFallback className="bg-transparent">
<Bot className="h-4 w-4 text-white" />
</AvatarFallback>
</Avatar>
<div className="rounded-lg p-3 bg-gradient-to-br from-neutral-800 to-neutral-900 border border-neutral-700/50">
<div className="flex space-x-2">
<div className="h-2 w-2 rounded-full bg-emerald-400 animate-bounce"></div>
<div className="h-2 w-2 rounded-full bg-emerald-400 animate-bounce [animation-delay:0.2s]"></div>
<div className="h-2 w-2 rounded-full bg-emerald-400 animate-bounce [animation-delay:0.4s]"></div>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
<div className="px-4 pt-4 pb-6 border-t border-neutral-700 bg-neutral-900">
{isSending && !isLoading && (
<div className="px-4 py-2 mb-3 rounded-lg bg-neutral-800/50 border border-neutral-700/30 text-sm text-neutral-400 flex items-center">
<div className="mr-2 relative">
<Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-400" />
<div className="absolute inset-0 blur-sm bg-emerald-400/20 rounded-full animate-pulse"></div>
</div>
Agent is thinking...
</div>
)}
<div className="rounded-lg bg-neutral-800/20 border border-neutral-700/30 p-1">
<ChatInput
onSendMessage={onSendMessage}
isLoading={isSending || isLoading}
placeholder="Type your message..."
autoFocus={true}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,204 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: /app/shared-chat/components/SharedSessionList.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search, Plus, Loader2, ChevronLeft, ChevronRight, MessageSquare } from "lucide-react";
import { Badge } from "@/components/ui/badge";
interface SharedSession {
id: string;
update_time: string;
name?: string;
}
interface SharedSessionListProps {
sessions: SharedSession[];
selectedSession: string | null;
isLoading: boolean;
searchTerm: string;
isCollapsed: boolean;
setSearchTerm: (value: string) => void;
setSelectedSession: (value: string | null) => void;
onNewSession: () => void;
onToggleCollapse: () => void;
agentName?: string;
}
export function SharedSessionList({
sessions,
selectedSession,
isLoading,
searchTerm,
isCollapsed,
setSearchTerm,
setSelectedSession,
onNewSession,
onToggleCollapse,
agentName = "Shared Agent"
}: SharedSessionListProps) {
const filteredSessions = sessions.filter((session) =>
session.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
(session.name && session.name.toLowerCase().includes(searchTerm.toLowerCase()))
);
const sortedSessions = [...filteredSessions].sort((a, b) => {
const updateTimeA = new Date(a.update_time).getTime();
const updateTimeB = new Date(b.update_time).getTime();
return updateTimeB - updateTimeA;
});
const formatDateTime = (dateTimeStr: string) => {
try {
const date = new Date(dateTimeStr);
const day = date.getDate().toString().padStart(2, "0");
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${day}/${month}/${year} ${hours}:${minutes}`;
} catch (error) {
return "Invalid date";
}
};
const getDisplayName = (session: SharedSession) => {
if (session.name) return session.name;
return `Session ${session.id.substring(0, 8)}`;
};
if (isCollapsed) {
return (
<div className="w-10 border-r border-neutral-800 bg-neutral-900 flex flex-col items-center">
<Button
variant="ghost"
size="icon"
className="mt-4 text-neutral-400 hover:text-emerald-400 hover:bg-neutral-800"
onClick={onToggleCollapse}
>
<ChevronRight className="h-5 w-5" />
</Button>
</div>
);
}
return (
<div className="w-64 border-r border-neutral-800 flex flex-col bg-neutral-900 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<div className="p-4 border-b border-neutral-800 flex items-center justify-between">
<div className="flex-1">
<h3 className="text-sm font-medium text-white flex items-center gap-2">
<div className="p-1 rounded-full bg-emerald-500/20">
<MessageSquare className="h-3.5 w-3.5 text-emerald-400" />
</div>
{agentName}
</h3>
<p className="text-xs text-neutral-400 mt-1">Shared Sessions</p>
</div>
<Button
variant="ghost"
size="icon"
className="text-neutral-400 hover:text-emerald-400 hover:bg-neutral-800"
onClick={onToggleCollapse}
>
<ChevronLeft className="h-5 w-5" />
</Button>
</div>
<div className="p-4 border-b border-neutral-800">
<div className="flex items-center justify-between mb-4">
<Button
onClick={onNewSession}
className="bg-emerald-800 text-emerald-100 hover:bg-emerald-700 border-emerald-700 w-full"
size="sm"
>
<Plus className="h-4 w-4 mr-1" /> New Session
</Button>
</div>
<div className="space-y-2">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-neutral-500" />
<Input
placeholder="Search sessions..."
className="pl-9 bg-neutral-800 border-neutral-700 text-neutral-200 focus-visible:ring-emerald-500"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
{isLoading ? (
<div className="flex justify-center items-center h-24">
<Loader2 className="h-5 w-5 text-emerald-400 animate-spin" />
</div>
) : sortedSessions.length > 0 ? (
<div className="px-4 pt-2 space-y-2">
{sortedSessions.map((session) => (
<div
key={session.id}
className={`p-3 rounded-md cursor-pointer transition-colors ${
selectedSession === session.id
? "bg-emerald-800/20 border border-emerald-600/40"
: "bg-neutral-800 hover:bg-neutral-700 border border-transparent"
}`}
onClick={() => setSelectedSession(session.id)}
>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-emerald-500 mr-2"></div>
<div className="text-neutral-200 font-medium truncate max-w-[180px]">
{getDisplayName(session)}
</div>
</div>
<div className="mt-1 flex items-center">
<Badge className="bg-neutral-700 text-emerald-400 border-neutral-600 text-xs">
Shared
</Badge>
<div className="text-xs text-neutral-500 ml-auto">
{formatDateTime(session.update_time)}
</div>
</div>
</div>
))}
</div>
) : searchTerm ? (
<div className="text-center py-4 text-neutral-400">
No results found
</div>
) : (
<div className="text-center py-4 text-neutral-400">
Click "New" to start
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,717 @@
/*
┌──────────────────────────────────────────────────────────────────────────────┐
│ @author: Davidson Gomes │
│ @file: shared-chat/page.tsx │
│ Developed by: Davidson Gomes │
│ Creation date: May 13, 2025 │
│ Contact: contato@evolution-api.com │
├──────────────────────────────────────────────────────────────────────────────┤
│ @copyright © Evolution API 2025. All rights reserved. │
│ Licensed under the Apache License, Version 2.0 │
│ │
│ You may not use this file except in compliance with the License. │
│ You may obtain a copy of the License at │
│ │
│ http://www.apache.org/licenses/LICENSE-2.0 │
│ │
│ Unless required by applicable law or agreed to in writing, software │
│ distributed under the License is distributed on an "AS IS" BASIS, │
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
│ See the License for the specific language governing permissions and │
│ limitations under the License. │
├──────────────────────────────────────────────────────────────────────────────┤
│ @important │
│ For any future changes to the code in this file, it is recommended to │
│ include, together with the modification, the information of the developer │
│ who changed it and the date of modification. │
└──────────────────────────────────────────────────────────────────────────────┘
*/
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { MessageSquare, Loader2, ChevronRight, Info } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/hooks/use-toast";
import {
Dialog,
DialogContent,
DialogTitle,
DialogHeader,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { getQueryParam } from "@/lib/utils";
import { getSharedAgent } from "@/services/agentService";
import { ChatMessage, ChatPart } from "@/services/sessionService";
import { useAgentWebSocket } from "@/hooks/use-agent-webSocket";
import { AgentInfo } from "./AgentInfo";
import Image from "next/image";
import { SharedSessionList } from "./components/SharedSessionList";
import { SharedChatPanel } from "./components/SharedChatPanel";
import { FileData } from "@/lib/file-utils";
interface AttachedFile {
filename: string;
content_type: string;
data: string;
size: number;
preview_url?: string;
}
interface FunctionMessageContent {
title: string;
content: string;
author?: string;
}
interface SharedAgentInfo {
id: string;
apiKey: string;
}
interface SharedSession {
id: string;
update_time: string;
name?: string;
messages: ChatMessage[];
}
export default function SharedChat() {
const [isLoading, setIsLoading] = useState(true);
const [agent, setAgent] = useState<any | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isSending, setIsSending] = useState(false);
const [agentParams, setAgentParams] = useState<SharedAgentInfo | null>(null);
const [isParamsDialogOpen, setIsParamsDialogOpen] = useState(false);
const [manualAgentId, setManualAgentId] = useState("");
const [manualApiKey, setManualApiKey] = useState("");
const [savedAgents, setSavedAgents] = useState<SharedAgentInfo[]>([]);
const [isInfoPanelOpen, setIsInfoPanelOpen] = useState(false);
const [sessions, setSessions] = useState<SharedSession[]>([]);
const [selectedSession, setSelectedSession] = useState<string | null>(null);
const [isSessionsCollapsed, setIsSessionsCollapsed] = useState(false);
const [sessionSearchTerm, setSessionSearchTerm] = useState("");
const { toast } = useToast();
const createNewSession = () => {
const sessionId = generateExternalId();
const newSession: SharedSession = {
id: sessionId,
update_time: new Date().toISOString(),
messages: [
{
id: `system-${Date.now()}`,
content: {
parts: [
{
text: "Welcome to this shared agent. Type a message to start chatting.",
},
],
role: "system",
},
author: "assistant",
timestamp: Date.now() / 1000,
},
],
};
setSessions((prev) => [...prev, newSession]);
setSelectedSession(sessionId);
setMessages(newSession.messages);
if (agentParams) {
saveSessionsToLocalStorage(agentParams.id, [...sessions, newSession]);
}
};
const saveSessionsToLocalStorage = (
agentId: string,
sessionsToSave: SharedSession[]
) => {
if (typeof window !== "undefined") {
const key = `shared_agent_sessions_${agentId}`;
localStorage.setItem(key, JSON.stringify(sessionsToSave));
}
};
const loadSessionsFromLocalStorage = (agentId: string): SharedSession[] => {
if (typeof window !== "undefined") {
const key = `shared_agent_sessions_${agentId}`;
const sessionsJson = localStorage.getItem(key);
if (sessionsJson) {
try {
return JSON.parse(sessionsJson);
} catch (e) {
console.error("Error parsing sessions from localStorage:", e);
}
}
}
return [];
};
useEffect(() => {
const agentId = getQueryParam("agent");
const apiKey = getQueryParam("key");
console.log("[Shared Chat] Params from URL:", { agentId, apiKey });
if (agentId && apiKey) {
setAgentParams({ id: agentId, apiKey });
if (typeof window !== "undefined") {
localStorage.setItem("shared_agent_api_key", apiKey);
console.log("[Shared Chat] API key set in localStorage");
const savedAgentsJson = localStorage.getItem("shared_agents") || "[]";
try {
const savedAgents = JSON.parse(savedAgentsJson) as SharedAgentInfo[];
const existingAgent = savedAgents.find((a) => a.id === agentId);
if (!existingAgent) {
const updatedAgents = [...savedAgents, { id: agentId, apiKey }];
localStorage.setItem(
"shared_agents",
JSON.stringify(updatedAgents)
);
console.log("[Shared Chat] Agent added to saved agents");
}
} catch (e) {
console.error("Error processing saved agents:", e);
}
}
} else {
if (typeof window !== "undefined") {
const savedAgentsJson = localStorage.getItem("shared_agents") || "[]";
try {
const savedAgents = JSON.parse(savedAgentsJson) as SharedAgentInfo[];
setSavedAgents(savedAgents);
if (savedAgents.length > 0) {
setAgentParams(savedAgents[savedAgents.length - 1]);
localStorage.setItem(
"shared_agent_api_key",
savedAgents[savedAgents.length - 1].apiKey
);
} else {
setIsParamsDialogOpen(true);
}
} catch (e) {
console.error("Error processing saved agents:", e);
setIsParamsDialogOpen(true);
}
} else {
setIsParamsDialogOpen(true);
}
}
}, []);
useEffect(() => {
const loadAgentData = async () => {
if (!agentParams) return;
setIsLoading(true);
try {
localStorage.setItem("shared_agent_api_key", agentParams.apiKey);
try {
const response = await getSharedAgent(agentParams.id);
setAgent(response.data);
} catch (error) {
console.error("Error loading agent data:", error);
setAgent({
id: agentParams.id,
name: "Shared Agent",
description: "This agent is being accessed via a shared API key",
type: "llm",
model: "Unknown model",
created_at: new Date().toISOString(),
});
}
const loadedSessions = loadSessionsFromLocalStorage(agentParams.id);
if (loadedSessions.length > 0) {
setSessions(loadedSessions);
const latestSession = loadedSessions.sort(
(a, b) =>
new Date(b.update_time).getTime() -
new Date(a.update_time).getTime()
)[0];
setSelectedSession(latestSession.id);
setMessages(latestSession.messages);
} else {
createNewSession();
}
} catch (error) {
console.error("Error setting up shared agent:", error);
toast({
title: "Error",
description: "Unable to set up the shared agent.",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
loadAgentData();
}, [agentParams, toast]);
useEffect(() => {
if (selectedSession && messages.length > 0) {
setSessions((prev) => {
const updatedSessions = prev.map((session) => {
if (session.id === selectedSession) {
return {
...session,
messages: messages,
update_time: new Date().toISOString(),
};
}
return session;
});
if (agentParams) {
saveSessionsToLocalStorage(agentParams.id, updatedSessions);
}
return updatedSessions;
});
}
}, [messages, selectedSession, agentParams]);
const handleSendMessage = async (messageText: string, files?: FileData[]) => {
if (
(!messageText.trim() && (!files || files.length === 0)) ||
!agentParams?.id ||
!selectedSession
)
return;
setIsSending(true);
const messageParts: ChatPart[] = [];
if (messageText.trim()) {
messageParts.push({ text: messageText });
}
if (files && files.length > 0) {
files.forEach((file) => {
messageParts.push({
inline_data: {
data: file.data,
mime_type: file.content_type,
},
});
});
}
setMessages((prev) => [
...prev,
{
id: `temp-${Date.now()}`,
content: {
parts: messageParts,
role: "user",
},
author: "user",
timestamp: Date.now() / 1000,
},
]);
wsSendMessage(messageText, files);
};
const generateExternalId = () => {
const now = new Date();
return (
now.getFullYear().toString() +
(now.getMonth() + 1).toString().padStart(2, "0") +
now.getDate().toString().padStart(2, "0") +
now.getHours().toString().padStart(2, "0") +
now.getMinutes().toString().padStart(2, "0") +
now.getSeconds().toString().padStart(2, "0") +
now.getMilliseconds().toString().padStart(3, "0")
);
};
const containsMarkdown = (text: string): boolean => {
if (!text || text.length < 3) return false;
const markdownPatterns = [
/[*_]{1,2}[^*_]+[*_]{1,2}/, // bold/italic
/\[[^\]]+\]\([^)]+\)/, // links
/^#{1,6}\s/m, // headers
/^[-*+]\s/m, // unordered lists
/^[0-9]+\.\s/m, // ordered lists
/^>\s/m, // block quotes
/`[^`]+`/, // inline code
/```[\s\S]*?```/, // code blocks
/^\|(.+\|)+$/m, // tables
/!\[[^\]]*\]\([^)]+\)/, // images
];
return markdownPatterns.some((pattern) => pattern.test(text));
};
const getMessageText = (
message: ChatMessage
): string | FunctionMessageContent => {
const author = message.author;
const parts = message.content.parts;
if (!parts || parts.length === 0) return "Empty content";
const functionCallPart = parts.find(
(part) => part.functionCall || part.function_call
);
const functionResponsePart = parts.find(
(part) => part.functionResponse || part.function_response
);
const inlineDataParts = parts.filter((part) => part.inline_data);
if (functionCallPart) {
const funcCall =
functionCallPart.functionCall || functionCallPart.function_call || {};
const args = funcCall.args || {};
const name = funcCall.name || "unknown";
const id = funcCall.id || "no-id";
return {
author,
title: `📞 Function call: ${name}`,
content: `ID: ${id}
Args: ${
Object.keys(args).length > 0
? `\n${JSON.stringify(args, null, 2)}`
: "{}"
}`,
} as FunctionMessageContent;
}
if (functionResponsePart) {
const funcResponse =
functionResponsePart.functionResponse ||
functionResponsePart.function_response ||
{};
const response = funcResponse.response || {};
const name = funcResponse.name || "unknown";
const id = funcResponse.id || "no-id";
const status = response.status || "unknown";
const statusEmoji = status === "error" ? "❌" : "✅";
let resultText = "";
if (status === "error") {
resultText = `Error: ${response.error_message || "Unknown error"}`;
} else if (response.report) {
resultText = `Result: ${response.report}`;
} else if (response.result && response.result.content) {
const content = response.result.content;
if (Array.isArray(content) && content.length > 0 && content[0].text) {
try {
const textContent = content[0].text;
const parsedJson = JSON.parse(textContent);
resultText = `Result: \n${JSON.stringify(parsedJson, null, 2)}`;
} catch (e) {
resultText = `Result: ${content[0].text}`;
}
} else {
resultText = `Result:\n${JSON.stringify(response, null, 2)}`;
}
} else {
resultText = `Result:\n${JSON.stringify(response, null, 2)}`;
}
return {
author,
title: `${statusEmoji} Function response: ${name}`,
content: `ID: ${id}\n${resultText}`,
} as FunctionMessageContent;
}
if (parts.length === 1 && parts[0].text) {
return {
author,
content: parts[0].text,
title: "Message",
} as FunctionMessageContent;
}
const textParts = parts
.filter((part) => part.text)
.map((part) => part.text)
.filter((text) => text);
if (textParts.length > 0) {
return {
author,
content: textParts.join("\n\n"),
title: "Message",
} as FunctionMessageContent;
}
return "Empty content";
};
const onEvent = useCallback((event: any) => {
setMessages((prev) => [...prev, event]);
}, []);
const onTurnComplete = useCallback(() => {
setIsSending(false);
}, []);
const externalId = selectedSession || generateExternalId();
const { sendMessage: wsSendMessage, disconnect: _ } = useAgentWebSocket({
agentId: agentParams?.id || "",
externalId,
apiKey: agentParams?.apiKey,
onEvent,
onTurnComplete,
});
const handleManualConnect = () => {
if (manualAgentId && manualApiKey) {
const newAgent = { id: manualAgentId, apiKey: manualApiKey };
if (typeof window !== "undefined") {
const savedAgentsJson = localStorage.getItem("shared_agents") || "[]";
try {
const savedAgents = JSON.parse(savedAgentsJson) as SharedAgentInfo[];
const existingAgentIndex = savedAgents.findIndex(
(a) => a.id === manualAgentId
);
if (existingAgentIndex >= 0) {
savedAgents[existingAgentIndex] = newAgent;
} else {
savedAgents.push(newAgent);
}
localStorage.setItem("shared_agents", JSON.stringify(savedAgents));
setSavedAgents(savedAgents);
} catch (e) {
console.error("Error processing saved agents:", e);
}
}
setAgentParams(newAgent);
setIsParamsDialogOpen(false);
} else {
toast({
title: "Incomplete data",
description: "Please fill in the agent ID and API key.",
variant: "destructive",
});
}
};
const selectSavedAgent = (agent: SharedAgentInfo) => {
setAgentParams(agent);
setIsParamsDialogOpen(false);
};
const handleSessionSelect = (sessionId: string | null) => {
if (!sessionId) return;
// Encontre a sessão selecionada e carregue suas mensagens
const session = sessions.find((s) => s.id === sessionId);
if (session) {
setMessages(session.messages);
setSelectedSession(sessionId);
}
};
return (
<div className="flex h-screen max-h-screen bg-neutral-950">
<SharedSessionList
sessions={sessions}
selectedSession={selectedSession}
isLoading={isLoading}
searchTerm={sessionSearchTerm}
isCollapsed={isSessionsCollapsed}
setSearchTerm={setSessionSearchTerm}
setSelectedSession={handleSessionSelect}
onNewSession={createNewSession}
onToggleCollapse={() => setIsSessionsCollapsed(!isSessionsCollapsed)}
agentName={agent?.name || "Shared Agent"}
/>
<div className="flex-1 flex flex-col overflow-hidden">
{agent && (
<div className="p-4 border-b border-neutral-700 bg-neutral-900 flex items-center justify-between">
<div className="flex items-center gap-4">
<Image
src="https://evolution-api.com/files/evo/logo-evo-ai.svg"
alt="Evolution API"
width={60}
height={30}
/>
<div className="h-10 w-px bg-neutral-800" />
<div
className="flex items-center gap-3 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setIsInfoPanelOpen(!isInfoPanelOpen)}
>
<div className="p-2 rounded-full bg-emerald-500/20">
<MessageSquare className="h-5 w-5 text-emerald-400" />
</div>
<div>
<h1 className="text-lg font-medium text-white flex items-center gap-2">
{agent.name}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-neutral-400 hover:text-emerald-400 p-0.5"
onClick={() => setIsInfoPanelOpen(true)}
>
<Info className="h-4 w-4" />
</Button>
</h1>
<p className="text-sm text-neutral-400">
{agent.description?.length > 100
? `${agent.description.substring(0, 100)}...`
: agent.description}
</p>
</div>
</div>
</div>
<Badge className="bg-emerald-500 text-white px-3 py-1 text-sm border-0">
Shared Agent
</Badge>
</div>
)}
{(selectedSession || agentParams) ? (
<SharedChatPanel
messages={messages}
isLoading={isLoading}
isSending={isSending}
agentName={agent?.name || "Shared Agent"}
onSendMessage={handleSendMessage}
getMessageText={getMessageText}
containsMarkdown={containsMarkdown}
sessionId={selectedSession || undefined}
/>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6 bg-neutral-950">
<div className="w-20 h-20 rounded-full bg-emerald-500/20 flex items-center justify-center mb-6 border border-emerald-500/30">
<MessageSquare className="h-10 w-10 text-emerald-400" />
</div>
<h2 className="text-2xl font-semibold text-white mb-3">
Welcome to Shared Chat
</h2>
<p className="text-neutral-400 mb-8 max-w-md">
To start, please enter an agent ID and API key
</p>
<Button
onClick={() => setIsParamsDialogOpen(true)}
className="bg-emerald-500 text-white hover:bg-emerald-600 px-6 py-6 h-auto rounded-xl"
>
Connect to Agent
</Button>
</div>
)}
</div>
<Dialog open={isParamsDialogOpen} onOpenChange={setIsParamsDialogOpen}>
<DialogContent className="bg-neutral-900 border-neutral-800 text-white max-w-md">
<DialogHeader>
<div className="flex items-center gap-2 mb-1">
<div className="p-1.5 rounded-full bg-emerald-500/20">
<MessageSquare className="h-5 w-5 text-emerald-400" />
</div>
<DialogTitle className="text-xl font-medium">
Connect to Shared Agent
</DialogTitle>
</div>
<DialogDescription className="text-neutral-400">
Enter the agent ID and API key to connect
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm text-neutral-300">Agent ID</label>
<Input
value={manualAgentId}
onChange={(e) => setManualAgentId(e.target.value)}
placeholder="Enter agent ID"
className="bg-neutral-800 border-neutral-700 text-white focus-visible:ring-emerald-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm text-neutral-300">API Key</label>
<Input
value={manualApiKey}
onChange={(e) => setManualApiKey(e.target.value)}
placeholder="Enter API key"
type="password"
className="bg-neutral-800 border-neutral-700 text-white focus-visible:ring-emerald-500"
/>
</div>
{savedAgents.length > 0 && (
<div className="pt-2 border-t border-neutral-700">
<p className="text-sm text-neutral-300 mb-2">Or select a saved agent:</p>
<div className="space-y-2 max-h-32 overflow-y-auto">
{savedAgents.map((agent) => (
<Button
key={agent.id}
variant="outline"
className="w-full justify-start text-left mb-1 bg-neutral-800 border-neutral-700 hover:bg-neutral-700 text-neutral-200"
onClick={() => selectSavedAgent(agent)}
>
<div className="truncate">{agent.id}</div>
</Button>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsParamsDialogOpen(false)}
className="bg-neutral-800 border-neutral-700 text-neutral-300 hover:bg-neutral-700 hover:text-white"
>
Cancel
</Button>
<Button
onClick={handleManualConnect}
disabled={!manualAgentId || !manualApiKey}
className="bg-emerald-500 text-white hover:bg-emerald-600"
>
Connect
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isInfoPanelOpen} onOpenChange={setIsInfoPanelOpen}>
<DialogContent className="bg-neutral-900 border-neutral-800 text-white max-w-3xl h-[80vh]">
<DialogHeader>
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-full bg-emerald-500/20">
<Info className="h-5 w-5 text-emerald-400" />
</div>
<DialogTitle className="text-xl font-medium">
{agent?.name || "Shared Agent"} Information
</DialogTitle>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto mt-2">
{agent && <AgentInfo agent={agent} isShared={true} />}
</div>
</DialogContent>
</Dialog>
</div>
);
}