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).
|
* Vendor Type-1 answers keyed by shared_field_id (sf_*); one value per key store-wide (§4.2).
|
||||||
*/
|
*/
|
||||||
sharedFieldAnswers Json?
|
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.
|
* Optional Gmail auto-reply HTML theming (colors, font, logo URL). Merged with defaults server-side.
|
||||||
@ -304,6 +308,7 @@ model CachedOrder {
|
|||||||
lastShopifySyncAt DateTime?
|
lastShopifySyncAt DateTime?
|
||||||
lastShopifySyncError String?
|
lastShopifySyncError String?
|
||||||
lineItems Json // Store line items as JSON
|
lineItems Json // Store line items as JSON
|
||||||
|
noteAttributes Json?
|
||||||
customer Json? // Store customer info as JSON
|
customer Json? // Store customer info as JSON
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@ -52,6 +52,7 @@ type CachedOrderRow = {
|
|||||||
totalPrice: string
|
totalPrice: string
|
||||||
currency: string | null
|
currency: string | null
|
||||||
lineItems: unknown
|
lineItems: unknown
|
||||||
|
noteAttributes?: unknown
|
||||||
shopifyCreatedAt: Date | null
|
shopifyCreatedAt: Date | null
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
}
|
}
|
||||||
@ -416,6 +417,7 @@ export async function GET(req: Request) {
|
|||||||
totalPrice: true,
|
totalPrice: true,
|
||||||
currency: true,
|
currency: true,
|
||||||
lineItems: true,
|
lineItems: true,
|
||||||
|
noteAttributes: true,
|
||||||
shopifyCreatedAt: true,
|
shopifyCreatedAt: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -221,12 +221,14 @@ function imageUrlFromImages(images: unknown): string | null {
|
|||||||
|
|
||||||
async function buildCartResponse(params: {
|
async function buildCartResponse(params: {
|
||||||
storeId: string
|
storeId: string
|
||||||
|
conversationId: string
|
||||||
shopifyDomain: string
|
shopifyDomain: string
|
||||||
productId: string
|
productId: string
|
||||||
requestedVariantId?: string | null
|
requestedVariantId?: string | null
|
||||||
quantity?: number
|
quantity?: number
|
||||||
message: string
|
message: string
|
||||||
existingCartLines: StorefrontCartLine[]
|
existingCartLines: StorefrontCartLine[]
|
||||||
|
attributionSource: "primary" | "upsell" | "direct"
|
||||||
}): Promise<CartResponse> {
|
}): Promise<CartResponse> {
|
||||||
const product = await prisma.cachedProduct.findFirst({
|
const product = await prisma.cachedProduct.findFirst({
|
||||||
where: { storeId: params.storeId, shopifyProductId: params.productId },
|
where: { storeId: params.storeId, shopifyProductId: params.productId },
|
||||||
@ -306,7 +308,12 @@ async function buildCartResponse(params: {
|
|||||||
productId: product.shopifyProductId,
|
productId: product.shopifyProductId,
|
||||||
title: product.title,
|
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) {
|
if (!cartUrl) {
|
||||||
return {
|
return {
|
||||||
content: "I could not create a cart link for that product right now.",
|
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: {
|
async function resolveDirectAddToCart(params: {
|
||||||
storeId: string
|
storeId: string
|
||||||
|
conversationId: string
|
||||||
shopifyDomain: string
|
shopifyDomain: string
|
||||||
message: string
|
message: string
|
||||||
existingCartLines: StorefrontCartLine[]
|
existingCartLines: StorefrontCartLine[]
|
||||||
@ -343,12 +351,14 @@ async function resolveDirectAddToCart(params: {
|
|||||||
const product = productSearchHitToActionProduct(hit)
|
const product = productSearchHitToActionProduct(hit)
|
||||||
return buildCartResponse({
|
return buildCartResponse({
|
||||||
storeId: params.storeId,
|
storeId: params.storeId,
|
||||||
|
conversationId: params.conversationId,
|
||||||
shopifyDomain: params.shopifyDomain,
|
shopifyDomain: params.shopifyDomain,
|
||||||
productId: hit.shopifyProductId,
|
productId: hit.shopifyProductId,
|
||||||
requestedVariantId: product?.variantId,
|
requestedVariantId: product?.variantId,
|
||||||
quantity: product?.quantity ?? 1,
|
quantity: product?.quantity ?? 1,
|
||||||
message: params.message,
|
message: params.message,
|
||||||
existingCartLines: params.existingCartLines,
|
existingCartLines: params.existingCartLines,
|
||||||
|
attributionSource: "direct",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -467,25 +477,30 @@ export async function POST(req: Request) {
|
|||||||
const cartResponse = action
|
const cartResponse = action
|
||||||
? await buildCartResponse({
|
? await buildCartResponse({
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
|
conversationId: conversation.id,
|
||||||
shopifyDomain: store.shopifyDomain,
|
shopifyDomain: store.shopifyDomain,
|
||||||
productId: action.productId,
|
productId: action.productId,
|
||||||
requestedVariantId: action.variantId,
|
requestedVariantId: action.variantId,
|
||||||
quantity: action.quantity,
|
quantity: action.quantity,
|
||||||
message: customerContent,
|
message: customerContent,
|
||||||
existingCartLines,
|
existingCartLines,
|
||||||
|
attributionSource: action.type === "upsell_suggestion" ? "upsell" : "primary",
|
||||||
})
|
})
|
||||||
: pendingCartProduct && isCartConfirmationMessage(customerContent)
|
: pendingCartProduct && isCartConfirmationMessage(customerContent)
|
||||||
? await buildCartResponse({
|
? await buildCartResponse({
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
|
conversationId: conversation.id,
|
||||||
shopifyDomain: store.shopifyDomain,
|
shopifyDomain: store.shopifyDomain,
|
||||||
productId: pendingCartProduct.productId,
|
productId: pendingCartProduct.productId,
|
||||||
requestedVariantId: pendingCartProduct.variantId,
|
requestedVariantId: pendingCartProduct.variantId,
|
||||||
quantity: pendingCartProduct.quantity,
|
quantity: pendingCartProduct.quantity,
|
||||||
message: customerContent,
|
message: customerContent,
|
||||||
existingCartLines,
|
existingCartLines,
|
||||||
|
attributionSource: "primary",
|
||||||
})
|
})
|
||||||
: await resolveDirectAddToCart({
|
: await resolveDirectAddToCart({
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
|
conversationId: conversation.id,
|
||||||
shopifyDomain: store.shopifyDomain,
|
shopifyDomain: store.shopifyDomain,
|
||||||
message: customerContent,
|
message: customerContent,
|
||||||
existingCartLines,
|
existingCartLines,
|
||||||
|
|||||||
@ -13,6 +13,11 @@ import {
|
|||||||
sharedFieldAnswersBodySchema,
|
sharedFieldAnswersBodySchema,
|
||||||
sharedFieldAnswersPayload,
|
sharedFieldAnswersPayload,
|
||||||
} from "@/lib/kb/sharedFieldAnswers"
|
} from "@/lib/kb/sharedFieldAnswers"
|
||||||
|
import {
|
||||||
|
customAiModulesBodySchema,
|
||||||
|
customAiModulesPayload,
|
||||||
|
parseCustomAiModulesJson,
|
||||||
|
} from "@/lib/kb/customAiModules"
|
||||||
import { replaceStructuredKnowledgeChunksForStore } from "@/lib/kb/structuredTemplateChunks"
|
import { replaceStructuredKnowledgeChunksForStore } from "@/lib/kb/structuredTemplateChunks"
|
||||||
import {
|
import {
|
||||||
getPublishedThemeId,
|
getPublishedThemeId,
|
||||||
@ -571,8 +576,8 @@ async function syncStorefrontTheme(params: {
|
|||||||
const updated = params.enabled
|
const updated = params.enabled
|
||||||
? upsertThemeLiquidInjection(themeLiquid.data.value, assetFilename)
|
? upsertThemeLiquidInjection(themeLiquid.data.value, assetFilename)
|
||||||
: removeThemeLiquidInjection(themeLiquid.data.value)
|
: removeThemeLiquidInjection(themeLiquid.data.value)
|
||||||
if (!updated.updated && (updated as any).error) {
|
if (!updated.updated && "error" in updated) {
|
||||||
throw new Error((updated as any).error)
|
throw new Error(String(updated.error))
|
||||||
}
|
}
|
||||||
if (updated.updated) {
|
if (updated.updated) {
|
||||||
const putThemeLiquid = await putThemeAsset(cleanDomain, accessToken, apiVersion, themeId, themeLiquidKey, updated.next)
|
const putThemeLiquid = await putThemeAsset(cleanDomain, accessToken, apiVersion, themeId, themeLiquidKey, updated.next)
|
||||||
@ -610,6 +615,7 @@ export async function GET(
|
|||||||
aiModules: unknown
|
aiModules: unknown
|
||||||
modulePolicies?: unknown
|
modulePolicies?: unknown
|
||||||
sharedFieldAnswers?: unknown
|
sharedFieldAnswers?: unknown
|
||||||
|
customAiModules?: unknown
|
||||||
gmailReplyTheme?: unknown
|
gmailReplyTheme?: unknown
|
||||||
lowStockThreshold?: number | null
|
lowStockThreshold?: number | null
|
||||||
lowStockShopifyThreshold?: number | null
|
lowStockShopifyThreshold?: number | null
|
||||||
@ -644,6 +650,7 @@ export async function GET(
|
|||||||
aiModules: store.aiModules,
|
aiModules: store.aiModules,
|
||||||
modulePolicies: parseModulePoliciesJson(store.modulePolicies ?? null),
|
modulePolicies: parseModulePoliciesJson(store.modulePolicies ?? null),
|
||||||
sharedFieldAnswers: parseSharedFieldAnswersJson(store.sharedFieldAnswers ?? null),
|
sharedFieldAnswers: parseSharedFieldAnswersJson(store.sharedFieldAnswers ?? null),
|
||||||
|
customAiModules: parseCustomAiModulesJson(store.customAiModules ?? null),
|
||||||
gmailReplyTheme:
|
gmailReplyTheme:
|
||||||
store.gmailReplyTheme === undefined
|
store.gmailReplyTheme === undefined
|
||||||
? undefined
|
? undefined
|
||||||
@ -704,6 +711,7 @@ export async function PATCH(
|
|||||||
lowStockThreshold?: number | null
|
lowStockThreshold?: number | null
|
||||||
lowStockShopifyThreshold?: number | null
|
lowStockShopifyThreshold?: number | null
|
||||||
storefrontOtpSessionMinutes?: number
|
storefrontOtpSessionMinutes?: number
|
||||||
|
customAiModules?: Prisma.InputJsonValue | typeof Prisma.JsonNull
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatePayload: StorePatchPayload = {
|
const updatePayload: StorePatchPayload = {
|
||||||
@ -760,6 +768,20 @@ export async function PATCH(
|
|||||||
reembedStructured = true
|
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 !== undefined) {
|
||||||
if (data.gmailReplyTheme === null) {
|
if (data.gmailReplyTheme === null) {
|
||||||
updatePayload.gmailReplyTheme = Prisma.JsonNull
|
updatePayload.gmailReplyTheme = Prisma.JsonNull
|
||||||
@ -880,6 +902,9 @@ export async function PATCH(
|
|||||||
aiModules: updatedStore.aiModules,
|
aiModules: updatedStore.aiModules,
|
||||||
modulePolicies: parseModulePoliciesJson(updatedStore.modulePolicies),
|
modulePolicies: parseModulePoliciesJson(updatedStore.modulePolicies),
|
||||||
sharedFieldAnswers: parseSharedFieldAnswersJson(updatedStore.sharedFieldAnswers),
|
sharedFieldAnswers: parseSharedFieldAnswersJson(updatedStore.sharedFieldAnswers),
|
||||||
|
customAiModules: parseCustomAiModulesJson(
|
||||||
|
(updatedStore as { customAiModules?: unknown }).customAiModules,
|
||||||
|
),
|
||||||
gmailReplyTheme:
|
gmailReplyTheme:
|
||||||
updatedStore.gmailReplyTheme == null
|
updatedStore.gmailReplyTheme == null
|
||||||
? null
|
? null
|
||||||
|
|||||||
@ -26,24 +26,35 @@ import {
|
|||||||
} from "@/lib/ai/orchestrator/moduleThreeTypes"
|
} from "@/lib/ai/orchestrator/moduleThreeTypes"
|
||||||
import { parseSharedFieldAnswersJson, sharedFieldAnswersPayload } from "@/lib/kb/sharedFieldAnswers"
|
import { parseSharedFieldAnswersJson, sharedFieldAnswersPayload } from "@/lib/kb/sharedFieldAnswers"
|
||||||
import { KNOWLEDGE_TEMPLATE_ANSWER_MAX } from "@/lib/kb/knowledgeTemplate"
|
import { KNOWLEDGE_TEMPLATE_ANSWER_MAX } from "@/lib/kb/knowledgeTemplate"
|
||||||
|
import {
|
||||||
|
customAiModulesPayload,
|
||||||
|
parseCustomAiModulesJson,
|
||||||
|
type CustomAiModule,
|
||||||
|
} from "@/lib/kb/customAiModules"
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Eye,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Info,
|
Info,
|
||||||
Layers,
|
Layers,
|
||||||
Loader2,
|
Loader2,
|
||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
Ruler,
|
Ruler,
|
||||||
Save,
|
Save,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
|
Trash2,
|
||||||
UserCircle,
|
UserCircle,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
const FAQ_CARD_ID = "faq"
|
const FAQ_CARD_ID = "faq"
|
||||||
const CATEGORY_CARD_ID = "category_metadetails"
|
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. */
|
/** Single grid order — no section groupings on the dashboard. */
|
||||||
const DASHBOARD_CARD_ORDER: Array<
|
const DASHBOARD_CARD_ORDER: Array<
|
||||||
@ -79,7 +90,13 @@ function categoryAutomation(on: boolean): AutomationFlags {
|
|||||||
return { knowledge: false, verifiedLookup: on, ticket: false }
|
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)
|
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() {
|
export default function JunoEngineModulesPage() {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const isLight = theme === "light"
|
const isLight = theme === "light"
|
||||||
@ -120,6 +154,11 @@ export default function JunoEngineModulesPage() {
|
|||||||
parseAiModulesJson(null),
|
parseAiModulesJson(null),
|
||||||
)
|
)
|
||||||
const [sharedFields, setSharedFields] = useState<Record<string, string>>({})
|
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 [categoryMetaEnabled, setCategoryMetaEnabled] = useState(false)
|
||||||
const [modalId, setModalId] = useState<ModalId>(null)
|
const [modalId, setModalId] = useState<ModalId>(null)
|
||||||
const [modalSaveError, setModalSaveError] = useState<string | null>(null)
|
const [modalSaveError, setModalSaveError] = useState<string | null>(null)
|
||||||
@ -142,6 +181,7 @@ export default function JunoEngineModulesPage() {
|
|||||||
const data = raw as {
|
const data = raw as {
|
||||||
aiModules?: unknown
|
aiModules?: unknown
|
||||||
sharedFieldAnswers?: unknown
|
sharedFieldAnswers?: unknown
|
||||||
|
customAiModules?: unknown
|
||||||
categoryMetadetailsEnabled?: boolean
|
categoryMetadetailsEnabled?: boolean
|
||||||
}
|
}
|
||||||
const parsedMods = parseAiModulesJson(data.aiModules)
|
const parsedMods = parseAiModulesJson(data.aiModules)
|
||||||
@ -150,6 +190,7 @@ export default function JunoEngineModulesPage() {
|
|||||||
setMods(parsedMods)
|
setMods(parsedMods)
|
||||||
setCategoryMetaEnabled(catOn)
|
setCategoryMetaEnabled(catOn)
|
||||||
setSharedFields(parseSharedFieldAnswersJson(data.sharedFieldAnswers))
|
setSharedFields(parseSharedFieldAnswersJson(data.sharedFieldAnswers))
|
||||||
|
setCustomModules(parseCustomAiModulesJson(data.customAiModules))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -202,12 +243,29 @@ export default function JunoEngineModulesPage() {
|
|||||||
if (!modalId) return
|
if (!modalId) return
|
||||||
setModalSaveError(null)
|
setModalSaveError(null)
|
||||||
setModuleInfoOpen(false)
|
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) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") setModalId(null)
|
if (e.key === "Escape") setModalId(null)
|
||||||
}
|
}
|
||||||
window.addEventListener("keydown", onKey)
|
window.addEventListener("keydown", onKey)
|
||||||
return () => window.removeEventListener("keydown", onKey)
|
return () => window.removeEventListener("keydown", onKey)
|
||||||
}, [modalId])
|
}, [customModules, modalId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (modalId) {
|
if (modalId) {
|
||||||
@ -220,7 +278,9 @@ export default function JunoEngineModulesPage() {
|
|||||||
}
|
}
|
||||||
}, [modalId])
|
}, [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" }
|
if (!activeStoreId) return { ok: false, error: "No store selected" }
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setToast(null)
|
setToast(null)
|
||||||
@ -236,12 +296,14 @@ export default function JunoEngineModulesPage() {
|
|||||||
aiModules: serializeAiModules(mergedMods),
|
aiModules: serializeAiModules(mergedMods),
|
||||||
categoryMetadetailsEnabled: categoryMetaEnabled,
|
categoryMetadetailsEnabled: categoryMetaEnabled,
|
||||||
sharedFieldAnswers: sharedFieldAnswersPayload(sharedFields),
|
sharedFieldAnswers: sharedFieldAnswersPayload(sharedFields),
|
||||||
|
customAiModules: customAiModulesPayload(customModuleOverride ?? customModules),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const j = (await res.json().catch(() => ({}))) as {
|
const j = (await res.json().catch(() => ({}))) as {
|
||||||
message?: string
|
message?: string
|
||||||
aiModules?: unknown
|
aiModules?: unknown
|
||||||
sharedFieldAnswers?: unknown
|
sharedFieldAnswers?: unknown
|
||||||
|
customAiModules?: unknown
|
||||||
categoryMetadetailsEnabled?: boolean
|
categoryMetadetailsEnabled?: boolean
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error(j.message || "Save failed")
|
if (!res.ok) throw new Error(j.message || "Save failed")
|
||||||
@ -251,6 +313,7 @@ export default function JunoEngineModulesPage() {
|
|||||||
setMods(nextMods)
|
setMods(nextMods)
|
||||||
setCategoryMetaEnabled(catOn)
|
setCategoryMetaEnabled(catOn)
|
||||||
setSharedFields(parseSharedFieldAnswersJson(j.sharedFieldAnswers))
|
setSharedFields(parseSharedFieldAnswersJson(j.sharedFieldAnswers))
|
||||||
|
setCustomModules(parseCustomAiModulesJson(j.customAiModules))
|
||||||
setToast("Saved.")
|
setToast("Saved.")
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
} catch (e) {
|
} 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 =
|
const modalModule: Exclude<L1Module, "UNKNOWN"> | null =
|
||||||
modalId && isModuleModalId(modalId) ? modalId : 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 =
|
const infoSpecMod: Exclude<L1Module, "UNKNOWN"> | null =
|
||||||
modalId === FAQ_CARD_ID
|
modalId === FAQ_CARD_ID
|
||||||
? "FAQ"
|
? "FAQ"
|
||||||
@ -589,6 +988,10 @@ export default function JunoEngineModulesPage() {
|
|||||||
? L1_MODULE_CARD_TITLE.FAQ
|
? L1_MODULE_CARD_TITLE.FAQ
|
||||||
: modalId === CATEGORY_CARD_ID
|
: modalId === CATEGORY_CARD_ID
|
||||||
? L1_MODULE_CARD_TITLE.CATEGORY_METADETAILS
|
? L1_MODULE_CARD_TITLE.CATEGORY_METADETAILS
|
||||||
|
: isCustomModuleModal
|
||||||
|
? modalId === ADD_CUSTOM_MODULE_ID
|
||||||
|
? "Add Module"
|
||||||
|
: draftCustomModule.title || "Custom module"
|
||||||
: modalModule
|
: modalModule
|
||||||
? L1_MODULE_CARD_TITLE[modalModule]
|
? L1_MODULE_CARD_TITLE[modalModule]
|
||||||
: "Module"}
|
: "Module"}
|
||||||
@ -603,12 +1006,53 @@ export default function JunoEngineModulesPage() {
|
|||||||
? L1_MODULE_CUSTOMER_LABEL.FAQ
|
? L1_MODULE_CUSTOMER_LABEL.FAQ
|
||||||
: modalId === CATEGORY_CARD_ID
|
: modalId === CATEGORY_CARD_ID
|
||||||
? L1_MODULE_CUSTOMER_LABEL.CATEGORY_METADETAILS
|
? L1_MODULE_CUSTOMER_LABEL.CATEGORY_METADETAILS
|
||||||
|
: isCustomModuleModal
|
||||||
|
? "Custom knowledge module embedded into storefront AI answers."
|
||||||
: modalModule
|
: modalModule
|
||||||
? L1_MODULE_CUSTOMER_LABEL[modalModule]
|
? L1_MODULE_CUSTOMER_LABEL[modalModule]
|
||||||
: ""}
|
: ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-1 shrink-0 relative">
|
<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 ? (
|
{hasModuleInfoPanel && infoSpecMod ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@ -668,6 +1112,8 @@ export default function JunoEngineModulesPage() {
|
|||||||
{renderAutomationRows(modalModule)}
|
{renderAutomationRows(modalModule)}
|
||||||
{renderPolicyFields(modalModule)}
|
{renderPolicyFields(modalModule)}
|
||||||
</>
|
</>
|
||||||
|
) : isCustomModuleModal ? (
|
||||||
|
renderCustomModuleEditor()
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -689,6 +1135,22 @@ export default function JunoEngineModulesPage() {
|
|||||||
isLight ? "border-slate-100 bg-slate-50/80" : "border-white/10 bg-slate-950/50",
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setModalId(null)}
|
onClick={() => setModalId(null)}
|
||||||
@ -707,6 +1169,10 @@ export default function JunoEngineModulesPage() {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
void (async () => {
|
void (async () => {
|
||||||
setModalSaveError(null)
|
setModalSaveError(null)
|
||||||
|
if (isCustomModuleModal) {
|
||||||
|
await saveDraftCustomModule()
|
||||||
|
return
|
||||||
|
}
|
||||||
const r = await saveChanges()
|
const r = await saveChanges()
|
||||||
if (r.ok) setModalId(null)
|
if (r.ok) setModalId(null)
|
||||||
else if (r.error) setModalSaveError(r.error)
|
else if (r.error) setModalSaveError(r.error)
|
||||||
@ -719,7 +1185,7 @@ export default function JunoEngineModulesPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Save className="w-4 h-4" />
|
<Save className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
Save changes
|
{isCustomModuleModal ? "Save module" : "Save changes"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -893,6 +1359,12 @@ export default function JunoEngineModulesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{customModules.map((module) => (
|
||||||
|
<div key={module.id} className="contents">
|
||||||
|
{renderCustomModuleCard(module)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="contents">{renderAddModuleCard()}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{modal}
|
{modal}
|
||||||
|
|||||||
@ -105,4 +105,62 @@ describe("revenueAttribution", () => {
|
|||||||
expect(result.directUpsellRevenue).toBe(0)
|
expect(result.directUpsellRevenue).toBe(0)
|
||||||
expect(result.directRows).toHaveLength(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
|
totalPrice: string
|
||||||
currency: string | null
|
currency: string | null
|
||||||
lineItems: unknown
|
lineItems: unknown
|
||||||
|
noteAttributes?: unknown
|
||||||
shopifyCreatedAt: Date | null
|
shopifyCreatedAt: Date | null
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
}
|
}
|
||||||
@ -93,7 +94,24 @@ function orderDate(order: OrderLike): Date {
|
|||||||
return order.shopifyCreatedAt ?? order.createdAt
|
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) {
|
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) {
|
if (conversation.boundShopifyOrderId && conversation.boundShopifyOrderId === order.shopifyOrderId) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ interface ShopifyOrderRecord {
|
|||||||
fulfillment_status?: string
|
fulfillment_status?: string
|
||||||
customer?: unknown
|
customer?: unknown
|
||||||
line_items?: unknown[]
|
line_items?: unknown[]
|
||||||
|
note_attributes?: unknown[]
|
||||||
created_at?: string
|
created_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,7 +137,7 @@ export async function updateProductCache(storeId: string, products: ShopifyProdu
|
|||||||
select: { shopifyDomain: true, shopifyAccessToken: true },
|
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) {
|
if (store?.shopifyDomain && store?.shopifyAccessToken && products.length > 0) {
|
||||||
try {
|
try {
|
||||||
const ids = products.map((p) => p.id)
|
const ids = products.map((p) => p.id)
|
||||||
@ -279,9 +280,10 @@ export async function updateOrderCache(storeId: string, orders: ShopifyOrderReco
|
|||||||
financialStatus: order.financial_status || null,
|
financialStatus: order.financial_status || null,
|
||||||
fulfillmentStatus: order.fulfillment_status || null,
|
fulfillmentStatus: order.fulfillment_status || null,
|
||||||
lineItems: (order.line_items || []) as Prisma.InputJsonValue,
|
lineItems: (order.line_items || []) as Prisma.InputJsonValue,
|
||||||
|
noteAttributes: (order.note_attributes || []) as Prisma.InputJsonValue,
|
||||||
customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined,
|
customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined,
|
||||||
shopifyCreatedAt: order.created_at ? new Date(order.created_at) : null
|
shopifyCreatedAt: order.created_at ? new Date(order.created_at) : null
|
||||||
},
|
} as Prisma.CachedOrderUncheckedCreateInput,
|
||||||
update: {
|
update: {
|
||||||
orderNumber: order.order_number?.toString() || order.name || "",
|
orderNumber: order.order_number?.toString() || order.name || "",
|
||||||
email: extractOrderEmail(order),
|
email: extractOrderEmail(order),
|
||||||
@ -295,10 +297,11 @@ export async function updateOrderCache(storeId: string, orders: ShopifyOrderReco
|
|||||||
? existingById.get(order.id.toString())?.fulfillmentStatus || null
|
? existingById.get(order.id.toString())?.fulfillmentStatus || null
|
||||||
: order.fulfillment_status || null,
|
: order.fulfillment_status || null,
|
||||||
lineItems: (order.line_items || []) as Prisma.InputJsonValue,
|
lineItems: (order.line_items || []) as Prisma.InputJsonValue,
|
||||||
|
noteAttributes: (order.note_attributes || []) as Prisma.InputJsonValue,
|
||||||
customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined,
|
customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined,
|
||||||
shopifyCreatedAt: order.created_at ? new Date(order.created_at) : null,
|
shopifyCreatedAt: order.created_at ? new Date(order.created_at) : null,
|
||||||
lastSyncedAt: new Date()
|
lastSyncedAt: new Date()
|
||||||
}
|
} as Prisma.CachedOrderUncheckedUpdateInput
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -531,10 +534,11 @@ export async function updateSingleOrder(storeId: string, order: ShopifyOrderReco
|
|||||||
fulfillmentStatus:
|
fulfillmentStatus:
|
||||||
shouldPreserve || hasSupplierHold ? preservedStatus : order.fulfillment_status || null,
|
shouldPreserve || hasSupplierHold ? preservedStatus : order.fulfillment_status || null,
|
||||||
lineItems: (order.line_items || []) as Prisma.InputJsonValue,
|
lineItems: (order.line_items || []) as Prisma.InputJsonValue,
|
||||||
|
noteAttributes: (order.note_attributes || []) as Prisma.InputJsonValue,
|
||||||
customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined,
|
customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined,
|
||||||
shopifyCreatedAt: order.created_at ? new Date(order.created_at) : null,
|
shopifyCreatedAt: order.created_at ? new Date(order.created_at) : null,
|
||||||
lastSyncedAt: new Date()
|
lastSyncedAt: new Date()
|
||||||
},
|
} as Prisma.CachedOrderUncheckedCreateInput,
|
||||||
update: {
|
update: {
|
||||||
orderNumber: order.order_number?.toString() || order.name || "",
|
orderNumber: order.order_number?.toString() || order.name || "",
|
||||||
email: extractOrderEmail(order),
|
email: extractOrderEmail(order),
|
||||||
@ -544,9 +548,10 @@ export async function updateSingleOrder(storeId: string, order: ShopifyOrderReco
|
|||||||
fulfillmentStatus:
|
fulfillmentStatus:
|
||||||
shouldPreserve || hasSupplierHold ? preservedStatus : order.fulfillment_status || null,
|
shouldPreserve || hasSupplierHold ? preservedStatus : order.fulfillment_status || null,
|
||||||
lineItems: (order.line_items || []) as Prisma.InputJsonValue,
|
lineItems: (order.line_items || []) as Prisma.InputJsonValue,
|
||||||
|
noteAttributes: (order.note_attributes || []) as Prisma.InputJsonValue,
|
||||||
customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined,
|
customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined,
|
||||||
shopifyCreatedAt: order.created_at ? new Date(order.created_at) : null,
|
shopifyCreatedAt: order.created_at ? new Date(order.created_at) : null,
|
||||||
lastSyncedAt: new Date()
|
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 { prisma } from "@/lib/prisma"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
|
import { Prisma } from "@prisma/client"
|
||||||
import { embedBatch } from "@/lib/kb/openaiEmbeddings"
|
import { embedBatch } from "@/lib/kb/openaiEmbeddings"
|
||||||
import { kbEmbeddingMetadata } from "@/lib/kb/embeddingMeta"
|
import { kbEmbeddingMetadata } from "@/lib/kb/embeddingMeta"
|
||||||
import {
|
import {
|
||||||
@ -9,10 +10,19 @@ import {
|
|||||||
} from "@/lib/kb/knowledgeTemplate"
|
} from "@/lib/kb/knowledgeTemplate"
|
||||||
import { parseModulePoliciesJson } from "@/lib/kb/modulePolicies"
|
import { parseModulePoliciesJson } from "@/lib/kb/modulePolicies"
|
||||||
import { parseSharedFieldAnswersJson } from "@/lib/kb/sharedFieldAnswers"
|
import { parseSharedFieldAnswersJson } from "@/lib/kb/sharedFieldAnswers"
|
||||||
|
import { parseCustomAiModulesJson } from "@/lib/kb/customAiModules"
|
||||||
import { L1_MODULE_CUSTOMER_LABEL } from "@/lib/ai/orchestrator/vendorModuleGroups"
|
import { L1_MODULE_CUSTOMER_LABEL } from "@/lib/ai/orchestrator/vendorModuleGroups"
|
||||||
import { SHARED_FIELD_SHORT_LABEL } from "@/lib/ai/orchestrator/moduleThreeTypes"
|
import { SHARED_FIELD_SHORT_LABEL } from "@/lib/ai/orchestrator/moduleThreeTypes"
|
||||||
import type { L1Module } from "@/lib/ai/orchestrator/types"
|
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.
|
* Rebuild STRUCTURED knowledge chunks from the store's v3 template + modulePolicies + sharedFieldAnswers.
|
||||||
* Clears STRUCTURED rows when the store is not in STRUCTURED mode.
|
* Clears STRUCTURED rows when the store is not in STRUCTURED mode.
|
||||||
@ -23,73 +33,106 @@ export async function replaceStructuredKnowledgeChunksForStore(storeId: string)
|
|||||||
storeId,
|
storeId,
|
||||||
)
|
)
|
||||||
|
|
||||||
const store = await prisma.store.findFirst({
|
const store = (await prisma.store.findFirst({
|
||||||
where: { id: storeId },
|
where: { id: storeId },
|
||||||
select: {
|
select: {
|
||||||
knowledgeBaseMode: true,
|
knowledgeBaseMode: true,
|
||||||
knowledgeTemplate: true,
|
knowledgeTemplate: true,
|
||||||
modulePolicies: true,
|
modulePolicies: true,
|
||||||
sharedFieldAnswers: true,
|
sharedFieldAnswers: true,
|
||||||
},
|
customAiModules: true,
|
||||||
})
|
} as unknown as Prisma.StoreSelect,
|
||||||
|
})) as StoreStructuredKnowledgeRow | null
|
||||||
|
|
||||||
if (!store || store.knowledgeBaseMode !== "STRUCTURED") {
|
if (!store) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const stored: KnowledgeTemplateStored =
|
const customModules = parseCustomAiModulesJson(store.customAiModules)
|
||||||
parseKnowledgeTemplateJson(store.knowledgeTemplate) ?? {
|
const hasEnabledCustomKnowledge = customModules.some(
|
||||||
version: 3,
|
(module) => module.enabled && module.answers.length > 0,
|
||||||
genericAnswers: {},
|
)
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
|
|
||||||
const modulePolicies = parseModulePoliciesJson(store.modulePolicies)
|
if (store.knowledgeBaseMode !== "STRUCTURED" && !hasEnabledCustomKnowledge) {
|
||||||
const sharedFieldAnswers = parseSharedFieldAnswersJson(store.sharedFieldAnswers)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const rows: {
|
const rows: {
|
||||||
content: string
|
content: string
|
||||||
metadata: ReturnType<typeof kbEmbeddingMetadata>
|
metadata: ReturnType<typeof kbEmbeddingMetadata>
|
||||||
}[] = []
|
}[] = []
|
||||||
|
|
||||||
for (const field of STORE_VOICE_FIELDS) {
|
if (store.knowledgeBaseMode === "STRUCTURED") {
|
||||||
const text = stored.genericAnswers[field.key]
|
const stored: KnowledgeTemplateStored =
|
||||||
if (text?.trim()) {
|
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({
|
rows.push({
|
||||||
content: `Q: ${field.label}\nA: ${text.trim()}`,
|
content: `Q: ${title} (store policy)\nA: ${text.trim()}`,
|
||||||
metadata: kbEmbeddingMetadata({
|
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",
|
moduleCode: "ALL",
|
||||||
fieldId: field.key,
|
fieldId,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [mod, text] of Object.entries(modulePolicies) as [L1Module, string][]) {
|
for (const customModule of customModules) {
|
||||||
if (!text?.trim()) continue
|
if (!customModule.enabled) continue
|
||||||
const title = L1_MODULE_CUSTOMER_LABEL[mod] ?? mod
|
for (const answer of customModule.answers) {
|
||||||
rows.push({
|
rows.push({
|
||||||
content: `Q: ${title} (store policy)\nA: ${text.trim()}`,
|
content: [
|
||||||
metadata: kbEmbeddingMetadata({
|
`Custom module: ${customModule.title}`,
|
||||||
sourceKey: `policy_${mod}`,
|
customModule.subtitle ? `Module subtitle: ${customModule.subtitle}` : "",
|
||||||
moduleCode: mod,
|
`Q: ${answer.subtitle}`,
|
||||||
fieldId: `policy_${mod}`,
|
`A: ${answer.answer}`,
|
||||||
}),
|
]
|
||||||
})
|
.filter(Boolean)
|
||||||
}
|
.join("\n"),
|
||||||
|
metadata: kbEmbeddingMetadata({
|
||||||
for (const [fieldId, text] of Object.entries(sharedFieldAnswers)) {
|
sourceKey: `custom_module_${customModule.id}_${answer.id}`,
|
||||||
if (!text?.trim()) continue
|
moduleCode: "CUSTOM",
|
||||||
const lab = SHARED_FIELD_SHORT_LABEL[fieldId] || fieldId
|
fieldId: answer.id,
|
||||||
rows.push({
|
}),
|
||||||
content: `Q: ${lab} (${fieldId})\nA: ${text.trim()}`,
|
})
|
||||||
metadata: kbEmbeddingMetadata({
|
}
|
||||||
sourceKey: fieldId,
|
|
||||||
moduleCode: "ALL",
|
|
||||||
fieldId,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
|
|||||||
@ -42,6 +42,7 @@ export const storeSelectVendorSettingsGetFull = {
|
|||||||
aiModules: true,
|
aiModules: true,
|
||||||
modulePolicies: true,
|
modulePolicies: true,
|
||||||
sharedFieldAnswers: true,
|
sharedFieldAnswers: true,
|
||||||
|
customAiModules: true,
|
||||||
gmailReplyTheme: true,
|
gmailReplyTheme: true,
|
||||||
lowStockThreshold: true,
|
lowStockThreshold: true,
|
||||||
lowStockShopifyThreshold: true,
|
lowStockShopifyThreshold: true,
|
||||||
@ -71,6 +72,7 @@ export const storeSelectPatchStorefrontAi = {
|
|||||||
aiModules: true,
|
aiModules: true,
|
||||||
modulePolicies: true,
|
modulePolicies: true,
|
||||||
sharedFieldAnswers: true,
|
sharedFieldAnswers: true,
|
||||||
|
customAiModules: true,
|
||||||
gmailReplyTheme: true,
|
gmailReplyTheme: true,
|
||||||
lowStockThreshold: true,
|
lowStockThreshold: true,
|
||||||
lowStockShopifyThreshold: true,
|
lowStockShopifyThreshold: true,
|
||||||
|
|||||||
@ -146,6 +146,7 @@ export function selectVariantForCart(params: {
|
|||||||
export function buildCartPermalink(
|
export function buildCartPermalink(
|
||||||
shopifyDomain: string,
|
shopifyDomain: string,
|
||||||
lines: Array<{ variantId: string; quantity?: number }>,
|
lines: Array<{ variantId: string; quantity?: number }>,
|
||||||
|
attributes?: Record<string, string | number | boolean | null | undefined>,
|
||||||
) {
|
) {
|
||||||
const cleanDomain = normalizeShopDomain(shopifyDomain)
|
const cleanDomain = normalizeShopDomain(shopifyDomain)
|
||||||
const encodedLines = lines
|
const encodedLines = lines
|
||||||
@ -158,7 +159,13 @@ export function buildCartPermalink(
|
|||||||
.filter((line): line is string => Boolean(line))
|
.filter((line): line is string => Boolean(line))
|
||||||
|
|
||||||
if (!cleanDomain || encodedLines.length === 0) return null
|
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[]) {
|
export function formatVariantChoices(variants: CartableVariant[]) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user