diff --git a/src/app/api/storefront-chat/messages/route.ts b/src/app/api/storefront-chat/messages/route.ts index 2497edd..71f5cb9 100644 --- a/src/app/api/storefront-chat/messages/route.ts +++ b/src/app/api/storefront-chat/messages/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server" import { prisma } from "@/lib/prisma" +import { generateStorefrontReply } from "@/lib/ai/storefrontGroq" type StorefrontMessagePayload = { shop: string @@ -10,6 +11,7 @@ type StorefrontMessagePayload = { export async function POST(req: Request) { try { + const p = prisma as any const payload = (await req.json().catch(() => ({}))) as Partial const { shop, visitorId, content } = payload @@ -38,7 +40,7 @@ export async function POST(req: Request) { return NextResponse.json({ message: "Invalid visitorId" }, { status: 400 }) } - const store = await prisma.store.findFirst({ + const store = await p.store.findFirst({ where: { shopifyDomain: trimmedShop }, select: { id: true, storefrontChatEnabled: true } }) @@ -51,7 +53,7 @@ export async function POST(req: Request) { } // Find or create conversation for this anonymous visitor. - let conversation = await prisma.storefrontChatConversation.findFirst({ + let conversation = await p.storefrontChatConversation.findFirst({ where: { storeId: store.id, visitorId: trimmedVisitorId }, select: { id: true } }) @@ -59,7 +61,7 @@ export async function POST(req: Request) { if (conversation) { // Very lightweight rate limit: max 5 messages per 30 seconds per conversation. const windowMs = 30_000 - const recentCount = await prisma.storefrontChatMessage.count({ + const recentCount = await p.storefrontChatMessage.count({ where: { conversationId: conversation.id, createdAt: { gte: new Date(Date.now() - windowMs) } @@ -70,7 +72,7 @@ export async function POST(req: Request) { return NextResponse.json({ message: "Too many messages, slow down" }, { status: 429 }) } } else { - conversation = await prisma.storefrontChatConversation.create({ + conversation = await p.storefrontChatConversation.create({ data: { storeId: store.id, visitorId: trimmedVisitorId, @@ -80,21 +82,45 @@ export async function POST(req: Request) { }) } - const message = await prisma.storefrontChatMessage.create({ + const customerMessage = await p.storefrontChatMessage.create({ data: { conversationId: conversation.id, senderType: "CUSTOMER", content: trimmedContent }, - select: { id: true, createdAt: true } + select: { id: true, createdAt: true, senderType: true, content: true } + }) + + const recentHistory = await p.storefrontChatMessage.findMany({ + where: { conversationId: conversation.id }, + orderBy: { createdAt: "asc" }, + take: 10, + select: { senderType: true, content: true } + }) + + const ai = await generateStorefrontReply({ + storeId: store.id, + query: trimmedContent, + visitorId: trimmedVisitorId, + history: recentHistory.map((m: any) => ({ + senderType: m.senderType, + content: m.content + })) + }) + + const aiMessage = await p.storefrontChatMessage.create({ + data: { + conversationId: conversation.id, + senderType: "AI", + content: ai.content + }, + select: { id: true, createdAt: true, senderType: true, content: true } }) return NextResponse.json({ success: true, - message: { - id: message.id, - createdAt: message.createdAt - } + messages: [customerMessage, aiMessage], + meta: ai.meta }) } catch (error) { console.error("[STORE_FRONT_CHAT_MESSAGES_POST]", error) @@ -102,3 +128,52 @@ export async function POST(req: Request) { } } +export async function GET(req: Request) { + try { + const p = prisma as any + const { searchParams } = new URL(req.url) + const shop = searchParams.get("shop")?.trim() || "" + const visitorId = searchParams.get("visitorId")?.trim() || "" + + if (!shop) { + return NextResponse.json({ message: "Missing shop" }, { status: 400 }) + } + if (!visitorId) { + return NextResponse.json({ message: "Missing visitorId" }, { status: 400 }) + } + + const store = await p.store.findFirst({ + where: { shopifyDomain: shop }, + select: { id: true, storefrontChatEnabled: true } + }) + + if (!store) { + return NextResponse.json({ message: "Store not found" }, { status: 404 }) + } + if (!store.storefrontChatEnabled) { + return NextResponse.json({ message: "Widget disabled" }, { status: 403 }) + } + + const conversation = await p.storefrontChatConversation.findFirst({ + where: { storeId: store.id, visitorId }, + select: { id: true } + }) + + if (!conversation) { + return NextResponse.json({ success: true, messages: [] }) + } + + const messages = await p.storefrontChatMessage.findMany({ + where: { conversationId: conversation.id }, + orderBy: { createdAt: "asc" }, + take: 120, + select: { id: true, content: true, senderType: true, createdAt: true } + }) + + return NextResponse.json({ success: true, messages }) + } catch (error) { + console.error("[STORE_FRONT_CHAT_MESSAGES_GET]", error) + return NextResponse.json({ message: "Internal Error" }, { status: 500 }) + } +} + diff --git a/src/app/dashboard/storefront-chat/page.tsx b/src/app/dashboard/storefront-chat/page.tsx index 82bf185..5a02847 100644 --- a/src/app/dashboard/storefront-chat/page.tsx +++ b/src/app/dashboard/storefront-chat/page.tsx @@ -52,6 +52,8 @@ export default async function StorefrontChatInboxPage() { }) const sorted = [...messages].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + const aiCount = sorted.filter((m) => m.senderType === "AI").length + const customerCount = sorted.filter((m) => m.senderType === "CUSTOMER").length return (
@@ -65,6 +67,9 @@ export default async function StorefrontChatInboxPage() {

{conversations.length} visitors

{sorted.length} messages

+

+ {customerCount} customer · {aiCount} AI +

@@ -84,11 +89,20 @@ export default async function StorefrontChatInboxPage() {
-

- {m.senderType === "CUSTOMER" ? "CUSTOMER" : m.senderType} - {" · "} - {m.conversation?.visitorId || "visitor"} -

+
+ + {m.senderType === "CUSTOMER" ? "CUSTOMER" : "AI"} + +

+ {m.conversation?.visitorId || "visitor"} +

+
{m.conversation?.customerEmail ? (

{m.conversation.customerEmail}

) : null} diff --git a/src/app/storefront-chat/widget/page.tsx b/src/app/storefront-chat/widget/page.tsx index 863bccb..7503c2b 100644 --- a/src/app/storefront-chat/widget/page.tsx +++ b/src/app/storefront-chat/widget/page.tsx @@ -7,6 +7,7 @@ type WidgetMessage = { id: string content: string createdAt: string + senderType: "CUSTOMER" | "AI" } function getOrCreateVisitorId(shop: string) { @@ -31,6 +32,7 @@ export default function StorefrontChatWidgetPage() { const [composer, setComposer] = useState("") const [sending, setSending] = useState(false) const [sendError, setSendError] = useState(null) + const [loadingHistory, setLoadingHistory] = useState(false) const composerRef = useRef(null) const messagesEndRef = useRef(null) @@ -74,6 +76,48 @@ export default function StorefrontChatWidgetPage() { setVisitorId(getOrCreateVisitorId(shop)) }, [shop]) + useEffect(() => { + if (!shop || !visitorId) return + let cancelled = false + + const run = async () => { + try { + setLoadingHistory(true) + const res = await fetch( + `/api/storefront-chat/messages?shop=${encodeURIComponent(shop)}&visitorId=${encodeURIComponent(visitorId)}` + ) + const data = await res.json().catch(() => ({})) + if (!res.ok) { + throw new Error(data?.message || "Failed to load history") + } + const nextMessages = Array.isArray(data?.messages) ? data.messages : [] + if (!cancelled) { + setMessages( + nextMessages.map((m: any) => ({ + id: String(m.id), + content: String(m.content || ""), + senderType: (m.senderType === "AI" ? "AI" : "CUSTOMER") as "AI" | "CUSTOMER", + createdAt: String(m.createdAt || new Date().toISOString()) + })) + ) + } + } catch (e: any) { + if (!cancelled) { + setSendError(e?.message || "Failed to load chat history") + } + } finally { + if (!cancelled) { + setLoadingHistory(false) + } + } + } + + void run() + return () => { + cancelled = true + } + }, [shop, visitorId]) + useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) }, [messages]) @@ -97,14 +141,28 @@ export default function StorefrontChatWidgetPage() { throw new Error(data?.message || "Failed to send message") } - setMessages(prev => [ - ...prev, - { - id: data?.message?.id || `local_${Date.now()}`, - content: text, - createdAt: new Date().toISOString() - } - ]) + 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() + } + ]) + } setComposer("") setTimeout(() => composerRef.current?.focus(), 0) } catch (e: any) { @@ -138,7 +196,11 @@ export default function StorefrontChatWidgetPage() {
) : null} - {messages.length === 0 ? ( + {loadingHistory ? ( +
+

Loading chat...

+
+ ) : messages.length === 0 ? (

Start a conversation

@@ -150,8 +212,17 @@ export default function StorefrontChatWidgetPage() { ) : (
{messages.map((m) => ( -
-
+
+
{m.content}
@@ -187,9 +258,7 @@ export default function StorefrontChatWidgetPage() {
-

- Customer messages are saved now. AI replies later. -

+

AI replies are generated from products, FAQs and KB.

) diff --git a/src/lib/ai/storefrontGroq.ts b/src/lib/ai/storefrontGroq.ts new file mode 100644 index 0000000..08f3632 --- /dev/null +++ b/src/lib/ai/storefrontGroq.ts @@ -0,0 +1,86 @@ +import { logger } from "@/lib/logger" +import { groqChatCompletion, type GroqChatMessage } from "@/lib/ai/groqClient" +import { getBlendedStorefrontContext } from "@/lib/storefront-chat/retrieval" + +type HistoryMessage = { + senderType: "CUSTOMER" | "AI" + content: string +} + +export async function generateStorefrontReply(params: { + storeId: string + query: string + visitorId: string + history: HistoryMessage[] +}) { + const startedAt = Date.now() + const { storeId, query, visitorId, history } = params + + const retrieval = await getBlendedStorefrontContext({ storeId, query }) + const contextLines = retrieval.items.map( + (item, idx) => `[${idx + 1}] (${item.source}) ${item.title}: ${item.content}` + ) + const contextBlock = contextLines.length + ? contextLines.join("\n") + : "No relevant KB/FAQ/product context found." + + const shortHistory = history.slice(-8).map((m) => ({ + role: m.senderType === "AI" ? "assistant" : "user", + content: m.content, + })) as GroqChatMessage[] + + const system = [ + "You are JUNO storefront assistant for an ecommerce store.", + "Use the provided context first (products, FAQs, knowledge).", + "If context is insufficient, ask one concise clarifying question.", + "Do not invent exact prices, stock or policy details that are not present.", + "Keep responses concise, friendly, and sales-supportive.", + "Return plain text only.", + ].join(" ") + + const user = [ + "Context:", + contextBlock, + "", + `Visitor ID: ${visitorId}`, + `Customer question: ${query}`, + ].join("\n") + + const messages: GroqChatMessage[] = [ + { role: "system", content: system }, + ...shortHistory, + { role: "user", content: user }, + ] + + const out = await groqChatCompletion(messages, { + temperature: 0.2, + maxTokens: 420, + timeoutMs: 28_000, + }) + + const fallback = + "Thanks for your message. I do not have enough details right now. Could you share the product name or what exactly you want to know?" + + const content = (out.content || "").trim() || fallback + + logger.info("[STOREFRONT_AI_REPLY]", { + storeId, + visitorId, + queryLength: query.length, + kbHits: retrieval.kbCount, + productHits: retrieval.productCount, + latencyMs: Date.now() - startedAt, + fallbackUsed: !out.content, + error: out.error || null, + }) + + return { + content, + meta: { + kbHits: retrieval.kbCount, + productHits: retrieval.productCount, + fallbackUsed: !out.content, + }, + } +} + diff --git a/src/lib/storefront-chat/retrieval.ts b/src/lib/storefront-chat/retrieval.ts new file mode 100644 index 0000000..a1f0f5a --- /dev/null +++ b/src/lib/storefront-chat/retrieval.ts @@ -0,0 +1,126 @@ +import { prisma } from "@/lib/prisma" + +type RetrievedContextItem = { + source: "KB" | "FAQ" | "PRODUCT" + title: string + content: string + score: number +} + +function tokenize(text: string) { + return text + .toLowerCase() + .split(/[^a-z0-9]+/g) + .map((t) => t.trim()) + .filter((t) => t.length >= 2) +} + +function scoreByTerms(text: string, terms: string[]) { + if (!text || !terms.length) return 0 + const hay = text.toLowerCase() + let score = 0 + for (const t of terms) { + if (hay.includes(t)) score += 1 + } + return score +} + +function compact(value: string, max = 420) { + if (value.length <= max) return value + return `${value.slice(0, max - 1)}...` +} + +function productPreview(product: any) { + const variants = Array.isArray(product?.variants) ? product.variants : [] + const firstVariant = variants[0] || {} + const price = firstVariant?.price ?? null + const sku = firstVariant?.sku ?? null + const inventory = firstVariant?.inventory_quantity ?? null + const status = product?.status || "unknown" + + const bits = [ + `Title: ${product?.title || "Unknown"}`, + product?.vendor ? `Vendor: ${product.vendor}` : "", + product?.productType ? `Type: ${product.productType}` : "", + `Status: ${status}`, + price !== null && price !== undefined ? `Price: ${String(price)}` : "", + sku ? `SKU: ${String(sku)}` : "", + inventory !== null && inventory !== undefined ? `Inventory: ${String(inventory)}` : "", + ].filter(Boolean) + + return bits.join(" | ") +} + +export async function getBlendedStorefrontContext(params: { + storeId: string + query: string + topKb?: number + topProducts?: number +}) { + const { storeId, query, topKb = 6, topProducts = 4 } = params + const terms = tokenize(query).slice(0, 10) + + const p = prisma as any + + const kbChunks = await p.knowledgeChunk.findMany({ + where: { storeId }, + orderBy: { createdAt: "desc" }, + take: 120, + select: { + id: true, + sourceType: true, + content: true, + metadata: true, + }, + }) + + const rankedKb: RetrievedContextItem[] = (kbChunks || []) + .map((c: any) => { + const score = scoreByTerms(String(c?.content || ""), terms) + return { + source: c?.sourceType === "FAQ" ? "FAQ" : "KB", + title: c?.sourceType === "FAQ" ? "FAQ entry" : "Knowledge document", + content: compact(String(c?.content || "")), + score, + } as RetrievedContextItem + }) + .filter((x: RetrievedContextItem) => x.score > 0 && x.content.length > 0) + .sort((a: RetrievedContextItem, b: RetrievedContextItem) => b.score - a.score) + .slice(0, topKb) + + const products = await p.cachedProduct.findMany({ + where: { storeId }, + orderBy: { updatedAt: "desc" }, + take: 200, + select: { + title: true, + vendor: true, + productType: true, + status: true, + variants: true, + images: true, + }, + }) + + const rankedProducts: RetrievedContextItem[] = (products || []) + .map((prod: any) => { + const text = `${prod?.title || ""} ${prod?.vendor || ""} ${prod?.productType || ""}` + const score = scoreByTerms(text, terms) + return { + source: "PRODUCT", + title: prod?.title || "Product", + content: compact(productPreview(prod)), + score, + } as RetrievedContextItem + }) + .filter((x: RetrievedContextItem) => x.score > 0) + .sort((a: RetrievedContextItem, b: RetrievedContextItem) => b.score - a.score) + .slice(0, topProducts) + + return { + items: [...rankedKb, ...rankedProducts], + kbCount: rankedKb.length, + productCount: rankedProducts.length, + } +} +