diff --git a/apps/api/src/app/support/dtos/agents-early-access.dto.ts b/apps/api/src/app/support/dtos/agents-early-access.dto.ts new file mode 100644 index 00000000000..09156198aa5 --- /dev/null +++ b/apps/api/src/app/support/dtos/agents-early-access.dto.ts @@ -0,0 +1,39 @@ +import { Type } from 'class-transformer'; +import { IsArray, IsDefined, IsString, ValidateNested } from 'class-validator'; + +export class HowAgentRunsTodayDto { + @IsDefined() + @IsString() + value: string; + + @IsDefined() + @IsString() + label: string; +} + +export class PlannedProviderDto { + @IsDefined() + @IsString() + id: string; + + @IsDefined() + @IsString() + label: string; +} + +export class AgentsEarlyAccessDto { + @IsDefined() + @ValidateNested() + @Type(() => HowAgentRunsTodayDto) + howAgentRunsToday: HowAgentRunsTodayDto; + + @IsDefined() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PlannedProviderDto) + plannedProviders: PlannedProviderDto[]; + + @IsDefined() + @IsString() + whatAgentDoes: string; +} diff --git a/apps/api/src/app/support/support.controller.ts b/apps/api/src/app/support/support.controller.ts index 89c9bf4839d..71c98906262 100644 --- a/apps/api/src/app/support/support.controller.ts +++ b/apps/api/src/app/support/support.controller.ts @@ -1,9 +1,11 @@ import { Body, Controller, Post, UseGuards } from '@nestjs/common'; import { ApiExcludeController } from '@nestjs/swagger'; import { Novu } from '@novu/api'; -import { UserSession } from '@novu/application-generic'; +import { PinoLogger, UserSession } from '@novu/application-generic'; +import { OrganizationRepository } from '@novu/dal'; import { UserSessionData } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; +import { AgentsEarlyAccessDto } from './dtos/agents-early-access.dto'; import { CreateSupportThreadDto } from './dtos/create-thread.dto'; import { PlainCardRequestDto } from './dtos/plain-card.dto'; import { PlainCardsGuard } from './guards/plain-cards.guard'; @@ -16,8 +18,12 @@ import { PlainCardsCommand } from './usecases/plain-cards.command'; export class SupportController { constructor( private createSupportThreadUsecase: CreateSupportThreadUsecase, + private organizationRepository: OrganizationRepository, + private logger: PinoLogger, private plainCardsUsecase: PlainCardsUsecase - ) {} + ) { + this.logger.setContext(SupportController.name); + } @UseGuards(PlainCardsGuard) @Post('customer-details') @@ -25,6 +31,49 @@ export class SupportController { return this.plainCardsUsecase.fetchCustomerDetails(PlainCardsCommand.create({ ...body })); } + @RequireAuthentication() + @Post('agents-early-access') + async submitAgentsEarlyAccess(@Body() body: AgentsEarlyAccessDto, @UserSession() user: UserSessionData) { + const organization = await this.organizationRepository.findById(user.organizationId); + const organizationName = organization?.name ?? ''; + + const secretKey = process.env.NOVU_SECRET_KEY; + + if (!secretKey) { + this.logger.warn('NOVU_SECRET_KEY is not set; skipping early-access-request-agents-internal-email trigger'); + + return { + success: true, + }; + } + + const novu = new Novu({ + security: { + secretKey, + }, + }); + + await novu.trigger({ + workflowId: 'early-access-request-agents-internal-email', + to: { + subscriberId: 'dima-internal', + email: 'dima@novu.co', + }, + payload: { + howAgentRunsToday: body.howAgentRunsToday.label, + whatAgentDoes: body.whatAgentDoes, + plannedProviders: body.plannedProviders.map((p) => p.label), + organizationId: user.organizationId, + organizationName, + userEmail: user.email ?? '', + }, + }); + + return { + success: true, + }; + } + @RequireAuthentication() @Post('create-thread') async createThread(@Body() body: CreateSupportThreadDto, @UserSession() user: UserSessionData) { diff --git a/apps/dashboard/public/images/agents-teaser.svg b/apps/dashboard/public/images/agents-teaser.svg new file mode 100644 index 00000000000..a92efb276ec --- /dev/null +++ b/apps/dashboard/public/images/agents-teaser.svg @@ -0,0 +1,437 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/dashboard/src/components/side-navigation/side-navigation.tsx b/apps/dashboard/src/components/side-navigation/side-navigation.tsx index eed0fc2e5e2..b7fe5e409aa 100644 --- a/apps/dashboard/src/components/side-navigation/side-navigation.tsx +++ b/apps/dashboard/src/components/side-navigation/side-navigation.tsx @@ -10,6 +10,7 @@ import { RiKey2Line, RiLayout5Line, RiLineChartLine, + RiRobot2Line, RiRouteFill, RiSettings4Line, RiSignalTowerLine, @@ -125,6 +126,19 @@ export const SideNavigation = () => { + + + Agents + + + + , + }, { path: ROUTES.API_KEYS, element: ( diff --git a/apps/dashboard/src/pages/agents.tsx b/apps/dashboard/src/pages/agents.tsx new file mode 100644 index 00000000000..edea1ffa22f --- /dev/null +++ b/apps/dashboard/src/pages/agents.tsx @@ -0,0 +1,531 @@ +import { CaretSortIcon } from '@radix-ui/react-icons'; +import { useMutation } from '@tanstack/react-query'; +import type { FormEvent, ReactElement, ReactNode } from 'react'; +import { useCallback, useId, useMemo, useState } from 'react'; +import { + RiArrowRightSLine, + RiChat3Line, + RiCheckLine, + RiCloseLine, + RiGithubFill, + RiMailLine, + RiMessage3Line, + RiMoreLine, + RiUser3Line, +} from 'react-icons/ri'; +import { + SiGithub, + SiGooglechat, + SiLinear, + SiMessenger, + SiMicrosoftteams, + SiTelegram, + SiWhatsapp, + SiZoom, +} from 'react-icons/si'; +import { NovuApiError, post } from '@/api/api.client'; +import { DashboardLayout } from '@/components/dashboard-layout'; +import { PageMeta } from '@/components/page-meta'; +import { Button } from '@/components/primitives/button'; +import { CompactButton } from '@/components/primitives/button-compact'; +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from '@/components/primitives/dialog'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/primitives/popover'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/primitives/select'; +import { Separator } from '@/components/primitives/separator'; +import { showErrorToast, showSuccessToast } from '@/components/primitives/sonner-helpers'; +import { DismissButton, Icon as TagIcon, Root as TagRoot } from '@/components/primitives/tag'; +import { Textarea } from '@/components/primitives/textarea'; +import { cn } from '@/utils/ui'; + +const slackIcon = '/images/providers/light/square/slack.svg'; +const msTeamsIcon = '/images/providers/light/square/msteams.svg'; +const discordIcon = '/images/providers/light/square/discord.svg'; + +const AGENT_RUN_OPTIONS = [ + { value: 'building', label: "We're building one now" }, + { value: 'production', label: 'We have an agent in production' }, + { value: 'exploring', label: 'We are exploring use cases' }, + { value: 'other', label: 'Other' }, +] as const; + +type AgentRunValue = (typeof AGENT_RUN_OPTIONS)[number]['value']; + +type ProviderId = + | 'whatsapp' + | 'telegram' + | 'email' + | 'zoom' + | 'linear' + | 'github' + | 'imessages' + | 'slack' + | 'ms-teams' + | 'google-chat' + | 'discord' + | 'fb-messenger' + | 'other'; + +type ProviderDefinition = { + id: ProviderId; + label: string; + icon: ReactElement; +}; + +const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ + { + id: 'whatsapp', + label: 'Whatsapp', + icon: , + }, + { id: 'telegram', label: 'Telegram', icon: }, + { id: 'email', label: 'Email', icon: }, + { id: 'zoom', label: 'Zoom', icon: }, + { id: 'linear', label: 'Linear', icon: }, + { id: 'github', label: 'GitHub', icon: }, + { + id: 'imessages', + label: 'iMessages', + icon: , + }, + { id: 'slack', label: 'Slack', icon: }, + { + id: 'ms-teams', + label: 'MS Teams', + icon: , + }, + { + id: 'google-chat', + label: 'Google Chat', + icon: , + }, + { + id: 'discord', + label: 'Discord', + icon: , + }, + { + id: 'fb-messenger', + label: 'FB Messenger', + icon: , + }, + { id: 'other', label: 'Other', icon: }, +]; + +type AgentsPillProps = { + children: ReactNode; + className?: string; +}; + +function AgentsPill({ children, className }: AgentsPillProps) { + return ( + + {children} + + ); +} + +type AgentsEarlyAccessDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +type AgentsEarlyAccessFormErrors = { + providers?: string; + description?: string; +}; + +type AgentsEarlyAccessRequestBody = { + howAgentRunsToday: { value: AgentRunValue; label: string }; + plannedProviders: { id: ProviderId; label: string }[]; + whatAgentDoes: string; +}; + +function AgentsEarlyAccessDialog({ open, onOpenChange }: AgentsEarlyAccessDialogProps) { + const formId = useId(); + const agentRunFieldId = `${formId}-agent-run`; + const providersLabelId = `${formId}-providers`; + const descriptionFieldId = `${formId}-description`; + + const [agentRun, setAgentRun] = useState('building'); + const [providerIds, setProviderIds] = useState([]); + const [description, setDescription] = useState(''); + const [providerMenuOpen, setProviderMenuOpen] = useState(false); + const [formErrors, setFormErrors] = useState({}); + + const providerById = useMemo(() => { + return new Map(PROVIDER_DEFINITIONS.map((p) => [p.id, p])); + }, []); + + const earlyAccessMutation = useMutation({ + mutationFn: (payload: AgentsEarlyAccessRequestBody) => + post<{ success: boolean }>('/support/agents-early-access', { body: payload }), + }); + + const resetForm = useCallback(() => { + setAgentRun('building'); + setProviderIds([]); + setDescription(''); + setProviderMenuOpen(false); + setFormErrors({}); + }, []); + + const handleOpenChange = (next: boolean) => { + if (!next) { + resetForm(); + } + + onOpenChange(next); + }; + + const toggleProvider = (id: ProviderId) => { + setFormErrors((prev) => ({ ...prev, providers: undefined })); + setProviderIds((prev) => { + if (prev.includes(id)) { + return prev.filter((x) => x !== id); + } + + return [...prev, id]; + }); + }; + + const removeProvider = (id: ProviderId) => { + setProviderIds((prev) => prev.filter((x) => x !== id)); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + const trimmedDescription = description.trim(); + const nextErrors: AgentsEarlyAccessFormErrors = {}; + + if (providerIds.length === 0) { + nextErrors.providers = 'Select at least one provider.'; + } + + if (!trimmedDescription) { + nextErrors.description = 'Describe what your agent does.'; + } + + if (Object.keys(nextErrors).length > 0) { + setFormErrors(nextErrors); + + return; + } + + setFormErrors({}); + + const agentRunLabel = AGENT_RUN_OPTIONS.find((o) => o.value === agentRun)?.label ?? agentRun; + const payload: AgentsEarlyAccessRequestBody = { + howAgentRunsToday: { value: agentRun, label: agentRunLabel }, + plannedProviders: providerIds.map((id) => ({ + id, + label: providerById.get(id)?.label ?? id, + })), + whatAgentDoes: trimmedDescription, + }; + + try { + await earlyAccessMutation.mutateAsync(payload); + showSuccessToast('We received your request and will be in touch.', 'Early access'); + handleOpenChange(false); + } catch (err) { + const message = err instanceof NovuApiError ? err.message : 'Something went wrong. Please try again.'; + + showErrorToast(message, 'Request failed'); + } + }; + + const selectedProvidersOrdered = useMemo(() => { + return PROVIDER_DEFINITIONS.filter((p) => providerIds.includes(p.id)); + }, [providerIds]); + + return ( + + + + + + + Request early access + + + Tell us about your use case and we'll reach out when your account is enabled. + + + + + Close + + + + + + + + + + + How does your agent run today? + + setAgentRun(v as AgentRunValue)}> + + + + + {AGENT_RUN_OPTIONS.map((opt) => ( + + {opt.label} + + ))} + + + + + + + What providers do you plan to use? + + + + + {selectedProvidersOrdered.length === 0 && ( + Select providers + )} + {selectedProvidersOrdered.map((p) => ( + + + {p.icon} + + {p.label} + { + ev.stopPropagation(); + removeProvider(p.id); + }} + /> + + ))} + + + + + + {PROVIDER_DEFINITIONS.map((p) => { + const isSelected = providerIds.includes(p.id); + + return ( + toggleProvider(p.id)} + > + {p.icon} + {p.label} + {isSelected ? ( + + ) : ( + + )} + + ); + })} + + + + {formErrors.providers ? ( + + {formErrors.providers} + + ) : null} + + + + + + + What does your agent do? + + { + setDescription(e.target.value); + setFormErrors((prev) => ({ ...prev, description: undefined })); + }} + className="min-h-[88px]" + aria-invalid={formErrors.description ? true : undefined} + /> + {formErrors.description ? ( + + {formErrors.description} + + ) : null} + + + + + + + Request access + + + + + + ); +} + +export function AgentsPage() { + const [earlyAccessOpen, setEarlyAccessOpen] = useState(false); + + return ( + <> + + + Agents}> + + + + + + + + Unified conversational API for AI agents + + + You own the agent Brain, and Novu gives it voice. Distribute your agent across multiple channels with + a unified API. + + + + + + + Connect your agent to + + + Slack + + + + GitHub + + + + MS Teams + + + + WhatsApp + + + + Linear + + and +15 more. + + + + + Access conversation history + + + 5 + + + and state via unified agent(){' '} + handler. + + + + + + Provider identities resolved to + + + + + Subscriber + + entity. + + + + + Rich provider interactions, like action buttons, attachments, reactions, and more. + + + + + setEarlyAccessOpen(true)} + > + Request early access + + + + + + + > + ); +} diff --git a/apps/dashboard/src/utils/routes.ts b/apps/dashboard/src/utils/routes.ts index 5852b0cdfc3..b9e1820dc75 100644 --- a/apps/dashboard/src/utils/routes.ts +++ b/apps/dashboard/src/utils/routes.ts @@ -71,6 +71,7 @@ export const ROUTES = { TRANSLATIONS_EDIT: '/env/:environmentSlug/translations/:resourceType/:resourceId/:locale', VARIABLES: '/env/:environmentSlug/variables', VARIABLES_CREATE: '/env/:environmentSlug/variables/create', + AGENTS: '/env/:environmentSlug/agents', } as const; export const buildRoute = (route: string, params: Record) => {
+ {formErrors.providers} +
+ {formErrors.description} +
+ Unified conversational API for AI agents +
+ You own the agent Brain, and Novu gives it voice. Distribute your agent across multiple channels with + a unified API. +
agent()