Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .source
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BadRequestException, forwardRef, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { PinoLogger, shortId } from '@novu/application-generic';
import {
AgentRepository,
ConversationActivityRepository,
ConversationActivityTypeEnum,
ConversationChannel,
Expand All @@ -10,11 +11,11 @@ import {
SubscriberRepository,
} from '@novu/dal';
import { AgentEventEnum } from '../../dtos/agent-event.enum';
import type { ReplyContentDto } from '../../dtos/agent-reply-payload.dto';
import { AgentConfigResolver } 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';
import type { ReplyContentDto } from '../../dtos/agent-reply-payload.dto';
import { HandleAgentReplyCommand } from './handle-agent-reply.command';

@Injectable()
Expand All @@ -23,6 +24,7 @@ export class HandleAgentReply {
private readonly conversationRepository: ConversationRepository,
private readonly activityRepository: ConversationActivityRepository,
private readonly subscriberRepository: SubscriberRepository,
private readonly agentRepository: AgentRepository,
@Inject(forwardRef(() => ChatSdkService))
private readonly chatSdkService: ChatSdkService,
private readonly bridgeExecutor: BridgeExecutorService,
Expand Down Expand Up @@ -53,14 +55,38 @@ export class HandleAgentReply {

const channel = this.getPrimaryChannel(conversation);

const agent = await this.agentRepository.findOne(
{
_environmentId: command.environmentId,
_organizationId: command.organizationId,
identifier: command.agentIdentifier,
},
{ name: 1, identifier: 1 }
);
const agentName = agent?.name;

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if (command.update) {
await this.deliverMessage(command, conversation, channel, command.update, ConversationActivityTypeEnum.UPDATE);
await this.deliverMessage(
command,
conversation,
channel,
command.update,
ConversationActivityTypeEnum.UPDATE,
agentName
);

return { status: 'update_sent' };
}

if (command.reply) {
await this.deliverMessage(command, conversation, channel, command.reply, ConversationActivityTypeEnum.MESSAGE);
await this.deliverMessage(
command,
conversation,
channel,
command.reply,
ConversationActivityTypeEnum.MESSAGE,
agentName
);

this.removeAckReaction(command, conversation, channel).catch((err) => {
this.logger.warn(err, `[agent:${command.agentIdentifier}] Failed to remove ack reaction`);
Expand Down Expand Up @@ -92,7 +118,8 @@ export class HandleAgentReply {
conversation: ConversationEntity,
channel: ConversationChannel,
content: ReplyContentDto,
type: ConversationActivityTypeEnum
type: ConversationActivityTypeEnum,
agentName?: string
): Promise<void> {
const textFallback = this.extractTextFallback(content);

Expand All @@ -111,8 +138,9 @@ export class HandleAgentReply {
integrationId: channel._integrationId,
platformThreadId: channel.platformThreadId,
agentId: command.agentIdentifier,
senderName: agentName,
content: textFallback,
richContent: (content.card || content.files?.length) ? (content as Record<string, unknown>) : undefined,
richContent: content.card || content.files?.length ? (content as Record<string, unknown>) : undefined,
type,
environmentId: command.environmentId,
organizationId: command.organizationId,
Expand Down Expand Up @@ -145,7 +173,8 @@ export class HandleAgentReply {
signals: HandleAgentReplyCommand['signals']
): Promise<void> {
const metadataSignals = (signals ?? []).filter(
(s): s is Extract<NonNullable<HandleAgentReplyCommand['signals']>[number], { type: 'metadata' }> => s.type === 'metadata'
(s): s is Extract<NonNullable<HandleAgentReplyCommand['signals']>[number], { type: 'metadata' }> =>
s.type === 'metadata'
);

if (metadataSignals.length) {
Expand Down
164 changes: 164 additions & 0 deletions apps/dashboard/src/api/conversations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { getDateRangeInMs, type IEnvironment } from '@novu/shared';
import { get } from './api.client';

export type ConversationFilters = {
dateRange?: string;
subscriberId?: string;
provider?: string[];
conversationId?: string;
status?: string;
};

export type ParticipantSubscriberData = {
firstName?: string;
lastName?: string;
avatar?: string;
subscriberId: string;
};

export type ParticipantAgentData = {
name: string;
identifier: string;
};

export type ConversationParticipantDto = {
type: string;
id: string;
subscriber?: ParticipantSubscriberData | null;
agent?: ParticipantAgentData | null;
};

export type ConversationChannelDto = {
platform: string;
_integrationId: string;
platformThreadId: string;
};

export type ConversationDto = {
_id: string;
identifier: string;
_agentId: string;
participants?: ConversationParticipantDto[];
channels?: ConversationChannelDto[];
status: string;
title: string;
metadata: Record<string, unknown>;
_environmentId: string;
_organizationId: string;
createdAt: string;
lastActivityAt: string;
};

export type ConversationsListResponse = {
data: ConversationDto[];
page: number;
totalCount: number;
pageSize: number;
hasMore: boolean;
};

export function getConversationsList({
environment,
page,
limit,
filters,
signal,
}: {
environment: IEnvironment;
page: number;
limit: number;
filters?: ConversationFilters;
signal?: AbortSignal;
}): Promise<ConversationsListResponse> {
const searchParams = new URLSearchParams();
searchParams.append('page', page.toString());
searchParams.append('limit', limit.toString());

if (filters?.status) {
searchParams.append('status', filters.status);
}

if (filters?.subscriberId) {
searchParams.append('subscriberId', filters.subscriberId);
}

if (filters?.dateRange) {
const after = new Date(Date.now() - getDateRangeInMs(filters.dateRange));
searchParams.append('after', after.toISOString());
}

if (filters?.provider?.length) {
for (const p of filters.provider) {
searchParams.append('provider', p);
}
}

if (filters?.conversationId) {
searchParams.append('conversationId', filters.conversationId);
}

return get<ConversationsListResponse>(`/conversations?${searchParams.toString()}`, {
environment,
signal,
});
}

export type ConversationActivityDto = {
_id: string;
identifier: string;
_conversationId: string;
type: 'message' | 'update' | 'signal';
content: string;
platform: string;
_integrationId: string;
platformThreadId: string;
senderType: 'subscriber' | 'platform_user' | 'agent' | 'system';
senderId: string;
senderName?: string;
platformMessageId?: string;
signalData?: { type: string; payload?: Record<string, unknown> };
_environmentId: string;
_organizationId: string;
createdAt: string;
};

export type ConversationActivitiesResponse = {
data: ConversationActivityDto[];
page: number;
totalCount: number;
pageSize: number;
hasMore: boolean;
};

/** `conversationIdentifier` is the public `identifier` field — the API resolves by identifier, not Mongo `_id`. */
export function getConversation(
conversationIdentifier: string,
environment: IEnvironment
): Promise<ConversationDto> {
return get<ConversationDto>(`/conversations/${encodeURIComponent(conversationIdentifier)}`, {
environment,
});
}

export function getConversationActivities({
conversationIdentifier,
environment,
page = 0,
limit = 50,
signal,
}: {
conversationIdentifier: string;
environment: IEnvironment;
page?: number;
limit?: number;
signal?: AbortSignal;
}): Promise<ConversationActivitiesResponse> {
const searchParams = new URLSearchParams();
searchParams.append('page', page.toString());
searchParams.append('limit', limit.toString());

return get<ConversationActivitiesResponse>(
`/conversations/${encodeURIComponent(conversationIdentifier)}/activities?${searchParams.toString()}`,
{ environment, signal }
);
}
14 changes: 14 additions & 0 deletions apps/dashboard/src/components/conversations/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { CONVERSATIONAL_PROVIDERS } from '@novu/shared';
import { ConversationFiltersData } from '@/types/conversation';

export const PROVIDER_OPTIONS = CONVERSATIONAL_PROVIDERS.filter((p) => !p.comingSoon).map((p) => ({
label: p.displayName,
value: p.providerId,
}));

export const defaultConversationFilters: ConversationFiltersData = {
dateRange: '24h',
subscriberId: '',
provider: [],
conversationId: '',
} as const;
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { RiArrowDownSLine, RiArrowUpSLine, RiCloseFill } from 'react-icons/ri';
import { Separator } from '@/components/primitives/separator';
import { Skeleton } from '@/components/primitives/skeleton';
import {
useFetchConversation,
useFetchConversationActivities,
} from '@/hooks/use-fetch-conversation-activities';
import { ConversationOverview } from './conversation-overview';
import { ConversationTimeline } from './conversation-timeline';

type ConversationDetailProps = {
conversationId: string;
onClose?: () => void;
onNavigate?: (direction: 'prev' | 'next') => void;
};

export function ConversationDetail({ conversationId, onClose, onNavigate }: ConversationDetailProps) {
const { conversation, isLoading: isConversationLoading } = useFetchConversation(conversationId);
const { activities, totalCount, isLoading: isActivitiesLoading } =
useFetchConversationActivities(conversationId);

return (
<div className="flex h-full flex-col">
<div className="flex h-8 shrink-0 items-center justify-between px-2">
<span className="text-text-strong text-label-sm font-medium">Conversation</span>
<div className="flex items-center gap-0.5">
{onNavigate && (
<>
<button
onClick={() => onNavigate('prev')}
className="text-text-soft hover:text-text-strong rounded p-0.5"
>
<RiArrowUpSLine className="size-4" />
</button>
<button
onClick={() => onNavigate('next')}
className="text-text-soft hover:text-text-strong rounded p-0.5"
>
<RiArrowDownSLine className="size-4" />
</button>
</>
)}
{onNavigate && onClose && <div className="bg-stroke-soft mx-0.5 h-4 w-px" />}
{onClose && (
<button onClick={onClose} className="text-text-soft hover:text-text-strong rounded p-0.5">
<RiCloseFill className="size-4" />
</button>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)}
</div>
</div>

<div className="flex-1 overflow-y-auto">
{isConversationLoading ? (
<OverviewSkeleton />
) : conversation ? (
<div className="px-3 pb-2">
<ConversationOverview conversation={conversation} />
</div>
) : null}

<Separator />

<ConversationTimeline
activities={activities}
isLoading={isActivitiesLoading}
totalCount={totalCount}
/>
</div>
</div>
);
}

function OverviewSkeleton() {
return (
<div className="flex flex-col gap-2 px-3 py-2">
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-36" />
<Skeleton className="h-4 w-32" />
</div>
<div className="border-stroke-soft flex flex-col gap-1 rounded-lg border p-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-1">
<Skeleton className="h-3.5 w-24" />
<Skeleton className="h-3.5 w-32" />
</div>
))}
</div>
<div className="border-stroke-soft rounded-lg border p-2">
<div className="flex items-center gap-2">
<Skeleton className="size-8 rounded-full" />
<div className="flex flex-1 flex-col gap-1">
<Skeleton className="h-3.5 w-24" />
<Skeleton className="h-3 w-36" />
</div>
</div>
</div>
</div>
);
}
Loading
Loading