From 71e6c3766ff2443237cf02899dcf91f0c07c1443 Mon Sep 17 00:00:00 2001 From: Polle Pas Date: Tue, 25 Mar 2025 17:49:04 +0100 Subject: [PATCH 01/12] poc-atomic-assistent-sidebar --- browser/data-browser/package.json | 6 +- .../src/components/AI/AIChatMessage.tsx | 453 ++++++++++ .../src/components/AI/AISidebar.tsx | 38 + .../src/components/AI/AISidebarContext.tsx | 43 + .../src/components/AI/AgentConfig.tsx | 551 ++++++++++++ .../components/AI/ChatMessagesContainer.tsx | 71 ++ .../src/components/AI/GeneratingIndicator.tsx | 61 ++ .../src/components/AI/MessageContextItem.tsx | 58 ++ .../src/components/AI/ModelSelect.tsx | 101 +++ .../src/components/AI/SimpleAIChat.tsx | 814 ++++++++++++++++++ .../src/components/AI/atomicSchemaHelpers.ts | 38 + .../data-browser/src/components/AI/types.ts | 33 + .../src/components/AI/useAtomicTools.ts | 287 ++++++ .../src/components/AI/useContextForAgent.ts | 98 +++ .../src/components/AI/useTools.ts | 230 +++++ .../src/components/MCPServersManager.tsx | 148 ++++ .../src/components/Navigation.tsx | 9 +- .../data-browser/src/components/Parent.tsx | 10 + .../components/ResourceContextMenu/index.tsx | 25 + .../src/components/ScrollArea.tsx | 1 - .../OntologySideBar/OntologiesPanel.tsx | 4 +- .../src/components/SkeletonButton.tsx | 2 + .../src/components/datatypes/Markdown.tsx | 4 + .../data-browser/src/helpers/AppSettings.tsx | 27 + .../data-browser/src/routes/AppSettings.tsx | 26 + browser/data-browser/src/routes/Sandbox.tsx | 17 +- .../src/views/Card/ResourceCard.tsx | 6 +- browser/pnpm-lock.yaml | 701 ++++++++++++--- 28 files changed, 3737 insertions(+), 125 deletions(-) create mode 100644 browser/data-browser/src/components/AI/AIChatMessage.tsx create mode 100644 browser/data-browser/src/components/AI/AISidebar.tsx create mode 100644 browser/data-browser/src/components/AI/AISidebarContext.tsx create mode 100644 browser/data-browser/src/components/AI/AgentConfig.tsx create mode 100644 browser/data-browser/src/components/AI/ChatMessagesContainer.tsx create mode 100644 browser/data-browser/src/components/AI/GeneratingIndicator.tsx create mode 100644 browser/data-browser/src/components/AI/MessageContextItem.tsx create mode 100644 browser/data-browser/src/components/AI/ModelSelect.tsx create mode 100644 browser/data-browser/src/components/AI/SimpleAIChat.tsx create mode 100644 browser/data-browser/src/components/AI/atomicSchemaHelpers.ts create mode 100644 browser/data-browser/src/components/AI/types.ts create mode 100644 browser/data-browser/src/components/AI/useAtomicTools.ts create mode 100644 browser/data-browser/src/components/AI/useContextForAgent.ts create mode 100644 browser/data-browser/src/components/AI/useTools.ts create mode 100644 browser/data-browser/src/components/MCPServersManager.tsx diff --git a/browser/data-browser/package.json b/browser/data-browser/package.json index fcb16c444..6b7d988b4 100644 --- a/browser/data-browser/package.json +++ b/browser/data-browser/package.json @@ -14,6 +14,8 @@ "@dnd-kit/utilities": "^3.2.2", "@emoji-mart/react": "^1.1.1", "@emotion/is-prop-valid": "^1.3.1", + "@modelcontextprotocol/sdk": "^1.6.1", + "@openrouter/ai-sdk-provider": "^0.4.3", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-tabs": "^1.1.1", @@ -27,6 +29,7 @@ "@tiptap/starter-kit": "^2.9.1", "@tiptap/suggestion": "^2.9.1", "@tomic/react": "workspace:*", + "ai": "^4.1.61", "emoji-mart": "^5.6.0", "polished": "^4.3.1", "prismjs": "^1.29.0", @@ -50,7 +53,8 @@ "styled-components": "^6.1.13", "stylis": "4.3.0", "tippy.js": "^6.3.7", - "tiptap-markdown": "^0.8.10" + "tiptap-markdown": "^0.8.10", + "zod": "^3.24.2" }, "devDependencies": { "@tanstack/router-devtools": "^1.95.1", diff --git a/browser/data-browser/src/components/AI/AIChatMessage.tsx b/browser/data-browser/src/components/AI/AIChatMessage.tsx new file mode 100644 index 000000000..cc8d69539 --- /dev/null +++ b/browser/data-browser/src/components/AI/AIChatMessage.tsx @@ -0,0 +1,453 @@ +import styled from 'styled-components'; +import Markdown from '../datatypes/Markdown'; +import { Details } from '../Details'; +import type { + CoreAssistantMessage, + CoreMessage, + CoreToolMessage, + FilePart, + ImagePart, + ToolCallPart, + ToolResultPart, +} from 'ai'; +import { + FaCircleExclamation, + FaFile, + FaMagnifyingGlass, + FaPencil, + FaSpinner, +} from 'react-icons/fa6'; +import { Row } from '../Row'; +import { ResourceInline } from '../../views/ResourceInline'; +import { TOOL_NAMES } from './useAtomicTools'; +import { useResource, useResources } from '@tomic/react'; +import type { AIMessageContext } from './types'; +import { MessageContextItem } from './MessageContextItem'; + +interface MessageProps { + message: AIChatDisplayMessage; +} + +export type AIChatErrorMessage = { + role: 'error'; + content: string; +}; + +export type MessageWithContext = { + role: 'annotated-message'; + message: CoreMessage; + context: AIMessageContext[]; +}; + +export type AIChatDisplayMessage = + | CoreMessage + | AIChatErrorMessage + | MessageWithContext; + +export function isAIErrorMessage( + message: AIChatDisplayMessage, +): message is AIChatErrorMessage { + return message.role === 'error'; +} + +export function isMessageWithContext( + message: AIChatDisplayMessage, +): message is MessageWithContext { + return message.role === 'annotated-message'; +} + +export function normalizeMessageForAPIIngestion( + messages: AIChatDisplayMessage[], +) { + return messages + .map(m => { + if (isMessageWithContext(m)) return m.message; + + if (isAIErrorMessage(m)) return undefined; + + return m; + }) + .filter(m => m !== undefined); +} + +function isToolMessage( + message: AIChatDisplayMessage, +): message is CoreToolMessage { + return message.role === 'tool'; +} + +function isAssistantMessage( + message: AIChatDisplayMessage, +): message is CoreAssistantMessage { + return message.role === 'assistant'; +} + +function isImagePart(part: unknown): part is ImagePart { + return ( + !!part && + typeof part === 'object' && + 'type' in part && + part.type === 'image' + ); +} + +function isFilePart(part: unknown): part is FilePart { + return ( + !!part && typeof part === 'object' && 'type' in part && part.type === 'file' + ); +} + +export const AIChatMessage = ({ message: messageIn }: MessageProps) => { + const message = isMessageWithContext(messageIn) + ? messageIn.message + : messageIn; + + if (message.role === 'user') { + return ( + + You + {isMessageWithContext(messageIn) && ( + + {messageIn.context.map(item => ( + + ))} + + )} + {typeof message.content === 'string' ? ( + + ) : Array.isArray(message.content) ? ( + <> + {message.content.map((part, index) => { + if (typeof part === 'string') { + return ; + } else if (isImagePart(part)) { + return ; + } else if (isFilePart(part)) { + return ; + } else if (part.type === 'text') { + return ( + + ); + } else { + return null; // Handle other part types if needed + } + })} + + ) : null} + + ); + } + + if (isAIErrorMessage(message)) { + return ( + + + + Error + + + + ); + } + + if (isToolMessage(message)) { + return message.content.map(c => { + const key = `result-${c.toolCallId}`; + + if (c.toolName === TOOL_NAMES.SEARCH_RESOURCE) { + return ; + } + + let result; + + if (typeof c.result === 'string') { + result = c.result; + } else { + result = JSON.stringify(c.result, null, 2); + } + + return ( +
+
+ + {result} + +
+
+ ); + }); + } + + if (isAssistantMessage(message)) { + if (message.content.length === 0) { + return null; + } + + if (typeof message.content === 'string') { + return ; + } + + return ( + <> + {message.content.map((c, index) => { + if (c.type === 'text') { + if (c.text.length === 0) { + return null; + } + + return ; + } + + if (isImagePart(c)) { + return ; + } + + if (c.type === 'tool-call') { + if (c.toolName === TOOL_NAMES.SEARCH_RESOURCE) { + return ( + + ); + } + + if (c.toolName === TOOL_NAMES.GET_ATOMIC_RESOURCE) { + return ; + } + + if (c.toolName === TOOL_NAMES.EDIT_ATOMIC_RESOURCE) { + return ; + } + + return ( + + Using tool: {c.toolName} + + ); + } + + if (c.type === 'reasoning') { + return ( + + Thinking... + + + ); + } + })} + + ); + } + + return Unknown message type; +}; + +const RenderUserContent = ({ text }: { text: string }) => { + const extractedText = text.match(/([\s\S]*?)<\/context>/); + + if (extractedText) { + return ; + } + + return ; +}; + +const ImageContent = ({ imagePart }: { imagePart: ImagePart }) => { + const imageSrc = + typeof imagePart.image === 'string' + ? imagePart.image + : 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; // Fallback 1x1 transparent image + + return ( + + + + ); +}; + +const FileContent = () => { + // Display filename/title based on what's available + // FilePart has data and mimeType properties + return ( + + + Attached File + + ); +}; + +const BasicMessage = ({ text }: { text: string }) => { + return ( + + + + ); +}; + +interface ToolCallMessageProps { + toolCall: ToolCallPart; +} + +const AtomicSearchToolMessage = ({ toolCall }: ToolCallMessageProps) => { + return ( + + + +
+ Searching for{' '} + {(toolCall.args as { query: string }).query} +
+
+
+ ); +}; + +const AtomicFetchToolMessage = ({ toolCall }: ToolCallMessageProps) => { + const resources = useResources( + (toolCall.args as { subjects: string[] }).subjects, + ); + + return ( + <> + {Array.from(resources.values()).map(resource => ( + + + Reading + + {resource.title.slice(0, 20)} + {resource.title.length > 20 ? '...' : ''} + + + + ))} + + ); +}; + +const AtomicEditToolMessage = ({ toolCall }: ToolCallMessageProps) => { + const property = useResource(toolCall.args.property); + const resource = useResource(toolCall.args.subject); + + return ( + + + + Changing {property.title} on{' '} + {resource.title} + + + ); +}; + +interface ToolResultMessageProps { + toolResultPart: ToolResultPart; +} + +const SearchResultMessage = ({ toolResultPart }: ToolResultMessageProps) => { + const subjects = Object.keys( + toolResultPart.result as Record, + ); + + return ( +
+
+
    + {subjects.map(resource => ( +
  1. + +
  2. + ))} +
+
+
+ ); +}; + +const MessageImageWrapper = styled.div` + margin: ${p => p.theme.size(1)} 0; + + img { + max-width: 100%; + max-height: 300px; + border-radius: ${p => p.theme.radius}; + } +`; + +const MessageFileWrapper = styled.div` + margin: ${p => p.theme.size(1)} 0; + + background-color: ${p => p.theme.colors.bg1}; + padding: ${p => p.theme.size(1)}; + border-radius: ${p => p.theme.radius}; +`; + +const ToolUseMessage = styled.div` + background-color: ${p => p.theme.colors.mainSelectedBg}; + padding: ${p => p.theme.size(2)}; + border-radius: ${p => p.theme.radius}; + font-size: 0.7rem; + width: fit-content; + span { + color: ${p => p.theme.colors.textLight}; + } +`; + +const MessageWrapper = styled.div` + border-radius: ${p => p.theme.radius}; + width: 90%; + padding-block: ${p => p.theme.size()}; +`; + +const UserMessageWrapper = styled(MessageWrapper)` + padding: ${p => p.theme.size()}; + background-color: ${p => p.theme.colors.bg}; + align-self: flex-end; + box-shadow: ${p => p.theme.boxShadow}; +`; + +const ErrorMessageWrapper = styled(MessageWrapper)` + padding: ${p => p.theme.size()}; + + background-color: ${p => (p.theme.darkMode ? '#440e0e' : '#f8dbdb')}; +`; + +const ReasoningMessageWrapper = styled(MessageWrapper)` + padding: ${p => p.theme.size()}; + color: ${p => p.theme.colors.textLight}; + font-style: italic; + max-height: 10rem; + overflow-y: auto; +`; + +const SenderName = styled.span` + display: inline-flex; + align-items: center; + gap: 1ch; + font-weight: bold; + font-size: 0.6rem; + color: ${p => p.theme.colors.textLight}; + svg { + font-size: 0.8rem; + color: ${p => p.theme.colors.textLight}; + } +`; + +const StyledPre = styled.pre` + background-color: ${p => p.theme.colors.bg}; + padding: ${p => p.theme.size()}; + border-radius: ${p => p.theme.radius}; + overflow-x: auto; + code { + font-family: Monaco, monospace; + font-size: 0.8em; + } +`; + +const ClippedTitle = styled.span` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 20ch; +`; + +const ContextItemRow = styled(Row)` + margin-block-end: ${p => p.theme.size(2)}; +`; diff --git a/browser/data-browser/src/components/AI/AISidebar.tsx b/browser/data-browser/src/components/AI/AISidebar.tsx new file mode 100644 index 000000000..0e4fa0494 --- /dev/null +++ b/browser/data-browser/src/components/AI/AISidebar.tsx @@ -0,0 +1,38 @@ +import { styled } from 'styled-components'; +import { SimpleAIChat } from './SimpleAIChat'; +import React from 'react'; +import { useAISidebar } from './AISidebarContext'; + +export const AISidebar: React.FC = () => { + const { isOpen } = useAISidebar(); + + return ( + + + + ); +}; + +const SidebarContainer = styled.div` + background-color: ${p => p.theme.colors.bg}; + display: none; + transform: translateX(30rem); + width: min(30rem, 100vw); + overflow: hidden; + border-left: 1px solid ${p => p.theme.colors.bg2}; + padding: ${p => p.theme.size()}; + padding-top: 2px; + transition: + display 100ms allow-discrete, + transform 100ms ease-in-out; + + &[data-open] { + transform: translateX(0rem); + display: block; + } + + @starting-style { + transform: translateX(30rem); + display: none; + } +`; diff --git a/browser/data-browser/src/components/AI/AISidebarContext.tsx b/browser/data-browser/src/components/AI/AISidebarContext.tsx new file mode 100644 index 000000000..a84429173 --- /dev/null +++ b/browser/data-browser/src/components/AI/AISidebarContext.tsx @@ -0,0 +1,43 @@ +import React, { useContext, useState, createContext } from 'react'; + +import type { AIMessageContext } from './types'; +import { useLocalStorage } from '../../hooks/useLocalStorage'; + +export const AISidebarContext = createContext<{ + isOpen: boolean; + setIsOpen: React.Dispatch>; + contextItems: AIMessageContext[]; + setContextItems: React.Dispatch>; +}>({ + isOpen: false, + setIsOpen: () => {}, + contextItems: [], + setContextItems: () => {}, +}); + +export const useAISidebar = () => { + return useContext(AISidebarContext); +}; +export const AISidebarContextProvider: React.FC = ({ + children, +}) => { + const [isOpen, setIsOpen] = useLocalStorage('atomic.aiSidebar.open', false); + const [contextItems, setContextItems] = useState([]); + + return ( + + {children} + + ); +}; + +export const newContextItem = ( + item: Omit, +): AIMessageContext => { + return { + ...item, + id: crypto.randomUUID(), + }; +}; diff --git a/browser/data-browser/src/components/AI/AgentConfig.tsx b/browser/data-browser/src/components/AI/AgentConfig.tsx new file mode 100644 index 000000000..02b726ebd --- /dev/null +++ b/browser/data-browser/src/components/AI/AgentConfig.tsx @@ -0,0 +1,551 @@ +import { useEffect, useState } from 'react'; +import { styled } from 'styled-components'; +import { Row, Column } from '../Row'; +import { FaPencil, FaPlus, FaTrash } from 'react-icons/fa6'; +import { IconButton } from '../IconButton/IconButton'; +import { ModelSelect } from './ModelSelect'; +import type { AIAgent, MCPServer } from './types'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + useDialog, +} from '../Dialog'; +import { useLocalStorage } from '../../hooks/useLocalStorage'; +import { Button } from '../Button'; +import { SkeletonButton } from '../SkeletonButton'; +import { useSettings } from '../../helpers/AppSettings'; +import { Checkbox, CheckboxLabel } from '../forms/Checkbox'; +import { generateObject } from 'ai'; +import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { z } from 'zod'; + +// Helper function to generate a unique ID +const generateId = () => { + return `custom-user-agent.${Math.random().toString(36).substring(2, 11)}`; +}; + +interface AgentConfigProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedAgent: AIAgent; + onSelectAgent: (agent: AIAgent) => void; +} + +const defaultNewAgent: Omit = { + name: '', + description: '', + systemPrompt: '', + availableTools: [], + model: 'google/gemini-2.0-flash-lite-001', + canReadAtomicData: false, + canWriteAtomicData: false, +}; + +const defaultAgents: AIAgent[] = [ + { + name: 'Atomic Data Agent', + id: 'dev.atomicdata.atomic-agent', + description: + "An agent that is specialized in helping you use AtomicServer. It takes context from what you're doing.", + systemPrompt: `You are an AI assistant in the Atomic Data Browser. Users will ask questions about their data and you will answer by looking at the data or using your own knowledge about the world. +Atomic Data uses JSON-AD, Every resource including the properties themselves have a subject (the '@id' property in the JSON-AD), this is a URL that points to the resource. +Resources are always referenced by subject so make sure you have all the subjects you need before editing or creating resources. + +Keep the following things in mind: +- If the user mentions a resource by its name and you don't know the subject, use the search-resource tool to find its subject. +- If you need details on resources referenced by another resource, use the get-atomic-resource tool. +- When talking about a resource, always wrap the title in a link using markdown. +- If you don't know the answer to the users question, try to figure it out by using the tools provided to you. +`, + availableTools: ['atomic-tools'], + model: 'openai/gpt-4o-mini', + canReadAtomicData: true, + canWriteAtomicData: true, + }, + { + name: 'General Agent', + id: 'dev.atomicdata.general-agent', + description: "A basic agent that doesn't have any special purpose.", + systemPrompt: ``, + availableTools: [], + model: 'google/gemini-2.0-flash-lite-001', + canReadAtomicData: true, + canWriteAtomicData: true, + }, +]; + +// This hook manages the agent configuration +export const useAIAgentConfig = () => { + const [agents, setAgents] = useLocalStorage( + 'atomic.ai.agents', + defaultAgents, + ); + const [autoAgentSelectEnabled, setAutoAgentSelectEnabled] = useLocalStorage( + 'atomic.ai.autoAgentSelect', + true, + ); + + // Save agents to settings + const saveAgents = (newAgents: AIAgent[]) => { + setAgents(newAgents); + }; + + return { + agents: agents.length > 0 ? agents : [], + autoAgentSelectEnabled, + setAutoAgentSelectEnabled, + saveAgents, + }; +}; + +function agentToText(agent: AIAgent, mcpServers: MCPServer[]) { + return `ID: ${agent.id} Name: ${agent.name} Description: ${agent.description} Tools: ${agent.availableTools.map(t => mcpServers.find(s => s.id === t)?.name).join(', ')}`; +} + +export const useAutoAgentSelect = () => { + const { mcpServers, openRouterApiKey } = useSettings(); + const { agents } = useAIAgentConfig(); + + const openrouter = createOpenRouter({ + apiKey: openRouterApiKey, + compatibility: 'strict', + }); + + const basePrompt = `You are a tool that determines what agent to use to answer the users question. +These are the agents to choose from + +${agents.map(agent => agentToText(agent, mcpServers)).join('\n')} + +Answer with only the ID of the agent you pick + +User question: `; + + const pickAgent = async (question: string): Promise => { + const prompt = basePrompt + question.trim(); + + const { object } = await generateObject({ + // model: openrouter('google/gemma-3-27b-it:free'), + model: openrouter('google/gemini-2.0-flash-lite-preview-02-05:free'), + schemaName: 'Agent', + schemaDescription: 'The agent to use for the question.', + schema: z.object({ + agentId: z.string(), + }), + prompt, + }); + + const agent = agents.find(a => a.id === object.agentId); + + if (!agent) { + throw new Error('Agent not found'); + } + + return agent; + }; + + return pickAgent; +}; + +export const AgentConfig = ({ + open, + onOpenChange, + selectedAgent, + onSelectAgent, +}: AgentConfigProps) => { + const { + agents, + autoAgentSelectEnabled, + setAutoAgentSelectEnabled, + saveAgents, + } = useAIAgentConfig(); + const [editingAgent, setEditingAgent] = useState(null); + const [isCreating, setIsCreating] = useState(false); + + const [dialogProps, show, close, isOpen] = useDialog({ + bindShow: onOpenChange, + }); + + const handleSaveAgent = () => { + if (!editingAgent) return; + + const newAgents = isCreating + ? [...agents, editingAgent] + : agents.map((agent: AIAgent) => + // Use ID to identify which agent we're editing + agent.id === editingAgent.id ? editingAgent : agent, + ); + + saveAgents(newAgents); + + // If we're editing the currently selected agent or creating a new one, update selection + if (selectedAgent.id === editingAgent.id || isCreating) { + onSelectAgent(editingAgent); + } + + setEditingAgent(null); + setIsCreating(false); + }; + + const handleDeleteAgent = (agentToDelete: AIAgent) => { + if (agents.length <= 1) { + // Prevent deleting the last agent + return; + } + + const newAgents = agents.filter( + (agent: AIAgent) => agent.id !== agentToDelete.id, + ); + saveAgents(newAgents); + + // If we're deleting the currently selected agent, select the first available + if (selectedAgent.id === agentToDelete.id) { + onSelectAgent(newAgents[0]); + } + }; + + const handleCreateNewAgent = () => { + setEditingAgent({ + ...defaultNewAgent, + id: generateId(), + }); + setIsCreating(true); + }; + + const handleEditAgent = (agent: AIAgent) => { + setEditingAgent({ ...agent }); + setIsCreating(false); + }; + + const handleCancel = () => { + setEditingAgent(null); + setIsCreating(false); + }; + + useEffect(() => { + if (open) { + show(); + } + }, [open]); + + return ( + + {isOpen && ( + <> + +

Select AI Agents

+
+ + {editingAgent ? ( + + ) : ( + +
+ + + Automatic Agent Selection + +

+ Pick best agent for the job based on name, description and + available tools +

+
+ + {agents.map((agent: AIAgent) => ( + onSelectAgent(agent)} + > + + {agent.name} + {agent.description} + + + { + e.stopPropagation(); + handleEditAgent(agent); + }} + title='Edit agent' + > + + + { + e.stopPropagation(); + handleDeleteAgent(agent); + }} + title='Delete agent' + disabled={agents.length <= 1} + > + + + + + ))} + + + + Create New Agent + +
+ )} +
+ {editingAgent && ( + + + + + )} + + )} +
+ ); +}; + +interface AgentFormProps { + agent: AIAgent; + onChange: (agent: AIAgent) => void; +} + +const AgentForm = ({ agent, onChange }: AgentFormProps) => { + const { mcpServers } = useSettings(); + + const handleChange = (field: keyof AIAgent, value: string | boolean) => { + onChange({ + ...agent, + [field]: value, + }); + }; + + const onToggleTool = (toolId: string) => { + onChange({ + ...agent, + availableTools: agent.availableTools.includes(toolId) + ? agent.availableTools.filter(t => t !== toolId) + : [...agent.availableTools, toolId], + }); + }; + + useEffect(() => { + // Check if the agent has any tools that are not available any more. + const currentlyAvailableServers = mcpServers.map(s => s.id); + const tools = agent.availableTools.filter(tool => + currentlyAvailableServers.includes(tool), + ); + + if (tools.length !== agent.availableTools.length) { + onChange({ + ...agent, + availableTools: tools, + }); + } + }, [mcpServers]); + + return ( + + + + handleChange('name', e.target.value)} + placeholder='Agent name' + /> + + + + + handleChange('description', e.target.value)} + placeholder='Agent description' + /> + + + + +