import { randomUUID } from "node:crypto" import { NextResponse, type NextRequest } from "next/server" import { SITE_CHAT_MAX_HISTORY_MESSAGES, SITE_CHAT_MAX_INPUT_CHARS, SITE_CHAT_MAX_MESSAGE_CHARS, SITE_CHAT_MAX_OUTPUT_CHARS, SITE_CHAT_MAX_OUTPUT_CHARS_PER_SESSION_WINDOW, SITE_CHAT_MAX_OUTPUT_TOKENS, SITE_CHAT_MAX_REQUESTS_PER_IP_WINDOW, SITE_CHAT_MAX_REQUESTS_PER_SESSION_WINDOW, SITE_CHAT_MODEL, SITE_CHAT_OUTPUT_WINDOW_MS, SITE_CHAT_REQUEST_WINDOW_MS, SITE_CHAT_SESSION_COOKIE, SITE_CHAT_SOURCE, SITE_CHAT_TEMPERATURE, isSiteChatSuppressedRoute, } from "@/lib/site-chat/config" import { buildSiteChatSystemPrompt } from "@/lib/site-chat/prompt" import { consumeChatOutput, consumeChatRequest, getChatRateLimitStatus, } from "@/lib/site-chat/rate-limit" import { formatManualContextForPrompt, retrieveManualContext, shouldUseManualKnowledgeForChat, summarizeManualRetrieval, } from "@/lib/manuals-knowledge" import { createSmsConsentPayload } from "@/lib/sms-compliance" type ChatRole = "user" | "assistant" type ChatMessage = { role: ChatRole content: string } type ChatRequestBody = { messages?: ChatMessage[] pathname?: string sessionId?: string visitor?: { consentCapturedAt?: string consentSourcePage?: string consentVersion?: string email?: string intent?: string marketingTextConsent?: boolean name?: string phone?: string serviceTextConsent?: boolean } } type ChatVisitorProfile = { consentCapturedAt: string consentSourcePage: string consentVersion: string email: string intent: string marketingTextConsent: boolean name: string phone: string serviceTextConsent: boolean } function getOptionalEnv(name: string) { const value = process.env[name] return typeof value === "string" && value.trim() ? value.trim() : "" } function getClientIp(request: NextRequest) { const forwardedFor = request.headers.get("x-forwarded-for") if (forwardedFor) { return forwardedFor.split(",")[0]?.trim() || "unknown" } return request.headers.get("x-real-ip") || "unknown" } function normalizeSessionId(rawSessionId: string | undefined | null) { const value = (rawSessionId || "").trim() if (!value) { return randomUUID() } return value.slice(0, 120) } function normalizePathname(rawPathname: string | undefined) { const pathname = typeof rawPathname === "string" && rawPathname.trim() ? rawPathname.trim() : "/" return pathname.startsWith("/") ? pathname : `/${pathname}` } function normalizeMessages(messages: ChatMessage[] | undefined) { const safeMessages = Array.isArray(messages) ? messages : [] return safeMessages .filter( (message) => message && (message.role === "user" || message.role === "assistant") ) .map((message) => ({ role: message.role, content: String(message.content || "") .replace(/\s+/g, " ") .trim() .slice(0, SITE_CHAT_MAX_MESSAGE_CHARS), })) .filter((message) => message.content.length > 0) .slice(-SITE_CHAT_MAX_HISTORY_MESSAGES) } function normalizeVisitorProfile( rawVisitor: ChatRequestBody["visitor"], pathname: string ): ChatVisitorProfile | null { if (!rawVisitor) { return null } const name = String(rawVisitor.name || "") .replace(/\s+/g, " ") .trim() .slice(0, 80) const phone = String(rawVisitor.phone || "") .replace(/\s+/g, " ") .trim() .slice(0, 40) const email = String(rawVisitor.email || "") .replace(/\s+/g, " ") .trim() .slice(0, 120) .toLowerCase() const intent = String(rawVisitor.intent || "") .replace(/\s+/g, " ") .trim() .slice(0, 80) if (!name || !phone || !email || !intent) { return null } const digits = phone.replace(/\D/g, "") const emailIsValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) if (digits.length < 10 || !emailIsValid) { return null } const consentPayload = createSmsConsentPayload({ consentCapturedAt: rawVisitor.consentCapturedAt, consentSourcePage: rawVisitor.consentSourcePage || pathname, consentVersion: rawVisitor.consentVersion, marketingTextConsent: rawVisitor.marketingTextConsent, serviceTextConsent: rawVisitor.serviceTextConsent, }) if (!consentPayload.serviceTextConsent) { return null } return { email, intent, name, phone, ...consentPayload, } } function truncateOutput(text: string) { const clean = text.replace(/\s+/g, " ").trim() if (clean.length <= SITE_CHAT_MAX_OUTPUT_CHARS) { return clean } return `${clean.slice(0, SITE_CHAT_MAX_OUTPUT_CHARS - 1).trimEnd()}…` } function extractAssistantText(data: any) { const messageContent = data?.choices?.[0]?.message?.content if (typeof messageContent === "string") { return messageContent } if (Array.isArray(messageContent)) { return messageContent .map((item) => { if (typeof item === "string") { return item } if (item && typeof item.text === "string") { return item.text } if (item && typeof item.content === "string") { return item.content } return "" }) .join(" ") .trim() } return "" } function buildManualKnowledgeQuery(messages: ChatMessage[]) { return messages .filter((message) => message.role === "user") .slice(-3) .map((message) => message.content.trim()) .filter(Boolean) .join(" ") } export async function POST(request: NextRequest) { const responseHeaders: Record = { "Cache-Control": "no-store", } try { const body = (await request.json().catch(() => ({}))) as ChatRequestBody const pathname = normalizePathname(body.pathname) const visitor = normalizeVisitorProfile(body.visitor, pathname) if (isSiteChatSuppressedRoute(pathname)) { return NextResponse.json( { error: "Chat is not available on this route." }, { status: 403, headers: responseHeaders } ) } if (!visitor) { return NextResponse.json( { error: "Name, phone, email, intent, and required service SMS consent are needed to start chat.", }, { status: 400, headers: responseHeaders } ) } const sessionId = normalizeSessionId( body.sessionId || request.cookies.get(SITE_CHAT_SESSION_COOKIE)?.value ) const ip = getClientIp(request) const messages = normalizeMessages(body.messages) const latestUserMessage = [...messages] .reverse() .find((message) => message.role === "user") if (!latestUserMessage) { return NextResponse.json( { error: "A user message is required.", sessionId }, { status: 400, headers: responseHeaders } ) } if (latestUserMessage.content.length > SITE_CHAT_MAX_INPUT_CHARS) { return NextResponse.json( { error: `Please keep each message under ${SITE_CHAT_MAX_INPUT_CHARS} characters.`, sessionId, }, { status: 400, headers: responseHeaders } ) } const limitStatus = getChatRateLimitStatus({ ip, maxIpRequests: SITE_CHAT_MAX_REQUESTS_PER_IP_WINDOW, maxSessionRequests: SITE_CHAT_MAX_REQUESTS_PER_SESSION_WINDOW, maxSessionOutputChars: SITE_CHAT_MAX_OUTPUT_CHARS_PER_SESSION_WINDOW, outputWindowMs: SITE_CHAT_OUTPUT_WINDOW_MS, requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS, sessionId, }) if (limitStatus.blocked) { const blockedResponse = NextResponse.json( { error: "Chat is temporarily limited right now. Please wait a bit or call Rocky Mountain Vending directly.", sessionId, limits: limitStatus, }, { status: 429, headers: responseHeaders } ) blockedResponse.cookies.set(SITE_CHAT_SESSION_COOKIE, sessionId, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 60 * 60 * 24 * 30, }) return blockedResponse } consumeChatRequest({ ip, requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS, sessionId, }) const manualKnowledgeQuery = buildManualKnowledgeQuery(messages) const shouldUseManualKnowledge = shouldUseManualKnowledgeForChat( visitor.intent, manualKnowledgeQuery ) let manualKnowledge = null let manualKnowledgeError: unknown = null if (shouldUseManualKnowledge) { try { manualKnowledge = await retrieveManualContext(manualKnowledgeQuery) } catch (error) { manualKnowledgeError = error console.error("[site-chat] manuals knowledge lookup failed", { pathname, sessionId, error, }) } } console.info( "[site-chat] manuals retrieval", summarizeManualRetrieval({ ran: shouldUseManualKnowledge, query: manualKnowledgeQuery, result: manualKnowledge, error: manualKnowledgeError, }) ) const systemPrompt = buildSiteChatSystemPrompt() const xaiApiKey = getOptionalEnv("XAI_API_KEY") if (!xaiApiKey) { console.warn("[site-chat] missing XAI_API_KEY", { pathname, sessionId, }) return NextResponse.json( { error: "Jessica is temporarily unavailable right now. Please call us or use the contact form.", sessionId, }, { status: 503, headers: responseHeaders } ) } const completionResponse = await fetch( "https://api.x.ai/v1/chat/completions", { method: "POST", headers: { Authorization: `Bearer ${xaiApiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: SITE_CHAT_MODEL, temperature: SITE_CHAT_TEMPERATURE, max_tokens: SITE_CHAT_MAX_OUTPUT_TOKENS, messages: [ { role: "system", content: `${systemPrompt}\n\nConversation context:\n- Current pathname: ${pathname}\n- Source: ${SITE_CHAT_SOURCE}\n- Visitor name: ${visitor.name}\n- Visitor email: ${visitor.email}\n- Visitor phone: ${visitor.phone}\n- Visitor intent: ${visitor.intent}\n- Service SMS consent: ${visitor.serviceTextConsent ? "yes" : "no"}\n- Marketing SMS consent: ${visitor.marketingTextConsent ? "yes" : "no"}`, }, ...(shouldUseManualKnowledge ? [ { role: "system" as const, content: manualKnowledge ? formatManualContextForPrompt(manualKnowledge) : "Manual knowledge context:\n- A manual lookup was attempted, but no reliable manual context is available.\n- Do not guess. Ask for the brand, model sticker, or a clear photo/video that can be texted in.", }, ] : []), ...messages, ], }), } ) const completionData = await completionResponse.json().catch(() => ({})) if (!completionResponse.ok) { console.error("[site-chat] xAI completion failed", { status: completionResponse.status, pathname, sessionId, completionData, }) return NextResponse.json( { error: "Jessica is having trouble replying right now. Please try again or call us directly.", sessionId, }, { status: 502, headers: responseHeaders } ) } const assistantReply = truncateOutput(extractAssistantText(completionData)) if (!assistantReply) { return NextResponse.json( { error: "Jessica did not return a usable reply. Please try again.", sessionId, }, { status: 502, headers: responseHeaders } ) } consumeChatOutput({ chars: assistantReply.length, outputWindowMs: SITE_CHAT_OUTPUT_WINDOW_MS, sessionId, }) const nextLimitStatus = getChatRateLimitStatus({ ip, maxIpRequests: SITE_CHAT_MAX_REQUESTS_PER_IP_WINDOW, maxSessionRequests: SITE_CHAT_MAX_REQUESTS_PER_SESSION_WINDOW, maxSessionOutputChars: SITE_CHAT_MAX_OUTPUT_CHARS_PER_SESSION_WINDOW, outputWindowMs: SITE_CHAT_OUTPUT_WINDOW_MS, requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS, sessionId, }) const response = NextResponse.json( { reply: assistantReply, sessionId, limits: nextLimitStatus, }, { headers: responseHeaders } ) response.cookies.set(SITE_CHAT_SESSION_COOKIE, sessionId, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 60 * 60 * 24 * 30, }) return response } catch (error) { console.error("[site-chat] request failed", error) const safeError = error instanceof Error && error.message.startsWith( "Missing required site chat environment variable:" ) ? "Jessica is temporarily unavailable right now. Please call us or use the contact form." : error instanceof Error ? error.message : "Chat failed unexpectedly." return NextResponse.json( { error: safeError, }, { status: 500, headers: responseHeaders } ) } }