-
-
- No active store selected
-
-
- Select a store from the top bar to manage its knowledge
- base.
-
-
+
+
+ Select an active store from the top bar to manage its knowledge
+ base.
+
+
+ ) : kbLoading ? (
+
+
+ Loading…
) : (
<>
-
-
setActiveTab("documents")}
- className={cn(
- "px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
- activeTab === "documents"
- ? "border-indigo-500 text-indigo-600"
- : isLight
- ? "border-transparent text-slate-500 hover:text-slate-900 hover:border-slate-300"
- : "border-transparent text-slate-400 hover:text-white hover:border-slate-500"
- )}
- >
- Documents
-
-
setActiveTab("faqs")}
- className={cn(
- "px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
- activeTab === "faqs"
- ? "border-indigo-500 text-indigo-600"
- : isLight
- ? "border-transparent text-slate-500 hover:text-slate-900 hover:border-slate-300"
- : "border-transparent text-slate-400 hover:text-white hover:border-slate-500"
- )}
- >
- FAQs
-
-
setActiveTab("ask")}
- className={cn(
- "px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
- activeTab === "ask"
- ? "border-indigo-500 text-indigo-600"
- : isLight
- ? "border-transparent text-slate-500 hover:text-slate-900 hover:border-slate-300"
- : "border-transparent text-slate-400 hover:text-white hover:border-slate-500"
- )}
- >
- Ask
-
+
+
+
+
+ Knowledge source
+
+
+ {modeLabel}
+
+
+
+
+
+
setStructuredOpen(true)}
+ className={cn(
+ "flex items-center gap-3 rounded-lg border px-4 py-3 text-left transition-all",
+ knowledgeBaseMode === "STRUCTURED"
+ ? isLight
+ ? "border-indigo-400 bg-indigo-50/80 ring-1 ring-indigo-200"
+ : "border-indigo-500/50 bg-indigo-500/10"
+ : isLight
+ ? "border-slate-200 hover:border-slate-300 hover:bg-slate-50/80"
+ : "border-white/10 hover:border-white/20 hover:bg-white/[0.06]",
+ )}
+ >
+
+
+
+
+
+ Structured profile
+
+
+ Guided questions · opens in a panel
+
+
+
+
+
+
void openDocumentsKnowledgePanel()}
+ className={cn(
+ "flex items-center gap-3 rounded-lg border px-4 py-3 text-left transition-all disabled:opacity-60 disabled:cursor-not-allowed",
+ knowledgeBaseMode === "DOCUMENTS"
+ ? isLight
+ ? "border-emerald-400 bg-emerald-50/80 ring-1 ring-emerald-200"
+ : "border-emerald-500/50 bg-emerald-500/10"
+ : isLight
+ ? "border-slate-200 hover:border-slate-300 hover:bg-slate-50/80"
+ : "border-white/10 hover:border-white/20 hover:bg-white/[0.06]",
+ )}
+ >
+
+ {modeBusy === "documents" ? (
+
+ ) : (
+
+ )}
+
+
+
+ Documents & FAQs
+
+
+ PDFs, FAQs & Ask · opens in a panel
+
+
+
+
+
+
+ {toast && (
+
+ {toast}
+
+ )}
-
- {activeTab === "documents" && (
-
- )}
- {activeTab === "faqs" && (
-
- )}
- {activeTab === "ask" && (
-
- )}
-
+ {structuredActive && (
+
+ You're on a structured profile. Open{" "}
+ setStructuredOpen(true)}
+ className="font-semibold text-indigo-500 hover:underline"
+ >
+ Structured profile
+ {" "}
+ to edit, or choose{" "}
+ void openDocumentsKnowledgePanel()}
+ className="font-semibold text-emerald-600 hover:underline dark:text-emerald-400"
+ >
+ Documents & FAQs
+ {" "}
+ to switch (you'll confirm replacing structured data).
+
+ )}
+
+
{
+ await loadKb(activeStoreId!)
+ setToast("Structured profile saved and embedded.")
+ }}
+ />
+
+
>
)}
)
}
-
diff --git a/src/app/dashboard/settings/knowledge-base/page.tsx b/src/app/dashboard/settings/knowledge-base/page.tsx
new file mode 100644
index 0000000..85bb3eb
--- /dev/null
+++ b/src/app/dashboard/settings/knowledge-base/page.tsx
@@ -0,0 +1,6 @@
+import { redirect } from "next/navigation"
+
+/** Old location; knowledge base lives under JUNO Engine. */
+export default function KnowledgeBaseSettingsRedirectPage() {
+ redirect("/dashboard/juno-engine/knowledge-base")
+}
diff --git a/src/components/kb/AskSection.tsx b/src/components/kb/AskSection.tsx
index 291d764..8419a00 100644
--- a/src/components/kb/AskSection.tsx
+++ b/src/components/kb/AskSection.tsx
@@ -10,7 +10,7 @@ type AskSectionProps = {
type Source = {
id: string
- type: "DOCUMENT" | "FAQ"
+ type: "DOCUMENT" | "FAQ" | "STRUCTURED"
documentId: string | null
faqId: string | null
similarity: number
@@ -22,6 +22,7 @@ export function AskSection({ storeId }: AskSectionProps) {
const [query, setQuery] = useState("")
const [includeFaq, setIncludeFaq] = useState(true)
const [includeDocuments, setIncludeDocuments] = useState(true)
+ const [includeStructured, setIncludeStructured] = useState(true)
const [answer, setAnswer] = useState
(null)
const [sources, setSources] = useState([])
const [isLoading, setIsLoading] = useState(false)
@@ -48,6 +49,7 @@ export function AskSection({ storeId }: AskSectionProps) {
query: query.trim(),
includeFaq,
includeDocuments,
+ includeStructured,
}),
},
)
@@ -75,8 +77,8 @@ export function AskSection({ storeId }: AskSectionProps) {
Ask JUNO
- Ask questions across your store's documents and FAQs. The
- AI will answer using only your knowledge base.
+ Ask questions across documents, FAQs, and your structured store profile.
+ The AI uses only your knowledge base.
@@ -105,7 +107,7 @@ export function AskSection({ storeId }: AskSectionProps) {
)}
/>
-
+
Use documents
+
+
+ setIncludeStructured(e.target.checked)
+ }
+ />
+ Use structured profile
+
{source.type === "FAQ"
? "FAQ"
- : "Document"}
+ : source.type === "STRUCTURED"
+ ? "Profile"
+ : "Document"}
Source #{index + 1}
diff --git a/src/components/kb/DocumentsFaqKnowledgeModal.tsx b/src/components/kb/DocumentsFaqKnowledgeModal.tsx
new file mode 100644
index 0000000..005e5e3
--- /dev/null
+++ b/src/components/kb/DocumentsFaqKnowledgeModal.tsx
@@ -0,0 +1,171 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import { createPortal } from "react-dom"
+import { DocumentsSection } from "@/components/kb/DocumentsSection"
+import { FaqSection } from "@/components/kb/FaqSection"
+import { AskSection } from "@/components/kb/AskSection"
+import { cn } from "@/lib/utils"
+import { X } from "lucide-react"
+
+type TabId = "documents" | "faqs" | "ask"
+
+type DocumentsFaqKnowledgeModalProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ storeId: string
+ isLight: boolean
+}
+
+export function DocumentsFaqKnowledgeModal({
+ open,
+ onOpenChange,
+ storeId,
+ isLight,
+}: DocumentsFaqKnowledgeModalProps) {
+ const [mounted, setMounted] = useState(false)
+ const [tab, setTab] = useState("documents")
+
+ useEffect(() => {
+ setMounted(true)
+ }, [])
+
+ useEffect(() => {
+ if (!open) return
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onOpenChange(false)
+ }
+ window.addEventListener("keydown", onKey)
+ return () => window.removeEventListener("keydown", onKey)
+ }, [open, onOpenChange])
+
+ useEffect(() => {
+ if (open) {
+ document.body.style.overflow = "hidden"
+ } else {
+ document.body.style.overflow = ""
+ }
+ return () => {
+ document.body.style.overflow = ""
+ }
+ }, [open])
+
+ if (!mounted || !open) return null
+
+ const panelClass = isLight
+ ? "bg-white border-slate-200 text-slate-900 shadow-xl"
+ : "bg-slate-900/95 border-white/10 text-white shadow-2xl backdrop-blur-xl"
+
+ const tabs: { id: TabId; label: string }[] = [
+ { id: "documents", label: "Documents" },
+ { id: "faqs", label: "FAQs" },
+ { id: "ask", label: "Ask" },
+ ]
+
+ return createPortal(
+
+
onOpenChange(false)}
+ />
+ e.stopPropagation()}
+ >
+
+
+
+ Documents & FAQs
+
+
+ Upload files, manage FAQs, and test retrieval with Ask—all for
+ this store.
+
+
+
onOpenChange(false)}
+ className={cn(
+ "shrink-0 rounded-lg p-2 transition-colors",
+ isLight
+ ? "text-slate-500 hover:bg-slate-100"
+ : "text-slate-400 hover:bg-white/10",
+ )}
+ aria-label="Close"
+ >
+
+
+
+
+
+ {tabs.map((t) => (
+ setTab(t.id)}
+ className={cn(
+ "rounded-t-lg px-4 py-2.5 text-sm font-medium transition-colors",
+ tab === t.id
+ ? isLight
+ ? "bg-slate-100 text-indigo-600"
+ : "bg-white/10 text-indigo-300"
+ : isLight
+ ? "text-slate-500 hover:bg-slate-50 hover:text-slate-800"
+ : "text-slate-400 hover:bg-white/5 hover:text-white",
+ )}
+ >
+ {t.label}
+
+ ))}
+
+
+
+ {tab === "documents" && (
+
+ )}
+ {tab === "faqs" &&
}
+ {tab === "ask" &&
}
+
+
+ ,
+ document.body,
+ )
+}
diff --git a/src/components/kb/DocumentsSection.tsx b/src/components/kb/DocumentsSection.tsx
index 2e0b4e0..545c1f6 100644
--- a/src/components/kb/DocumentsSection.tsx
+++ b/src/components/kb/DocumentsSection.tsx
@@ -1,6 +1,7 @@
"use client"
import { useEffect, useState } from "react"
+import Link from "next/link"
import { useTheme } from "@/components/ThemeProvider"
import { cn } from "@/lib/utils"
@@ -17,9 +18,17 @@ type KnowledgeDocument = {
type DocumentsSectionProps = {
storeId: string
+ locked?: boolean
+ lockedMessage?: string
+ settingsHref?: string
}
-export function DocumentsSection({ storeId }: DocumentsSectionProps) {
+export function DocumentsSection({
+ storeId,
+ locked = false,
+ lockedMessage = "This store uses structured knowledge. Switch to Documents & FAQs above or edit your structured profile.",
+ settingsHref = "/dashboard/juno-engine/knowledge-base#kb-source",
+}: DocumentsSectionProps) {
const { theme } = useTheme()
const isLight = theme === "light"
const [documents, setDocuments] = useState([])
@@ -118,6 +127,27 @@ export function DocumentsSection({ storeId }: DocumentsSectionProps) {
return (
+ {locked && (
+
+
{lockedMessage}
+
+ Knowledge base
+
+
+ )}
@@ -204,7 +241,8 @@ export function DocumentsSection({ storeId }: DocumentsSectionProps) {
void handleDelete(doc.id)}
- className="p-1.5 rounded-lg border border-red-200 text-red-500 hover:bg-red-50 transition-colors"
+ disabled={locked}
+ className="p-1.5 rounded-lg border border-red-200 text-red-500 hover:bg-red-50 transition-colors disabled:opacity-40 disabled:pointer-events-none"
title="Delete document"
>
diff --git a/src/components/kb/FaqSection.tsx b/src/components/kb/FaqSection.tsx
index 68e1c97..865fedc 100644
--- a/src/components/kb/FaqSection.tsx
+++ b/src/components/kb/FaqSection.tsx
@@ -1,6 +1,7 @@
"use client"
import { useEffect, useState } from "react"
+import Link from "next/link"
import { useTheme } from "@/components/ThemeProvider"
import { cn } from "@/lib/utils"
@@ -14,9 +15,17 @@ type FaqItem = {
type FaqSectionProps = {
storeId: string
+ locked?: boolean
+ lockedMessage?: string
+ settingsHref?: string
}
-export function FaqSection({ storeId }: FaqSectionProps) {
+export function FaqSection({
+ storeId,
+ locked = false,
+ lockedMessage = "Manual FAQs are disabled while using structured knowledge. Switch mode using the cards above.",
+ settingsHref = "/dashboard/juno-engine/knowledge-base#kb-source",
+}: FaqSectionProps) {
const { theme } = useTheme()
const isLight = theme === "light"
const [faqs, setFaqs] = useState([])
@@ -167,6 +176,27 @@ export function FaqSection({ storeId }: FaqSectionProps) {
return (
+ {locked && (
+
+
{lockedMessage}
+
+ Knowledge base
+
+
+ )}
FAQs
@@ -175,12 +205,18 @@ export function FaqSection({ storeId }: FaqSectionProps) {
trusted answers.
-
+
{isImporting ? "Importing..." : "Import from JSON file"}
@@ -189,7 +225,7 @@ export function FaqSection({ storeId }: FaqSectionProps) {
accept="application/json"
className="hidden"
onChange={handleImport}
- disabled={isImporting}
+ disabled={isImporting || locked}
/>
@@ -204,6 +240,7 @@ export function FaqSection({ storeId }: FaqSectionProps) {
onSubmit={handleAddFaq}
className={cn(
"border rounded-xl p-4 space-y-4 transition-all",
+ locked && "opacity-50 pointer-events-none",
isLight ? "bg-white border-slate-100 shadow-sm" : "bg-slate-900/40 border-white/5 backdrop-blur-sm"
)}
>
diff --git a/src/components/kb/StructuredKnowledgeModal.tsx b/src/components/kb/StructuredKnowledgeModal.tsx
new file mode 100644
index 0000000..3f534d6
--- /dev/null
+++ b/src/components/kb/StructuredKnowledgeModal.tsx
@@ -0,0 +1,373 @@
+"use client"
+
+import { useCallback, useEffect, useState } from "react"
+import { createPortal } from "react-dom"
+import {
+ KNOWLEDGE_TEMPLATE_FIELDS,
+ type KnowledgeTemplateFieldKey,
+ type KnowledgeTemplateStored,
+} from "@/lib/kb/knowledgeTemplate"
+import { cn } from "@/lib/utils"
+import { Loader2, Save, X } from "lucide-react"
+
+type KbCounts = {
+ documents: number
+ faqs: number
+ structuredChunks: number
+}
+
+type StructuredKnowledgeModalProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ storeId: string
+ knowledgeBaseMode: "UNSET" | "STRUCTURED" | "DOCUMENTS"
+ onSaved: () => void | Promise
+ isLight: boolean
+}
+
+export function StructuredKnowledgeModal({
+ open,
+ onOpenChange,
+ storeId,
+ knowledgeBaseMode,
+ onSaved,
+ isLight,
+}: StructuredKnowledgeModalProps) {
+ const [mounted, setMounted] = useState(false)
+ const [loading, setLoading] = useState(false)
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState(null)
+
+ const [answers, setAnswers] = useState>(
+ () =>
+ KNOWLEDGE_TEMPLATE_FIELDS.reduce(
+ (acc, f) => {
+ acc[f.key] = ""
+ return acc
+ },
+ {} as Record,
+ ),
+ )
+ const [misc, setMisc] = useState("")
+
+ const loadForm = useCallback(async () => {
+ const res = await fetch(
+ `/api/stores/${encodeURIComponent(storeId)}/knowledge-template`,
+ )
+ if (!res.ok) return
+ const data = (await res.json()) as {
+ knowledgeTemplate: KnowledgeTemplateStored | null
+ }
+ const tmpl = data.knowledgeTemplate
+ setAnswers((prev) => {
+ const next = { ...prev }
+ for (const f of KNOWLEDGE_TEMPLATE_FIELDS) {
+ const v = tmpl?.answers?.[f.key]
+ next[f.key] = typeof v === "string" ? v : ""
+ }
+ return next
+ })
+ setMisc(typeof tmpl?.misc === "string" ? tmpl.misc : "")
+ }, [storeId])
+
+ useEffect(() => {
+ setMounted(true)
+ }, [])
+
+ useEffect(() => {
+ if (!open || !storeId) return
+ let cancelled = false
+ ; (async () => {
+ try {
+ setLoading(true)
+ setError(null)
+ await loadForm()
+ } finally {
+ if (!cancelled) setLoading(false)
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [open, storeId, loadForm])
+
+ useEffect(() => {
+ if (!open) return
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onOpenChange(false)
+ }
+ window.addEventListener("keydown", onKey)
+ return () => window.removeEventListener("keydown", onKey)
+ }, [open, onOpenChange])
+
+ useEffect(() => {
+ if (open) {
+ document.body.style.overflow = "hidden"
+ } else {
+ document.body.style.overflow = ""
+ }
+ return () => {
+ document.body.style.overflow = ""
+ }
+ }, [open])
+
+ async function handleSave(e: React.FormEvent) {
+ e.preventDefault()
+ setSaving(true)
+ setError(null)
+
+ const payload: Record = {
+ template: {
+ answers,
+ misc: misc.trim() || undefined,
+ },
+ }
+
+ if (knowledgeBaseMode === "DOCUMENTS") {
+ payload.knowledgeBaseMode = "STRUCTURED"
+ }
+
+ const trySave = async (confirmDestructiveSwitch?: boolean) => {
+ const body = {
+ ...payload,
+ ...(confirmDestructiveSwitch ? { confirmDestructiveSwitch: true } : {}),
+ }
+ const res = await fetch(
+ `/api/stores/${encodeURIComponent(storeId)}/knowledge-template`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ )
+ const j = await res.json().catch(() => ({}))
+ if (res.status === 400 && j.code === "CONFIRM_REQUIRED") {
+ const ok = window.confirm(
+ "Switching to structured knowledge will remove all uploaded documents and FAQs for this store. Continue?",
+ )
+ if (!ok) return false
+ return await trySave(true)
+ }
+ if (!res.ok) {
+ throw new Error(j.message || "Failed to save")
+ }
+ return true
+ }
+
+ try {
+ const ok = await trySave()
+ if (ok) {
+ await onSaved()
+ onOpenChange(false)
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to save")
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ if (!mounted || !open) return null
+
+ const panelClass = isLight
+ ? "bg-white border-slate-200 text-slate-900 shadow-xl"
+ : "bg-slate-900/95 border-white/10 text-white shadow-2xl backdrop-blur-xl"
+
+ return createPortal(
+
+
onOpenChange(false)}
+ />
+ e.stopPropagation()}
+ >
+
+
+
+ Structured store profile
+
+
+ Answers are embedded for AI retrieval. Skip any field you
+ don't need.
+
+
+
onOpenChange(false)}
+ className={cn(
+ "p-2 rounded-lg transition-colors shrink-0",
+ isLight
+ ? "hover:bg-slate-100 text-slate-500"
+ : "hover:bg-white/10 text-slate-400",
+ )}
+ aria-label="Close"
+ >
+
+
+
+
+ {loading ? (
+
+
+ Loading…
+
+ ) : (
+
+ )}
+
+ ,
+ document.body,
+ )
+}
diff --git a/src/lib/kb/bedrockChat.ts b/src/lib/kb/bedrockChat.ts
deleted file mode 100644
index 2fd266a..0000000
--- a/src/lib/kb/bedrockChat.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime"
-import { logger } from "@/lib/logger"
-
-const AWS_REGION = process.env.AWS_REGION || "us-east-1"
-const BEDROCK_CHAT_MODEL =
- process.env.BEDROCK_CHAT_MODEL || "anthropic.claude-3-haiku-20240307-v1:0"
-
-// Initialize Bedrock client
-const bedrockClient = new BedrockRuntimeClient({
- region: AWS_REGION,
- credentials: process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY
- ? {
- accessKeyId: process.env.AWS_ACCESS_KEY_ID,
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
- }
- : undefined, // Will use default credential chain (IAM role, etc.)
-})
-
-export interface ChatCompletionOptions {
- systemPrompt?: string
- maxTokens?: number
- temperature?: number
-}
-
-export async function chatCompletion(
- userMessage: string,
- options: ChatCompletionOptions = {},
-): Promise {
- const {
- systemPrompt = "",
- maxTokens = 512,
- temperature = 0.7,
- } = options
-
- try {
- // Claude Messages API format
- const requestBody = {
- anthropic_version: "bedrock-2023-05-31",
- max_tokens: maxTokens,
- temperature: temperature,
- system: systemPrompt,
- messages: [
- {
- role: "user" as const,
- content: userMessage,
- },
- ],
- }
-
- const command = new InvokeModelCommand({
- modelId: BEDROCK_CHAT_MODEL,
- contentType: "application/json",
- accept: "application/json",
- body: JSON.stringify(requestBody),
- })
-
- const response = await bedrockClient.send(command)
-
- if (!response.body) {
- logger.error("Empty response body from Bedrock chat API")
- return "I am unable to generate an answer at this time."
- }
-
- // Parse response body
- const responseBody = JSON.parse(
- new TextDecoder().decode(response.body),
- ) as {
- content?: Array<{
- type?: string
- text?: string
- }>
- stop_reason?: string
- }
-
- // Extract text from response
- const textContent = responseBody.content
- ?.filter((item) => item.type === "text")
- .map((item) => item.text || "")
- .join("")
- .trim()
-
- return textContent || "I am unable to generate an answer at this time."
- } catch (error) {
- logger.error("Bedrock chat completion failed", error)
- return "I am unable to generate an answer at this time."
- }
-}
diff --git a/src/lib/kb/bedrockEmbeddings.ts b/src/lib/kb/bedrockEmbeddings.ts
deleted file mode 100644
index e5abd0c..0000000
--- a/src/lib/kb/bedrockEmbeddings.ts
+++ /dev/null
@@ -1,168 +0,0 @@
-import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime"
-import { logger } from "@/lib/logger"
-
-const AWS_REGION = process.env.AWS_REGION || "us-east-1"
-// Hard-code Titan Text Embeddings v2 so all embeddings go through this model
-const BEDROCK_EMBEDDING_MODEL = "amazon.titan-embed-text-v2:0"
-
-/**
- * AWS Bedrock Rate Limits:
- * - Titan Text Embeddings V2: 2,000 requests/minute (~33 req/sec)
- * - We use 2-second delays between requests (30 req/min) to stay well under the limit
- * - Throttling errors trigger exponential backoff: 5s, 10s, 20s, 40s, 80s
- */
-
-// Initialize Bedrock client with retry configuration
-const bedrockClient = new BedrockRuntimeClient({
- region: AWS_REGION,
- credentials: process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY
- ? {
- accessKeyId: process.env.AWS_ACCESS_KEY_ID,
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
- }
- : undefined, // Will use default credential chain (IAM role, etc.)
- maxAttempts: 5, // AWS SDK default retry attempts
-})
-
-if (!process.env.AWS_ACCESS_KEY_ID && !process.env.AWS_SECRET_ACCESS_KEY) {
- logger.warn(
- "AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are not set. Bedrock will use default credential chain.",
- )
-}
-
-/**
- * Retry helper with exponential backoff for throttling errors
- */
-async function retryWithBackoff(
- fn: () => Promise,
- maxRetries: number = 5,
- baseDelay: number = 5000, // Start with 5 seconds for throttling
-): Promise {
- let lastError: unknown
-
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
- try {
- return await fn()
- } catch (error: any) {
- lastError = error
-
- // Check if it's a throttling error
- const isThrottling =
- error?.name === "ThrottlingException" ||
- error?.$metadata?.httpStatusCode === 429 ||
- error?.message?.includes("Too many requests")
-
- if (isThrottling && attempt < maxRetries) {
- // Exponential backoff with longer delays: 5s, 10s, 20s, 40s, 80s
- const delay = baseDelay * Math.pow(2, attempt)
- logger.warn(
- `Bedrock throttling detected, retrying in ${delay / 1000}s (attempt ${attempt + 1}/${maxRetries + 1})`,
- )
- await new Promise((resolve) => setTimeout(resolve, delay))
- continue
- }
-
- // If not throttling or max retries reached, throw
- throw error
- }
- }
-
- throw lastError
-}
-
-export async function embedBatch(texts: string[]): Promise {
- if (texts.length === 0) return []
-
- const results: number[][] = []
- // AWS Bedrock Titan V2 limit: 2000 requests/minute = ~33 req/sec
- // Use 2 seconds delay to stay well under limit (30 req/min = safe margin)
- const REQUEST_DELAY = 2000 // 2 seconds between requests
-
- for (let i = 0; i < texts.length; i++) {
- const text = texts[i]
- const trimmed = text.trim()
- if (!trimmed) {
- results.push([])
- continue
- }
-
- try {
- // Titan Text Embeddings V2 request format
- const requestBody = {
- inputText: trimmed,
- dimensions: 1024, // Use 1024 dimensions to match database schema
- }
-
- const command = new InvokeModelCommand({
- modelId: BEDROCK_EMBEDDING_MODEL,
- contentType: "application/json",
- accept: "application/json",
- body: JSON.stringify(requestBody),
- })
-
- // Use retry logic with exponential backoff
- const response = await retryWithBackoff(async () => {
- return await bedrockClient.send(command)
- })
-
- if (!response.body) {
- logger.error("Empty response body from Bedrock embedding API")
- results.push([])
- continue
- }
-
- // Parse response body
- const responseBody = JSON.parse(
- new TextDecoder().decode(response.body),
- ) as {
- embedding?: number[]
- inputTokenCount?: number
- }
-
- const vector = responseBody.embedding
- if (!vector || !Array.isArray(vector)) {
- logger.error("Invalid embedding response from Bedrock", responseBody)
- results.push([])
- continue
- }
-
- // Verify dimension matches expected (1024)
- if (vector.length !== 1024) {
- logger.warn(
- `Expected 1024 dimensions but got ${vector.length}. Truncating or padding as needed.`,
- )
- // Pad or truncate to 1024
- if (vector.length < 1024) {
- vector.push(...new Array(1024 - vector.length).fill(0))
- } else {
- vector.splice(1024)
- }
- }
-
- results.push(vector)
-
- // Add delay between requests to avoid rate limiting (except for last request)
- if (i < texts.length - 1) {
- await new Promise((resolve) => setTimeout(resolve, REQUEST_DELAY))
- }
- } catch (error: any) {
- const isThrottling =
- error?.name === "ThrottlingException" ||
- error?.$metadata?.httpStatusCode === 429 ||
- error?.message?.includes("Too many requests")
-
- if (isThrottling) {
- logger.error(
- `Bedrock throttling error after retries for text ${i + 1}/${texts.length}`,
- error,
- )
- } else {
- logger.error(`Bedrock embedding request failed for text ${i + 1}/${texts.length}`, error)
- }
- // On any error, push an empty vector and continue.
- results.push([])
- }
- }
-
- return results
-}
diff --git a/src/lib/kb/embeddingMeta.ts b/src/lib/kb/embeddingMeta.ts
new file mode 100644
index 0000000..cd83ceb
--- /dev/null
+++ b/src/lib/kb/embeddingMeta.ts
@@ -0,0 +1,27 @@
+/** OpenAI embedding model used for pgvector rows (must match query-time filters). */
+export const KB_EMBEDDING_MODEL =
+ process.env.OPENAI_EMBEDDING_MODEL?.trim() || "text-embedding-3-small"
+
+const parsedDims = Number(process.env.OPENAI_EMBEDDING_DIMENSIONS || "1024")
+export const KB_EMBEDDING_DIMENSIONS =
+ Number.isFinite(parsedDims) && parsedDims > 0 ? parsedDims : 1024
+
+export const KB_EMBEDDING_PROVIDER = "openai" as const
+
+export type KbChunkEmbeddingMetadata = {
+ provider: typeof KB_EMBEDDING_PROVIDER
+ embeddingModel: string
+ embeddingDims: number
+ sourceKey?: string
+}
+
+export function kbEmbeddingMetadata(
+ extra?: Partial>,
+): KbChunkEmbeddingMetadata {
+ return {
+ provider: KB_EMBEDDING_PROVIDER,
+ embeddingModel: KB_EMBEDDING_MODEL,
+ embeddingDims: KB_EMBEDDING_DIMENSIONS,
+ ...extra,
+ }
+}
diff --git a/src/lib/kb/indexing.ts b/src/lib/kb/indexing.ts
index b33073e..1f44642 100644
--- a/src/lib/kb/indexing.ts
+++ b/src/lib/kb/indexing.ts
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/prisma"
import { logger } from "@/lib/logger"
-import { embedBatch } from "@/lib/kb/bedrockEmbeddings"
+import { embedBatch } from "@/lib/kb/openaiEmbeddings"
+import { kbEmbeddingMetadata } from "@/lib/kb/embeddingMeta"
import type { TextChunk } from "@/lib/kb/documentProcessing"
export async function indexDocumentChunks(chunks: TextChunk[]) {
@@ -17,6 +18,10 @@ export async function indexDocumentChunks(chunks: TextChunk[]) {
const values = chunks.map((chunk, idx) => {
const embedding = embeddings[idx]
const vectorLiteral = embedding.length ? `'[${embedding.join(",")}]'` : "NULL"
+ const metadata = {
+ ...chunk.metadata,
+ ...kbEmbeddingMetadata(),
+ }
return prisma.$executeRawUnsafe(
`
@@ -37,7 +42,7 @@ export async function indexDocumentChunks(chunks: TextChunk[]) {
chunk.storeId,
chunk.documentId,
chunk.content,
- chunk.metadata as unknown as object,
+ metadata as unknown as object,
)
})
diff --git a/src/lib/kb/kbAnswer.ts b/src/lib/kb/kbAnswer.ts
new file mode 100644
index 0000000..1761b8a
--- /dev/null
+++ b/src/lib/kb/kbAnswer.ts
@@ -0,0 +1,49 @@
+import { openaiChatCompletion } from "@/lib/kb/openaiChat"
+import { groqChatCompletion } from "@/lib/ai/groqClient"
+import { logger } from "@/lib/logger"
+
+/**
+ * Generate an answer for KB RAG: OpenAI chat first, then Groq if OpenAI is unavailable or empty.
+ */
+export async function generateKbRagAnswer(
+ userMessage: string,
+ options: { systemPrompt: string; maxTokens?: number },
+): Promise<{ text: string; source: "openai" | "groq" | "none" }> {
+ const maxTokens = options.maxTokens ?? 512
+
+ const openai = await openaiChatCompletion(
+ [
+ { role: "system", content: options.systemPrompt },
+ { role: "user", content: userMessage },
+ ],
+ { maxTokens, temperature: 0.3 },
+ )
+
+ if (openai.content?.trim()) {
+ return { text: openai.content, source: "openai" }
+ }
+
+ logger.info("KB answer falling back to Groq", { openaiError: openai.error })
+
+ const groq = await groqChatCompletion(
+ [
+ { role: "system", content: options.systemPrompt },
+ { role: "user", content: userMessage },
+ ],
+ { maxTokens, temperature: 0.2 },
+ )
+
+ if (groq.content?.trim()) {
+ logger.info("KB answer generated via Groq fallback")
+ return { text: groq.content, source: "groq" }
+ }
+
+ logger.error("KB answer: both OpenAI and Groq failed", {
+ openaiError: openai.error,
+ groqError: groq.error,
+ })
+ return {
+ text: "I could not generate an answer right now. Please try again later.",
+ source: "none",
+ }
+}
diff --git a/src/lib/kb/knowledgeTemplate.ts b/src/lib/kb/knowledgeTemplate.ts
new file mode 100644
index 0000000..841a0d9
--- /dev/null
+++ b/src/lib/kb/knowledgeTemplate.ts
@@ -0,0 +1,104 @@
+import { z } from "zod"
+
+/** Six fixed prompts + optional misc (open-ended). */
+export const KNOWLEDGE_TEMPLATE_FIELD_KEYS = [
+ "brandVoice",
+ "shippingReturns",
+ "sizingFit",
+ "materialsCare",
+ "policies",
+ "differentiators",
+] as const
+
+export type KnowledgeTemplateFieldKey = (typeof KNOWLEDGE_TEMPLATE_FIELD_KEYS)[number]
+
+export const KNOWLEDGE_TEMPLATE_FIELDS: {
+ key: KnowledgeTemplateFieldKey
+ label: string
+ description: string
+ placeholder: string
+}[] = [
+ {
+ key: "brandVoice",
+ label: "Brand voice and personality",
+ description:
+ "How should your store sound? Tone, formality, phrases to use or avoid, and what makes your brand feel human.",
+ placeholder:
+ "e.g. Friendly and upbeat; we say “we” and avoid slang; we’re optimistic but never pushy…",
+ },
+ {
+ key: "shippingReturns",
+ label: "Shipping, returns, and exchanges",
+ description:
+ "Regions you ship to, typical timelines, costs, return windows, and how customers start a return.",
+ placeholder:
+ "e.g. US & Canada; 3–5 business days standard; free over $75; 30-day returns via portal…",
+ },
+ {
+ key: "sizingFit",
+ label: "Sizing and fit",
+ description:
+ "Fit notes, size guides, model references, and how customers should choose a size.",
+ placeholder:
+ "e.g. True to size; relaxed fit on hoodies; see size chart on each product page…",
+ },
+ {
+ key: "materialsCare",
+ label: "Materials, care, and quality",
+ description:
+ "Fabrics, ingredients, durability, care instructions, and sustainability claims you stand behind.",
+ placeholder:
+ "e.g. 100% organic cotton; machine wash cold; hang dry; OEKO-TEX where noted…",
+ },
+ {
+ key: "policies",
+ label: "Policies customers ask about",
+ description:
+ "Warranty, price adjustments, pre-orders, backorders, subscriptions, or limits worth stating clearly.",
+ placeholder:
+ "e.g. 1-year defect warranty; no price matching; pre-orders ship when noted on the product…",
+ },
+ {
+ key: "differentiators",
+ label: "What makes your store special",
+ description:
+ "Differentiators, origin story, craftsmanship, community, or anything that sets you apart from competitors.",
+ placeholder:
+ "e.g. Small-batch, made in Portland; we donate 1% to ocean cleanup; limited seasonal drops…",
+ },
+]
+
+const answerMax = 12_000
+const miscMax = 16_000
+
+export const knowledgeTemplatePutSchema = z.object({
+ knowledgeBaseMode: z.enum(["STRUCTURED", "DOCUMENTS"]).optional(),
+ confirmDestructiveSwitch: z.boolean().optional(),
+ template: z
+ .object({
+ answers: z.record(z.string().max(answerMax)).optional(),
+ misc: z.string().max(miscMax).optional(),
+ })
+ .optional(),
+})
+
+export type KnowledgeTemplateStored = {
+ version: number
+ answers: Partial>
+ misc?: string
+ updatedAt: string
+}
+
+export function normalizeTemplateAnswers(
+ raw: Record | undefined,
+): Partial> {
+ if (!raw) return {}
+ const out: Partial> = {}
+ for (const key of KNOWLEDGE_TEMPLATE_FIELD_KEYS) {
+ const v = raw[key]
+ if (typeof v === "string" && v.trim()) {
+ out[key] = v.trim()
+ }
+ }
+ return out
+}
diff --git a/src/lib/kb/openaiChat.ts b/src/lib/kb/openaiChat.ts
new file mode 100644
index 0000000..089f252
--- /dev/null
+++ b/src/lib/kb/openaiChat.ts
@@ -0,0 +1,53 @@
+import OpenAI from "openai"
+import { logger } from "@/lib/logger"
+
+const DEFAULT_MODEL = "gpt-4o-mini"
+
+export type OpenAiChatMessage = {
+ role: "system" | "user" | "assistant"
+ content: string
+}
+
+export type OpenAiChatOptions = {
+ model?: string
+ maxTokens?: number
+ temperature?: number
+}
+
+/**
+ * Chat completions via OpenAI (used for KB RAG answers; embeddings stay in openaiEmbeddings.ts).
+ */
+export async function openaiChatCompletion(
+ messages: OpenAiChatMessage[],
+ options: OpenAiChatOptions = {},
+): Promise<{ content: string | null; error?: string }> {
+ const apiKey = process.env.OPENAI_API_KEY?.trim()
+ if (!apiKey) {
+ logger.warn("OPENAI_API_KEY is not set; skipping OpenAI chat")
+ return { content: null, error: "missing_api_key" }
+ }
+
+ const model =
+ options.model?.trim() ||
+ process.env.OPENAI_CHAT_MODEL?.trim() ||
+ DEFAULT_MODEL
+
+ try {
+ const client = new OpenAI({ apiKey })
+ const res = await client.chat.completions.create({
+ model,
+ messages,
+ max_tokens: options.maxTokens ?? 512,
+ temperature: options.temperature ?? 0.3,
+ })
+ const text = res.choices[0]?.message?.content?.trim() ?? null
+ if (!text) {
+ return { content: null, error: "empty_response" }
+ }
+ return { content: text }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ logger.warn("OpenAI chat completion failed", { message })
+ return { content: null, error: message }
+ }
+}
diff --git a/src/lib/kb/openaiEmbeddings.ts b/src/lib/kb/openaiEmbeddings.ts
new file mode 100644
index 0000000..99009ff
--- /dev/null
+++ b/src/lib/kb/openaiEmbeddings.ts
@@ -0,0 +1,118 @@
+import OpenAI from "openai"
+import { logger } from "@/lib/logger"
+import {
+ KB_EMBEDDING_DIMENSIONS,
+ KB_EMBEDDING_MODEL,
+} from "@/lib/kb/embeddingMeta"
+
+const MAX_INPUTS_PER_REQUEST = 64
+const MAX_RETRIES = 5
+const BASE_DELAY_MS = 800
+
+function sleep(ms: number) {
+ return new Promise((r) => setTimeout(r, ms))
+}
+
+function isRetryableStatus(status: number | undefined) {
+ return status === 429 || (status !== undefined && status >= 500)
+}
+
+/**
+ * Batch embeddings via OpenAI (text-embedding-3-small + dimensions for pgvector / cost tradeoff).
+ * Returns one vector per input; empty strings yield empty arrays (callers skip DB insert).
+ */
+export async function embedBatch(texts: string[]): Promise {
+ if (texts.length === 0) return []
+
+ const apiKey = process.env.OPENAI_API_KEY?.trim()
+ if (!apiKey) {
+ logger.error("OPENAI_API_KEY is not set; cannot generate embeddings")
+ return texts.map(() => [])
+ }
+
+ const client = new OpenAI({ apiKey })
+ const out: number[][] = new Array(texts.length).fill(null).map(() => [])
+
+ for (let start = 0; start < texts.length; start += MAX_INPUTS_PER_REQUEST) {
+ const end = Math.min(start + MAX_INPUTS_PER_REQUEST, texts.length)
+ const slice = texts.slice(start, end)
+ const normalized = slice.map((t) => (t.trim().length ? t.trim() : ""))
+
+ const indicesWithText: number[] = []
+ const inputs: string[] = []
+ normalized.forEach((t, i) => {
+ if (t.length) {
+ indicesWithText.push(start + i)
+ inputs.push(t)
+ }
+ })
+
+ if (inputs.length === 0) continue
+
+ let attempt = 0
+ let embeddings: number[][] | null = null
+
+ while (attempt <= MAX_RETRIES) {
+ try {
+ const started = Date.now()
+ const res = await client.embeddings.create({
+ model: KB_EMBEDDING_MODEL,
+ input: inputs,
+ dimensions: KB_EMBEDDING_DIMENSIONS,
+ })
+ logger.info("OpenAI embeddings batch", {
+ count: inputs.length,
+ ms: Date.now() - started,
+ model: KB_EMBEDDING_MODEL,
+ })
+
+ const byIndex = new Map()
+ for (const item of res.data) {
+ const vec = item.embedding
+ if (!Array.isArray(vec) || vec.length !== KB_EMBEDDING_DIMENSIONS) {
+ logger.error("Unexpected embedding dimensions from OpenAI", {
+ expected: KB_EMBEDDING_DIMENSIONS,
+ got: vec?.length,
+ })
+ throw new Error("Invalid embedding dimensions")
+ }
+ byIndex.set(item.index, vec)
+ }
+
+ embeddings = []
+ for (let i = 0; i < inputs.length; i++) {
+ const vec = byIndex.get(i)
+ embeddings.push(vec ?? [])
+ }
+ break
+ } catch (err: unknown) {
+ const status =
+ err && typeof err === "object" && "status" in err
+ ? Number((err as { status?: number }).status)
+ : undefined
+ if (!isRetryableStatus(status) || attempt >= MAX_RETRIES) {
+ logger.error("OpenAI embedding request failed", {
+ attempt,
+ status,
+ message: err instanceof Error ? err.message : String(err),
+ })
+ embeddings = inputs.map(() => [])
+ break
+ }
+ const delay = BASE_DELAY_MS * Math.pow(2, attempt)
+ logger.warn(`OpenAI embeddings retry in ${delay}ms`, { attempt, status })
+ await sleep(delay)
+ attempt++
+ }
+ }
+
+ if (!embeddings) continue
+
+ embeddings.forEach((vec, j) => {
+ const globalIdx = indicesWithText[j]!
+ out[globalIdx] = vec
+ })
+ }
+
+ return out
+}
diff --git a/src/lib/kb/storeKbCleanup.ts b/src/lib/kb/storeKbCleanup.ts
new file mode 100644
index 0000000..eb74f2c
--- /dev/null
+++ b/src/lib/kb/storeKbCleanup.ts
@@ -0,0 +1,50 @@
+import fs from "fs/promises"
+import path from "path"
+import type { PrismaClient } from "@prisma/client"
+import { logger } from "@/lib/logger"
+
+export async function deleteDocumentKbForStore(
+ prisma: PrismaClient,
+ storeId: string,
+) {
+ const docs = await prisma.$queryRawUnsafe<{ id: string; storagePath: string }[]>(
+ `SELECT "id", "storagePath" FROM "KnowledgeDocument" WHERE "storeId" = $1`,
+ storeId,
+ )
+ for (const d of docs) {
+ try {
+ await fs.unlink(path.resolve(d.storagePath))
+ } catch (e) {
+ logger.warn("Failed to delete KB file on disk", {
+ path: d.storagePath,
+ message: e instanceof Error ? e.message : String(e),
+ })
+ }
+ }
+ await prisma.$executeRawUnsafe(
+ `DELETE FROM "KnowledgeChunk" WHERE "storeId" = $1 AND "sourceType"::text = 'DOCUMENT'`,
+ storeId,
+ )
+ await prisma.$executeRawUnsafe(
+ `DELETE FROM "KnowledgeDocument" WHERE "storeId" = $1`,
+ storeId,
+ )
+}
+
+export async function deleteFaqKbForStore(prisma: PrismaClient, storeId: string) {
+ await prisma.$executeRawUnsafe(
+ `DELETE FROM "KnowledgeChunk" WHERE "storeId" = $1 AND "sourceType"::text = 'FAQ'`,
+ storeId,
+ )
+ await prisma.faqItem.deleteMany({ where: { storeId } })
+}
+
+export async function deleteStructuredKbForStore(
+ prisma: PrismaClient,
+ storeId: string,
+) {
+ await prisma.$executeRawUnsafe(
+ `DELETE FROM "KnowledgeChunk" WHERE "storeId" = $1 AND "sourceType"::text = 'STRUCTURED'`,
+ storeId,
+ )
+}
diff --git a/src/lib/kb/structuredTemplateChunks.ts b/src/lib/kb/structuredTemplateChunks.ts
new file mode 100644
index 0000000..f8e88b6
--- /dev/null
+++ b/src/lib/kb/structuredTemplateChunks.ts
@@ -0,0 +1,77 @@
+import { prisma } from "@/lib/prisma"
+import { logger } from "@/lib/logger"
+import { embedBatch } from "@/lib/kb/openaiEmbeddings"
+import { kbEmbeddingMetadata } from "@/lib/kb/embeddingMeta"
+import {
+ KNOWLEDGE_TEMPLATE_FIELDS,
+ type KnowledgeTemplateStored,
+} from "@/lib/kb/knowledgeTemplate"
+
+export async function replaceStructuredKnowledgeChunks(
+ storeId: string,
+ stored: KnowledgeTemplateStored,
+) {
+ await prisma.$executeRawUnsafe(
+ `DELETE FROM "KnowledgeChunk" WHERE "storeId" = $1 AND "sourceType"::text = 'STRUCTURED'`,
+ storeId,
+ )
+
+ const rows: { content: string; metadata: ReturnType }[] =
+ []
+
+ for (const field of KNOWLEDGE_TEMPLATE_FIELDS) {
+ const text = stored.answers[field.key]
+ if (text?.trim()) {
+ rows.push({
+ content: `Q: ${field.label}\nA: ${text.trim()}`,
+ metadata: kbEmbeddingMetadata({ sourceKey: field.key }),
+ })
+ }
+ }
+
+ if (stored.misc?.trim()) {
+ rows.push({
+ content: `Q: Anything else customers should know\nA: ${stored.misc.trim()}`,
+ metadata: kbEmbeddingMetadata({ sourceKey: "misc" }),
+ })
+ }
+
+ if (!rows.length) {
+ return
+ }
+
+ const embeddings = await embedBatch(rows.map((r) => r.content))
+ if (embeddings.length !== rows.length) {
+ throw new Error("Embedding count mismatch for structured template")
+ }
+
+ for (let i = 0; i < rows.length; i++) {
+ const embedding = embeddings[i]!
+ if (!embedding.length) {
+ logger.error("Empty embedding for structured template row", { storeId, i })
+ throw new Error("Failed to embed structured knowledge template")
+ }
+ const vectorLiteral = `'[${embedding.join(",")}]'`
+ await prisma.$executeRawUnsafe(
+ `
+ INSERT INTO "KnowledgeChunk"
+ ("id", "storeId", "documentId", "faqItemId", "sourceType", "content", "metadata", "embedding", "createdAt", "updatedAt")
+ VALUES (
+ gen_random_uuid()::text,
+ $1,
+ NULL,
+ NULL,
+ 'STRUCTURED',
+ $2,
+ $3,
+ ${vectorLiteral},
+ NOW(),
+ NOW()
+ )
+ `,
+ storeId,
+ rows[i]!.content,
+ rows[i]!.metadata as unknown as object,
+ )
+ }
+}