From 007ed7a2c0daa626b2fd7457b5721ebb33d80174 Mon Sep 17 00:00:00 2001 From: Ghost69 Date: Tue, 21 Jul 2026 12:13:51 +0100 Subject: [PATCH] Add F.A.T.U Concierge AI customer-care and shopping assistant. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grounded chat with knowledge retrieval, commerce tools, support tickets, and admin console — defaulting to a safe mock provider until AI credentials are configured. Co-authored-by: Cursor --- .env.example | 12 + apps/admin/app/concierge/page.tsx | 11 + apps/admin/components/admin-shell.tsx | 2 + .../admin/components/concierge-admin-view.tsx | 241 +++++++++++++ apps/api/src/app.module.ts | 2 + .../api/src/concierge/concierge.controller.ts | 127 +++++++ apps/api/src/concierge/concierge.dto.ts | 71 ++++ apps/api/src/concierge/concierge.module.ts | 15 + apps/api/src/concierge/concierge.service.ts | 299 ++++++++++++++++ .../app/api/concierge/chat/route.ts | 128 +++++++ apps/storefront/app/layout.tsx | 2 + apps/storefront/app/studio-guide/page.tsx | 8 +- .../concierge/concierge-launcher.tsx | 30 ++ .../components/concierge/concierge-panel.tsx | 279 +++++++++++++++ .../concierge/concierge-provider.tsx | 327 ++++++++++++++++++ .../components/concierge/concierge-root.tsx | 14 + .../lib/concierge/concierge.spec.ts | 130 +++++++ apps/storefront/lib/concierge/intent.spec.ts | 33 ++ apps/storefront/lib/concierge/intent.ts | 150 ++++++++ .../lib/concierge/knowledge/corpus.ts | 209 +++++++++++ apps/storefront/lib/concierge/orchestrator.ts | 286 +++++++++++++++ .../lib/concierge/prompts/system.ts | 28 ++ .../lib/concierge/security/rate-limit.ts | 32 ++ .../lib/concierge/security/redact.ts | 16 + apps/storefront/lib/concierge/tools/cart.ts | 78 +++++ .../storefront/lib/concierge/tools/catalog.ts | 190 ++++++++++ apps/storefront/lib/concierge/tools/orders.ts | 121 +++++++ .../storefront/lib/concierge/tools/tickets.ts | 87 +++++ docs/AI_ANALYTICS_DEFINITIONS.md | 49 +++ docs/AI_CHANGELOG.md | 17 + docs/AI_CUSTOMER_CARE_AUDIT.md | 203 +++++++++++ docs/AI_CUSTOMER_CARE_SPEC.md | 209 +++++++++++ docs/AI_CUSTOMER_CARE_TODO.md | 103 ++++++ docs/AI_KNOWLEDGE_SOURCES.md | 52 +++ docs/AI_OPERATIONS_RUNBOOK.md | 62 ++++ docs/AI_SECURITY_AND_PRIVACY.md | 48 +++ docs/AI_TEST_MATRIX.md | 38 ++ docs/coordination/BACKEND_TO_FRONTEND.md | 10 + docs/coordination/FRONTEND_TO_BACKEND.md | 12 + packages/configuration/src/index.spec.ts | 6 + packages/configuration/src/index.ts | 22 ++ packages/contracts/src/index.ts | 123 +++++++ .../20260721120000_ai_concierge/migration.sql | 139 ++++++++ packages/database/prisma/schema.prisma | 175 ++++++++++ 44 files changed, 4193 insertions(+), 3 deletions(-) create mode 100644 apps/admin/app/concierge/page.tsx create mode 100644 apps/admin/components/concierge-admin-view.tsx create mode 100644 apps/api/src/concierge/concierge.controller.ts create mode 100644 apps/api/src/concierge/concierge.dto.ts create mode 100644 apps/api/src/concierge/concierge.module.ts create mode 100644 apps/api/src/concierge/concierge.service.ts create mode 100644 apps/storefront/app/api/concierge/chat/route.ts create mode 100644 apps/storefront/components/concierge/concierge-launcher.tsx create mode 100644 apps/storefront/components/concierge/concierge-panel.tsx create mode 100644 apps/storefront/components/concierge/concierge-provider.tsx create mode 100644 apps/storefront/components/concierge/concierge-root.tsx create mode 100644 apps/storefront/lib/concierge/concierge.spec.ts create mode 100644 apps/storefront/lib/concierge/intent.spec.ts create mode 100644 apps/storefront/lib/concierge/intent.ts create mode 100644 apps/storefront/lib/concierge/knowledge/corpus.ts create mode 100644 apps/storefront/lib/concierge/orchestrator.ts create mode 100644 apps/storefront/lib/concierge/prompts/system.ts create mode 100644 apps/storefront/lib/concierge/security/rate-limit.ts create mode 100644 apps/storefront/lib/concierge/security/redact.ts create mode 100644 apps/storefront/lib/concierge/tools/cart.ts create mode 100644 apps/storefront/lib/concierge/tools/catalog.ts create mode 100644 apps/storefront/lib/concierge/tools/orders.ts create mode 100644 apps/storefront/lib/concierge/tools/tickets.ts create mode 100644 docs/AI_ANALYTICS_DEFINITIONS.md create mode 100644 docs/AI_CHANGELOG.md create mode 100644 docs/AI_CUSTOMER_CARE_AUDIT.md create mode 100644 docs/AI_CUSTOMER_CARE_SPEC.md create mode 100644 docs/AI_CUSTOMER_CARE_TODO.md create mode 100644 docs/AI_KNOWLEDGE_SOURCES.md create mode 100644 docs/AI_OPERATIONS_RUNBOOK.md create mode 100644 docs/AI_SECURITY_AND_PRIVACY.md create mode 100644 docs/AI_TEST_MATRIX.md create mode 100644 packages/database/prisma/migrations/20260721120000_ai_concierge/migration.sql diff --git a/.env.example b/.env.example index 08f56f8..0b5293b 100644 --- a/.env.example +++ b/.env.example @@ -51,3 +51,15 @@ MOCK_PAYMENT_WEBHOOK_SECRET=local-development-mock-webhook-secret FLUTTERWAVE_BASE_URL=https://api.flutterwave.com/v3 FLUTTERWAVE_SECRET_KEY= FLUTTERWAVE_WEBHOOK_HASH= +# F.A.T.U Concierge (AI customer care). Keys are server-only — never NEXT_PUBLIC_*. +# Default provider is mock (deterministic grounded answers) so CI works without secrets. +AI_ENABLED=true +AI_PROVIDER=mock +AI_MODEL=gpt-4.1-mini +AI_API_KEY= +AI_FALLBACK_MODEL= +AI_MAX_DAILY_REQUESTS=2000 +AI_KNOWLEDGE_SYNC_SECRET= +AI_SUPPORT_EMAIL= +AI_CHAT_RETENTION_DAYS=90 +AI_ASSISTANT_NAME=F.A.T.U Concierge diff --git a/apps/admin/app/concierge/page.tsx b/apps/admin/app/concierge/page.tsx new file mode 100644 index 0000000..64daab4 --- /dev/null +++ b/apps/admin/app/concierge/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next'; +import { ConciergeAdminView } from '@/components/concierge-admin-view'; + +export const metadata: Metadata = { + title: 'Concierge', + description: 'AI customer-care conversations, tickets, knowledge, and metrics.', +}; + +export default function ConciergeAdminPage() { + return ; +} diff --git a/apps/admin/components/admin-shell.tsx b/apps/admin/components/admin-shell.tsx index 191c860..a7d56fb 100644 --- a/apps/admin/components/admin-shell.tsx +++ b/apps/admin/components/admin-shell.tsx @@ -5,6 +5,7 @@ import { BarChart3, Boxes, Factory, + Headset, LayoutDashboard, Menu, Palette, @@ -25,6 +26,7 @@ const NAV = [ { href: '/orders', label: 'Orders', icon: ShoppingBag }, { href: '/artworks', label: 'Artworks', icon: Palette }, { href: '/storyteller', label: 'Brand Storyteller', icon: Sparkles }, + { href: '/concierge', label: 'Concierge', icon: Headset }, { href: '/garments', label: 'Garments', icon: Boxes }, { href: '/production', label: 'Production', icon: Factory }, { href: '/customers', label: 'Customers', icon: Users }, diff --git a/apps/admin/components/concierge-admin-view.tsx b/apps/admin/components/concierge-admin-view.tsx new file mode 100644 index 0000000..1aab56c --- /dev/null +++ b/apps/admin/components/concierge-admin-view.tsx @@ -0,0 +1,241 @@ +'use client'; + +import { Heading, Text } from '@tms/ui'; +import { useEffect, useState } from 'react'; + +interface Metrics { + totalConversations: number; + ticketsByStatus: Record; + eventsByType: Record; + retentionDays: number; + assistantName: string; +} + +interface TicketRow { + reference: string; + category: string; + priority: string; + status: string; + summary: string; + createdAt: string; +} + +interface KnowledgeRow { + title: string; + sourceType: string; + canonicalUrl: string; + published: boolean; + lastSyncedAt: string | null; + syncError: string | null; + chunkCount: number; +} + +interface ConversationRow { + publicId: string; + status: string; + intent: string | null; + messageCount: number; + updatedAt: string; +} + +const DEMO_METRICS: Metrics = { + totalConversations: 0, + ticketsByStatus: { OPEN: 0 }, + eventsByType: {}, + retentionDays: 90, + assistantName: 'F.A.T.U Concierge', +}; + +const DEMO_KNOWLEDGE: KnowledgeRow[] = [ + { + title: 'Delivery', + sourceType: 'POLICY', + canonicalUrl: '/delivery', + published: true, + lastSyncedAt: null, + syncError: null, + chunkCount: 1, + }, + { + title: 'Returns & exchanges', + sourceType: 'POLICY', + canonicalUrl: '/returns', + published: true, + lastSyncedAt: null, + syncError: null, + chunkCount: 1, + }, + { + title: 'Size guide', + sourceType: 'POLICY', + canonicalUrl: '/size-guide', + published: true, + lastSyncedAt: null, + syncError: null, + chunkCount: 1, + }, +]; + +function apiBase(): string { + return (process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000').replace(/\/$/, ''); +} + +async function adminFetch(path: string): Promise { + try { + const response = await fetch(`${apiBase()}${path}`, { + credentials: 'include', + cache: 'no-store', + }); + if (!response.ok) return null; + const json = (await response.json()) as { data: T }; + return json.data; + } catch { + return null; + } +} + +/** + * Concierge operations console. Loads live admin endpoints when the API is + * reachable; otherwise shows an honest preview seeded from the public knowledge + * corpus (no fabricated conversation volumes). + */ +export function ConciergeAdminView() { + const [metrics, setMetrics] = useState(DEMO_METRICS); + const [tickets, setTickets] = useState([]); + const [knowledge, setKnowledge] = useState(DEMO_KNOWLEDGE); + const [conversations, setConversations] = useState([]); + const [live, setLive] = useState(false); + + useEffect(() => { + void (async () => { + const [m, t, k, c] = await Promise.all([ + adminFetch('/api/v1/concierge/admin/metrics'), + adminFetch('/api/v1/concierge/admin/tickets'), + adminFetch('/api/v1/concierge/admin/knowledge'), + adminFetch('/api/v1/concierge/admin/conversations'), + ]); + if (m || t || k || c) { + setLive(true); + if (m) setMetrics(m); + if (t) setTickets(t); + if (k) setKnowledge(k); + if (c) setConversations(c); + } + })(); + }, []); + + return ( +
+
+

Customer care

+ + F.A.T.U Concierge + + + Conversations, support tickets, knowledge sync, and assisted-care metrics. API keys are + never shown here. + + {!live ? ( +

+ Preview mode — connect the API with an admin session to load live queues. Knowledge + listed below mirrors the storefront seed corpus. +

+ ) : null} +
+ +
+ + + + +
+ +
+ + Support ticket queue + + {tickets.length === 0 ? ( +

No tickets yet.

+ ) : ( +
+ + + + + + + + + + + + {tickets.map((ticket) => ( + + + + + + + + ))} + +
ReferencePriorityStatusCategorySummary
{ticket.reference}{ticket.priority}{ticket.status}{ticket.category}{ticket.summary}
+
+ )} +
+ +
+ + Recent conversations + + {conversations.length === 0 ? ( +

No stored conversations yet.

+ ) : ( +
    + {conversations.map((c) => ( +
  • + {c.publicId} + + {c.intent ?? '—'} · {c.messageCount} msgs · {c.status} + +
  • + ))} +
+ )} +
+ +
+ + Knowledge sources + +
    + {knowledge.map((row) => ( +
  • +

    {row.title}

    +

    + {row.sourceType} · {row.chunkCount} chunks ·{' '} + {row.published ? 'published' : 'unpublished'} + {row.canonicalUrl ? ` · ${row.canonicalUrl}` : ''} +

    +
  • + ))} +
+
+
+ ); +} + +function MetricCard({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 13e06bc..fae8232 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -7,6 +7,7 @@ import { ArtworkModule } from './artworks/artwork.module.js'; import { CatalogueModule } from './catalogue/catalogue.module.js'; import { AuthModule } from './auth/auth.module.js'; import { CartModule } from './cart/cart.module.js'; +import { ConciergeModule } from './concierge/concierge.module.js'; import { DesignModule } from './designs/design.module.js'; import { HealthController } from './health/health.controller.js'; import { HealthService } from './health/health.service.js'; @@ -24,6 +25,7 @@ import { PaymentModule } from './payments/payment.module.js'; CatalogueModule, GarmentModule, CartModule, + ConciergeModule, DesignModule, InventoryModule, MediaModule, diff --git a/apps/api/src/concierge/concierge.controller.ts b/apps/api/src/concierge/concierge.controller.ts new file mode 100644 index 0000000..6d48a92 --- /dev/null +++ b/apps/api/src/concierge/concierge.controller.ts @@ -0,0 +1,127 @@ +import { + Body, + Controller, + Get, + Headers, + Inject, + Post, + Query, + Req, + UnauthorizedException, + UseGuards, +} from '@nestjs/common'; +import { + ApiExtraModels, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import type { ApiResponse } from '@tms/contracts'; +import type { Request } from 'express'; + +import { AdminSessionGuard } from '../admin-auth/admin-session.guard.js'; +import { SessionGuard } from '../auth/session.guard.js'; +import { CreateSupportTicketDto, UpsertKnowledgeDto } from './concierge.dto.js'; +import { ConciergeService } from './concierge.service.js'; + +@ApiTags('concierge') +@ApiExtraModels(CreateSupportTicketDto, UpsertKnowledgeDto) +@Controller('concierge') +export class ConciergeController { + constructor(@Inject(ConciergeService) private readonly concierge: ConciergeService) {} + + @Post('tickets') + @ApiOperation({ summary: 'Create a customer-care support ticket from the Concierge' }) + @ApiOkResponse({ description: 'Ticket created' }) + async createTicket( + @Body() body: CreateSupportTicketDto, + @Req() request: Request, + ): Promise> { + // Optional session — guests may open tickets with an email. + const customerUserId = request.authSession?.session.user.id; + const ticket = await this.concierge.createTicket(body, customerUserId); + return this.respond(request, ticket); + } + + @Post('messages') + @ApiOperation({ summary: 'Persist a Concierge message (server-side analytics)' }) + async appendMessage( + @Body() + body: { + conversationPublicId: string; + role: 'USER' | 'ASSISTANT' | 'SYSTEM' | 'TOOL'; + content: string; + citationsJson?: unknown; + intent?: string; + pagePath?: string; + latencyMs?: number; + }, + @Req() request: Request, + ): Promise> { + const saved = await this.concierge.appendMessage(body); + return this.respond(request, { id: saved.id }); + } + + @Get('settings') + @ApiOperation({ summary: 'Public Concierge settings (no secrets)' }) + async settings(@Req() request: Request): Promise> { + return this.respond(request, await this.concierge.getSettings()); + } + + @Post('knowledge/sync') + @ApiOperation({ summary: 'Upsert a knowledge source (secret or admin)' }) + async syncKnowledge( + @Body() body: UpsertKnowledgeDto, + @Headers('x-ai-knowledge-sync-secret') secret: string | undefined, + @Req() request: Request, + ): Promise> { + if (!this.concierge.assertKnowledgeSyncSecret(secret) && !request.adminSession) { + throw new UnauthorizedException('Knowledge sync requires a valid secret or admin session.'); + } + return this.respond(request, await this.concierge.upsertKnowledge(body)); + } + + @Get('admin/tickets') + @UseGuards(AdminSessionGuard) + async adminTickets( + @Query('status') status: string | undefined, + @Req() request: Request, + ): Promise> { + return this.respond(request, await this.concierge.listTickets(status)); + } + + @Get('admin/conversations') + @UseGuards(AdminSessionGuard) + async adminConversations(@Req() request: Request): Promise> { + return this.respond(request, await this.concierge.listConversations()); + } + + @Get('admin/knowledge') + @UseGuards(AdminSessionGuard) + async adminKnowledge(@Req() request: Request): Promise> { + return this.respond(request, await this.concierge.listKnowledge()); + } + + @Get('admin/metrics') + @UseGuards(AdminSessionGuard) + async adminMetrics(@Req() request: Request): Promise> { + return this.respond(request, await this.concierge.metrics()); + } + + /** Placeholder so SessionGuard import stays meaningful for optional future auth. */ + @Get('me/orders-hint') + @UseGuards(SessionGuard) + async ordersHint(@Req() request: Request): Promise> { + return this.respond(request, { + message: 'Use GET /api/v1/orders for authoritative order history.', + userId: request.authSession?.session.user.id, + }); + } + + private respond(request: Request, data: T): ApiResponse { + return { + data, + meta: { correlationId: request.correlationId ?? 'unavailable' }, + }; + } +} diff --git a/apps/api/src/concierge/concierge.dto.ts b/apps/api/src/concierge/concierge.dto.ts new file mode 100644 index 0000000..c129113 --- /dev/null +++ b/apps/api/src/concierge/concierge.dto.ts @@ -0,0 +1,71 @@ +import { IsEmail, IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class CreateSupportTicketDto { + @IsString() + @MinLength(2) + @MaxLength(64) + category!: string; + + @IsString() + @MinLength(1) + @MaxLength(500) + summary!: string; + + @IsString() + @MinLength(1) + @MaxLength(4_000) + customerMessage!: string; + + @IsString() + @MinLength(1) + @MaxLength(2_000) + aiContextSummary!: string; + + @IsOptional() + @IsEnum(['LOW', 'NORMAL', 'HIGH', 'URGENT'] as const) + priority?: 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'; + + @IsOptional() + @IsEmail() + guestEmail?: string; + + @IsOptional() + @IsString() + @MaxLength(64) + orderReference?: string; + + @IsOptional() + @IsString() + @MaxLength(64) + conversationPublicId?: string; +} + +export class UpsertKnowledgeDto { + @IsString() + @MaxLength(32) + sourceType!: string; + + @IsString() + @MaxLength(200) + sourceId!: string; + + @IsString() + @MaxLength(300) + title!: string; + + @IsString() + @MaxLength(500) + canonicalUrl!: string; + + @IsString() + @MinLength(1) + content!: string; + + @IsOptional() + @IsString() + @MaxLength(16) + locale?: string; + + @IsOptional() + priority?: number; +} diff --git a/apps/api/src/concierge/concierge.module.ts b/apps/api/src/concierge/concierge.module.ts new file mode 100644 index 0000000..805ba1f --- /dev/null +++ b/apps/api/src/concierge/concierge.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; + +import { AdminAuthModule } from '../admin-auth/admin-auth.module.js'; +import { AuthModule } from '../auth/auth.module.js'; +import { DatabaseService } from '../database/database.service.js'; +import { ConciergeController } from './concierge.controller.js'; +import { ConciergeService } from './concierge.service.js'; + +@Module({ + imports: [AuthModule, AdminAuthModule], + controllers: [ConciergeController], + providers: [ConciergeService, DatabaseService], + exports: [ConciergeService], +}) +export class ConciergeModule {} diff --git a/apps/api/src/concierge/concierge.service.ts b/apps/api/src/concierge/concierge.service.ts new file mode 100644 index 0000000..200000f --- /dev/null +++ b/apps/api/src/concierge/concierge.service.ts @@ -0,0 +1,299 @@ +import { createHash, randomBytes } from 'node:crypto'; + +import { Inject, Injectable } from '@nestjs/common'; +import { loadEnvironment } from '@tms/configuration'; + +import { DatabaseService } from '../database/database.service.js'; +import type { CreateSupportTicketDto, UpsertKnowledgeDto } from './concierge.dto.js'; + +function ticketReference(): string { + return `ST-${randomBytes(3).toString('hex').toUpperCase()}`; +} + +function checksum(content: string): string { + return createHash('sha256').update(content).digest('hex').slice(0, 24); +} + +const SOURCE_TYPE_MAP = { + product: 'PRODUCT', + artwork: 'ARTWORK', + collection: 'COLLECTION', + policy: 'POLICY', + story: 'STORY', + faq: 'FAQ', + page: 'PAGE', + article: 'ARTICLE', +} as const; + +@Injectable() +export class ConciergeService { + constructor(@Inject(DatabaseService) private readonly database: DatabaseService) {} + + async createTicket(input: CreateSupportTicketDto, customerUserId?: string) { + let conversationId: string | undefined; + if (input.conversationPublicId) { + const conversation = await this.database.client.aiConversation.findUnique({ + where: { publicId: input.conversationPublicId }, + select: { id: true }, + }); + conversationId = conversation?.id; + } + + const ticket = await this.database.client.supportTicket.create({ + data: { + id: crypto.randomUUID(), + reference: ticketReference(), + customerUserId: customerUserId ?? null, + guestEmail: input.guestEmail ?? null, + conversationId: conversationId ?? null, + orderReference: input.orderReference ?? null, + category: input.category, + priority: input.priority ?? 'NORMAL', + summary: input.summary, + customerMessage: input.customerMessage, + aiContextSummary: input.aiContextSummary, + status: 'OPEN', + }, + }); + + await this.database.client.aiAnalyticsEvent.create({ + data: { + id: crypto.randomUUID(), + conversationId: conversationId ?? null, + eventType: 'support_ticket_created', + metadata: { reference: ticket.reference, category: ticket.category }, + }, + }); + + return { + id: ticket.id, + reference: ticket.reference, + status: ticket.status, + priority: ticket.priority, + category: ticket.category, + createdAt: ticket.createdAt.toISOString(), + }; + } + + async listTickets(status?: string) { + const tickets = await this.database.client.supportTicket.findMany({ + where: status ? { status: status as never } : undefined, + orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }], + take: 100, + }); + return tickets.map((t) => ({ + id: t.id, + reference: t.reference, + category: t.category, + priority: t.priority, + status: t.status, + summary: t.summary, + orderReference: t.orderReference, + createdAt: t.createdAt.toISOString(), + updatedAt: t.updatedAt.toISOString(), + })); + } + + async listConversations() { + const rows = await this.database.client.aiConversation.findMany({ + orderBy: { updatedAt: 'desc' }, + take: 100, + include: { _count: { select: { messages: true } } }, + }); + return rows.map((c) => ({ + id: c.id, + publicId: c.publicId, + status: c.status, + intent: c.intent, + pagePath: c.pagePath, + feedbackScore: c.feedbackScore, + createdAt: c.createdAt.toISOString(), + updatedAt: c.updatedAt.toISOString(), + messageCount: c._count.messages, + })); + } + + async ensureConversation(publicId: string, pagePath?: string) { + return this.database.client.aiConversation.upsert({ + where: { publicId }, + create: { + id: crypto.randomUUID(), + publicId, + pagePath: pagePath ?? null, + status: 'OPEN', + }, + update: { pagePath: pagePath ?? undefined }, + }); + } + + async appendMessage(input: { + conversationPublicId: string; + role: 'USER' | 'ASSISTANT' | 'SYSTEM' | 'TOOL'; + content: string; + citationsJson?: unknown; + toolName?: string; + latencyMs?: number; + intent?: string; + pagePath?: string; + }) { + const conversation = await this.ensureConversation( + input.conversationPublicId, + input.pagePath, + ); + if (input.intent) { + await this.database.client.aiConversation.update({ + where: { id: conversation.id }, + data: { intent: input.intent }, + }); + } + return this.database.client.aiMessage.create({ + data: { + id: crypto.randomUUID(), + conversationId: conversation.id, + role: input.role, + content: input.content, + citationsJson: input.citationsJson as never, + toolName: input.toolName ?? null, + latencyMs: input.latencyMs ?? null, + }, + }); + } + + async upsertKnowledge(input: UpsertKnowledgeDto) { + const sourceTypeKey = input.sourceType.toLowerCase() as keyof typeof SOURCE_TYPE_MAP; + const sourceType = SOURCE_TYPE_MAP[sourceTypeKey]; + if (!sourceType) { + throw Object.assign(new Error('Invalid sourceType'), { status: 400 }); + } + const locale = input.locale ?? 'en-NG'; + const sum = checksum(input.content); + const source = await this.database.client.aiKnowledgeSource.upsert({ + where: { + sourceType_sourceId_locale: { + sourceType, + sourceId: input.sourceId, + locale, + }, + }, + create: { + id: crypto.randomUUID(), + sourceType, + sourceId: input.sourceId, + title: input.title, + canonicalUrl: input.canonicalUrl, + locale, + version: sum, + checksum: sum, + priority: input.priority ?? 0, + published: true, + lastSyncedAt: new Date(), + chunks: { + create: [ + { + id: crypto.randomUUID(), + content: input.content, + tokenHint: Math.ceil(input.content.length / 4), + }, + ], + }, + }, + update: { + title: input.title, + canonicalUrl: input.canonicalUrl, + version: sum, + checksum: sum, + priority: input.priority ?? 0, + published: true, + lastSyncedAt: new Date(), + syncError: null, + chunks: { + deleteMany: {}, + create: [ + { + id: crypto.randomUUID(), + content: input.content, + tokenHint: Math.ceil(input.content.length / 4), + }, + ], + }, + }, + }); + return { + id: source.id, + sourceId: source.sourceId, + checksum: source.checksum, + lastSyncedAt: source.lastSyncedAt?.toISOString() ?? null, + }; + } + + async listKnowledge() { + const sources = await this.database.client.aiKnowledgeSource.findMany({ + orderBy: [{ priority: 'desc' }, { updatedAt: 'desc' }], + take: 200, + include: { _count: { select: { chunks: true } } }, + }); + return sources.map((s) => ({ + id: s.id, + sourceType: s.sourceType, + sourceId: s.sourceId, + title: s.title, + canonicalUrl: s.canonicalUrl, + priority: s.priority, + published: s.published, + checksum: s.checksum, + lastSyncedAt: s.lastSyncedAt?.toISOString() ?? null, + syncError: s.syncError, + chunkCount: s._count.chunks, + })); + } + + async metrics() { + const [conversations, tickets, events] = await Promise.all([ + this.database.client.aiConversation.count(), + this.database.client.supportTicket.groupBy({ + by: ['status'], + _count: true, + }), + this.database.client.aiAnalyticsEvent.groupBy({ + by: ['eventType'], + _count: true, + }), + ]); + return { + totalConversations: conversations, + ticketsByStatus: Object.fromEntries(tickets.map((t) => [t.status, t._count])), + eventsByType: Object.fromEntries(events.map((e) => [e.eventType, e._count])), + retentionDays: loadEnvironment().AI_CHAT_RETENTION_DAYS, + assistantName: loadEnvironment().AI_ASSISTANT_NAME, + }; + } + + async getSettings() { + const env = loadEnvironment(); + const existing = await this.database.client.aiConciergeSettings.findFirst({ + orderBy: { createdAt: 'asc' }, + }); + if (existing) { + return { + assistantName: existing.assistantName, + enabled: existing.enabled, + supportEmail: existing.supportEmail, + retentionDays: existing.retentionDays, + escalationNote: existing.escalationNote, + }; + } + return { + assistantName: env.AI_ASSISTANT_NAME, + enabled: env.AI_ENABLED, + supportEmail: env.AI_SUPPORT_EMAIL ?? null, + retentionDays: env.AI_CHAT_RETENTION_DAYS, + escalationNote: null, + }; + } + + assertKnowledgeSyncSecret(header: string | undefined): boolean { + const secret = loadEnvironment().AI_KNOWLEDGE_SYNC_SECRET; + if (!secret) return process.env.NODE_ENV !== 'production'; + return Boolean(header && header === secret); + } +} diff --git a/apps/storefront/app/api/concierge/chat/route.ts b/apps/storefront/app/api/concierge/chat/route.ts new file mode 100644 index 0000000..5992f60 --- /dev/null +++ b/apps/storefront/app/api/concierge/chat/route.ts @@ -0,0 +1,128 @@ +import { ConciergeChatRequestSchema } from '@tms/contracts'; +import { runConciergeTurn } from '@/lib/concierge/orchestrator'; +import { checkRateLimit } from '@/lib/concierge/security/rate-limit'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const MAX_BODY_BYTES = 16_384; + +function clientKey(request: Request): string { + const forwarded = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim(); + return forwarded || request.headers.get('x-real-ip') || 'anonymous'; +} + +/** + * Concierge chat BFF. Streams NDJSON events so the UI can show progress without + * fake typing delays. Provider keys never leave the server. + */ +export async function POST(request: Request): Promise { + if (process.env.AI_ENABLED === 'false') { + return Response.json( + { error: { code: 'INTEGRATION_UNAVAILABLE', message: 'The Concierge is temporarily unavailable.' } }, + { status: 503 }, + ); + } + + const limit = checkRateLimit(`concierge:${clientKey(request)}`, { + windowMs: 60_000, + max: 20, + }); + if (!limit.ok) { + return Response.json( + { + error: { + code: 'RATE_LIMITED', + message: 'Too many Concierge requests. Please wait a moment.', + retryAfterSeconds: limit.retryAfterSeconds, + }, + }, + { status: 429, headers: { 'Retry-After': String(limit.retryAfterSeconds) } }, + ); + } + + const raw = await request.text(); + if (raw.length > MAX_BODY_BYTES) { + return Response.json( + { error: { code: 'VALIDATION_FAILED', message: 'Request too large.' } }, + { status: 413 }, + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return Response.json( + { error: { code: 'VALIDATION_FAILED', message: 'Invalid JSON body.' } }, + { status: 400 }, + ); + } + + const body = ConciergeChatRequestSchema.safeParse(parsed); + if (!body.success) { + return Response.json( + { + error: { + code: 'VALIDATION_FAILED', + message: 'Invalid Concierge request.', + details: body.error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message, + })), + }, + }, + { status: 400 }, + ); + } + + const cookie = request.headers.get('cookie') ?? undefined; + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + const send = (event: Record) => { + controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`)); + }; + try { + send({ type: 'status', phase: 'routing' }); + const started = Date.now(); + const result = await runConciergeTurn(body.data, { + cookie, + requestId: body.data.clientRequestId, + }); + send({ type: 'status', phase: 'generating' }); + // Stream the text in small chunks for perceived responsiveness. + const chunkSize = 48; + for (let i = 0; i < result.text.length; i += chunkSize) { + send({ type: 'token', text: result.text.slice(i, i + chunkSize) }); + } + send({ + type: 'final', + result: { + ...result, + latencyMs: Date.now() - started, + }, + }); + } catch (error) { + send({ + type: 'error', + message: + error instanceof Error + ? 'The Concierge hit an unexpected error. Please try again.' + : 'The Concierge is unavailable.', + }); + } finally { + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'application/x-ndjson; charset=utf-8', + 'Cache-Control': 'no-store', + 'X-Concierge-Stream': '1', + }, + }); +} diff --git a/apps/storefront/app/layout.tsx b/apps/storefront/app/layout.tsx index 110f35d..aa3dbed 100644 --- a/apps/storefront/app/layout.tsx +++ b/apps/storefront/app/layout.tsx @@ -4,6 +4,7 @@ import { AuthProvider } from '@/components/account/auth-provider'; import { WishlistProvider } from '@/components/account/wishlist-provider'; import { CartDrawer } from '@/components/cart/cart-drawer'; import { CartProvider } from '@/components/cart/cart-provider'; +import { ConciergeRoot } from '@/components/concierge/concierge-root'; import { isCartServerBacked } from '@/lib/cart-api'; import { SiteFooter } from '@/components/site/site-footer'; import { SiteHeader } from '@/components/site/site-header'; @@ -88,6 +89,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) + diff --git a/apps/storefront/app/studio-guide/page.tsx b/apps/storefront/app/studio-guide/page.tsx index 847d23c..46fd506 100644 --- a/apps/storefront/app/studio-guide/page.tsx +++ b/apps/storefront/app/studio-guide/page.tsx @@ -6,7 +6,7 @@ import { StudioGuideChat } from '@/components/studio-guide/studio-guide-chat'; export const metadata: Metadata = { title: 'Studio Guide', description: - 'Chat with the Studio Guide — your AI assistant for the artworks, the Design Studio, sizing and policies.', + 'Chat with the F.A.T.U Concierge — artwork, Design Studio, sizing, delivery, and orders.', }; export default function StudioGuidePage() { @@ -19,8 +19,10 @@ export default function StudioGuidePage() { Studio Guide - Ask about the artworks, how the Design Studio works, sizing or our policies. For - anything about a specific order, our{' '} + The site-wide{' '} + F.A.T.U Concierge (launcher on every + page) is the primary assistant. This page keeps the original Studio Guide preview chat. + For a specific order, our{' '} + + {assistantName} + {panel === 'minimised' ? ( + + ) : null} + + ); +} diff --git a/apps/storefront/components/concierge/concierge-panel.tsx b/apps/storefront/components/concierge/concierge-panel.tsx new file mode 100644 index 0000000..8fc17ce --- /dev/null +++ b/apps/storefront/components/concierge/concierge-panel.tsx @@ -0,0 +1,279 @@ +'use client'; + +import { buttonVariants, cn, Price } from '@tms/ui'; +import { + ArrowRight, + Minimize2, + RotateCw, + Send, + ShieldCheck, + Sparkles, + X, +} from 'lucide-react'; +import Link from 'next/link'; +import { useEffect, useId, useRef } from 'react'; +import { type ConciergeMessage, useConcierge } from './concierge-provider'; + +export function ConciergePanel() { + const { + panel, + close, + minimise, + messages, + pending, + send, + retryLast, + assistantName, + quickActions, + } = useConcierge(); + const dialogRef = useRef(null); + const logRef = useRef(null); + const inputId = useId(); + const inputRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (panel === 'open') { + if (!dialog.open) dialog.showModal(); + inputRef.current?.focus(); + } else if (dialog.open) { + dialog.close(); + } + }, [panel]); + + useEffect(() => { + logRef.current?.scrollTo({ top: logRef.current.scrollHeight, behavior: 'smooth' }); + }, [messages, pending]); + + return ( + { + if (panel === 'open') close(); + }} + onCancel={(e) => { + e.preventDefault(); + close(); + }} + className={cn( + 'fixed inset-0 z-50 m-0 max-h-none max-w-none flex-col border-0 bg-transparent p-0', + 'backdrop:bg-ink/40 md:backdrop:bg-ink/25', + 'open:flex', + )} + > +
+
+ + + +
+

{assistantName}

+

+ Artwork, sizing, delivery, Design Studio, and orders — grounded in studio sources. +

+
+
+ + +
+
+ +
+ {messages.length === 0 ? ( +

+ I’m the {assistantName}. I can help you discover artwork, choose a garment and size, + understand delivery, or check an order. +

+ ) : null} + + {messages.map((message) => + message.role === 'user' ? ( +
+

+ {message.text} +

+
+ ) : ( + + ), + )} + + {pending ? ( +
+ + Working… +
+ ) : null} +
+ +
+ {quickActions.map((prompt) => ( + + ))} +
+ + +
+
+ ); +} + +function AssistantBubble({ + message, + onRetry, +}: { + message: ConciergeMessage; + onRetry: () => void; +}) { + return ( +
+
+ {message.text || (message.streaming ? '…' : '')} + {message.result?.guarded ? ( +

+ + Checked against studio sources, not guessed. +

+ ) : null} +
+ + {message.result?.cards?.length ? ( +
    + {message.result.cards.map((card) => ( +
  • + +
    +

    {card.title}

    + {card.subtitle ?

    {card.subtitle}

    : null} + {card.reason ?

    {card.reason}

    : null} + {card.priceMinor != null && card.currency ? ( +

    + +

    + ) : null} +
    + + +
  • + ))} +
+ ) : null} + + {message.result?.citations?.length ? ( +
    + {message.result.citations.map((ref) => ( +
  • + + {ref.label} + +
  • + ))} +
+ ) : null} + + {message.error ? ( + + ) : null} +
+ ); +} + +function Composer({ + inputId, + inputRef, + pending, + onSend, +}: { + inputId: string; + inputRef: React.RefObject; + pending: boolean; + onSend: (text: string) => void; +}) { + return ( +
{ + e.preventDefault(); + const value = inputRef.current?.value ?? ''; + onSend(value); + if (inputRef.current) inputRef.current.value = ''; + }} + > + +