From 8a2866c8d0778874b7693324c91ad43ff1d61599 Mon Sep 17 00:00:00 2001 From: Ghost69 Date: Sun, 12 Jul 2026 01:16:36 +0100 Subject: [PATCH 01/12] feat(concierge): Concierge V2 - multi-turn AI scent advisor (WIP, not for prod) Another session's in-progress rebuild of the AI concierge, committed to its own branch so it is saved and shareable without touching production main. Adds: /api/v2/concierge routes (chat, conversations, allowance, feedback), lib/concierge orchestrator/router/tools/usage, multi-provider AI router with OpenAI/Anthropic/Gemini/xAI adapters, web-search integration, admin concierge status, and a rewritten concierge client that consumes the v2 endpoints. Edits shared files (schema.prisma, env, feature-flags, error catalogue, integrations). NOT READY FOR PRODUCTION: - Ships an additive Prisma migration (prisma/migrations/20260711230000_concierge_v2) that MUST be applied to the target database first - the rewritten /concierge page calls the v2 routes, which query the new tables and will error until the migration runs. - Per docs/CONCIERGE_V2_TODO.md, several validation items are incomplete (provider/fallback, ownership/entitlement, streaming tests) and preview deployment testing is still blocked. Passes local typecheck, lint and concierge unit tests. Co-Authored-By: Claude Fable 5 --- .env.example | 18 +- app/admin/concierge/page.tsx | 49 ++ app/api/v2/concierge/allowance/route.ts | 13 + app/api/v2/concierge/chat/route.ts | 51 +++ .../v2/concierge/conversations/[id]/route.ts | 25 ++ app/api/v2/concierge/conversations/route.ts | 20 + .../concierge/messages/[id]/feedback/route.ts | 15 + app/concierge/page.tsx | 2 +- app/globals.css | 6 + components/admin/admin-sidebar.tsx | 3 +- components/concierge/concierge-client.tsx | 422 +++++------------- contracts/error-catalogue.md | 4 + docs/CONCIERGE_V2_ARCHITECTURE.md | 68 +++ docs/CONCIERGE_V2_AUDIT.md | 83 ++++ docs/CONCIERGE_V2_EVALUATION.md | 42 ++ docs/CONCIERGE_V2_HANDOFF.md | 62 +++ docs/CONCIERGE_V2_PROVIDER_MATRIX.md | 31 ++ docs/CONCIERGE_V2_RATE_LIMITS.md | 27 ++ docs/CONCIERGE_V2_SECURITY.md | 41 ++ docs/CONCIERGE_V2_TODO.md | 59 +++ eslint.config.mjs | 1 + integrations/ai/capabilities.ts | 39 ++ integrations/ai/index.ts | 10 +- integrations/ai/router.ts | 161 +++++++ integrations/ai/types.ts | 2 +- integrations/ai/xai.ts | 19 + integrations/registry.ts | 2 + integrations/web-search/hosted.ts | 22 + integrations/web-search/types.ts | 17 + lib/concierge/context.ts | 48 ++ lib/concierge/conversation.ts | 93 ++++ lib/concierge/evaluation.ts | 47 ++ lib/concierge/orchestrator.ts | 122 +++++ lib/concierge/prompt.ts | 32 ++ lib/concierge/response-policy.ts | 27 ++ lib/concierge/router.ts | 79 ++++ lib/concierge/tools/catalogue.ts | 128 ++++++ lib/concierge/tools/knowledge.ts | 30 ++ lib/concierge/tools/reviews.ts | 27 ++ lib/concierge/tools/user.ts | 22 + lib/concierge/types.ts | 128 ++++++ lib/concierge/usage.ts | 44 ++ lib/config/feature-flags.ts | 2 + lib/env.ts | 17 +- lib/http/errors.ts | 8 + .../20260711230000_concierge_v2/migration.sql | 57 +++ prisma/schema.prisma | 124 ++++- tests/concierge/context.test.ts | 22 + tests/concierge/regression.test.ts | 9 + tests/concierge/response-policy.test.ts | 17 + tests/concierge/router.test.ts | 26 ++ tests/e2e/accessibility.spec.ts | 1 + tests/e2e/concierge-v2.spec.ts | 24 + 53 files changed, 2111 insertions(+), 337 deletions(-) create mode 100644 app/admin/concierge/page.tsx create mode 100644 app/api/v2/concierge/allowance/route.ts create mode 100644 app/api/v2/concierge/chat/route.ts create mode 100644 app/api/v2/concierge/conversations/[id]/route.ts create mode 100644 app/api/v2/concierge/conversations/route.ts create mode 100644 app/api/v2/concierge/messages/[id]/feedback/route.ts create mode 100644 docs/CONCIERGE_V2_ARCHITECTURE.md create mode 100644 docs/CONCIERGE_V2_AUDIT.md create mode 100644 docs/CONCIERGE_V2_EVALUATION.md create mode 100644 docs/CONCIERGE_V2_HANDOFF.md create mode 100644 docs/CONCIERGE_V2_PROVIDER_MATRIX.md create mode 100644 docs/CONCIERGE_V2_RATE_LIMITS.md create mode 100644 docs/CONCIERGE_V2_SECURITY.md create mode 100644 docs/CONCIERGE_V2_TODO.md create mode 100644 integrations/ai/capabilities.ts create mode 100644 integrations/ai/router.ts create mode 100644 integrations/ai/xai.ts create mode 100644 integrations/web-search/hosted.ts create mode 100644 integrations/web-search/types.ts create mode 100644 lib/concierge/context.ts create mode 100644 lib/concierge/conversation.ts create mode 100644 lib/concierge/evaluation.ts create mode 100644 lib/concierge/orchestrator.ts create mode 100644 lib/concierge/prompt.ts create mode 100644 lib/concierge/response-policy.ts create mode 100644 lib/concierge/router.ts create mode 100644 lib/concierge/tools/catalogue.ts create mode 100644 lib/concierge/tools/knowledge.ts create mode 100644 lib/concierge/tools/reviews.ts create mode 100644 lib/concierge/tools/user.ts create mode 100644 lib/concierge/types.ts create mode 100644 lib/concierge/usage.ts create mode 100644 prisma/migrations/20260711230000_concierge_v2/migration.sql create mode 100644 tests/concierge/context.test.ts create mode 100644 tests/concierge/regression.test.ts create mode 100644 tests/concierge/response-policy.test.ts create mode 100644 tests/concierge/router.test.ts create mode 100644 tests/e2e/concierge-v2.spec.ts diff --git a/.env.example b/.env.example index c25057d..e50ec40 100644 --- a/.env.example +++ b/.env.example @@ -32,7 +32,9 @@ SHOPIFY_WEBHOOK_SECRET="" # --- AI (provider-independent; default is the offline deterministic mock) --- # Model IDs below are the current latest (2026-07); override to pin/upgrade without code changes. -AI_PROVIDER="mock" # mock | anthropic | openai | gemini +AI_PROVIDER="mock" # mock | anthropic | openai | gemini | xai +AI_PROVIDER_PRIORITY="openai,anthropic,gemini,xai" +AI_DEMO_MODE="false" ANTHROPIC_API_KEY="" ANTHROPIC_MODEL="claude-haiku-4-5-20251001" # e.g. claude-haiku-4-5-20251001 | claude-sonnet-5 | claude-opus-4-8 OPENAI_API_KEY="" @@ -56,7 +58,7 @@ COMMERCE_FREE_SHIPPING_THRESHOLD_NGN="500000" COMMERCE_FLAT_SHIPPING_NGN="2500" # --- Feature flags (comma-separated). Prefix with ! to force-disable a default-on flag. --- -# Available: shopify_commerce, ai_concierge, loyalty_rewards, referral_rewards, +# Available: shopify_commerce, ai_concierge, concierge_v2, loyalty_rewards, referral_rewards, # sample_credits, whatsapp_marketing, agentic_feed FEATURE_FLAGS="" @@ -65,3 +67,15 @@ FEATURE_FLAGS="" # per-instance limiter is used (documented limitation in docs/SECURITY_REVIEW.md). UPSTASH_REDIS_REST_URL="" UPSTASH_REDIS_REST_TOKEN="" + +# --- Concierge V2 limits and cost controls --- +CONCIERGE_GUEST_QUESTIONS="1" +CONCIERGE_AUTH_PER_MINUTE="12" +CONCIERGE_AUTH_DAILY="100" +CONCIERGE_WEB_DAILY="15" +CONCIERGE_MAX_TOOL_CALLS="8" +CONCIERGE_MAX_SEARCH_CALLS="3" +CONCIERGE_MAX_OUTPUT_TOKENS="1400" +CONCIERGE_DAILY_SPEND_USD="25" +CONCIERGE_MONTHLY_SPEND_USD="300" +CONCIERGE_CATALOGUE_ONLY="false" diff --git a/app/admin/concierge/page.tsx b/app/admin/concierge/page.tsx new file mode 100644 index 0000000..2d4510b --- /dev/null +++ b/app/admin/concierge/page.tsx @@ -0,0 +1,49 @@ +import { prisma } from "@/lib/prisma" +import { env, integrationStatus } from "@/lib/env" +import { allFlags } from "@/lib/config/feature-flags" +import { conciergeProviderStatus } from "@/integrations/ai/router" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +export const dynamic = "force-dynamic" + +export default async function ConciergeAdminPage() { + const since = new Date(Date.now() - 24 * 60 * 60 * 1000) + const [conversations, usage, feedback] = await Promise.all([ + prisma.conciergeConversation.count(), + prisma.conciergeUsageEvent.aggregate({ where: { createdAt: { gte: since } }, _count: true, _avg: { totalLatencyMs: true }, _sum: { inputTokens: true, outputTokens: true, estimatedCostMicros: true } }), + prisma.conciergeFeedback.groupBy({ by: ["rating"], _count: true }), + ]) + const flags = allFlags() + const configured = integrationStatus() + const providers = conciergeProviderStatus() + return ( +
+
+

Concierge V2

+

Provider health, usage, limits, and emergency controls. Secret values are never shown.

+
+
+ + + + +
+ + Provider capability registry + + {providers.map((provider) => )}
ProviderConfiguredModelPriorityCapabilitiesCircuit
{provider.id}{provider.enabled ? "Yes" : "No"}{provider.model}{provider.priority}{provider.capabilities.join(", ")}{provider.circuits.some((c) => c.openUntil && c.openUntil > Date.now()) ? "Open" : "Healthy"}
+
+
+
+ Limits and cost controls + + + Usage, last 24 hours `${x.rating}: ${x._count}`).join(", ") || "None"} /> +
+

Change provider priority, limits, spend gates, catalogue-only mode, or the emergency kill switch through environment configuration and feature flags, then redeploy. API secret values remain server-only.

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

{label}

{value}

} +function Setting({ label, value }: { label: string; value: string | number }) { return
{label}{value}
} diff --git a/app/api/v2/concierge/allowance/route.ts b/app/api/v2/concierge/allowance/route.ts new file mode 100644 index 0000000..c5179bc --- /dev/null +++ b/app/api/v2/concierge/allowance/route.ts @@ -0,0 +1,13 @@ +import { route } from "@/lib/http/handler" +import { env } from "@/lib/env" +import { prisma } from "@/lib/prisma" +import { resolveConciergeIdentity } from "@/lib/concierge/conversation" + +export const runtime = "nodejs" +type AllowanceData = { authenticated: boolean; remaining: number | null; dailyLimit: number | null } +export const GET = route(async ({ req }) => { + const { identity } = await resolveConciergeIdentity(req) + if (identity.isAuthenticated) return { data: { authenticated: true, remaining: null, dailyLimit: env.CONCIERGE_AUTH_DAILY } } + const usage = await prisma.conciergeGuestAllowance.findUnique({ where: { guestKeyHash: identity.guestKeyHash! }, select: { successCount: true } }) + return { data: { authenticated: false, remaining: Math.max(0, env.CONCIERGE_GUEST_QUESTIONS - (usage?.successCount ?? 0)), dailyLimit: null } } +}) diff --git a/app/api/v2/concierge/chat/route.ts b/app/api/v2/concierge/chat/route.ts new file mode 100644 index 0000000..f98f698 --- /dev/null +++ b/app/api/v2/concierge/chat/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from "next/server" +import { z } from "zod" +import { isFeatureEnabled } from "@/lib/config/feature-flags" +import { ERROR_CATALOGUE, AppError, isAppError } from "@/lib/http/errors" +import { GUEST_COOKIE, resolveConciergeIdentity } from "@/lib/concierge/conversation" +import { routeConciergeIntent } from "@/lib/concierge/router" +import { orchestrateConciergeTurn } from "@/lib/concierge/orchestrator" +import { assertConciergeEntitlement, recordConciergeUsage } from "@/lib/concierge/usage" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +const Body = z.object({ message: z.string().trim().min(1).max(2000), conversationId: z.string().uuid().optional(), sampleFirst: z.boolean().optional() }) +const encode = (value: unknown) => new TextEncoder().encode(`${JSON.stringify(value)}\n`) + +export async function POST(req: NextRequest) { + const requestId = crypto.randomUUID() + if (!isFeatureEnabled("ai_concierge") || !isFeatureEnabled("concierge_v2")) return NextResponse.json({ data: null, error: { code: "FEATURE_DISABLED", message: ERROR_CATALOGUE.FEATURE_DISABLED.message }, meta: {}, requestId }, { status: 403 }) + let body: z.infer + let resolved: Awaited> + try { + body = Body.parse(await req.json()) + resolved = await resolveConciergeIdentity(req) + await assertConciergeEntitlement(req, resolved.identity, routeConciergeIntent(body.message).requiresWebResearch) + } catch (error) { + const appError = isAppError(error) ? error : error instanceof z.ZodError ? new AppError("VALIDATION_ERROR") : new AppError("INTERNAL_ERROR", { internal: error }) + return NextResponse.json({ data: null, error: { code: appError.code, message: appError.safeMessage }, meta: {}, requestId }, { status: appError.status }) + } + const startedAt = Date.now() + const stream = new ReadableStream({ + async start(controller) { + try { + controller.enqueue(encode({ type: "status", requestId, message: "Starting perfume intelligence" })) + const result = await orchestrateConciergeTurn({ requestId, identity: resolved.identity, ...body, signal: req.signal, onStatus: (message) => controller.enqueue(encode({ type: "status", message })) }) + const chunks = result.answer.match(/.{1,80}(?:\s|$)/g) ?? [result.answer] + for (const delta of chunks) { if (req.signal.aborted) throw new AppError("SERVICE_UNAVAILABLE", { message: "Generation was cancelled." }); controller.enqueue(encode({ type: "delta", delta })) } + if (result.products.length) controller.enqueue(encode({ type: "products", products: result.products })) + if (result.sources.length) controller.enqueue(encode({ type: "sources", sources: result.sources })) + controller.enqueue(encode({ type: "done", result: { ...result, answer: undefined } })) + await recordConciergeUsage({ requestId, identity: resolved.identity, result, startedAt, completionStatus: "SUCCESS" }) + } catch (error) { + const appError = isAppError(error) ? error : new AppError("INTERNAL_ERROR", { internal: error }) + controller.enqueue(encode({ type: "error", error: { code: appError.code, message: appError.safeMessage, retryable: appError.status >= 500 } })) + await recordConciergeUsage({ requestId, identity: resolved.identity, startedAt, completionStatus: req.signal.aborted ? "CANCELLED" : "FAILED", errorCode: appError.code }) + } finally { controller.close() } + }, + }) + const response = new NextResponse(stream, { status: 200, headers: { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store", "x-request-id": requestId, "x-content-type-options": "nosniff" } }) + if (resolved.newGuestToken) response.cookies.set(GUEST_COOKIE, resolved.newGuestToken, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 60 * 60 * 24 * 365 }) + return response +} diff --git a/app/api/v2/concierge/conversations/[id]/route.ts b/app/api/v2/concierge/conversations/[id]/route.ts new file mode 100644 index 0000000..80bd651 --- /dev/null +++ b/app/api/v2/concierge/conversations/[id]/route.ts @@ -0,0 +1,25 @@ +import { z } from "zod" +import { route } from "@/lib/http/handler" +import { resolveConciergeIdentity, loadOwnedConversation, renameOwnedConversation, archiveOwnedConversation } from "@/lib/concierge/conversation" + +export const runtime = "nodejs" +const Body = z.object({ title: z.string().trim().min(1).max(100) }) + +export const GET = route(async ({ req }) => { + const id = req.nextUrl.pathname.split("/").at(-1)! + const { identity } = await resolveConciergeIdentity(req) + return { data: { conversation: await loadOwnedConversation(id, identity) } } +}) +export const PATCH = route(async ({ req }) => { + const id = req.nextUrl.pathname.split("/").at(-1)! + const { identity } = await resolveConciergeIdentity(req) + const { title } = Body.parse(await req.json()) + await renameOwnedConversation(id, identity, title) + return { data: { id, title } } +}) +export const DELETE = route(async ({ req }) => { + const id = req.nextUrl.pathname.split("/").at(-1)! + const { identity } = await resolveConciergeIdentity(req) + await archiveOwnedConversation(id, identity) + return { data: { id, archived: true } } +}) diff --git a/app/api/v2/concierge/conversations/route.ts b/app/api/v2/concierge/conversations/route.ts new file mode 100644 index 0000000..9d7675a --- /dev/null +++ b/app/api/v2/concierge/conversations/route.ts @@ -0,0 +1,20 @@ +import { z } from "zod" +import { route } from "@/lib/http/handler" +import { resolveConciergeIdentity, listOwnedConversations, createOwnedConversation } from "@/lib/concierge/conversation" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export const GET = route(async ({ req }) => { + const { identity } = await resolveConciergeIdentity(req) + const conversations = await listOwnedConversations(identity, req.nextUrl.searchParams.get("q") ?? undefined) + return { data: { conversations } } +}) + +const Body = z.object({ title: z.string().trim().min(1).max(100).optional() }) +export const POST = route(async ({ req }) => { + const { identity } = await resolveConciergeIdentity(req) + const body = Body.parse(await req.json()) + const conversation = await createOwnedConversation(identity, body.title) + return { data: { conversation }, status: 201 } +}) diff --git a/app/api/v2/concierge/messages/[id]/feedback/route.ts b/app/api/v2/concierge/messages/[id]/feedback/route.ts new file mode 100644 index 0000000..ba0d079 --- /dev/null +++ b/app/api/v2/concierge/messages/[id]/feedback/route.ts @@ -0,0 +1,15 @@ +import { z } from "zod" +import { route, raise } from "@/lib/http/handler" +import { prisma } from "@/lib/prisma" +import { resolveConciergeIdentity } from "@/lib/concierge/conversation" + +const Body = z.object({ rating: z.enum(["HELPFUL", "NOT_HELPFUL", "REPORTED"]), reason: z.string().trim().max(500).optional() }) +export const POST = route(async ({ req }) => { + const messageId = req.nextUrl.pathname.split("/").at(-2)! + const { identity } = await resolveConciergeIdentity(req) + const message = await prisma.conciergeMessage.findFirst({ where: { id: messageId, role: "assistant", conversation: identity.userId ? { userId: identity.userId } : { guestKeyHash: identity.guestKeyHash! } }, select: { id: true } }) + if (!message) raise("NOT_FOUND") + const body = Body.parse(await req.json()) + const feedback = await prisma.conciergeFeedback.upsert({ where: { messageId }, create: { messageId, userId: identity.userId, guestKeyHash: identity.guestKeyHash, rating: body.rating, reportReason: body.reason }, update: { rating: body.rating, reportReason: body.reason } }) + return { data: { feedback: { id: feedback.id, rating: feedback.rating } } } +}) diff --git a/app/concierge/page.tsx b/app/concierge/page.tsx index 3ce884c..2124c49 100644 --- a/app/concierge/page.tsx +++ b/app/concierge/page.tsx @@ -5,7 +5,7 @@ import { ConciergeClient } from "@/components/concierge/concierge-client" export const metadata: Metadata = { title: "AI Scent Concierge", description: - "Describe the mood, occasion and notes you love, and the Fádé Scent Concierge recommends real, in-stock fragrances from our catalogue.", + "Ask Fádé Perfume Intelligence about notes, accords, perfume technique, climate, comparisons, current research, and live catalogue availability.", } export default function ConciergePage() { diff --git a/app/globals.css b/app/globals.css index 049887d..4062924 100644 --- a/app/globals.css +++ b/app/globals.css @@ -967,3 +967,9 @@ .animate-accordion-up { animation: accordion-up 0.2s ease-out; } + +@media (max-width: 1023px) { + aside.\-translate-x-full { + visibility: hidden; + } +} diff --git a/components/admin/admin-sidebar.tsx b/components/admin/admin-sidebar.tsx index fb7b9c7..74aa62a 100644 --- a/components/admin/admin-sidebar.tsx +++ b/components/admin/admin-sidebar.tsx @@ -3,7 +3,7 @@ import * as React from "react" import Link from "next/link" import { usePathname } from "next/navigation" -import { LayoutDashboard, Package, ShoppingCart, Tags, Folder, ChevronLeft, Menu, LogOut, Mail, Warehouse } from "lucide-react" +import { LayoutDashboard, Package, ShoppingCart, Tags, Folder, ChevronLeft, Menu, LogOut, Mail, Warehouse, Bot } from "lucide-react" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" @@ -18,6 +18,7 @@ const navItems = [ { name: "Orders", href: "/admin/orders", icon: ShoppingCart }, { name: "Inventory", href: "/admin/inventory", icon: Warehouse }, { name: "Newsletter", href: "/admin/newsletter", icon: Mail }, + { name: "Concierge V2", href: "/admin/concierge", icon: Bot }, ] function SidebarContent() { diff --git a/components/concierge/concierge-client.tsx b/components/concierge/concierge-client.tsx index 4ab54af..bc67d10 100644 --- a/components/concierge/concierge-client.tsx +++ b/components/concierge/concierge-client.tsx @@ -1,347 +1,131 @@ "use client" import * as React from "react" -import Link from "next/link" import Image from "next/image" -import { Sparkles, ArrowUp, RotateCcw, ArrowRight } from "lucide-react" -import { cn } from "@/lib/utils" +import Link from "next/link" +import { ArrowUp, Bot, Check, Copy, History, Menu, MessageSquarePlus, RotateCcw, Search, Square, ThumbsDown, ThumbsUp, X } from "lucide-react" import { Button } from "@/components/ui/button" -import { LogoMark } from "@/components/logo" - -/** Product shape returned by the recommendation engine (subset the UI needs). */ -interface ConciergeProduct { - id: string - slug: string - name: string - brand: string | null - price: { amountNGN: number } - images: string[] -} - -interface ConciergeItem { - product: ConciergeProduct - reasons: string[] - availability: "in_stock" | "preorder" | "waitlist" -} - -interface AssistantTurn { - role: "assistant" - text: string - items: ConciergeItem[] - disclaimer?: string -} -interface UserTurn { - role: "user" - text: string -} -type Turn = UserTurn | AssistantTurn - -const SUGGESTED_PROMPTS = [ - "A warm oud for Lagos evenings", - "Something clean and fresh for the office", - "Vanilla, but not too sweet", - "A romantic scent under ₦200,000", -] +import { cn } from "@/lib/utils" +import type { ConciergeProductCard, ConciergeSource } from "@/lib/concierge/types" -const AVAILABILITY_LABEL: Record = { - in_stock: "In stock", - preorder: "Pre-order", - waitlist: "Waitlist", -} +interface Turn { id?: string; role: "user" | "assistant"; text: string; products?: ConciergeProductCard[]; sources?: ConciergeSource[]; status?: string; error?: { code: string; message: string } } +interface ConversationSummary { id: string; title: string | null; updatedAt: string; _count: { messages: number } } -function formatNGN(amount: number) { - return new Intl.NumberFormat("en-NG", { - style: "currency", - currency: "NGN", - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(amount) -} +const PROMPTS = ["What does bergamot smell like?", "What works for a rainy-season office?", "Compare oud and sandalwood.", "Which Fádé scents are currently available?"] +const money = (amount: number) => new Intl.NumberFormat("en-NG", { style: "currency", currency: "NGN", maximumFractionDigits: 0 }).format(amount) export function ConciergeClient() { const [turns, setTurns] = React.useState([]) + const [conversations, setConversations] = React.useState([]) + const [conversationId, setConversationId] = React.useState() const [input, setInput] = React.useState("") const [sampleFirst, setSampleFirst] = React.useState(false) - const [status, setStatus] = React.useState<"idle" | "loading" | "error" | "unavailable">("idle") - const [lastQuery, setLastQuery] = React.useState("") + const [running, setRunning] = React.useState(false) + const [historyOpen, setHistoryOpen] = React.useState(false) + const [historyQuery, setHistoryQuery] = React.useState("") + const [allowance, setAllowance] = React.useState<{ authenticated: boolean; remaining: number | null }>() + const [copied, setCopied] = React.useState() + const abortRef = React.useRef(null) const scrollRef = React.useRef(null) + const textRef = React.useRef(null) + + const loadSidebar = React.useCallback(async () => { + const [historyRes, allowanceRes] = await Promise.all([fetch(`/api/v2/concierge/conversations${historyQuery ? `?q=${encodeURIComponent(historyQuery)}` : ""}`), fetch("/api/v2/concierge/allowance")]) + if (historyRes.ok) setConversations((await historyRes.json()).data?.conversations ?? []) + if (allowanceRes.ok) setAllowance((await allowanceRes.json()).data) + }, [historyQuery]) + + React.useEffect(() => { void loadSidebar() }, [loadSidebar]) + React.useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }) }, [turns, running]) + React.useEffect(() => { const el = textRef.current; if (el) { el.style.height = "0px"; el.style.height = `${Math.min(el.scrollHeight, 176)}px` } }, [input]) + React.useEffect(() => () => abortRef.current?.abort(), []) + + const newChat = () => { abortRef.current?.abort(); setConversationId(undefined); setTurns([]); setInput(""); setHistoryOpen(false) } + + const openConversation = async (id: string) => { + if (running) return + const response = await fetch(`/api/v2/concierge/conversations/${id}`) + const json = await response.json() + if (!response.ok || !json.data?.conversation) return + const conversation = json.data.conversation + setConversationId(id) + setTurns((conversation.messages ?? []).map((message: any) => ({ id: message.id, role: message.role, text: message.content, sources: Array.isArray(message.sources) ? message.sources : [] }))) + setHistoryOpen(false) + } - React.useEffect(() => { - scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }) - }, [turns, status]) - - const ask = React.useCallback( - async (message: string) => { - const trimmed = message.trim() - if (!trimmed || status === "loading") return - - setTurns((prev) => [...prev, { role: "user", text: trimmed }]) - setInput("") - setLastQuery(trimmed) - setStatus("loading") - - try { - const res = await fetch("/api/v1/concierge", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: trimmed, sampleFirst, limit: 6 }), - }) - const json = await res.json() - - if (!res.ok || json?.error) { - if (json?.error?.code === "FEATURE_DISABLED") { - setStatus("unavailable") - return - } - setStatus("error") - return + const ask = React.useCallback(async (raw: string) => { + const message = raw.trim() + if (!message || running) return + const controller = new AbortController(); abortRef.current = controller + setInput(""); setRunning(true) + setTurns((previous) => [...previous, { role: "user", text: message }, { role: "assistant", text: "", status: "Understanding your question" }]) + try { + const response = await fetch("/api/v2/concierge/chat", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ message, conversationId, sampleFirst }), signal: controller.signal }) + if (!response.ok || !response.body) { + const json = await response.json().catch(() => null) + throw Object.assign(new Error(json?.error?.message ?? "The concierge is unavailable."), { code: json?.error?.code ?? "SERVICE_UNAVAILABLE" }) + } + const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = "" + while (true) { + const { value, done } = await reader.read(); if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split("\n"); buffer = lines.pop() ?? "" + for (const line of lines) { + if (!line.trim()) continue + const event = JSON.parse(line) + setTurns((previous) => previous.map((turn, index) => index !== previous.length - 1 ? turn : event.type === "status" ? { ...turn, status: event.message } + : event.type === "delta" ? { ...turn, text: turn.text + event.delta, status: undefined } + : event.type === "products" ? { ...turn, products: event.products } + : event.type === "sources" ? { ...turn, sources: event.sources } + : event.type === "done" ? { ...turn, id: event.result.messageId, status: undefined } + : event.type === "error" ? { ...turn, status: undefined, error: event.error } + : turn)) + if (event.type === "done") setConversationId(event.result.conversationId) } - - const data = json.data - setTurns((prev) => [ - ...prev, - { - role: "assistant", - text: data.message ?? "Here are some matches.", - items: (data.items ?? []) as ConciergeItem[], - disclaimer: data.disclaimer, - }, - ]) - setStatus("idle") - } catch { - setStatus("error") } - }, - [sampleFirst, status], - ) + await loadSidebar() + } catch (error) { + if ((error as Error).name !== "AbortError") setTurns((previous) => previous.map((turn, index) => index === previous.length - 1 ? { ...turn, status: undefined, error: { code: (error as any).code ?? "SERVICE_UNAVAILABLE", message: (error as Error).message } } : turn)) + } finally { setRunning(false); abortRef.current = null } + }, [conversationId, loadSidebar, running, sampleFirst]) + const contextualTurn = [...turns].reverse().find((turn) => turn.role === "assistant" && ((turn.products?.length ?? 0) || (turn.sources?.length ?? 0))) const isEmpty = turns.length === 0 - - const composer = ( -
{ - e.preventDefault() - ask(input) - }} - className="w-full" - > -
-