diff --git a/apps/api/src/app/agents/services/agent-inbound-handler.service.ts b/apps/api/src/app/agents/services/agent-inbound-handler.service.ts index a81d01ef0d2..59edce2fe9b 100644 --- a/apps/api/src/app/agents/services/agent-inbound-handler.service.ts +++ b/apps/api/src/app/agents/services/agent-inbound-handler.service.ts @@ -93,7 +93,7 @@ export class AgentInboundHandler { organizationId: config.organizationId, }); - const channel = conversation.channels[0]; + const channel = conversation.channels?.[0]; const isFirstMessage = !channel?.firstPlatformMessageId; if (isFirstMessage && config.reactionOnMessageReceived && message.id) { diff --git a/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts b/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts index fe7c51d3547..d084c3fa4d3 100644 --- a/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts +++ b/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts @@ -10,7 +10,7 @@ import { SubscriberRepository, } from '@novu/dal'; import { AgentEventEnum } from '../../dtos/agent-event.enum'; -import { AgentConfigResolver } from '../../services/agent-config-resolver.service'; +import { AgentConfigResolver, ResolvedAgentConfig } from '../../services/agent-config-resolver.service'; import { AgentConversationService } from '../../services/agent-conversation.service'; import { BridgeExecutorService } from '../../services/bridge-executor.service'; import { ChatSdkService } from '../../services/chat-sdk.service'; @@ -59,10 +59,15 @@ export class HandleAgentReply { return { status: 'update_sent' }; } + const needsConfig = !!(command.reply || command.resolve); + const config = needsConfig + ? await this.agentConfigResolver.resolve(conversation._agentId, command.integrationIdentifier) + : null; + if (command.reply) { await this.deliverMessage(command, conversation, channel, command.reply, ConversationActivityTypeEnum.MESSAGE); - this.removeAckReaction(command, conversation, channel).catch((err) => { + this.removeAckReaction(config!, conversation, channel).catch((err) => { this.logger.warn(err, `[agent:${command.agentIdentifier}] Failed to remove ack reaction`); }); } @@ -72,7 +77,7 @@ export class HandleAgentReply { } if (command.resolve) { - await this.executeResolveSignal(command, conversation, channel, command.resolve); + await this.executeResolveSignal(command, config!, conversation, channel, command.resolve); } return { status: 'ok' }; @@ -198,6 +203,7 @@ export class HandleAgentReply { private async executeResolveSignal( command: HandleAgentReplyCommand, + config: ResolvedAgentConfig, conversation: ConversationEntity, channel: ConversationChannel, signal: { summary?: string } @@ -223,29 +229,26 @@ export class HandleAgentReply { }), ]); - this.reactOnResolve(command, conversation, channel).catch((err) => { + this.reactOnResolve(config, conversation, channel).catch((err) => { this.logger.warn(err, `[agent:${command.agentIdentifier}] Failed to add resolve reaction`); }); - this.fireOnResolveBridgeCall(command, conversation).catch((err) => { + this.fireOnResolveBridgeCall(command, config, conversation).catch((err) => { this.logger.error(err, `[agent:${command.agentIdentifier}] Failed to fire onResolve bridge call`); }); } private async removeAckReaction( - command: HandleAgentReplyCommand, + config: ResolvedAgentConfig, conversation: ConversationEntity, channel: ConversationChannel ): Promise { const firstMessageId = channel.firstPlatformMessageId; - if (!firstMessageId) return; - - const config = await this.agentConfigResolver.resolve(conversation._agentId, command.integrationIdentifier); - if (!config.reactionOnMessageReceived) return; + if (!firstMessageId || !config.reactionOnMessageReceived) return; await this.chatSdkService.removeReaction( conversation._agentId, - command.integrationIdentifier, + config.integrationIdentifier, channel.platform, channel.platformThreadId, firstMessageId, @@ -254,19 +257,16 @@ export class HandleAgentReply { } private async reactOnResolve( - command: HandleAgentReplyCommand, + config: ResolvedAgentConfig, conversation: ConversationEntity, channel: ConversationChannel ): Promise { const firstMessageId = channel.firstPlatformMessageId; - if (!firstMessageId) return; - - const config = await this.agentConfigResolver.resolve(conversation._agentId, command.integrationIdentifier); - if (!config.reactionOnResolved) return; + if (!firstMessageId || !config.reactionOnResolved) return; await this.chatSdkService.reactToMessage( conversation._agentId, - command.integrationIdentifier, + config.integrationIdentifier, channel.platform, channel.platformThreadId, firstMessageId, @@ -276,10 +276,9 @@ export class HandleAgentReply { private async fireOnResolveBridgeCall( command: HandleAgentReplyCommand, + config: ResolvedAgentConfig, conversation: ConversationEntity ): Promise { - const config = await this.agentConfigResolver.resolve(conversation._agentId, command.integrationIdentifier); - const subscriberParticipant = conversation.participants.find((p) => p.type === 'subscriber'); const [subscriber, history] = await Promise.all([ subscriberParticipant diff --git a/libs/dal/src/repositories/conversation/conversation.repository.ts b/libs/dal/src/repositories/conversation/conversation.repository.ts index 028b37df5ef..e1349cb56a9 100644 --- a/libs/dal/src/repositories/conversation/conversation.repository.ts +++ b/libs/dal/src/repositories/conversation/conversation.repository.ts @@ -121,7 +121,12 @@ export class ConversationRepository extends BaseRepositoryV2< _id: id, _environmentId: environmentId, _organizationId: organizationId, - 'channels.platformThreadId': platformThreadId, + channels: { + $elemMatch: { + platformThreadId, + firstPlatformMessageId: { $exists: false }, + }, + }, }, { $set: { 'channels.$.firstPlatformMessageId': firstPlatformMessageId } } ); diff --git a/packages/novu/src/commands/init/create-app.ts b/packages/novu/src/commands/init/create-app.ts index 5c41a4e8f3c..36ca04d00c0 100644 --- a/packages/novu/src/commands/init/create-app.ts +++ b/packages/novu/src/commands/init/create-app.ts @@ -16,6 +16,7 @@ export class DownloadError extends Error {} export async function createApp({ appPath, packageManager, + templateChoice, typescript, eslint, srcDir, @@ -26,6 +27,7 @@ export async function createApp({ }: { appPath: string; packageManager: PackageManager; + templateChoice: string; typescript: boolean; eslint: boolean; srcDir: boolean; @@ -36,7 +38,7 @@ export async function createApp({ }): Promise { let repoInfo: RepoInfo | undefined; const mode: TemplateMode = typescript ? 'ts' : 'js'; - const template: TemplateType = 'app-react-email'; + const template: TemplateType = templateChoice === 'agent' ? 'app-agent' : 'app-react-email'; const root = path.resolve(appPath); diff --git a/packages/novu/src/commands/init/index.ts b/packages/novu/src/commands/init/index.ts index 741c8b76b1a..8c96f604eac 100644 --- a/packages/novu/src/commands/init/index.ts +++ b/packages/novu/src/commands/init/index.ts @@ -28,6 +28,7 @@ export interface IInitCommandOptions { secretKey?: string; projectPath?: string; apiUrl: string; + template?: string; } export async function init(program: IInitCommandOptions, anonymousId?: string): Promise { @@ -148,11 +149,36 @@ export async function init(program: IInitCommandOptions, anonymousId?: string): process.exit(1); } + const supportedTemplates = ['notifications', 'agent'] as const; + let templateChoice = program.template; + + if (templateChoice && !supportedTemplates.includes(templateChoice as (typeof supportedTemplates)[number])) { + console.error(`Invalid template "${program.template}". Supported templates: ${supportedTemplates.join(', ')}`); + process.exit(1); + } + + if (!templateChoice) { + const res = await prompts({ + onState: onPromptState, + type: 'select', + name: 'template', + message: 'What type of Novu app do you want to create?', + choices: [ + { title: 'Notifications', value: 'notifications', description: 'Workflows, email templates, and in-app inbox' }, + { title: 'Agent', value: 'agent', description: 'Conversational AI agent with chat platform support' }, + ], + initial: 0, + }); + + templateChoice = res.template; + } + + if (!templateChoice) { + console.error('No template selected.'); + process.exit(1); + } + const preferences = {} as Record; - /** - * If the user does not provide the necessary flags, prompt them for whether - * to use TS or JS. - */ const defaults: typeof preferences = { typescript: true, eslint: true, @@ -175,6 +201,7 @@ export async function init(program: IInitCommandOptions, anonymousId?: string): await createApp({ appPath: resolvedProjectPath, packageManager: 'npm', + templateChoice, typescript: defaults.typescript as boolean, eslint: defaults.eslint as boolean, srcDir: defaults.srcDir as boolean, diff --git a/packages/novu/src/commands/init/templates/app-agent/ts/README-template.md b/packages/novu/src/commands/init/templates/app-agent/ts/README-template.md new file mode 100644 index 00000000000..88c7f9fcd79 --- /dev/null +++ b/packages/novu/src/commands/init/templates/app-agent/ts/README-template.md @@ -0,0 +1,69 @@ +# Novu Agent + +A conversational AI agent powered by [Novu](https://novu.co) and [Next.js](https://nextjs.org). + +## Getting Started + +1. Start the development server: + + ```bash + npm run dev + ``` + +2. Connect a chat platform in the [Novu Dashboard](https://dashboard.novu.co). + +3. Replace the demo handler in `app/novu/agents/support-agent.tsx` with your LLM call. + +Your agent is served at `/api/novu` and handles incoming messages via the Novu Bridge protocol. + +## Project Structure + +```text +app/ + api/novu/route.ts → Bridge endpoint serving your agent + novu/agents/ + index.ts → Agent exports + support-agent.tsx → Your agent handler (edit this!) + page.tsx → Landing page +``` + +## Agent API + +Your agent handler receives a context object with: + +| Method / Property | Description | +|---|---| +| `ctx.message` | The inbound message (text, author, timestamp) | +| `ctx.conversation` | Current conversation state and metadata | +| `ctx.history` | Recent conversation history | +| `ctx.subscriber` | Resolved subscriber info | +| `ctx.platform` | Source platform (slack, teams, whatsapp) | +| `ctx.reply(content)` | Send a reply (text, Markdown, or Card) | +| `ctx.metadata.set(k, v)` | Set conversation metadata | +| `ctx.resolve(summary?)` | Mark conversation as resolved | +| `ctx.trigger(workflowId)` | Trigger a Novu workflow | + +## Wiring Up Your LLM + +Replace the demo handler in `app/novu/agents/support-agent.tsx` with your LLM call: + +```typescript +onMessage: async (ctx) => { + const response = await openai.chat.completions.create({ + model: 'gpt-4', + messages: [ + { role: 'system', content: 'You are a helpful support agent.' }, + ...ctx.history.map((h) => ({ role: h.role, content: h.content })), + { role: 'user', content: ctx.message?.text ?? '' }, + ], + }); + + await ctx.reply(response.choices[0].message.content ?? ''); +}, +``` + +## Learn More + +- [Novu Agent Docs](https://docs.novu.co/agents) +- [Novu Framework SDK](https://docs.novu.co/framework) +- [Next.js Documentation](https://nextjs.org/docs) diff --git a/packages/novu/src/commands/init/templates/app-agent/ts/app/api/novu/route.ts b/packages/novu/src/commands/init/templates/app-agent/ts/app/api/novu/route.ts new file mode 100644 index 00000000000..74faa4fa17e --- /dev/null +++ b/packages/novu/src/commands/init/templates/app-agent/ts/app/api/novu/route.ts @@ -0,0 +1,6 @@ +import { serve } from '@novu/framework/next'; +import { supportAgent } from '../../novu/agents'; + +export const { GET, POST, OPTIONS } = serve({ + agents: [supportAgent], +}); diff --git a/packages/novu/src/commands/init/templates/app-agent/ts/app/globals.css b/packages/novu/src/commands/init/templates/app-agent/ts/app/globals.css new file mode 100644 index 00000000000..d8e9f7044f7 --- /dev/null +++ b/packages/novu/src/commands/init/templates/app-agent/ts/app/globals.css @@ -0,0 +1,19 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background: #fafafa; + color: #171717; +} + +@media (prefers-color-scheme: dark) { + body { + background: #0a0a0a; + color: #fafafa; + } +} diff --git a/packages/novu/src/commands/init/templates/app-agent/ts/app/layout.tsx b/packages/novu/src/commands/init/templates/app-agent/ts/app/layout.tsx new file mode 100644 index 00000000000..873f4383bff --- /dev/null +++ b/packages/novu/src/commands/init/templates/app-agent/ts/app/layout.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from 'next'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'Novu Agent', + description: 'Conversational AI agent powered by Novu', +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + + return ( + + {children} + + ); +} diff --git a/packages/novu/src/commands/init/templates/app-agent/ts/app/novu/agents/index.ts b/packages/novu/src/commands/init/templates/app-agent/ts/app/novu/agents/index.ts new file mode 100644 index 00000000000..2ed8d1e7c4f --- /dev/null +++ b/packages/novu/src/commands/init/templates/app-agent/ts/app/novu/agents/index.ts @@ -0,0 +1 @@ +export { supportAgent } from './support-agent'; diff --git a/packages/novu/src/commands/init/templates/app-agent/ts/app/novu/agents/support-agent.tsx b/packages/novu/src/commands/init/templates/app-agent/ts/app/novu/agents/support-agent.tsx new file mode 100644 index 00000000000..11ccb0834c5 --- /dev/null +++ b/packages/novu/src/commands/init/templates/app-agent/ts/app/novu/agents/support-agent.tsx @@ -0,0 +1,58 @@ +/** @jsxImportSource @novu/framework */ +import { agent, Card, CardText, Actions, Button } from '@novu/framework'; + +export const supportAgent = agent('support-agent', { + onMessage: async (ctx) => { + const text = (ctx.message?.text ?? '').toLowerCase(); + const isFirstMessage = ctx.conversation.messageCount <= 1; + + if (isFirstMessage) { + ctx.metadata.set('topic', 'unknown'); + await ctx.reply( + + How can I help you today? + +