Add Component Gallery to AGS (#4693)

* fix message instance check error

* general refactor, enable 3rd party gallery

* format fixes

* update detail view

* improve detail view and test sync capabilities

* minor tweaks, version bump

* version bump

* update uv.lock

* update lockfile

* update uv.lock

* update uv lock

* pin uv version

* uv version

* revert

* revert

* minor side bar and drag drop layout fixes

* revert version numbering.

---------

Co-authored-by: Eric Zhu <ekzhu@users.noreply.github.com>
This commit is contained in:
Victor Dibia
2024-12-14 15:33:14 -08:00
committed by GitHub
parent 0bfc0652ae
commit c7145156b1
33 changed files with 2122 additions and 566 deletions

View File

@@ -5,7 +5,16 @@ from typing import Any, Callable, Dict, Optional, Union
from uuid import UUID
from autogen_agentchat.base._task import TaskResult
from autogen_agentchat.messages import AgentMessage, ChatMessage, MultiModalMessage, TextMessage
from autogen_agentchat.messages import (
AgentMessage,
ChatMessage,
HandoffMessage,
MultiModalMessage,
StopMessage,
TextMessage,
ToolCallMessage,
ToolCallResultMessage,
)
from autogen_core import CancellationToken
from autogen_core import Image as AGImage
from fastapi import WebSocket, WebSocketDisconnect
@@ -91,16 +100,22 @@ class WebSocketManager:
if formatted_message:
await self._send_message(run_id, formatted_message)
# Save message if it's a content message
if isinstance(message, TextMessage):
await self._save_message(run_id, message)
elif isinstance(message, MultiModalMessage):
# Save messages by concrete type
if isinstance(
message,
(
TextMessage,
MultiModalMessage,
StopMessage,
HandoffMessage,
ToolCallMessage,
ToolCallResultMessage,
),
):
await self._save_message(run_id, message)
# Capture final result if it's a TeamResult
elif isinstance(message, TeamResult):
final_result = message.model_dump()
elif isinstance(message, (AgentMessage, ChatMessage)):
await self._save_message(run_id, message)
if not cancellation_token.is_cancelled() and run_id not in self._closed_connections:
if final_result:
await self._update_run(run_id, RunStatus.COMPLETE, team_result=final_result)
@@ -301,19 +316,20 @@ class WebSocketManager:
]
return {"type": "message", "data": message_dump}
elif isinstance(message, TextMessage):
return {"type": "message", "data": message.model_dump()}
elif isinstance(message, TeamResult):
return {
"type": "result",
"data": message.model_dump(),
"status": "complete",
}
elif isinstance(message, (AgentMessage, ChatMessage)):
elif isinstance(
message, (TextMessage, StopMessage, HandoffMessage, ToolCallMessage, ToolCallResultMessage)
):
return {"type": "message", "data": message.model_dump()}
return None
except Exception as e:
logger.error(f"Message formatting error: {e}")
return None

View File

@@ -99,6 +99,10 @@ const Layout = ({
<ConfigProvider
theme={{
token: {
borderRadius: 4,
colorBgBase: darkMode === "dark" ? "#05080C" : "#ffffff",
},
algorithm:
darkMode === "dark"
? theme.darkAlgorithm

View File

@@ -9,6 +9,7 @@ import {
Bot,
PanelLeftClose,
PanelLeftOpen,
GalleryHorizontalEnd,
} from "lucide-react";
import Icon from "./icons";
@@ -24,6 +25,12 @@ interface INavItem {
}
const navigation: INavItem[] = [
{
name: "Team Builder",
href: "/build",
icon: Bot,
breadcrumbs: [{ name: "Team Builder", href: "/build", current: true }],
},
{
name: "Playground",
href: "/",
@@ -31,10 +38,10 @@ const navigation: INavItem[] = [
breadcrumbs: [{ name: "Playground", href: "/", current: true }],
},
{
name: "Agent Teams",
href: "/build",
icon: Bot,
breadcrumbs: [{ name: "Build", href: "/build", current: true }],
name: "Gallery",
href: "/gallery",
icon: GalleryHorizontalEnd,
breadcrumbs: [{ name: "Gallery", href: "/gallery", current: true }],
},
];
@@ -134,29 +141,37 @@ const Sidebar = ({ link, meta, isMobile }: SidebarProps) => {
const IconComponent = item.icon;
const navLink = (
<Link
to={item.href}
onClick={() => handleNavClick(item)}
className={classNames(
// Base styles
"group flex gap-x-3 rounded-md mr-2 p-2 text-sm font-medium",
!showFull && "justify-center",
// Color states
isActive
? "bg-tertiary text-accent "
: "text-secondary hover:bg-tertiary hover:text-accent"
<div className="relative">
{isActive && (
<div className="bg-accent absolute top-1 left-0.5 z-50 h-8 w-1 bg-opacity-80 rounded">
{" "}
</div>
)}
>
<IconComponent
<Link
to={item.href}
onClick={() => handleNavClick(item)}
className={classNames(
"h-6 w-6 shrink-0",
// Base styles
"group ml-1 flex gap-x-3 rounded-md mr-2 p-2 text-sm font-medium",
!showFull && "justify-center",
// Color states
isActive
? "text-accent"
: "text-secondary group-hover:text-accent"
? "bg-secondary text-primary "
: "text-secondary hover:bg-tertiary hover:text-accent"
)}
/>
{showFull && item.name}
</Link>
>
{" "}
<IconComponent
className={classNames(
"h-6 w-6 shrink-0",
isActive
? "text-accent"
: "text-secondary group-hover:text-accent"
)}
/>
{showFull && item.name}
</Link>
</div>
);
return (

View File

@@ -98,6 +98,7 @@ export interface SessionRuns {
export interface BaseConfig {
component_type: string;
version?: string;
description?: string;
}
export interface WebSocketMessage {
@@ -119,7 +120,6 @@ export type ModelTypes =
export type AgentTypes =
| "AssistantAgent"
| "CodingAssistantAgent"
| "UserProxyAgent"
| "MultimodalWebSurfer"
| "FileSurfer"
@@ -132,12 +132,6 @@ export type TeamTypes =
| "SelectorGroupChat"
| "MagenticOneGroupChat";
// class ComponentType(str, Enum):
// TEAM = "team"
// AGENT = "agent"
// MODEL = "model"
// TOOL = "tool"
// TERMINATION = "termination"
export type TerminationTypes =
| "MaxMessageTermination"
| "StopMessageTermination"
@@ -153,11 +147,11 @@ export type ComponentTypes =
| "termination";
export type ComponentConfigTypes =
| TeamConfigTypes
| TeamConfig
| AgentConfig
| ModelConfigTypes
| ModelConfig
| ToolConfig
| TerminationConfigTypes;
| TerminationConfig;
export interface BaseModelConfig extends BaseConfig {
model: string;
@@ -178,22 +172,57 @@ export interface OpenAIModelConfig extends BaseModelConfig {
model_type: "OpenAIChatCompletionClient";
}
export type ModelConfigTypes = AzureOpenAIModelConfig | OpenAIModelConfig;
export type ModelConfig = AzureOpenAIModelConfig | OpenAIModelConfig;
export interface ToolConfig extends BaseConfig {
export interface BaseToolConfig extends BaseConfig {
name: string;
description: string;
content: string;
tool_type: ToolTypes;
}
export interface AgentConfig extends BaseConfig {
export interface PythonFunctionToolConfig extends BaseToolConfig {
tool_type: "PythonFunction";
}
export type ToolConfig = PythonFunctionToolConfig;
export interface BaseAgentConfig extends BaseConfig {
name: string;
agent_type: AgentTypes;
system_message?: string;
model_client?: ModelConfigTypes;
model_client?: ModelConfig;
tools?: ToolConfig[];
description?: string;
}
export interface AssistantAgentConfig extends BaseAgentConfig {
agent_type: "AssistantAgent";
}
export interface UserProxyAgentConfig extends BaseAgentConfig {
agent_type: "UserProxyAgent";
}
export interface MultimodalWebSurferAgentConfig extends BaseAgentConfig {
agent_type: "MultimodalWebSurfer";
}
export interface FileSurferAgentConfig extends BaseAgentConfig {
agent_type: "FileSurfer";
}
export interface MagenticOneCoderAgentConfig extends BaseAgentConfig {
agent_type: "MagenticOneCoderAgent";
}
export type AgentConfig =
| AssistantAgentConfig
| UserProxyAgentConfig
| MultimodalWebSurferAgentConfig
| FileSurferAgentConfig
| MagenticOneCoderAgentConfig;
// export interface TerminationConfig extends BaseConfig {
// termination_type: TerminationTypes;
// max_messages?: number;
@@ -217,28 +246,19 @@ export interface TextMentionTerminationConfig extends BaseTerminationConfig {
export interface CombinationTerminationConfig extends BaseTerminationConfig {
termination_type: "CombinationTermination";
operator: "and" | "or";
conditions: TerminationConfigTypes[];
conditions: TerminationConfig[];
}
export type TerminationConfigTypes =
export type TerminationConfig =
| MaxMessageTerminationConfig
| TextMentionTerminationConfig
| CombinationTerminationConfig;
// export interface TeamConfig extends BaseConfig {
// name: string;
// participants: AgentConfig[];
// team_type: TeamTypes;
// model_client?: ModelConfig;
// termination_condition?: TerminationConfig;
// selector_prompt?: string;
// }
export interface BaseTeamConfig extends BaseConfig {
name: string;
participants: AgentConfig[];
team_type: TeamTypes;
termination_condition?: TerminationConfigTypes;
termination_condition?: TerminationConfig;
}
export interface RoundRobinGroupChatConfig extends BaseTeamConfig {
@@ -248,15 +268,13 @@ export interface RoundRobinGroupChatConfig extends BaseTeamConfig {
export interface SelectorGroupChatConfig extends BaseTeamConfig {
team_type: "SelectorGroupChat";
selector_prompt: string;
model_client: ModelConfigTypes;
model_client: ModelConfig;
}
export type TeamConfigTypes =
| RoundRobinGroupChatConfig
| SelectorGroupChatConfig;
export type TeamConfig = RoundRobinGroupChatConfig | SelectorGroupChatConfig;
export interface Team extends DBModel {
config: TeamConfigTypes;
config: TeamConfig;
}
export interface TeamResult {

View File

@@ -0,0 +1,200 @@
import React, { useState, useRef } from "react";
import { Modal, Tabs, Input, Button, Alert, Upload } from "antd";
import { Globe, Upload as UploadIcon, Code } from "lucide-react";
import { MonacoEditor } from "../monaco";
import type { InputRef, UploadFile, UploadProps } from "antd";
import { Gallery } from "./types";
import { defaultGallery } from "./utils";
interface GalleryCreateModalProps {
open: boolean;
onCancel: () => void;
onCreateGallery: (gallery: Gallery) => void;
}
export const GalleryCreateModal: React.FC<GalleryCreateModalProps> = ({
open,
onCancel,
onCreateGallery,
}) => {
const [activeTab, setActiveTab] = useState("url");
const [url, setUrl] = useState("");
const [jsonContent, setJsonContent] = useState(
JSON.stringify(defaultGallery, null, 2)
);
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const editorRef = useRef(null);
const handleUrlImport = async () => {
setIsLoading(true);
setError("");
try {
const response = await fetch(url);
const data = await response.json();
// TODO: Validate against Gallery schema
onCreateGallery(data);
onCancel();
} catch (err) {
setError("Failed to fetch or parse gallery from URL");
} finally {
setIsLoading(false);
}
};
const handleFileUpload = (info: { file: UploadFile }) => {
const { status, originFileObj } = info.file;
if (status === "done" && originFileObj instanceof File) {
const reader = new FileReader();
reader.onload = (e: ProgressEvent<FileReader>) => {
try {
const content = JSON.parse(e.target?.result as string);
// TODO: Validate against Gallery schema
onCreateGallery(content);
onCancel();
} catch (err) {
setError("Invalid JSON file");
}
};
reader.readAsText(originFileObj);
} else if (status === "error") {
setError("File upload failed");
}
};
const handlePasteImport = () => {
try {
const content = JSON.parse(jsonContent);
// TODO: Validate against Gallery schema
onCreateGallery(content);
onCancel();
} catch (err) {
setError("Invalid JSON format");
}
};
const uploadProps: UploadProps = {
accept: ".json",
showUploadList: false,
customRequest: ({ file, onSuccess }) => {
setTimeout(() => {
onSuccess && onSuccess("ok");
}, 0);
},
onChange: handleFileUpload,
};
const inputRef = useRef<InputRef>(null);
const items = [
{
key: "url",
label: (
<span className="flex items-center gap-2">
<Globe className="w-4 h-4" /> URL Import
</span>
),
children: (
<div className="space-y-4">
<Input
ref={inputRef}
placeholder="Enter gallery URL..."
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<div className="text-xs">
Sample
<a
role="button"
onClick={(e) => {
setUrl(
"https://raw.githubusercontent.com/victordibia/multiagent-systems-with-autogen/refs/heads/main/research/components/gallery/base.json"
);
e.preventDefault();
}}
href="https://raw.githubusercontent.com/victordibia/multiagent-systems-with-autogen/refs/heads/main/research/components/gallery/base.json"
target="_blank"
rel="noreferrer"
className="text-accent"
>
{" "}
gallery.json{" "}
</a>
</div>
<Button
type="primary"
onClick={handleUrlImport}
disabled={!url || isLoading}
block
>
Import from URL
</Button>
</div>
),
},
{
key: "file",
label: (
<span className="flex items-center gap-2">
<UploadIcon className="w-4 h-4" /> File Upload
</span>
),
children: (
<div className="border-2 border-dashed rounded-lg p-8 text-center space-y-4">
<Upload.Dragger {...uploadProps}>
<p className="ant-upload-drag-icon">
<UploadIcon className="w-8 h-8 mx-auto text-secondary" />
</p>
<p className="ant-upload-text">
Click or drag JSON file to this area
</p>
</Upload.Dragger>
</div>
),
},
{
key: "paste",
label: (
<span className="flex items-center gap-2">
<Code className="w-4 h-4" /> Paste JSON
</span>
),
children: (
<div className="space-y-4">
<div className="h-64">
<MonacoEditor
value={jsonContent}
onChange={setJsonContent}
editorRef={editorRef}
language="json"
minimap={false}
/>
</div>
<Button type="primary" onClick={handlePasteImport} block>
Import JSON
</Button>
</div>
),
},
];
return (
<Modal
title="Create New Gallery"
open={open}
onCancel={onCancel}
footer={null}
width={800}
>
<div className="mt-4">
<Tabs activeKey={activeTab} onChange={setActiveTab} items={items} />
{error && (
<Alert message={error} type="error" showIcon className="mt-4" />
)}
</div>
</Modal>
);
};
export default GalleryCreateModal;

View File

@@ -0,0 +1,315 @@
import React, { useState, useRef } from "react";
import { Button, message, Tooltip } from "antd";
import {
Package,
Users,
Bot,
Globe,
RefreshCw,
Edit2,
X,
Wrench,
Brain,
Timer,
Save,
ChevronUp,
ChevronDown,
Edit,
} from "lucide-react";
import type { Gallery } from "./types";
import { useGalleryStore } from "./store";
import { MonacoEditor } from "../monaco";
import { ComponentConfigTypes } from "../../types/datamodel";
import { getRelativeTimeString, TruncatableText } from "../atoms";
const ComponentGrid: React.FC<{
title: string;
icon: React.ReactNode;
items: ComponentConfigTypes[];
}> = ({ title, icon, items }) => {
const [isExpanded, setIsExpanded] = useState(true);
return (
<div className="bg-tertiary rounded p-2">
<div
className="flex items-center justify-between cursor-pointer mb-2"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<span className="text-primary">{icon}</span>
<span className="text-sm capitalize">
{items.length} {items.length === 1 ? title : `${title}s`}
</span>
</div>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-primary" />
) : (
<ChevronDown className="w-4 h-4 text-primary" />
)}
</div>
<div
className={`space-y-2 transition-all duration-200 ${
isExpanded ? "max-h-[500px]" : "max-h-0"
} overflow-hidden`}
>
{items.map((item, idx) => (
<div
key={idx}
className="bg-secondary rounded p-3 hover:bg-tertiary transition-colors"
>
<div className="text-sm font-medium truncate">
{item.component_type}
</div>
{item.description && (
<p className="text-xs text-primary mt-1 ">
<TruncatableText
content={item.description}
textThreshold={150}
/>
</p>
)}
</div>
))}
</div>
</div>
);
};
interface GalleryDetailProps {
gallery: Gallery;
onSave: (updates: Partial<Gallery>) => void;
onDirtyStateChange: (isDirty: boolean) => void;
}
export const GalleryDetail: React.FC<GalleryDetailProps> = ({
gallery,
onSave,
onDirtyStateChange,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const [jsonValue, setJsonValue] = useState(JSON.stringify(gallery, null, 2));
const editorRef = useRef(null);
const { syncGallery, getLastSyncTime } = useGalleryStore();
const handleSync = async () => {
if (!gallery.url) return;
setIsSyncing(true);
try {
await syncGallery(gallery.id);
message.success("Gallery synced successfully");
} catch (error) {
message.error("Failed to sync gallery");
} finally {
setIsSyncing(false);
}
};
const handleJsonChange = (value: string) => {
setJsonValue(value);
onDirtyStateChange(true);
};
const handleSave = async () => {
try {
const parsedGallery = JSON.parse(jsonValue);
const updatedGallery = {
...parsedGallery,
id: gallery.id,
metadata: {
...parsedGallery.metadata,
updated_at: new Date().toISOString(),
},
};
await onSave(updatedGallery);
onDirtyStateChange(false);
setIsEditing(false);
message.success("Gallery updated successfully");
} catch (error) {
message.error("Invalid JSON format");
}
};
const gridItems = [
{
icon: <Users className="w-4 h-4" />,
title: "team",
items: gallery.items.teams,
},
{
icon: <Bot className="w-4 h-4" />,
title: "agent",
items: gallery.items.components.agents,
},
{
icon: <Wrench className="w-4 h-4" />,
title: "tool",
items: gallery.items.components.tools,
},
{
icon: <Brain className="w-4 h-4" />,
title: "model",
items: gallery.items.components.models,
},
{
icon: <Timer className="w-4 h-4" />,
title: "termination",
items: gallery.items.components.terminations,
},
];
return (
<div className="space-y-6">
{/* Banner Section - Kept unchanged */}
<div className="relative h-72 rounded bg-secondary overflow-hidden">
<img
src="/images/bg/layeredbg.svg"
alt="Gallery Banner"
className="absolute w-full h-full object-cover"
/>
<div className="relative z-10 p-6 h-full flex flex-col justify-between">
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-medium text-primary">
{gallery.name}
</h1>
{gallery.url && (
<Tooltip title="Remote Gallery">
<Globe className="w-5 h-5 text-secondary" />
</Tooltip>
)}
</div>
<p className="text-secondary w-1/2 mt-2 line-clamp-3">
{gallery.metadata.description}
</p>
<p className="text-secondary text-sm mt-2">
{gallery.metadata.author}
</p>
</div>
<div className="flex gap-2">
<div className="bg-tertiary backdrop-blur rounded p-2 flex items-center gap-2">
<Package className="w-4 h-4 text-secondary" />
<span className="text-sm">
{Object.values(gallery.items.components).reduce(
(sum, arr) => sum + arr.length,
0
)}{" "}
components
</span>
</div>
<div className="bg-tertiary backdrop-blur rounded p-2 text-sm">
v{gallery.metadata.version}
</div>
{gallery.metadata.tags?.map((tag) => (
<div
key={tag}
className="bg-tertiary backdrop-blur rounded p-2 px-2 text-sm"
>
{tag}
</div>
))}
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-2">
{gallery.url && (
<Tooltip
title={
getLastSyncTime(gallery.id)
? `Last synced: ${getRelativeTimeString(
getLastSyncTime(gallery.id) || ""
)}`
: "Never synced"
}
>
<Button
icon={
<RefreshCw
className={(isSyncing ? "animate-spin" : "") + " w-4 h-4"}
/>
}
loading={isSyncing}
onClick={handleSync}
>
Sync
</Button>
</Tooltip>
)}
{!isEditing ? (
<Button
icon={<Edit className="w-4 h-4" />}
onClick={() => setIsEditing(true)}
>
Edit
</Button>
) : (
<>
<Button
icon={<X className="w-4 h-4" />}
onClick={() => setIsEditing(false)}
>
Cancel
</Button>
<Button type="primary" onClick={handleSave}>
Save Changes
</Button>
</>
)}
</div>
{/* Grid Layout */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{gridItems.map((item) => (
<ComponentGrid
key={item.title}
title={item.title}
icon={item.icon}
items={item.items}
/>
))}
</div>
{/* Editor Section */}
{isEditing && (
<div
className="fixed bottom-0 left-0 right-0 bg-primary z-50 shadow-lg transition-transform duration-300 ease-in-out transform"
style={{ height: "70vh" }}
>
<div className="border-b border-secondary p-4 flex justify-between items-center">
<h3 className="text-normal font-medium">
Edit Gallery Configuration
</h3>
<div className="inline-flex gap-2">
<Tooltip title="Save Changes">
<Button
icon={<Save className="w-4 h-4" />}
onClick={handleSave}
/>
</Tooltip>
<Tooltip title="Cancel Editing">
<Button
icon={<X className="w-4 h-4" />}
onClick={() => setIsEditing(false)}
/>
</Tooltip>
</div>
</div>
<div className="h-[calc(100%-60px)]">
<MonacoEditor
value={jsonValue}
onChange={handleJsonChange}
editorRef={editorRef}
language="json"
minimap={true}
/>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,205 @@
import React, { useEffect, useState } from "react";
import { message, Modal } from "antd";
import { ChevronRight } from "lucide-react";
import { useGalleryStore } from "./store";
import { GallerySidebar } from "./sidebar";
import { GalleryDetail } from "./detail";
import { GalleryCreateModal } from "./create-modal";
import type { Gallery } from "./types";
export const GalleryManager: React.FC = () => {
const [isLoading, setIsLoading] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(() => {
if (typeof window !== "undefined") {
const stored = localStorage.getItem("gallerySidebar");
return stored !== null ? JSON.parse(stored) : true;
}
return true;
});
const {
galleries,
selectedGalleryId,
selectGallery,
addGallery,
updateGallery,
removeGallery,
setDefaultGallery,
getSelectedGallery,
getDefaultGallery,
} = useGalleryStore();
const [messageApi, contextHolder] = message.useMessage();
const currentGallery = getSelectedGallery();
// Persist sidebar state
useEffect(() => {
if (typeof window !== "undefined") {
localStorage.setItem("gallerySidebar", JSON.stringify(isSidebarOpen));
}
}, [isSidebarOpen]);
// Handle URL params
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const galleryId = params.get("galleryId");
if (galleryId && !selectedGalleryId) {
handleSelectGallery(galleryId);
}
}, []);
// Update URL when gallery changes
useEffect(() => {
if (selectedGalleryId) {
window.history.pushState({}, "", `?galleryId=${selectedGalleryId}`);
}
}, [selectedGalleryId]);
const handleSelectGallery = async (galleryId: string) => {
if (hasUnsavedChanges) {
Modal.confirm({
title: "Unsaved Changes",
content: "You have unsaved changes. Do you want to discard them?",
okText: "Discard",
cancelText: "Go Back",
onOk: () => {
selectGallery(galleryId);
setHasUnsavedChanges(false);
},
});
} else {
selectGallery(galleryId);
}
};
const handleCreateGallery = async (galleryData: Gallery) => {
const newGallery: Gallery = {
id: `gallery_${Date.now()}`,
name: galleryData.name || "New Gallery",
url: galleryData.url,
metadata: {
...galleryData.metadata,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
items: galleryData.items || {
teams: [],
components: {
agents: [],
models: [],
tools: [],
terminations: [],
},
},
};
try {
setIsLoading(true);
await addGallery(newGallery);
messageApi.success("Gallery created successfully");
selectGallery(newGallery.id);
} catch (error) {
messageApi.error("Failed to create gallery");
console.error(error);
} finally {
setIsLoading(false);
}
};
const handleDeleteGallery = async (galleryId: string) => {
try {
await removeGallery(galleryId);
messageApi.success("Gallery deleted successfully");
} catch (error) {
messageApi.error("Failed to delete gallery");
console.error(error);
}
};
const handleUpdateGallery = async (
galleryId: string,
updates: Partial<Gallery>
) => {
try {
await updateGallery(galleryId, updates);
setHasUnsavedChanges(false);
messageApi.success("Gallery updated successfully");
} catch (error) {
messageApi.error("Failed to update gallery");
console.error(error);
}
};
return (
<div className="relative flex h-full w-full">
{contextHolder}
{/* Create Modal */}
<GalleryCreateModal
open={isCreateModalOpen}
onCancel={() => setIsCreateModalOpen(false)}
onCreateGallery={handleCreateGallery}
/>
{/* Sidebar */}
<div
className={`absolute left-0 top-0 h-full transition-all duration-200 ease-in-out ${
isSidebarOpen ? "w-64" : "w-12"
}`}
>
<GallerySidebar
isOpen={isSidebarOpen}
galleries={galleries}
currentGallery={currentGallery}
onToggle={() => setIsSidebarOpen(!isSidebarOpen)}
onSelectGallery={(gallery) => handleSelectGallery(gallery.id)}
onCreateGallery={() => setIsCreateModalOpen(true)}
onDeleteGallery={handleDeleteGallery}
defaultGalleryId={getDefaultGallery()?.id}
onSetDefault={setDefaultGallery}
isLoading={isLoading}
/>
</div>
{/* Main Content */}
<div
className={`flex-1 transition-all -mr-6 duration-200 ${
isSidebarOpen ? "ml-64" : "ml-12"
}`}
>
<div className="p-4 pt-2">
{/* Breadcrumb */}
<div className="flex items-center gap-2 mb-4 text-sm">
<span className="text-primary font-medium">Galleries</span>
{currentGallery && (
<>
<ChevronRight className="w-4 h-4 text-secondary" />
<span className="text-secondary">{currentGallery.name}</span>
</>
)}
</div>
{/* Content Area */}
{currentGallery ? (
<GalleryDetail
gallery={currentGallery}
onSave={(updates) =>
handleUpdateGallery(currentGallery.id, updates)
}
onDirtyStateChange={setHasUnsavedChanges}
/>
) : (
<div className="flex items-center justify-center h-[calc(100vh-120px)] text-secondary">
Select a gallery from the sidebar or create a new one
</div>
)}
</div>
</div>
</div>
);
};
export default GalleryManager;

View File

@@ -0,0 +1,276 @@
import React from "react";
import { Button, Tooltip, Tag } from "antd";
import {
Plus,
Trash2,
PanelLeftClose,
PanelLeftOpen,
Pin,
Package,
RefreshCw,
Globe,
Info,
} from "lucide-react";
import type { Gallery } from "./types";
import { getRelativeTimeString } from "../atoms";
import { useGalleryStore } from "./store";
interface GallerySidebarProps {
isOpen: boolean;
galleries: Gallery[];
currentGallery: Gallery | null;
onToggle: () => void;
onSelectGallery: (gallery: Gallery) => void;
onCreateGallery: () => void;
onDeleteGallery: (galleryId: string) => void;
onSetDefault: (galleryId: string) => void;
isLoading?: boolean;
defaultGalleryId: string;
}
export const GallerySidebar: React.FC<GallerySidebarProps> = ({
isOpen,
galleries,
currentGallery,
onToggle,
onSelectGallery,
onCreateGallery,
onDeleteGallery,
onSetDefault,
defaultGalleryId,
isLoading = false,
}) => {
const { syncGallery, getLastSyncTime } = useGalleryStore();
// Render collapsed state
if (!isOpen) {
return (
<div className="h-full border-r border-secondary">
<div className="p-2 -ml-2">
<Tooltip title={`Galleries (${galleries.length})`}>
<button
onClick={onToggle}
className="p-2 rounded-md hover:bg-secondary hover:text-accent text-secondary transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-opacity-50"
>
<PanelLeftOpen strokeWidth={1.5} className="h-6 w-6" />
</button>
</Tooltip>
</div>
<div className="mt-4 px-2 -ml-1">
<Tooltip title="Create new gallery">
<Button
type="text"
className="w-full p-2 flex justify-center"
onClick={onCreateGallery}
icon={<Plus className="w-4 h-4" />}
/>
</Tooltip>
</div>
</div>
);
}
// Render expanded state
return (
<div className="h-full border-r border-secondary">
{/* Header */}
<div className="flex items-center justify-between pt-0 p-4 pl-2 pr-2 border-b border-secondary">
<div className="flex items-center gap-2">
<span className="text-primary font-medium">Galleries</span>
<span className="px-2 py-0.5 text-xs bg-accent/10 text-accent rounded">
{galleries.length}
</span>
</div>
<Tooltip title="Close Sidebar">
<button
onClick={onToggle}
className="p-2 rounded-md hover:bg-secondary hover:text-accent text-secondary transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-opacity-50"
>
<PanelLeftClose strokeWidth={1.5} className="h-6 w-6" />
</button>
</Tooltip>
</div>
{/* Create Gallery Button */}
<div className="my-4 flex text-sm">
<div className="mr-2 w-full">
<Tooltip title="Create new gallery">
<Button
type="primary"
className="w-full"
icon={<Plus className="w-4 h-4" />}
onClick={onCreateGallery}
>
New Gallery
</Button>
</Tooltip>
</div>
</div>
{/* Section Label */}
<div className="py-2 text-sm text-secondary">All Galleries</div>
{/* Galleries List */}
{isLoading ? (
<div className="p-4 text-center text-secondary text-sm">Loading...</div>
) : galleries.length === 0 ? (
<div className="p-4 text-center text-secondary text-sm">
No galleries found
</div>
) : (
<div className="scroll overflow-y-auto h-[calc(100%-170px)]">
<>
{galleries.map((gallery) => (
<div key={gallery.id} className="relative border-secondary">
<div
className={`absolute top-1 left-0.5 z-50 h-[calc(100%-8px)] w-1 bg-opacity-80 rounded ${
currentGallery?.id === gallery.id
? "bg-accent"
: "bg-tertiary"
}`}
/>
<div
className={`group ml-1 flex flex-col p-3 rounded-l cursor-pointer hover:bg-secondary ${
currentGallery?.id === gallery.id
? "border-accent bg-secondary"
: "border-transparent"
}`}
onClick={() => onSelectGallery(gallery)}
>
{/* Gallery Name and Actions Row */}
<div className="flex items-center justify-between min-w-0">
{" "}
{/* Added min-w-0 */}
<div className="flex items-center gap-2 min-w-0 flex-1">
{" "}
{/* Added min-w-0 and flex-1 */}
<div className="truncate flex-1">
{" "}
{/* Wrapped name in div with truncate and flex-1 */}
<span className="font-medium">{gallery.name}</span>
</div>
{gallery.url && (
<Tooltip title="Remote Gallery">
<Globe className="w-3 h-3 text-secondary flex-shrink-0" />{" "}
{/* Added flex-shrink-0 */}
</Tooltip>
)}
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity ml-2 flex-shrink-0">
{gallery.url && (
<Tooltip
title={
getLastSyncTime(gallery.id)
? `Last synced: ${getLastSyncTime(gallery.id)}`
: "Never synced"
}
>
<Button
type="text"
size="small"
className="p-0 min-w-[24px] h-6"
icon={<RefreshCw className="w-4 h-4" />}
onClick={(e) => {
e.stopPropagation();
syncGallery(gallery.id);
}}
/>
</Tooltip>
)}
<Tooltip
title={
defaultGalleryId === gallery.id
? "Default gallery"
: "Set as default gallery"
}
>
<Button
type="text"
size="small"
className={`p-0 min-w-[24px] h-6 ${
defaultGalleryId === gallery.id ? "text-accent" : ""
}`}
icon={
<Pin
className={`w-4 h-4 ${
defaultGalleryId === gallery.id
? "fill-accent"
: ""
}`}
/>
}
onClick={(e) => {
e.stopPropagation();
onSetDefault(gallery.id);
}}
/>
</Tooltip>
<Tooltip
title={
galleries.length === 1
? "Cannot delete the last gallery"
: "Delete gallery"
}
>
<Button
type="text"
size="small"
className="p-0 min-w-[24px] h-6"
danger
disabled={galleries.length === 1}
icon={<Trash2 className="w-4 h-4 text-red-500" />}
onClick={(e) => {
e.stopPropagation();
onDeleteGallery(gallery.id);
}}
/>
</Tooltip>
</div>
</div>
{/* Rest of the content remains the same */}
<div className="mt-1 flex items-center gap-2 text-xs text-secondary">
<span className="bg-secondary/20 truncate rounded px-1">
v{gallery.metadata.version}
</span>
<div className="flex items-center gap-1">
<Package className="w-3 h-3" />
<span>
{Object.values(gallery.items.components).reduce(
(sum, arr) => sum + arr.length,
0
)}{" "}
components
</span>
</div>
</div>
{/* Updated Timestamp */}
<div className="mt-1 flex items-center gap-1 text-xs text-secondary">
<span>
{getRelativeTimeString(gallery.metadata.updated_at)}
{defaultGalleryId === gallery.id ? (
<span className="text-accent border-accent border rounded px-1 ml-1 py-0.5">
default
</span>
) : (
""
)}
</span>
</div>
</div>
</div>
))}
</>
<div className="p-2 mt-2 border-dashed border rounded text-xs mr-2">
Gallery items marked as default (
<Pin className="w-4 h-4 inline-block -mt-0.5" />) are available in
the builder by default.
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,156 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { Gallery } from "./types";
import {
AgentConfig,
ModelConfig,
TeamConfig,
TerminationConfig,
ToolConfig,
} from "../../types/datamodel";
import { defaultGallery } from "./utils";
interface GalleryStore {
galleries: Gallery[];
defaultGalleryId: string;
selectedGalleryId: string | null;
addGallery: (gallery: Gallery) => void;
updateGallery: (id: string, gallery: Partial<Gallery>) => void;
removeGallery: (id: string) => void;
setDefaultGallery: (id: string) => void;
selectGallery: (id: string) => void;
getDefaultGallery: () => Gallery;
getSelectedGallery: () => Gallery | null;
syncGallery: (id: string) => Promise<void>;
getLastSyncTime: (id: string) => string | null;
getGalleryComponents: () => {
teams: TeamConfig[];
components: {
agents: AgentConfig[];
models: ModelConfig[];
tools: ToolConfig[];
terminations: TerminationConfig[];
};
};
}
export const useGalleryStore = create<GalleryStore>()(
persist(
(set, get) => ({
galleries: [defaultGallery],
defaultGalleryId: defaultGallery.id,
selectedGalleryId: defaultGallery.id,
addGallery: (gallery) =>
set((state) => {
if (state.galleries.find((g) => g.id === gallery.id)) return state;
return {
galleries: [gallery, ...state.galleries],
defaultGalleryId: state.defaultGalleryId || gallery.id,
selectedGalleryId: state.selectedGalleryId || gallery.id,
};
}),
updateGallery: (id, updates) =>
set((state) => ({
galleries: state.galleries.map((gallery) =>
gallery.id === id
? {
...gallery,
...updates,
metadata: {
...gallery.metadata,
...updates.metadata,
updated_at: new Date().toISOString(),
},
}
: gallery
),
})),
removeGallery: (id) =>
set((state) => {
if (state.galleries.length <= 1) return state;
const newGalleries = state.galleries.filter((g) => g.id !== id);
const updates: Partial<GalleryStore> = {
galleries: newGalleries,
};
if (id === state.defaultGalleryId) {
updates.defaultGalleryId = newGalleries[0].id;
}
if (id === state.selectedGalleryId) {
updates.selectedGalleryId = newGalleries[0].id;
}
return updates;
}),
setDefaultGallery: (id) =>
set((state) => {
const gallery = state.galleries.find((g) => g.id === id);
if (!gallery) return state;
return { defaultGalleryId: id };
}),
selectGallery: (id) =>
set((state) => {
const gallery = state.galleries.find((g) => g.id === id);
if (!gallery) return state;
return { selectedGalleryId: id };
}),
getDefaultGallery: () => {
const { galleries, defaultGalleryId } = get();
return galleries.find((g) => g.id === defaultGalleryId)!;
},
getSelectedGallery: () => {
const { galleries, selectedGalleryId } = get();
if (!selectedGalleryId) return null;
return galleries.find((g) => g.id === selectedGalleryId) || null;
},
syncGallery: async (id) => {
const gallery = get().galleries.find((g) => g.id === id);
if (!gallery?.url) return;
try {
const response = await fetch(gallery.url);
const remoteGallery = await response.json();
get().updateGallery(id, {
...remoteGallery,
id, // preserve local id
metadata: {
...remoteGallery.metadata,
lastSynced: new Date().toISOString(),
},
});
} catch (error) {
console.error("Failed to sync gallery:", error);
throw error;
}
},
getLastSyncTime: (id) => {
const gallery = get().galleries.find((g) => g.id === id);
return gallery?.metadata.lastSynced ?? null;
},
getGalleryComponents: () => {
const defaultGallery = get().getDefaultGallery();
return {
teams: defaultGallery.items.teams,
components: defaultGallery.items.components,
};
},
}),
{
name: "gallery-storage",
}
)
);

View File

@@ -0,0 +1,44 @@
import {
AgentConfig,
ModelConfig,
TeamConfig,
TerminationConfig,
ToolConfig,
} from "../../types/datamodel";
export interface GalleryMetadata {
author: string;
created_at: string;
updated_at: string;
version: string;
description?: string;
tags?: string[];
license?: string;
homepage?: string;
category?: string;
lastSynced?: string;
}
export interface Gallery {
id: string;
name: string;
url?: string;
metadata: GalleryMetadata;
items: {
teams: TeamConfig[];
components: {
agents: AgentConfig[];
models: ModelConfig[];
tools: ToolConfig[];
terminations: TerminationConfig[];
};
};
}
export interface GalleryAPI {
listGalleries: () => Promise<Gallery[]>;
getGallery: (id: string) => Promise<Gallery>;
createGallery: (gallery: Gallery) => Promise<Gallery>;
updateGallery: (gallery: Gallery) => Promise<Gallery>;
deleteGallery: (id: string) => Promise<void>;
}

View File

@@ -0,0 +1,194 @@
import {
AssistantAgentConfig,
CombinationTerminationConfig,
MaxMessageTerminationConfig,
OpenAIModelConfig,
PythonFunctionToolConfig,
RoundRobinGroupChatConfig,
TextMentionTerminationConfig,
UserProxyAgentConfig,
} from "../../types/datamodel";
export const defaultGallery = {
id: "gallery_default",
name: "Default Component Gallery",
metadata: {
author: "AutoGen Team",
created_at: "2024-12-12T00:00:00Z",
updated_at: "2024-12-12T00:00:00Z",
version: "1.0.0",
description:
"A default gallery containing basic components for human-in-loop conversations",
tags: ["human-in-loop", "assistant"],
license: "MIT",
category: "conversation",
},
items: {
teams: [
{
component_type: "team",
description:
"A team with an assistant agent and a user agent to enable human-in-loop task completion in a round-robin fashion",
name: "huma_in_loop_team",
participants: [
{
component_type: "agent",
description:
"An assistant agent that can help users complete tasks",
name: "assistant_agent",
agent_type: "AssistantAgent",
system_message:
"You are a helpful assistant. Solve tasks carefully. You also have a calculator tool which you can use if needed. When the task is done respond with TERMINATE.",
model_client: {
component_type: "model",
description: "A GPT-4o mini model",
model: "gpt-4o-mini",
model_type: "OpenAIChatCompletionClient",
},
tools: [
{
component_type: "tool",
name: "calculator",
description:
"A simple calculator that performs basic arithmetic operations between two numbers",
content:
"def calculator(a: float, b: float, operator: str) -> str:\n try:\n if operator == '+':\n return str(a + b)\n elif operator == '-':\n return str(a - b)\n elif operator == '*':\n return str(a * b)\n elif operator == '/':\n if b == 0:\n return 'Error: Division by zero'\n return str(a / b)\n else:\n return 'Error: Invalid operator. Please use +, -, *, or /'\n except Exception as e:\n return f'Error: {str(e)}'",
tool_type: "PythonFunction",
},
],
},
{
component_type: "agent",
description: "A user agent that is driven by a human user",
name: "user_agent",
agent_type: "UserProxyAgent",
tools: [],
},
],
team_type: "RoundRobinGroupChat",
termination_condition: {
description:
"Terminate the conversation when the user mentions 'TERMINATE' or after 10 messages",
component_type: "termination",
termination_type: "CombinationTermination",
operator: "or",
conditions: [
{
component_type: "termination",
description:
"Terminate the conversation when the user mentions 'TERMINATE'",
termination_type: "TextMentionTermination",
text: "TERMINATE",
},
{
component_type: "termination",
description: "Terminate the conversation after 10 messages",
termination_type: "MaxMessageTermination",
max_messages: 10,
},
],
},
} as RoundRobinGroupChatConfig,
],
components: {
agents: [
{
component_type: "agent",
description: "An assistant agent that can help users complete tasks",
name: "assistant_agent",
agent_type: "AssistantAgent",
system_message:
"You are a helpful assistant. Solve tasks carefully. You also have a calculator tool which you can use if needed. When the task is done respond with TERMINATE.",
model_client: {
component_type: "model",
description: "A GPT-4o mini model",
model: "gpt-4o-mini",
model_type: "OpenAIChatCompletionClient",
},
tools: [
{
component_type: "tool",
name: "calculator",
description:
"A simple calculator that performs basic arithmetic operations between two numbers",
content:
"def calculator(a: float, b: float, operator: str) -> str:\n try:\n if operator == '+':\n return str(a + b)\n elif operator == '-':\n return str(a - b)\n elif operator == '*':\n return str(a * b)\n elif operator == '/':\n if b == 0:\n return 'Error: Division by zero'\n return str(a / b)\n else:\n return 'Error: Invalid operator. Please use +, -, *, or /'\n except Exception as e:\n return f'Error: {str(e)}'",
tool_type: "PythonFunction",
},
],
} as AssistantAgentConfig,
{
component_type: "agent",
description: "A user agent that is driven by a human user",
name: "user_agent",
agent_type: "UserProxyAgent",
tools: [],
} as UserProxyAgentConfig,
],
models: [
{
component_type: "model",
description: "A GPT-4o mini model",
model: "gpt-4o-mini",
model_type: "OpenAIChatCompletionClient",
} as OpenAIModelConfig,
],
tools: [
{
component_type: "tool",
name: "calculator",
description:
"A simple calculator that performs basic arithmetic operations between two numbers",
content:
"def calculator(a: float, b: float, operator: str) -> str:\n try:\n if operator == '+':\n return str(a + b)\n elif operator == '-':\n return str(a - b)\n elif operator == '*':\n return str(a * b)\n elif operator == '/':\n if b == 0:\n return 'Error: Division by zero'\n return str(a / b)\n else:\n return 'Error: Invalid operator. Please use +, -, *, or /'\n except Exception as e:\n return f'Error: {str(e)}'",
tool_type: "PythonFunction",
} as PythonFunctionToolConfig,
{
component_type: "tool",
name: "fetch_website",
description: "Fetch and return the content of a website URL",
content:
"async def fetch_website(url: str) -> str:\n try:\n import requests\n from urllib.parse import urlparse\n \n # Validate URL format\n parsed = urlparse(url)\n if not parsed.scheme or not parsed.netloc:\n return \"Error: Invalid URL format. Please include http:// or https://\"\n \n # Add scheme if not present\n if not url.startswith(('http://', 'https://')): \n url = 'https://' + url\n \n # Set headers to mimic a browser request\n headers = {\n 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'\n }\n \n # Make the request with a timeout\n response = requests.get(url, headers=headers, timeout=10)\n response.raise_for_status()\n \n # Return the text content\n return response.text\n \n except requests.exceptions.Timeout:\n return \"Error: Request timed out\"\n except requests.exceptions.ConnectionError:\n return \"Error: Failed to connect to the website\"\n except requests.exceptions.HTTPError as e:\n return f\"Error: HTTP {e.response.status_code} - {e.response.reason}\"\n except Exception as e:\n return f\"Error: {str(e)}\"",
tool_type: "PythonFunction",
} as PythonFunctionToolConfig,
],
terminations: [
{
component_type: "termination",
description:
"Terminate the conversation when the user mentions 'TERMINATE'",
termination_type: "TextMentionTermination",
text: "TERMINATE",
} as TextMentionTerminationConfig,
{
component_type: "termination",
description: "Terminate the conversation after 10 messages",
termination_type: "MaxMessageTermination",
max_messages: 10,
} as MaxMessageTerminationConfig,
{
component_type: "termination",
description:
"Terminate the conversation when the user mentions 'TERMINATE' or after 10 messages",
termination_type: "CombinationTermination",
operator: "or",
conditions: [
{
component_type: "termination",
description:
"Terminate the conversation when the user mentions 'TERMINATE'",
termination_type: "TextMentionTermination",
text: "TERMINATE",
},
{
component_type: "termination",
description: "Terminate the conversation after 10 messages",
termination_type: "MaxMessageTermination",
max_messages: 10,
},
],
} as CombinationTerminationConfig,
],
},
},
};

View File

@@ -23,7 +23,7 @@ import AgentNode from "./agentnode";
import {
AgentMessageConfig,
AgentConfig,
TeamConfigTypes,
TeamConfig,
Run,
} from "../../../../types/datamodel";
import { CustomEdge, CustomEdgeData } from "./edge";
@@ -32,7 +32,7 @@ import { AgentFlowToolbar } from "./toolbar";
import { EdgeMessageModal } from "./edgemessagemodal";
interface AgentFlowProps {
teamConfig: TeamConfigTypes;
teamConfig: TeamConfig;
run: Run;
}

View File

@@ -6,7 +6,7 @@ import {
Run,
Message,
WebSocketMessage,
TeamConfigTypes,
TeamConfig,
AgentMessageConfig,
RunStatus,
TeamResult,
@@ -46,9 +46,7 @@ export default function ChatView({ session }: ChatViewProps) {
const [activeSocket, setActiveSocket] = React.useState<WebSocket | null>(
null
);
const [teamConfig, setTeamConfig] = React.useState<TeamConfigTypes | null>(
null
);
const [teamConfig, setTeamConfig] = React.useState<TeamConfig | null>(null);
const inputTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const activeSocketRef = React.useRef<WebSocket | null>(null);
@@ -83,6 +81,7 @@ export default function ChatView({ session }: ChatViewProps) {
React.useEffect(() => {
if (session?.id) {
loadSessionRuns();
setCurrentRun(null);
} else {
setExistingRuns([]);
setCurrentRun(null);
@@ -99,7 +98,7 @@ export default function ChatView({ session }: ChatViewProps) {
})
.catch((error) => {
console.error("Error loading team config:", error);
messageApi.error("Failed to load team config");
// messageApi.error("Failed to load team config");
setTeamConfig(null);
});
}

View File

@@ -123,7 +123,7 @@ const InputRequestView: React.FC<InputRequestProps> = ({
onChange={handleInputChange}
onKeyDown={handleKeyDown}
disabled={disabled || isSubmitting}
className="flex-1 px-3 py-2 rounded bg-background border border-secondary focus:border-accent focus:ring-1 focus:ring-accent outline-none disabled:opacity-50"
className="text-primary flex-1 px-3 py-2 rounded bg-tertiary border border-secondary focus:border-accent focus:ring-1 focus:ring-accent outline-none disabled:opacity-50"
placeholder={
disabled
? "Input timeout - please restart the conversation"

View File

@@ -45,7 +45,7 @@ const RenderMultiModal: React.FC<{ content: (string | ImageContent)[] }> = ({
const RenderToolCall: React.FC<{ content: FunctionCall[] }> = ({ content }) => (
<div className="space-y-2">
{content.map((call) => (
<div key={call.id} className="border rounded p-2">
<div key={call.id} className="border border-secondary rounded p-2">
<div className="font-medium">Function: {call.name}</div>
<TruncatableText
content={JSON.stringify(JSON.parse(call.arguments), null, 2)}
@@ -66,7 +66,7 @@ const RenderToolResult: React.FC<{ content: FunctionExecutionResult[] }> = ({
<div className="font-medium">Result ID: {result.call_id}</div>
<TruncatableText
content={result.content}
className="text-sm mt-1 bg-secondary p-2 border rounded scroll overflow-x-scroll"
className="text-sm mt-1 bg-secondary p-2 border border-secondary rounded scroll overflow-x-scroll"
/>
</div>
))}

View File

@@ -11,7 +11,7 @@ import {
ChevronUp,
Bot,
} from "lucide-react";
import { Run, Message, TeamConfigTypes } from "../../../types/datamodel";
import { Run, Message, TeamConfig } from "../../../types/datamodel";
import AgentFlow from "./agentflow/agentflow";
import { RenderMessage } from "./rendermessage";
import InputRequestView from "./inputrequest";
@@ -24,7 +24,7 @@ import {
interface RunViewProps {
run: Run;
teamConfig?: TeamConfigTypes;
teamConfig?: TeamConfig;
onInputResponse?: (response: string) => void;
onCancel?: () => void;
isFirstRun?: boolean;

View File

@@ -37,7 +37,11 @@ export const SessionManager: React.FC = () => {
setIsLoading(true);
const data = await sessionAPI.listSessions(user.email);
setSessions(data);
if (!session && data.length > 0) {
// Only set first session if there's no sessionId in URL
const params = new URLSearchParams(window.location.search);
const sessionId = params.get("sessionId");
if (!session && data.length > 0 && !sessionId) {
setSession(data[0]);
}
} catch (error) {
@@ -48,6 +52,31 @@ export const SessionManager: React.FC = () => {
}
}, [user?.email, setSessions, session, setSession]);
// Handle initial URL params
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const sessionId = params.get("sessionId");
if (sessionId && !session) {
handleSelectSession({ id: parseInt(sessionId) } as Session);
}
}, []);
// Handle browser back/forward
useEffect(() => {
const handleLocationChange = () => {
const params = new URLSearchParams(window.location.search);
const sessionId = params.get("sessionId");
if (!sessionId && session) {
setSession(null);
}
};
window.addEventListener("popstate", handleLocationChange);
return () => window.removeEventListener("popstate", handleLocationChange);
}, [session]);
const handleSaveSession = async (sessionData: Partial<Session>) => {
if (!user?.email) return;
@@ -80,10 +109,10 @@ export const SessionManager: React.FC = () => {
try {
const response = await sessionAPI.deleteSession(sessionId, user.email);
console.log("response", response);
setSessions(sessions.filter((s) => s.id !== sessionId));
if (session?.id === sessionId || sessions.length === 0) {
setSession(sessions[0] || null);
window.history.pushState({}, "", window.location.pathname); // Clear URL params
}
messageApi.success("Session deleted");
} catch (error) {
@@ -98,10 +127,28 @@ export const SessionManager: React.FC = () => {
try {
setIsLoading(true);
const data = await sessionAPI.getSession(selectedSession.id, user.email);
if (!data) {
// Session not found
messageApi.error("Session not found");
window.history.pushState({}, "", window.location.pathname); // Clear URL
if (sessions.length > 0) {
setSession(sessions[0]); // Fall back to first session
} else {
setSession(null);
}
return;
}
setSession(data);
window.history.pushState({}, "", `?sessionId=${selectedSession.id}`);
} catch (error) {
console.error("Error loading session:", error);
messageApi.error("Error loading session");
window.history.pushState({}, "", window.location.pathname); // Clear invalid URL
if (sessions.length > 0) {
setSession(sessions[0]); // Fall back to first session
} else {
setSession(null);
}
} finally {
setIsLoading(false);
}
@@ -130,11 +177,12 @@ export const SessionManager: React.FC = () => {
setIsEditorOpen(true);
}}
onDeleteSession={handleDeleteSession}
isLoading={isLoading}
/>
</div>
<div
className={`flex-1 transition-all duration-200 ${
className={`flex-1 transition-all -mr-4 duration-200 ${
isSidebarOpen ? "ml-64" : "ml-12"
}`}
>

View File

@@ -6,6 +6,8 @@ import {
Trash2,
PanelLeftClose,
PanelLeftOpen,
InfoIcon,
RefreshCcw,
} from "lucide-react";
import type { Session } from "../../types/datamodel";
import { getRelativeTimeString } from "../atoms";
@@ -18,6 +20,7 @@ interface SidebarProps {
onSelectSession: (session: Session) => void;
onEditSession: (session?: Session) => void;
onDeleteSession: (sessionId: number) => void;
isLoading?: boolean;
}
export const Sidebar: React.FC<SidebarProps> = ({
@@ -28,6 +31,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
onSelectSession,
onEditSession,
onDeleteSession,
isLoading = false,
}) => {
if (!isOpen) {
return (
@@ -95,55 +99,72 @@ export const Sidebar: React.FC<SidebarProps> = ({
</div>
</div>
<div className="py-2 flex text-sm text-secondary">Recents</div>
<div className="py-2 flex text-sm text-secondary">
Recents{" "}
{isLoading && (
<RefreshCcw className="w-4 h-4 inline-block ml-2 animate-spin" />
)}
</div>
{/* no sessions found */}
{sessions.length === 0 && (
<div className="mb-2 text-xs text-secondary">No sessions found</div>
{!isLoading && sessions.length === 0 && (
<div className="p-2 mr-2 text-center text-secondary text-sm border border-dashed rounded ">
<InfoIcon className="w-4 h-4 inline-block mr-1.5 -mt-0.5" />
No recent sessions found
</div>
)}
<div className="overflow-y-auto h-[calc(100%-150px)]">
{sessions.map((s) => (
<div
key={s.id}
className={`group flex items-center justify-between p-2 py-1 text-sm cursor-pointer hover:bg-tertiary ${
currentSession?.id === s.id
? "border-l-2 border-accent bg-tertiary"
: ""
}`}
onClick={() => onSelectSession(s)}
>
<span className="truncate text-sm flex-1">{s.name}</span>
<span className="ml-2 truncate text-xs text-secondary flex-1">
{getRelativeTimeString(s.updated_at || "")}
</span>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Tooltip title="Edit session">
<Button
type="text"
size="small"
className="p-0 min-w-[24px] h-6"
icon={<Edit className="w-4 h-4" />}
onClick={(e) => {
e.stopPropagation();
onEditSession(s);
}}
/>
</Tooltip>
<Tooltip title="Delete session">
<Button
type="text"
size="small"
className="p-0 min-w-[24px] h-6"
danger
icon={<Trash2 className="w-4 h-4 text-red-500" />}
onClick={(e) => {
e.stopPropagation();
if (s.id) onDeleteSession(s.id);
}}
/>
</Tooltip>
<div key={s.id} className="relative">
<div
className={`bg-accent absolute top-1 left-0.5 z-50 h-[calc(100%-8px)]
w-1 bg-opacity-80 rounded ${
currentSession?.id === s.id ? "bg-accent" : "bg-tertiary"
}`}
>
{" "}
</div>
<div
className={`group ml-1 flex items-center justify-between rounded-l p-2 py-1 text-sm cursor-pointer hover:bg-tertiary ${
currentSession?.id === s.id
? " border-accent bg-secondary"
: ""
}`}
onClick={() => onSelectSession(s)}
>
<span className="truncate text-sm flex-1">{s.name}</span>
<span className="ml-2 truncate text-xs text-secondary flex-1">
{getRelativeTimeString(s.updated_at || "")}
</span>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Tooltip title="Edit session">
<Button
type="text"
size="small"
className="p-0 min-w-[24px] h-6"
icon={<Edit className="w-4 h-4" />}
onClick={(e) => {
e.stopPropagation();
onEditSession(s);
}}
/>
</Tooltip>
<Tooltip title="Delete session">
<Button
type="text"
size="small"
className="p-0 min-w-[24px] h-6"
danger
icon={<Trash2 className="w-4 h-4 text-red-500" />}
onClick={(e) => {
e.stopPropagation();
if (s.id) onDeleteSession(s.id);
}}
/>
</Tooltip>
</div>
</div>
</div>
))}

View File

@@ -20,16 +20,16 @@ import "@xyflow/react/dist/style.css";
import { Button, Layout, message, Modal, Switch, Tooltip } from "antd";
import { Cable, Code2, Save } from "lucide-react";
import { useTeamBuilderStore } from "./store";
import { ComponentLibrary } from "./components/library";
import { ComponentLibrary } from "./library";
import { ComponentTypes, Team } from "../../../types/datamodel";
import { CustomNode, CustomEdge, DragItem } from "./types";
import { edgeTypes, nodeTypes } from "./components/nodes";
import { edgeTypes, nodeTypes } from "./nodes";
// import builder css
import "./builder.css";
import TeamBuilderToolbar from "./components/toolbar";
import TeamBuilderToolbar from "./toolbar";
import { MonacoEditor } from "../../monaco";
import { NodeEditor } from "./components/node-editor";
import { NodeEditor } from "./node-editor";
const { Sider, Content } = Layout;

View File

@@ -1,316 +0,0 @@
import React from "react";
import { Input, Collapse, type CollapseProps } from "antd";
import { useDraggable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import {
Brain,
Settings,
FoldVertical,
ChevronDown,
Bot,
Wrench,
Timer,
Maximize2,
MinimizeIcon,
Minimize2,
} from "lucide-react";
import {
AgentConfig,
ModelConfig,
TerminationConfig,
ToolConfig,
} from "../../../../types/datamodel";
import Sider from "antd/es/layout/Sider";
// Types
interface ComponentConfigTypes {
[key: string]: any;
}
type ComponentTypes = "agent" | "model" | "tool" | "termination";
interface LibraryProps {}
interface PresetItemProps {
id: string;
type: ComponentTypes;
config: ComponentConfigTypes;
label: string;
icon: React.ReactNode;
}
interface SectionItem {
label: string;
config: ComponentConfigTypes;
}
const PresetItem: React.FC<PresetItemProps> = ({
id,
type,
config,
label,
icon,
}) => {
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
id,
data: {
current: {
type,
config,
label,
},
},
});
const style = {
transform: CSS.Transform.toString(transform),
opacity: isDragging ? 0.5 : undefined,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="p-2 text-primary mb-2 border border-secondary rounded cursor-move hover:bg-secondary transition-colors"
>
<div className="flex items-center gap-2">
{icon}
<span>{label}</span>
</div>
</div>
);
};
export const ComponentLibrary: React.FC<LibraryProps> = () => {
const [searchTerm, setSearchTerm] = React.useState("");
const [isMinimized, setIsMinimized] = React.useState(false);
const sections: Array<{
title: string;
items: SectionItem[];
icon: React.ReactNode;
type: ComponentTypes;
}> = [
{
title: "Agents",
type: "agent",
items: [
{
label: "Assistant Agent",
config: {
name: "assistant_agent",
agent_type: "AssistantAgent",
system_message:
"You are a helpful assistant. Solve tasks carefully.",
model_client: {
component_type: "model",
model: "gpt-4o-mini",
model_type: "OpenAIChatCompletionClient",
},
} as AgentConfig,
},
{
label: "User Proxy Agent",
config: {
name: "user_agent",
agent_type: "UserProxyAgent",
} as AgentConfig,
},
],
icon: <Bot className="w-4 h-4" />,
},
{
title: "Models",
type: "model",
items: [
{
label: "OpenAI GPT4o-mini",
config: {
model: "gpt-4o-mini",
model_type: "OpenAIChatCompletionClient",
} as ModelConfig,
},
{
label: "OpenAI GPT4o",
config: {
model: "gpt-4o",
model_type: "OpenAIChatCompletionClient",
} as ModelConfig,
},
],
icon: <Brain className="w-4 h-4" />,
},
{
title: "Tools",
type: "tool",
items: [
{
label: "Calculator Tool",
config: {
component_type: "tool",
name: "calculator",
description:
"Perform basic arithmetic operations (+, -, *, /) between two numbers",
content:
"async def calculator(num1: float, num2: float, operation: str) -> str:\n operations = {\n '+': lambda x, y: x + y,\n '-': lambda x, y: x - y,\n '*': lambda x, y: x * y,\n '/': lambda x, y: x / y if y != 0 else 'Error: Division by zero'\n }\n \n if operation not in operations:\n return f\"Error: Invalid operation. Please use one of: {', '.join(operations.keys())}\"\n \n try:\n result = operations[operation](num1, num2)\n return f\"{num1} {operation} {num2} = {result}\"\n except Exception as e:\n return f\"Error: {str(e)}\"",
tool_type: "PythonFunction",
} as ToolConfig,
},
{
label: "Fetch Website",
config: {
component_type: "tool",
name: "fetch_website",
description: "Fetch and return the content of a website URL",
content:
"async def fetch_website(url: str) -> str:\n try:\n import requests\n from urllib.parse import urlparse\n \n # Validate URL format\n parsed = urlparse(url)\n if not parsed.scheme or not parsed.netloc:\n return \"Error: Invalid URL format. Please include http:// or https://\"\n \n # Add scheme if not present\n if not url.startswith(('http://', 'https://')): \n url = 'https://' + url\n \n # Set headers to mimic a browser request\n headers = {\n 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'\n }\n \n # Make the request with a timeout\n response = requests.get(url, headers=headers, timeout=10)\n response.raise_for_status()\n \n # Return the text content\n return response.text\n \n except requests.exceptions.Timeout:\n return \"Error: Request timed out\"\n except requests.exceptions.ConnectionError:\n return \"Error: Failed to connect to the website\"\n except requests.exceptions.HTTPError as e:\n return f\"Error: HTTP {e.response.status_code} - {e.response.reason}\"\n except Exception as e:\n return f\"Error: {str(e)}\"",
tool_type: "PythonFunction",
} as ToolConfig,
},
],
icon: <Wrench className="w-4 h-4" />,
},
{
title: "Terminations",
type: "termination",
items: [
{
label: "10 Max Message Termination",
config: {
termination_type: "MaxMessageTermination",
max_messages: 10,
} as TerminationConfig,
},
{
label: "Text Message Termination",
config: {
termination_type: "TextMentionTermination",
text: "TERMINATE",
} as TerminationConfig,
},
{
label: "Max or Text Termination",
config: {
component_type: "termination",
termination_type: "CombinationTermination",
operator: "or",
conditions: [
{
component_type: "termination",
termination_type: "TextMentionTermination",
text: "TERMINATE",
},
{
component_type: "termination",
termination_type: "MaxMessageTermination",
max_messages: 10,
},
],
} as TerminationConfig,
},
],
icon: <Timer className="w-4 h-4" />,
},
];
const items: CollapseProps["items"] = sections.map((section, index) => {
const filteredItems = section.items.filter((item) =>
item.label.toLowerCase().includes(searchTerm.toLowerCase())
);
return {
key: section.title,
label: (
<div className="flex items-center gap-2 font-medium">
{section.icon}
<span>{section.title}</span>
<span className="text-xs text-gray-500">
({filteredItems.length})
</span>
</div>
),
children: (
<div className="space-y-2">
{filteredItems.map((item, itemIndex) => (
<PresetItem
key={itemIndex}
id={`${section.title.toLowerCase()}-${itemIndex}`}
type={section.type}
config={item.config}
label={item.label}
icon={section.icon}
/>
))}
</div>
),
};
});
if (isMinimized) {
return (
<div
onClick={() => setIsMinimized(false)}
className="absolute group top-4 left-4 bg-primary shadow-md rounded px-4 pr-2 py-2 cursor-pointer transition-all duration-300 z-50 flex items-center gap-2"
>
<span>Show Component Library</span>
<button
onClick={() => setIsMinimized(false)}
className="p-1 group-hover:bg-tertiary rounded transition-colors "
title="Maximize Library"
>
<Maximize2 className="w-4 h-4" />
</button>
</div>
);
}
return (
<Sider
width={300}
className="bg-primary z-10 mr-2 border-r border-secondary"
>
<div className="rounded p-2 pt-2">
<div className="flex justify-between items-center mb-2">
<div className="text-normal ">Component Library</div>
<button
onClick={() => setIsMinimized(true)}
className="p-1 hover:bg-tertiary rounded transition-colors"
title="Minimize Library"
>
<Minimize2 className="w-4 h-4" />
</button>
</div>
<div className="mb-4 text-secondary">
Drag a component to add it to the team
</div>
<div className="flex items-center gap-2 mb-4">
<Input
placeholder="Search components..."
onChange={(e) => setSearchTerm(e.target.value)}
className="flex-1 p-2"
/>
</div>
<Collapse
items={items}
defaultActiveKey={["Agents"]}
bordered={false}
expandIcon={({ isActive }) => (
<ChevronDown
strokeWidth={1}
className={(isActive ? "transform rotate-180" : "") + " w-4 h-4"}
/>
)}
/>
</div>
</Sider>
);
};
export default ComponentLibrary;

View File

@@ -0,0 +1,228 @@
import React from "react";
import { Input, Collapse, type CollapseProps } from "antd";
import { useDraggable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import {
Brain,
ChevronDown,
Bot,
Wrench,
Timer,
Maximize2,
Minimize2,
} from "lucide-react";
import type {
AgentConfig,
ModelConfig,
TerminationConfig,
ToolConfig,
} from "../../../types/datamodel";
import Sider from "antd/es/layout/Sider";
import { useGalleryStore } from "../../gallery/store";
interface ComponentConfigTypes {
[key: string]: any;
}
type ComponentTypes = "agent" | "model" | "tool" | "termination";
interface LibraryProps {}
interface PresetItemProps {
id: string;
type: ComponentTypes;
config: ComponentConfigTypes;
label: string;
icon: React.ReactNode;
}
const PresetItem: React.FC<PresetItemProps> = ({
id,
type,
config,
label,
icon,
}) => {
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
id,
data: {
current: {
type,
config,
label,
},
},
});
const style = {
transform: CSS.Transform.toString(transform),
opacity: isDragging ? 0.5 : undefined,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="p-2 text-primary mb-2 border border-secondary rounded cursor-move hover:bg-secondary transition-colors"
>
<div className="flex items-center gap-2">
{icon}
<span>{label}</span>
</div>
</div>
);
};
export const ComponentLibrary: React.FC<LibraryProps> = () => {
const [searchTerm, setSearchTerm] = React.useState("");
const [isMinimized, setIsMinimized] = React.useState(false);
const defaultGallery = useGalleryStore((state) => state.getDefaultGallery());
if (!defaultGallery) {
return null;
}
// Map gallery components to sections format
const sections = React.useMemo(
() => [
{
title: "Agents",
type: "agent" as ComponentTypes,
items: defaultGallery.items.components.agents.map((agent) => ({
label: agent.name,
config: agent,
})),
icon: <Bot className="w-4 h-4" />,
},
{
title: "Models",
type: "model" as ComponentTypes,
items: defaultGallery.items.components.models.map((model) => ({
label: `${model.model_type} - ${model.model}`,
config: model,
})),
icon: <Brain className="w-4 h-4" />,
},
{
title: "Tools",
type: "tool" as ComponentTypes,
items: defaultGallery.items.components.tools.map((tool) => ({
label: tool.name,
config: tool,
})),
icon: <Wrench className="w-4 h-4" />,
},
{
title: "Terminations",
type: "termination" as ComponentTypes,
items: defaultGallery.items.components.terminations.map(
(termination) => ({
label: `${termination.termination_type}`,
config: termination,
})
),
icon: <Timer className="w-4 h-4" />,
},
],
[defaultGallery]
);
const items: CollapseProps["items"] = sections.map((section) => {
const filteredItems = section.items.filter((item) =>
item.label.toLowerCase().includes(searchTerm.toLowerCase())
);
return {
key: section.title,
label: (
<div className="flex items-center gap-2 font-medium">
{section.icon}
<span>{section.title}</span>
<span className="text-xs text-gray-500">
({filteredItems.length})
</span>
</div>
),
children: (
<div className="space-y-2">
{filteredItems.map((item, itemIndex) => (
<PresetItem
key={itemIndex}
id={`${section.title.toLowerCase()}-${itemIndex}`}
type={section.type}
config={item.config}
label={item.label}
icon={section.icon}
/>
))}
</div>
),
};
});
if (isMinimized) {
return (
<div
onClick={() => setIsMinimized(false)}
className="absolute group top-4 left-4 bg-primary shadow-md rounded px-4 pr-2 py-2 cursor-pointer transition-all duration-300 z-50 flex items-center gap-2"
>
<span>Show Component Library</span>
<button
onClick={() => setIsMinimized(false)}
className="p-1 group-hover:bg-tertiary rounded transition-colors"
title="Maximize Library"
>
<Maximize2 className="w-4 h-4" />
</button>
</div>
);
}
return (
<Sider
width={300}
className="bg-primary z-10 mr-2 border-r border-secondary"
>
<div className="rounded p-2 pt-2">
<div className="flex justify-between items-center mb-2">
<div className="text-normal">Component Library</div>
<button
onClick={() => setIsMinimized(true)}
className="p-1 hover:bg-tertiary rounded transition-colors"
title="Minimize Library"
>
<Minimize2 className="w-4 h-4" />
</button>
</div>
<div className="mb-4 text-secondary">
Drag a component to add it to the team
</div>
<div className="flex items-center gap-2 mb-4">
<Input
placeholder="Search components..."
onChange={(e) => setSearchTerm(e.target.value)}
className="flex-1 p-2"
/>
</div>
<Collapse
items={items}
defaultActiveKey={["Agents"]}
bordered={false}
expandIcon={({ isActive }) => (
<ChevronDown
strokeWidth={1}
className={(isActive ? "transform rotate-180" : "") + " w-4 h-4"}
/>
)}
/>
</div>
</Sider>
);
};
export default ComponentLibrary;

View File

@@ -1,15 +1,15 @@
import React, { useEffect, useState } from "react";
import { Drawer, Button, Space, message, Select, Input } from "antd";
import { NodeEditorProps } from "../types";
import { useTeamBuilderStore } from "../store";
import { NodeEditorProps } from "./types";
import { useTeamBuilderStore } from "./store";
import {
TeamConfigTypes,
TeamConfig,
ComponentTypes,
TeamTypes,
ModelTypes,
SelectorGroupChatConfig,
RoundRobinGroupChatConfig,
ModelConfigTypes,
ModelConfig,
AzureOpenAIModelConfig,
OpenAIModelConfig,
ComponentConfigTypes,
@@ -17,12 +17,12 @@ import {
ToolConfig,
AgentTypes,
ToolTypes,
TerminationConfigTypes,
TerminationConfig,
TerminationTypes,
MaxMessageTerminationConfig,
TextMentionTerminationConfig,
CombinationTerminationConfig,
} from "../../../../types/datamodel";
} from "../../../types/datamodel";
const { TextArea } = Input;
@@ -32,7 +32,7 @@ interface EditorProps<T> {
disabled?: boolean;
}
const TeamEditor: React.FC<EditorProps<TeamConfigTypes>> = ({
const TeamEditor: React.FC<EditorProps<TeamConfig>> = ({
value,
onChange,
disabled,
@@ -119,7 +119,7 @@ const TeamEditor: React.FC<EditorProps<TeamConfigTypes>> = ({
);
};
const ModelEditor: React.FC<EditorProps<ModelConfigTypes>> = ({
const ModelEditor: React.FC<EditorProps<ModelConfig>> = ({
value,
onChange,
disabled,
@@ -358,7 +358,7 @@ const ToolEditor: React.FC<EditorProps<ToolConfig>> = ({
);
};
const TerminationEditor: React.FC<EditorProps<TerminationConfigTypes>> = ({
const TerminationEditor: React.FC<EditorProps<TerminationConfig>> = ({
value,
onChange,
disabled,
@@ -413,7 +413,7 @@ const TerminationEditor: React.FC<EditorProps<TerminationConfigTypes>> = ({
const handleUpdateCondition = (
index: number,
newCondition: TerminationConfigTypes
newCondition: TerminationConfig
) => {
if (value.termination_type === "CombinationTermination") {
const newConditions = [...value.conditions];
@@ -632,7 +632,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({ node, onUpdate }) => {
function validateConfig(config: ComponentConfigTypes): void {
switch (config.component_type) {
case "team": {
const teamConfig = config as TeamConfigTypes;
const teamConfig = config as TeamConfig;
if ("selector_prompt" in teamConfig) {
// Type guard for SelectorGroupChatConfig
if (!teamConfig.selector_prompt) {
@@ -646,7 +646,7 @@ function validateConfig(config: ComponentConfigTypes): void {
}
case "model":
const modelConfig = config as ModelConfigTypes;
const modelConfig = config as ModelConfig;
if ("AzureOpenAIChatCompletionClient" in modelConfig) {
const azureConfig = config as AzureOpenAIModelConfig;
if (

View File

@@ -16,26 +16,27 @@ import {
Timer,
Trash2Icon,
Edit,
Bot,
} from "lucide-react";
import { NodeData, CustomNode } from "../types";
import { NodeData, CustomNode } from "./types";
import {
AgentConfig,
TeamConfigTypes,
ModelConfigTypes,
TeamConfig,
ModelConfig,
ToolConfig,
TerminationConfigTypes,
TerminationConfig,
ComponentTypes,
} from "../../../../types/datamodel";
} from "../../../types/datamodel";
import { useDroppable } from "@dnd-kit/core";
import { TruncatableText } from "../../../atoms";
import { useTeamBuilderStore } from "../store";
import { TruncatableText } from "../../atoms";
import { useTeamBuilderStore } from "./store";
// Icon mapping for different node types
const iconMap: Record<NodeData["type"], LucideIcon> = {
team: Users,
agent: Brain,
agent: Bot,
tool: Wrench,
model: Settings,
model: Brain,
termination: Timer,
};
@@ -152,9 +153,12 @@ const BaseNode: React.FC<BaseNodeProps> = ({
{headerContent}
</div>
{descriptionContent && (
{data.config.description && (
<div className="px-3 py-2 border-b text-sm text-gray-600">
{descriptionContent}
<TruncatableText
content={data.config.description}
textThreshold={150}
/>
</div>
)}
@@ -190,7 +194,7 @@ const ConnectionBadge: React.FC<{
// Team Node
export const TeamNode: React.FC<NodeProps<CustomNode>> = (props) => {
const config = props.data.config as TeamConfigTypes;
const config = props.data.config as TeamConfig;
const hasModel =
config.team_type === "SelectorGroupChat" && !!config.model_client;
const participantCount = config.participants?.length || 0;
@@ -410,7 +414,7 @@ export const AgentNode: React.FC<NodeProps<CustomNode>> = (props) => {
// Model Node
export const ModelNode: React.FC<NodeProps<CustomNode>> = (props) => {
const config = props.data.config as ModelConfigTypes;
const config = props.data.config as ModelConfig;
return (
<BaseNode
@@ -471,7 +475,7 @@ export const ToolNode: React.FC<NodeProps<CustomNode>> = (props) => {
// First, let's add the Termination Node component
export const TerminationNode: React.FC<NodeProps<CustomNode>> = (props) => {
const config = props.data.config as TerminationConfigTypes;
const config = props.data.config as TerminationConfig;
return (
<BaseNode

View File

@@ -8,22 +8,19 @@ import {
} from "./types";
import { nanoid } from "nanoid";
import {
TeamConfigTypes,
TeamConfig,
AgentConfig,
ModelConfigTypes,
ModelConfig,
ToolConfig,
ComponentTypes,
ComponentConfigTypes,
TerminationConfigTypes,
TerminationConfig,
} from "../../../types/datamodel";
import {
convertTeamConfigToGraph,
getLayoutedElements,
} from "./utils/converter";
import { convertTeamConfigToGraph, getLayoutedElements } from "./utils";
const MAX_HISTORY = 50;
const isTeamConfig = (config: any): config is TeamConfigTypes => {
const isTeamConfig = (config: any): config is TeamConfig => {
return "team_type" in config;
};
@@ -31,7 +28,7 @@ const isAgentConfig = (config: any): config is AgentConfig => {
return "agent_type" in config;
};
const isModelConfig = (config: any): config is ModelConfigTypes => {
const isModelConfig = (config: any): config is ModelConfig => {
return "model_type" in config;
};
@@ -39,7 +36,7 @@ const isToolConfig = (config: any): config is ToolConfig => {
return "tool_type" in config;
};
const isTerminationConfig = (config: any): config is TerminationConfigTypes => {
const isTerminationConfig = (config: any): config is TerminationConfig => {
return "termination_type" in config;
};
@@ -49,7 +46,7 @@ export interface TeamBuilderState {
selectedNodeId: string | null;
history: Array<{ nodes: CustomNode[]; edges: CustomEdge[] }>;
currentHistoryIndex: number;
originalConfig: TeamConfigTypes | null;
originalConfig: TeamConfig | null;
addNode: (
type: ComponentTypes,
position: Position,
@@ -69,8 +66,8 @@ export interface TeamBuilderState {
redo: () => void;
// Sync with JSON
syncToJson: () => TeamConfigTypes | null;
loadFromJson: (config: TeamConfigTypes) => GraphState;
syncToJson: () => TeamConfig | null;
loadFromJson: (config: TeamConfig) => GraphState;
layoutNodes: () => void;
resetHistory: () => void;
}
@@ -79,7 +76,7 @@ const buildTeamConfig = (
teamNode: CustomNode,
nodes: CustomNode[],
edges: CustomEdge[]
): TeamConfigTypes | null => {
): TeamConfig | null => {
if (!isTeamConfig(teamNode.data.config)) return null;
const config = { ...teamNode.data.config };
@@ -343,12 +340,15 @@ export const useTeamBuilderStore = create<TeamBuilderState>((set, get) => ({
newNodes.push(newNode);
}
const { nodes: layoutedNodes, edges: layoutedEdges } =
getLayoutedElements(newNodes, newEdges);
return {
nodes: newNodes,
edges: newEdges,
nodes: layoutedNodes,
edges: layoutedEdges,
history: [
...state.history.slice(0, state.currentHistoryIndex + 1),
{ nodes: newNodes, edges: newEdges },
{ nodes: layoutedNodes, edges: layoutedEdges },
].slice(-MAX_HISTORY),
currentHistoryIndex: state.currentHistoryIndex + 1,
};
@@ -644,7 +644,7 @@ export const useTeamBuilderStore = create<TeamBuilderState>((set, get) => ({
});
},
loadFromJson: (config: TeamConfigTypes) => {
loadFromJson: (config: TeamConfig) => {
const { nodes, edges } = convertTeamConfigToGraph(config);
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(
nodes,

View File

@@ -1,13 +1,6 @@
import { Node, Edge } from "@xyflow/react";
import { ComponentConfigTypes, ComponentTypes } from "../../../types/datamodel";
interface NodeConnections {
modelClient: string | null;
tools: string[];
participants: string[];
termination: string | null;
}
export interface NodeData extends Record<string, unknown> {
label: string;
type: ComponentTypes;

View File

@@ -1,5 +1,5 @@
import dagre from "@dagrejs/dagre";
import { CustomNode, CustomEdge } from "../types";
import { CustomNode, CustomEdge } from "./types";
import { nanoid } from "nanoid";
import {
TeamConfig,
@@ -8,7 +8,7 @@ import {
ToolConfig,
ComponentTypes,
TerminationConfig,
} from "../../../../types/datamodel";
} from "../../../types/datamodel";
interface ConversionResult {
nodes: CustomNode[];
@@ -100,7 +100,7 @@ export const convertTeamConfigToGraph = (
nodes.push(teamNode);
// Add model client if present
if (config.model_client) {
if (config.team_type === "SelectorGroupChat" && config.model_client) {
const modelNode = createNode(
"model",
{ x: 200, y: 50 },

View File

@@ -99,7 +99,7 @@ export const TeamManager: React.FC = () => {
if (!teamId || !user?.email) return;
setIsLoading(true);
try {
const data = await teamAPI.getTeam(teamId, user.email!); // We can assert user.email exists since we checked above
const data = await teamAPI.getTeam(teamId, user.email!);
setCurrentTeam(data);
window.history.pushState({}, "", `?teamId=${teamId}`);
} catch (error) {
@@ -126,9 +126,7 @@ export const TeamManager: React.FC = () => {
}
};
const handleCreateTeam = () => {
const newTeam = Object.assign({}, defaultTeam);
newTeam.config.name = "new_team_" + new Date().getTime();
const handleCreateTeam = (newTeam: Team) => {
setCurrentTeam(newTeam);
// also save it to db
@@ -191,7 +189,7 @@ export const TeamManager: React.FC = () => {
{/* Main Content */}
<div
className={`flex-1 transition-all duration-200 ${
className={`flex-1 transition-all -mr-6 duration-200 ${
isSidebarOpen ? "ml-64" : "ml-12"
}`}
>
@@ -222,7 +220,7 @@ export const TeamManager: React.FC = () => {
onDirtyStateChange={setHasUnsavedChanges}
/>
) : (
<div className="flex items-center justify-center h-[calc(100vh-120px)] text-secondary">
<div className="flex items-center justify-center h-[calc(100vh-190px)] text-secondary">
Select a team from the sidebar or create a new one
</div>
)}

View File

@@ -8,9 +8,15 @@ import {
PanelLeftClose,
PanelLeftOpen,
Calendar,
Copy,
GalleryHorizontalEnd,
InfoIcon,
RefreshCcw,
} from "lucide-react";
import type { Team } from "../../types/datamodel";
import { getRelativeTimeString } from "../atoms";
import { defaultTeam } from "./types";
import { useGalleryStore } from "../gallery/store";
interface TeamSidebarProps {
isOpen: boolean;
@@ -18,7 +24,7 @@ interface TeamSidebarProps {
currentTeam: Team | null;
onToggle: () => void;
onSelectTeam: (team: Team) => void;
onCreateTeam: () => void;
onCreateTeam: (team: Team) => void;
onEditTeam: (team: Team) => void;
onDeleteTeam: (teamId: number) => void;
isLoading?: boolean;
@@ -35,6 +41,13 @@ export const TeamSidebar: React.FC<TeamSidebarProps> = ({
onDeleteTeam,
isLoading = false,
}) => {
const defaultGallery = useGalleryStore((state) => state.getDefaultGallery());
const createTeam = () => {
const newTeam = Object.assign({}, defaultTeam);
newTeam.config.name = "new_team_" + new Date().getTime();
onCreateTeam(newTeam);
};
// Render collapsed state
if (!isOpen) {
return (
@@ -55,7 +68,7 @@ export const TeamSidebar: React.FC<TeamSidebarProps> = ({
<Button
type="text"
className="w-full p-2 flex justify-center"
onClick={onCreateTeam}
onClick={createTeam}
icon={<Plus className="w-4 h-4" />}
/>
</Tooltip>
@@ -94,7 +107,7 @@ export const TeamSidebar: React.FC<TeamSidebarProps> = ({
type="primary"
className="w-full"
icon={<Plus className="w-4 h-4" />}
onClick={onCreateTeam}
onClick={createTeam}
>
New Team
</Button>
@@ -103,32 +116,59 @@ export const TeamSidebar: React.FC<TeamSidebarProps> = ({
</div>
{/* Section Label */}
<div className="py-2 text-sm text-secondary">Recents</div>
<div className="py-2 text-sm text-secondary">
Recents
{isLoading && (
<RefreshCcw className="w-4 h-4 inline-block ml-2 animate-spin" />
)}
</div>
{/* Teams List */}
{isLoading ? (
<div className="p-4 text-center text-secondary text-sm">Loading...</div>
) : teams.length === 0 ? (
<div className="p-4 text-center text-secondary text-sm">
No teams found
{!isLoading && teams.length === 0 && (
<div className="p-2 mr-2 text-center text-secondary text-sm border border-dashed rounded ">
<InfoIcon className="w-4 h-4 inline-block mr-1.5 -mt-0.5" />
No recent teams found
</div>
) : (
<div className="scroll overflow-y-auto h-[calc(100%-170px)]">
{teams.map((team) => (
)}
<div className="scroll overflow-y-auto h-[calc(100%-170px)]">
<>
{teams.length > 0 && (
<div
key={team.id}
className={`group flex flex-col p-3 cursor-pointer hover:bg-tertiary border-l-2 ${
currentTeam?.id === team.id
? "border-accent bg-tertiary"
: "border-transparent"
key={"teams_title"}
className={` ${
isLoading ? "opacity-50 pointer-events-none" : ""
}`}
onClick={() => onSelectTeam(team)}
>
{/* Team Name and Actions Row */}
<div className="flex items-center justify-between">
<span className="font-medium truncate">{team.config.name}</span>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{/* <Tooltip title="Edit team">
{" "}
{teams.map((team) => (
<div key={team.id} className="relative border-secondary">
{
<div
className={` absolute top-1 left-0.5 z-50 h-[calc(100%-8px)]
w-1 bg-opacity-80 rounded ${
currentTeam?.id === team.id ? "bg-accent" : "bg-tertiary"
}`}
>
{" "}
</div>
}
<div
className={`group ml-1 flex flex-col p-3 rounded-l cursor-pointer hover:bg-secondary ${
currentTeam?.id === team.id
? "border-accent bg-secondary"
: "border-transparent"
}`}
onClick={() => onSelectTeam(team)}
>
{/* Team Name and Actions Row */}
<div className="flex items-center justify-between">
<span className="font-medium truncate">
{team.config.name}
</span>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{/* <Tooltip title="Edit team">
<Button
type="text"
size="small"
@@ -140,47 +180,116 @@ export const TeamSidebar: React.FC<TeamSidebarProps> = ({
}}
/>
</Tooltip> */}
<Tooltip title="Delete team">
<Button
type="text"
size="small"
className="p-0 min-w-[24px] h-6"
danger
icon={<Trash2 className="w-4 h-4 text-red-500" />}
onClick={(e) => {
e.stopPropagation();
if (team.id) onDeleteTeam(team.id);
}}
/>
</Tooltip>
</div>
</div>
<Tooltip title="Delete team">
<Button
type="text"
size="small"
className="p-0 min-w-[24px] h-6"
danger
icon={<Trash2 className="w-4 h-4 text-red-500" />}
onClick={(e) => {
e.stopPropagation();
if (team.id) onDeleteTeam(team.id);
}}
/>
</Tooltip>
</div>
</div>
{/* Team Metadata Row */}
<div className="mt-1 flex items-center gap-2 text-xs text-secondary">
<span className="bg-secondary/20 truncate rounded">
{team.config.team_type}
</span>
<div className="flex items-center gap-1">
<Bot className="w-3 h-3" />
<span>
{team.config.participants.length}{" "}
{team.config.participants.length === 1 ? "agent" : "agents"}
</span>
</div>
</div>
{/* Team Metadata Row */}
<div className="mt-1 flex items-center gap-2 text-xs text-secondary">
<span className="bg-secondary/20 truncate rounded">
{team.config.team_type}
</span>
<div className="flex items-center gap-1">
<Bot className="w-3 h-3" />
<span>
{team.config.participants.length}{" "}
{team.config.participants.length === 1
? "agent"
: "agents"}
</span>
</div>
</div>
{/* Updated Timestamp */}
{team.updated_at && (
<div className="mt-1 flex items-center gap-1 text-xs text-secondary">
{/* <Calendar className="w-3 h-3" /> */}
<span>{getRelativeTimeString(team.updated_at)}</span>
{/* Updated Timestamp */}
{team.updated_at && (
<div className="mt-1 flex items-center gap-1 text-xs text-secondary">
{/* <Calendar className="w-3 h-3" /> */}
<span>{getRelativeTimeString(team.updated_at)}</span>
</div>
)}
</div>
</div>
)}
))}
</div>
))}
</div>
)}
)}
{/* Gallery Teams Section */}
<div
key={"gallery_title"}
className="py-2 text-sm text-secondary mt-4"
>
<GalleryHorizontalEnd className="w-4 h-4 inline-block mr-1.5" />
From Gallery
</div>
<div key={"gallery_content"} className="scroll overflow-y-auto">
{defaultGallery?.items.teams.map((galleryTeam) => (
<div
key={galleryTeam.name + galleryTeam.team_type}
className="relative border-secondary"
>
<div
className={`absolute top-1 left-0.5 z-50 h-[calc(100%-8px)]
w-1 bg-opacity-80 rounded bg-tertiary`}
/>
<div className="group ml-1 flex flex-col p-3 rounded-l cursor-pointer hover:bg-secondary">
{/* Team Name and Use Template Action */}
<div className="flex items-center justify-between">
<span className="font-medium truncate">
{galleryTeam.name}
</span>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Tooltip title="Use as template">
<Button
type="text"
size="small"
className="p-0 min-w-[24px] h-6"
icon={<Copy className="w-4 h-4" />}
onClick={(e) => {
e.stopPropagation();
galleryTeam.name =
galleryTeam.name + "_" + new Date().getTime();
onCreateTeam({
config: galleryTeam,
});
}}
/>
</Tooltip>
</div>
</div>
{/* Team Metadata Row */}
<div className="mt-1 flex items-center gap-2 text-xs text-secondary">
<span className="bg-secondary/20 truncate rounded">
{galleryTeam.team_type}
</span>
<div className="flex items-center gap-1">
<Bot className="w-3 h-3" />
<span>
{galleryTeam.participants.length}{" "}
{galleryTeam.participants.length === 1
? "agent"
: "agents"}
</span>
</div>
</div>
</div>
</div>
))}
</div>
</>
</div>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import type { Team, TeamConfigTypes } from "../../types/datamodel";
import type { Team, TeamConfig } from "../../types/datamodel";
export interface TeamEditorProps {
team?: Team;
@@ -16,7 +16,7 @@ export interface TeamListProps {
isLoading?: boolean;
}
export const defaultTeamConfig: TeamConfigTypes = {
export const defaultTeamConfig: TeamConfig = {
version: "1.0.0",
component_type: "team",
name: "default_team",

View File

@@ -0,0 +1,28 @@
import * as React from "react";
import Layout from "../components/layout";
import { graphql } from "gatsby";
import GalleryManager from "../components/views/gallery/manager";
// markup
const GalleryPage = ({ data }: any) => {
return (
<Layout meta={data.site.siteMetadata} title="Home" link={"/gallery"}>
<main style={{ height: "100%" }} className=" h-full ">
<GalleryManager />
</main>
</Layout>
);
};
export const query = graphql`
query HomePageQuery {
site {
siteMetadata {
description
title
}
}
}
`;
export default GalleryPage;

View File

@@ -105,7 +105,7 @@ body {
border: grey;
}
.ant-modal-content {
/* .ant-modal-content {
@apply dark:bg-primary dark:text-primary;
}
.ant-modal-footer {
@@ -118,7 +118,7 @@ body {
.ant-modal-title,
.ant-modal-header {
@apply bg-primary text-primary;
}
} */
a:hover {
@apply text-accent;
}

View File

@@ -0,0 +1 @@
<svg id="visual" viewBox="0 0 900 300" width="900" height="300" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"><path d="M484 300L542 300L542 280L564 280L564 260L490 260L490 240L484 240L484 220L637 220L637 200L570 200L570 180L567 180L567 160L629 160L629 140L580 140L580 120L567 120L567 100L457 100L457 80L531 80L531 60L615 60L615 40L504 40L504 20L490 20L490 0L900 0L900 20L900 20L900 40L900 40L900 60L900 60L900 80L900 80L900 100L900 100L900 120L900 120L900 140L900 140L900 160L900 160L900 180L900 180L900 200L900 200L900 220L900 220L900 240L900 240L900 260L900 260L900 280L900 280L900 300L900 300Z" fill="#8187fa"></path><path d="M536 300L660 300L660 280L640 280L640 260L649 260L649 240L620 240L620 220L574 220L574 200L613 200L613 180L659 180L659 160L632 160L632 140L637 140L637 120L502 120L502 100L558 100L558 80L608 80L608 60L550 60L550 40L564 40L564 20L631 20L631 0L900 0L900 20L900 20L900 40L900 40L900 60L900 60L900 80L900 80L900 100L900 100L900 120L900 120L900 140L900 140L900 160L900 160L900 180L900 180L900 200L900 200L900 220L900 220L900 240L900 240L900 260L900 260L900 280L900 280L900 300L900 300Z" fill="#797ff8"></path><path d="M642 300L651 300L651 280L623 280L623 260L680 260L680 240L565 240L565 220L669 220L669 200L557 200L557 180L688 180L688 160L612 160L612 140L658 140L658 120L588 120L588 100L662 100L662 80L553 80L553 60L662 60L662 40L628 40L628 20L701 20L701 0L900 0L900 20L900 20L900 40L900 40L900 60L900 60L900 80L900 80L900 100L900 100L900 120L900 120L900 140L900 140L900 160L900 160L900 180L900 180L900 200L900 200L900 220L900 220L900 240L900 240L900 260L900 260L900 280L900 280L900 300L900 300Z" fill="#7177f6"></path><path d="M684 300L658 300L658 280L652 280L652 260L693 260L693 240L618 240L618 220L614 220L614 200L593 200L593 180L601 180L601 160L713 160L713 140L701 140L701 120L717 120L717 100L665 100L665 80L635 80L635 60L594 60L594 40L646 40L646 20L689 20L689 0L900 0L900 20L900 20L900 40L900 40L900 60L900 60L900 80L900 80L900 100L900 100L900 120L900 120L900 140L900 140L900 160L900 160L900 180L900 180L900 200L900 200L900 220L900 220L900 240L900 240L900 260L900 260L900 280L900 280L900 300L900 300Z" fill="#696ff4"></path><path d="M670 300L736 300L736 280L762 280L762 260L700 260L700 240L754 240L754 220L688 220L688 200L735 200L735 180L726 180L726 160L763 160L763 140L662 140L662 120L739 120L739 100L703 100L703 80L762 80L762 60L690 60L690 40L652 40L652 20L753 20L753 0L900 0L900 20L900 20L900 40L900 40L900 60L900 60L900 80L900 80L900 100L900 100L900 120L900 120L900 140L900 140L900 160L900 160L900 180L900 180L900 200L900 200L900 220L900 220L900 240L900 240L900 260L900 260L900 280L900 280L900 300L900 300Z" fill="#6167f2"></path><path d="M717 300L754 300L754 280L739 280L739 260L735 260L735 240L790 240L790 220L761 220L761 200L772 200L772 180L754 180L754 160L749 160L749 140L754 140L754 120L782 120L782 100L701 100L701 80L748 80L748 60L721 60L721 40L730 40L730 20L746 20L746 0L900 0L900 20L900 20L900 40L900 40L900 60L900 60L900 80L900 80L900 100L900 100L900 120L900 120L900 140L900 140L900 160L900 160L900 180L900 180L900 200L900 200L900 220L900 220L900 240L900 240L900 260L900 260L900 280L900 280L900 300L900 300Z" fill="#585ff0"></path><path d="M813 300L784 300L784 280L772 280L772 260L813 260L813 240L811 240L811 220L794 220L794 200L805 200L805 180L757 180L757 160L818 160L818 140L818 140L818 120L773 120L773 100L817 100L817 80L792 80L792 60L791 60L791 40L811 40L811 20L814 20L814 0L900 0L900 20L900 20L900 40L900 40L900 60L900 60L900 80L900 80L900 100L900 100L900 120L900 120L900 140L900 140L900 160L900 160L900 180L900 180L900 200L900 200L900 220L900 220L900 240L900 240L900 260L900 260L900 280L900 280L900 300L900 300Z" fill="#4f57ed"></path><path d="M814 300L830 300L830 280L805 280L805 260L820 260L820 240L829 240L829 220L830 220L830 200L842 200L842 180L839 180L839 160L829 160L829 140L855 140L855 120L856 120L856 100L834 100L834 80L820 80L820 60L847 60L847 40L841 40L841 20L820 20L820 0L900 0L900 20L900 20L900 40L900 40L900 60L900 60L900 80L900 80L900 100L900 100L900 120L900 120L900 140L900 140L900 160L900 160L900 180L900 180L900 200L900 200L900 220L900 220L900 240L900 240L900 260L900 260L900 280L900 280L900 300L900 300Z" fill="#464feb"></path></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB