From 43149059d4fcc029d29a5eb3c1a9ad668bb2ffea Mon Sep 17 00:00:00 2001 From: root Date: Tue, 14 Apr 2026 22:05:28 +0500 Subject: [PATCH] fixed: AI modules --- src/app/api/storefront-chat/messages/route.ts | 126 ++++++++++++----- src/app/storefront-chat/widget/page.tsx | 115 ++++++++++++--- src/lib/ai/orchestrator/runL1.ts | 132 ++++++++++++++++-- 3 files changed, 302 insertions(+), 71 deletions(-) diff --git a/src/app/api/storefront-chat/messages/route.ts b/src/app/api/storefront-chat/messages/route.ts index 2090c7c..53250fc 100644 --- a/src/app/api/storefront-chat/messages/route.ts +++ b/src/app/api/storefront-chat/messages/route.ts @@ -8,6 +8,8 @@ import { generateStorefrontReply } from "@/lib/ai/storefrontGroq" import { applyRateLimit } from "@/lib/rate-limit" import { storefrontChatPostSchema } from "@/lib/api-schemas/public" import { validationErrorResponse } from "@/lib/api-schemas/validate" +import { extractOrderNumberHints } from "@/lib/ai/gmailContext" +import { getOrderDetailForVerifiedEmail } from "@/lib/ai/tools/orderTools" function normalizeShopDomain(input: string) { return input @@ -28,6 +30,32 @@ function shopVariants(cleanDomain: string) { } const STOREFRONT_TICKET_TRIGGER = "STOREFRONT_TICKET" +const ESCALATION_ALREADY_DISPATCHED_REPLY = + "Your request is already dispatched to our human support team. Please wait for the follow-up email from our team." +const ORDER_VERIFICATION_REQUIRED_REPLY = + "To help with order-specific requests, please verify your email first using the Order help section below." +const ORDER_NOT_FOUND_FOR_VERIFIED_EMAIL_REPLY = + "I could not find that order for your verified email. Please double-check the order number and try again." + +function formatEscalationModuleLabel(moduleName?: string): string { + const raw = (moduleName || "CUSTOMER_SUPPORT").trim() + return raw + .split(/[_\s]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join(" ") +} + +function buildStorefrontTicketSubject(moduleName?: string): string { + return `${formatEscalationModuleLabel(moduleName)} Request` +} + +function buildOrderScopedTicketSubject(moduleName?: string, orderNumber?: string | null): string { + const base = buildStorefrontTicketSubject(moduleName) + const clean = (orderNumber || "").replace(/^\s*#?\s*/, "").trim() + if (!clean) return base + return `${base} - Order ${clean}` +} function shouldCreateEscalationTicket(ai: { content: string; meta?: { usedTools?: string[]; module?: string } }): boolean { if (Array.isArray(ai.meta?.usedTools) && ai.meta.usedTools.includes("ticket_intake")) { @@ -122,10 +150,11 @@ export async function POST(req: Request) { const recentHistory = await prisma.storefrontChatMessage.findMany({ where: { conversationId: conversation.id }, - orderBy: { createdAt: "asc" }, - take: 10, + orderBy: { createdAt: "desc" }, + take: 4, select: { senderType: true, content: true }, }) + const historyForAi = recentHistory.reverse() const sessionOk = Boolean(conversation.emailVerifiedAt) && @@ -137,7 +166,7 @@ export async function POST(req: Request) { storeName: store.businessName || "Our store", query: trimmedContent, visitorId, - history: recentHistory.map((m) => ({ + history: historyForAi.map((m) => ({ senderType: m.senderType, content: m.content, })), @@ -149,51 +178,78 @@ export async function POST(req: Request) { boundShopifyOrderId: conversation.boundShopifyOrderId, }) + const convoEmail = await prisma.storefrontChatConversation.findUnique({ + where: { id: conversation.id }, + select: { customerEmail: true }, + }) + const ticketTo = + convoEmail?.customerEmail?.trim().toLowerCase() || + customerEmail?.trim().toLowerCase() || + `visitor+${visitorId}@storefront.local` + const isEscalation = shouldCreateEscalationTicket(ai) + const verifiedEmail = + sessionOk && conversation.assertedEmail + ? conversation.assertedEmail.trim().toLowerCase() + : null + const requestedOrderHint = extractOrderNumberHints(trimmedContent)[0] ?? null + const hasOrderSpecificRequest = Boolean(requestedOrderHint) + const verifiedOrder = + isEscalation && verifiedEmail && requestedOrderHint + ? await getOrderDetailForVerifiedEmail(store.id, verifiedEmail, { + orderNumberHint: requestedOrderHint, + }) + : null + const ticketSubject = buildOrderScopedTicketSubject(ai.meta?.module, verifiedOrder?.orderNumber) + + // Security-first guardrail: + // For order-specific requests, only escalate when the referenced order is verified for the + // currently verified email session. + const blockedForMissingVerification = isEscalation && hasOrderSpecificRequest && !verifiedEmail + const blockedForOrderMismatch = isEscalation && hasOrderSpecificRequest && verifiedEmail && !verifiedOrder + + const existingEscalation = + isEscalation && !blockedForMissingVerification && !blockedForOrderMismatch + ? await prisma.emailLog.findFirst({ + where: { + storeId: store.id, + trigger: STOREFRONT_TICKET_TRIGGER, + to: ticketTo, + subject: ticketSubject, + }, + select: { id: true }, + }) + : null + + const alreadyEscalated = isEscalation && Boolean(existingEscalation) + const aiContent = blockedForMissingVerification + ? ORDER_VERIFICATION_REQUIRED_REPLY + : blockedForOrderMismatch + ? ORDER_NOT_FOUND_FOR_VERIFIED_EMAIL_REPLY + : alreadyEscalated + ? ESCALATION_ALREADY_DISPATCHED_REPLY + : ai.content + const aiMessage = await prisma.storefrontChatMessage.create({ data: { conversationId: conversation.id, senderType: "AI", - content: ai.content + content: aiContent }, select: { id: true, createdAt: true, senderType: true, content: true } }) - if (shouldCreateEscalationTicket(ai)) { + if ( + isEscalation && + !alreadyEscalated && + !blockedForMissingVerification && + !blockedForOrderMismatch + ) { try { const owner = await prisma.store.findUnique({ where: { id: store.id }, select: { userId: true }, }) - const convoEmail = await prisma.storefrontChatConversation.findUnique({ - where: { id: conversation.id }, - select: { customerEmail: true }, - }) - const ticketTo = - convoEmail?.customerEmail?.trim().toLowerCase() || - customerEmail?.trim().toLowerCase() || - `visitor+${visitorId}@storefront.local` - const ticketSubject = `[Storefront] ${ai.meta.module} · ${conversation.id}` - - const existing = await prisma.emailLog.findFirst({ - where: { - storeId: store.id, - to: ticketTo, - subject: ticketSubject, - trigger: STOREFRONT_TICKET_TRIGGER, - }, - select: { id: true }, - }) - - if (existing) { - await prisma.emailLog.update({ - where: { id: existing.id }, - data: { - bodyPreview: trimmedContent.slice(0, 2000), - sentAt: new Date(), - status: "SENT", - }, - }) - } else if (owner?.userId) { + if (owner?.userId) { await prisma.emailLog.create({ data: { userId: owner.userId, diff --git a/src/app/storefront-chat/widget/page.tsx b/src/app/storefront-chat/widget/page.tsx index 80c6662..60c637f 100644 --- a/src/app/storefront-chat/widget/page.tsx +++ b/src/app/storefront-chat/widget/page.tsx @@ -11,6 +11,58 @@ type WidgetMessage = { senderType: "CUSTOMER" | "AI" } +function isLikelyHttpUrl(value: string) { + return /^https?:\/\/[^\s)]+$/i.test(value.trim()) +} + +function renderMessageContent(content: string) { + // Supports both markdown links: [label](https://...) + // and plain URLs in free text. + const markdownOrUrl = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s)]+)/gi + const nodes: Array = [] + let lastIndex = 0 + let match: RegExpExecArray | null + + while ((match = markdownOrUrl.exec(content)) !== null) { + const [full] = match + const start = match.index + const end = start + full.length + + if (start > lastIndex) { + nodes.push(content.slice(lastIndex, start)) + } + + const markdownLabel = match[1] + const markdownUrl = match[2] + const plainUrl = match[3] + const href = markdownUrl || plainUrl || "" + + if (isLikelyHttpUrl(href)) { + nodes.push( + + {markdownLabel || href} + + ) + } else { + nodes.push(full) + } + + lastIndex = end + } + + if (lastIndex < content.length) { + nodes.push(content.slice(lastIndex)) + } + + return nodes +} + function getOrCreateVisitorId(shop: string) { const key = `juno_storefront_visitor_id_${shop}` try { @@ -34,6 +86,7 @@ export default function StorefrontChatWidgetPage() { const [sending, setSending] = useState(false) const [sendError, setSendError] = useState(null) const [loadingHistory, setLoadingHistory] = useState(false) + const [aiTyping, setAiTyping] = useState(false) const [verifyEmail, setVerifyEmail] = useState("") const [otpCode, setOtpCode] = useState("") @@ -250,6 +303,19 @@ export default function StorefrontChatWidgetPage() { if (!shop || !visitorId) return const text = composer.trim() if (!text) return + const typingStartedAt = Date.now() + + const optimisticCustomerMessage: WidgetMessage = { + id: `local_customer_${Date.now()}`, + content: text, + senderType: "CUSTOMER", + createdAt: new Date().toISOString(), + } + + setMessages((prev) => [...prev, optimisticCustomerMessage]) + setComposer("") + setAiTyping(true) + setTimeout(() => composerRef.current?.focus(), 0) try { setSendError(null) @@ -267,31 +333,25 @@ export default function StorefrontChatWidgetPage() { const returned = Array.isArray(data?.messages) ? data.messages : [] if (returned.length) { - setMessages(prev => [ - ...prev, - ...returned.map((m: any) => ({ - id: String(m.id || `local_${Date.now()}`), - content: String(m.content || ""), - senderType: (m.senderType === "AI" ? "AI" : "CUSTOMER") as "AI" | "CUSTOMER", - createdAt: String(m.createdAt || new Date().toISOString()) - })) - ]) - } else { - setMessages(prev => [ - ...prev, - { - id: `local_${Date.now()}`, - content: text, - senderType: "CUSTOMER", - createdAt: new Date().toISOString() - } - ]) + const returnedTyped = returned.map((m: any) => ({ + id: String(m.id || `local_${Date.now()}`), + content: String(m.content || ""), + senderType: (m.senderType === "AI" ? "AI" : "CUSTOMER") as "AI" | "CUSTOMER", + createdAt: String(m.createdAt || new Date().toISOString()) + })) + const aiReplies = returnedTyped.filter((m: WidgetMessage) => m.senderType === "AI") + const elapsed = Date.now() - typingStartedAt + const minTypingMs = 1000 + if (elapsed < minTypingMs) { + await new Promise((resolve) => setTimeout(resolve, minTypingMs - elapsed)) + } + setMessages(prev => [...prev, ...aiReplies]) } - setComposer("") - setTimeout(() => composerRef.current?.focus(), 0) } catch (e: any) { setSendError(e?.message || "Failed to send message") + setMessages((prev) => prev.filter((m) => m.id !== optimisticCustomerMessage.id)) } finally { + setAiTyping(false) setSending(false) } } @@ -342,10 +402,21 @@ export default function StorefrontChatWidgetPage() { : "bg-indigo-600 text-white" }`} > - {m.content} + {renderMessageContent(m.content)} ))} + {aiTyping ? ( +
+
+
+ + + +
+
+
+ ) : null}
)} diff --git a/src/lib/ai/orchestrator/runL1.ts b/src/lib/ai/orchestrator/runL1.ts index 2d6222e..7f0aac8 100644 --- a/src/lib/ai/orchestrator/runL1.ts +++ b/src/lib/ai/orchestrator/runL1.ts @@ -36,6 +36,8 @@ const WIDGET_CANCEL_GENERAL_HELP = const FAQ_DISABLED_FALLBACK = "I don't know about this—please contact our support team for help." +const OUT_OF_SCOPE_WIDGET_FALLBACK = + "I can only help with this store's products, orders, shipping, returns, and policies. Please ask a store-related question." /** Limit customer-supplied text size (prompt injection + token cost). */ const L1_MAX_CUSTOMER_MESSAGE_CHARS = 6000 @@ -201,6 +203,61 @@ function isGenericDeliveryEtaQuestion(message: string): boolean { return !orderSpecificSignals } +function isClearlyOutOfScopeMessage(message: string): boolean { + const text = message.trim().toLowerCase() + if (!text) return false + + // Basic math / homework / coding assistant requests that are unrelated to storefront support. + if ( + /(^|\b)(solve|calculate|what is|what's)\s+[\d\s+\-*/().^=]+(\?|$)/i.test(text) || + /(^|\b)(math|algebra|geometry|calculus|equation|integral|derivative|prime number)(\b|$)/i.test( + text, + ) || + /(^|\b)(write code|debug|python|javascript|typescript|java|c\+\+|sql query)(\b|$)/i.test( + text, + ) + ) { + return true + } + + // Storefront-relevant keywords should keep normal handling. + if ( + /(order|track|shipping|delivery|return|refund|exchange|cancel|address|product|size|stock|policy|warranty|payment|store|vendor|supplier)/i.test( + text, + ) + ) { + return false + } + + // Generic knowledge chatter is out of scope for the storefront widget. + if ( + /(who is|who was|capital of|weather|news|joke|translate|recipe|movie|song|history|science|politics)/i.test( + text, + ) + ) { + return true + } + + return false +} + +function extractLastCustomerContext( + history: RunL1Input["history"], + currentMessage: string, +): string | null { + const normalizedCurrent = currentMessage.trim().toLowerCase() + for (let i = history.length - 1; i >= 0; i--) { + const msg = history[i] + if (msg.senderType === "AI" || msg.role === "assistant") continue + const text = (msg.content || "").trim() + if (!text) continue + if (text.length < 3) continue + if (text.toLowerCase() === normalizedCurrent) continue + return text.slice(0, 240) + } + return null +} + async function composeReply(params: { storeName: string storeReference: string @@ -281,11 +338,30 @@ function isPolicyLikeReturnOrChangeQuestion(message: string, module: L1Module): } export async function runL1Orchestrator(input: RunL1Input): Promise { + if (input.channel === "widget" && isClearlyOutOfScopeMessage(input.message)) { + return { + text: OUT_OF_SCOPE_WIDGET_FALLBACK, + meta: { + module: "UNKNOWN", + pillar: "K", + usedTools: [], + kbHits: 0, + productHits: 0, + }, + } + } + + const lastCustomerContext = extractLastCustomerContext(input.history, input.message) + const effectiveMessage = + lastCustomerContext + ? `${input.message}\nPrior customer context from this same conversation: ${lastCustomerContext}` + : input.message + const modules = parseAiModulesJson(input.aiModulesJson) const sharedAnswers = parseSharedFieldAnswersJson(input.sharedFieldAnswersJson) const retrieval = await getBlendedStorefrontContext({ storeId: input.storeId, - query: input.message, + query: effectiveMessage, }) const kbContext = retrieval.items.length > 0 @@ -294,11 +370,11 @@ export async function runL1Orchestrator(input: RunL1Input): Promise .join("\n") : "" - const routed = await routeCustomerIntent(input.message) + const routed = await routeCustomerIntent(effectiveMessage) let activeModule: L1Module = routed.module if ( (activeModule === "SHIPPING_TRACKING" || activeModule === "ORDER_STATUS") && - isGenericDeliveryEtaQuestion(input.message) + isGenericDeliveryEtaQuestion(effectiveMessage) ) { activeModule = "FAQ" } @@ -324,8 +400,9 @@ export async function runL1Orchestrator(input: RunL1Input): Promise const type1Facts = formatSharedFieldsForModule(activeModule, sharedAnswers) const preferKnowledgeReplyOverTicket = activeModule === "RETURN_EXCHANGE" || activeModule === "ORDER_CHANGE" - ? isPolicyLikeReturnOrChangeQuestion(input.message, activeModule) + ? isPolicyLikeReturnOrChangeQuestion(effectiveMessage, activeModule) : false + const forceKnowledgeOnly = activeModule === "WARRANTY" const categoryMetadetailsOn = input.categoryMetadetailsEnabled !== undefined && input.categoryMetadetailsEnabled !== null @@ -408,7 +485,7 @@ export async function runL1Orchestrator(input: RunL1Input): Promise ordersForEmail.length > 0 && (activeModule === "ORDER_SUMMARY" || activeModule === "ORDER_STATUS") ) { - const listMode = detectOrderListMode(input.message) + const listMode = detectOrderListMode(effectiveMessage) if (listMode) { return { text: buildOrderListText(ordersForEmail, listMode), @@ -435,7 +512,11 @@ export async function runL1Orchestrator(input: RunL1Input): Promise ] .filter(Boolean) .join("\n\n") - if (pillar === "X" && isAutomationEnabled(modules, activeModule, "ticket")) { + if ( + pillar === "X" && + isAutomationEnabled(modules, activeModule, "ticket") && + !forceKnowledgeOnly + ) { return { text: ticketHandoffText(activeModule, input.storeName), meta: { @@ -449,7 +530,8 @@ export async function runL1Orchestrator(input: RunL1Input): Promise } if ( pillar === "T" && - isAutomationEnabled(modules, activeModule, "ticket") + isAutomationEnabled(modules, activeModule, "ticket") && + !forceKnowledgeOnly ) { return { text: ticketHandoffText(activeModule, input.storeName), @@ -465,7 +547,7 @@ export async function runL1Orchestrator(input: RunL1Input): Promise const text = await composeReply({ storeName: input.storeName, storeReference: facts.trim() ? facts : EMPTY_STORE_REFERENCE, - message: input.message, + message: effectiveMessage, history: historyToGroq(input.history), }) return { @@ -484,6 +566,7 @@ export async function runL1Orchestrator(input: RunL1Input): Promise pillar === "X" && ordersForEmail.length > 0 && isAutomationEnabled(modules, activeModule, "ticket") && + !forceKnowledgeOnly && !preferKnowledgeReplyOverTicket ) { return { @@ -498,11 +581,11 @@ export async function runL1Orchestrator(input: RunL1Input): Promise } } - const hints = extractOrderNumberHints(input.message) + const hints = extractOrderNumberHints(effectiveMessage) const hintText = input.channel === "email" - ? `${input.message}\n${(input.history || []).map((h) => h.content).join("\n")}` - : input.message + ? `${effectiveMessage}\n${(input.history || []).map((h) => h.content).join("\n")}` + : effectiveMessage const disambiguate = customerEmail && @@ -517,18 +600,39 @@ export async function runL1Orchestrator(input: RunL1Input): Promise customerEmail != null && ORDER_DETAIL_MODULES.includes(activeModule) && !input.boundShopifyOrderId - ? pickMentionedOrderId(input.message, ordersForEmail) + ? pickMentionedOrderId(effectiveMessage, ordersForEmail) : null const effectiveBoundShopifyOrderId = mentionedOrderId ?? (shouldAutoPickLatestOrder && ordersForEmail[0] ? ordersForEmail[0].shopifyOrderId : input.boundShopifyOrderId ?? null) + const hasExplicitOrderHint = hints.length > 0 + + if ( + ORDER_DETAIL_MODULES.includes(activeModule) && + customerEmail && + hasExplicitOrderHint && + !mentionedOrderId && + isAutomationEnabled(modules, activeModule, "verifiedLookup") + ) { + return { + text: "I could not find that order number for your verified email. Please double-check the order number and try again.", + meta: { + module: activeModule, + pillar, + usedTools: ["list_orders"], + kbHits: retrieval.kbCount, + productHits: retrieval.productCount, + }, + } + } if ( ORDER_DETAIL_MODULES.includes(activeModule) && customerEmail && disambiguate && + !hasExplicitOrderHint && !effectiveBoundShopifyOrderId && isAutomationEnabled(modules, activeModule, "verifiedLookup") ) { @@ -602,7 +706,7 @@ export async function runL1Orchestrator(input: RunL1Input): Promise isAutomationEnabled(modules, activeModule, "knowledge")) || (activeModule === "INVENTORY_STOCK" && isModuleEnabled(modules, activeModule)) if (runProductSearch) { - const hits = await searchProductsForStore(input.storeId, input.message, 5) + const hits = await searchProductsForStore(input.storeId, effectiveMessage, 5) usedTools.push("search_products") if (hits.length) { productFacts = hits @@ -635,7 +739,7 @@ export async function runL1Orchestrator(input: RunL1Input): Promise const text = await composeReply({ storeName: input.storeName, storeReference: facts.trim() ? facts : EMPTY_STORE_REFERENCE, - message: input.message, + message: effectiveMessage, history: historyToGroq(input.history), })