diff --git a/frontend/src/components/icons/index.tsx b/frontend/src/components/icons/index.tsx index 206f0cd98b..b84525a276 100644 --- a/frontend/src/components/icons/index.tsx +++ b/frontend/src/components/icons/index.tsx @@ -53,6 +53,7 @@ export { Eye as EyeIcon, // IoMdEye, EyeIcon (Heroicons) EyeOff as EyeOffIcon, // IoMdEyeOff, MdOutlineVisibilityOff, EyeOffIcon (Heroicons), EyeClosedIcon (Octicons) Filter as FilterIcon, // FilterIcon (Heroicons) + Fingerprint as FingerprintIcon, // Fingerprint icon for context IDs Flame as FlameIcon, // MdLocalFireDepartment HelpCircle as HelpIcon, // MdHelpOutline, MdOutlineQuestionMark Home as HomeIcon, // HomeIcon (Heroicons) diff --git a/frontend/src/components/pages/agents/details/a2a/chat/ai-agent-chat.tsx b/frontend/src/components/pages/agents/details/a2a/chat/ai-agent-chat.tsx index 1ebae3a387..6f5c36cc15 100644 --- a/frontend/src/components/pages/agents/details/a2a/chat/ai-agent-chat.tsx +++ b/frontend/src/components/pages/agents/details/a2a/chat/ai-agent-chat.tsx @@ -13,6 +13,9 @@ import { Conversation, ConversationContent, ConversationEmptyState } from 'components/ai-elements/conversation'; import { Loader } from 'components/ai-elements/loader'; +import { FingerprintIcon } from 'components/icons'; +import { Badge } from 'components/redpanda-ui/components/badge'; +import { CopyButton } from 'components/redpanda-ui/components/copy-button'; import { useCallback, useEffect, useRef } from 'react'; import { ChatInput } from './components/chat-input'; @@ -22,7 +25,7 @@ import { useChatMessages } from './hooks/use-chat-messages'; import { useCumulativeUsage } from './hooks/use-cumulative-usage'; import type { AIAgentChatProps } from './types'; -export const AIAgentChat = ({ agent }: AIAgentChatProps) => { +export const AIAgentChat = ({ agent, headerActions }: AIAgentChatProps) => { const textareaRef = useRef(null); const containerRef = useRef(null); @@ -86,10 +89,27 @@ export const AIAgentChat = ({ agent }: AIAgentChatProps) => {
{/* Context ID header */} {Boolean(contextId) && ( -
-
- context_id: - {contextId} +
+
+
+
+ +
+
+ Context ID + + {contextId} + + +
+
+ {headerActions}
)} diff --git a/frontend/src/components/pages/agents/details/a2a/chat/types.ts b/frontend/src/components/pages/agents/details/a2a/chat/types.ts index 4dc8a83bc0..637a5b9ff9 100644 --- a/frontend/src/components/pages/agents/details/a2a/chat/types.ts +++ b/frontend/src/components/pages/agents/details/a2a/chat/types.ts @@ -99,4 +99,5 @@ export type ChatMessage = { export type AIAgentChatProps = { agent: AIAgent; + headerActions?: React.ReactNode; }; diff --git a/frontend/src/components/pages/agents/details/ai-agent-card-tab.tsx b/frontend/src/components/pages/agents/details/ai-agent-card-tab.tsx new file mode 100644 index 0000000000..592b24c197 --- /dev/null +++ b/frontend/src/components/pages/agents/details/ai-agent-card-tab.tsx @@ -0,0 +1,683 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { create } from '@bufbuild/protobuf'; +import { FieldMaskSchema } from '@bufbuild/protobuf/wkt'; +import { Markdown } from '@redpanda-data/ui'; +import { getRouteApi } from '@tanstack/react-router'; +import { Button } from 'components/redpanda-ui/components/button'; +import { Card, CardContent, CardHeader, CardTitle } from 'components/redpanda-ui/components/card'; +import { DynamicCodeBlock } from 'components/redpanda-ui/components/code-block-dynamic'; +import { Input } from 'components/redpanda-ui/components/input'; +import { Label } from 'components/redpanda-ui/components/label'; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from 'components/redpanda-ui/components/sheet'; +import { Textarea } from 'components/redpanda-ui/components/textarea'; +import { Text } from 'components/redpanda-ui/components/typography'; +import { StringArrayInput } from 'components/ui/common/string-array-input'; +import { + AlertCircle, + Code as CodeIcon, + Edit, + Link as LinkIcon, + Plus, + Save, + Settings, + Terminal, + Trash2, +} from 'lucide-react'; +import { + AIAgent_AgentCard_ProviderSchema, + AIAgent_AgentCard_SkillSchema, + AIAgent_AgentCardSchema, + AIAgentUpdateSchema, + UpdateAIAgentRequestSchema, +} from 'protogen/redpanda/api/dataplane/v1alpha3/ai_agent_pb'; +import { useState } from 'react'; +import { useGetA2ACodeSnippetQuery, useGetAIAgentQuery, useUpdateAIAgentMutation } from 'react-query/api/ai-agent'; +import { toast } from 'sonner'; +import { formatToastErrorMessageGRPC } from 'utils/toast.utils'; + +import GoLogo from '../../../../assets/go.svg'; +import JavaLogo from '../../../../assets/java.svg'; +import NodeLogo from '../../../../assets/node.svg'; +import PythonLogo from '../../../../assets/python.svg'; + +const routeApi = getRouteApi('/agents/$id'); + +const AVAILABLE_LANGUAGES = ['python', 'javascript', 'java', 'go', 'curl'] as const; + +const getLanguageIcon = (language: string) => { + switch (language) { + case 'python': + return PythonLogo; + case 'javascript': + return NodeLogo; + case 'java': + return JavaLogo; + case 'go': + return GoLogo; + case 'curl': + return 'terminal'; + default: + return null; + } +}; + +const getLanguageLabel = (language: string): string => { + const labels: Record = { + python: 'Python', + javascript: 'JavaScript', + java: 'Java', + go: 'Go', + curl: 'cURL', + }; + return labels[language] || language; +}; + +type AgentCard = { + iconUrl: string; + documentationUrl: string; + provider?: { + organization: string; + url: string; + }; + skills: Array<{ + id: string; + name: string; + description: string; + tags: string[]; + examples: string[]; + }>; +}; + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Agent card tab contains CRUD operations for agent card configuration with layout complexity +export const AIAgentCardTab = () => { + const { id } = routeApi.useParams(); + const { data: aiAgentData } = useGetAIAgentQuery({ id: id || '' }, { enabled: !!id }); + const { mutateAsync: updateAIAgent, isPending: isUpdatePending } = useUpdateAIAgentMutation(); + + const [isEditing, setIsEditing] = useState(false); + const [editedCard, setEditedCard] = useState(null); + const [selectedLanguage, setSelectedLanguage] = useState('python'); + + const { data: codeSnippetData, isLoading: isLoadingCodeSnippet } = useGetA2ACodeSnippetQuery({ + language: selectedLanguage, + }); + + const agent = aiAgentData?.aiAgent; + + if (!agent) { + return null; + } + + const displayCard: AgentCard = editedCard || { + iconUrl: agent.agentCard?.iconUrl || '', + documentationUrl: agent.agentCard?.documentationUrl || '', + provider: agent.agentCard?.provider + ? { + organization: agent.agentCard.provider.organization || '', + url: agent.agentCard.provider.url || '', + } + : undefined, + skills: + agent.agentCard?.skills.map((skill) => ({ + id: skill.id, + name: skill.name, + description: skill.description, + tags: skill.tags || [], + examples: skill.examples || [], + })) || [], + }; + + const updateField = (field: 'iconUrl' | 'documentationUrl', value: string) => { + setEditedCard({ + ...displayCard, + [field]: value, + }); + }; + + const updateProvider = (field: 'organization' | 'url', value: string) => { + setEditedCard({ + ...displayCard, + provider: { + organization: displayCard.provider?.organization || '', + url: displayCard.provider?.url || '', + [field]: value, + }, + }); + }; + + const addSkill = () => { + const newSkills = [...displayCard.skills, { id: '', name: '', description: '', tags: [], examples: [] }]; + setEditedCard({ + ...displayCard, + skills: newSkills, + }); + }; + + const removeSkill = (index: number) => { + setEditedCard({ + ...displayCard, + skills: displayCard.skills.filter((_, i) => i !== index), + }); + }; + + const updateSkill = ( + index: number, + field: 'id' | 'name' | 'description' | 'tags' | 'examples', + value: string | string[] + ) => { + const updatedSkills = [...displayCard.skills]; + updatedSkills[index] = { ...updatedSkills[index], [field]: value }; + setEditedCard({ + ...displayCard, + skills: updatedSkills, + }); + }; + + const handleSave = async () => { + if (!id) { + return; + } + + try { + const hasData = !!( + displayCard.iconUrl || + displayCard.documentationUrl || + displayCard.skills.length > 0 || + displayCard.provider?.organization || + displayCard.provider?.url + ); + + const agentCard = hasData + ? create(AIAgent_AgentCardSchema, { + iconUrl: displayCard.iconUrl || undefined, + documentationUrl: displayCard.documentationUrl || undefined, + provider: + displayCard.provider?.organization || displayCard.provider?.url + ? create(AIAgent_AgentCard_ProviderSchema, { + organization: displayCard.provider.organization || undefined, + url: displayCard.provider.url || undefined, + }) + : undefined, + skills: displayCard.skills.map((skill) => + create(AIAgent_AgentCard_SkillSchema, { + id: skill.id.trim(), + name: skill.name.trim(), + description: skill.description.trim(), + tags: skill.tags.filter((t: string) => t.trim()), + examples: skill.examples.filter((e: string) => e.trim()), + }) + ), + }) + : undefined; + + await updateAIAgent( + create(UpdateAIAgentRequestSchema, { + id, + aiAgent: create(AIAgentUpdateSchema, { + displayName: agent.displayName, + description: agent.description, + model: agent.model, + maxIterations: agent.maxIterations, + provider: agent.provider, + systemPrompt: agent.systemPrompt, + serviceAccount: agent.serviceAccount, + resources: agent.resources, + mcpServers: agent.mcpServers, + subagents: agent.subagents, + tags: agent.tags, + gateway: agent.gateway, + agentCard, + }), + updateMask: create(FieldMaskSchema, { + paths: [ + 'agent_card.icon_url', + 'agent_card.documentation_url', + 'agent_card.provider.organization', + 'agent_card.provider.url', + 'agent_card.skills', + ], + }), + }), + { + onSuccess: () => { + toast.success('Agent card updated successfully'); + setIsEditing(false); + setEditedCard(null); + }, + onError: (error) => { + toast.error(formatToastErrorMessageGRPC({ error, action: 'update', entity: 'agent card' })); + }, + } + ); + } catch { + // Error already handled + } + }; + + const handleCancel = () => { + setIsEditing(false); + setEditedCard(null); + }; + + const renderSkillField = ( + skill: AgentCard['skills'][number], + index: number, + field: 'id' | 'name' | 'description' + ) => { + const placeholders = { + id: 'e.g., redpanda-cluster-info', + name: 'e.g., Redpanda Cluster Information', + description: 'Describe what this skill does...', + }; + + if (field === 'description') { + return ( +