new attributes for AI

This commit is contained in:
root 2026-05-12 22:35:05 +05:00
parent 710ab671b3
commit bd883a9c96
14 changed files with 820 additions and 55 deletions

View File

@ -0,0 +1 @@
ALTER TABLE "Store" ADD COLUMN "customAiModules" JSONB;

View File

@ -0,0 +1 @@
ALTER TABLE "CachedOrder" ADD COLUMN "noteAttributes" JSONB;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,
})),
)
}

View File

@ -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,73 +33,106 @@ 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 stored: KnowledgeTemplateStored =
parseKnowledgeTemplateJson(store.knowledgeTemplate) ?? {
version: 3,
genericAnswers: {},
updatedAt: new Date().toISOString(),
}
const customModules = parseCustomAiModulesJson(store.customAiModules)
const hasEnabledCustomKnowledge = customModules.some(
(module) => module.enabled && module.answers.length > 0,
)
const modulePolicies = parseModulePoliciesJson(store.modulePolicies)
const sharedFieldAnswers = parseSharedFieldAnswersJson(store.sharedFieldAnswers)
if (store.knowledgeBaseMode !== "STRUCTURED" && !hasEnabledCustomKnowledge) {
return
}
const rows: {
content: string
metadata: ReturnType<typeof kbEmbeddingMetadata>
}[] = []
for (const field of STORE_VOICE_FIELDS) {
const text = stored.genericAnswers[field.key]
if (text?.trim()) {
if (store.knowledgeBaseMode === "STRUCTURED") {
const stored: KnowledgeTemplateStored =
parseKnowledgeTemplateJson(store.knowledgeTemplate) ?? {
version: 3,
genericAnswers: {},
updatedAt: new Date().toISOString(),
}
const modulePolicies = parseModulePoliciesJson(store.modulePolicies)
const sharedFieldAnswers = parseSharedFieldAnswersJson(store.sharedFieldAnswers)
for (const field of STORE_VOICE_FIELDS) {
const text = stored.genericAnswers[field.key]
if (text?.trim()) {
rows.push({
content: `Q: ${field.label}\nA: ${text.trim()}`,
metadata: kbEmbeddingMetadata({
sourceKey: field.key,
moduleCode: "ALL",
fieldId: field.key,
}),
})
}
}
for (const [mod, text] of Object.entries(modulePolicies) as [L1Module, string][]) {
if (!text?.trim()) continue
const title = L1_MODULE_CUSTOMER_LABEL[mod] ?? mod
rows.push({
content: `Q: ${field.label}\nA: ${text.trim()}`,
content: `Q: ${title} (store policy)\nA: ${text.trim()}`,
metadata: kbEmbeddingMetadata({
sourceKey: field.key,
sourceKey: `policy_${mod}`,
moduleCode: mod,
fieldId: `policy_${mod}`,
}),
})
}
for (const [fieldId, text] of Object.entries(sharedFieldAnswers)) {
if (!text?.trim()) continue
const lab = SHARED_FIELD_SHORT_LABEL[fieldId] || fieldId
rows.push({
content: `Q: ${lab} (${fieldId})\nA: ${text.trim()}`,
metadata: kbEmbeddingMetadata({
sourceKey: fieldId,
moduleCode: "ALL",
fieldId: field.key,
fieldId,
}),
})
}
}
for (const [mod, text] of Object.entries(modulePolicies) as [L1Module, string][]) {
if (!text?.trim()) continue
const title = L1_MODULE_CUSTOMER_LABEL[mod] ?? mod
rows.push({
content: `Q: ${title} (store policy)\nA: ${text.trim()}`,
metadata: kbEmbeddingMetadata({
sourceKey: `policy_${mod}`,
moduleCode: mod,
fieldId: `policy_${mod}`,
}),
})
}
for (const [fieldId, text] of Object.entries(sharedFieldAnswers)) {
if (!text?.trim()) continue
const lab = SHARED_FIELD_SHORT_LABEL[fieldId] || fieldId
rows.push({
content: `Q: ${lab} (${fieldId})\nA: ${text.trim()}`,
metadata: kbEmbeddingMetadata({
sourceKey: fieldId,
moduleCode: "ALL",
fieldId,
}),
})
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) {

View File

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

View File

@ -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[]) {