From c73e215e03e3eb71027e0c46333e2b697b138568 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 13 Apr 2026 16:50:50 +0300 Subject: [PATCH 1/6] Add conversational agents UI and API Introduce conversational Agents feature: add API client (list/create/delete) and types, plus React components for listing, table view, create and delete dialogs with pagination, search, permissions and toasts. Wire the new AgentsList into the Agents page and gate it behind IS_CONVERSATIONAL_AGENTS_ENABLED feature flag. Also add QueryKeys.fetchAgents and the new feature flag enum entry. --- .../agents/dtos/create-agent-request.dto.ts | 6 +- apps/api/src/app/agents/e2e/agents.e2e.ts | 13 +- .../app/workflows-v2/dtos/create-step.dto.ts | 6 +- .../workflows-v2/dtos/create-workflow.dto.ts | 12 +- .../dtos/duplicate-workflow.dto.ts | 5 +- apps/dashboard/src/api/agents.ts | 87 +++++++ .../src/components/agents/agents-list.tsx | 245 ++++++++++++++++++ .../src/components/agents/agents-table.tsx | 140 ++++++++++ .../components/agents/create-agent-dialog.tsx | 225 ++++++++++++++++ .../components/agents/delete-agent-dialog.tsx | 37 +++ .../src/components/workflow-editor/schema.ts | 6 +- apps/dashboard/src/pages/agents.tsx | 192 +++++++------- apps/dashboard/src/utils/query-keys.ts | 1 + packages/shared/src/consts/index.ts | 1 + packages/shared/src/consts/slug-identifier.ts | 9 + packages/shared/src/types/feature-flags.ts | 2 + 16 files changed, 882 insertions(+), 105 deletions(-) create mode 100644 apps/dashboard/src/api/agents.ts create mode 100644 apps/dashboard/src/components/agents/agents-list.tsx create mode 100644 apps/dashboard/src/components/agents/agents-table.tsx create mode 100644 apps/dashboard/src/components/agents/create-agent-dialog.tsx create mode 100644 apps/dashboard/src/components/agents/delete-agent-dialog.tsx create mode 100644 packages/shared/src/consts/slug-identifier.ts diff --git a/apps/api/src/app/agents/dtos/create-agent-request.dto.ts b/apps/api/src/app/agents/dtos/create-agent-request.dto.ts index 4da94f1ad64..f07f376b507 100644 --- a/apps/api/src/app/agents/dtos/create-agent-request.dto.ts +++ b/apps/api/src/app/agents/dtos/create-agent-request.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { SLUG_IDENTIFIER_REGEX, slugIdentifierFormatMessage } from '@novu/shared'; +import { IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator'; export class CreateAgentRequestDto { @ApiProperty() @@ -10,6 +11,9 @@ export class CreateAgentRequestDto { @ApiProperty() @IsString() @IsNotEmpty() + @Matches(SLUG_IDENTIFIER_REGEX, { + message: slugIdentifierFormatMessage('identifier'), + }) identifier: string; @ApiPropertyOptional() diff --git a/apps/api/src/app/agents/e2e/agents.e2e.ts b/apps/api/src/app/agents/e2e/agents.e2e.ts index a0dad59613b..980f05a3626 100644 --- a/apps/api/src/app/agents/e2e/agents.e2e.ts +++ b/apps/api/src/app/agents/e2e/agents.e2e.ts @@ -61,6 +61,15 @@ describe('Agents API - /agents #novu-v2', () => { expect(afterDelete.status).to.equal(404); }); + it('should return 400 when identifier is not a valid slug', async () => { + const res = await session.testAgent.post('/v1/agents').send({ + name: 'Invalid Slug Agent', + identifier: 'bad id with spaces', + }); + + expect(res.status).to.equal(400); + }); + it('should return 404 when agent identifier does not exist', async () => { const res = await session.testAgent.get('/v1/agents/nonexistent-agent-id-xyz'); @@ -153,9 +162,7 @@ describe('Agents API - /agents #novu-v2', () => { expect(removeRes.status).to.equal(204); - const listAfterRemove = await session.testAgent.get( - `/v1/agents/${encodeURIComponent(identifier)}/integrations` - ); + const listAfterRemove = await session.testAgent.get(`/v1/agents/${encodeURIComponent(identifier)}/integrations`); expect(listAfterRemove.body.data.length).to.equal(0); diff --git a/apps/api/src/app/workflows-v2/dtos/create-step.dto.ts b/apps/api/src/app/workflows-v2/dtos/create-step.dto.ts index 6d227845f55..d2bf42a1bc7 100644 --- a/apps/api/src/app/workflows-v2/dtos/create-step.dto.ts +++ b/apps/api/src/app/workflows-v2/dtos/create-step.dto.ts @@ -11,7 +11,7 @@ import { SmsControlDto, ThrottleControlDto, } from '@novu/application-generic'; -import { StepTypeEnum } from '@novu/shared'; +import { SLUG_IDENTIFIER_REGEX, StepTypeEnum, slugIdentifierFormatMessage } from '@novu/shared'; import { IsEnum, IsObject, IsOptional, IsString, Matches } from 'class-validator'; // Base DTO for common properties @@ -27,8 +27,8 @@ export class BaseStepConfigDto { @ApiPropertyOptional({ description: 'Unique identifier for the step' }) @IsString() - @Matches(/^[a-zA-Z0-9]+(?:[-_.][a-zA-Z0-9]+)*$/, { - message: 'stepId must be a valid slug format (letters, numbers, hyphens, dot and underscores only)', + @Matches(SLUG_IDENTIFIER_REGEX, { + message: slugIdentifierFormatMessage('stepId'), }) @IsOptional() stepId?: string; diff --git a/apps/api/src/app/workflows-v2/dtos/create-workflow.dto.ts b/apps/api/src/app/workflows-v2/dtos/create-workflow.dto.ts index 248ab42eead..a7660f6e69a 100644 --- a/apps/api/src/app/workflows-v2/dtos/create-workflow.dto.ts +++ b/apps/api/src/app/workflows-v2/dtos/create-workflow.dto.ts @@ -12,7 +12,13 @@ import { ThrottleControlDto, WorkflowCommonsFields, } from '@novu/application-generic'; -import { SeverityLevelEnum, StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; +import { + SeverityLevelEnum, + SLUG_IDENTIFIER_REGEX, + StepTypeEnum, + slugIdentifierFormatMessage, + WorkflowCreationSourceEnum, +} from '@novu/shared'; import { Type } from 'class-transformer'; import { IsArray, IsEnum, IsOptional, IsString, Matches, ValidateNested } from 'class-validator'; import { @@ -67,8 +73,8 @@ export type StepCreateDto = export class CreateWorkflowDto extends WorkflowCommonsFields { @ApiProperty({ description: 'Unique identifier for the workflow' }) @IsString() - @Matches(/^[a-zA-Z0-9]+(?:[-_.][a-zA-Z0-9]+)*$/, { - message: 'workflowId must be a valid slug format (letters, numbers, hyphens, dot and underscores only)', + @Matches(SLUG_IDENTIFIER_REGEX, { + message: slugIdentifierFormatMessage('workflowId'), }) workflowId: string; diff --git a/apps/api/src/app/workflows-v2/dtos/duplicate-workflow.dto.ts b/apps/api/src/app/workflows-v2/dtos/duplicate-workflow.dto.ts index 2a9152af78c..0123a4fcf0b 100644 --- a/apps/api/src/app/workflows-v2/dtos/duplicate-workflow.dto.ts +++ b/apps/api/src/app/workflows-v2/dtos/duplicate-workflow.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { SLUG_IDENTIFIER_REGEX, slugIdentifierFormatMessage } from '@novu/shared'; import { IsArray, IsBoolean, IsOptional, IsString, Matches } from 'class-validator'; export class DuplicateWorkflowDto { @@ -16,8 +17,8 @@ export class DuplicateWorkflowDto { }) @IsOptional() @IsString() - @Matches(/^[a-zA-Z0-9]+(?:[-_.][a-zA-Z0-9]+)*$/, { - message: 'workflowId must be a valid slug format (letters, numbers, hyphens, dot and underscores only)', + @Matches(SLUG_IDENTIFIER_REGEX, { + message: slugIdentifierFormatMessage('workflowId'), }) workflowId?: string; diff --git a/apps/dashboard/src/api/agents.ts b/apps/dashboard/src/api/agents.ts new file mode 100644 index 00000000000..b19def03b9f --- /dev/null +++ b/apps/dashboard/src/api/agents.ts @@ -0,0 +1,87 @@ +import type { DirectionEnum, IEnvironment } from '@novu/shared'; +import { del, get, post } from '@/api/api.client'; + +export type AgentResponse = { + _id: string; + name: string; + identifier: string; + description?: string; + _environmentId: string; + _organizationId: string; + createdAt: string; + updatedAt: string; +}; + +export type ListAgentsResponse = { + data: AgentResponse[]; + next: string | null; + previous: string | null; + totalCount: number; + totalCountCapped: boolean; +}; + +export type CreateAgentBody = { + name: string; + identifier: string; + description?: string; +}; + +export type ListAgentsParams = { + environment: IEnvironment; + limit?: number; + after?: string; + before?: string; + orderBy?: 'createdAt' | 'updatedAt' | '_id'; + orderDirection?: DirectionEnum; + identifier?: string; + signal?: AbortSignal; +}; + +function buildAgentsQuery(params: ListAgentsParams): string { + const searchParams = new URLSearchParams(); + + if (params.limit != null) { + searchParams.set('limit', String(params.limit)); + } + + if (params.after) { + searchParams.set('after', params.after); + } + + if (params.before) { + searchParams.set('before', params.before); + } + + if (params.orderBy) { + searchParams.set('orderBy', params.orderBy); + } + + if (params.orderDirection) { + searchParams.set('orderDirection', params.orderDirection); + } + + if (params.identifier) { + searchParams.set('identifier', params.identifier); + } + + const qs = searchParams.toString(); + + return qs ? `?${qs}` : ''; +} + +export function listAgents(params: ListAgentsParams): Promise { + const query = buildAgentsQuery(params); + + return get(`/agents${query}`, { + environment: params.environment, + signal: params.signal, + }); +} + +export function createAgent(environment: IEnvironment, body: CreateAgentBody): Promise { + return post('/agents', { environment, body }); +} + +export function deleteAgent(environment: IEnvironment, identifier: string): Promise { + return del(`/agents/${encodeURIComponent(identifier)}`, { environment }); +} diff --git a/apps/dashboard/src/components/agents/agents-list.tsx b/apps/dashboard/src/components/agents/agents-list.tsx new file mode 100644 index 00000000000..240b20602e4 --- /dev/null +++ b/apps/dashboard/src/components/agents/agents-list.tsx @@ -0,0 +1,245 @@ +import { DirectionEnum, PermissionsEnum } from '@novu/shared'; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useState } from 'react'; +import { RiAddLine } from 'react-icons/ri'; +import { type AgentResponse, type CreateAgentBody, createAgent, deleteAgent, listAgents } from '@/api/agents'; +import { NovuApiError } from '@/api/api.client'; +import { AgentsTable } from '@/components/agents/agents-table'; +import { CreateAgentDialog } from '@/components/agents/create-agent-dialog'; +import { DeleteAgentDialog } from '@/components/agents/delete-agent-dialog'; +import { ListNoResults } from '@/components/list-no-results'; +import { FacetedFormFilter } from '@/components/primitives/form/faceted-filter/facated-form-filter'; +import { PermissionButton } from '@/components/primitives/permission-button'; +import { showErrorToast, showSuccessToast } from '@/components/primitives/sonner-helpers'; +import { requireEnvironment, useEnvironment } from '@/context/environment/hooks'; +import { useHasPermission } from '@/hooks/use-has-permission'; +import { QueryKeys } from '@/utils/query-keys'; + +const PAGE_SIZE_OPTIONS = [10, 12, 20, 50]; + +export function AgentsList() { + const queryClient = useQueryClient(); + const { currentEnvironment } = useEnvironment(); + const has = useHasPermission(); + const canReadAgents = has({ permission: PermissionsEnum.AGENT_READ }); + + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [after, setAfter] = useState(); + const [before, setBefore] = useState(); + const [limit, setLimit] = useState(12); + const [createOpen, setCreateOpen] = useState(false); + const [agentToDelete, setAgentToDelete] = useState(null); + + useEffect(() => { + const t = setTimeout(() => { + setDebouncedSearch((prev) => { + const next = search.trim(); + + if (prev !== next) { + setAfter(undefined); + setBefore(undefined); + } + + return next; + }); + }, 400); + + return () => clearTimeout(t); + }, [search]); + + const listQuery = useQuery({ + queryKey: [QueryKeys.fetchAgents, currentEnvironment?._id, { after, before, limit, identifier: debouncedSearch }], + queryFn: () => + listAgents({ + environment: requireEnvironment(currentEnvironment, 'No environment selected'), + limit, + after, + before, + orderBy: 'updatedAt', + orderDirection: DirectionEnum.DESC, + identifier: debouncedSearch || undefined, + }), + enabled: Boolean(currentEnvironment) && canReadAgents, + placeholderData: keepPreviousData, + }); + + const createMutation = useMutation({ + mutationFn: (body: CreateAgentBody) => + createAgent(requireEnvironment(currentEnvironment, 'No environment selected'), body), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchAgents] }); + showSuccessToast('Agent created', 'Your agent is ready to use.'); + }, + onError: (err: Error) => { + const message = err instanceof NovuApiError ? err.message : 'Could not create agent.'; + + showErrorToast(message, 'Create failed'); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (identifier: string) => + deleteAgent(requireEnvironment(currentEnvironment, 'No environment selected'), identifier), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchAgents] }); + showSuccessToast('Agent deleted', 'The agent was removed.'); + setAgentToDelete(null); + }, + onError: (err: Error) => { + const message = err instanceof NovuApiError ? err.message : 'Could not delete agent.'; + + showErrorToast(message, 'Delete failed'); + }, + }); + + const handleNextPage = useCallback(() => { + const next = listQuery.data?.next; + + if (!next) { + return; + } + + setAfter(next); + setBefore(undefined); + }, [listQuery.data?.next]); + + const handlePreviousPage = useCallback(() => { + const previous = listQuery.data?.previous; + + if (!previous) { + return; + } + + setBefore(previous); + setAfter(undefined); + }, [listQuery.data?.previous]); + + const handlePageSizeChange = useCallback((nextLimit: number) => { + setLimit(nextLimit); + setAfter(undefined); + setBefore(undefined); + }, []); + + const handleCreateSubmit = useCallback( + async (body: CreateAgentBody) => { + await createMutation.mutateAsync(body); + }, + [createMutation] + ); + + if (!canReadAgents) { + return ( +
+ You don't have permission to view agents for this organization. +
+ ); + } + + const data = listQuery.data; + const isLoading = listQuery.isPending; + const agents = data?.data ?? []; + const hasFilters = debouncedSearch.length > 0; + const showEmptyBlank = !listQuery.isError && !isLoading && !hasFilters && agents.length === 0; + const showNoResults = !listQuery.isError && !isLoading && hasFilters && agents.length === 0; + + return ( +
+
+ + setCreateOpen(true)} + > + Add agent + +
+ + {listQuery.isError ? ( +
Could not load agents. Try again later.
+ ) : null} + + {showEmptyBlank ? ( +
+

No agents yet

+

+ Create an agent to connect your brain to channels with a unified API. +

+ setCreateOpen(true)} + > + Add agent + +
+ ) : null} + + {showNoResults ? ( + setSearch('')} + /> + ) : null} + + {!listQuery.isError && !showEmptyBlank && !showNoResults ? ( + + ) : null} + + + + { + if (!open) { + setAgentToDelete(null); + } + }} + onConfirm={() => { + if (agentToDelete) { + deleteMutation.mutate(agentToDelete.identifier); + } + }} + agentName={agentToDelete?.name ?? ''} + agentIdentifier={agentToDelete?.identifier ?? ''} + isDeleting={deleteMutation.isPending} + /> +
+ ); +} diff --git a/apps/dashboard/src/components/agents/agents-table.tsx b/apps/dashboard/src/components/agents/agents-table.tsx new file mode 100644 index 00000000000..ad0cb319acb --- /dev/null +++ b/apps/dashboard/src/components/agents/agents-table.tsx @@ -0,0 +1,140 @@ +import { PermissionsEnum } from '@novu/shared'; +import { RiMore2Fill, RiRobot2Line } from 'react-icons/ri'; +import type { AgentResponse } from '@/api/agents'; +import { CompactButton } from '@/components/primitives/button-compact'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/primitives/dropdown-menu'; +import { + Table, + TableBody, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from '@/components/primitives/table'; +import { TablePaginationFooter } from '@/components/primitives/table-pagination-footer'; +import { useHasPermission } from '@/hooks/use-has-permission'; +import { formatDateSimple } from '@/utils/format-date'; +import { cn } from '@/utils/ui'; + +type AgentsTableProps = { + agents: AgentResponse[]; + isLoading: boolean; + onRequestDelete: (agent: AgentResponse) => void; + paginationProps: { + pageSize: number; + pageSizeOptions?: number[]; + currentItemsCount: number; + onPreviousPage: () => void; + onNextPage: () => void; + onPageSizeChange: (pageSize: number) => void; + hasPreviousPage: boolean; + hasNextPage: boolean; + totalCount?: number; + totalCountCapped?: boolean; + }; +}; + +function AgentRowSkeleton() { + return ( + + +
+ + + ); +} + +export function AgentsTable({ agents, isLoading, onRequestDelete, paginationProps }: AgentsTableProps) { + const has = useHasPermission(); + const canWrite = has?.({ permission: PermissionsEnum.AGENT_WRITE }) ?? true; + + return ( + }> + + + Agent + Description + Last updated + + + + {!isLoading && ( + + {agents.map((agent) => { + return ( + + +
+ + + +
+ {agent.name} + {agent.identifier} +
+
+
+ + + {agent.description?.trim() || '—'} + + + + {formatDateSimple(agent.updatedAt)} + + + {canWrite ? ( + + + + Open menu + + + + onRequestDelete(agent)} + > + Delete + + + + ) : null} + +
+ ); + })} +
+ )} + {!isLoading && agents.length > 0 ? ( + + + + + + + + ) : null} +
+ ); +} diff --git a/apps/dashboard/src/components/agents/create-agent-dialog.tsx b/apps/dashboard/src/components/agents/create-agent-dialog.tsx new file mode 100644 index 00000000000..114a67a1db8 --- /dev/null +++ b/apps/dashboard/src/components/agents/create-agent-dialog.tsx @@ -0,0 +1,225 @@ +import { SLUG_IDENTIFIER_REGEX, slugIdentifierFormatMessage } from '@novu/shared'; +import type { FormEvent, ReactNode } from 'react'; +import { useId, useState } from 'react'; +import { RiArrowRightSLine, RiCloseLine, RiExternalLinkLine, RiInformationFill } from 'react-icons/ri'; +import type { CreateAgentBody } from '@/api/agents'; +import { Button } from '@/components/primitives/button'; +import { CompactButton } from '@/components/primitives/button-compact'; +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from '@/components/primitives/dialog'; +import { Hint, HintIcon } from '@/components/primitives/hint'; +import { Input } from '@/components/primitives/input'; +import { Textarea } from '@/components/primitives/textarea'; + +const DOCS_AGENTS_LEARN_MORE_HREF = 'https://docs.novu.co'; + +type CreateAgentDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (body: CreateAgentBody) => Promise; + isSubmitting: boolean; +}; + +type FormErrors = { + name?: string; + identifier?: string; +}; + +function RequiredFieldLabel({ htmlFor, children }: { htmlFor: string; children: ReactNode }) { + return ( + + ); +} + +export function CreateAgentDialog({ open, onOpenChange, onSubmit, isSubmitting }: CreateAgentDialogProps) { + const formId = useId(); + const nameId = `${formId}-name`; + const identifierId = `${formId}-identifier`; + const descriptionId = `${formId}-description`; + + const [name, setName] = useState(''); + const [identifier, setIdentifier] = useState(''); + const [description, setDescription] = useState(''); + const [errors, setErrors] = useState({}); + + const reset = () => { + setName(''); + setIdentifier(''); + setDescription(''); + setErrors({}); + }; + + const handleOpenChange = (next: boolean) => { + if (!next) { + reset(); + } + + onOpenChange(next); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + const trimmedName = name.trim(); + const trimmedIdentifier = identifier.trim(); + const nextErrors: FormErrors = {}; + + if (!trimmedName) { + nextErrors.name = 'Name is required.'; + } + + if (!trimmedIdentifier) { + nextErrors.identifier = 'Identifier is required.'; + } else if (!SLUG_IDENTIFIER_REGEX.test(trimmedIdentifier)) { + nextErrors.identifier = slugIdentifierFormatMessage('identifier'); + } + + if (Object.keys(nextErrors).length > 0) { + setErrors(nextErrors); + + return; + } + + setErrors({}); + + const body: CreateAgentBody = { + name: trimmedName, + identifier: trimmedIdentifier, + }; + + const trimmedDescription = description.trim(); + + if (trimmedDescription) { + body.description = trimmedDescription; + } + + await onSubmit(body); + handleOpenChange(false); + }; + + return ( + + +
+
+
+ + Add agent + + + Give your agent a unified way to communicate with your users.{' '} + + Learn more + + + +
+ + + Close + + +
+
+ +
+
+
+
+ Agent name + { + setName(e.target.value); + setErrors((prev) => ({ ...prev, name: undefined })); + }} + placeholder="e.g. Wine Sommelier Agent" + hasError={Boolean(errors.name)} + aria-invalid={errors.name ? true : undefined} + aria-describedby={errors.name ? `${nameId}-error` : undefined} + /> + {errors.name ? ( + + ) : null} +
+ +
+ Identifier + { + setIdentifier(e.target.value); + setErrors((prev) => ({ ...prev, identifier: undefined })); + }} + placeholder="e.g. wine-sommelier-agent" + hasError={Boolean(errors.identifier)} + aria-invalid={errors.identifier ? true : undefined} + aria-describedby={ + errors.identifier ? `${identifierId}-hint ${identifierId}-error` : `${identifierId}-hint` + } + /> + + + Used in code and APIs. Must be unique. Letters, numbers, hyphens, underscores, and dots only (no + spaces). + + {errors.identifier ? ( + + ) : null} +
+ +
+ +