new attributes for AI
This commit is contained in:
parent
710ab671b3
commit
bd883a9c96
@ -0,0 +1 @@
|
||||
ALTER TABLE "Store" ADD COLUMN "customAiModules" JSONB;
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE "CachedOrder" ADD COLUMN "noteAttributes" JSONB;
|
||||
@ -144,6 +144,10 @@ model Store {
|
||||
* Vendor Type-1 answers keyed by shared_field_id (sf_*); one value per key store-wide (§4.2).
|
||||
*/
|
||||
sharedFieldAnswers Json?
|
||||
/**
|
||||
* Vendor-created knowledge modules. Each enabled module is embedded into STRUCTURED chunks.
|
||||
*/
|
||||
customAiModules Json?
|
||||
|
||||
/**
|
||||
* Optional Gmail auto-reply HTML theming (colors, font, logo URL). Merged with defaults server-side.
|
||||
@ -304,6 +308,7 @@ model CachedOrder {
|
||||
lastShopifySyncAt DateTime?
|
||||
lastShopifySyncError String?
|
||||
lineItems Json // Store line items as JSON
|
||||
noteAttributes Json?
|
||||
customer Json? // Store customer info as JSON
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@ -52,6 +52,7 @@ type CachedOrderRow = {
|
||||
totalPrice: string
|
||||
currency: string | null
|
||||
lineItems: unknown
|
||||
noteAttributes?: unknown
|
||||
shopifyCreatedAt: Date | null
|
||||
createdAt: Date
|
||||
}
|
||||
@ -416,6 +417,7 @@ export async function GET(req: Request) {
|
||||
totalPrice: true,
|
||||
currency: true,
|
||||
lineItems: true,
|
||||
noteAttributes: true,
|
||||
shopifyCreatedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
|
||||
@ -221,12 +221,14 @@ function imageUrlFromImages(images: unknown): string | null {
|
||||
|
||||
async function buildCartResponse(params: {
|
||||
storeId: string
|
||||
conversationId: string
|
||||
shopifyDomain: string
|
||||
productId: string
|
||||
requestedVariantId?: string | null
|
||||
quantity?: number
|
||||
message: string
|
||||
existingCartLines: StorefrontCartLine[]
|
||||
attributionSource: "primary" | "upsell" | "direct"
|
||||
}): Promise<CartResponse> {
|
||||
const product = await prisma.cachedProduct.findFirst({
|
||||
where: { storeId: params.storeId, shopifyProductId: params.productId },
|
||||
@ -306,7 +308,12 @@ async function buildCartResponse(params: {
|
||||
productId: product.shopifyProductId,
|
||||
title: product.title,
|
||||
})
|
||||
const cartUrl = buildCartPermalink(params.shopifyDomain, nextCartLines)
|
||||
const cartUrl = buildCartPermalink(params.shopifyDomain, nextCartLines, {
|
||||
juno_ai_conversation_id: params.conversationId,
|
||||
juno_ai_product_id: product.shopifyProductId,
|
||||
juno_ai_variant_id: selected.variant.id,
|
||||
juno_ai_source: params.attributionSource,
|
||||
})
|
||||
if (!cartUrl) {
|
||||
return {
|
||||
content: "I could not create a cart link for that product right now.",
|
||||
@ -332,6 +339,7 @@ async function buildCartResponse(params: {
|
||||
|
||||
async function resolveDirectAddToCart(params: {
|
||||
storeId: string
|
||||
conversationId: string
|
||||
shopifyDomain: string
|
||||
message: string
|
||||
existingCartLines: StorefrontCartLine[]
|
||||
@ -343,12 +351,14 @@ async function resolveDirectAddToCart(params: {
|
||||
const product = productSearchHitToActionProduct(hit)
|
||||
return buildCartResponse({
|
||||
storeId: params.storeId,
|
||||
conversationId: params.conversationId,
|
||||
shopifyDomain: params.shopifyDomain,
|
||||
productId: hit.shopifyProductId,
|
||||
requestedVariantId: product?.variantId,
|
||||
quantity: product?.quantity ?? 1,
|
||||
message: params.message,
|
||||
existingCartLines: params.existingCartLines,
|
||||
attributionSource: "direct",
|
||||
})
|
||||
}
|
||||
|
||||
@ -467,25 +477,30 @@ export async function POST(req: Request) {
|
||||
const cartResponse = action
|
||||
? await buildCartResponse({
|
||||
storeId: store.id,
|
||||
conversationId: conversation.id,
|
||||
shopifyDomain: store.shopifyDomain,
|
||||
productId: action.productId,
|
||||
requestedVariantId: action.variantId,
|
||||
quantity: action.quantity,
|
||||
message: customerContent,
|
||||
existingCartLines,
|
||||
attributionSource: action.type === "upsell_suggestion" ? "upsell" : "primary",
|
||||
})
|
||||
: pendingCartProduct && isCartConfirmationMessage(customerContent)
|
||||
? await buildCartResponse({
|
||||
storeId: store.id,
|
||||
conversationId: conversation.id,
|
||||
shopifyDomain: store.shopifyDomain,
|
||||
productId: pendingCartProduct.productId,
|
||||
requestedVariantId: pendingCartProduct.variantId,
|
||||
quantity: pendingCartProduct.quantity,
|
||||
message: customerContent,
|
||||
existingCartLines,
|
||||
attributionSource: "primary",
|
||||
})
|
||||
: await resolveDirectAddToCart({
|
||||
storeId: store.id,
|
||||
conversationId: conversation.id,
|
||||
shopifyDomain: store.shopifyDomain,
|
||||
message: customerContent,
|
||||
existingCartLines,
|
||||
|
||||
@ -13,6 +13,11 @@ import {
|
||||
sharedFieldAnswersBodySchema,
|
||||
sharedFieldAnswersPayload,
|
||||
} from "@/lib/kb/sharedFieldAnswers"
|
||||
import {
|
||||
customAiModulesBodySchema,
|
||||
customAiModulesPayload,
|
||||
parseCustomAiModulesJson,
|
||||
} from "@/lib/kb/customAiModules"
|
||||
import { replaceStructuredKnowledgeChunksForStore } from "@/lib/kb/structuredTemplateChunks"
|
||||
import {
|
||||
getPublishedThemeId,
|
||||
@ -571,8 +576,8 @@ async function syncStorefrontTheme(params: {
|
||||
const updated = params.enabled
|
||||
? upsertThemeLiquidInjection(themeLiquid.data.value, assetFilename)
|
||||
: removeThemeLiquidInjection(themeLiquid.data.value)
|
||||
if (!updated.updated && (updated as any).error) {
|
||||
throw new Error((updated as any).error)
|
||||
if (!updated.updated && "error" in updated) {
|
||||
throw new Error(String(updated.error))
|
||||
}
|
||||
if (updated.updated) {
|
||||
const putThemeLiquid = await putThemeAsset(cleanDomain, accessToken, apiVersion, themeId, themeLiquidKey, updated.next)
|
||||
@ -610,6 +615,7 @@ export async function GET(
|
||||
aiModules: unknown
|
||||
modulePolicies?: unknown
|
||||
sharedFieldAnswers?: unknown
|
||||
customAiModules?: unknown
|
||||
gmailReplyTheme?: unknown
|
||||
lowStockThreshold?: number | null
|
||||
lowStockShopifyThreshold?: number | null
|
||||
@ -644,6 +650,7 @@ export async function GET(
|
||||
aiModules: store.aiModules,
|
||||
modulePolicies: parseModulePoliciesJson(store.modulePolicies ?? null),
|
||||
sharedFieldAnswers: parseSharedFieldAnswersJson(store.sharedFieldAnswers ?? null),
|
||||
customAiModules: parseCustomAiModulesJson(store.customAiModules ?? null),
|
||||
gmailReplyTheme:
|
||||
store.gmailReplyTheme === undefined
|
||||
? undefined
|
||||
@ -704,6 +711,7 @@ export async function PATCH(
|
||||
lowStockThreshold?: number | null
|
||||
lowStockShopifyThreshold?: number | null
|
||||
storefrontOtpSessionMinutes?: number
|
||||
customAiModules?: Prisma.InputJsonValue | typeof Prisma.JsonNull
|
||||
}
|
||||
|
||||
const updatePayload: StorePatchPayload = {
|
||||
@ -760,6 +768,20 @@ export async function PATCH(
|
||||
reembedStructured = true
|
||||
}
|
||||
|
||||
if (data.customAiModules !== undefined) {
|
||||
const parsed = customAiModulesBodySchema.safeParse(data.customAiModules)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ message: "Invalid customAiModules", issues: parsed.error.flatten() },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const normalized = customAiModulesPayload(parsed.data)
|
||||
updatePayload.customAiModules =
|
||||
normalized.length === 0 ? Prisma.JsonNull : (normalized as Prisma.InputJsonValue)
|
||||
reembedStructured = true
|
||||
}
|
||||
|
||||
if (data.gmailReplyTheme !== undefined) {
|
||||
if (data.gmailReplyTheme === null) {
|
||||
updatePayload.gmailReplyTheme = Prisma.JsonNull
|
||||
@ -880,6 +902,9 @@ export async function PATCH(
|
||||
aiModules: updatedStore.aiModules,
|
||||
modulePolicies: parseModulePoliciesJson(updatedStore.modulePolicies),
|
||||
sharedFieldAnswers: parseSharedFieldAnswersJson(updatedStore.sharedFieldAnswers),
|
||||
customAiModules: parseCustomAiModulesJson(
|
||||
(updatedStore as { customAiModules?: unknown }).customAiModules,
|
||||
),
|
||||
gmailReplyTheme:
|
||||
updatedStore.gmailReplyTheme == null
|
||||
? null
|
||||
|
||||
@ -26,24 +26,35 @@ import {
|
||||
} from "@/lib/ai/orchestrator/moduleThreeTypes"
|
||||
import { parseSharedFieldAnswersJson, sharedFieldAnswersPayload } from "@/lib/kb/sharedFieldAnswers"
|
||||
import { KNOWLEDGE_TEMPLATE_ANSWER_MAX } from "@/lib/kb/knowledgeTemplate"
|
||||
import {
|
||||
customAiModulesPayload,
|
||||
parseCustomAiModulesJson,
|
||||
type CustomAiModule,
|
||||
} from "@/lib/kb/customAiModules"
|
||||
import {
|
||||
ChevronRight,
|
||||
Eye,
|
||||
HelpCircle,
|
||||
Info,
|
||||
Layers,
|
||||
Loader2,
|
||||
MapPin,
|
||||
Package,
|
||||
Pencil,
|
||||
Plus,
|
||||
Ruler,
|
||||
Save,
|
||||
ShieldAlert,
|
||||
ThumbsUp,
|
||||
Trash2,
|
||||
UserCircle,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
|
||||
const FAQ_CARD_ID = "faq"
|
||||
const CATEGORY_CARD_ID = "category_metadetails"
|
||||
const ADD_CUSTOM_MODULE_ID = "custom_module_new"
|
||||
const CUSTOM_MODULE_PREFIX = "custom_module:"
|
||||
|
||||
/** Single grid order — no section groupings on the dashboard. */
|
||||
const DASHBOARD_CARD_ORDER: Array<
|
||||
@ -79,7 +90,13 @@ function categoryAutomation(on: boolean): AutomationFlags {
|
||||
return { knowledge: false, verifiedLookup: on, ticket: false }
|
||||
}
|
||||
|
||||
type ModalId = typeof FAQ_CARD_ID | typeof CATEGORY_CARD_ID | Exclude<L1Module, "UNKNOWN"> | null
|
||||
type CustomModuleModalId = typeof ADD_CUSTOM_MODULE_ID | `${typeof CUSTOM_MODULE_PREFIX}${string}`
|
||||
type ModalId =
|
||||
| typeof FAQ_CARD_ID
|
||||
| typeof CATEGORY_CARD_ID
|
||||
| Exclude<L1Module, "UNKNOWN">
|
||||
| CustomModuleModalId
|
||||
| null
|
||||
|
||||
const ALL_MODAL_MODULES = new Set<L1Module>(L1_MODULE_IDS)
|
||||
|
||||
@ -107,6 +124,23 @@ function moduleIcon(m: Exclude<L1Module, "UNKNOWN">) {
|
||||
}
|
||||
}
|
||||
|
||||
function makeId(prefix: "custom" | "answer") {
|
||||
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
function emptyCustomModule(): CustomAiModule {
|
||||
const now = new Date().toISOString()
|
||||
return {
|
||||
id: makeId("custom"),
|
||||
title: "",
|
||||
subtitle: "",
|
||||
enabled: true,
|
||||
answers: [{ id: makeId("answer"), subtitle: "", answer: "" }],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
export default function JunoEngineModulesPage() {
|
||||
const { theme } = useTheme()
|
||||
const isLight = theme === "light"
|
||||
@ -120,6 +154,11 @@ export default function JunoEngineModulesPage() {
|
||||
parseAiModulesJson(null),
|
||||
)
|
||||
const [sharedFields, setSharedFields] = useState<Record<string, string>>({})
|
||||
const [customModules, setCustomModules] = useState<CustomAiModule[]>([])
|
||||
const [draftCustomModule, setDraftCustomModule] = useState<CustomAiModule>(() =>
|
||||
emptyCustomModule(),
|
||||
)
|
||||
const [customModuleViewMode, setCustomModuleViewMode] = useState<"view" | "edit">("view")
|
||||
const [categoryMetaEnabled, setCategoryMetaEnabled] = useState(false)
|
||||
const [modalId, setModalId] = useState<ModalId>(null)
|
||||
const [modalSaveError, setModalSaveError] = useState<string | null>(null)
|
||||
@ -142,6 +181,7 @@ export default function JunoEngineModulesPage() {
|
||||
const data = raw as {
|
||||
aiModules?: unknown
|
||||
sharedFieldAnswers?: unknown
|
||||
customAiModules?: unknown
|
||||
categoryMetadetailsEnabled?: boolean
|
||||
}
|
||||
const parsedMods = parseAiModulesJson(data.aiModules)
|
||||
@ -150,6 +190,7 @@ export default function JunoEngineModulesPage() {
|
||||
setMods(parsedMods)
|
||||
setCategoryMetaEnabled(catOn)
|
||||
setSharedFields(parseSharedFieldAnswersJson(data.sharedFieldAnswers))
|
||||
setCustomModules(parseCustomAiModulesJson(data.customAiModules))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@ -202,12 +243,29 @@ export default function JunoEngineModulesPage() {
|
||||
if (!modalId) return
|
||||
setModalSaveError(null)
|
||||
setModuleInfoOpen(false)
|
||||
if (modalId === ADD_CUSTOM_MODULE_ID) {
|
||||
setDraftCustomModule(emptyCustomModule())
|
||||
setCustomModuleViewMode("edit")
|
||||
} else if (typeof modalId === "string" && modalId.startsWith(CUSTOM_MODULE_PREFIX)) {
|
||||
const id = modalId.slice(CUSTOM_MODULE_PREFIX.length)
|
||||
const existing = customModules.find((module) => module.id === id)
|
||||
if (existing) {
|
||||
setDraftCustomModule({
|
||||
...existing,
|
||||
answers:
|
||||
existing.answers.length > 0
|
||||
? existing.answers.map((answer) => ({ ...answer }))
|
||||
: [{ id: makeId("answer"), subtitle: "", answer: "" }],
|
||||
})
|
||||
setCustomModuleViewMode("view")
|
||||
}
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setModalId(null)
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [modalId])
|
||||
}, [customModules, modalId])
|
||||
|
||||
useEffect(() => {
|
||||
if (modalId) {
|
||||
@ -220,7 +278,9 @@ export default function JunoEngineModulesPage() {
|
||||
}
|
||||
}, [modalId])
|
||||
|
||||
async function saveChanges(): Promise<{ ok: boolean; error?: string }> {
|
||||
async function saveChanges(
|
||||
customModuleOverride?: CustomAiModule[],
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
if (!activeStoreId) return { ok: false, error: "No store selected" }
|
||||
setSaving(true)
|
||||
setToast(null)
|
||||
@ -236,12 +296,14 @@ export default function JunoEngineModulesPage() {
|
||||
aiModules: serializeAiModules(mergedMods),
|
||||
categoryMetadetailsEnabled: categoryMetaEnabled,
|
||||
sharedFieldAnswers: sharedFieldAnswersPayload(sharedFields),
|
||||
customAiModules: customAiModulesPayload(customModuleOverride ?? customModules),
|
||||
}),
|
||||
})
|
||||
const j = (await res.json().catch(() => ({}))) as {
|
||||
message?: string
|
||||
aiModules?: unknown
|
||||
sharedFieldAnswers?: unknown
|
||||
customAiModules?: unknown
|
||||
categoryMetadetailsEnabled?: boolean
|
||||
}
|
||||
if (!res.ok) throw new Error(j.message || "Save failed")
|
||||
@ -251,6 +313,7 @@ export default function JunoEngineModulesPage() {
|
||||
setMods(nextMods)
|
||||
setCategoryMetaEnabled(catOn)
|
||||
setSharedFields(parseSharedFieldAnswersJson(j.sharedFieldAnswers))
|
||||
setCustomModules(parseCustomAiModulesJson(j.customAiModules))
|
||||
setToast("Saved.")
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
@ -272,6 +335,80 @@ export default function JunoEngineModulesPage() {
|
||||
}))
|
||||
}
|
||||
|
||||
const customModalModuleId =
|
||||
typeof modalId === "string" && modalId.startsWith(CUSTOM_MODULE_PREFIX)
|
||||
? modalId.slice(CUSTOM_MODULE_PREFIX.length)
|
||||
: null
|
||||
const isCustomModuleModal = modalId === ADD_CUSTOM_MODULE_ID || Boolean(customModalModuleId)
|
||||
const customModalExisting = customModalModuleId
|
||||
? customModules.find((module) => module.id === customModalModuleId) ?? null
|
||||
: null
|
||||
|
||||
function setDraftAnswer(
|
||||
answerId: string,
|
||||
key: "subtitle" | "answer",
|
||||
value: string,
|
||||
) {
|
||||
setDraftCustomModule((prev) => ({
|
||||
...prev,
|
||||
answers: prev.answers.map((answer) =>
|
||||
answer.id === answerId ? { ...answer, [key]: value } : answer,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
function addDraftAnswer() {
|
||||
setDraftCustomModule((prev) => ({
|
||||
...prev,
|
||||
answers: [...prev.answers, { id: makeId("answer"), subtitle: "", answer: "" }],
|
||||
}))
|
||||
}
|
||||
|
||||
function removeDraftAnswer(answerId: string) {
|
||||
setDraftCustomModule((prev) => ({
|
||||
...prev,
|
||||
answers:
|
||||
prev.answers.length > 1
|
||||
? prev.answers.filter((answer) => answer.id !== answerId)
|
||||
: prev.answers,
|
||||
}))
|
||||
}
|
||||
|
||||
function nextCustomModulesFromDraft(): { ok: true; modules: CustomAiModule[] } | { ok: false; error: string } {
|
||||
const normalized = customAiModulesPayload([draftCustomModule])[0]
|
||||
if (!normalized?.title) {
|
||||
return { ok: false, error: "Add a module title." }
|
||||
}
|
||||
if (normalized.answers.length === 0) {
|
||||
return { ok: false, error: "Add at least one subtitle and answer." }
|
||||
}
|
||||
const exists = customModules.some((module) => module.id === normalized.id)
|
||||
const modules = exists
|
||||
? customModules.map((module) => (module.id === normalized.id ? normalized : module))
|
||||
: [...customModules, normalized]
|
||||
return { ok: true, modules }
|
||||
}
|
||||
|
||||
async function saveDraftCustomModule() {
|
||||
const next = nextCustomModulesFromDraft()
|
||||
if (!next.ok) {
|
||||
setModalSaveError(next.error)
|
||||
return
|
||||
}
|
||||
setCustomModules(next.modules)
|
||||
const result = await saveChanges(next.modules)
|
||||
if (result.ok) setModalId(null)
|
||||
else if (result.error) setModalSaveError(result.error)
|
||||
}
|
||||
|
||||
async function deleteCustomModule(moduleId: string) {
|
||||
const nextModules = customModules.filter((module) => module.id !== moduleId)
|
||||
setCustomModules(nextModules)
|
||||
const result = await saveChanges(nextModules)
|
||||
if (result.ok) setModalId(null)
|
||||
else if (result.error) setModalSaveError(result.error)
|
||||
}
|
||||
|
||||
const modalModule: Exclude<L1Module, "UNKNOWN"> | null =
|
||||
modalId && isModuleModalId(modalId) ? modalId : null
|
||||
|
||||
@ -536,6 +673,268 @@ export default function JunoEngineModulesPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function renderCustomModuleCard(module: CustomAiModule) {
|
||||
const subtitle =
|
||||
module.subtitle ||
|
||||
module.answers[0]?.subtitle ||
|
||||
"Vendor-created knowledge module"
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cardShell}
|
||||
onClick={() => setModalId(`${CUSTOM_MODULE_PREFIX}${module.id}`)}
|
||||
>
|
||||
<span className="p-5 flex items-start gap-4 w-full">
|
||||
<span className={iconWrap}>
|
||||
<Plus className="w-5 h-5" />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 text-left">
|
||||
<span
|
||||
className={cn(
|
||||
"font-black text-base leading-tight block",
|
||||
isLight ? "text-slate-900" : "text-white",
|
||||
)}
|
||||
>
|
||||
{module.title}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs mt-1 line-clamp-2 block",
|
||||
isLight ? "text-slate-500" : "text-slate-400",
|
||||
)}
|
||||
>
|
||||
{subtitle}
|
||||
</span>
|
||||
</span>
|
||||
<span className={badgeCls(module.enabled)}>{module.enabled ? "On" : "Off"}</span>
|
||||
<ChevronRight className="w-5 h-5 shrink-0 text-slate-400 self-center" />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function renderAddModuleCard() {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
cardShell,
|
||||
isLight
|
||||
? "border-dashed border-indigo-200 bg-indigo-50/40 hover:bg-indigo-50"
|
||||
: "border-dashed border-indigo-400/30 bg-indigo-500/10 hover:bg-indigo-500/15",
|
||||
)}
|
||||
onClick={() => setModalId(ADD_CUSTOM_MODULE_ID)}
|
||||
>
|
||||
<span className="p-5 flex items-start gap-4 w-full">
|
||||
<span className={iconWrap}>
|
||||
<Plus className="w-5 h-5" />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 text-left">
|
||||
<span
|
||||
className={cn(
|
||||
"font-black text-base leading-tight block",
|
||||
isLight ? "text-slate-900" : "text-white",
|
||||
)}
|
||||
>
|
||||
Add Module
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs mt-1 line-clamp-2 block",
|
||||
isLight ? "text-slate-500" : "text-slate-400",
|
||||
)}
|
||||
>
|
||||
Create custom knowledge with title, subtitle, and answer.
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight className="w-5 h-5 shrink-0 text-slate-400 self-center" />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function renderCustomModuleEditor() {
|
||||
const subtle = isLight ? "text-slate-600" : "text-slate-400"
|
||||
const rowBorder = isLight ? "border-slate-200 bg-slate-50/50" : "border-white/10 bg-white/[0.03]"
|
||||
const canView = modalId !== ADD_CUSTOM_MODULE_ID && customModuleViewMode === "view"
|
||||
|
||||
if (canView) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className={cn("rounded-xl border p-4", rowBorder)}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className={cn("text-sm font-bold", isLight ? "text-slate-900" : "text-white")}>
|
||||
{draftCustomModule.title || "Untitled module"}
|
||||
</p>
|
||||
<p className={cn("text-xs mt-1", subtle)}>
|
||||
{draftCustomModule.subtitle || "No module subtitle added."}
|
||||
</p>
|
||||
</div>
|
||||
<span className={badgeCls(draftCustomModule.enabled)}>
|
||||
{draftCustomModule.enabled ? "On" : "Off"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{draftCustomModule.answers.map((answer) => (
|
||||
<div key={answer.id} className={cn("rounded-xl border p-4", rowBorder)}>
|
||||
<p className={cn("text-sm font-bold", isLight ? "text-slate-900" : "text-white")}>
|
||||
{answer.subtitle}
|
||||
</p>
|
||||
<p className={cn("text-sm mt-2 whitespace-pre-wrap leading-relaxed", subtle)}>
|
||||
{answer.answer}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className={cn("rounded-xl border p-4 space-y-4", rowBorder)}>
|
||||
<label className="flex items-start justify-between gap-4">
|
||||
<span>
|
||||
<span className={cn("text-sm font-bold block", isLight ? "text-slate-900" : "text-white")}>
|
||||
Module enabled
|
||||
</span>
|
||||
<span className={cn("text-xs mt-1 block", subtle)}>
|
||||
When enabled, its answers are embedded and can be used by storefront AI.
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-slate-500 w-4 h-4 accent-indigo-600 mt-1"
|
||||
checked={draftCustomModule.enabled}
|
||||
disabled={saving}
|
||||
onChange={(e) =>
|
||||
setDraftCustomModule((prev) => ({ ...prev, enabled: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
<label className={cn("text-[11px] font-semibold uppercase tracking-wide", subtle)}>
|
||||
Module title
|
||||
</label>
|
||||
<input
|
||||
value={draftCustomModule.title}
|
||||
onChange={(e) =>
|
||||
setDraftCustomModule((prev) => ({
|
||||
...prev,
|
||||
title: e.target.value.slice(0, 80),
|
||||
}))
|
||||
}
|
||||
maxLength={80}
|
||||
disabled={saving}
|
||||
placeholder="Example: VIP delivery rules"
|
||||
className={cn(
|
||||
"w-full px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/30",
|
||||
inputSurface,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className={cn("text-[11px] font-semibold uppercase tracking-wide", subtle)}>
|
||||
Module subtitle
|
||||
</label>
|
||||
<input
|
||||
value={draftCustomModule.subtitle}
|
||||
onChange={(e) =>
|
||||
setDraftCustomModule((prev) => ({
|
||||
...prev,
|
||||
subtitle: e.target.value.slice(0, 160),
|
||||
}))
|
||||
}
|
||||
maxLength={160}
|
||||
disabled={saving}
|
||||
placeholder="Short description shown on the module card"
|
||||
className={cn(
|
||||
"w-full px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/30",
|
||||
inputSurface,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{draftCustomModule.answers.map((answer, index) => (
|
||||
<div key={answer.id} className={cn("rounded-xl border p-4 space-y-3", rowBorder)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className={cn("text-sm font-bold", isLight ? "text-slate-900" : "text-white")}>
|
||||
Subtitle answer {index + 1}
|
||||
</p>
|
||||
{draftCustomModule.answers.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => removeDraftAnswer(answer.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-bold",
|
||||
isLight
|
||||
? "text-rose-700 hover:bg-rose-50"
|
||||
: "text-rose-300 hover:bg-rose-500/10",
|
||||
)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Remove
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className={cn("text-[11px] font-semibold uppercase tracking-wide", subtle)}>
|
||||
Subtitle / customer question
|
||||
</label>
|
||||
<input
|
||||
value={answer.subtitle}
|
||||
onChange={(e) => setDraftAnswer(answer.id, "subtitle", e.target.value.slice(0, 160))}
|
||||
maxLength={160}
|
||||
disabled={saving}
|
||||
placeholder="Example: Do you offer same-day delivery?"
|
||||
className={cn(
|
||||
"w-full px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/30",
|
||||
inputSurface,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className={cn("text-[11px] font-semibold uppercase tracking-wide", subtle)}>
|
||||
Answer
|
||||
</label>
|
||||
<textarea
|
||||
value={answer.answer}
|
||||
onChange={(e) =>
|
||||
setDraftAnswer(answer.id, "answer", e.target.value.slice(0, KNOWLEDGE_TEMPLATE_ANSWER_MAX))
|
||||
}
|
||||
rows={5}
|
||||
maxLength={KNOWLEDGE_TEMPLATE_ANSWER_MAX}
|
||||
disabled={saving}
|
||||
placeholder="Write the exact business answer JUNO can use."
|
||||
className={cn(
|
||||
"w-full px-3 py-2 rounded-lg border text-sm resize-y min-h-[120px] focus:outline-none focus:ring-2 focus:ring-indigo-500/30",
|
||||
inputSurface,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || draftCustomModule.answers.length >= 12}
|
||||
onClick={addDraftAnswer}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-bold border",
|
||||
isLight
|
||||
? "border-slate-200 text-indigo-700 hover:bg-indigo-50"
|
||||
: "border-white/10 text-indigo-300 hover:bg-indigo-500/10",
|
||||
)}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add another subtitle
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const infoSpecMod: Exclude<L1Module, "UNKNOWN"> | null =
|
||||
modalId === FAQ_CARD_ID
|
||||
? "FAQ"
|
||||
@ -589,6 +988,10 @@ export default function JunoEngineModulesPage() {
|
||||
? L1_MODULE_CARD_TITLE.FAQ
|
||||
: modalId === CATEGORY_CARD_ID
|
||||
? L1_MODULE_CARD_TITLE.CATEGORY_METADETAILS
|
||||
: isCustomModuleModal
|
||||
? modalId === ADD_CUSTOM_MODULE_ID
|
||||
? "Add Module"
|
||||
: draftCustomModule.title || "Custom module"
|
||||
: modalModule
|
||||
? L1_MODULE_CARD_TITLE[modalModule]
|
||||
: "Module"}
|
||||
@ -603,12 +1006,53 @@ export default function JunoEngineModulesPage() {
|
||||
? L1_MODULE_CUSTOMER_LABEL.FAQ
|
||||
: modalId === CATEGORY_CARD_ID
|
||||
? L1_MODULE_CUSTOMER_LABEL.CATEGORY_METADETAILS
|
||||
: isCustomModuleModal
|
||||
? "Custom knowledge module embedded into storefront AI answers."
|
||||
: modalModule
|
||||
? L1_MODULE_CUSTOMER_LABEL[modalModule]
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-1 shrink-0 relative">
|
||||
{isCustomModuleModal && modalId !== ADD_CUSTOM_MODULE_ID ? (
|
||||
<div
|
||||
className={cn(
|
||||
"mr-1 flex rounded-lg border p-1",
|
||||
isLight ? "border-slate-200 bg-slate-50" : "border-white/10 bg-white/[0.04]",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomModuleViewMode("view")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-bold",
|
||||
customModuleViewMode === "view"
|
||||
? "bg-indigo-600 text-white"
|
||||
: isLight
|
||||
? "text-slate-600 hover:bg-white"
|
||||
: "text-slate-300 hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomModuleViewMode("edit")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-bold",
|
||||
customModuleViewMode === "edit"
|
||||
? "bg-indigo-600 text-white"
|
||||
: isLight
|
||||
? "text-slate-600 hover:bg-white"
|
||||
: "text-slate-300 hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{hasModuleInfoPanel && infoSpecMod ? (
|
||||
<>
|
||||
<button
|
||||
@ -668,6 +1112,8 @@ export default function JunoEngineModulesPage() {
|
||||
{renderAutomationRows(modalModule)}
|
||||
{renderPolicyFields(modalModule)}
|
||||
</>
|
||||
) : isCustomModuleModal ? (
|
||||
renderCustomModuleEditor()
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@ -689,6 +1135,22 @@ export default function JunoEngineModulesPage() {
|
||||
isLight ? "border-slate-100 bg-slate-50/80" : "border-white/10 bg-slate-950/50",
|
||||
)}
|
||||
>
|
||||
{customModalExisting ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => void deleteCustomModule(customModalExisting.id)}
|
||||
className={cn(
|
||||
"mr-auto inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold disabled:opacity-50",
|
||||
isLight
|
||||
? "text-rose-700 hover:bg-rose-50"
|
||||
: "text-rose-300 hover:bg-rose-500/10",
|
||||
)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalId(null)}
|
||||
@ -707,6 +1169,10 @@ export default function JunoEngineModulesPage() {
|
||||
onClick={() =>
|
||||
void (async () => {
|
||||
setModalSaveError(null)
|
||||
if (isCustomModuleModal) {
|
||||
await saveDraftCustomModule()
|
||||
return
|
||||
}
|
||||
const r = await saveChanges()
|
||||
if (r.ok) setModalId(null)
|
||||
else if (r.error) setModalSaveError(r.error)
|
||||
@ -719,7 +1185,7 @@ export default function JunoEngineModulesPage() {
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
Save changes
|
||||
{isCustomModuleModal ? "Save module" : "Save changes"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -893,6 +1359,12 @@ export default function JunoEngineModulesPage() {
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{customModules.map((module) => (
|
||||
<div key={module.id} className="contents">
|
||||
{renderCustomModuleCard(module)}
|
||||
</div>
|
||||
))}
|
||||
<div className="contents">{renderAddModuleCard()}</div>
|
||||
</div>
|
||||
|
||||
{modal}
|
||||
|
||||
@ -105,4 +105,62 @@ describe("revenueAttribution", () => {
|
||||
expect(result.directUpsellRevenue).toBe(0)
|
||||
expect(result.directRows).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("matches anonymous storefront orders through JUNO cart note attributes", () => {
|
||||
const result = computeAiRevenueAttribution({
|
||||
events: [
|
||||
{
|
||||
id: "event-1",
|
||||
conversationId: "conv-anon",
|
||||
eventType: "upsell_clicked",
|
||||
productId: "p-upsell",
|
||||
variantId: "v-upsell",
|
||||
quantity: 1,
|
||||
createdAt: new Date("2026-05-01T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "event-2",
|
||||
conversationId: "conv-anon",
|
||||
eventType: "customer_question",
|
||||
productId: null,
|
||||
variantId: null,
|
||||
quantity: null,
|
||||
createdAt: new Date("2026-05-01T09:59:00Z"),
|
||||
},
|
||||
],
|
||||
conversations: [
|
||||
{
|
||||
id: "conv-anon",
|
||||
customerEmail: null,
|
||||
boundShopifyOrderId: null,
|
||||
createdAt: new Date("2026-05-01T09:58:00Z"),
|
||||
updatedAt: new Date("2026-05-01T10:02:00Z"),
|
||||
},
|
||||
],
|
||||
orders: [
|
||||
{
|
||||
shopifyOrderId: "order-1",
|
||||
orderNumber: "#1001",
|
||||
email: "checkout@example.com",
|
||||
totalPrice: "25.00",
|
||||
currency: "GBP",
|
||||
shopifyCreatedAt: new Date("2026-05-01T10:10:00Z"),
|
||||
createdAt: new Date("2026-05-01T10:10:00Z"),
|
||||
noteAttributes: [
|
||||
{ name: "juno_ai_conversation_id", value: "conv-anon" },
|
||||
{ name: "juno_ai_source", value: "upsell" },
|
||||
],
|
||||
lineItems: [
|
||||
{ product_id: "p-upsell", variant_id: "v-upsell", title: "Socks", quantity: 1, price: "5.00" },
|
||||
{ product_id: "p-base", variant_id: "v-base", title: "Shoes", quantity: 1, price: "20.00" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.directUpsellRevenue).toBe(5)
|
||||
expect(result.aiAssistedRevenue).toBe(25)
|
||||
expect(result.directRows).toHaveLength(1)
|
||||
expect(result.assistedRows).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@ -23,6 +23,7 @@ type OrderLike = {
|
||||
totalPrice: string
|
||||
currency: string | null
|
||||
lineItems: unknown
|
||||
noteAttributes?: unknown
|
||||
shopifyCreatedAt: Date | null
|
||||
createdAt: Date
|
||||
}
|
||||
@ -93,7 +94,24 @@ function orderDate(order: OrderLike): Date {
|
||||
return order.shopifyCreatedAt ?? order.createdAt
|
||||
}
|
||||
|
||||
function noteAttributeValue(raw: unknown, key: string): string | null {
|
||||
if (!Array.isArray(raw)) return null
|
||||
const expected = key.trim().toLowerCase()
|
||||
for (const row of raw) {
|
||||
if (!row || typeof row !== "object") continue
|
||||
const item = row as { name?: unknown; key?: unknown; value?: unknown }
|
||||
const name = asString(item.name ?? item.key)?.toLowerCase()
|
||||
if (name !== expected) continue
|
||||
return asString(item.value)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function orderMatchesConversation(order: OrderLike, conversation: ConversationLike) {
|
||||
const attributedConversationId = noteAttributeValue(order.noteAttributes, "juno_ai_conversation_id")
|
||||
if (attributedConversationId && attributedConversationId === conversation.id) {
|
||||
return true
|
||||
}
|
||||
if (conversation.boundShopifyOrderId && conversation.boundShopifyOrderId === order.shopifyOrderId) {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ interface ShopifyOrderRecord {
|
||||
fulfillment_status?: string
|
||||
customer?: unknown
|
||||
line_items?: unknown[]
|
||||
note_attributes?: unknown[]
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
@ -136,7 +137,7 @@ export async function updateProductCache(storeId: string, products: ShopifyProdu
|
||||
select: { shopifyDomain: true, shopifyAccessToken: true },
|
||||
})
|
||||
|
||||
let metafieldsByProductId = new Map<string, Prisma.InputJsonValue>()
|
||||
const metafieldsByProductId = new Map<string, Prisma.InputJsonValue>()
|
||||
if (store?.shopifyDomain && store?.shopifyAccessToken && products.length > 0) {
|
||||
try {
|
||||
const ids = products.map((p) => p.id)
|
||||
@ -279,9 +280,10 @@ export async function updateOrderCache(storeId: string, orders: ShopifyOrderReco
|
||||
financialStatus: order.financial_status || null,
|
||||
fulfillmentStatus: order.fulfillment_status || null,
|
||||
lineItems: (order.line_items || []) as Prisma.InputJsonValue,
|
||||
noteAttributes: (order.note_attributes || []) as Prisma.InputJsonValue,
|
||||
customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined,
|
||||
shopifyCreatedAt: order.created_at ? new Date(order.created_at) : null
|
||||
},
|
||||
} as Prisma.CachedOrderUncheckedCreateInput,
|
||||
update: {
|
||||
orderNumber: order.order_number?.toString() || order.name || "",
|
||||
email: extractOrderEmail(order),
|
||||
@ -295,10 +297,11 @@ export async function updateOrderCache(storeId: string, orders: ShopifyOrderReco
|
||||
? existingById.get(order.id.toString())?.fulfillmentStatus || null
|
||||
: order.fulfillment_status || null,
|
||||
lineItems: (order.line_items || []) as Prisma.InputJsonValue,
|
||||
noteAttributes: (order.note_attributes || []) as Prisma.InputJsonValue,
|
||||
customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined,
|
||||
shopifyCreatedAt: order.created_at ? new Date(order.created_at) : null,
|
||||
lastSyncedAt: new Date()
|
||||
}
|
||||
} as Prisma.CachedOrderUncheckedUpdateInput
|
||||
})
|
||||
)
|
||||
|
||||
@ -531,10 +534,11 @@ export async function updateSingleOrder(storeId: string, order: ShopifyOrderReco
|
||||
fulfillmentStatus:
|
||||
shouldPreserve || hasSupplierHold ? preservedStatus : order.fulfillment_status || null,
|
||||
lineItems: (order.line_items || []) as Prisma.InputJsonValue,
|
||||
noteAttributes: (order.note_attributes || []) as Prisma.InputJsonValue,
|
||||
customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined,
|
||||
shopifyCreatedAt: order.created_at ? new Date(order.created_at) : null,
|
||||
lastSyncedAt: new Date()
|
||||
},
|
||||
} as Prisma.CachedOrderUncheckedCreateInput,
|
||||
update: {
|
||||
orderNumber: order.order_number?.toString() || order.name || "",
|
||||
email: extractOrderEmail(order),
|
||||
@ -544,9 +548,10 @@ export async function updateSingleOrder(storeId: string, order: ShopifyOrderReco
|
||||
fulfillmentStatus:
|
||||
shouldPreserve || hasSupplierHold ? preservedStatus : order.fulfillment_status || null,
|
||||
lineItems: (order.line_items || []) as Prisma.InputJsonValue,
|
||||
noteAttributes: (order.note_attributes || []) as Prisma.InputJsonValue,
|
||||
customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined,
|
||||
shopifyCreatedAt: order.created_at ? new Date(order.created_at) : null,
|
||||
lastSyncedAt: new Date()
|
||||
}
|
||||
} as Prisma.CachedOrderUncheckedUpdateInput
|
||||
})
|
||||
}
|
||||
|
||||
111
src/lib/kb/customAiModules.ts
Normal file
111
src/lib/kb/customAiModules.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { z } from "zod"
|
||||
import { KNOWLEDGE_TEMPLATE_ANSWER_MAX } from "@/lib/kb/knowledgeTemplate"
|
||||
|
||||
const CUSTOM_MODULE_ID = /^custom_[a-z0-9_-]{6,80}$/
|
||||
const CUSTOM_ANSWER_ID = /^answer_[a-z0-9_-]{4,80}$/
|
||||
const TITLE_MAX = 80
|
||||
const SUBTITLE_MAX = 160
|
||||
const MAX_CUSTOM_MODULES = 30
|
||||
const MAX_ANSWERS_PER_MODULE = 12
|
||||
|
||||
export type CustomAiModuleAnswer = {
|
||||
id: string
|
||||
subtitle: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
export type CustomAiModule = {
|
||||
id: string
|
||||
title: string
|
||||
subtitle: string
|
||||
enabled: boolean
|
||||
answers: CustomAiModuleAnswer[]
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export const customAiModulesBodySchema = z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().regex(CUSTOM_MODULE_ID),
|
||||
title: z.string().max(TITLE_MAX),
|
||||
subtitle: z.string().max(SUBTITLE_MAX).optional().default(""),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
answers: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().regex(CUSTOM_ANSWER_ID),
|
||||
subtitle: z.string().max(SUBTITLE_MAX),
|
||||
answer: z.string().max(KNOWLEDGE_TEMPLATE_ANSWER_MAX),
|
||||
}),
|
||||
)
|
||||
.max(MAX_ANSWERS_PER_MODULE)
|
||||
.optional()
|
||||
.default([]),
|
||||
createdAt: z.string().optional(),
|
||||
updatedAt: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.max(MAX_CUSTOM_MODULES)
|
||||
|
||||
function cleanText(value: unknown, max: number) {
|
||||
return typeof value === "string" ? value.trim().slice(0, max) : ""
|
||||
}
|
||||
|
||||
export function parseCustomAiModulesJson(raw: unknown): CustomAiModule[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
|
||||
const out: CustomAiModule[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== "object") continue
|
||||
const row = item as Record<string, unknown>
|
||||
const id = cleanText(row.id, 96)
|
||||
if (!CUSTOM_MODULE_ID.test(id) || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
|
||||
const title = cleanText(row.title, TITLE_MAX)
|
||||
if (!title) continue
|
||||
|
||||
const answers: CustomAiModuleAnswer[] = []
|
||||
const seenAnswers = new Set<string>()
|
||||
const rawAnswers = Array.isArray(row.answers) ? row.answers : []
|
||||
for (const answerRow of rawAnswers) {
|
||||
if (!answerRow || typeof answerRow !== "object") continue
|
||||
const answer = answerRow as Record<string, unknown>
|
||||
const answerId = cleanText(answer.id, 96)
|
||||
if (!CUSTOM_ANSWER_ID.test(answerId) || seenAnswers.has(answerId)) continue
|
||||
seenAnswers.add(answerId)
|
||||
|
||||
const subtitle = cleanText(answer.subtitle, SUBTITLE_MAX)
|
||||
const body = cleanText(answer.answer, KNOWLEDGE_TEMPLATE_ANSWER_MAX)
|
||||
if (!subtitle || !body) continue
|
||||
answers.push({ id: answerId, subtitle, answer: body })
|
||||
if (answers.length >= MAX_ANSWERS_PER_MODULE) break
|
||||
}
|
||||
|
||||
out.push({
|
||||
id,
|
||||
title,
|
||||
subtitle: cleanText(row.subtitle, SUBTITLE_MAX),
|
||||
enabled: row.enabled !== false,
|
||||
answers,
|
||||
createdAt: cleanText(row.createdAt, 40) || undefined,
|
||||
updatedAt: cleanText(row.updatedAt, 40) || undefined,
|
||||
})
|
||||
if (out.length >= MAX_CUSTOM_MODULES) break
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export function customAiModulesPayload(modules: CustomAiModule[]): CustomAiModule[] {
|
||||
const now = new Date().toISOString()
|
||||
return parseCustomAiModulesJson(
|
||||
modules.map((module) => ({
|
||||
...module,
|
||||
updatedAt: now,
|
||||
createdAt: module.createdAt || now,
|
||||
})),
|
||||
)
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { logger } from "@/lib/logger"
|
||||
import { Prisma } from "@prisma/client"
|
||||
import { embedBatch } from "@/lib/kb/openaiEmbeddings"
|
||||
import { kbEmbeddingMetadata } from "@/lib/kb/embeddingMeta"
|
||||
import {
|
||||
@ -9,10 +10,19 @@ import {
|
||||
} from "@/lib/kb/knowledgeTemplate"
|
||||
import { parseModulePoliciesJson } from "@/lib/kb/modulePolicies"
|
||||
import { parseSharedFieldAnswersJson } from "@/lib/kb/sharedFieldAnswers"
|
||||
import { parseCustomAiModulesJson } from "@/lib/kb/customAiModules"
|
||||
import { L1_MODULE_CUSTOMER_LABEL } from "@/lib/ai/orchestrator/vendorModuleGroups"
|
||||
import { SHARED_FIELD_SHORT_LABEL } from "@/lib/ai/orchestrator/moduleThreeTypes"
|
||||
import type { L1Module } from "@/lib/ai/orchestrator/types"
|
||||
|
||||
type StoreStructuredKnowledgeRow = {
|
||||
knowledgeBaseMode: "UNSET" | "STRUCTURED" | "DOCUMENTS"
|
||||
knowledgeTemplate: unknown
|
||||
modulePolicies: unknown
|
||||
sharedFieldAnswers: unknown
|
||||
customAiModules?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild STRUCTURED knowledge chunks from the store's v3 template + modulePolicies + sharedFieldAnswers.
|
||||
* Clears STRUCTURED rows when the store is not in STRUCTURED mode.
|
||||
@ -23,20 +33,36 @@ export async function replaceStructuredKnowledgeChunksForStore(storeId: string)
|
||||
storeId,
|
||||
)
|
||||
|
||||
const store = await prisma.store.findFirst({
|
||||
const store = (await prisma.store.findFirst({
|
||||
where: { id: storeId },
|
||||
select: {
|
||||
knowledgeBaseMode: true,
|
||||
knowledgeTemplate: true,
|
||||
modulePolicies: true,
|
||||
sharedFieldAnswers: true,
|
||||
},
|
||||
})
|
||||
customAiModules: true,
|
||||
} as unknown as Prisma.StoreSelect,
|
||||
})) as StoreStructuredKnowledgeRow | null
|
||||
|
||||
if (!store || store.knowledgeBaseMode !== "STRUCTURED") {
|
||||
if (!store) {
|
||||
return
|
||||
}
|
||||
|
||||
const customModules = parseCustomAiModulesJson(store.customAiModules)
|
||||
const hasEnabledCustomKnowledge = customModules.some(
|
||||
(module) => module.enabled && module.answers.length > 0,
|
||||
)
|
||||
|
||||
if (store.knowledgeBaseMode !== "STRUCTURED" && !hasEnabledCustomKnowledge) {
|
||||
return
|
||||
}
|
||||
|
||||
const rows: {
|
||||
content: string
|
||||
metadata: ReturnType<typeof kbEmbeddingMetadata>
|
||||
}[] = []
|
||||
|
||||
if (store.knowledgeBaseMode === "STRUCTURED") {
|
||||
const stored: KnowledgeTemplateStored =
|
||||
parseKnowledgeTemplateJson(store.knowledgeTemplate) ?? {
|
||||
version: 3,
|
||||
@ -47,11 +73,6 @@ export async function replaceStructuredKnowledgeChunksForStore(storeId: string)
|
||||
const modulePolicies = parseModulePoliciesJson(store.modulePolicies)
|
||||
const sharedFieldAnswers = parseSharedFieldAnswersJson(store.sharedFieldAnswers)
|
||||
|
||||
const rows: {
|
||||
content: string
|
||||
metadata: ReturnType<typeof kbEmbeddingMetadata>
|
||||
}[] = []
|
||||
|
||||
for (const field of STORE_VOICE_FIELDS) {
|
||||
const text = stored.genericAnswers[field.key]
|
||||
if (text?.trim()) {
|
||||
@ -91,6 +112,28 @@ export async function replaceStructuredKnowledgeChunksForStore(storeId: string)
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const customModule of customModules) {
|
||||
if (!customModule.enabled) continue
|
||||
for (const answer of customModule.answers) {
|
||||
rows.push({
|
||||
content: [
|
||||
`Custom module: ${customModule.title}`,
|
||||
customModule.subtitle ? `Module subtitle: ${customModule.subtitle}` : "",
|
||||
`Q: ${answer.subtitle}`,
|
||||
`A: ${answer.answer}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
metadata: kbEmbeddingMetadata({
|
||||
sourceKey: `custom_module_${customModule.id}_${answer.id}`,
|
||||
moduleCode: "CUSTOM",
|
||||
fieldId: answer.id,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
return
|
||||
|
||||
@ -42,6 +42,7 @@ export const storeSelectVendorSettingsGetFull = {
|
||||
aiModules: true,
|
||||
modulePolicies: true,
|
||||
sharedFieldAnswers: true,
|
||||
customAiModules: true,
|
||||
gmailReplyTheme: true,
|
||||
lowStockThreshold: true,
|
||||
lowStockShopifyThreshold: true,
|
||||
@ -71,6 +72,7 @@ export const storeSelectPatchStorefrontAi = {
|
||||
aiModules: true,
|
||||
modulePolicies: true,
|
||||
sharedFieldAnswers: true,
|
||||
customAiModules: true,
|
||||
gmailReplyTheme: true,
|
||||
lowStockThreshold: true,
|
||||
lowStockShopifyThreshold: true,
|
||||
|
||||
@ -146,6 +146,7 @@ export function selectVariantForCart(params: {
|
||||
export function buildCartPermalink(
|
||||
shopifyDomain: string,
|
||||
lines: Array<{ variantId: string; quantity?: number }>,
|
||||
attributes?: Record<string, string | number | boolean | null | undefined>,
|
||||
) {
|
||||
const cleanDomain = normalizeShopDomain(shopifyDomain)
|
||||
const encodedLines = lines
|
||||
@ -158,7 +159,13 @@ export function buildCartPermalink(
|
||||
.filter((line): line is string => Boolean(line))
|
||||
|
||||
if (!cleanDomain || encodedLines.length === 0) return null
|
||||
return `https://${cleanDomain}/cart/${encodedLines.join(",")}`
|
||||
const params = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(attributes ?? {})) {
|
||||
if (value === null || value === undefined || value === "") continue
|
||||
params.set(`attributes[${key}]`, String(value).slice(0, 255))
|
||||
}
|
||||
const query = params.toString()
|
||||
return `https://${cleanDomain}/cart/${encodedLines.join(",")}${query ? `?${query}` : ""}`
|
||||
}
|
||||
|
||||
export function formatVariantChoices(variants: CartableVariant[]) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user