feat(storefront-chat): add synchronous GROQ AI replies

Add blended KB/FAQ + product context retrieval for storefront chat, generate synchronous AI responses, persist AI messages, and update widget/debug inbox to display threaded CUSTOMER/AI conversations.

Made-with: Cursor
This commit is contained in:
talatops 2026-04-02 17:19:12 +05:00
parent ce5049a64f
commit 0b384fcc59
5 changed files with 399 additions and 29 deletions

View File

@ -1,5 +1,6 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { generateStorefrontReply } from "@/lib/ai/storefrontGroq"
type StorefrontMessagePayload = { type StorefrontMessagePayload = {
shop: string shop: string
@ -10,6 +11,7 @@ type StorefrontMessagePayload = {
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const p = prisma as any
const payload = (await req.json().catch(() => ({}))) as Partial<StorefrontMessagePayload> const payload = (await req.json().catch(() => ({}))) as Partial<StorefrontMessagePayload>
const { shop, visitorId, content } = payload const { shop, visitorId, content } = payload
@ -38,7 +40,7 @@ export async function POST(req: Request) {
return NextResponse.json({ message: "Invalid visitorId" }, { status: 400 }) return NextResponse.json({ message: "Invalid visitorId" }, { status: 400 })
} }
const store = await prisma.store.findFirst({ const store = await p.store.findFirst({
where: { shopifyDomain: trimmedShop }, where: { shopifyDomain: trimmedShop },
select: { id: true, storefrontChatEnabled: true } select: { id: true, storefrontChatEnabled: true }
}) })
@ -51,7 +53,7 @@ export async function POST(req: Request) {
} }
// Find or create conversation for this anonymous visitor. // 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 }, where: { storeId: store.id, visitorId: trimmedVisitorId },
select: { id: true } select: { id: true }
}) })
@ -59,7 +61,7 @@ export async function POST(req: Request) {
if (conversation) { if (conversation) {
// Very lightweight rate limit: max 5 messages per 30 seconds per conversation. // Very lightweight rate limit: max 5 messages per 30 seconds per conversation.
const windowMs = 30_000 const windowMs = 30_000
const recentCount = await prisma.storefrontChatMessage.count({ const recentCount = await p.storefrontChatMessage.count({
where: { where: {
conversationId: conversation.id, conversationId: conversation.id,
createdAt: { gte: new Date(Date.now() - windowMs) } 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 }) return NextResponse.json({ message: "Too many messages, slow down" }, { status: 429 })
} }
} else { } else {
conversation = await prisma.storefrontChatConversation.create({ conversation = await p.storefrontChatConversation.create({
data: { data: {
storeId: store.id, storeId: store.id,
visitorId: trimmedVisitorId, 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: { data: {
conversationId: conversation.id, conversationId: conversation.id,
senderType: "CUSTOMER", senderType: "CUSTOMER",
content: trimmedContent 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({ return NextResponse.json({
success: true, success: true,
message: { messages: [customerMessage, aiMessage],
id: message.id, meta: ai.meta
createdAt: message.createdAt
}
}) })
} catch (error) { } catch (error) {
console.error("[STORE_FRONT_CHAT_MESSAGES_POST]", 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 })
}
}

View File

@ -52,6 +52,8 @@ export default async function StorefrontChatInboxPage() {
}) })
const sorted = [...messages].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) 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 ( return (
<div className="flex flex-col gap-4 p-1"> <div className="flex flex-col gap-4 p-1">
@ -65,6 +67,9 @@ export default async function StorefrontChatInboxPage() {
<div className="shrink-0 text-right"> <div className="shrink-0 text-right">
<p className="text-sm font-black text-white">{conversations.length} visitors</p> <p className="text-sm font-black text-white">{conversations.length} visitors</p>
<p className="text-xs text-slate-500 mt-1">{sorted.length} messages</p> <p className="text-xs text-slate-500 mt-1">{sorted.length} messages</p>
<p className="text-[11px] text-slate-500 mt-1">
{customerCount} customer · {aiCount} AI
</p>
</div> </div>
</div> </div>
@ -84,11 +89,20 @@ export default async function StorefrontChatInboxPage() {
<div key={m.id} className="flex flex-col gap-2"> <div key={m.id} className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span
className={`text-[10px] font-black uppercase tracking-widest rounded-full px-2 py-1 border ${
m.senderType === "AI"
? "text-indigo-300 border-indigo-400/30 bg-indigo-500/10"
: "text-emerald-300 border-emerald-400/30 bg-emerald-500/10"
}`}
>
{m.senderType === "CUSTOMER" ? "CUSTOMER" : "AI"}
</span>
<p className="text-xs font-black uppercase tracking-widest text-slate-400 truncate"> <p className="text-xs font-black uppercase tracking-widest text-slate-400 truncate">
{m.senderType === "CUSTOMER" ? "CUSTOMER" : m.senderType}
{" · "}
{m.conversation?.visitorId || "visitor"} {m.conversation?.visitorId || "visitor"}
</p> </p>
</div>
{m.conversation?.customerEmail ? ( {m.conversation?.customerEmail ? (
<p className="text-[11px] text-slate-500 truncate">{m.conversation.customerEmail}</p> <p className="text-[11px] text-slate-500 truncate">{m.conversation.customerEmail}</p>
) : null} ) : null}

View File

@ -7,6 +7,7 @@ type WidgetMessage = {
id: string id: string
content: string content: string
createdAt: string createdAt: string
senderType: "CUSTOMER" | "AI"
} }
function getOrCreateVisitorId(shop: string) { function getOrCreateVisitorId(shop: string) {
@ -31,6 +32,7 @@ export default function StorefrontChatWidgetPage() {
const [composer, setComposer] = useState("") const [composer, setComposer] = useState("")
const [sending, setSending] = useState(false) const [sending, setSending] = useState(false)
const [sendError, setSendError] = useState<string | null>(null) const [sendError, setSendError] = useState<string | null>(null)
const [loadingHistory, setLoadingHistory] = useState(false)
const composerRef = useRef<HTMLTextAreaElement | null>(null) const composerRef = useRef<HTMLTextAreaElement | null>(null)
const messagesEndRef = useRef<HTMLDivElement | null>(null) const messagesEndRef = useRef<HTMLDivElement | null>(null)
@ -74,6 +76,48 @@ export default function StorefrontChatWidgetPage() {
setVisitorId(getOrCreateVisitorId(shop)) setVisitorId(getOrCreateVisitorId(shop))
}, [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(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages]) }, [messages])
@ -97,14 +141,28 @@ export default function StorefrontChatWidgetPage() {
throw new Error(data?.message || "Failed to send message") throw new Error(data?.message || "Failed to send message")
} }
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 => [ setMessages(prev => [
...prev, ...prev,
{ {
id: data?.message?.id || `local_${Date.now()}`, id: `local_${Date.now()}`,
content: text, content: text,
senderType: "CUSTOMER",
createdAt: new Date().toISOString() createdAt: new Date().toISOString()
} }
]) ])
}
setComposer("") setComposer("")
setTimeout(() => composerRef.current?.focus(), 0) setTimeout(() => composerRef.current?.focus(), 0)
} catch (e: any) { } catch (e: any) {
@ -138,7 +196,11 @@ export default function StorefrontChatWidgetPage() {
</div> </div>
) : null} ) : null}
{messages.length === 0 ? ( {loadingHistory ? (
<div className="h-full flex items-center justify-center text-center p-4">
<p className="text-sm font-medium text-slate-500">Loading chat...</p>
</div>
) : messages.length === 0 ? (
<div className="h-full flex items-center justify-center text-center p-4"> <div className="h-full flex items-center justify-center text-center p-4">
<div> <div>
<p className="font-black text-slate-900">Start a conversation</p> <p className="font-black text-slate-900">Start a conversation</p>
@ -150,8 +212,17 @@ export default function StorefrontChatWidgetPage() {
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{messages.map((m) => ( {messages.map((m) => (
<div key={m.id} className="flex justify-end"> <div
<div className="max-w-[85%] px-3 py-2 rounded-2xl bg-indigo-600 text-white text-sm font-medium"> key={m.id}
className={`flex ${m.senderType === "AI" ? "justify-start" : "justify-end"}`}
>
<div
className={`max-w-[88%] px-3 py-2 rounded-2xl text-sm font-medium whitespace-pre-wrap ${
m.senderType === "AI"
? "bg-white border border-slate-200 text-slate-800"
: "bg-indigo-600 text-white"
}`}
>
{m.content} {m.content}
</div> </div>
</div> </div>
@ -187,9 +258,7 @@ export default function StorefrontChatWidgetPage() {
<Send className="w-4 h-4" /> <Send className="w-4 h-4" />
</button> </button>
</div> </div>
<p className="mt-2 text-[11px] text-slate-500"> <p className="mt-2 text-[11px] text-slate-500">AI replies are generated from products, FAQs and KB.</p>
Customer messages are saved now. AI replies later.
</p>
</div> </div>
</div> </div>
) )

View File

@ -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,
},
}
}

View File

@ -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,
}
}