Add Activepieces integration for workflow automation

- Add Activepieces fork with SmoothSchedule custom piece
- Create integrations app with Activepieces service layer
- Add embed token endpoint for iframe integration
- Create Automations page with embedded workflow builder
- Add sidebar visibility fix for embed mode
- Add list inactive customers endpoint to Public API
- Include SmoothSchedule triggers: event created/updated/cancelled
- Include SmoothSchedule actions: create/update/cancel events, list resources/services/customers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-18 22:59:37 -05:00
parent 9848268d34
commit 3aa7199503
16292 changed files with 1284892 additions and 4708 deletions

View File

@@ -0,0 +1,70 @@
import { t } from 'i18next';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
type AgentResult,
AgentTaskStatus,
ContentBlockType,
isNil,
} from '@activepieces/shared';
import {
AgentToolBlock,
DoneBlock,
FailedBlock,
MarkdownBlock,
PromptBlock,
StructuredOutputBlock,
ThinkingBlock,
} from './timeline-blocks';
type AgentTimelineProps = {
className?: string;
agentResult?: AgentResult;
};
export const AgentTimeline = ({
agentResult,
className = '',
}: AgentTimelineProps) => {
if (isNil(agentResult)) {
return <p>{t('No agent output available')}</p>;
}
return (
<div className={`h-full flex w-full flex-col ${className}`}>
<ScrollArea className="flex-1 min-h-0 relative">
<div className="absolute left-2 top-4 bottom-8 w-px bg-border" />
<div className="space-y-7 pb-4">
{agentResult.prompt.length > 0 && (
<PromptBlock prompt={agentResult.prompt} />
)}
{agentResult.steps.map((step, index) => {
switch (step.type) {
case ContentBlockType.MARKDOWN:
return <MarkdownBlock key={index} step={step} index={index} />;
case ContentBlockType.TOOL_CALL:
return (
<AgentToolBlock key={index} block={step} index={index} />
);
default:
return null;
}
})}
{!isNil(agentResult.structuredOutput) && (
<StructuredOutputBlock output={agentResult.structuredOutput} />
)}
{agentResult.status === AgentTaskStatus.IN_PROGRESS && (
<ThinkingBlock />
)}
{agentResult.status === AgentTaskStatus.COMPLETED && <DoneBlock />}
{agentResult.status === AgentTaskStatus.FAILED && <FailedBlock />}
</div>
</ScrollArea>
</div>
);
};

View File

@@ -0,0 +1,278 @@
import { t } from 'i18next';
import {
CircleX,
Loader2,
Wrench,
MessageSquareText,
CircleCheckBig,
CheckCheck,
SquareTerminal,
Braces,
} from 'lucide-react';
import { ApMarkdown } from '@/components/custom/markdown';
import { DataList } from '@/components/data-list';
import { JsonViewer } from '@/components/json-viewer';
import { SimpleJsonViewer } from '@/components/simple-json-viewer';
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from '@/components/ui/accordion';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import {
ExecuteToolResponse,
isNil,
MarkdownContentBlock,
MarkdownVariant,
TASK_COMPLETION_TOOL_NAME,
ToolCallStatus,
ExecutionToolStatus,
type ToolCallContentBlock,
} from '@activepieces/shared';
import { agentToolHooks } from '../agent-tool-hooks';
interface AgentToolBlockProps {
block: ToolCallContentBlock;
index: number;
}
const parseJsonOrReturnOriginal = (json: unknown) => {
try {
return JSON.parse(json as string);
} catch {
return json;
}
};
const TimelineItem = ({
icon,
children,
iconLeft = 'left-0',
}: {
icon: React.ReactNode;
children: React.ReactNode;
iconLeft?: string;
}) => {
return (
<div className="relative pl-7 animate-fade">
<div
className={`absolute bg-background ${iconLeft} w-4 h-4 top-3.5 flex items-center justify-center`}
>
{icon}
</div>
{children}
</div>
);
};
export const AgentToolBlock = ({ block, index }: AgentToolBlockProps) => {
if ([TASK_COMPLETION_TOOL_NAME].includes(block.toolName ?? '')) return null;
const { data: metadata, isLoading } = agentToolHooks.useToolMetadata(block);
const output = block.output as ExecuteToolResponse | null;
const errorMessage = output?.errorMessage as string | null;
const isDone = block.status === ToolCallStatus.COMPLETED;
const isSuccess = output?.status ?? ExecutionToolStatus.FAILED;
const hasInstructions = !isNil(block.input?.instruction);
const resolvedFields = output?.resolvedInput ?? null;
const result = output?.output
? parseJsonOrReturnOriginal(output.output)
: null;
const defaultTab = resolvedFields ? 'resolvedFields' : 'result';
const renderStatusIcon = () => {
if (!isDone) return <Loader2 className="h-4 w-4 animate-spin shrink-0" />;
return isSuccess === ExecutionToolStatus.SUCCESS ? (
<CheckCheck className="h-4 w-4 text-success shrink-0" />
) : (
<CircleX className="h-4 w-4 text-destructive shrink-0" />
);
};
const renderToolIcon = () => {
if (isLoading) return <Loader2 className="h-4 w-4 animate-spin shrink-0" />;
if (metadata?.logoUrl)
return (
<img
src={metadata.logoUrl}
alt="Tool logo"
className="h-4 w-4 object-contain shrink-0"
/>
);
return <Wrench className="h-4 w-4 shrink-0" />;
};
const ToolHeader = (
<div className="flex items-center gap-2 w-full">
{renderToolIcon()}
<span
className={`flex gap-1 items-center ${
!isSuccess ? 'text-destructive' : ''
}`}
>
<span className="text-sm font-semibold">
{isLoading ? 'Loading...' : metadata?.displayName ?? 'Unknown Tool'}
{!isSuccess && t(' (Failed)')}
</span>
</span>
</div>
);
return (
<TimelineItem key={`step-${index}-${block.type}`} icon={renderStatusIcon()}>
<Accordion
type="single"
collapsible
className="border-0 w-full bg-accent/20 rounded-md text-foreground border border-border"
>
<AccordionItem value={`block-${index}`} className="border-0">
<AccordionTrigger className="p-3 text-sm">
{ToolHeader}
</AccordionTrigger>
<AccordionContent>
<div className="space-y-3 w-full my-2">
{hasInstructions && (
<ApMarkdown
variant={MarkdownVariant.BORDERLESS}
markdown={block.input?.instruction as string}
/>
)}
{!isLoading && (
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList variant="outline" className="mb-0">
<TabsTrigger
value="resolvedFields"
variant="outline"
className="text-xs"
>
{t('Parameters')}
</TabsTrigger>
<TabsTrigger
value="result"
variant="outline"
className="text-xs"
>
{isNil(errorMessage) ? t('Output') : t('Error')}
</TabsTrigger>
</TabsList>
<TabsContent
value="resolvedFields"
className="overflow-hidden mt-3"
>
{resolvedFields ? (
<DataList data={resolvedFields} />
) : (
<div className="text-muted-foreground text-sm">
{t('No resolved fields')}
</div>
)}
</TabsContent>
<TabsContent value="result" className="overflow-hidden mt-3">
{result ? (
<SimpleJsonViewer
data={result}
hideCopyButton
maxHeight={300}
/>
) : !isNil(errorMessage) ? (
<ApMarkdown
variant={MarkdownVariant.BORDERLESS}
markdown={errorMessage}
/>
) : (
<div className="text-muted-foreground text-sm">
{t('No result')}
</div>
)}
</TabsContent>
</Tabs>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</TimelineItem>
);
};
export const MarkdownBlock = ({
index,
step,
}: {
index: number;
step: MarkdownContentBlock;
}) => {
return (
<TimelineItem
key={`step-${index}-${step.type}`}
icon={<MessageSquareText className="h-4 w-4 text-muted-foreground" />}
>
<div className="bg-accent/20 rounded-md p-3 text-sm text-foreground border border-border">
<ApMarkdown
markdown={step.markdown}
variant={MarkdownVariant.BORDERLESS}
/>
</div>
</TimelineItem>
);
};
export const StructuredOutputBlock = ({ output }: { output: any }) => {
return (
<TimelineItem icon={<Braces className="h-4 w-4 text-muted-foreground" />}>
<JsonViewer json={output} title={t('output')} />
</TimelineItem>
);
};
export const ThinkingBlock = () => {
return (
<TimelineItem
icon={<Loader2 className="h-4 w-4 text-muted-foreground animate-spin" />}
>
<div className="bg-accent/20 rounded-md p-3 w-full text-sm text-foreground border border-border animate-pulse">
<span>{t('Agent is thinking...')}</span>
</div>
</TimelineItem>
);
};
export const PromptBlock = ({ prompt }: { prompt: string }) => {
return (
<TimelineItem icon={<SquareTerminal className="h-4 w-4 text-primary" />}>
<div className="bg-primary/5 rounded-md p-3 text-sm text-foreground border border-border">
<ApMarkdown markdown={prompt} variant={MarkdownVariant.BORDERLESS} />
</div>
</TimelineItem>
);
};
export const DoneBlock = () => {
return (
<TimelineItem icon={<CircleCheckBig className="h-4 w-4 text-green-600" />}>
<div className="border border-green-500/40 bg-green-50/60 rounded-md p-3 text-sm text-green-700 font-medium flex items-center gap-2">
<span>{t('Done!')}</span>
</div>
</TimelineItem>
);
};
export const FailedBlock = () => {
return (
<TimelineItem icon={<CircleX className="h-4 w-4 text-red-600" />}>
<div className="border border-red-500/40 bg-red-50/60 rounded-md p-3 text-sm text-red-700 font-medium flex items-center gap-2">
<span>{t('Failed')}</span>
</div>
</TimelineItem>
);
};

View File

@@ -0,0 +1,45 @@
import { useQuery } from '@tanstack/react-query';
import { ToolCallType, type ToolCallContentBlock } from '@activepieces/shared';
import { piecesApi } from '../pieces/lib/pieces-api';
type ToolMetadata = {
displayName?: string | null;
logoUrl?: string | null;
};
export const agentToolHooks = {
useToolMetadata(contentBlock: ToolCallContentBlock) {
return useQuery<ToolMetadata, Error>({
queryKey: [
'mcp-tool-metadata',
contentBlock.toolName,
contentBlock.toolCallType,
],
queryFn: async () => {
switch (contentBlock.toolCallType) {
case ToolCallType.PIECE: {
const piece = await piecesApi.get({
name: contentBlock.pieceName,
version: contentBlock.pieceVersion,
});
const actionMetadata = piece.actions[contentBlock.actionName];
return {
displayName:
actionMetadata?.displayName ?? contentBlock.actionName,
logoUrl: piece.logoUrl,
};
}
case ToolCallType.FLOW:
return {
displayName: contentBlock.displayName,
logoUrl: null,
};
default:
return { displayName: null, logoUrl: null };
}
},
});
},
};

View File

@@ -0,0 +1,98 @@
import { t } from 'i18next';
import { ChevronDown, Puzzle, Workflow } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { AgentTool, AgentToolType } from '@activepieces/shared';
import { AgentFlowToolDialog } from './flow-tool-dialog';
import { AgentPieceDialog } from './piece-tool-dialog';
type AddAgentToolDropdownProps = {
tools: AgentTool[];
disabled?: boolean;
onToolsUpdate: (tools: AgentTool[]) => void;
};
export const AddAgentToolDropdown = ({
tools,
disabled,
onToolsUpdate,
}: AddAgentToolDropdownProps) => {
const [openDropdown, setOpenDropdown] = useState(false);
const [showAddPieceDialog, setShowAddPieceDialog] = useState(false);
const [showAddFlowDialog, setShowAddFlowDialog] = useState(false);
return (
<DropdownMenu
modal={false}
open={openDropdown}
onOpenChange={setOpenDropdown}
>
<DropdownMenuTrigger disabled={disabled} asChild>
<Button variant="basic">
<span>{t('Add tool')}</span>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<AgentPieceDialog
tools={tools}
open={showAddPieceDialog}
onToolsUpdate={(tools) => {
onToolsUpdate(tools);
setShowAddPieceDialog(false);
setOpenDropdown(false);
}}
onClose={() => {
setShowAddPieceDialog(false);
setOpenDropdown(false);
}}
>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setShowAddPieceDialog(true);
}}
>
<Puzzle className="h-4 w-4 me-2" />
<span>{t('From piece')}</span>
</DropdownMenuItem>
</AgentPieceDialog>
<AgentFlowToolDialog
open={showAddFlowDialog}
selectedFlows={tools
.filter((tool) => tool.type === AgentToolType.FLOW)
.map((tool) => tool.flowId!)}
onToolsUpdate={(newTools) => {
onToolsUpdate(newTools);
setShowAddFlowDialog(false);
setOpenDropdown(false);
}}
onClose={() => {
setShowAddFlowDialog(false);
setOpenDropdown(false);
}}
tools={tools}
>
<DropdownMenuItem
onSelect={(e) => {
e.stopPropagation();
e.preventDefault();
setShowAddFlowDialog(true);
}}
>
<Workflow className="h-4 w-4 me-2" />
<span>{t('From flow')}</span>
</DropdownMenuItem>
</AgentFlowToolDialog>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -0,0 +1,20 @@
import { t } from 'i18next';
const formatNames = (names: string[]) => {
if (names.length === 1) {
return names[0];
}
const formattedNames = names.map((name, idx) => {
if (idx < names.length - 1) {
return `${name},`;
}
return `${t('and')} ${name}`;
});
return formattedNames.join(' ');
};
export const agentConfigUtils = {
formatNames,
};

View File

@@ -0,0 +1,20 @@
import { t } from 'i18next';
import { Plus } from 'lucide-react';
import { flowHooks } from '@/features/flows/lib/flow-hooks';
export const CreateMcpFlowButton = () => {
const { mutate: createMcpFlow, isPending } = flowHooks.useCreateMcpFlow();
return (
<div
onClick={() => createMcpFlow()}
className="border p-2 h-[150px] w-[150px] flex flex-col items-center justify-center hover:bg-accent hover:text-accent-foreground cursor-pointer rounded-lg border-dashed border-muted-foreground/50"
>
<Plus className="w-[40px] h-[40px] text-muted-foreground" />
<div className="mt-2 text-center text-md">
{isPending ? t('Creating...') : t('Create New Flow')}
</div>
</div>
);
};

View File

@@ -0,0 +1,102 @@
import { t } from 'i18next';
import { Workflow } from 'lucide-react';
import { useMemo } from 'react';
import { useDebounce } from 'use-debounce';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { PopulatedFlow } from '@activepieces/shared';
import { CreateMcpFlowButton } from './create-mcp-flow-button';
import { flowDialogUtils } from './flow-dialog-utils';
interface FlowDialogContentProps {
flows: PopulatedFlow[];
selectedFlows: string[];
setSelectedFlows: (value: string[] | ((prev: string[]) => string[])) => void;
searchQuery: string;
}
export const FlowDialogContent = ({
flows,
selectedFlows,
setSelectedFlows,
searchQuery,
}: FlowDialogContentProps) => {
const [debouncedQuery] = useDebounce(searchQuery, 300);
const filteredFlows = useMemo(() => {
if (!debouncedQuery) return flows;
const query = debouncedQuery.toLowerCase();
return flows.filter((flow) =>
flow.version.displayName.toLowerCase().includes(query),
);
}, [flows, debouncedQuery]);
const handleSelectFlow = (flowId: string) => {
setSelectedFlows((prev: string[]) => {
const newSelected = prev.includes(flowId)
? prev.filter((id: string) => id !== flowId)
: [...prev, flowId];
return newSelected;
});
};
return (
<ScrollArea className="grow overflow-y-auto rounded-md">
<div className="grid grid-cols-4 gap-4">
<CreateMcpFlowButton />
{filteredFlows.map((flow) => {
const tooltip = flowDialogUtils.getFlowTooltip(flow);
const isSelectable = flowDialogUtils.isFlowSelectable(flow);
return (
<Tooltip key={flow.id}>
<TooltipTrigger asChild>
<div
className={`border p-2 h-[150px] w-[150px] flex flex-col items-center justify-center hover:bg-accent hover:text-accent-foreground cursor-pointer rounded-lg relative ${
!isSelectable ? 'opacity-50' : ''
} ${selectedFlows.includes(flow.id) ? 'bg-accent' : ''}`}
onClick={() => isSelectable && handleSelectFlow(flow.id)}
>
<Checkbox
checked={selectedFlows.includes(flow.id)}
onCheckedChange={() =>
isSelectable && handleSelectFlow(flow.id)
}
className="absolute top-2 left-2"
onClick={(e) => e.stopPropagation()}
disabled={!isSelectable}
/>
<Workflow className="w-[40px] h-[40px] text-muted-foreground" />
<div className="w-full mt-2 text-center text-md px-2 text-ellipsis overflow-hidden">
{flow.version.displayName}
</div>
</div>
</TooltipTrigger>
{tooltip && (
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
)}
</Tooltip>
);
})}
</div>
{filteredFlows.length === 0 && (
<div className="text-center text-muted-foreground py-8">
{searchQuery ? t('No flows found') : t('No flows available')}
</div>
)}
</ScrollArea>
);
};

View File

@@ -0,0 +1,24 @@
import { t } from 'i18next';
import { PopulatedFlow, FlowVersionState } from '@activepieces/shared';
const isFlowSelectable = (flow: PopulatedFlow) => {
return (
flow.version.state === FlowVersionState.LOCKED && flow.status === 'ENABLED'
);
};
const getFlowTooltip = (flow: PopulatedFlow) => {
if (flow.version.state !== FlowVersionState.LOCKED) {
return t('Flow must be published to be selected');
}
if (flow.status !== 'ENABLED') {
return t('Flow must be enabled to be selected');
}
return '';
};
export const flowDialogUtils = {
isFlowSelectable,
getFlowTooltip,
};

View File

@@ -0,0 +1,147 @@
import { DialogTrigger } from '@radix-ui/react-dialog';
import { useQuery } from '@tanstack/react-query';
import { t } from 'i18next';
import { Search } from 'lucide-react';
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { flowsApi } from '@/features/flows/lib/flows-api';
import { authenticationSession } from '@/lib/authentication-session';
import {
PopulatedFlow,
AgentTool,
FlowTriggerType,
AgentToolType,
} from '@activepieces/shared';
import { FlowDialogContent } from './flow-dialog-content';
type AgentFlowToolDialogProps = {
children: React.ReactNode;
selectedFlows: string[];
open: boolean;
onToolsUpdate: (tools: AgentTool[]) => void;
onClose: () => void;
tools: AgentTool[];
};
export function AgentFlowToolDialog({
open,
selectedFlows: initialSelectedFlows,
onToolsUpdate,
children,
onClose,
tools,
}: AgentFlowToolDialogProps) {
const [searchQuery, setSearchQuery] = useState('');
const [selectedFlows, setSelectedFlows] =
useState<string[]>(initialSelectedFlows);
const projectId = authenticationSession.getProjectId();
const { data: flows } = useQuery({
queryKey: ['flows', projectId],
queryFn: async () => {
const flows = await flowsApi
.list({
cursor: undefined,
limit: 1000,
projectId: projectId!,
})
.then((response) => {
return response.data.filter(
(flow: PopulatedFlow) =>
flow.version.trigger.type === FlowTriggerType.PIECE &&
flow.version.trigger.settings.pieceName ===
'@activepieces/piece-mcp',
);
});
return flows;
},
});
const handleSave = () => {
const newTools = selectedFlows.map((flowId) => ({
type: AgentToolType.FLOW,
flowId: flowId,
toolName: flowId,
})) as AgentTool[];
const nonFlowTools: AgentTool[] = tools.filter(
(tool) => tool.type !== AgentToolType.FLOW,
);
const updatedTools = [...nonFlowTools, ...newTools];
onToolsUpdate(updatedTools);
handleClose();
};
const handleClose = () => {
setSearchQuery('');
onClose();
};
return (
<Dialog
open={open}
onOpenChange={(open) => {
if (!open) {
handleClose();
}
}}
>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="w-[90vw] max-w-[750px] h-[80vh] max-h-[800px] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{t('Add Flow Tools')}</DialogTitle>
<DialogDescription>
{t('Select flows to add as agent tools')}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 px-1">
<div className="relative mt-1">
<Search className="absolute left-2 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('Search flows')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
</div>
<ScrollArea className="grow overflow-y-auto px-1 pt-4">
<FlowDialogContent
flows={flows || []}
searchQuery={searchQuery}
selectedFlows={selectedFlows}
setSelectedFlows={setSelectedFlows}
/>
</ScrollArea>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="ghost">
{t('Close')}
</Button>
</DialogClose>
<Button type="button" onClick={handleSave}>
{t('Save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,83 @@
import { t } from 'i18next';
import { Workflow, Trash2, EllipsisVertical } from 'lucide-react';
import { useState } from 'react';
import { ConfirmationDeleteDialog } from '@/components/delete-dialog';
import { Card, CardContent } from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { AgentFlowTool as AgentFlowToolType } from '@activepieces/shared';
type AgentFlowToolProps = {
disabled?: boolean;
tool: AgentFlowToolType;
removeTool: (toolIds: string[]) => Promise<void>;
};
export const AgentFlowTool = ({
disabled,
tool,
removeTool,
}: AgentFlowToolProps) => {
const [open, setOpen] = useState(false);
const openFlow = () => {
window.open(`/flows/${tool.flowId}`, '_blank');
};
return (
<Card key={`flow-${tool.toolName}`}>
<CardContent className="flex items-center justify-between p-3 min-h-[48px]">
<div
className="flex items-center gap-3 min-w-0 group cursor-pointer"
onClick={openFlow}
>
<div className="h-8 w-8 rounded-md bg-muted flex items-center justify-center shrink-0">
<Workflow className="h-5 w-5 text-muted-foreground" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-medium truncate">
<span className="group-hover:underline">
{tool.flowId || t('Flow')}
</span>
</h3>
</div>
</div>
<div className="flex items-center gap-2">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
disabled={disabled}
className="rounded-full p-2 hover:bg-muted cursor-pointer"
asChild
>
<EllipsisVertical className="h-8 w-8" />
</DropdownMenuTrigger>
<DropdownMenuContent
noAnimationOnOut={true}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<ConfirmationDeleteDialog
title={`${t('Delete')} ${tool.flowId}`}
message={t('Are you sure you want to delete this tool?')}
mutationFn={async () => await removeTool([tool.toolName])}
entityName={t('Tool')}
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<div className="flex cursor-pointer flex-row gap-2 items-center">
<Trash2 className="h-4 w-4 text-destructive" />
<span className="text-destructive">{t('Delete')}</span>
</div>
</DropdownMenuItem>
</ConfirmationDeleteDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,108 @@
import { t } from 'i18next';
import { ControllerRenderProps } from 'react-hook-form';
import { ScrollArea } from '@/components/ui/scroll-area';
import { piecesHooks } from '@/features/pieces/lib/pieces-hooks';
import type { AgentTool } from '@activepieces/shared';
import { AgentToolType } from '@activepieces/shared';
import { AddAgentToolDropdown } from './add-agent-tool-dropwdown';
import { AgentFlowTool } from './flow-tool';
import { AgentPieceTool } from './piece-tool';
interface AgentToolsProps {
agentToolsField: ControllerRenderProps;
disabled?: boolean;
}
export const AgentTools = ({ disabled, agentToolsField }: AgentToolsProps) => {
const tools = Array.isArray(agentToolsField.value)
? (agentToolsField.value as AgentTool[])
: [];
const onToolsUpdate = (tools: AgentTool[]) => agentToolsField.onChange(tools);
const { pieces } = piecesHooks.usePieces({});
const removeTool = async (toolIds: string[]): Promise<void> => {
const newTools = tools.filter((tool) => !toolIds.includes(tool.toolName));
onToolsUpdate(newTools);
};
const piecesCount =
tools.filter((tool) => tool.type === AgentToolType.PIECE).length || 0;
const flowsCount =
tools.filter((tool) => tool.type === AgentToolType.FLOW).length || 0;
const totalToolsCount = piecesCount + flowsCount;
const hasTools = totalToolsCount > 0;
const pieceToToolMap = tools.reduce((acc, tool) => {
const key =
tool.type === AgentToolType.PIECE
? tool.pieceMetadata?.pieceName
: tool.flowId;
if (key) {
acc[key] = acc[key] || [];
acc[key].push(tool);
}
return acc;
}, {} as Record<string, AgentTool[]>);
return (
<div>
<div className="flex items-center justify-between">
<div className="space-y-0">
<h2 className="text-sm flex font-medium items-center gap-2">
{t('Tools')}
</h2>
</div>
<div className="flex gap-2">
<AddAgentToolDropdown
disabled={disabled}
onToolsUpdate={(tools) => {
onToolsUpdate?.(tools);
}}
tools={tools}
/>
</div>
</div>
<div className="mt-2">
{hasTools && (
<ScrollArea>
<div className="space-y-2">
{pieceToToolMap &&
Object.entries(pieceToToolMap).map(([toolKey, tools]) => {
if (tools[0].type === AgentToolType.PIECE) {
return (
<AgentPieceTool
disabled={disabled}
key={toolKey}
tools={tools}
pieces={pieces || []}
removeTool={removeTool}
/>
);
} else if (tools[0].type === AgentToolType.FLOW) {
return (
<AgentFlowTool
disabled={disabled}
key={toolKey}
tool={tools[0]}
removeTool={removeTool}
/>
);
}
return null;
})}
</div>
</ScrollArea>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,123 @@
import { t } from 'i18next';
import React, { useState } from 'react';
// eslint-disable-next-line import/no-restricted-paths
import { CreateOrEditConnectionDialog } from '@/app/connections/create-edit-connection-dialog';
import { SearchableSelect } from '@/components/custom/searchable-select';
import { Label } from '@/components/ui/label';
import { appConnectionsQueries } from '@/features/connections/lib/app-connections-hooks';
import { authenticationSession } from '@/lib/authentication-session';
import { PieceMetadataModelSummary } from '@activepieces/pieces-framework';
import { isNil } from '@activepieces/shared';
type ConnectionDropdownProps = {
piece: PieceMetadataModelSummary;
value: string | null;
onChange: (connectionExternalId: string | null) => void;
disabled?: boolean;
label?: string;
placeholder?: string;
showLabel?: boolean;
required?: boolean;
showError?: boolean;
};
export const ConnectionDropdown = React.memo(
({
piece,
value,
onChange,
disabled = false,
label = t('Connection'),
placeholder = t('Select a connection'),
showLabel = true,
required = false,
showError = false,
}: ConnectionDropdownProps) => {
const [connectionDialogOpen, setConnectionDialogOpen] = useState(false);
const {
data: connections,
isLoading: connectionsLoading,
refetch: refetchConnections,
isRefetching: isRefetchingConnections,
} = appConnectionsQueries.useAppConnections({
request: {
pieceName: piece?.name || '',
projectId: authenticationSession.getProjectId()!,
limit: 1000,
},
extraKeys: [piece?.name, authenticationSession.getProjectId()!],
staleTime: 0,
});
const pieceHasAuth = !isNil(piece?.auth);
const shouldShowError =
showError && required && pieceHasAuth && value === null;
if (!piece || !pieceHasAuth) {
return null;
}
const connectionOptions =
connections?.data?.map((connection) => ({
label: connection.displayName,
value: connection.externalId,
})) ?? [];
const connectionOptionsWithNewConnectionOption = [
{ label: t('+ New Connection'), value: '' },
...connectionOptions,
];
const handleChange = (selectedValue: string | null) => {
if (selectedValue) {
onChange(selectedValue as string);
} else {
setConnectionDialogOpen(true);
}
};
return (
<>
<CreateOrEditConnectionDialog
piece={piece}
open={connectionDialogOpen}
setOpen={(open, connection) => {
setConnectionDialogOpen(open);
if (connection) {
onChange(connection.externalId);
refetchConnections();
}
}}
reconnectConnection={null}
isGlobalConnection={false}
/>
<div className="space-y-2">
{showLabel && <Label>{label}</Label>}
<SearchableSelect
value={value ?? undefined}
onChange={handleChange}
options={connectionOptionsWithNewConnectionOption}
placeholder={placeholder}
loading={connectionsLoading || isRefetchingConnections}
disabled={disabled}
showDeselect={!required && !disabled && value !== null}
triggerClassName={
shouldShowError ? 'border-destructive' : undefined
}
/>
{shouldShowError && (
<p className="text-sm font-medium text-destructive wrap-break-word">
{t('Connection is required')}
</p>
)}
</div>
</>
);
},
);
ConnectionDropdown.displayName = 'ConnectionDropdown';

View File

@@ -0,0 +1,251 @@
import { DialogTrigger } from '@radix-ui/react-dialog';
import { t } from 'i18next';
import { ChevronLeft, Search } from 'lucide-react';
import React, { useState, useMemo } from 'react';
import { useDebounce } from 'use-debounce';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { stepsHooks } from '@/features/pieces/lib/steps-hooks';
import { PieceStepMetadataWithSuggestions } from '@/lib/types';
import {
isNil,
AgentTool,
AgentPieceTool,
AgentToolType,
} from '@activepieces/shared';
import { PieceActionsDialog } from './piece-actions';
import { PiecesContent } from './pieces-content';
type AgentPieceDialogProps = {
children: React.ReactNode;
tools: AgentTool[];
open: boolean;
onToolsUpdate: (tools: AgentTool[]) => void;
onClose: () => void;
};
export type ActionInfo = {
actionName: string;
actionDisplayName: string;
};
export function AgentPieceDialog({
tools,
open,
onToolsUpdate,
children,
onClose,
}: AgentPieceDialogProps) {
const [searchQuery, setSearchQuery] = useState('');
const [selectedConnectionExternalId, setSelectedConnectionExternalId] =
useState<string | null>(null);
console.log('@@@@@@@@@@@@@@@@@@@@@222222222');
console.log(selectedConnectionExternalId);
console.log('@@@@@@@@@@@@@@@@@@@@@222222222');
const [debouncedQuery] = useDebounce(searchQuery, 300);
const [showValidationErrors, setShowValidationErrors] = useState(false);
const { metadata, isLoading: isPiecesLoading } =
stepsHooks.useAllStepsMetadata({
searchQuery: debouncedQuery,
type: 'action',
});
const [selectedPiece, setSelectedPiece] =
useState<PieceStepMetadataWithSuggestions | null>(null);
const [selectedActions, setSelectedActions] = useState<ActionInfo[]>([]);
const pieceMetadata = useMemo(() => {
return (
metadata?.filter(
(m): m is PieceStepMetadataWithSuggestions =>
'suggestedActions' in m && 'suggestedTriggers' in m,
) ?? []
);
}, [metadata]);
const handlePieceSelect = (piece: PieceStepMetadataWithSuggestions) => {
const existingTools = tools?.filter(
(tool): tool is AgentPieceTool =>
tool.type === AgentToolType.PIECE &&
tool.pieceMetadata?.pieceName === piece.pieceName,
);
if (existingTools && existingTools.length > 0) {
setSelectedActions(
existingTools.map((tool) => ({
actionName: tool.pieceMetadata?.actionName,
actionDisplayName: tool.pieceMetadata?.actionName,
})),
);
setSelectedConnectionExternalId(
(existingTools[0].pieceMetadata?.predefinedInput?.auth as string) ||
null,
);
}
setSelectedPiece(piece);
};
const handleActionSelect = (action: ActionInfo) => {
setSelectedActions((prev) => {
const isAlreadySelected = prev.some(
(a) => a.actionName === action.actionName,
);
const newSelected = isAlreadySelected
? prev.filter((a) => a.actionName !== action.actionName)
: [...prev, action];
return newSelected;
});
};
const handleSelectAll = (checked: boolean) => {
if (checked && selectedPiece) {
setSelectedActions(
selectedPiece.suggestedActions?.map((a) => ({
actionName: a.name,
actionDisplayName: a.displayName,
})) ?? [],
);
} else {
setSelectedActions([]);
}
};
const handleSave = () => {
if (!isNil(selectedPiece?.auth) && isNil(selectedConnectionExternalId)) {
setShowValidationErrors(true);
return;
}
setShowValidationErrors(false);
if (!selectedPiece) return;
const newTools: AgentTool[] = selectedActions.map((action) => ({
type: AgentToolType.PIECE,
toolName: action.actionName,
pieceMetadata: {
pieceVersion: selectedPiece.pieceVersion,
pieceName: selectedPiece.pieceName,
actionName: action.actionName,
predefinedInput: {
auth: !isNil(selectedConnectionExternalId)
? `{{connections['${selectedConnectionExternalId}']}}`
: undefined,
},
},
}));
const oldTools = tools;
onToolsUpdate([...oldTools, ...newTools]);
handleClose();
};
const handleClose = () => {
setSelectedPiece(null);
setSearchQuery('');
setSelectedActions([]);
setShowValidationErrors(false);
onClose();
};
return (
<Dialog
open={open}
onOpenChange={(open) => {
if (!open) {
handleClose();
}
}}
>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="w-[90vw] max-w-[750px] h-[80vh] max-h-[800px] flex flex-col overflow-hidden">
<DialogHeader className={`${selectedPiece ? 'gap-2' : 'gap-0'}`}>
<DialogTitle>
{selectedPiece ? (
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => {
setSelectedPiece(null);
setSearchQuery('');
}}
>
<ChevronLeft className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('Back')}</TooltipContent>
</Tooltip>
{selectedPiece.displayName}
</div>
) : (
t('Add Tool')
)}
</DialogTitle>
</DialogHeader>
{selectedPiece ? (
<PieceActionsDialog
piece={selectedPiece}
selectedActions={selectedActions}
onSelectAction={handleActionSelect}
onSelectAll={handleSelectAll}
selectedConnectionExternalId={selectedConnectionExternalId}
setSelectedConnectionExternalId={setSelectedConnectionExternalId}
showValidationErrors={showValidationErrors}
/>
) : (
<>
<div className="flex flex-col gap-4 px-1">
<div className="relative mt-1">
<Search className="absolute left-2 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('Search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
</div>
<ScrollArea className="grow overflow-y-auto px-1 pt-4">
<PiecesContent
isPiecesLoading={isPiecesLoading}
pieceMetadata={pieceMetadata}
onPieceSelect={handlePieceSelect}
/>
</ScrollArea>
</>
)}
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="ghost">
{t('Close')}
</Button>
</DialogClose>
<Button loading={false} type="button" onClick={handleSave}>
{t('Save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,129 @@
import { t } from 'i18next';
import React from 'react';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
import { piecesHooks } from '@/features/pieces/lib/pieces-hooks';
import { PieceStepMetadataWithSuggestions } from '@/lib/types';
import { isNil } from '@activepieces/shared';
import { ConnectionDropdown } from './connection-dropdown';
import { ActionInfo } from '.';
interface PieceActionsDialogProps {
piece: PieceStepMetadataWithSuggestions;
selectedActions: ActionInfo[];
onSelectAction: (action: ActionInfo) => void;
onSelectAll: (checked: boolean) => void;
selectedConnectionExternalId: string | null;
setSelectedConnectionExternalId: (
connectionExternalId: string | null,
) => void;
showValidationErrors?: boolean;
}
export const PieceActionsDialog: React.FC<PieceActionsDialogProps> = ({
piece,
selectedActions,
onSelectAction,
onSelectAll,
selectedConnectionExternalId,
setSelectedConnectionExternalId,
showValidationErrors = false,
}) => {
const { pieces } = piecesHooks.usePieces({});
const selectedPiece = pieces?.find((p) => p.name === piece.pieceName);
const allSelected =
piece.suggestedActions &&
piece.suggestedActions.length > 0 &&
piece.suggestedActions.every((a) =>
selectedActions.some((action) => action.actionName === a.name),
);
const someSelected = selectedActions.length > 0 && !allSelected;
const pieceHasAuth = selectedPiece && !isNil(selectedPiece.auth);
return (
<>
{pieceHasAuth && (
<div className="px-3 mb-4">
<div className="space-y-3">
<h4 className="text-sm font-semibold">{t('Connection')}</h4>
<ConnectionDropdown
piece={selectedPiece}
value={selectedConnectionExternalId}
onChange={setSelectedConnectionExternalId}
placeholder={t('Select a connection')}
showLabel={false}
required={true}
showError={showValidationErrors}
/>
</div>
</div>
)}
<div className="flex items-center mb-2 gap-4 px-3">
<Checkbox
checked={allSelected ? true : someSelected ? 'indeterminate' : false}
onCheckedChange={(checked) => onSelectAll(!!checked)}
/>
<span className="text-sm font-bold select-none">{t('Select all')}</span>
</div>
<ScrollArea className="grow overflow-y-auto rounded-md">
<div className="flex flex-col gap-2">
{piece.suggestedActions &&
piece.suggestedActions.map((action) => (
<div
key={action.name}
className="flex items-start gap-4 rounded-md px-3 py-2 hover:bg-accent cursor-pointer"
onClick={() =>
onSelectAction({
actionName: action.name,
actionDisplayName: action.displayName,
})
}
>
<Checkbox
checked={selectedActions.some(
(selectedAction) =>
selectedAction.actionName === action.name,
)}
onCheckedChange={() =>
onSelectAction({
actionName: action.name,
actionDisplayName: action.displayName,
})
}
className="mt-1"
onClick={(e) => e.stopPropagation()}
/>
<div className="flex gap-2">
<img src={piece.logoUrl} alt="" className="w-5 h-5 mt-1" />
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">
{action.displayName}
</span>
</div>
{action.description && (
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{action.description}
</div>
)}
</div>
</div>
</div>
))}
{piece.suggestedActions && piece.suggestedActions.length === 0 && (
<div className="text-center text-muted-foreground py-8">
{t('No actions available')}
</div>
)}
</div>
</ScrollArea>
</>
);
};

View File

@@ -0,0 +1,52 @@
import { t } from 'i18next';
import React from 'react';
import { LoadingSpinner } from '@/components/ui/spinner';
import { PieceStepMetadataWithSuggestions } from '@/lib/types';
interface PiecesContentProps {
isPiecesLoading: boolean;
pieceMetadata: PieceStepMetadataWithSuggestions[];
onPieceSelect: (piece: PieceStepMetadataWithSuggestions) => void;
}
export const PiecesContent: React.FC<PiecesContentProps> = ({
isPiecesLoading,
pieceMetadata,
onPieceSelect,
}) => {
if (isPiecesLoading) {
return (
<div className="flex items-center justify-center w-full h-full">
<LoadingSpinner />
</div>
);
}
if (!isPiecesLoading && pieceMetadata && pieceMetadata.length === 0) {
return (
<div className="text-center h-full flex items-center justify-center">
{t('No pieces found')}
</div>
);
}
return (
<div className="grid grid-cols-4 gap-4">
{pieceMetadata.map((piece, index) => (
<div
key={index}
onClick={() => onPieceSelect(piece)}
className="border p-2 h-[150px] w-[150px] flex flex-col items-center justify-center hover:bg-accent hover:text-accent-foreground cursor-pointer rounded-lg"
>
<img
className="w-[40px] h-[40px]"
src={piece.logoUrl}
alt={piece.displayName}
/>
<div className="mt-2 text-center text-md">{piece.displayName}</div>
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,130 @@
import { t } from 'i18next';
import { EllipsisVertical, Puzzle, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { ConfirmationDeleteDialog } from '@/components/delete-dialog';
import { Card, CardContent } from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { PieceMetadataModelSummary } from '@activepieces/pieces-framework';
import { AgentTool, AgentToolType } from '@activepieces/shared';
import { agentConfigUtils } from './config-utils';
type AgentPieceToolProps = {
disabled?: boolean;
tools: AgentTool[];
pieces: PieceMetadataModelSummary[];
removeTool: (toolIds: string[]) => Promise<void>;
};
type PieceInfo = {
displayName: string;
logoUrl?: string;
};
export const AgentPieceTool = ({
disabled,
tools,
pieces,
removeTool,
}: AgentPieceToolProps) => {
const [open, setOpen] = useState(false);
const getPieceInfo = (tool: AgentTool) => {
if (tool.type !== AgentToolType.PIECE || !tool.pieceMetadata) {
return { displayName: 'Unknown', logoUrl: undefined };
}
const pieceMetadata = pieces?.find(
(p) => p.name === tool.pieceMetadata?.pieceName,
);
return {
displayName: pieceMetadata?.displayName || tool.pieceMetadata.pieceName,
logoUrl: pieceMetadata?.logoUrl,
};
};
const pieceInfoMap: Record<string, PieceInfo> = {};
tools.forEach((tool: AgentTool) => {
pieceInfoMap[tool.toolName] = getPieceInfo(tool);
});
const actionDisplayNames = tools
.map((tool) => {
if (tool.type === AgentToolType.PIECE) {
return tool.pieceMetadata?.actionName;
}
return undefined;
})
.filter((name) => name !== undefined);
const toolName =
tools[0].type === AgentToolType.PIECE
? pieceInfoMap[tools[0].toolName]?.displayName
: undefined;
return (
<Card key={`piece-${toolName}`}>
<CardContent className="flex items-center justify-between p-3 min-h-[48px]">
<div className="flex items-center gap-3 min-w-0">
<div className="h-8 w-8 rounded-md bg-muted flex items-center justify-center shrink-0">
{pieceInfoMap[tools[0].toolName]?.logoUrl ? (
<img
src={pieceInfoMap[tools[0].toolName].logoUrl}
alt={pieceInfoMap[tools[0].toolName].displayName}
className="h-5 w-5 object-contain"
/>
) : (
<Puzzle className="h-5 w-5 text-muted-foreground" />
)}
</div>
<div className="min-w-0">
<h3 className="text-sm font-medium truncate">
{`${pieceInfoMap[tools[0].toolName]?.displayName}`}
</h3>
<span className="text-xs text-muted-foreground">
{agentConfigUtils.formatNames(actionDisplayNames)}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
disabled={disabled}
className="rounded-full p-2 hover:bg-muted cursor-pointer"
asChild
>
<EllipsisVertical className="h-8 w-8" />
</DropdownMenuTrigger>
<DropdownMenuContent
noAnimationOnOut={true}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<ConfirmationDeleteDialog
title={`${t('Delete')} ${toolName}`}
message={t('Are you sure you want to delete this tool?')}
mutationFn={async () =>
await removeTool(tools.map((tool) => tool.toolName))
}
entityName={t('Tool')}
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<div className="flex cursor-pointer flex-row gap-2 items-center">
<Trash2 className="h-4 w-4 text-destructive" />
<span className="text-destructive">{t('Delete')}</span>
</div>
</DropdownMenuItem>
</ConfirmationDeleteDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,142 @@
import { t } from 'i18next';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { AgentOutputFieldType } from '@activepieces/shared';
import { FieldTypeIcon } from './field-type-icon';
interface AddFieldPopoverProps {
onAddField: (
type: AgentOutputFieldType,
name: string,
description: string,
) => void;
disabled: boolean;
}
export const AddFieldPopover = ({
onAddField,
disabled,
}: AddFieldPopoverProps) => {
const [fieldType, setFieldType] = useState<AgentOutputFieldType | undefined>(
undefined,
);
const [fieldName, setFieldName] = useState('');
const [fieldDescription, setFieldDescription] = useState('');
const [open, setOpen] = useState(false);
const handleAdd = () => {
if (fieldType && fieldName.trim() && fieldDescription.trim()) {
onAddField(fieldType, fieldName.trim(), fieldDescription.trim());
setFieldType(undefined);
setFieldName('');
setFieldDescription('');
setOpen(false);
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full" disabled={disabled}>
<Plus className="h-4 w-4 mr-2" />
{t('Add Field')}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-80"
side="bottom"
align="center"
sideOffset={10}
>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Field Type</label>
<Select
value={fieldType}
onValueChange={(value) =>
setFieldType(value as AgentOutputFieldType)
}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value={AgentOutputFieldType.TEXT}>
<div className="flex items-center">
<FieldTypeIcon
type={AgentOutputFieldType.TEXT}
className="h-4 w-4 mr-2 text-muted-foreground"
/>
<span>Text</span>
</div>
</SelectItem>
<SelectItem value={AgentOutputFieldType.NUMBER}>
<div className="flex items-center">
<FieldTypeIcon
type={AgentOutputFieldType.NUMBER}
className="h-4 w-4 mr-2 text-muted-foreground"
/>
<span>Number</span>
</div>
</SelectItem>
<SelectItem value={AgentOutputFieldType.BOOLEAN}>
<div className="flex items-center">
<FieldTypeIcon
type={AgentOutputFieldType.BOOLEAN}
className="h-4 w-4 mr-2 text-muted-foreground"
/>
<span>Yes/No</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Field Name</label>
<Input
id="field-name"
placeholder="Enter field name"
value={fieldName}
onChange={(e) => setFieldName(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Field Description</label>
<Input
id="field-description"
placeholder="Enter field description"
value={fieldDescription}
onChange={(e) => setFieldDescription(e.target.value)}
/>
</div>
<Button
className="w-full"
onClick={handleAdd}
variant={'default'}
disabled={
!fieldType || !fieldName.trim() || !fieldDescription.trim()
}
>
Add
</Button>
</div>
</PopoverContent>
</Popover>
);
};

View File

@@ -0,0 +1,28 @@
import {
Type as TextIcon,
Hash as NumberIcon,
CheckSquare as BooleanIcon,
} from 'lucide-react';
import { AgentOutputFieldType } from '@activepieces/shared';
interface FieldTypeIconProps {
type: AgentOutputFieldType;
className?: string;
}
export const FieldTypeIcon = ({
type,
className = 'h-4 w-4',
}: FieldTypeIconProps) => {
switch (type) {
case AgentOutputFieldType.TEXT:
return <TextIcon className={className} />;
case AgentOutputFieldType.NUMBER:
return <NumberIcon className={className} />;
case AgentOutputFieldType.BOOLEAN:
return <BooleanIcon className={className} />;
default:
return null;
}
};

View File

@@ -0,0 +1,93 @@
import { t } from 'i18next';
import { X } from 'lucide-react';
import { ControllerRenderProps } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { AgentOutputFieldType, AgentOutputField } from '@activepieces/shared';
import { AddFieldPopover } from './add-field-popover';
import { FieldTypeIcon } from './field-type-icon';
export const AgentStructuredOutput = ({
structuredOutputField,
disabled,
}: {
structuredOutputField: ControllerRenderProps;
disabled: boolean;
}) => {
const value = structuredOutputField.value;
const outputFields = Array.isArray(value)
? (value as AgentOutputField[])
: [];
const handleAddField = (
type: AgentOutputFieldType,
name: string,
description: string,
) => {
const newField = { displayName: name, description, type };
structuredOutputField.onChange([...(outputFields ?? []), newField]);
};
const handleRemoveField = (displayName: string) => {
const newFields = outputFields.filter((f) => f.displayName !== displayName);
structuredOutputField.onChange(newFields);
};
return (
<div>
<div className="flex items-center justify-between mb-2">
<h2 className="text-sm font-medium">{t('Structured Output')}</h2>
</div>
<div className="flex flex-col gap-2 mt-4">
{outputFields.length > 0 ? (
<Card>
<CardContent className="px-2 py-2">
<div className="flex flex-col gap-3">
{outputFields.map((field, idx) => (
<div
key={field.displayName + field.type + idx}
className="flex items-center justify-between"
>
<div className="grid grid-cols-12 gap-2 w-full items-center">
<div className="col-span-1 flex items-center justify-center h-full">
<FieldTypeIcon type={field.type} className="h-4 w-4" />
</div>
<div className="col-span-10 flex flex-col justify-center">
<span className="font-medium text-sm">
{field.displayName}
</span>
{field.description && (
<span className="text-xs text-muted-foreground">
{field.description}
</span>
)}
</div>
<div className="col-span-1 flex items-center justify-end">
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveField(field.displayName)}
disabled={disabled}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
) : (
<div className="text-muted-foreground text-sm">
{t('No structured output fields yet.')}
</div>
)}
<AddFieldPopover disabled={disabled} onAddField={handleAddField} />
</div>
</div>
);
};