diff --git a/keep-ui/app/(health)/health/check.tsx b/keep-ui/app/(health)/health/check.tsx index e7cd942b9b..b979a45db4 100644 --- a/keep-ui/app/(health)/health/check.tsx +++ b/keep-ui/app/(health)/health/check.tsx @@ -2,7 +2,7 @@ import ProvidersTiles from "@/app/(keep)/providers/providers-tiles"; import React, { useEffect, useState } from "react"; -import { defaultProvider, Provider } from "@/app/(keep)/providers/providers"; +import { defaultProvider, Provider } from "@/shared/api/providers"; import { useProvidersWithHealthCheck } from "@/utils/hooks/useProviders"; import Loading from "@/app/(keep)/loading"; import HealthPageBanner from "@/components/banners/health-page-banner"; @@ -43,7 +43,7 @@ const useFetchProviders = () => { }; }; -export default function ProviderHealthPage () { +export default function ProviderHealthPage() { const { providers, isLocalhost, mutate } = useFetchProviders(); if (!providers || providers.length <= 0) { diff --git a/keep-ui/app/(keep)/alerts/alert-assign-ticket-modal.tsx b/keep-ui/app/(keep)/alerts/alert-assign-ticket-modal.tsx index 37d81f024e..591410ac8f 100644 --- a/keep-ui/app/(keep)/alerts/alert-assign-ticket-modal.tsx +++ b/keep-ui/app/(keep)/alerts/alert-assign-ticket-modal.tsx @@ -3,7 +3,7 @@ import Select, { components } from "react-select"; import { Button, TextInput, Text, Icon } from "@tremor/react"; import { PlusIcon } from "@heroicons/react/20/solid"; import { useForm, Controller, SubmitHandler } from "react-hook-form"; -import { Providers } from "../providers/providers"; +import { Providers } from "@/shared/api/providers"; import { AlertDto } from "@/entities/alerts/model"; import Modal from "@/components/ui/Modal"; import { useApi } from "@/shared/lib/hooks/useApi"; diff --git a/keep-ui/app/(keep)/alerts/alert-menu.tsx b/keep-ui/app/(keep)/alerts/alert-menu.tsx index c8873d6756..430913db1d 100644 --- a/keep-ui/app/(keep)/alerts/alert-menu.tsx +++ b/keep-ui/app/(keep)/alerts/alert-menu.tsx @@ -11,7 +11,7 @@ import { } from "@heroicons/react/24/outline"; import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid"; import { IoNotificationsOffOutline } from "react-icons/io5"; -import { ProviderMethod } from "@/app/(keep)/providers/providers"; +import { ProviderMethod } from "@/shared/api/providers"; import { AlertDto } from "@/entities/alerts/model"; import { useProviders } from "utils/hooks/useProviders"; import { useAlerts } from "utils/hooks/useAlerts"; diff --git a/keep-ui/app/(keep)/alerts/alert-method-modal.tsx b/keep-ui/app/(keep)/alerts/alert-method-modal.tsx index 907a4300b0..2708a638a2 100644 --- a/keep-ui/app/(keep)/alerts/alert-method-modal.tsx +++ b/keep-ui/app/(keep)/alerts/alert-method-modal.tsx @@ -4,7 +4,7 @@ import { Provider, ProviderMethod, ProviderMethodParam, -} from "@/app/(keep)/providers/providers"; +} from "@/shared/api/providers"; import { toast } from "react-toastify"; import Loading from "@/app/(keep)/loading"; import { diff --git a/keep-ui/app/(keep)/alerts/alert-quality-table.tsx b/keep-ui/app/(keep)/alerts/alert-quality-table.tsx index 92ea1b400b..fe2d824e6f 100644 --- a/keep-ui/app/(keep)/alerts/alert-quality-table.tsx +++ b/keep-ui/app/(keep)/alerts/alert-quality-table.tsx @@ -10,7 +10,7 @@ import React, { import { GenericTable } from "@/components/table/GenericTable"; import { useAlertQualityMetrics } from "utils/hooks/useAlertQuality"; import { useProviders } from "utils/hooks/useProviders"; -import { Provider, ProvidersResponse } from "@/app/(keep)/providers/providers"; +import { Provider, ProvidersResponse } from "@/shared/api/providers"; import { TabGroup, TabList, Tab, Callout } from "@tremor/react"; import { GenericFilters } from "@/components/filters/GenericFilters"; import { useSearchParams } from "next/navigation"; diff --git a/keep-ui/app/(keep)/deduplication/DeduplicationSidebar.tsx b/keep-ui/app/(keep)/deduplication/DeduplicationSidebar.tsx index 1769f0abd5..f520740466 100644 --- a/keep-ui/app/(keep)/deduplication/DeduplicationSidebar.tsx +++ b/keep-ui/app/(keep)/deduplication/DeduplicationSidebar.tsx @@ -23,7 +23,7 @@ import { import { KeyedMutator } from "swr"; import { useApi } from "@/shared/lib/hooks/useApi"; import { KeepApiError } from "@/shared/api"; -import { Providers } from "@/app/(keep)/providers/providers"; +import { Providers } from "@/shared/api/providers"; import SidePanel from "@/components/SidePanel"; import { useConfig } from "@/utils/hooks/useConfig"; diff --git a/keep-ui/app/(keep)/providers/components/providers-categories/providers-categories.tsx b/keep-ui/app/(keep)/providers/components/providers-categories/providers-categories.tsx index fcaccf8796..bdea367dd9 100644 --- a/keep-ui/app/(keep)/providers/components/providers-categories/providers-categories.tsx +++ b/keep-ui/app/(keep)/providers/components/providers-categories/providers-categories.tsx @@ -1,4 +1,4 @@ -import { TProviderCategory } from "@/app/(keep)/providers/providers"; +import { TProviderCategory } from "@/shared/api/providers"; import { Badge } from "@tremor/react"; import { useFilterContext } from "../../filter-context"; diff --git a/keep-ui/app/(keep)/providers/components/providers-filter-by-label/providers-filter-by-label.tsx b/keep-ui/app/(keep)/providers/components/providers-filter-by-label/providers-filter-by-label.tsx index 669c966f19..7d9cf81d0e 100644 --- a/keep-ui/app/(keep)/providers/components/providers-filter-by-label/providers-filter-by-label.tsx +++ b/keep-ui/app/(keep)/providers/components/providers-filter-by-label/providers-filter-by-label.tsx @@ -2,7 +2,7 @@ import type { FC } from "react"; import { MultiSelect, MultiSelectItem } from "@tremor/react"; import { TagIcon } from "@heroicons/react/20/solid"; import { useFilterContext, PROVIDER_LABELS } from "../../filter-context"; -import type { TProviderLabels } from "../../providers"; +import type { TProviderLabels } from "@/shared/api/providers"; export const ProvidersFilterByLabel: FC = (props) => { const { setProvidersSelectedTags, providersSelectedTags } = diff --git a/keep-ui/app/(keep)/providers/filter-context/constants.ts b/keep-ui/app/(keep)/providers/filter-context/constants.ts index c8f4b5203a..3bcc1ea5c7 100644 --- a/keep-ui/app/(keep)/providers/filter-context/constants.ts +++ b/keep-ui/app/(keep)/providers/filter-context/constants.ts @@ -1,4 +1,4 @@ -import { TProviderLabels } from "../providers"; +import { TProviderLabels } from "@/shared/api/providers"; export const PROVIDER_LABELS: Record = { alert: "Alert", diff --git a/keep-ui/app/(keep)/providers/filter-context/filter-context.tsx b/keep-ui/app/(keep)/providers/filter-context/filter-context.tsx index e61f91dbad..c84c741d9c 100644 --- a/keep-ui/app/(keep)/providers/filter-context/filter-context.tsx +++ b/keep-ui/app/(keep)/providers/filter-context/filter-context.tsx @@ -2,7 +2,10 @@ import { createContext, useState, FC, PropsWithChildren } from "react"; import { IFilterContext } from "./types"; import { useSearchParams } from "next/navigation"; import { PROVIDER_LABELS_KEYS } from "./constants"; -import type { TProviderCategory, TProviderLabels } from "../providers"; +import type { + TProviderCategory, + TProviderLabels, +} from "@/shared/api/providers"; export const FilterContext = createContext(null); diff --git a/keep-ui/app/(keep)/providers/filter-context/types.ts b/keep-ui/app/(keep)/providers/filter-context/types.ts index aeb6f0d5c2..4b7e3b32ba 100644 --- a/keep-ui/app/(keep)/providers/filter-context/types.ts +++ b/keep-ui/app/(keep)/providers/filter-context/types.ts @@ -1,5 +1,5 @@ import { Dispatch, SetStateAction } from "react"; -import { TProviderCategory, TProviderLabels } from "../providers"; +import { TProviderCategory, TProviderLabels } from "@/shared/api/providers"; export interface IFilterContext { providersSearchString: string; diff --git a/keep-ui/app/(keep)/providers/form-fields.tsx b/keep-ui/app/(keep)/providers/form-fields.tsx index 6ab5090b7b..77daa3175f 100644 --- a/keep-ui/app/(keep)/providers/form-fields.tsx +++ b/keep-ui/app/(keep)/providers/form-fields.tsx @@ -6,7 +6,7 @@ import { ProviderFormKVData, ProviderFormValue, ProviderInputErrors, -} from "./providers"; +} from "@/shared/api/providers"; import { Title, Text, diff --git a/keep-ui/app/(keep)/providers/form-validation.ts b/keep-ui/app/(keep)/providers/form-validation.ts index cf1bf5e1b9..9ca2b691b9 100644 --- a/keep-ui/app/(keep)/providers/form-validation.ts +++ b/keep-ui/app/(keep)/providers/form-validation.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { Provider } from "./providers"; +import { Provider } from "@/shared/api/providers"; type URLOptions = { protocols: string[]; diff --git a/keep-ui/app/(keep)/providers/page.client.tsx b/keep-ui/app/(keep)/providers/page.client.tsx index 18c397bc1b..93309a7ad0 100644 --- a/keep-ui/app/(keep)/providers/page.client.tsx +++ b/keep-ui/app/(keep)/providers/page.client.tsx @@ -1,5 +1,5 @@ "use client"; -import { defaultProvider, Provider } from "./providers"; +import { defaultProvider, Provider } from "@/shared/api/providers"; import ProvidersTiles from "./providers-tiles"; import React, { useState, useEffect } from "react"; import Loading from "@/app/(keep)/loading"; diff --git a/keep-ui/app/(keep)/providers/provider-form-scopes.tsx b/keep-ui/app/(keep)/providers/provider-form-scopes.tsx index d5b60130ee..a2db9bf3ee 100644 --- a/keep-ui/app/(keep)/providers/provider-form-scopes.tsx +++ b/keep-ui/app/(keep)/providers/provider-form-scopes.tsx @@ -12,7 +12,7 @@ import { Icon, Button, } from "@tremor/react"; -import { Provider } from "./providers"; +import { Provider } from "@/shared/api/providers"; import { ArrowPathIcon, QuestionMarkCircleIcon, diff --git a/keep-ui/app/(keep)/providers/provider-form.tsx b/keep-ui/app/(keep)/providers/provider-form.tsx index b44c739d91..7345209ebc 100644 --- a/keep-ui/app/(keep)/providers/provider-form.tsx +++ b/keep-ui/app/(keep)/providers/provider-form.tsx @@ -5,7 +5,7 @@ import { ProviderFormData, ProviderFormValue, ProviderInputErrors, -} from "./providers"; +} from "@/shared/api/providers"; import Image from "next/image"; import { Title, @@ -451,8 +451,6 @@ const ProviderForm = ({ ?.filter((scope) => scope.mandatory_for_webhook) .every((scope) => providerValidatedScopes[scope.name] === true); - const [activeTab, setActiveTab] = useState(0); - const renderFormContent = () => ( <>
diff --git a/keep-ui/app/(keep)/providers/provider-row.tsx b/keep-ui/app/(keep)/providers/provider-row.tsx index d5298dcdf6..90b923552f 100644 --- a/keep-ui/app/(keep)/providers/provider-row.tsx +++ b/keep-ui/app/(keep)/providers/provider-row.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect } from "react"; import { TableRow, TableCell } from "@tremor/react"; import Image from "next/image"; import "./provider-row.css"; -import { Provider } from "./providers"; +import { Provider } from "@/shared/api/providers"; const ProviderRow = ({ provider }: { provider: Provider }) => { return ( diff --git a/keep-ui/app/(keep)/providers/provider-semi-automated.tsx b/keep-ui/app/(keep)/providers/provider-semi-automated.tsx index aadb1a0f87..181b50774e 100644 --- a/keep-ui/app/(keep)/providers/provider-semi-automated.tsx +++ b/keep-ui/app/(keep)/providers/provider-semi-automated.tsx @@ -1,5 +1,5 @@ import useSWR from "swr"; -import { Provider } from "./providers"; +import { Provider } from "@/shared/api/providers"; import { Subtitle, Title, Text, Icon } from "@tremor/react"; import { CopyBlock, a11yLight, railscast } from "react-code-blocks"; import Image from "next/image"; diff --git a/keep-ui/app/(keep)/providers/provider-tile.tsx b/keep-ui/app/(keep)/providers/provider-tile.tsx index cb349735b2..fbfdfacacb 100644 --- a/keep-ui/app/(keep)/providers/provider-tile.tsx +++ b/keep-ui/app/(keep)/providers/provider-tile.tsx @@ -6,7 +6,7 @@ import { Text, Title, } from "@tremor/react"; -import { Provider, TProviderLabels } from "./providers"; +import { Provider, TProviderLabels } from "@/shared/api/providers"; import { BellAlertIcon, ChatBubbleBottomCenterIcon, @@ -27,6 +27,7 @@ interface Props { onClick: () => void; } +// TODO: move to a separate file const WebhookIcon = (props: any) => ( [key, z.string()])) - ); - - const propertiesSchema = z.object({ - with: withSchema, - config: z.string().optional(), - if: z.string().optional(), - vars: z.record(z.string(), z.string()).optional(), - }); - - const response = await openai.beta.chat.completions.parse({ - response_format: zodResponseFormat(propertiesSchema, "step_properties"), - model: "gpt-4o", - messages: [ - { - role: "system", - content: GENERAL_INSTRUCTIONS, - }, - { - role: "user", - content: `Generate properties for the step: ${name} with type: ${stepType}. Return a JSON object that represents the full properties of the step definition to achieve the following: ${aim}. - The "with" property can only use these keys: ${combinedParams.map((key) => `"${key}"`).join(", ") ?? "none"}. - You can optionally include "config", "if", and "vars" properties.`, - }, - ], - }); - return response.choices[0].message.parsed; -} diff --git a/keep-ui/app/(keep)/workflows/preview/[workflowId]/page.tsx b/keep-ui/app/(keep)/workflows/preview/[workflowId]/page.tsx index fd7d49fecc..c1105f63b4 100644 --- a/keep-ui/app/(keep)/workflows/preview/[workflowId]/page.tsx +++ b/keep-ui/app/(keep)/workflows/preview/[workflowId]/page.tsx @@ -40,6 +40,7 @@ export default function PageWithId({ {workflowPreviewData && workflowPreviewData.name === key && ( )} diff --git a/keep-ui/entities/alerts/model/index.ts b/keep-ui/entities/alerts/model/index.ts index 609c2bb2c6..c98d7ed49c 100644 --- a/keep-ui/entities/alerts/model/index.ts +++ b/keep-ui/entities/alerts/model/index.ts @@ -1 +1,2 @@ export * from "./models"; +export { useAvailableAlertFields } from "./useAvailableAlertFields"; diff --git a/keep-ui/entities/alerts/model/useAvailableAlertFields.ts b/keep-ui/entities/alerts/model/useAvailableAlertFields.ts new file mode 100644 index 0000000000..48115451bd --- /dev/null +++ b/keep-ui/entities/alerts/model/useAvailableAlertFields.ts @@ -0,0 +1,58 @@ +import { useSearchAlerts } from "@/utils/hooks/useSearchAlerts"; +import { useMemo } from "react"; + +const DAY = 3600 * 24; + +export const useAvailableAlertFields = ({ + timeframe = DAY, +}: { + timeframe?: number; +} = {}) => { + const defaultQuery = { + combinator: "or", + rules: [ + { + combinator: "and", + rules: [{ field: "source", operator: "=", value: "" }], + }, + { + combinator: "and", + rules: [{ field: "source", operator: "=", value: "" }], + }, + ], + }; + const { data: alertsFound = [], isLoading } = useSearchAlerts({ + query: defaultQuery, + timeframe, + }); + +interface AlertType { + [key: string]: unknown; +} + +const MAX_DEPTH = 10; + +const fields = useMemo(() => { + const getNestedKeys = (obj: AlertType, prefix = "", depth = 0): string[] => { + if (depth > MAX_DEPTH) return []; + return Object.entries(obj).reduce((acc, [key, value]) => { + const newKey = prefix ? `${prefix}.${key}` : key; + if (value && typeof value === "object" && !Array.isArray(value)) { + const nestedKeys = getNestedKeys(value as AlertType, newKey, depth + 1); + acc.push(...nestedKeys); + return acc; + } + acc.push(newKey); + return acc; + }, []); + }; + const uniqueFields = new Set(); + alertsFound.forEach((alert: AlertType) => { + const alertKeys = getNestedKeys(alert); + alertKeys.forEach(key => uniqueFields.add(key)); + }); + return Array.from(uniqueFields); +}, [alertsFound]); + + return { fields, isLoading }; +}; diff --git a/keep-ui/entities/workflows/lib/parser.ts b/keep-ui/entities/workflows/lib/parser.ts index eaf0de3884..71cfc09d66 100644 --- a/keep-ui/entities/workflows/lib/parser.ts +++ b/keep-ui/entities/workflows/lib/parser.ts @@ -8,7 +8,7 @@ import { V2StepForeach, V2StepStep, } from "@/entities/workflows"; -import { Provider } from "@/app/(keep)/providers/providers"; +import { Provider } from "@/shared/api/providers"; import { v4 as uuidv4 } from "uuid"; import { JSON_SCHEMA, load } from "js-yaml"; import { diff --git a/keep-ui/entities/workflows/model/__tests__/workflow-store.test.tsx b/keep-ui/entities/workflows/model/__tests__/workflow-store.test.tsx index ec4203d557..c5faf51459 100644 --- a/keep-ui/entities/workflows/model/__tests__/workflow-store.test.tsx +++ b/keep-ui/entities/workflows/model/__tests__/workflow-store.test.tsx @@ -6,7 +6,7 @@ import { } from "@/entities/workflows"; import { v4 as uuidv4 } from "uuid"; import { Connection } from "@xyflow/react"; -import { getToolboxConfiguration } from "@/features/workflows/builder/lib/utils"; +import { Provider } from "@/shared/api/providers"; // Mock uuid to return predictable values jest.mock("uuid", () => ({ @@ -21,7 +21,38 @@ jest.mock("../../../../shared/ui/utils/showErrorToast", () => ({ showErrorToast: () => showErrorToastMock(), })); -const mockToolboxConfiguration = getToolboxConfiguration([]); +const mockProvider: Provider = { + id: "mock-provider", + type: "mock", + config: {}, + installed: true, + linked: true, + last_alert_received: "", + details: { + authentication: {}, + }, + display_name: "Mock Provider", + can_query: true, + can_notify: true, + validatedScopes: {}, + tags: [], + pulling_available: true, + pulling_enabled: true, + health: true, + categories: [], + coming_soon: false, +}; + +const notInstalledProvider: Provider = { + ...mockProvider, + type: "notinstalled", + installed: false, +}; + +const mockProvidersConfiguration = { + providers: [mockProvider, notInstalledProvider], + installedProviders: [mockProvider], +}; describe("useWorkflowStore", () => { beforeEach(() => { @@ -66,7 +97,7 @@ describe("useWorkflowStore", () => { // Add a trigger node act(() => { - result.current.addNodeBetween( + result.current.addNodeBetweenSafe( "edge-1", { id: "interval", @@ -101,7 +132,7 @@ describe("useWorkflowStore", () => { }, isValid: true, }); - result.current.initializeWorkflow(null, mockToolboxConfiguration); + result.current.initializeWorkflow(null, mockProvidersConfiguration); }); expect(result.current.nodes).toHaveLength(5); @@ -109,7 +140,7 @@ describe("useWorkflowStore", () => { // Try to add another trigger act(() => { const edges = result.current.edges; - result.current.addNodeBetween( + result.current.addNodeBetweenSafe( edges[1].id, { id: "interval", @@ -191,7 +222,7 @@ describe("useWorkflowStore", () => { }, isValid: true, }); - result.current.initializeWorkflow(null, mockToolboxConfiguration); + result.current.initializeWorkflow(null, mockProvidersConfiguration); }); // Delete interval trigger @@ -374,7 +405,7 @@ describe("useWorkflowStore", () => { { id: "step1", name: "Step 1", - type: "step", + type: "step-mock", componentType: "task", properties: { stepParams: ["param1"], @@ -393,7 +424,7 @@ describe("useWorkflowStore", () => { }, isValid: true, }); - result.current.initializeWorkflow(null, mockToolboxConfiguration); + result.current.initializeWorkflow(null, mockProvidersConfiguration); }); // Verify no validation errors and canDeploy is true @@ -413,7 +444,7 @@ describe("useWorkflowStore", () => { }, isValid: false, }); - result.current.initializeWorkflow(null, mockToolboxConfiguration); + result.current.initializeWorkflow(null, mockProvidersConfiguration); }); // Verify validation errors are captured @@ -435,8 +466,8 @@ describe("useWorkflowStore", () => { sequence: [ { id: "step1", - name: "Step 1", - type: "step", + name: "step1", + type: "step-mock", componentType: "task", properties: { stepParams: [], @@ -445,7 +476,7 @@ describe("useWorkflowStore", () => { { id: "step2", name: "", - type: "step", + type: "step-uninstalled", componentType: "task", properties: { stepParams: [], @@ -460,14 +491,21 @@ describe("useWorkflowStore", () => { }, isValid: false, }); - result.current.initializeWorkflow(null, mockToolboxConfiguration); + result.current.initializeWorkflow(null, mockProvidersConfiguration); }); // Verify step validation errors are captured + expect(result.current.validationErrors).toHaveProperty("step1"); + expect(result.current.validationErrors["step1"]).toBe( + "No parameters configured" + ); expect(result.current.validationErrors).toHaveProperty("step2"); + expect(result.current.validationErrors["step2"]).toBe( + "Step name cannot be empty." + ); }); - it("should allow deployment if only provider errors exist", () => { + it("should allow deployment if errors exist but are about missing providers", () => { const { result } = renderHook(() => useWorkflowStore()); // Setup a workflow with provider-related errors @@ -478,10 +516,13 @@ describe("useWorkflowStore", () => { { id: "step1", name: "Step 1", - type: "step", + type: "step-notinstalled", componentType: "task", properties: { - stepParams: [], + stepParams: ["param1"], + with: { + param1: "value1", + }, }, }, ], @@ -493,7 +534,7 @@ describe("useWorkflowStore", () => { }, isValid: false, }); - result.current.initializeWorkflow(null, mockToolboxConfiguration); + result.current.initializeWorkflow(null, mockProvidersConfiguration); }); // Verify canDeploy is true despite provider errors diff --git a/keep-ui/entities/workflows/model/types.ts b/keep-ui/entities/workflows/model/types.ts index 0973c91e38..36953e8ae0 100644 --- a/keep-ui/entities/workflows/model/types.ts +++ b/keep-ui/entities/workflows/model/types.ts @@ -1,7 +1,7 @@ import { Edge, Node } from "@xyflow/react"; import { Workflow } from "@/shared/api/workflows"; import { z } from "zod"; - +import { Provider } from "@/shared/api/providers"; export type WorkflowMetadata = Pick; export type V2Properties = Record; @@ -51,6 +51,7 @@ export const V2StepAlertTriggerSchema = z.object({ }); export const IncidentEventEnum = z.enum(["created", "updated", "deleted"]); +export type IncidentEvent = z.infer; export const V2StepIncidentTriggerSchema = z.object({ id: z.string(), @@ -90,13 +91,11 @@ export const V2ActionSchema = z.object({ z.union([z.string(), z.number(), z.boolean(), z.object({})]) ) .superRefine((withObj, ctx) => { - console.log(withObj, ctx); + // console.log(withObj, { "ctx.path": ctx.path }); // const actionParams = ctx.path[0].properties.actionParams; // const withKeys = Object.keys(withObj); - // // Check if all keys in 'with' are present in actionParams // const validKeys = withKeys.every((key) => actionParams.includes(key)); - // if (!validKeys) { // ctx.addIssue({ // code: z.ZodIssueCode.custom, @@ -170,6 +169,13 @@ export type V2StepConditionThreshold = z.infer< typeof V2StepConditionThresholdSchema >; +export const V2StepConditionSchema = z.union([ + V2StepConditionAssertSchema, + V2StepConditionThresholdSchema, +]); + +export type V2StepCondition = z.infer; + export const V2StepForeachSchema = z.object({ id: z.string(), name: z.string(), @@ -304,6 +310,24 @@ export type StoreSet = ( | ((state: FlowState) => FlowState | Partial) ) => void; +export type ToolboxConfiguration = { + groups: ( + | { + name: "Triggers"; + steps: V2StepTrigger[]; + } + | { + name: string; + steps: Omit[]; + } + )[]; +}; + +export type ProvidersConfiguration = { + providers: Provider[]; + installedProviders: Provider[]; +}; + export interface FlowStateValues { workflowId: string | null; definition: DefinitionV2 | null; @@ -313,6 +337,8 @@ export interface FlowStateValues { selectedEdge: string | null; v2Properties: Record; toolboxConfiguration: ToolboxConfiguration | null; + providers: Provider[] | null; + installedProviders: Provider[] | null; isLayouted: boolean; isInitialized: boolean; @@ -347,21 +373,24 @@ export interface FlowState extends FlowStateValues { step: V2StepTemplate | V2StepTrigger, type: "node" | "edge" ) => string | null; - setToolBoxConfig: (config: ToolboxConfiguration) => void; + addNodeBetweenSafe: ( + nodeOrEdgeId: string, + step: V2StepTemplate | V2StepTrigger, + type: "node" | "edge" + ) => string | null; + setProviders: (providers: Provider[]) => void; + setInstalledProviders: (providers: Provider[]) => void; setEditorOpen: (open: boolean) => void; updateSelectedNodeData: (key: string, value: any) => void; updateV2Properties: (properties: Record) => void; setSelectedNode: (id: string | null) => void; onNodesChange: (changes: any) => void; onEdgesChange: (changes: any) => void; - onConnect: (connection: any) => void; - onDragOver: (event: React.DragEvent) => void; - onDrop: (event: DragEvent, screenToFlowPosition: any) => void; setNodes: (nodes: FlowNode[]) => void; setEdges: (edges: Edge[]) => void; getNodeById: (id: string) => FlowNode | undefined; getEdgeById: (id: string) => Edge | undefined; - deleteNodes: (ids: string | string[]) => void; + deleteNodes: (ids: string | string[]) => string[]; getNextEdge: (nodeId: string) => Edge | undefined; reset: () => void; setDefinition: (def: DefinitionV2) => void; @@ -374,19 +403,11 @@ export interface FlowState extends FlowStateValues { }) => void; initializeWorkflow: ( workflowId: string | null, - toolboxConfiguration: ToolboxConfiguration + { providers, installedProviders }: ProvidersConfiguration ) => void; updateDefinition: () => void; + // Deprecated + onConnect: (connection: any) => void; + onDragOver: (event: React.DragEvent) => void; + onDrop: (event: DragEvent, screenToFlowPosition: any) => void; } -export type ToolboxConfiguration = { - groups: ( - | { - name: "Triggers"; - steps: V2StepTrigger[]; - } - | { - name: string; - steps: Omit[]; - } - )[]; -}; diff --git a/keep-ui/entities/workflows/model/validation.ts b/keep-ui/entities/workflows/model/validation.ts index 97822cf743..69bfee3871 100644 --- a/keep-ui/entities/workflows/model/validation.ts +++ b/keep-ui/entities/workflows/model/validation.ts @@ -1,8 +1,11 @@ +import { Provider } from "@/shared/api/providers"; import { Definition, V2Step } from "./types"; export type ValidationResult = [string, string]; -export const PROVIDERS_WITH_NO_CONFIG = ["console", "bash"]; +export const checkProviderNeedsInstallation = (providerObject: Provider) => { + return providerObject.config && Object.keys(providerObject.config).length > 0; +}; export function validateGlobalPure(definition: Definition): ValidationResult[] { const errors: ValidationResult[] = []; @@ -91,41 +94,79 @@ export function validateGlobalPure(definition: Definition): ValidationResult[] { return errors; } -export function validateStepPure(step: V2Step): string | null { - if (step.type.includes("condition-")) { +function validateProviderConfig( + providerType: string | undefined, + providerConfig: string, + providers: Provider[], + installedProviders: Provider[] +) { + const providerObject = providers?.find((p) => p.type === providerType); + + if (!providerObject) { + return `Provider type '${providerType}' is not supported`; + } + // If config is not empty, it means that the provider needs installation + const doesProviderNeedInstallation = + checkProviderNeedsInstallation(providerObject); + + if (!doesProviderNeedInstallation) { + return null; + } + + if (!providerConfig) { + return `No ${providerType} provider selected`; + } + + if ( + doesProviderNeedInstallation && + installedProviders.find( + (p) => p.type === providerType && p.details?.name === providerConfig + ) === undefined + ) { + return `The '${providerConfig}' ${providerType} provider is not installed. Please install it before executing this workflow.`; + } + return null; +} + +export function validateStepPure( + step: V2Step, + providers: Provider[], + installedProviders: Provider[] +): string | null { + if (step.componentType === "switch") { if (!step.name) { - return "Step/action name cannot be empty."; + return "Condition name cannot be empty."; + } + const branches = step.branches || { + true: [], + false: [], + }; + const conditionHasActions = branches.true.length > 0; + if (!conditionHasActions) { + return "Conditions true branch must contain at least one action."; } - const branches = - step?.componentType === "switch" - ? step.branches - : { - true: [], - false: [], - }; - const onlyActions = branches?.true?.every((step: V2Step) => + const onlyActions = branches.true.every((step: V2Step) => step.type.includes("action-") ); if (!onlyActions) { return "Conditions can only contain actions."; } - const conditionHasActions = branches?.true - ? branches?.true.length > 0 - : false; - if (!conditionHasActions) { - return "Conditions must contain at least one action."; - } - const valid = conditionHasActions && onlyActions; - return valid ? null : "Conditions must contain at least one action."; + return null; } - if (step?.componentType === "task") { - if (!step?.name) { + if (step.componentType === "task") { + if (!step.name) { return "Step name cannot be empty."; } - const providerType = step?.type.split("-")[1]; - const providerConfig = (step?.properties.config as string)?.trim(); - if (!providerConfig && !PROVIDERS_WITH_NO_CONFIG.includes(providerType)) { - return "No provider selected"; + const providerType = step.type.split("-")[1]; + const providerConfig = (step.properties.config || "").trim(); + const providerError = validateProviderConfig( + providerType, + providerConfig, + providers, + installedProviders + ); + if (providerError) { + return providerError; } if ( !Object.values(step?.properties?.with || {}).some( diff --git a/keep-ui/entities/workflows/model/workflow-store.ts b/keep-ui/entities/workflows/model/workflow-store.ts index 04fbd93a4a..850e953670 100644 --- a/keep-ui/entities/workflows/model/workflow-store.ts +++ b/keep-ui/entities/workflows/model/workflow-store.ts @@ -22,13 +22,13 @@ import { FlowState, FlowNode, Definition, - ToolboxConfiguration, V2StepTemplateSchema, V2EndStep, V2StartStep, V2StepTrigger, V2StepTemplate, V2StepTriggerSchema, + ProvidersConfiguration, } from "@/entities/workflows"; import { validateStepPure, validateGlobalPure } from "./validation"; import { getLayoutedWorkflowElements } from "../lib/getLayoutedWorkflowElements"; @@ -36,12 +36,20 @@ import { wrapDefinitionV2 } from "@/entities/workflows/lib/parser"; import { showErrorToast } from "@/shared/ui/utils/showErrorToast"; import { ZodError } from "zod"; import { fromError } from "zod-validation-error"; -class WorkflowBuilderError extends Error { +import { + edgeCanAddStep, + edgeCanAddTrigger, + getToolboxConfiguration, +} from "@/features/workflows/builder/lib/utils"; +import { Provider } from "@/shared/api/providers"; + +class KeepWorkflowStoreError extends Error { constructor(message: string) { super(message); - this.name = "WorkflowBuilderError"; + this.name = "KeepWorkflowStoreError"; } } +const PROTECTED_NODE_IDS = ["start", "end", "trigger_start", "trigger_end"]; /** * Add a node between two edges @@ -51,7 +59,7 @@ class WorkflowBuilderError extends Error { * @param set - The set function * @param get - The get function * @returns The id of the new node - * @throws WorkflowBuilderError if the node or edge or step is not defined + * @throws KeepWorkflowStoreError if the node or edge or step is not defined * @throws ZodError if the step is not valid */ function addNodeBetween( @@ -61,8 +69,12 @@ function addNodeBetween( set: StoreSet, get: StoreGet ) { - if (!nodeOrEdgeId || !rawStep) { - throw new WorkflowBuilderError("Node or edge or step is not defined"); + if (!rawStep) { + throw new KeepWorkflowStoreError("Step is not defined"); + } + + if (!nodeOrEdgeId) { + throw new KeepWorkflowStoreError("Node or edge id is not defined"); } const isTriggerComponent = rawStep.componentType === "trigger"; @@ -77,53 +89,75 @@ function addNodeBetween( let edge = {} as Edge; if (type === "node") { edge = get().edges.find((edge) => edge.target === nodeOrEdgeId) as Edge; + if (!edge) { + throw new KeepWorkflowStoreError( + `Edge with target ${nodeOrEdgeId} not found` + ); + } } if (type === "edge") { edge = get().edges.find((edge) => edge.id === nodeOrEdgeId) as Edge; + if (!edge) { + throw new KeepWorkflowStoreError( + `Edge with id ${nodeOrEdgeId} not found` + ); + } } - if (!edge) { - throw new WorkflowBuilderError("Edge not found"); + let { source: sourceId, target: targetId } = edge || {}; + if (sourceId === "trigger_start") { + targetId = "trigger_end"; + } + + if (isTriggerComponent && !edgeCanAddTrigger(sourceId, targetId)) { + throw new KeepWorkflowStoreError(`Edge ${edge.id} cannot add trigger`); } - let { source: sourceId, target: targetId } = edge || {}; if (!sourceId) { - throw new WorkflowBuilderError("Source is not defined"); + throw new KeepWorkflowStoreError( + `Source is not defined for edge ${edge.id}` + ); } if (!targetId) { - throw new WorkflowBuilderError("Target is not defined"); + throw new KeepWorkflowStoreError( + `Target is not defined for edge ${edge.id}` + ); } if (sourceId !== "trigger_start" && isTriggerComponent) { - throw new WorkflowBuilderError( - "Trigger is only allowed at the start of the workflow" + throw new KeepWorkflowStoreError( + `Trigger is only allowed at the start of the workflow. Attempted to add trigger at edge ${edge.id}` ); } if (sourceId == "trigger_start" && !isTriggerComponent) { - throw new WorkflowBuilderError( - "Only trigger can be added at the start of the workflow" + throw new KeepWorkflowStoreError( + `Only trigger can be added at the start of the workflow. Attempted to add step at edge ${edge.id}` ); } + if (!isTriggerComponent && !edgeCanAddStep(sourceId, targetId)) { + throw new KeepWorkflowStoreError(`Edge ${edge.id} cannot add step`); + } + const nodes = get().nodes; // Return if the trigger is already in the workflow if (isTriggerComponent && nodes.find((node) => node && step.id === node.id)) { - throw new WorkflowBuilderError( - "This type of trigger is already in the workflow" + throw new KeepWorkflowStoreError( + `Trigger of type ${step.type} is already in the workflow` ); } let targetIndex = nodes.findIndex((node) => node.id === targetId); const sourceIndex = nodes.findIndex((node) => node.id === sourceId); if (targetIndex == -1) { - throw new WorkflowBuilderError("Target node not found"); + throw new KeepWorkflowStoreError( + `Target node with id ${targetId} not found` + ); } - if (sourceId === "trigger_start") { - targetId = "trigger_end"; - } + // for triggers, we use the id from the step, for steps we generate a new id const newNodeId = isTriggerComponent ? step.id : uuidv4(); const cloneStep = JSON.parse(JSON.stringify(step)); const newStep = { ...cloneStep, id: newNodeId }; @@ -173,6 +207,7 @@ function addNodeBetween( nodes: newNodes, isLayouted: false, changes: get().changes + 1, + lastChangedAt: Date.now(), }); switch (newNodeId) { @@ -224,6 +259,8 @@ const defaultState: FlowStateValues = { v2Properties: {}, editorOpen: false, toolboxConfiguration: null, + providers: null, + installedProviders: null, isInitialized: false, isLayouted: false, selectedEdge: null, @@ -260,6 +297,15 @@ export const useWorkflowStore = create()( nodeOrEdgeId: string, step: V2StepTemplate | V2StepTrigger, type: "node" | "edge" + ) => { + const newNodeId = addNodeBetween(nodeOrEdgeId, step, type, set, get); + set({ selectedNode: newNodeId, selectedEdge: null }); + return newNodeId ?? null; + }, + addNodeBetweenSafe: ( + nodeOrEdgeId: string, + step: V2StepTemplate | V2StepTrigger, + type: "node" | "edge" ) => { try { const newNodeId = addNodeBetween(nodeOrEdgeId, step, type, set, get); @@ -278,8 +324,9 @@ export const useWorkflowStore = create()( return null; } }, - setToolBoxConfig: (config: ToolboxConfiguration) => - set({ toolboxConfiguration: config }), + setProviders: (providers: Provider[]) => set({ providers }), + setInstalledProviders: (installedProviders: Provider[]) => + set({ installedProviders }), setEditorOpen: (open) => set({ editorOpen: open }), updateSelectedNodeData: (key, value) => { const currentSelectedNode = get().selectedNode; @@ -329,7 +376,24 @@ export const useWorkflowStore = create()( // Check each step's validity for (const step of sequence) { - const error = validateStepPure(step); + const error = validateStepPure( + step, + get().providers ?? [], + get().installedProviders ?? [] + ); + if (step.componentType === "switch") { + [...step.branches.true, ...step.branches.false].forEach((branch) => { + const error = validateStepPure( + branch, + get().providers ?? [], + get().installedProviders ?? [] + ); + if (error) { + validationErrors[branch.name || branch.id] = error; + isValid = false; + } + }); + } if (error) { validationErrors[step.name || step.id] = error; isValid = false; @@ -474,12 +538,15 @@ export const useWorkflowStore = create()( deleteNodes: (ids) => { //for now handling only single node deletion. can later enhance to multiple deletions if (typeof ids !== "string") { - return; + return []; + } + if (PROTECTED_NODE_IDS.includes(ids)) { + throw new KeepWorkflowStoreError("Cannot delete protected node"); } const nodes = get().nodes; const nodeStartIndex = nodes.findIndex((node) => ids == node.id); if (nodeStartIndex === -1) { - return; + return []; } let idArray = Array.isArray(ids) ? ids : [ids]; @@ -561,16 +628,18 @@ export const useWorkflowStore = create()( }); get().onLayout({ direction: "DOWN" }); get().updateDefinition(); + + return [ids]; }, getNextEdge: (nodeId: string) => { const node = get().getNodeById(nodeId); if (!node) { - throw new WorkflowBuilderError("getNextEdge::Node not found"); + throw new KeepWorkflowStoreError("Node not found"); } // TODO: handle multiple edges const edges = get().edges.filter((e) => e.source === nodeId); if (!edges.length) { - throw new WorkflowBuilderError("getNextEdge::Edge not found"); + throw new KeepWorkflowStoreError("Edge not found"); } if (node.data.componentType === "switch") { // If the node is a switch, return the second edge, because "true" is the second edge @@ -588,8 +657,14 @@ export const useWorkflowStore = create()( }) => onLayout(params, set, get), initializeWorkflow: ( workflowId: string | null, - toolboxConfiguration: ToolboxConfiguration - ) => initializeWorkflow(workflowId, toolboxConfiguration, set, get), + { providers, installedProviders }: ProvidersConfiguration + ) => + initializeWorkflow( + workflowId, + { providers, installedProviders }, + set, + get + ), })) ); @@ -634,9 +709,9 @@ function onLayout( }); } -async function initializeWorkflow( +function initializeWorkflow( workflowId: string | null, - toolboxConfiguration: ToolboxConfiguration, + { providers, installedProviders }: ProvidersConfiguration, set: StoreSet, get: StoreGet ) { @@ -648,6 +723,8 @@ async function initializeWorkflow( let parsedWorkflow = definition?.value; const name = parsedWorkflow?.properties?.name; + const toolboxConfiguration = getToolboxConfiguration(providers); + const fullSequence = [ { id: "start", @@ -677,6 +754,8 @@ async function initializeWorkflow( nodes, edges, v2Properties: { ...(parsedWorkflow?.properties ?? {}), name }, + providers, + installedProviders, toolboxConfiguration, isLoading: false, isInitialized: true, diff --git a/keep-ui/features/workflows/ai-assistant/index.ts b/keep-ui/features/workflows/ai-assistant/index.ts new file mode 100644 index 0000000000..576fb1b1e7 --- /dev/null +++ b/keep-ui/features/workflows/ai-assistant/index.ts @@ -0,0 +1 @@ +export { WorkflowBuilderChatSafe } from "./ui/WorkflowBuilderChatSafe"; diff --git a/keep-ui/app/(keep)/workflows/builder/_constants.ts b/keep-ui/features/workflows/ai-assistant/lib/constants.ts similarity index 55% rename from keep-ui/app/(keep)/workflows/builder/_constants.ts rename to keep-ui/features/workflows/ai-assistant/lib/constants.ts index 44bf7f6139..41037af2dd 100644 --- a/keep-ui/app/(keep)/workflows/builder/_constants.ts +++ b/keep-ui/features/workflows/ai-assistant/lib/constants.ts @@ -1,6 +1,3 @@ -export const ADD_TRIGGER_AFTER_EDGE_ID = "etrigger_start-trigger_end"; -export const ADD_STEPS_AFTER_EDGE_ID = "etrigger_end-end"; - export const GENERAL_INSTRUCTIONS = ` You are an workflow builder assistant for Keep Platform. You are responsible for helping the user to build a workflow. You are given a workflow definition, and you are responsible for helping the user add, remove, or modify steps in the workflow. @@ -55,6 +52,10 @@ export const GENERAL_INSTRUCTIONS = ` ` } + If alert trigger is used, {{alert.}} can be used to access the properties of the alert. + If incident trigger is used, {{incident.}} can be used to access the properties of the incident. + + There are 5 types of steps: - step: fetch data from a provider - action: send data to a provider @@ -131,61 +132,96 @@ export const GENERAL_INSTRUCTIONS = ` "type": "foreach-type", "properties": { "value": "value", - "with": { - "query-param1": "value1", - "query-param2": "value2" - } }, + "sequence": [StepJSON[]] } `} - To access the results of a previous steps, use the following syntax: {{ steps.step-id.results }} - Example of a workflow definition with two steps: [ - { - "label": "get-user-data", - "id": "467cd570-a083-4247-b716-27de7d8df6e5", - "name": "get-user-data", - "componentType": "task", - "type": "step-mysql", - "properties": { - "config": " mysql-prod ", - "with": { - "query": "SELECT email FROM users WHERE id = 1", - "single_row": true + To access the results of a previous steps, use the following syntax: {{ steps..results }} + + Example of a workflow definition with an alert trigger: ${` + [ + { + type: "alert", + componentType: "trigger", + name: "Alert", + id: "alert", + "properties": { + "source": "sentry", + "severity": "critical", + "service": "r\"(payments|ftp)\"" }, - "stepParams": [ - "query", - "as_dict", - "single_row", - "kwargs" - ], - "actionParams": null - } - }, - { - "label": "send-notification", - "id": "e87d8b71-00b4-4392-96dd-bc982e1ce524", - "name": "send-notification", - "componentType": "task", - "type": "action-slack", - "properties": { - "config": " slack-demo ", - "with": { - "message": "User email: {{ steps.get-user-data.results.email }}" + }, + { + "id": "42997fbf-1266-4195-8f90-ccd20d034c9e", + "name": "send-slack-message-team-payments", + "componentType": "task", + "type": "action-slack", + "properties": { + "with": { + "message": "\"A new alert from Sentry: Alert: {{ alert.name }} - {{ alert.description }}\n{{ alert}}\"\n" + }, + "stepParams": null, + "actionParams": [ + "message", + "blocks", + "channel", + "slack_timestamp", + "thread_timestamp", + "attachments", + "username", + "notification_type", + "kwargs" + ], + "if": "'{{ alert.service }}' == 'payments'" + }, + }, + { + "id": "5d3383d9-862c-4863-8d72-65e07631f911", + "name": "create-jira-ticket-oncall-board", + "componentType": "task", + "type": "action-jira", + "properties": { + "with": { + "board_name": "Oncall Board", + "custom_fields": { + "customfield_10201": "Critical" + }, + "description": "\"This ticket was created by Keep.\nPlease check the alert details below:\n{code:json} {{ alert }} {code}\"\n", + "enrich_alert": [ + { + "key": "ticket_type", + "value": "jira" + }, + { + "key": "ticket_id", + "value": "results.issue.key" + }, + { + "key": "ticket_url", + "value": "results.ticket_url" + } + ], + "issuetype": "Task", + "summary": "{{ alert.name }} - {{ alert.description }} (created by Keep)" + }, + "stepParams": ["ticket_id", "board_id", "kwargs"], + "actionParams": [ + "summary", + "description", + "issue_type", + "project_key", + "board_name", + "issue_id", + "labels", + "components", + "custom_fields", + "kwargs" + ], + "if": "'{{ alert.service }}' == 'ftp' and not '{{ alert.ticket_id }}'" }, - "stepParams": null, - "actionParams": [ - "message", - "blocks", - "channel", - "slack_timestamp", - "thread_timestamp", - "attachments", - "username", - "kwargs" - ] } - } - ] + ] + `} `; diff --git a/keep-ui/features/workflows/ai-assistant/lib/utils.ts b/keep-ui/features/workflows/ai-assistant/lib/utils.ts new file mode 100644 index 0000000000..92642da99c --- /dev/null +++ b/keep-ui/features/workflows/ai-assistant/lib/utils.ts @@ -0,0 +1,57 @@ +import { + getYamlActionFromAction, + getYamlStepFromStep, +} from "@/entities/workflows/lib/parser"; +import { + FlowNode, + V2ActionStep, + V2Step, + V2StepStep, + V2StepTrigger, +} from "@/entities/workflows/model/types"; +import { Edge } from "@xyflow/react"; + +export function getYamlFromStep(step: V2Step | V2StepTrigger) { + try { + if (step.componentType === "task" && step.type.startsWith("step-")) { + return getYamlStepFromStep(step as V2StepStep); + } + if (step.componentType === "task" && step.type.startsWith("action-")) { + return getYamlActionFromAction(step as V2ActionStep); + } + if (step.componentType === "trigger") { + return { + type: step.type, + ...step.properties, + }; + } + // TODO: add other types + return null; + } catch (error) { + console.error(error); + return null; + } +} + +export function getWorkflowSummaryForCopilot(nodes: FlowNode[], edges: Edge[]) { + return { + nodes: nodes.map((n) => ({ + id: n.id, + nextStepId: n.nextStepId, + prevStepId: n.prevStepId, + ...n.data, + })), + edges: edges.map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + })), + }; +} + +export function getErrorMessage(e: unknown, defaultMessage?: string) { + if (e instanceof Error) { + return e.message; + } + return defaultMessage ?? "Unknown error"; +} diff --git a/keep-ui/features/workflows/ai-assistant/ui/AddStepUI.tsx b/keep-ui/features/workflows/ai-assistant/ui/AddStepUI.tsx new file mode 100644 index 0000000000..32fa14e4b2 --- /dev/null +++ b/keep-ui/features/workflows/ai-assistant/ui/AddStepUI.tsx @@ -0,0 +1,122 @@ +import { Button } from "@/components/ui"; +import { StepPreview } from "./StepPreview"; +import { SuggestionResult, SuggestionStatus } from "./SuggestionStatus"; +import clsx from "clsx"; +import { V2Step } from "@/entities/workflows/model/types"; +import { useWorkflowStore } from "@/entities/workflows"; +import { getErrorMessage } from "../lib/utils"; + +type AddStepUIPropsCommon = { + step: V2Step; + addBeforeNodeId: string; +}; + +type AddStepUIPropsComplete = AddStepUIPropsCommon & { + status: "complete"; + result: SuggestionResult; + respond: undefined; +}; + +type AddStepUIPropsExecuting = AddStepUIPropsCommon & { + status: "executing"; + result: undefined; + respond: (response: SuggestionResult) => void; +}; + +type AddStepUIProps = AddStepUIPropsComplete | AddStepUIPropsExecuting; + +export const AddStepUI = ({ + status, + step, + addBeforeNodeId, + result, + respond, +}: AddStepUIProps) => { + const { addNodeBetween, setSelectedNode, getNodeById } = useWorkflowStore(); + + const selectNode = () => { + const node = getNodeById(addBeforeNodeId); + if (node) { + setSelectedNode(node.id); + } + }; + + const nodeLink = (nodeId: string) => { + if (nodeId === "start" || nodeId === "end") { + return `"${nodeId}"`; + } + return ( + + "{nodeId}" + + ); + }; + + const onAdd = () => { + try { + addNodeBetween(addBeforeNodeId, step, "node"); + respond?.({ + status: "complete", + message: "Step added", + }); + } catch (e) { + respond?.({ + status: "error", + message: getErrorMessage(e), + }); + } + }; + + const onCancel = () => { + respond?.({ + status: "declined", + message: "User cancelled adding step", + }); + }; + + if (status === "complete") { + return ( +
+
+ Do you want to add this action before node {nodeLink(addBeforeNodeId)} + ? +
+ + +
+ ); + } + + return ( +
+
+ {/* TODO: add the place where the action will be added in text */} +
+ Do you want to add this action before node {nodeLink(addBeforeNodeId)} + ? +
+
+ +
+
+
+ + +
+
+ ); +}; diff --git a/keep-ui/features/workflows/ai-assistant/ui/AddTriggerOrStepSkeleton.tsx b/keep-ui/features/workflows/ai-assistant/ui/AddTriggerOrStepSkeleton.tsx new file mode 100644 index 0000000000..740513e9d3 --- /dev/null +++ b/keep-ui/features/workflows/ai-assistant/ui/AddTriggerOrStepSkeleton.tsx @@ -0,0 +1,17 @@ +import Skeleton from "react-loading-skeleton"; + +export const AddTriggerOrStepSkeleton = () => { + return ( +
+
+ +
+
+ +
+
+ +
+
+ ); +}; diff --git a/keep-ui/features/workflows/ai-assistant/ui/AddTriggerUI.tsx b/keep-ui/features/workflows/ai-assistant/ui/AddTriggerUI.tsx new file mode 100644 index 0000000000..ca5deb3f2a --- /dev/null +++ b/keep-ui/features/workflows/ai-assistant/ui/AddTriggerUI.tsx @@ -0,0 +1,127 @@ +import { useState, useCallback, useEffect } from "react"; +import { useWorkflowStore } from "@/entities/workflows"; +import { WF_DEBUG_INFO } from "../../builder/ui/debug-settings"; +import { Button } from "@/components/ui"; +import { JsonCard } from "@/shared/ui"; +import { StepPreview } from "./StepPreview"; +import { SuggestionResult, SuggestionStatus } from "./SuggestionStatus"; +import { getErrorMessage } from "../lib/utils"; +import { V2StepTrigger } from "@/entities/workflows/model/types"; + +type AddTriggerUIPropsCommon = { + trigger: V2StepTrigger; +}; + +type AddTriggerUIPropsComplete = AddTriggerUIPropsCommon & { + status: "complete"; + result: SuggestionResult; + respond: undefined; +}; + +type AddTriggerUIPropsExecuting = AddTriggerUIPropsCommon & { + status: "executing"; + result: undefined; + respond: ((response: SuggestionResult) => void) | undefined; +}; + +type AddTriggerUIProps = AddTriggerUIPropsComplete | AddTriggerUIPropsExecuting; + +export const AddTriggerUI = ({ + status, + trigger, + respond, + result, +}: AddTriggerUIProps) => { + const [isAddingTrigger, setIsAddingTrigger] = useState(false); + const { addNodeBetween, getNextEdge } = useWorkflowStore(); + + const handleAddTrigger = useCallback(() => { + if (isAddingTrigger) { + return; + } + setIsAddingTrigger(true); + try { + const nextEdge = getNextEdge("trigger_start"); + if (!nextEdge) { + respond?.({ + status: "error", + message: "Can't find the edge to add the trigger after", + }); + return; + } + try { + addNodeBetween(nextEdge.id, trigger, "edge"); + respond?.({ + status: "complete", + message: "Trigger added", + }); + } catch (e) { + respond?.({ + status: "error", + message: getErrorMessage(e), + }); + } + } catch (e) { + respond?.({ + status: "error", + message: getErrorMessage(e), + }); + } + setIsAddingTrigger(false); + }, [trigger, respond]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + handleAddTrigger(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [respond]); + + if (status === "complete") { + return ( +
+ {WF_DEBUG_INFO && } +

Do you want to add this trigger to the workflow?

+ + +
+ ); + } + return ( +
+ {WF_DEBUG_INFO && } +

Do you want to add this trigger to the workflow?

+
+ +
+ + +
+
+
+ ); +}; diff --git a/keep-ui/features/workflows/builder/ui/BuilderChat/StepPreview.tsx b/keep-ui/features/workflows/ai-assistant/ui/StepPreview.tsx similarity index 69% rename from keep-ui/features/workflows/builder/ui/BuilderChat/StepPreview.tsx rename to keep-ui/features/workflows/ai-assistant/ui/StepPreview.tsx index 4fd23267e3..f443714183 100644 --- a/keep-ui/features/workflows/builder/ui/BuilderChat/StepPreview.tsx +++ b/keep-ui/features/workflows/ai-assistant/ui/StepPreview.tsx @@ -1,21 +1,14 @@ -import { - V2ActionStep, - V2Step, - V2StepStep, - V2StepTrigger, -} from "@/entities/workflows"; +import { V2Step, V2StepTrigger } from "@/entities/workflows"; import clsx from "clsx"; import Image from "next/image"; import { NodeTriggerIcon } from "@/entities/workflows/ui/NodeTriggerIcon"; -import { normalizeStepType } from "../../lib/utils"; -import { - getYamlStepFromStep, - getYamlActionFromAction, -} from "@/entities/workflows/lib/parser"; -import { YamlStep, YamlAction } from "@/entities/workflows/model/yaml.types"; +import { normalizeStepType } from "../../builder/lib/utils"; import { Editor } from "@monaco-editor/react"; import { stringify } from "yaml"; import { getTriggerDescriptionFromStep } from "@/entities/workflows/lib/getTriggerDescription"; +import { getYamlFromStep } from "../lib/utils"; +import { WF_DEBUG_INFO } from "../../builder/ui/debug-settings"; +import { JsonCard } from "@/shared/ui"; function getStepIconUrl(data: V2Step | V2StepTrigger) { const { type } = data || {}; @@ -33,48 +26,15 @@ export const StepPreview = ({ step: V2Step | V2StepTrigger; className?: string; }) => { - const type = normalizeStepType(step?.type); - let yamlOfStep: - | YamlStep - | YamlAction - | ({ type: string } & Record) - | null = null; - if ( - step.componentType === "task" && - step.type.startsWith("step-") && - "stepParams" in step.properties - ) { - try { - yamlOfStep = getYamlStepFromStep(step as V2StepStep); - } catch (error) { - console.error(error); - } - } - if ( - step.componentType === "task" && - step.type.startsWith("action-") && - "actionParams" in step.properties - ) { - try { - yamlOfStep = getYamlActionFromAction(step as V2ActionStep); - } catch (error) { - console.error(error); - } - } - if (step.componentType === "trigger") { - yamlOfStep = { - type: step.type, - ...step.properties, - }; - } - - const yaml = yamlOfStep ? stringify(yamlOfStep) : null; + const yamlDefinition = getYamlFromStep(step); + const yaml = yamlDefinition ? stringify(yamlDefinition) : null; const displayName = step.name; const subtitle = getTriggerDescriptionFromStep(step as V2StepTrigger); return (
+ {WF_DEBUG_INFO && }
- + {message}

); diff --git a/keep-ui/features/workflows/ai-assistant/ui/WorkflowBuilderChat.tsx b/keep-ui/features/workflows/ai-assistant/ui/WorkflowBuilderChat.tsx new file mode 100644 index 0000000000..012ccb8a29 --- /dev/null +++ b/keep-ui/features/workflows/ai-assistant/ui/WorkflowBuilderChat.tsx @@ -0,0 +1,1082 @@ +import { useMemo, useState } from "react"; +import { Provider } from "@/shared/api/providers"; +import { + DefinitionV2, + IncidentEvent, + IncidentEventEnum, + ToolboxConfiguration, + V2ActionSchema, + V2ActionStep, + V2Step, + V2StepCondition, + V2StepConditionSchema, + V2StepStep, + V2StepStepSchema, + V2StepTrigger, + V2StepTriggerSchema, +} from "@/entities/workflows/model/types"; +import { + CopilotChat, + CopilotKitCSSProperties, + useCopilotChatSuggestions, +} from "@copilotkit/react-ui"; +import { useWorkflowStore } from "@/entities/workflows"; +import { + useCopilotAction, + useCopilotChat, + useCopilotReadable, +} from "@copilotkit/react-core"; +import { Button } from "@/components/ui"; +import { GENERAL_INSTRUCTIONS } from "@/features/workflows/ai-assistant/lib/constants"; +import { showSuccessToast } from "@/shared/ui/utils/showSuccessToast"; +import { WF_DEBUG_INFO } from "../../builder/ui/debug-settings"; +import { AddTriggerUI } from "./AddTriggerUI"; +import { SuggestionResult } from "./SuggestionStatus"; +import { AddStepUI } from "./AddStepUI"; +import { useAvailableAlertFields } from "@/entities/alerts/model"; +import { + getErrorMessage, + getWorkflowSummaryForCopilot, +} from "@/features/workflows/ai-assistant/lib/utils"; +import { AddTriggerOrStepSkeleton } from "@/features/workflows/ai-assistant/ui/AddTriggerOrStepSkeleton"; +import { foreachTemplate, getTriggerTemplate } from "../../builder/lib/utils"; +import "@copilotkit/react-ui/styles.css"; +import "./chat.css"; +export interface WorkflowBuilderChatProps { + definition: DefinitionV2; + installedProviders: Provider[]; +} + +export function WorkflowBuilderChat({ + definition, + installedProviders, +}: WorkflowBuilderChatProps) { + const { + nodes, + edges, + toolboxConfiguration, + selectedEdge, + selectedNode, + deleteNodes, + validationErrors, + } = useWorkflowStore(); + + const steps = useMemo(() => { + if (!toolboxConfiguration || !toolboxConfiguration.groups) { + return []; + } + const result = []; + for (const group of toolboxConfiguration.groups) { + if (group.name !== "Triggers") { + // Type guard to filter out triggers + const nonTriggerSteps = group.steps.filter( + (step): step is Omit => step.componentType !== "trigger" + ); + result.push(...nonTriggerSteps); + } + } + return result; + }, [toolboxConfiguration]); + + const workflowSummary = useMemo(() => { + return getWorkflowSummaryForCopilot(nodes, edges); + }, [nodes, edges]); + + useCopilotReadable( + { + description: "Current workflow", + value: workflowSummary, + }, + [workflowSummary] + ); + + useCopilotReadable( + { + description: "Installed providers", + value: installedProviders, + convert: (description, installedProviders: Provider[]) => { + return installedProviders + .map((p) => `${p.type}, id: ${p.id}`) + .join(", "); + }, + }, + [installedProviders] + ); + + useCopilotReadable( + { + description: "These are steps that you can add to the workflow", + value: toolboxConfiguration, + convert: (description, toolboxConfiguration: ToolboxConfiguration) => { + const result: string[] = []; + toolboxConfiguration?.groups?.forEach((group) => { + result.push( + `==== ${group.name}, componentType: ${group.steps[0].componentType} ====` + ); + group.steps.forEach((step) => { + result.push( + `${step.type}, properties: ${JSON.stringify(step.properties)}` + ); + }); + }); + return result.join("\n"); + }, + }, + [steps] + ); + + useCopilotReadable( + { + description: "Selected node id", + value: selectedNode, + }, + [selectedNode] + ); + + useCopilotReadable( + { + description: "Validation errors", + value: validationErrors, + }, + [validationErrors] + ); + + useCopilotChatSuggestions( + { + instructions: + "Suggest the most relevant next actions. E.g. if workflow is empty ask what workflow user is trying to build, if workflow already has some steps, suggest either to explain or add a new step. If some step is selected, suggest to explain it or help to configure it. If there are validation errors, suggest to fix them. If you waiting for user to accept or reject the suggestion, suggest relevant short answers.", + minSuggestions: 1, + maxSuggestions: 3, + }, + [nodes, steps, selectedNode] + ); + + const { setMessages } = useCopilotChat(); + + const { v2Properties: properties, updateV2Properties: setProperties } = + useWorkflowStore(); + + useCopilotAction({ + name: "changeWorkflowName", + description: "Change the name of the workflow", + parameters: [ + { + name: "name", + description: "The new name of the workflow", + type: "string", + required: true, + }, + ], + handler: ({ name }: { name: string }) => { + setProperties({ ...properties, name }); + showSuccessToast("Workflow name updated"); + }, + }); + + useCopilotAction({ + name: "changeWorkflowDescription", + description: "Change the description of the workflow", + parameters: [ + { + name: "description", + description: "The new description of the workflow", + type: "string", + required: true, + }, + ], + handler: ({ description }: { description: string }) => { + setProperties({ ...properties, description }); + showSuccessToast("Workflow description updated"); + }, + }); + + useCopilotAction({ + name: "removeStepNode", + description: "Remove a step from the workflow", + parameters: [ + { + name: "stepType", + description: "The type of step to remove", + type: "string", + required: true, + }, + { + name: "stepId", + description: "The id of the step to remove", + type: "string", + required: true, + }, + ], + renderAndWaitForResponse: ({ status, args, respond }) => { + if (status === "inProgress") { + return
Loading...
; + } + const stepId = args.stepId; + // TODO: nice UI for this + if (confirm(`Are you sure you want to remove ${stepId} step?`)) { + try { + const deletedNodeIds = deleteNodes(stepId); + if (deletedNodeIds.length > 0) { + respond?.("Step removed"); + return

Step {stepId} removed

; + } else { + respond?.("Step removal failed"); + return

Step removal failed

; + } + } catch (e) { + respond?.({ + status: "error", + message: getErrorMessage(e, "Step removal failed"), + }); + return

Step removal failed

; + } + } else { + respond?.("User cancelled the step removal"); + return

Step removal cancelled

; + } + }, + }); + + useCopilotAction({ + name: "removeTriggerNode", + description: "Remove a trigger from the workflow", + parameters: [ + { + name: "triggerNodeId", + description: + "The id of the trigger to remove. One of 'manual', 'alert', 'incident', 'interval'", + type: "string", + required: true, + }, + ], + renderAndWaitForResponse: ({ status, args, respond }) => { + if (status === "inProgress") { + return
Loading...
; + } + const triggerNodeId = args.triggerNodeId; + + // TODO: nice UI for this + if ( + confirm(`Are you sure you want to remove ${triggerNodeId} trigger?`) + ) { + try { + const deletedNodeIds = deleteNodes(triggerNodeId); + if (deletedNodeIds.length > 0) { + respond?.("Trigger removed"); + return

Trigger {triggerNodeId} removed

; + } else { + respond?.("Trigger removal failed"); + return

Trigger removal failed

; + } + } catch (e) { + respond?.({ + status: "error", + message: getErrorMessage(e, "Trigger removal failed"), + }); + return

Trigger removal failed

; + } + } else { + respond?.("User cancelled the trigger removal"); + return

Trigger removal cancelled

; + } + }, + }); + + /** + * Get the definition of a trigger + * @param triggerType - The type of trigger + * @param triggerProperties - The properties of the trigger + * @returns The definition of the trigger + * @throws ZodError if the trigger type is not supported or triggerProperties are invalid + */ + function getTriggerDefinitionFromCopilotAction( + triggerType: string, + triggerProperties: V2StepTrigger["properties"] + ) { + const triggerTemplate = getTriggerTemplate(triggerType); + + const triggerDefinition = { + ...triggerTemplate, + properties: { + ...triggerTemplate.properties, + ...triggerProperties, + }, + }; + return V2StepTriggerSchema.parse(triggerDefinition); + } + + useCopilotAction({ + name: "addManualTrigger", + description: + "Add a manual trigger to the workflow. There could be only one manual trigger in the workflow.", + parameters: [], + renderAndWaitForResponse: (args) => { + if (args.status === "inProgress") { + return ; + } + + const trigger = getTriggerDefinitionFromCopilotAction("manual", { + manual: "true", + }); + + if (args.status === "complete" && "result" in args) { + return ( + + ); + } + + return ( + + ); + }, + }); + + const { fields } = useAvailableAlertFields(); + const possibleAlertProperties = useMemo(() => { + if (!fields || fields.length === 0) { + return ["source", "severity", "status", "message", "timestamp"]; + } + return fields?.map((field) => field.split(".").pop()); + }, [fields]); + + useCopilotReadable({ + description: "Possible alert properties", + value: possibleAlertProperties, + }); + + useCopilotAction({ + name: "addAlertTrigger", + description: + "Add an alert trigger to the workflow. There could be only one alert trigger in the workflow, if you need more combine them into one alert trigger.", + parameters: [ + { + name: "alertFilters", + description: "The filters of the alert trigger", + type: "object[]", + required: true, + attributes: [ + { + name: "attribute", + description: `One of alert properties`, + type: "string", + required: true, + }, + { + name: "value", + description: "The value of the alert filter", + type: "string", + required: true, + }, + ], + }, + ], + renderAndWaitForResponse: (args) => { + if (args.status === "inProgress") { + return ; + } + + const properties = { + alert: args.args.alertFilters.reduce( + (acc, filter) => { + acc[filter.attribute] = filter.value; + return acc; + }, + {} as Record + ), + }; + + const trigger = getTriggerDefinitionFromCopilotAction( + "alert", + properties + ); + + if (args.status === "complete" && "result" in args) { + return ( + + ); + } + + return ( + + ); + }, + }); + + useCopilotAction({ + name: "addIntervalTrigger", + description: + "Add an interval trigger to the workflow. There could be only one interval trigger in the workflow.", + parameters: [ + { + name: "interval", + description: "The interval of the interval trigger in seconds", + type: "number", + required: true, + }, + ], + renderAndWaitForResponse: (args) => { + if (args.status === "inProgress") { + return ; + } + + const properties = { + interval: args.args.interval, + }; + + const trigger = getTriggerDefinitionFromCopilotAction( + "interval", + properties + ); + + if (args.status === "complete" && "result" in args) { + return ( + + ); + } + + return ( + + ); + }, + }); + + useCopilotAction({ + name: "addIncidentTrigger", + description: + "Add an incident trigger to the workflow. There could be only one incident trigger in the workflow.", + parameters: [ + { + name: "incidentEvents", + description: `The events of the incident trigger, one of: ${IncidentEventEnum.options.map((o) => `"${o}"`).join(", ")}`, + type: "string[]", + required: true, + }, + ], + renderAndWaitForResponse: (args) => { + if (args.status === "inProgress") { + return ; + } + + const properties = { + incident: { + events: args.args.incidentEvents as IncidentEvent[], + }, + }; + + const trigger = getTriggerDefinitionFromCopilotAction( + "incident", + properties + ); + + if (args.status === "complete" && "result" in args) { + return ( + + ); + } + + return ( + + ); + }, + }); + + function getActionStepFromCopilotAction(args: { + actionId: string; + actionType: string; + actionName: string; + providerName: string; + withActionParams: { name: string; value: string }[]; + }) { + const template = steps.find( + (step): step is V2ActionStep => + step.type === args.actionType && + step.componentType === "task" && + "actionParams" in step.properties + ); + if (!template) { + return null; + } + const action: V2ActionStep = { + ...template, + id: args.actionId, + name: args.actionName, + properties: { + ...template.properties, + with: args.withActionParams.reduce( + (acc, param) => { + acc[param.name] = param.value; + return acc; + }, + {} as Record + ), + }, + }; + return V2ActionSchema.parse(action); + } + + useCopilotAction({ + name: "addAction", + description: + "Add an action to the workflow. Actions are sending notifications to a provider.", + parameters: [ + { + name: "withActionParams", + description: "The parameters of the action to add", + type: "object[]", + required: true, + attributes: [ + { + name: "name", + description: "The name of the action parameter", + type: "string", + required: true, + }, + { + name: "value", + description: "The value of the action parameter", + type: "string", + required: true, + }, + ], + }, + { + name: "actionId", + description: "The id of the action to add", + type: "string", + required: true, + }, + { + name: "actionType", + description: "The type of the action to add", + type: "string", + required: true, + }, + { + name: "actionName", + description: "The kebab-case name of the action to add", + type: "string", + required: true, + }, + { + name: "providerName", + description: "The name of the provider to add", + type: "string", + required: true, + }, + { + name: "addBeforeNodeId", + description: `The id of the node to add the action before. For workflows with no steps, should be 'end'. Cannot be a node with componentType: 'trigger'. If adding to a condition branch, search for node id: +- Must end with '__empty_true' for true branch +- Must end with '__empty_false' for false branch +Example: 'node_123__empty_true'`, + type: "string", + required: true, + }, + ], + renderAndWaitForResponse: ({ status, args, respond, result }) => { + if (status === "inProgress") { + return ; + } + const action = getActionStepFromCopilotAction(args); + if (!action) { + respond?.({ + status: "error", + error: "Action definition is invalid", + }); + return
Action definition is invalid
; + } + + if (status === "complete") { + return ( + + ); + } + + return ( + + ); + }, + }); + + function getStepStepFromCopilotAction(args: { + stepId: string; + stepType: string; + stepName: string; + providerName: string; + withStepParams: { name: string; value: string }[]; + }) { + const template = steps.find( + (step): step is V2StepStep => step.type === args.stepType + ); + if (!template) { + return null; + } + + const step: V2StepStep = { + ...template, + id: args.stepId, + name: args.stepName, + properties: { + ...template.properties, + with: args.withStepParams.reduce( + (acc, param) => { + acc[param.name] = param.value; + return acc; + }, + {} as Record + ), + }, + }; + return V2StepStepSchema.parse(step); + } + + useCopilotAction({ + name: "addStep", + description: + "Add a step to the workflow. Steps are fetching data from a provider.", + parameters: [ + { + name: "withStepParams", + description: "The parameters of the step to add", + type: "object[]", + required: true, + attributes: [ + { + name: "name", + description: "The name of the step parameter", + type: "string", + required: true, + }, + { + name: "value", + description: "The value of the step parameter", + type: "string", + required: true, + }, + ], + }, + { + name: "stepId", + description: "The id of the step to add", + type: "string", + required: true, + }, + { + name: "stepType", + description: "The type of the step to add, should start with 'step-'", + type: "string", + required: true, + }, + { + name: "stepName", + description: "The kebab-case name of the step to add", + type: "string", + required: true, + }, + { + name: "providerName", + description: "The name of the provider to add", + type: "string", + required: true, + }, + { + name: "addBeforeNodeId", + description: `The id of the node to add the step before. For workflows with no steps, should be 'end'. Cannot be a node with componentType: 'trigger'. If adding to a condition branch, search for node id: +- Must end with '__empty_true' for true branch +- Must end with '__empty_false' for false branch +Example: 'node_123__empty_true'`, + type: "string", + required: true, + }, + ], + renderAndWaitForResponse: ({ status, args, respond, result }) => { + if (status === "inProgress") { + return ; + } + const step = getStepStepFromCopilotAction(args); + if (!step) { + respond?.({ + status: "error", + error: "Step definition is invalid", + }); + return
Step definition is invalid
; + } + + if (status === "complete") { + return ( + + ); + } + + return ( + + ); + }, + }); + + function getConditionStepFromCopilotAction(args: { + conditionId: string; + conditionType: string; + conditionName: string; + conditionValue: string; + compareToValue: string; + }) { + const template = steps.find( + (step): step is V2StepCondition => step.type === args.conditionType + ); + if (!template) { + throw new Error("Condition type is invalid"); + } + + const condition: V2StepCondition = { + ...template, + id: args.conditionId, + name: args.conditionName, + properties: { + ...template.properties, + value: args.conditionValue, + compare_to: args.compareToValue, + }, + }; + return V2StepConditionSchema.parse(condition); + } + + useCopilotAction({ + name: "addCondition", + description: "Add a condition to the workflow.", + parameters: [ + { + name: "conditionId", + description: "The id of the condition to add", + type: "string", + required: true, + }, + { + name: "conditionType", + description: + "The type of the condition to add. One of: 'condition-assert', 'condition-threshold'", + type: "string", + required: true, + }, + { + name: "conditionName", + description: "The kebab-case name of the condition to add", + type: "string", + required: true, + }, + { + name: "conditionValue", + description: "The value of the condition to add", + type: "string", + required: true, + }, + { + name: "compareToValue", + description: "The value to compare the condition to", + type: "string", + required: true, + }, + { + name: "addBeforeNodeId", + description: `The id of the node to add the condition before. For workflows with no steps, should be 'end'. Cannot be a node with componentType: 'trigger'. If adding to a condition branch, search for node id: +- Must end with '__empty_true' for true branch +- Must end with '__empty_false' for false branch +Example: 'node_123__empty_true'`, + type: "string", + required: true, + }, + ], + renderAndWaitForResponse: ({ status, args, respond, result }) => { + if (status === "inProgress") { + return ; + } + try { + const condition = getConditionStepFromCopilotAction(args); + if (!condition) { + respond?.({ + status: "error", + message: "Condition definition is invalid", + }); + return
Condition definition is invalid
; + } + if (status === "complete") { + return ( + + ); + } + return ( + + ); + } catch (e: any) { + respond?.({ status: "error", message: getErrorMessage(e) }); + return
Failed to add condition {e?.message}
; + } + }, + }); + + function getForeachStepFromCopilotAction(args: { + foreachName: string; + value: string; + addBeforeNodeId: string; + }) { + return { + ...foreachTemplate, + name: args.foreachName, + id: `foreach_${args.foreachName}`, + properties: { + ...foreachTemplate.properties, + value: args.value, + }, + }; + } + + useCopilotAction({ + name: "addForeach", + description: "Add a foreach loop to the workflow.", + parameters: [ + { + name: "foreachName", + description: "The kebab-case name of the foreach to add", + type: "string", + required: true, + }, + { + name: "value", + description: + "The value to iterate over. Could refer to results from previous steps: '{{ steps..results }}'.", + type: "string", + required: true, + }, + { + name: "addBeforeNodeId", + description: `The id of the node to add the foreach before. For workflows with no steps, should be 'end'. Cannot be a node with componentType: 'trigger'. If adding to a condition branch, search for node id: +- Must end with '__empty_true' for true branch +- Must end with '__empty_false' for false branch +Example: 'node_123__empty_true'`, + type: "string", + required: true, + }, + ], + renderAndWaitForResponse: ({ status, args, respond, result }) => { + if (status === "inProgress") { + return ; + } + const foreach = getForeachStepFromCopilotAction(args); + + if (status === "complete") { + return ( + + ); + } + return ( + + ); + }, + }); + + // const testStep = useTestStep(); + + // TODO: add this action + // useCopilotAction({ + // name: "testRunStep", + // description: "Test run a step with given parameters", + // parameters: [ + // { + // name: "providerId", + // description: "The id of the provider to test", + // type: "string", + // required: true, + // }, + // { + // name: "providerType", + // description: "The type of the provider to test", + // type: "string", + // required: true, + // }, + // { + // name: "stepType", + // description: "The type of the step to test: 'action' or 'step'", + // type: "string", + // required: true, + // }, + // { + // name: "stepParams", + // description: "The parameters of the step to test", + // type: "object[]", + // required: true, + // }, + // ], + // render: ({ + // status, + // args: { providerId, stepParams, stepType, providerType }, + // result, + // }) => { + // if (status === "inProgress") { + // return
Loading...
; + // } + // const step = steps?.find((step: any) => step.type === stepType) as V2Step; + // if (!step) { + // return
Step not found
; + // } + // const method = stepType === "action" ? "_notify" : "_query"; + // try { + // const result = await testStep( + // { + // provider_id: providerId, + // provider_type: providerType, + // }, + // method, + // stepParams + // ); + // return
{JSON.stringify(result, null, 2)}
; + // } catch (e) { + // return
Failed to test step: {e.toString()}
; + // } + // }, + // }); + + const [debugInfoVisible, setDebugInfoVisible] = useState(false); + const chatInstructions = + GENERAL_INSTRUCTIONS + + `If you you need to use a provider that is not installed, add step, but mention to user that you need to add the provider first. + Then asked to create a complete workflow, you break down the workflow into steps, outline the steps, show them to user, and then iterate over the steps one by one, generate step definition, show it to user to decide if they want to add them to the workflow.`; + + return ( +
+ {/* Debug info */} + {WF_DEBUG_INFO && ( +
+
+ + +
+ {debugInfoVisible && ( + <> +
{JSON.stringify(definition.value, null, 2)}
+
selectedNode={JSON.stringify(selectedNode, null, 2)}
+
selectedEdge={JSON.stringify(selectedEdge, null, 2)}
+ + )} +
+ )} + 80%, send a slack message to the channel #alerts", + }} + className="h-full flex-1" + /> +
+ ); +} diff --git a/keep-ui/features/workflows/ai-assistant/ui/WorkflowBuilderChatSafe.tsx b/keep-ui/features/workflows/ai-assistant/ui/WorkflowBuilderChatSafe.tsx new file mode 100644 index 0000000000..88ae013db6 --- /dev/null +++ b/keep-ui/features/workflows/ai-assistant/ui/WorkflowBuilderChatSafe.tsx @@ -0,0 +1,61 @@ +import { useConfig } from "@/utils/hooks/useConfig"; +import Image from "next/image"; +import BuilderChatPlaceholder from "@/features/workflows/ai-assistant/ui/ai-workflow-placeholder.png"; +import { SparklesIcon } from "@heroicons/react/24/outline"; +import { Text, Title } from "@tremor/react"; +import { Link } from "@/components/ui"; +import { DefinitionV2 } from "@/entities/workflows"; +import { + WorkflowBuilderChat, + WorkflowBuilderChatProps, +} from "@/features/workflows/ai-assistant/ui/WorkflowBuilderChat"; + +type WorkflowBuilderChatSafeProps = Omit< + WorkflowBuilderChatProps, + "definition" +> & { + definition: DefinitionV2 | null; +}; + +export function WorkflowBuilderChatSafe({ + definition, + ...props +}: WorkflowBuilderChatSafeProps) { + const { data: config } = useConfig(); + + // If AI is not enabled, return null to collapse the chat section + if (!config?.OPEN_AI_API_KEY_SET) { + return ( +
+ Workflow AI Assistant +
+
+
+ + AI is disabled + Contact us to enable AI for you. + + Contact us + +
+
+
+ ); + } + + if (definition == null) { + return null; + } + + return ; +} diff --git a/keep-ui/features/workflows/builder/ui/BuilderChat/ai-workflow-placeholder.png b/keep-ui/features/workflows/ai-assistant/ui/ai-workflow-placeholder.png similarity index 100% rename from keep-ui/features/workflows/builder/ui/BuilderChat/ai-workflow-placeholder.png rename to keep-ui/features/workflows/ai-assistant/ui/ai-workflow-placeholder.png diff --git a/keep-ui/features/workflows/builder/ui/BuilderChat/chat.css b/keep-ui/features/workflows/ai-assistant/ui/chat.css similarity index 100% rename from keep-ui/features/workflows/builder/ui/BuilderChat/chat.css rename to keep-ui/features/workflows/ai-assistant/ui/chat.css diff --git a/keep-ui/features/workflows/builder/ui/BuilderChat/debug-args.tsx b/keep-ui/features/workflows/ai-assistant/ui/debug-args.tsx similarity index 100% rename from keep-ui/features/workflows/builder/ui/BuilderChat/debug-args.tsx rename to keep-ui/features/workflows/ai-assistant/ui/debug-args.tsx diff --git a/keep-ui/features/workflows/builder/lib/utils.tsx b/keep-ui/features/workflows/builder/lib/utils.tsx index fb01860fd7..fe97255781 100644 --- a/keep-ui/features/workflows/builder/lib/utils.tsx +++ b/keep-ui/features/workflows/builder/lib/utils.tsx @@ -1,4 +1,4 @@ -import { Provider } from "@/app/(keep)/providers/providers"; +import { Provider } from "@/shared/api/providers"; import { ToolboxConfiguration, V2StepConditionThreshold, @@ -71,7 +71,7 @@ export const getTriggerTemplate = (triggerType: string) => { export const triggerTypes = ["manual", "alert", "incident", "interval"]; -const foreachTemplate: Omit = { +export const foreachTemplate: Omit = { type: "foreach", componentType: "container", name: "Foreach", @@ -81,21 +81,22 @@ const foreachTemplate: Omit = { sequence: [], }; -const conditionThresholdTemplate: Omit = { - type: "condition-threshold", - componentType: "switch", - name: "Threshold", - properties: { - value: "", - compare_to: "", - }, - branches: { - true: [], - false: [], - }, -}; +export const conditionThresholdTemplate: Omit = + { + type: "condition-threshold", + componentType: "switch", + name: "Threshold", + properties: { + value: "", + compare_to: "", + }, + branches: { + true: [], + false: [], + }, + }; -const conditionAssertTemplate: Omit = { +export const conditionAssertTemplate: Omit = { type: "condition-assert", componentType: "switch", name: "Assert", @@ -183,3 +184,28 @@ export const normalizeStepType = (type: string) => { ?.replace("condition-", "") ?.replace("trigger_", ""); }; + +export function edgeCanHaveAddButton(source: string, target: string) { + let showAddButton = + !source?.includes("empty") && + !target?.includes("trigger_end") && + source !== "start"; + + if (!showAddButton) { + showAddButton = + target?.includes("trigger_end") && source?.includes("trigger_start"); + } + return showAddButton; +} + +export function edgeCanAddTrigger(source: string, target: string) { + return source?.includes("trigger_start") && target?.includes("trigger_end"); +} + +export function edgeCanAddStep(source: string, target: string) { + return ( + !source?.includes("empty") && + !target?.includes("trigger_end") && + source !== "start" + ); +} diff --git a/keep-ui/features/workflows/builder/ui/BuilderChat/AddStepUI.tsx b/keep-ui/features/workflows/builder/ui/BuilderChat/AddStepUI.tsx deleted file mode 100644 index ad0f9f27f9..0000000000 --- a/keep-ui/features/workflows/builder/ui/BuilderChat/AddStepUI.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import { Button } from "@/components/ui"; -import { useWorkflowStore, V2Step } from "@/entities/workflows"; -import { WF_DEBUG_INFO } from "../debug-settings"; -import { DebugArgs } from "./debug-args"; -import { StepPreview } from "./StepPreview"; -import { SuggestionResult, SuggestionStatus } from "./SuggestionStatus"; -import clsx from "clsx"; -import { DebugJSON } from "@/shared/ui"; -import { useCallback } from "react"; -import { triggerTypes } from "../../lib/utils"; -import { Edge } from "@xyflow/react"; - -type AddStepUIProps = - | { - status: "complete"; - args: { - stepDefinitionJSON?: string; - addAfterNodeName?: string; - addAfterEdgeId?: string; - isStart?: boolean; - }; - respond: undefined; - result: SuggestionResult; - } - | { - status: "executing"; - args: { - stepDefinitionJSON?: string; - addAfterNodeName?: string; - addAfterEdgeId?: string; - isStart?: boolean; - }; - respond: (response: SuggestionResult) => void; - result: undefined; - }; - -function getComponentType(stepType: string) { - if (stepType.startsWith("step-")) return "task"; - if (stepType.startsWith("action-")) return "task"; - if (stepType.startsWith("condition-")) return "switch"; - if (stepType === "foreach") return "container"; - if (triggerTypes.includes(stepType)) return "trigger"; - return "task"; -} - -function parseAndAutoCorrectStepDefinition(stepDefinitionJSON: string) { - const step = JSON.parse(stepDefinitionJSON); - return { - ...step, - componentType: getComponentType(step.type), - }; -} - -export const AddStepUI = ({ - status, - args, - respond, - result, -}: AddStepUIProps) => { - const { - definition, - nodes, - getNodeById, - getNextEdge, - addNodeBetween, - getEdgeById, - } = useWorkflowStore(); - let { - stepDefinitionJSON, - addAfterNodeName: addAfterNodeIdOrName, - addAfterEdgeId, - isStart, - } = args; - - const addNodeAfterNode = useCallback( - ( - nodeToAddAfterId: string, - step: V2Step, - isStart: boolean, - respond: (response: any) => void, - addAfterEdgeId?: string - ) => { - if ( - nodeToAddAfterId === "alert" || - nodeToAddAfterId === "incident" || - nodeToAddAfterId === "interval" || - nodeToAddAfterId === "manual" - ) { - nodeToAddAfterId = "trigger_end"; - } - let node = getNodeById(isStart ? "trigger_end" : nodeToAddAfterId); - if (!node) { - const nodeByName = definition?.value.sequence.find( - (s) => s.name === nodeToAddAfterId - ); - if (nodeByName) { - node = getNodeById(nodeByName.id); - } - if (!node) { - respond?.({ - status: "error", - error: new Error("Can't find the node to add the step after"), - message: "Step not added due to error", - }); - return; - } - } - let nextEdge: Edge | null = null; - if (addAfterEdgeId) { - nextEdge = getEdgeById(addAfterEdgeId) ?? null; - } - if (!nextEdge) { - nextEdge = getNextEdge(node.id) ?? null; - } - if (!nextEdge) { - respond?.({ - status: "error", - error: new Error("Can't find the edge to add the step after"), - message: "Step not added due to error", - }); - return; - } - try { - addNodeBetween(nextEdge.id, step, "edge"); - respond?.({ - status: "complete", - stepId: step.id, - message: "Step added", - }); - } catch (e) { - respond?.({ - status: "error", - error: e, - message: "Step not added due to error", - }); - } - }, - [addNodeBetween, definition?.value.sequence, getNextEdge, getNodeById] - ); - - if (!stepDefinitionJSON) { - return
Step definition not found
; - } - if (definition?.value.sequence.length === 0) { - isStart = true; - } - let step = parseAndAutoCorrectStepDefinition(stepDefinitionJSON); - - if (status === "complete") { - return ( -
- {WF_DEBUG_INFO && ( - - )} - - -
- ); - } - - return ( -
-
-
- Do you want to add this step after {addAfterNodeIdOrName} -
{step.name}
- {WF_DEBUG_INFO && ( - - )} - {WF_DEBUG_INFO && ( - - )} -
-
- -
-
-
- - -
-
- ); -}; diff --git a/keep-ui/features/workflows/builder/ui/BuilderChat/AddTriggerUI.tsx b/keep-ui/features/workflows/builder/ui/BuilderChat/AddTriggerUI.tsx deleted file mode 100644 index 328cec3da4..0000000000 --- a/keep-ui/features/workflows/builder/ui/BuilderChat/AddTriggerUI.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import { useState, useMemo, useCallback, useEffect } from "react"; -import { useWorkflowStore, V2StepTriggerSchema } from "@/entities/workflows"; -import { WF_DEBUG_INFO } from "../debug-settings"; -import { Button } from "@/components/ui"; -import { getTriggerTemplate } from "@/features/workflows/builder/lib/utils"; -import { DebugArgs } from "./debug-args"; -import { DebugJSON } from "@/shared/ui"; -import { StepPreview } from "./StepPreview"; -import { SuggestionResult, SuggestionStatus } from "./SuggestionStatus"; - -/** - * Get the definition of a trigger - * @param triggerType - The type of trigger - * @param triggerProperties - The properties of the trigger - * @returns The definition of the trigger - * @throws ZodError if the trigger type is not supported or triggerProperties are invalid - */ -function getTriggerDefinition(triggerType: string, triggerProperties: string) { - const triggerTemplate = getTriggerTemplate(triggerType); - - // TODO: validate triggerProperties here or in addNodeBetween?? - const triggerDefinition = { - ...triggerTemplate, - properties: { - ...triggerTemplate.properties, - ...JSON.parse(triggerProperties), - }, - }; - return V2StepTriggerSchema.parse(triggerDefinition); -} - -type AddTriggerUIProps = - | { - status: "complete"; - args: { - triggerType?: string; - triggerProperties?: string; - }; - respond: undefined; - result: SuggestionResult; - } - | { - status: "executing"; - args: { - triggerType?: string; - triggerProperties?: string; - }; - respond: ((response: SuggestionResult) => void) | undefined; - result: undefined; - }; - -export const AddTriggerUI = ({ - status, - args, - respond, - result, -}: AddTriggerUIProps) => { - const [isAddingTrigger, setIsAddingTrigger] = useState(false); - const { nodes, addNodeBetween, getNextEdge } = useWorkflowStore(); - const { triggerType, triggerProperties } = args; - - const triggerDefinition = useMemo(() => { - if (!triggerType || !triggerProperties) { - throw new Error("Trigger type or properties not provided"); - } - try { - return getTriggerDefinition(triggerType, triggerProperties); - } catch (e) { - respond?.({ - status: "error", - error: e, - message: "Error getting trigger definition", - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [triggerType, triggerProperties]); - - const handleAddTrigger = useCallback(() => { - if (!triggerDefinition) { - respond?.({ - status: "error", - error: new Error("trigger definition not found"), - message: "trigger definition not found", - }); - return; - } - if (isAddingTrigger) { - return; - } - setIsAddingTrigger(true); - try { - const nextEdge = getNextEdge("trigger_start"); - if (!nextEdge) { - respond?.({ - status: "error", - error: new Error("Can't find the edge to add the trigger after"), - message: "Trigger not added due to error", - }); - return; - } - try { - addNodeBetween(nextEdge.id, triggerDefinition, "edge"); - respond?.({ - status: "complete", - message: "Trigger added", - }); - } catch (e) { - respond?.({ - status: "error", - error: e, - message: "Error adding trigger", - }); - } - } catch (e) { - console.error(e); - respond?.({ - status: "error", - error: e, - message: "Error adding trigger", - }); - } - setIsAddingTrigger(false); - }, [triggerDefinition, respond]); - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { - if (!triggerDefinition) { - return; - } - handleAddTrigger(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [args, respond]); - - if (!triggerType || !triggerProperties) { - respond?.({ - status: "error", - error: new Error("Trigger type or properties not provided"), - message: "Trigger type or properties not provided", - }); - return <>Trigger type or properties not provided; - } - if (!triggerDefinition) { - respond?.({ - status: "error", - error: new Error("Trigger definition not found"), - message: "Trigger definition not found", - }); - return <>Trigger definition not found; - } - if (status === "complete") { - return ( -
- {WF_DEBUG_INFO && ( - - )} - {WF_DEBUG_INFO && ( - - )} - - -
- ); - } - return ( -
- {WF_DEBUG_INFO && ( - - )} - {WF_DEBUG_INFO && ( - - )} -

Do you want to add this trigger to the workflow?

-
- -
- - -
-
-
- ); -}; diff --git a/keep-ui/features/workflows/builder/ui/BuilderChat/builder-chat.tsx b/keep-ui/features/workflows/builder/ui/BuilderChat/builder-chat.tsx deleted file mode 100644 index add8db5ea5..0000000000 --- a/keep-ui/features/workflows/builder/ui/BuilderChat/builder-chat.tsx +++ /dev/null @@ -1,779 +0,0 @@ -import { useMemo, useState } from "react"; -import { Provider } from "@/app/(keep)/providers/providers"; -import { - V2Step, - FlowNode, - ToolboxConfiguration, - V2StepStep, - DefinitionV2, - IncidentEventEnum, -} from "@/entities/workflows/model/types"; -import { - CopilotChat, - CopilotKitCSSProperties, - useCopilotChatSuggestions, -} from "@copilotkit/react-ui"; -import { useWorkflowStore } from "@/entities/workflows"; -import { - useCopilotAction, - useCopilotChat, - useCopilotReadable, -} from "@copilotkit/react-core"; -import { Button, Link } from "@/components/ui"; -import { generateStepDefinition } from "@/app/(keep)/workflows/builder/_actions/getStepJson"; -import { GENERAL_INSTRUCTIONS } from "@/app/(keep)/workflows/builder/_constants"; -import { showSuccessToast } from "@/shared/ui/utils/showSuccessToast"; -import { WF_DEBUG_INFO } from "../debug-settings"; -import { AddTriggerUI } from "./AddTriggerUI"; -import { AddStepUI } from "./AddStepUI"; -import { useTestStep } from "../Editor/StepTest"; -import { useConfig } from "@/utils/hooks/useConfig"; -import { Title, Text } from "@tremor/react"; -import { SparklesIcon } from "@heroicons/react/24/outline"; -import BuilderChatPlaceholder from "./ai-workflow-placeholder.png"; -import Image from "next/image"; -import { Edge } from "@xyflow/react"; -import { SuggestionResult } from "./SuggestionStatus"; -import { useSearchAlerts } from "@/utils/hooks/useSearchAlerts"; -import "@copilotkit/react-ui/styles.css"; -import "./chat.css"; - -const useAlertKeys = () => { - const defaultQuery = { - combinator: "or", - rules: [ - { - combinator: "and", - rules: [{ field: "source", operator: "=", value: "" }], - }, - { - combinator: "and", - rules: [{ field: "source", operator: "=", value: "" }], - }, - ], - }; - const { data: alertsFound = [], isLoading } = useSearchAlerts({ - query: defaultQuery, - timeframe: 3600 * 24, - }); - - const keys = useMemo(() => { - const getNestedKeys = (obj: any, prefix = ""): string[] => { - return Object.entries(obj).reduce((acc, [key, value]) => { - const newKey = prefix ? `${prefix}.${key}` : key; - if (value && typeof value === "object" && !Array.isArray(value)) { - return [...acc, ...getNestedKeys(value, newKey)]; - } - return [...acc, newKey]; - }, []); - }; - return [ - ...alertsFound.reduce>((acc, alert) => { - const alertKeys = getNestedKeys(alert); - return new Set([...acc, ...alertKeys]); - }, new Set()), - ]; - }, [alertsFound]); - - return { keys, isLoading }; -}; - -interface BuilderChatProps { - definition: DefinitionV2; - installedProviders: Provider[]; -} - -function getWorkflowSummaryForCopilot(nodes: FlowNode[], edges: Edge[]) { - return { - nodes: nodes.map((n) => ({ - id: n.id, - nextStepId: n.nextStepId, - prevStepId: n.prevStepId, - ...n.data, - })), - edges: edges.map((e) => ({ id: e.id, source: e.source, target: e.target })), - }; -} - -export function BuilderChat({ - definition, - installedProviders, -}: BuilderChatProps) { - const { - nodes, - edges, - toolboxConfiguration, - addNodeBetween, - selectedEdge, - selectedNode, - deleteNodes, - validationErrors, - } = useWorkflowStore(); - - const steps = useMemo(() => { - if (!toolboxConfiguration || !toolboxConfiguration.groups) { - return []; - } - const result = []; - for (const group of toolboxConfiguration.groups) { - if (group.name !== "Triggers") { - // Type guard to filter out triggers - const nonTriggerSteps = group.steps.filter( - (step): step is Omit => step.componentType !== "trigger" - ); - result.push(...nonTriggerSteps); - } - } - return result; - }, [toolboxConfiguration]); - - const workflowSummary = useMemo(() => { - return getWorkflowSummaryForCopilot(nodes, edges); - }, [nodes, edges]); - - useCopilotReadable( - { - description: "Current workflow", - value: workflowSummary, - }, - [workflowSummary] - ); - - useCopilotReadable( - { - description: "Installed providers", - value: installedProviders, - convert: (description, installedProviders: Provider[]) => { - return installedProviders - .map((p) => `${p.type}, id: ${p.id}`) - .join(", "); - }, - }, - [installedProviders] - ); - - useCopilotReadable( - { - description: "These are steps that you can add to the workflow", - value: toolboxConfiguration, - convert: (description, toolboxConfiguration: ToolboxConfiguration) => { - const result: string[] = []; - toolboxConfiguration?.groups?.forEach((group) => { - result.push( - `==== ${group.name}, componentType: ${group.steps[0].componentType} ====` - ); - group.steps.forEach((step) => { - result.push( - `${step.type}, properties: ${JSON.stringify(step.properties)}` - ); - }); - }); - return result.join("\n"); - }, - }, - [steps] - ); - - useCopilotReadable( - { - description: "Selected node id", - value: selectedNode, - }, - [selectedNode] - ); - - useCopilotReadable( - { - description: "Validation errors", - value: validationErrors, - }, - [validationErrors] - ); - - useCopilotChatSuggestions( - { - instructions: - "Suggest the most relevant next actions. E.g. if workflow is empty ask what workflow user is trying to build, if workflow already has some steps, suggest either to explain or add a new step. If some step is selected, suggest to explain it or help to configure it. If there are validation errors, suggest to fix them. If you waiting for user to accept or reject the suggestion, suggest relevant short answers.", - minSuggestions: 1, - maxSuggestions: 3, - }, - [nodes, steps, selectedNode] - ); - - const { setMessages } = useCopilotChat(); - - const { v2Properties: properties, updateV2Properties: setProperties } = - useWorkflowStore(); - - useCopilotAction({ - name: "changeWorkflowName", - description: "Change the name of the workflow", - parameters: [ - { - name: "name", - description: "The new name of the workflow", - type: "string", - required: true, - }, - ], - handler: ({ name }: { name: string }) => { - setProperties({ ...properties, name }); - showSuccessToast("Workflow name updated"); - }, - }); - - useCopilotAction({ - name: "changeWorkflowDescription", - description: "Change the description of the workflow", - parameters: [ - { - name: "description", - description: "The new description of the workflow", - type: "string", - required: true, - }, - ], - handler: ({ description }: { description: string }) => { - setProperties({ ...properties, description }); - showSuccessToast("Workflow description updated"); - }, - }); - - useCopilotAction({ - name: "removeStepNode", - description: "Remove a step from the workflow", - parameters: [ - { - name: "stepType", - description: "The type of step to remove", - type: "string", - required: true, - }, - { - name: "stepId", - description: "The id of the step to remove", - type: "string", - required: true, - }, - ], - handler: ({ stepId }: { stepId: string }) => { - if ( - stepId === "start" || - stepId === "end" || - stepId === "trigger_start" || - stepId === "trigger_end" - ) { - return; - } - // TODO: nice UI for this - if (confirm(`Are you sure you want to remove ${stepId} step?`)) { - deleteNodes(stepId); - } - }, - }); - - useCopilotAction({ - name: "removeTriggerNode", - description: "Remove a trigger from the workflow", - parameters: [ - { - name: "triggerNodeId", - description: - "The id of the trigger to remove. One of 'manual', 'alert', 'incident', 'interval'", - type: "string", - required: true, - }, - ], - handler: ({ triggerNodeId }: { triggerNodeId: string }) => { - // TODO: nice UI for this - if ( - confirm(`Are you sure you want to remove ${triggerNodeId} trigger?`) - ) { - deleteNodes(triggerNodeId); - } - }, - }); - - // TODO: simplify this action, e.g. params: componentType, name, properties - useCopilotAction( - { - name: "generateStepDefinition", - description: "Generate a workflow step definition", - parameters: [ - { - name: "stepType", - description: - "The type of step to add e.g. action-slack, step-python, condition-assert, etc", - type: "string", - required: true, - }, - { - name: "name", - description: "The short name of the step", - type: "string", - required: true, - }, - { - name: "aim", - description: - "The detailed description of the step's purpose and proposed solution", - type: "string", - required: true, - }, - ], - handler: async ({ - stepType, - name, - aim, - }: { - stepType: string; - name: string; - aim: string; - }) => { - const step = steps?.find((step: any) => step.type === stepType); - if (!step) { - return; - } - try { - const stepDefinition = await generateStepDefinition({ - name, - stepType, - stepProperties: { ...step.properties }, - aim, - }); - return { - ...step, - name: name ?? step.name, - properties: { - ...step.properties, - with: stepDefinition, - }, - }; - } catch (e) { - console.error(e); - return; - } - }, - }, - [steps] - ); - - useCopilotAction({ - name: "addManualTrigger", - description: - "Add a manual trigger to the workflow. There could be only one manual trigger in the workflow.", - parameters: [], - renderAndWaitForResponse: (args) => { - if (args.status === "inProgress") { - // TODO: skeleton loader - return
Loading...
; - } - - if (args.status === "complete" && "result" in args) { - return AddTriggerUI({ - status: "complete", - args: { - triggerType: "manual", - triggerProperties: JSON.stringify({ - manual: "true", - }), - }, - respond: undefined, - result: args.result as SuggestionResult, - }); - } - - return AddTriggerUI({ - status: "executing", - args: { - triggerType: "manual", - triggerProperties: JSON.stringify({ - manual: "true", - }), - }, - respond: args.respond, - result: undefined, - }); - }, - }); - - const { keys } = useAlertKeys(); - const possibleAlertProperties = useMemo(() => { - if (!keys || keys.length === 0) { - return ["source", "severity", "status", "message", "timestamp"]; - } - return keys?.map((key) => key.split(".").pop()); - }, [keys]); - - useCopilotAction( - { - name: "addAlertTrigger", - description: - "Add an alert trigger to the workflow. There could be only one alert trigger in the workflow, if you need more combine them into one alert trigger.", - parameters: [ - { - name: "alertFilters", - description: `The filters of the alert trigger, this should be a JSON object, there keys are one of: ${possibleAlertProperties.join( - ", " - )}, values are strings. This cannot be empty (undefined!)`, - type: "string", - required: true, - }, - ], - renderAndWaitForResponse: (args) => { - if (args.status === "inProgress") { - // TODO: skeleton loader - return
Loading...
; - } - - const argsToPass = { - triggerType: "alert", - triggerProperties: JSON.stringify({ - alert: JSON.parse(args.args.alertFilters), - }), - }; - - if (args.status === "complete" && "result" in args) { - return AddTriggerUI({ - status: "complete", - args: argsToPass, - respond: undefined, - result: args.result as SuggestionResult, - }); - } - - return AddTriggerUI({ - status: "executing", - args: argsToPass, - respond: args.respond, - result: undefined, - }); - }, - }, - [possibleAlertProperties] - ); - - useCopilotAction({ - name: "addIntervalTrigger", - description: - "Add an interval trigger to the workflow. There could be only one interval trigger in the workflow.", - parameters: [ - { - name: "interval", - description: "The interval of the interval trigger in seconds", - type: "number", - required: true, - }, - ], - renderAndWaitForResponse: (args) => { - if (args.status === "inProgress") { - // TODO: skeleton loader - return
Loading...
; - } - - const argsToPass = { - triggerType: "interval", - triggerProperties: JSON.stringify({ interval: args.args.interval }), - }; - - if (args.status === "complete" && "result" in args) { - return AddTriggerUI({ - status: "complete", - args: argsToPass, - respond: undefined, - result: args.result as SuggestionResult, - }); - } - - return AddTriggerUI({ - status: "executing", - args: argsToPass, - respond: args.respond, - result: undefined, - }); - }, - }); - - useCopilotAction({ - name: "addIncidentTrigger", - description: - "Add an incident trigger to the workflow. There could be only one incident trigger in the workflow.", - parameters: [ - { - name: "incidentEvents", - description: `The events of the incident trigger, one of: ${IncidentEventEnum.options.map((o) => `"${o}"`).join(", ")}`, - type: "string[]", - required: true, - }, - ], - renderAndWaitForResponse: (args) => { - if (args.status === "inProgress") { - // TODO: skeleton loader - return
Loading...
; - } - - const argsToPass = { - triggerType: "incident", - triggerProperties: JSON.stringify({ - incident: { events: args.args.incidentEvents }, - }), - }; - - if (args.status === "complete" && "result" in args) { - return AddTriggerUI({ - status: "complete", - args: argsToPass, - respond: undefined, - result: args.result as SuggestionResult, - }); - } - - return AddTriggerUI({ - status: "executing", - args: argsToPass, - respond: args.respond, - result: undefined, - }); - }, - }); - - // TODO: split this action into: addAction, addStep, addCondition, addForeach so parameters are more accurate - useCopilotAction( - { - name: "addStep", - description: - "Add a step to the workflow. After adding a step ensure you have the updated workflow definition.", - parameters: [ - { - name: "stepDefinitionJSON", - description: - "The step definition to add, use the 'generateStepDefinition' action to generate a step definition.", - type: "string", - required: true, - }, - { - name: "addAfterNodeName", - description: - "The 'name' of the step to add the new step after, get it from the workflow definition. If workflow is empty, use 'trigger_end'.", - type: "string", - required: true, - }, - { - name: "addAfterEdgeId", - description: - "If you want to add the step after specific edge, use the edgeId.", - type: "string", - required: false, - }, - { - // TODO: replace with more accurate nodeOrEdgeId description - name: "isStart", - description: "Whether the step is the start of the workflow", - type: "boolean", - required: false, - }, - ], - renderAndWaitForResponse: (args) => { - if (args.status === "inProgress") { - return
Loading...
; - } - return AddStepUI(args); - }, - }, - [steps, selectedNode, selectedEdge, addNodeBetween] - ); - - // const testStep = useTestStep(); - - // TODO: add this action - // useCopilotAction({ - // name: "testRunStep", - // description: "Test run a step with given parameters", - // parameters: [ - // { - // name: "providerId", - // description: "The id of the provider to test", - // type: "string", - // required: true, - // }, - // { - // name: "providerType", - // description: "The type of the provider to test", - // type: "string", - // required: true, - // }, - // { - // name: "stepType", - // description: "The type of the step to test: 'action' or 'step'", - // type: "string", - // required: true, - // }, - // { - // name: "stepParams", - // description: "The parameters of the step to test", - // type: "object[]", - // required: true, - // }, - // ], - // render: ({ - // status, - // args: { providerId, stepParams, stepType, providerType }, - // result, - // }) => { - // if (status === "inProgress") { - // return
Loading...
; - // } - // const step = steps?.find((step: any) => step.type === stepType) as V2Step; - // if (!step) { - // return
Step not found
; - // } - // const method = stepType === "action" ? "_notify" : "_query"; - // try { - // const result = await testStep( - // { - // provider_id: providerId, - // provider_type: providerType, - // }, - // method, - // stepParams - // ); - // return
{JSON.stringify(result, null, 2)}
; - // } catch (e) { - // return
Failed to test step: {e.toString()}
; - // } - // }, - // }); - - const [debugInfoVisible, setDebugInfoVisible] = useState(false); - const chatInstructions = useMemo(() => { - return ( - GENERAL_INSTRUCTIONS + - `Here is the list of providers that are installed: ${installedProviders.map((p) => `type: ${p.type}, id: ${p.id}`).join(", ")}. If you you need to use a provider that is not installed, add step, but mention to user that you need to add the provider first.` + - "Then asked to create a complete workflow, you break down the workflow into steps, outline the steps, show them to user, and then iterate over the steps one by one, generate step definition, show it to user to decide if they want to add them to the workflow." - ); - }, [installedProviders]); - - return ( -
- {/* Debug info */} - {WF_DEBUG_INFO && ( -
-
- - - -
- {debugInfoVisible && ( - <> -
{JSON.stringify(definition.value, null, 2)}
-
selectedNode={JSON.stringify(selectedNode, null, 2)}
-
selectedEdge={JSON.stringify(selectedEdge, null, 2)}
- - )} -
- )} - 80%, send a slack message to the channel #alerts", - }} - className="h-full flex-1" - /> -
- ); -} - -type BuilderChatSafeProps = Omit & { - definition: DefinitionV2 | null; -}; - -export function BuilderChatSafe({ - definition, - ...props -}: BuilderChatSafeProps) { - const { data: config } = useConfig(); - - // If AI is not enabled, return null to collapse the chat section - if (!config?.OPEN_AI_API_KEY_SET) { - return ( -
- Workflow AI Assistant -
-
-
- - AI is disabled - Contact us to enable AI for you. - - Contact us - -
-
-
- ); - } - - if (definition == null) { - return null; - } - - return ; -} diff --git a/keep-ui/features/workflows/builder/ui/Editor/ReactFlowEditor.tsx b/keep-ui/features/workflows/builder/ui/Editor/ReactFlowEditor.tsx index f4514de85b..efd8aeb605 100644 --- a/keep-ui/features/workflows/builder/ui/Editor/ReactFlowEditor.tsx +++ b/keep-ui/features/workflows/builder/ui/Editor/ReactFlowEditor.tsx @@ -8,45 +8,45 @@ import { WorkflowToolbox } from "../WorkflowToolbox"; import { WorkflowEditorV2 } from "./WorkflowEditor"; import { TriggerEditor } from "./TriggerEditor"; import { WorkflowStatus } from "../workflow-status"; +import { triggerTypes } from "../../lib/utils"; const ReactFlowEditor = () => { const { selectedNode, selectedEdge, setEditorOpen, getNodeById, editorOpen } = useWorkflowStore(); - const stepEditorRef = useRef(null); const containerRef = useRef(null); - const isTrigger = ["interval", "manual", "alert", "incident"].includes( - selectedNode || "" - ); + const dividerRef = useRef(null); - useEffect(() => { - setEditorOpen(true); - if (!selectedNode && !selectedEdge) { - return; - } - // TODO: refactor to use ref callback function, e.g. ref={(el) => { - // if (el) { - // el.scrollIntoView({ behavior: "smooth", block: "start" }); - // } - // }} - // Scroll the StepEditorV2 into view when the editor is opened - const timer = setTimeout(() => { - if (containerRef.current && stepEditorRef.current) { + const isTrigger = triggerTypes.includes(selectedNode || ""); + const isStepEditor = !selectedNode?.includes("empty") && !isTrigger; + + useEffect( + function scrollRelevantEditorIntoView() { + if (!selectedNode && !selectedEdge) { + return; + } + // Scroll the view to the divider into view when the editor is opened, so the relevant editor is visible + const timer = setTimeout(() => { + if (!containerRef.current || !dividerRef.current) { + return; + } const containerRect = containerRef.current.getBoundingClientRect(); - const stepEditorRect = stepEditorRef.current.getBoundingClientRect(); - // Check if StepEditorV2 is already at the top of the container - const isAtTop = stepEditorRect.top <= containerRect.top; + const dividerRect = dividerRef.current.getBoundingClientRect(); + // Check if the divider is already at the top of the container + const isAtTop = dividerRect.top <= containerRect.top; - if (!isAtTop) { - // Scroll the StepEditorV2 into view - stepEditorRef.current.scrollIntoView({ - behavior: "smooth", - block: "start", - }); + if (isAtTop) { + return; } - } - }, 100); - return () => clearTimeout(timer); // Cleanup the timer on unmount - }, [selectedNode, selectedEdge]); + // Scroll the divider into view + dividerRef.current.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + }, 100); + return () => clearTimeout(timer); // Cleanup the timer on unmount + }, + [selectedNode, selectedEdge] + ); const initialFormData = useMemo(() => { if (!selectedNode) { @@ -57,8 +57,7 @@ const ReactFlowEditor = () => { return { name, type, properties }; }, [selectedNode]); - const isStepEditor = - !selectedNode?.includes("empty") && !isTrigger && initialFormData; + const showDivider = Boolean(selectedNode || selectedEdge); return (
@@ -94,11 +93,9 @@ const ReactFlowEditor = () => {
- {(isStepEditor || isTrigger) && ( - - )} + {showDivider && } {isTrigger && } - {isStepEditor && ( + {isStepEditor && initialFormData && ( - p.type === providerType && p.config && Object.keys(p.config).length > 0 - ) ?? false; - - if ( - providerConfig && - isThisProviderNeedsInstallation && - installedProviders?.find( - (p) => (p.type === providerType && p.details?.name) === providerConfig - ) === undefined - ) { - return "This provider is not installed and you'll need to install it before executing this workflow."; - } - return ""; -} - function InstallProviderButton({ providerType }: { providerType: string }) { const { data: { providers } = {} } = useProviders(); const revalidateMultiple = useRevalidateMultiple(); @@ -163,21 +122,24 @@ function KeepSetupProviderEditor({ updateProperty, providerType, providerError, - providerNameError, }: KeepEditorProps & { providerError?: string | null; providerNameError?: string | null; }) { const { data: { providers, installed_providers: installedProviders } = {} } = useProviders(); + const providerObject = + providers?.find((p) => p.type === providerType) ?? null; const installedProviderByType = installedProviders?.filter( (p) => p.type === providerType ); - const providerConfig = getProviderConfig(providerType, properties); - - const providerObject = - providers?.find((p) => p.type === providerType) ?? null; + const doesProviderNeedInstallation = providerObject + ? checkProviderNeedsInstallation(providerObject) + : false; + const providerConfig = !doesProviderNeedInstallation + ? "default-" + providerType + : (properties.config ?? "")?.trim(); const isCustomConfig = installedProviderByType?.find((p) => p.details?.name === providerConfig) === @@ -187,6 +149,11 @@ function KeepSetupProviderEditor({ isCustomConfig ? "enter-manually" : (providerConfig ?? "") ); + const isGeneralError = providerError?.includes("No provider selected"); + const inputError = + providerError && !isGeneralError ? providerError : undefined; + const isSelectError = !!inputError && selectValue !== "enter-manually"; + const handleSelectChange = (value: string) => { setSelectValue(value); if (value === "enter-manually" || value === "add-new") { @@ -215,12 +182,12 @@ function KeepSetupProviderEditor({ ); }; - if (PROVIDERS_WITH_NO_CONFIG.includes(providerType ?? "")) { + if (!doesProviderNeedInstallation) { return (
{providerType} provider does not - require configuration + require installation
); @@ -230,7 +197,9 @@ function KeepSetupProviderEditor({
Select provider - {providerError && {providerError}} + {isGeneralError && ( + {providerError} + )}