diff --git a/apps/api/src/app/connect/connect.module.ts b/apps/api/src/app/connect/connect.module.ts index a2e9f5a643d..07be0893099 100644 --- a/apps/api/src/app/connect/connect.module.ts +++ b/apps/api/src/app/connect/connect.module.ts @@ -8,6 +8,7 @@ import { ConversationActivityRepository, ConversationRepository, EnvironmentRepository, + HumanInteractionRepository, IntegrationRepository, McpConnectionRepository, SubscriberRepository, @@ -35,6 +36,7 @@ import { ClaimKeylessConnect } from './usecases/claim-keyless-connect/claim-keyl AgentMcpServerRepository, McpConnectionRepository, EnvironmentRepository, + HumanInteractionRepository, ], exports: [ConnectClaimTokenService], }) diff --git a/apps/api/src/app/connect/services/connect-claim-token.service.ts b/apps/api/src/app/connect/services/connect-claim-token.service.ts index b63b0820ac5..dadd780852f 100644 --- a/apps/api/src/app/connect/services/connect-claim-token.service.ts +++ b/apps/api/src/app/connect/services/connect-claim-token.service.ts @@ -84,6 +84,30 @@ export class ConnectClaimTokenService { return issued; } + /** + * True once the claim token issued for this keyless environment has been + * consumed — i.e. its assets now live in a real organization. Best-effort: + * cache outages and missing tokens read as "not claimed". + */ + async isEnvironmentClaimed(environmentId: string): Promise { + if (!this.cacheService.cacheEnabled()) { + return false; + } + + try { + const token = await this.cacheService.get(`${ENV_TOKEN_KEY_PREFIX}{${environmentId}}`); + if (!token || !isConnectClaimTokenFormat(token)) { + return false; + } + + return await this.tokens.isTokenUsed(token); + } catch (error) { + this.logger.warn({ err: error, environmentId }, 'Failed to read connect claim state for environment'); + + return false; + } + } + async isSignupCtaPosted(conversationId: string): Promise { if (!this.cacheService.cacheEnabled()) { return false; diff --git a/apps/api/src/app/connect/usecases/claim-keyless-connect/claim-keyless-connect.usecase.ts b/apps/api/src/app/connect/usecases/claim-keyless-connect/claim-keyless-connect.usecase.ts index ec2a7931d7f..31a99d31519 100644 --- a/apps/api/src/app/connect/usecases/claim-keyless-connect/claim-keyless-connect.usecase.ts +++ b/apps/api/src/app/connect/usecases/claim-keyless-connect/claim-keyless-connect.usecase.ts @@ -16,6 +16,7 @@ import { ConversationRepository, EnvironmentEntity, EnvironmentRepository, + HumanInteractionRepository, IntegrationRepository, McpConnectionRepository, SubscriberRepository, @@ -49,6 +50,7 @@ export class ClaimKeylessConnect { private readonly subscriberRepository: SubscriberRepository, private readonly agentMcpServerRepository: AgentMcpServerRepository, private readonly mcpConnectionRepository: McpConnectionRepository, + private readonly humanInteractionRepository: HumanInteractionRepository, private readonly logger: PinoLogger ) { this.logger.setContext(this.constructor.name); @@ -106,6 +108,7 @@ export class ClaimKeylessConnect { await this.conversationActivityRepository.update(sourceScope, { $set: target }, { session }); await this.agentMcpServerRepository.update(sourceScope, { $set: target }, { session }); await this.mcpConnectionRepository.update(sourceScope, { $set: target }, { session }); + await this.humanInteractionRepository.update(sourceScope, { $set: target }, { session }); await this.subscriberRepository.update( { ...sourceScope, subscriberId: { $ne: KEYLESS_SUBSCRIBER_ID } }, { $set: target }, diff --git a/apps/api/src/app/human/e2e/human-interactions.e2e.ts b/apps/api/src/app/human/e2e/human-interactions.e2e.ts index 59c5d1851a4..6ee274755be 100644 --- a/apps/api/src/app/human/e2e/human-interactions.e2e.ts +++ b/apps/api/src/app/human/e2e/human-interactions.e2e.ts @@ -16,6 +16,7 @@ import { ChatInstanceRegistry } from '../../agents/conversation-runtime/ingress/ import { AgentInboundHandler } from '../../agents/conversation-runtime/ingress/inbound-turn.handler'; import { startTelegramApiStub, type TelegramApiStub } from '../../agents/e2e/helpers/telegram-api-stub'; import { AgentEventEnum } from '../../agents/shared/enums/agent-event.enum'; +import { ConnectClaimTokenService } from '../../connect/services/connect-claim-token.service'; const integrationRepository = new IntegrationRepository(); const agentIntegrationRepository = new AgentIntegrationRepository(); @@ -585,4 +586,98 @@ describe('Human interactions (create → deliver → resolve) #novu-v2', () => { expect(listRes.body.data.length).to.be.greaterThan(0); }); }); + + describe('keyless demo cap', () => { + let originalKeylessOrgId: string | undefined; + let originalCap: string | undefined; + + beforeEach(() => { + originalKeylessOrgId = process.env.KEYLESS_ORGANIZATION_ID; + originalCap = process.env.KEYLESS_HUMAN_INTERACTION_CAP; + process.env.KEYLESS_ORGANIZATION_ID = session.organization._id; + process.env.KEYLESS_HUMAN_INTERACTION_CAP = '2'; + }); + + afterEach(() => { + restoreEnv('KEYLESS_ORGANIZATION_ID', originalKeylessOrgId); + restoreEnv('KEYLESS_HUMAN_INTERACTION_CAP', originalCap); + }); + + function restoreEnv(name: string, value: string | undefined) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + + function telegramSends() { + return telegramApiStub.calls.filter((call) => call.method === 'sendMessage'); + } + + it('replaces delivery with the sign-up card once the cap is reached, and only sends it once', async () => { + expect((await createInteraction({ kind: 'tell', prompt: 'One.' })).status).to.equal(201); + expect((await createInteraction({ kind: 'tell', prompt: 'Two.' })).status).to.equal(201); + const sendsBefore = telegramSends().length; + + const third = await createInteraction({ kind: 'ask', prompt: 'Three?' }); + expect(third.status).to.equal(429, JSON.stringify(third.body)); + expect(third.body.code).to.equal('KEYLESS_HUMAN_CAP_REACHED'); + expect(third.body.cap).to.equal(2); + expect(third.body.claimUrl).to.match(/\/connect\/claim\?token=/); + expect(third.body.message).to.include(third.body.claimUrl); + + // The human got the CTA card on the channel the prompt would have used — not the prompt itself. + const sends = telegramSends(); + expect(sends.length).to.equal(sendsBefore + 1); + const cta = JSON.stringify(sends[sends.length - 1].payload); + expect(cta).to.include('connect/claim'); + expect(cta).to.include('Sign up'); + expect(cta).to.not.include('Three?'); + + // Nothing was persisted for the blocked call. + expect(await humanInteractionRepository.count({ _environmentId: session.environment._id })).to.equal(2); + + // A retrying agent keeps getting the 429 + link, but the human is not spammed again. + const fourth = await createInteraction({ kind: 'ask', prompt: 'Four?' }); + expect(fourth.status).to.equal(429); + expect(fourth.body.claimUrl).to.equal(third.body.claimUrl); + expect(telegramSends().length).to.equal(sendsBefore + 1); + }); + + it('does not apply to non-keyless organizations', async () => { + process.env.KEYLESS_ORGANIZATION_ID = 'some-other-org'; + + expect((await createInteraction({ kind: 'tell', prompt: 'One.' })).status).to.equal(201); + expect((await createInteraction({ kind: 'tell', prompt: 'Two.' })).status).to.equal(201); + expect((await createInteraction({ kind: 'tell', prompt: 'Three.' })).status).to.equal(201); + }); + + it('moves interactions with the claim and points a stale keyless credential at re-auth', async () => { + const created = await createInteraction({ kind: 'approve', prompt: 'Keep me?' }); + expect(created.status).to.equal(201, JSON.stringify(created.body)); + + const tokenService = testServer.getService(ConnectClaimTokenService); + const { token } = await tokenService.issueOrGetForEnvironment({ + env: session.environment._id, + org: session.organization._id, + }); + + const claimer = new UserSession(); + await claimer.initialize(); + const claimRes = await claimer.testAgent.post('/v1/connect/claim').send({ token }); + expect(claimRes.status).to.equal(200, JSON.stringify(claimRes.body)); + const targetEnvironmentId = claimRes.body.data.environmentId as string; + + const row = await humanInteractionRepository.findByIdentifier(targetEnvironmentId, created.body.data.id); + expect(row?._organizationId).to.equal(claimer.organization._id); + expect(await humanInteractionRepository.count({ _environmentId: session.environment._id })).to.equal(0); + + // The keyless env is now empty; the CLI must be told to re-auth, not to run setup again. + const after = await createInteraction({ kind: 'tell', prompt: 'Still here?' }); + expect(after.status).to.equal(403, JSON.stringify(after.body)); + expect(after.body.message).to.match(/claimed into your Novu account/); + expect(after.body.message).to.include('human setup --secret-key'); + }); + }); }); diff --git a/apps/api/src/app/human/human.module.ts b/apps/api/src/app/human/human.module.ts index b1339a9d9d5..2b2235c062e 100644 --- a/apps/api/src/app/human/human.module.ts +++ b/apps/api/src/app/human/human.module.ts @@ -8,6 +8,7 @@ import { } from '@novu/dal'; import { AgentsModule } from '../agents/agents.module'; import { AuthModule } from '../auth/auth.module'; +import { ConnectModule } from '../connect/connect.module'; import { SharedModule } from '../shared/shared.module'; import { HumanInteractionsController } from './human-interactions.controller'; import { HumanDeliveryService } from './services/human-delivery.service'; @@ -24,7 +25,7 @@ import { SetupHumanRelay } from './usecases/setup-human-relay/setup-human-relay. * Framework `ctx.*` helpers create in-thread cards via `CreateConversationInteraction`. */ @Module({ - imports: [SharedModule, AuthModule, AgentsModule], + imports: [SharedModule, AuthModule, AgentsModule, ConnectModule], controllers: [HumanInteractionsController], providers: [ HumanInteractionRepository, diff --git a/apps/api/src/app/human/services/human-delivery.service.ts b/apps/api/src/app/human/services/human-delivery.service.ts index 4e23dbf06d1..f621ce408ec 100644 --- a/apps/api/src/app/human/services/human-delivery.service.ts +++ b/apps/api/src/app/human/services/human-delivery.service.ts @@ -17,6 +17,7 @@ import { } from '@novu/shared'; import { OutboundGateway } from '../../agents/conversation-runtime/egress/outbound.gateway'; import { buildPendingContent } from '../../agents/human-relay/human-card.builder'; +import type { ReplyContentDto } from '../../agents/shared/dtos/agent-reply-payload.dto'; export interface ResolvedHumanTarget { platform: string; @@ -139,12 +140,21 @@ export class HumanDeliveryService { async deliver( interaction: HumanInteractionEntity, target: ResolvedHumanTarget + ): Promise<{ platformMessageId: string; platformThreadId: string }> { + return this.deliverContent(interaction._agentId, target, buildPendingContent(interaction)); + } + + /** One-off DM of arbitrary content (e.g. the keyless sign-up CTA) to an already-resolved target. */ + async deliverContent( + agentId: string, + target: ResolvedHumanTarget, + content: ReplyContentDto ): Promise<{ platformMessageId: string; platformThreadId: string }> { const sent = await this.outboundGateway.sendDirectMessage( - interaction._agentId, + agentId, target.integrationIdentifier, target.platformUserId, - buildPendingContent(interaction) + content ); return { platformMessageId: sent.messageId, platformThreadId: sent.platformThreadId }; diff --git a/apps/api/src/app/human/usecases/create-interaction/create-interaction.usecase.spec.ts b/apps/api/src/app/human/usecases/create-interaction/create-interaction.usecase.spec.ts index abd4d2af785..6396c173448 100644 --- a/apps/api/src/app/human/usecases/create-interaction/create-interaction.usecase.spec.ts +++ b/apps/api/src/app/human/usecases/create-interaction/create-interaction.usecase.spec.ts @@ -19,6 +19,7 @@ describe('CreateInteraction', () => { }; const humanInteractionRepository = { countPendingForSubscriber: sinon.stub().resolves(0), + count: sinon.stub().resolves(0), create: sinon.stub().resolves(created), stampDelivery: sinon.stub().resolves(undefined), markDeliveredIfPending: sinon.stub().resolves({ ...created, status: HumanInteractionStatusEnum.DELIVERED }), @@ -34,12 +35,20 @@ describe('CreateInteraction', () => { platformUserId: '777', }), deliver: sinon.stub().resolves({ platformMessageId: 'msg-1', platformThreadId: 'thread-1' }), + deliverContent: sinon.stub().resolves({ platformMessageId: 'cta-1', platformThreadId: 'thread-1' }), + }; + const connectClaimTokenService = { + isEnvironmentClaimed: sinon.stub().resolves(false), + issueOrGetForEnvironment: sinon.stub().resolves({ token: 'tok', expiresAt: '2026-01-08T00:00:00.000Z' }), + isSignupCtaPosted: sinon.stub().resolves(false), + tryMarkSignupCtaPosted: sinon.stub().resolves(true), }; const logger = { setContext: sinon.stub(), warn: sinon.stub() }; const usecase = new CreateInteraction( humanInteractionRepository as any, agentRepository as any, deliveryService as any, + connectClaimTokenService as any, logger as any ); const command = { @@ -59,6 +68,7 @@ describe('CreateInteraction', () => { agentRepository, deliveryService, humanInteractionRepository, + connectClaimTokenService, }; } @@ -271,4 +281,152 @@ describe('CreateInteraction', () => { expect(result.to).to.deep.equal(['sub-2']); expect(result.failedTo).to.deep.equal(['sub-1']); }); + + describe('keyless demo cap', () => { + let originalKeylessOrgId: string | undefined; + let originalCap: string | undefined; + + beforeEach(() => { + originalKeylessOrgId = process.env.KEYLESS_ORGANIZATION_ID; + originalCap = process.env.KEYLESS_HUMAN_INTERACTION_CAP; + process.env.KEYLESS_ORGANIZATION_ID = 'org1'; + process.env.KEYLESS_HUMAN_INTERACTION_CAP = '2'; + }); + + afterEach(() => { + restoreEnv('KEYLESS_ORGANIZATION_ID', originalKeylessOrgId); + restoreEnv('KEYLESS_HUMAN_INTERACTION_CAP', originalCap); + }); + + function restoreEnv(name: string, value: string | undefined) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + + it('creates normally while under the cap', async () => { + const { usecase, command, agentRepository, humanInteractionRepository, deliveryService } = setup(); + agentRepository.findOne.resolves({ _id: 'agent-hitl', identifier: 'human-hitl' }); + humanInteractionRepository.count.resolves(1); + + await usecase.execute(command as any); + + expect(humanInteractionRepository.create.calledOnce).to.equal(true); + expect(deliveryService.deliverContent.called).to.equal(false); + }); + + it('sends the sign-up card instead of the prompt and returns 429 with the claim link once capped', async () => { + const { + usecase, + command, + agentRepository, + humanInteractionRepository, + deliveryService, + connectClaimTokenService, + } = setup(); + agentRepository.findOne.resolves({ _id: 'agent-hitl', identifier: 'human-hitl' }); + humanInteractionRepository.count.resolves(2); + + let thrown: unknown; + try { + await usecase.execute(command as any); + } catch (error) { + thrown = error; + } + + expect(thrown).to.be.instanceOf(HttpException); + const response = (thrown as HttpException).getResponse() as Record; + expect((thrown as HttpException).getStatus()).to.equal(429); + expect(response.code).to.equal('KEYLESS_HUMAN_CAP_REACHED'); + expect(response.cap).to.equal(2); + expect(response.claimUrl).to.match(/\/connect\/claim\?token=tok$/); + expect(response.message).to.include(response.claimUrl as string); + + expect(humanInteractionRepository.create.called).to.equal(false); + expect(deliveryService.deliver.called).to.equal(false); + expect(deliveryService.deliverContent.calledOnce).to.equal(true); + expect(deliveryService.deliverContent.firstCall.args[0]).to.equal('agent-hitl'); + expect(JSON.stringify(deliveryService.deliverContent.firstCall.args[2])).to.include('Sign up'); + expect(connectClaimTokenService.tryMarkSignupCtaPosted.calledOnceWith('human:env1')).to.equal(true); + }); + + it('does not resend the card when the CTA was already posted for the environment', async () => { + const { + usecase, + command, + agentRepository, + humanInteractionRepository, + deliveryService, + connectClaimTokenService, + } = setup(); + agentRepository.findOne.resolves({ _id: 'agent-hitl', identifier: 'human-hitl' }); + humanInteractionRepository.count.resolves(5); + connectClaimTokenService.isSignupCtaPosted.resolves(true); + + let status: number | undefined; + try { + await usecase.execute(command as any); + } catch (error) { + status = (error as HttpException).getStatus(); + } + + expect(status).to.equal(429); + expect(deliveryService.deliverContent.called).to.equal(false); + }); + + it('still returns 429 when the claim link cannot be issued', async () => { + const { + usecase, + command, + agentRepository, + humanInteractionRepository, + deliveryService, + connectClaimTokenService, + } = setup(); + agentRepository.findOne.resolves({ _id: 'agent-hitl', identifier: 'human-hitl' }); + humanInteractionRepository.count.resolves(2); + connectClaimTokenService.issueOrGetForEnvironment.rejects(new Error('cache down')); + + let thrown: HttpException | undefined; + try { + await usecase.execute(command as any); + } catch (error) { + thrown = error as HttpException; + } + + expect(thrown?.getStatus()).to.equal(429); + expect((thrown?.getResponse() as Record).claimUrl).to.equal(undefined); + expect(deliveryService.deliverContent.called).to.equal(false); + }); + + it('rejects a claimed keyless environment with a re-auth hint before looking up the agent', async () => { + const { usecase, command, agentRepository, connectClaimTokenService } = setup(); + connectClaimTokenService.isEnvironmentClaimed.resolves(true); + + let thrown: HttpException | undefined; + try { + await usecase.execute(command as any); + } catch (error) { + thrown = error as HttpException; + } + + expect(thrown?.getStatus()).to.equal(403); + expect(thrown?.message).to.include('human setup --secret-key'); + expect(agentRepository.findOne.called).to.equal(false); + }); + + it('ignores the cap for non-keyless organizations', async () => { + process.env.KEYLESS_ORGANIZATION_ID = 'some-other-org'; + const { usecase, command, agentRepository, humanInteractionRepository, connectClaimTokenService } = setup(); + agentRepository.findOne.resolves({ _id: 'agent-hitl', identifier: 'human-hitl' }); + humanInteractionRepository.count.resolves(50); + + await usecase.execute(command as any); + + expect(humanInteractionRepository.create.calledOnce).to.equal(true); + expect(connectClaimTokenService.isEnvironmentClaimed.called).to.equal(false); + }); + }); }); diff --git a/apps/api/src/app/human/usecases/create-interaction/create-interaction.usecase.ts b/apps/api/src/app/human/usecases/create-interaction/create-interaction.usecase.ts index 9d86a76aa67..cfb4e28bb25 100644 --- a/apps/api/src/app/human/usecases/create-interaction/create-interaction.usecase.ts +++ b/apps/api/src/app/human/usecases/create-interaction/create-interaction.usecase.ts @@ -1,7 +1,12 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, HttpException, Injectable, NotFoundException } from '@nestjs/common'; import { InstrumentUsecase, PinoLogger } from '@novu/application-generic'; import { AgentEntity, AgentRepository, HumanInteractionRepository } from '@novu/dal'; import { normalizeHumanTo } from '@novu/shared'; +import type { ReplyContentDto } from '../../../agents/shared/dtos/agent-reply-payload.dto'; +import { ConnectClaimTokenService } from '../../../connect/services/connect-claim-token.service'; +import { resolveKeylessHumanInteractionCap } from '../../../keyless/keyless-abuse.constants'; +import { isKeylessOrganization } from '../../../keyless/keyless-organization.helpers'; +import { buildConnectClaimUrl, buildKeylessHumanSignupCard } from '../../../keyless/keyless-signup.helpers'; import { type InteractionResponseDto, toInteractionResponse } from '../../dtos/interaction-response.dto'; import { HumanDeliveryService } from '../../services/human-delivery.service'; import { @@ -14,12 +19,19 @@ import { import { DEFAULT_HUMAN_RELAY_IDENTIFIER } from '../setup-human-relay/setup-human-relay.usecase'; import { CreateInteractionCommand } from './create-interaction.command'; +/** Machine-readable code on the 429 body so `@novu/human` can branch without parsing prose. */ +export const KEYLESS_HUMAN_CAP_REACHED_CODE = 'KEYLESS_HUMAN_CAP_REACHED'; + +export const KEYLESS_HUMAN_CLAIMED_MESSAGE = + 'This demo workspace was claimed into your Novu account. Run `human setup --secret-key ` (or set NOVU_SECRET_KEY) to continue.'; + @Injectable() export class CreateInteraction { constructor( private readonly humanInteractionRepository: HumanInteractionRepository, private readonly agentRepository: AgentRepository, private readonly deliveryService: HumanDeliveryService, + private readonly connectClaimTokenService: ConnectClaimTokenService, private readonly logger: PinoLogger ) { this.logger.setContext(this.constructor.name); @@ -29,12 +41,24 @@ export class CreateInteraction { async execute(command: CreateInteractionCommand): Promise { assertHumanChooseOptions(command.kind, command.options); + const isKeyless = isKeylessOrganization(command.organizationId); + + // Once claimed, the relay agent and channels live in the user's own + // environment; a stale keyless credential must not read as "run setup". + if (isKeyless && (await this.connectClaimTokenService.isEnvironmentClaimed(command.environmentId))) { + throw new ForbiddenException(KEYLESS_HUMAN_CLAIMED_MESSAGE); + } + const agent = await this.resolveAgent(command); const subscriberIds = normalizeHumanTo(command.to); if (subscriberIds.length === 0) { throw new BadRequestException('`to` must include at least one subscriberId'); } + if (isKeyless) { + await this.assertKeylessHumanCap(command, agent, subscriberIds); + } + await assertHumanPendingCap(this.humanInteractionRepository, { environmentId: command.environmentId, subscriberIds, @@ -43,18 +67,7 @@ export class CreateInteraction { `Human "${subscriberId}" already has ${pendingCount} pending interactions (cap ${cap}). Wait for answers or cancel stale ones with \`human list\`.`, }); - const resolved = await Promise.all( - subscriberIds.map(async (subscriberId) => ({ - subscriberId, - target: await this.deliveryService.resolveChannel({ - environmentId: command.environmentId, - organizationId: command.organizationId, - agentId: agent._id, - subscriberId, - via: command.via, - }), - })) - ); + const resolved = await this.resolveTargets(command, agent, subscriberIds); const interaction = await this.humanInteractionRepository.create( buildPendingHumanInteraction({ @@ -84,6 +97,112 @@ export class CreateInteraction { return toInteractionResponse(delivered.interaction, delivered.failedSubscriberIds); } + private async resolveTargets(command: CreateInteractionCommand, agent: AgentEntity, subscriberIds: string[]) { + return Promise.all( + subscriberIds.map(async (subscriberId) => ({ + subscriberId, + target: await this.deliveryService.resolveChannel({ + environmentId: command.environmentId, + organizationId: command.organizationId, + agentId: agent._id, + subscriberId, + via: command.via, + }), + })) + ); + } + + /** + * Keyless demo cap (`KEYLESS_HUMAN_INTERACTION_CAP`, counted across every + * interaction the environment ever created). Past it, the human gets the + * sign-up card on the channel the prompt would have used — once per + * environment, so a retrying agent does not spam them — and the caller gets + * a 429 carrying the same claim link. + */ + private async assertKeylessHumanCap( + command: CreateInteractionCommand, + agent: AgentEntity, + subscriberIds: string[] + ): Promise { + const cap = resolveKeylessHumanInteractionCap(); + const used = await this.humanInteractionRepository.count({ _environmentId: command.environmentId }); + + if (used < cap) { + return; + } + + const claimUrl = await this.resolveClaimUrl(command); + await this.postKeylessSignupCta(command, agent, subscriberIds, claimUrl); + + const message = claimUrl + ? `You've used the ${cap} free messages of this keyless demo. Sign up for a free Novu account to keep your channels and continue: ${claimUrl}` + : `You've used the ${cap} free messages of this keyless demo. Sign up for a free Novu account to keep your channels and continue.`; + + throw new HttpException( + { statusCode: 429, message, code: KEYLESS_HUMAN_CAP_REACHED_CODE, cap, ...(claimUrl ? { claimUrl } : {}) }, + 429 + ); + } + + private async resolveClaimUrl(command: CreateInteractionCommand): Promise { + try { + const { token } = await this.connectClaimTokenService.issueOrGetForEnvironment({ + env: command.environmentId, + org: command.organizationId, + }); + + return buildConnectClaimUrl(token); + } catch (err) { + this.logger.warn({ err, environmentId: command.environmentId }, 'Failed to issue keyless claim token'); + + return undefined; + } + } + + private async postKeylessSignupCta( + command: CreateInteractionCommand, + agent: AgentEntity, + subscriberIds: string[], + claimUrl: string | undefined + ): Promise { + if (!claimUrl) { + return; + } + + const ctaKey = `human:${command.environmentId}`; + + try { + if (await this.connectClaimTokenService.isSignupCtaPosted(ctaKey)) { + return; + } + + const content = { card: buildKeylessHumanSignupCard(claimUrl) } as ReplyContentDto; + let deliveredCount = 0; + + for (const subscriberId of subscriberIds) { + try { + const target = await this.deliveryService.resolveChannel({ + environmentId: command.environmentId, + organizationId: command.organizationId, + agentId: agent._id, + subscriberId, + via: command.via, + }); + await this.deliveryService.deliverContent(agent._id, target, content); + deliveredCount += 1; + } catch (err) { + this.logger.warn({ err, subscriberId }, 'Failed to deliver keyless signup CTA to one human'); + } + } + + if (deliveredCount > 0) { + await this.connectClaimTokenService.tryMarkSignupCtaPosted(ctaKey); + } + } catch (err) { + this.logger.warn({ err, environmentId: command.environmentId }, 'Failed to post keyless signup CTA'); + } + } + private async resolveAgent(command: CreateInteractionCommand): Promise { const identifier = command.agentIdentifier ?? DEFAULT_HUMAN_RELAY_IDENTIFIER; diff --git a/apps/api/src/app/keyless/keyless-abuse.constants.ts b/apps/api/src/app/keyless/keyless-abuse.constants.ts index 33bdafcf2f7..5a39d1d0a4a 100644 --- a/apps/api/src/app/keyless/keyless-abuse.constants.ts +++ b/apps/api/src/app/keyless/keyless-abuse.constants.ts @@ -24,6 +24,17 @@ export const KEYLESS_GENERATE_CAP_PER_IP_PER_DAY = parsePositiveIntEnv( export const KEYLESS_MAX_AGENTS_PER_ENV = parsePositiveIntEnv(process.env.KEYLESS_MAX_AGENTS_PER_ENV, 2); +/** + * How many `@novu/human` interactions a keyless environment may create before + * delivery is replaced by the sign-up CTA. Read at call time (not module load) + * so the value can change per process/test without a restart. + */ +export const KEYLESS_HUMAN_INTERACTION_CAP_DEFAULT = 5; + +export function resolveKeylessHumanInteractionCap(): number { + return parsePositiveIntEnv(process.env.KEYLESS_HUMAN_INTERACTION_CAP, KEYLESS_HUMAN_INTERACTION_CAP_DEFAULT); +} + export const KEYLESS_DAILY_COUNTER_TTL_SECONDS = 86_400; export const INCR_WITH_EXPIRE_SCRIPT = ` diff --git a/apps/api/src/app/keyless/keyless-signup.helpers.ts b/apps/api/src/app/keyless/keyless-signup.helpers.ts index 8a4067fdb73..443326ff1f2 100644 --- a/apps/api/src/app/keyless/keyless-signup.helpers.ts +++ b/apps/api/src/app/keyless/keyless-signup.helpers.ts @@ -68,3 +68,33 @@ export function buildKeylessSignupCard(claimUrl: string): CardElement { ], }; } + +/** + * Sent on the human's linked channel *instead of* the agent's message once a + * keyless `@novu/human` environment hits its interaction cap. + */ +export function buildKeylessHumanSignupCard(claimUrl: string): CardElement { + return { + type: 'card', + children: [ + { + type: 'text', + content: + "You've reached the limit of this free `human` demo. Sign up for a free Novu account to keep this " + + 'channel and your setup — your agents pick up right where they left off.', + }, + { type: 'divider' }, + { + type: 'actions', + children: [ + { + type: 'link-button', + label: 'Sign up & keep this setup', + url: claimUrl, + style: 'primary', + }, + ], + }, + ], + }; +} diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 8695da33670..86a91e16d87 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -31,6 +31,8 @@ }, "dependencies": { "@ai-sdk/react": "^3.0.51", + "@assistant-ui/react": "^0.15.16", + "@assistant-ui/react-markdown": "^0.14.12", "@better-auth/sso": "^1.6.23", "@calcom/embed-react": "1.5.2", "@clerk/react": "^6.11.3", @@ -143,6 +145,7 @@ "react-router-dom": "^7.18.2", "react-timezone-select": "^3.2.8", "recharts": "2.15.4", + "remark-gfm": "^4.0.1", "sanitize-html": "^2.17.5", "shiki": "^3.21.0", "sonner": "^1.7.0", diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/agent-message-to-thread-message.ts b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/agent-message-to-thread-message.ts new file mode 100644 index 00000000000..df4dae7c1bd --- /dev/null +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/agent-message-to-thread-message.ts @@ -0,0 +1,220 @@ +import type { ThreadMessageLike, ToolApprovalOption } from '@assistant-ui/react'; +import type { AgentCardElement, AgentMessage } from '@novu/react'; +import { APPROVAL_OPTIONS } from './approval-options'; + +const POWERED_BY = + /(?:\n+)?(?:_*Powered by\s*\[[^\]]+\]\([^)]+\)_*|_*\[Powered by Novu\]\([^)]+\)_*|Powered by\s*]+\|[^>]+>|Powered by\s*]*>[\s\S]*?<\/a>|Powered by Novu\u200B?)\s*$/i; + +function stripPoweredBy(text: string): string { + return text.replace(POWERED_BY, '').trimEnd(); +} + +function isPoweredByWatermark(content: string): boolean { + const trimmed = content + .trim() + .replace(/^_+|_+$/g, '') + .trim(); + + return /^powered by/i.test(trimmed) && /novu/i.test(trimmed); +} + +function brandedReplyMarkdown(card: AgentCardElement): string | null { + if (card.title?.trim()) return null; + if (card.subtitle?.trim()) return null; + if (card.imageUrl?.trim()) return null; + + const texts: string[] = []; + let sawWatermark = false; + + for (const child of card.children) { + if (child.type !== 'text') { + return null; + } + + const content = child.content; + if (!content) continue; + + if (isPoweredByWatermark(content)) { + sawWatermark = true; + continue; + } + + texts.push(content); + } + + if (!sawWatermark || texts.length === 0) { + return null; + } + + return texts.join('\n\n'); +} + +function approvalOptions(part: Extract): ToolApprovalOption[] { + const options: ToolApprovalOption[] = []; + + if (part.denyActionId) { + options.push(APPROVAL_OPTIONS.denied); + } + if (part.approveActionId) { + options.push(APPROVAL_OPTIONS.approved); + } + if (part.trustToolActionId) { + options.push(APPROVAL_OPTIONS['trust-tool']); + } + if (part.trustServerActionId && part.source?.type === 'mcp') { + options.push({ + ...APPROVAL_OPTIONS['trust-server'], + label: `Always allow ${part.source.serverName}`, + }); + } + + return options; +} + +function approvalGate(part: Extract) { + const options = approvalOptions(part); + + if (part.state === 'pending') { + return { id: part.approvalId, options }; + } + + return { + id: part.approvalId, + options, + approved: part.state === 'approved', + optionId: part.state === 'approved' ? APPROVAL_OPTIONS.approved.id : APPROVAL_OPTIONS.denied.id, + }; +} + +type ThreadContent = Exclude; + +export function agentMessageToThreadMessage(message: AgentMessage): ThreadMessageLike { + const content: ThreadContent[number][] = []; + const approvalByToolUseId = new Map( + message.parts + .filter((part): part is Extract => part.type === 'approval') + .map((part) => [part.toolUseId, part]) + ); + + const isStreaming = message.parts.some( + (part) => (part.type === 'text' || part.type === 'thinking') && part.state === 'streaming' + ); + const hasPendingApproval = message.parts.some((part) => part.type === 'approval' && part.state === 'pending'); + + for (const part of message.parts) { + switch (part.type) { + case 'text': { + const text = stripPoweredBy(part.text); + if (!text.trim()) break; + content.push({ + type: 'text', + text, + status: part.state === 'streaming' ? { type: 'running' } : { type: 'complete' }, + }); + break; + } + case 'thinking': { + if (!part.text.trim() && part.state !== 'streaming') break; + content.push({ + type: 'reasoning', + text: part.text || '\u200b', + status: part.state === 'streaming' ? { type: 'running' } : { type: 'complete' }, + }); + break; + } + case 'tool': { + if (approvalByToolUseId.has(part.toolUseId)) break; + content.push({ + type: 'tool-call', + toolCallId: part.toolUseId, + toolName: part.toolName, + argsText: JSON.stringify(part.input ?? {}), + result: part.output, + isError: part.state === 'output-error', + }); + break; + } + case 'approval': { + content.push({ + type: 'tool-call', + toolCallId: part.toolUseId, + toolName: part.toolName, + argsText: JSON.stringify(part.input ?? {}), + approval: approvalGate(part), + }); + break; + } + case 'card': { + const unwrapped = brandedReplyMarkdown(part.card); + if (unwrapped) { + const text = stripPoweredBy(unwrapped); + if (text.trim()) { + content.push({ type: 'text', text, status: { type: 'complete' } }); + } + break; + } + content.push({ + type: 'data', + name: 'novu-card', + data: part, + }); + break; + } + case 'mcp-connection': { + content.push({ type: 'data', name: 'novu-mcp', data: part }); + break; + } + case 'source': + break; + case 'file': { + content.push({ type: 'data', name: 'novu-file', data: part }); + break; + } + case 'data': { + content.push({ type: 'data', name: part.name, data: part.data }); + break; + } + default: + break; + } + } + + if (message.role === 'user') { + const threadMessageId = message.idempotencyKey ?? message.id; + + return { + id: threadMessageId, + role: 'user', + createdAt: new Date(message.createdAt), + content: content.length > 0 ? content : [{ type: 'text', text: '' }], + metadata: { + custom: { novuStatus: message.status, novuMessageId: message.id }, + }, + }; + } + + let status: ThreadMessageLike['status']; + if (isStreaming || message.status === 'sending') { + status = { type: 'running' }; + } else if (hasPendingApproval) { + status = { type: 'requires-action', reason: 'tool-calls' }; + } else { + status = { type: 'complete', reason: 'stop' }; + } + + return { + id: message.id, + role: 'assistant', + createdAt: new Date(message.createdAt), + content, + status, + metadata: { custom: { novuStatus: message.status } }, + }; +} + +export function textFromAppendContent(content: readonly { type: string; text?: string }[]): string { + return content + .filter((part) => part.type === 'text' && typeof part.text === 'string') + .map((part) => part.text ?? '') + .join(''); +} diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/approval-options.ts b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/approval-options.ts new file mode 100644 index 00000000000..edaa44f6360 --- /dev/null +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/approval-options.ts @@ -0,0 +1,25 @@ +import type { ToolApprovalOption } from '@assistant-ui/react'; +import type { AgentToolApprovalDecision } from '@novu/react'; + +export const APPROVAL_OPTIONS = { + denied: { id: 'denied', kind: 'reject-once', label: 'Deny' }, + approved: { id: 'approved', kind: 'allow-once', label: 'Approve once' }, + 'trust-tool': { id: 'trust-tool', kind: 'allow-always', label: 'Always allow this tool' }, + 'trust-server': { id: 'trust-server', kind: 'allow-always', label: 'Always allow server' }, +} as const satisfies Record; + +export function isApprovalOptionId(id: string): id is AgentToolApprovalDecision { + return id in APPROVAL_OPTIONS; +} + +export function decisionFromApprovalOption(optionId: string | undefined, approved: boolean): AgentToolApprovalDecision { + if (optionId && isApprovalOptionId(optionId)) { + return optionId; + } + + if (optionId) { + throw new Error(`Unknown approval option id: ${optionId}`); + } + + return approved ? 'approved' : 'denied'; +} diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/card-view.ts b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/card-view.ts new file mode 100644 index 00000000000..645ff96bd9a --- /dev/null +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/card-view.ts @@ -0,0 +1,103 @@ +import type { AgentCardChild, AgentCardElement } from '@novu/react'; +import { toSafeExternalUrl } from '@/utils/url'; + +export type CardButtonView = { id: string; label: string; value?: string; style?: string }; + +export type CardChildView = + | { type: 'text'; content: string } + | { type: 'divider' } + | { type: 'image'; url: string; alt: string } + | { type: 'link'; url: string; label: string } + | { type: 'actions'; buttons: CardButtonView[] }; + +export type CardView = { + title?: string; + subtitle?: string; + imageUrl?: string; + children: CardChildView[]; +}; + +function linkView(label: string, url: string): CardChildView | null { + const safeUrl = toSafeExternalUrl(url); + const trimmedLabel = label.trim(); + + return safeUrl && trimmedLabel ? { type: 'link', url: safeUrl, label: trimmedLabel } : null; +} + +function viewsFromAgentChild(child: AgentCardChild): CardChildView[] { + switch (child.type) { + case 'text': { + const content = child.content.trim(); + + return content ? [{ type: 'text', content }] : []; + } + case 'divider': + return [{ type: 'divider' }]; + case 'image': { + const url = toSafeExternalUrl(child.url); + + return url ? [{ type: 'image', url, alt: child.alt ?? '' }] : []; + } + case 'link': { + const view = linkView(child.label, child.url); + + return view ? [view] : []; + } + case 'button': + return [ + { + type: 'actions', + buttons: [{ id: child.id, label: child.label, value: child.value, style: child.style }], + }, + ]; + case 'actions': { + const views: CardChildView[] = []; + const buttons: CardButtonView[] = []; + + for (const actionChild of child.children) { + if (actionChild.type === 'button') { + buttons.push({ + id: actionChild.id, + label: actionChild.label, + value: actionChild.value, + style: actionChild.style, + }); + continue; + } + + if (actionChild.type === 'link-button') { + const view = linkView(actionChild.label, actionChild.url); + if (view) { + views.push(view); + } + } + } + + if (buttons.length > 0) { + views.push({ type: 'actions', buttons }); + } + + return views; + } + case 'section': + return child.children.flatMap((nested) => viewsFromAgentChild(nested)); + case 'fields': + return child.children + .map((field) => `${field.label}: ${field.value}`.trim()) + .filter(Boolean) + .map((content) => ({ type: 'text' as const, content })); + case 'table': + return []; + } +} + +export function cardViewFromElement(card: AgentCardElement): CardView { + const children = Array.isArray(card.children) ? card.children : []; + + return { + title: card.title?.trim() || undefined, + subtitle: card.subtitle?.trim() || undefined, + imageUrl: toSafeExternalUrl(card.imageUrl), + children: children.flatMap((child) => viewsFromAgentChild(child)), + }; +} diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/approval-card.tsx b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/approval-card.tsx new file mode 100644 index 00000000000..9a52ae28e21 --- /dev/null +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/approval-card.tsx @@ -0,0 +1,121 @@ +import type { ComponentProps } from 'react'; +import { RiCheckLine, RiCloseLine, RiLoader4Line, RiTerminalBoxLine } from 'react-icons/ri'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/primitives/dropdown-menu'; +import { cn } from '@/utils/ui'; + +export type ApprovalState = 'request' | 'running' | 'done' | 'denied'; + +export type AlwaysAllowOption = { + label: string; + onSelect: () => void; +}; + +const ghostPill = + 'text-text-soft hover:bg-bg-weak hover:text-text-strong focus-visible:ring-stroke-soft h-8 shrink-0 rounded-full px-3.5 text-xs font-medium whitespace-nowrap transition-[background-color,color,scale] duration-150 outline-none focus-visible:ring-2 active:scale-[0.96]'; + +const primaryPill = + 'bg-primary-base text-static-white hover:bg-primary-darker focus-visible:ring-primary-base h-8 shrink-0 rounded-full px-3.5 text-xs font-medium whitespace-nowrap transition-[background-color,scale] duration-150 outline-none focus-visible:ring-2 focus-visible:ring-offset-2 active:scale-[0.96]'; + +export function ApprovalCard({ + state, + command, + title, + subtitle, + onAllowOnce, + alwaysAllowOptions = [], + onDeny, + className, + ...props +}: Omit, 'children' | 'state' | 'command' | 'title' | 'subtitle' | 'onAllowOnce' | 'onDeny'> & { + state: ApprovalState; + command: string; + title: string; + subtitle: string; + onAllowOnce?: () => void; + alwaysAllowOptions?: AlwaysAllowOption[]; + onDeny?: () => void; +}) { + return ( +
+
+ + + +
+

{title}

+

{subtitle}

+
+
+ +
+        {command}
+      
+ +
+ {state === 'request' ? ( + <> + +
+ {alwaysAllowOptions.length === 1 ? ( + + ) : alwaysAllowOptions.length > 1 ? ( + + + + + + {alwaysAllowOptions.map((option) => ( + + {option.label} + + ))} + + + ) : null} + +
+ + ) : ( +
+ {state === 'running' ? ( + <> + + Approved, running + + ) : state === 'denied' ? ( + <> + + Denied + + ) : ( + <> + + Approved + + )} +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/connect-card.tsx b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/connect-card.tsx new file mode 100644 index 00000000000..85c967c92ee --- /dev/null +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/connect-card.tsx @@ -0,0 +1,76 @@ +import type { AgentMessage } from '@novu/react'; +import { RiCheckLine, RiCloseLine, RiExternalLinkLine } from 'react-icons/ri'; +import { McpIcon } from '@/components/agents/mcp-icon'; +import { Button } from '@/components/primitives/button'; +import { toSafeExternalUrl } from '@/utils/url'; + +type McpConnectionPart = Extract; + +export function ConnectCard({ part }: { part: McpConnectionPart }) { + const connectUrl = toSafeExternalUrl(part.authorizeUrl); + const autoApproveUrl = toSafeExternalUrl(part.authorizeUrlWithAutoApprove); + const pending = part.state === 'pending'; + const connected = part.state === 'connected'; + + if (!pending) { + return ( +
+ {connected ? ( + + ) : ( + + )} + + {connected ? 'Connected' : 'Failed to connect'}: {part.displayName} + +
+ ); + } + + return ( +
+
+ + + +
+

Connect {part.displayName}

+

Connection request

+
+
+ +

+ {part.message ?? `Connect your ${part.displayName} account so the agent can continue.`} +

+ +
+ {autoApproveUrl ? ( + + ) : null} + {connectUrl ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/day-separator.tsx b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/day-separator.tsx new file mode 100644 index 00000000000..855c5618786 --- /dev/null +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/day-separator.tsx @@ -0,0 +1,72 @@ +import { useAuiState } from '@assistant-ui/react'; +import type { FC, ReactNode } from 'react'; +import { cn } from '@/utils/ui'; + +function asDate(value: Date | string | number | undefined): Date | null { + if (value == null) return null; + const date = value instanceof Date ? value : new Date(value); + + return Number.isNaN(date.getTime()) ? null : date; +} + +function dayKey(date: Date): string { + return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; +} + +function dayLabel(date: Date): string { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const day = new Date(date); + day.setHours(0, 0, 0, 0); + const diffDays = Math.round((today.getTime() - day.getTime()) / 86_400_000); + if (diffDays === 0) return 'Today'; + if (diffDays === 1) return 'Yesterday'; + + return date.toLocaleDateString(undefined, { + weekday: 'long', + month: 'short', + day: 'numeric', + }); +} + +function timeLabel(date: Date): string { + return date.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + }); +} + +export const MessageChronology: FC<{ children: ReactNode }> = ({ children }) => { + const role = useAuiState((s) => s.message.role); + const createdAt = useAuiState((s) => s.message.createdAt); + const index = useAuiState((s) => s.message.index); + const prevCreatedAt = useAuiState((s) => s.thread.messages[index - 1]?.createdAt); + + const date = asDate(createdAt); + const prev = asDate(prevCreatedAt); + const showDay = date != null && (index === 0 || !prev || dayKey(date) !== dayKey(prev)); + + return ( +
+ {showDay && date ? ( +
+ + {dayLabel(date)} + +
+ ) : null} + {children} + {date ? ( + + ) : null} +
+ ); +}; diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/error-state.tsx b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/error-state.tsx new file mode 100644 index 00000000000..315a57bcc43 --- /dev/null +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/error-state.tsx @@ -0,0 +1,55 @@ +import type { ComponentProps } from 'react'; +import { RiErrorWarningLine, RiRefreshLine } from 'react-icons/ri'; +import { Shimmer } from '@/components/ai-elements/shimmer'; +import { cn } from '@/utils/ui'; + +export type ErrorStateProps = Omit, 'children' | 'role'> & { + title: string; + detail: string; + retrying: boolean; + onRetry?: () => void; +}; + +export function ErrorState({ title, detail, retrying, onRetry, className, ...props }: ErrorStateProps) { + if (retrying) { + return ( +
+ + + Retrying + +
+ ); + } + + return ( +
+ +
+

{title}

+

{detail}

+
+ {onRetry ? ( + + ) : null} +
+ ); +} diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/file.tsx b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/file.tsx new file mode 100644 index 00000000000..7e69c72298e --- /dev/null +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/file.tsx @@ -0,0 +1,43 @@ +import type { FileMessagePartComponent } from '@assistant-ui/react'; +import { memo } from 'react'; +import { RiDownloadLine, RiFileLine } from 'react-icons/ri'; + +function getFileDataKind(data: string, sourceType?: 'url' | 'id'): 'data-uri' | 'url' | 'base64' | 'id' { + if (sourceType === 'url' && /^data:/i.test(data)) return 'data-uri'; + if (sourceType) return sourceType; + if (/^data:/i.test(data)) return 'data-uri'; + if (/^https?:\/\//i.test(data)) return 'url'; + + return 'base64'; +} + +const FileImpl: FileMessagePartComponent = ({ filename, data, mimeType, sourceType }) => { + const kind = getFileDataKind(data, sourceType); + const canDownload = kind !== 'id' && (kind !== 'url' || /^(https?:\/\/|blob:)/i.test(data)); + const href = kind === 'base64' ? `data:${mimeType};base64,${data}` : data; + + return ( +
+ + + {filename || 'Unnamed file'} + + {canDownload ? ( + + + Download + + ) : null} +
+ ); +}; + +export const File = memo(FileImpl); diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/markdown-text.tsx b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/markdown-text.tsx new file mode 100644 index 00000000000..c2cf01a35da --- /dev/null +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/markdown-text.tsx @@ -0,0 +1,165 @@ +import '@assistant-ui/react-markdown/styles/dot.css'; + +import type { TextMessagePartProps } from '@assistant-ui/react'; +import { + type CodeHeaderProps, + MarkdownTextPrimitive, + unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, + useIsMarkdownCodeBlock, +} from '@assistant-ui/react-markdown'; +import { type FC, memo, useMemo, useRef, useState } from 'react'; +import { RiCheckLine, RiFileCopyLine } from 'react-icons/ri'; +import remarkGfm from 'remark-gfm'; +import { cn } from '@/utils/ui'; +import { TooltipIconButton } from './tooltip-icon-button'; + +type MarkdownTextProps = Partial & { + components?: Parameters[0]; +}; + +const useShallowStable = | undefined>(value: T): T => { + const ref = useRef(value); + if (value !== ref.current) { + const prev = ref.current; + const stable = + value !== undefined && + prev !== undefined && + Object.keys(prev).length === Object.keys(value).length && + Object.keys(value).every((key) => prev[key] === value[key]); + if (!stable) ref.current = value; + } + + return ref.current; +}; + +const MarkdownTextImpl: FC = ({ components }) => { + const stableComponents = useShallowStable(components); + const markdownComponents = useMemo(() => { + if (!stableComponents) return defaultComponents; + + return { + ...defaultComponents, + ...memoizeMarkdownComponents(stableComponents), + }; + }, [stableComponents]); + + return ( + + ); +}; + +export const MarkdownText = memo(MarkdownTextImpl); + +function useCopyToClipboard() { + const [isCopied, setIsCopied] = useState(false); + + const copyToClipboard = async (value: string) => { + await navigator.clipboard.writeText(value); + setIsCopied(true); + window.setTimeout(() => setIsCopied(false), 1500); + }; + + return { isCopied, copyToClipboard }; +} + +const CodeHeader: FC = ({ language, code }) => { + const { isCopied, copyToClipboard } = useCopyToClipboard(); + + return ( +
+ {language} + { + if (!code || isCopied) return; + void copyToClipboard(code); + }} + > + {isCopied ? : } + +
+ ); +}; + +const defaultComponents = memoizeMarkdownComponents({ + h1: ({ className, ...props }) => ( +

+ ), + h2: ({ className, ...props }) => ( +

+ ), + h3: ({ className, ...props }) => ( +

+ ), + h4: ({ className, ...props }) => ( +

+ ), + p: ({ className, ...props }) =>

, + a: ({ className, ...props }) => ( + + ), + blockquote: ({ className, ...props }) => ( +

+ ), + ul: ({ className, ...props }) => ( +
    li]:mt-1', className)} {...props} /> + ), + ol: ({ className, ...props }) => ( +
      li]:mt-1', className)} {...props} /> + ), + hr: ({ className, ...props }) =>
      , + table: ({ className, ...props }) => ( + + ), + th: ({ className, ...props }) => ( + td:first-child]:rounded-es-lg [&:last-child>td:last-child]:rounded-ee-lg', + className + )} + {...props} + /> + ), + li: ({ className, ...props }) =>
    1. , + strong: ({ className, ...props }) => , + pre: ({ className, ...props }) => ( +
      +  ),
      +  code: function Code({ className, ...props }) {
      +    const isCodeBlock = useIsMarkdownCodeBlock();
      +
      +    return (
      +      
      +    );
      +  },
      +  CodeHeader,
      +});
      diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/novu-approval-card.tsx b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/novu-approval-card.tsx
      new file mode 100644
      index 00000000000..9291f09e96e
      --- /dev/null
      +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/novu-approval-card.tsx
      @@ -0,0 +1,73 @@
      +import type { ToolCallMessagePartComponent } from '@assistant-ui/react';
      +import { type AlwaysAllowOption, ApprovalCard, type ApprovalState } from './approval-card';
      +
      +function commandPreview(args: unknown, argsText: string | undefined, toolName: string): string {
      +  if (argsText?.trim()) return argsText.trim();
      +  if (args && typeof args === 'object' && Object.keys(args as object).length > 0) {
      +    try {
      +      return JSON.stringify(args, null, 2);
      +    } catch {
      +      return toolName;
      +    }
      +  }
      +
      +  return toolName;
      +}
      +
      +function approvalState(approved: boolean | undefined, resolution: 'cancelled' | 'expired' | undefined): ApprovalState {
      +  if (resolution === 'cancelled' || resolution === 'expired' || approved === false) {
      +    return 'denied';
      +  }
      +  if (approved === undefined) return 'request';
      +
      +  return 'done';
      +}
      +
      +export const NovuApprovalCard: ToolCallMessagePartComponent = ({
      +  toolName,
      +  args,
      +  argsText,
      +  approval,
      +  respondToApproval,
      +}) => {
      +  const options = approval?.options ?? [];
      +
      +  const respond = (optionId: string, approved: boolean) => {
      +    respondToApproval?.({ optionId, approved });
      +  };
      +
      +  let onAllowOnce: (() => void) | undefined;
      +  let onDeny: (() => void) | undefined;
      +  const alwaysAllowOptions: AlwaysAllowOption[] = [];
      +
      +  for (const option of options) {
      +    switch (option.kind) {
      +      case 'allow-once':
      +        onAllowOnce = () => respond(option.id, true);
      +        break;
      +      case 'allow-always':
      +        alwaysAllowOptions.push({
      +          label: option.label ?? 'Always allow',
      +          onSelect: () => respond(option.id, true),
      +        });
      +        break;
      +      case 'reject-once':
      +        onDeny = () => respond(option.id, false);
      +        break;
      +      default:
      +        break;
      +    }
      +  }
      +
      +  return (
      +    
      +  );
      +};
      diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/reasoning.aui.tsx b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/reasoning.aui.tsx
      new file mode 100644
      index 00000000000..42d3491c078
      --- /dev/null
      +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/reasoning.aui.tsx
      @@ -0,0 +1,41 @@
      +import { type ReasoningMessagePartComponent } from '@assistant-ui/react';
      +import { memo, type PropsWithChildren } from 'react';
      +import { RiArrowDownSLine, RiBrainLine } from 'react-icons/ri';
      +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/primitives/collapsible';
      +import { MarkdownText } from './markdown-text';
      +
      +export function ReasoningRoot({ streaming, children }: PropsWithChildren<{ streaming?: boolean }>) {
      +  return (
      +    
      +      {children}
      +    
      +  );
      +}
      +
      +export function ReasoningTrigger({ active }: { active?: boolean }) {
      +  return (
      +    
      +      
      +      {active ? 'Reasoning' : 'Reasoning'}
      +      
      +    
      +  );
      +}
      +
      +export function ReasoningContent({ children, ...props }: PropsWithChildren<{ 'aria-busy'?: boolean }>) {
      +  return (
      +    
      +      
      {children}
      +
      + ); +} + +export function ReasoningText({ children }: PropsWithChildren) { + return
      {children}
      ; +} + +const ReasoningImpl: ReasoningMessagePartComponent = () => ; + +// biome-ignore lint/style/useComponentExportOnlyModules: assistant-ui part renderer +export const Reasoning = memo(ReasoningImpl) as unknown as ReasoningMessagePartComponent; +Reasoning.displayName = 'Reasoning'; diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/thinking-indicator.tsx b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/thinking-indicator.tsx new file mode 100644 index 00000000000..f648d6bec4c --- /dev/null +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/thinking-indicator.tsx @@ -0,0 +1,27 @@ +import type { ComponentProps } from 'react'; +import { Shimmer } from '@/components/ai-elements/shimmer'; +import { cn } from '@/utils/ui'; + +export function ThinkingIndicator({ + label, + className, + ...props +}: Omit, 'children' | 'label'> & { + label: string; +}) { + return ( + + + + {label} + + + ); +} diff --git a/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/thread.aui.tsx b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/thread.aui.tsx new file mode 100644 index 00000000000..c754e16930b --- /dev/null +++ b/apps/dashboard/src/components/agents/web-chat-panel/assistant-ui/elements/thread.aui.tsx @@ -0,0 +1,478 @@ +import { + ActionBarMorePrimitive, + ActionBarPrimitive, + type AssistantState, + AuiIf, + ComposerPrimitive, + ErrorPrimitive, + groupPartByType, + MessagePrimitive, + SuggestionPrimitive, + ThreadPrimitive, + type ToolCallMessagePartComponent, + useAuiState, +} from '@assistant-ui/react'; +import { + type ComponentType, + createContext, + type FC, + type PropsWithChildren, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { + RiArrowDownLine, + RiArrowUpLine, + RiCheckLine, + RiDownloadLine, + RiFileCopyLine, + RiLoader4Line, + RiMoreLine, +} from 'react-icons/ri'; +import { Button } from '@/components/primitives/button'; +import { Skeleton } from '@/components/primitives/skeleton'; +import { cn } from '@/utils/ui'; +import { MessageChronology } from './day-separator'; +import { MarkdownText } from './markdown-text'; +import { Reasoning, ReasoningContent, ReasoningRoot, ReasoningText, ReasoningTrigger } from './reasoning.aui'; +import { ToolFallback } from './tool-fallback.aui'; +import { ToolGroupContent, ToolGroupRoot, ToolGroupTrigger } from './tool-group.aui'; +import { TooltipIconButton } from './tooltip-icon-button'; + +export type ThreadGroupPart = MessagePrimitive.GroupedParts.GroupPart; + +// biome-ignore lint/style/useComponentExportOnlyModules: grouping helper is used by Thread +export const defaultThreadPartGroupBy = groupPartByType({ + reasoning: ['group-chainOfThought', 'group-reasoning'], + 'tool-call': ['group-chainOfThought', 'group-tool'], + 'standalone-tool-call': [], +}); + +export type ThreadComponents = { + AssistantMessage?: ComponentType | undefined; + UserMessage?: ComponentType | undefined; + Welcome?: ComponentType | undefined; + Indicator?: ComponentType | undefined; + Banner?: ComponentType | undefined; + Composer?: ComponentType<{ autoFocus: boolean }> | undefined; + AfterComposer?: ComponentType | undefined; + ToolFallback?: ToolCallMessagePartComponent | undefined; + ToolGroup?: ComponentType> | undefined; + ReasoningGroup?: ComponentType> | undefined; + groupBy?: typeof defaultThreadPartGroupBy; +}; + +export type ThreadProps = { + components?: ThreadComponents | undefined; + autoFocus?: boolean | undefined; +}; + +const EMPTY_COMPONENTS: ThreadComponents = {}; + +const ThreadComponentsContext = createContext(EMPTY_COMPONENTS); + +const isNewChatView = (s: AssistantState) => + s.thread.messages.length === 0 && (!s.thread.isLoading || s.threads.isLoading); + +const isHistoryLoadingView = (s: AssistantState) => + s.thread.messages.length === 0 && s.thread.isLoading && !s.thread.isDisabled && !s.threads.isLoading; + +const ThreadHistorySkeleton: FC = () => ( + + Loading conversation + +
      + + + +
      + +
      + + +
      +
      +); + +export const Thread: FC = ({ components = EMPTY_COMPONENTS, autoFocus = true }) => { + const isEmpty = useAuiState(isNewChatView); + + return ( + + + + ); +}; + +const ThreadRoot: FC<{ isEmpty: boolean; autoFocus: boolean }> = ({ isEmpty, autoFocus }) => { + const { + Welcome = ThreadWelcome, + Banner, + Composer: ComposerComponent = Composer, + AfterComposer, + } = useContext(ThreadComponentsContext); + + return ( + + +
      + + + + + + + +
      + {() => } +
      + + + + + {Banner ? : null} + + + + {AfterComposer ? : null} + + +
      +
      +
      + ); +}; + +const ThreadMessage: FC = () => { + const { AssistantMessage: AssistantMessageComponent = AssistantMessage } = useContext(ThreadComponentsContext); + const { UserMessage: UserMessageComponent = UserMessage } = useContext(ThreadComponentsContext); + const role = useAuiState((s) => s.message.role); + + return ( + {role === 'user' ? : } + ); +}; + +const ThreadViewportBottomStateSync: FC = () => { + const markerRef = useRef(null); + + useEffect(() => { + const viewport = markerRef.current?.closest('[data-slot="aui_thread-viewport"]'); + if (!viewport) return; + + let frame: number | undefined; + const syncIfAtBottom = () => { + cancelAnimationFrame(frame ?? 0); + frame = requestAnimationFrame(() => { + const bottomDistance = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; + if (Math.abs(bottomDistance) <= 1) { + viewport.dispatchEvent(new Event('scroll')); + } + }); + }; + + const observer = new MutationObserver((mutations) => { + const reserveChanged = mutations.some((mutation) => { + const target = mutation.target; + if (target instanceof HTMLElement && target.matches('[data-aui-top-anchor-reserve]')) { + return true; + } + + return [...mutation.addedNodes, ...mutation.removedNodes].some( + (node) => + node instanceof HTMLElement && + (node.matches('[data-aui-top-anchor-reserve]') || node.querySelector('[data-aui-top-anchor-reserve]')) + ); + }); + + if (reserveChanged) syncIfAtBottom(); + }); + + observer.observe(viewport, { + attributes: true, + attributeFilter: ['style'], + childList: true, + subtree: true, + }); + + return () => { + observer.disconnect(); + cancelAnimationFrame(frame ?? 0); + }; + }, []); + + return
    2. + ), + td: ({ className, ...props }) => ( + + ), + tr: ({ className, ...props }) => ( +