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:
parent
ce5049a64f
commit
0b384fcc59
@ -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<StorefrontMessagePayload>
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div className="flex flex-col gap-4 p-1">
|
||||
@ -65,6 +67,9 @@ export default async function StorefrontChatInboxPage() {
|
||||
<div className="shrink-0 text-right">
|
||||
<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-[11px] text-slate-500 mt-1">
|
||||
{customerCount} customer · {aiCount} AI
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -84,11 +89,20 @@ export default async function StorefrontChatInboxPage() {
|
||||
<div key={m.id} className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-black uppercase tracking-widest text-slate-400 truncate">
|
||||
{m.senderType === "CUSTOMER" ? "CUSTOMER" : m.senderType}
|
||||
{" · "}
|
||||
{m.conversation?.visitorId || "visitor"}
|
||||
</p>
|
||||
<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">
|
||||
{m.conversation?.visitorId || "visitor"}
|
||||
</p>
|
||||
</div>
|
||||
{m.conversation?.customerEmail ? (
|
||||
<p className="text-[11px] text-slate-500 truncate">{m.conversation.customerEmail}</p>
|
||||
) : null}
|
||||
|
||||
@ -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<string | null>(null)
|
||||
const [loadingHistory, setLoadingHistory] = useState(false)
|
||||
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(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() {
|
||||
</div>
|
||||
) : 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>
|
||||
<p className="font-black text-slate-900">Start a conversation</p>
|
||||
@ -150,8 +212,17 @@ export default function StorefrontChatWidgetPage() {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{messages.map((m) => (
|
||||
<div key={m.id} className="flex justify-end">
|
||||
<div className="max-w-[85%] px-3 py-2 rounded-2xl bg-indigo-600 text-white text-sm font-medium">
|
||||
<div
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
@ -187,9 +258,7 @@ export default function StorefrontChatWidgetPage() {
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-slate-500">
|
||||
Customer messages are saved now. AI replies later.
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] text-slate-500">AI replies are generated from products, FAQs and KB.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
86
src/lib/ai/storefrontGroq.ts
Normal file
86
src/lib/ai/storefrontGroq.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
126
src/lib/storefront-chat/retrieval.ts
Normal file
126
src/lib/storefront-chat/retrieval.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user