317 lines
16 KiB
TypeScript
317 lines
16 KiB
TypeScript
import type { AxiosInstance } from "axios";
|
|
import { PAGE_SECTION_TYPES } from "@homaroma/types";
|
|
|
|
const localizedStringSchema = {
|
|
oneOf: [
|
|
{ type: "string" },
|
|
{
|
|
type: "object",
|
|
additionalProperties: { type: "string" }
|
|
}
|
|
]
|
|
};
|
|
|
|
const localizedStringListSchema = {
|
|
type: "array",
|
|
items: localizedStringSchema
|
|
};
|
|
|
|
const localizedStringExample = '{ "th": "ข้อความภาษาไทย", "en": "English text" }';
|
|
const localizedStringListExample = `[ ${localizedStringExample} ]`;
|
|
|
|
const SECTION_TOOL_CONFIGS = [
|
|
{
|
|
name: "update_hero_article",
|
|
sectionType: "HERO_ARTICLE",
|
|
description: `Update a HERO_ARTICLE section. Use localized objects for title, subtitle, author, and category. Use plain strings for image URLs, overlayColor, publishDate/date, and buttonLink. Example payload: { "sectionId": 12, "title": ${localizedStringExample}, "subtitle": ${localizedStringExample}, "author": ${localizedStringExample}, "imageUrl": "https://..." }`,
|
|
properties: {
|
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
|
title: { ...localizedStringSchema, description: `Localized article title, for example ${localizedStringExample}` },
|
|
subtitle: { ...localizedStringSchema, description: `Localized subtitle, for example ${localizedStringExample}` },
|
|
author: { ...localizedStringSchema, description: `Localized author name, for example ${localizedStringExample}` },
|
|
category: { ...localizedStringSchema, description: `Localized category or tag, for example ${localizedStringExample}` },
|
|
publishDate: { type: "string", description: "Plain string publish date or display date" },
|
|
date: { type: "string", description: "Plain string alternative date field accepted by backend" },
|
|
imageUrl: { type: "string", description: "Plain string main image URL" },
|
|
backgroundImageUrl: { type: "string", description: "Plain string background image URL" },
|
|
overlayColor: { type: "string", description: "Plain string overlay color in hex or rgba" }
|
|
},
|
|
required: ["sectionId"]
|
|
},
|
|
{
|
|
name: "update_rich_text",
|
|
sectionType: "RICH_TEXT",
|
|
description: `Update a RICH_TEXT section. Title is optional and localized. Content is required and may be HTML or plain text per language. Example payload: { "sectionId": 34, "title": ${localizedStringExample}, "content": ${localizedStringExample} }`,
|
|
properties: {
|
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
|
title: { ...localizedStringSchema, description: `Localized title displayed above the content, for example ${localizedStringExample}` },
|
|
content: { ...localizedStringSchema, description: `Localized HTML or plain text content, for example ${localizedStringExample}` }
|
|
},
|
|
required: ["sectionId", "content"]
|
|
},
|
|
{
|
|
name: "update_scent_notes_breakdown",
|
|
sectionType: "SCENT_NOTES_BREAKDOWN",
|
|
description: `Update a SCENT_NOTES_BREAKDOWN section. Title and description are localized. Each note item can be a plain string or a localized object. Use either heartNotes or middleNotes; the backend mirrors whichever one you provide so both stay in sync. Example payload: { "sectionId": 40, "title": ${localizedStringExample}, "topNotes": ${localizedStringListExample} }`,
|
|
properties: {
|
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
|
title: { ...localizedStringSchema, description: `Localized title, for example ${localizedStringExample}` },
|
|
description: { ...localizedStringSchema, description: `Localized supporting description, for example ${localizedStringExample}` },
|
|
topNotes: { ...localizedStringListSchema, description: `Top note values, for example ${localizedStringListExample}` },
|
|
heartNotes: { ...localizedStringListSchema, description: `Heart note values. The server mirrors heartNotes and middleNotes, for example ${localizedStringListExample}` },
|
|
middleNotes: { ...localizedStringListSchema, description: `Middle note values. The server mirrors heartNotes and middleNotes, for example ${localizedStringListExample}` },
|
|
baseNotes: { ...localizedStringListSchema, description: `Base note values, for example ${localizedStringListExample}` }
|
|
},
|
|
required: ["sectionId"]
|
|
},
|
|
{
|
|
name: "update_quote_callout",
|
|
sectionType: "QUOTE_CALLOUT",
|
|
description: `Update a QUOTE_CALLOUT section. Quote is required and localized. Author and role are optional localized fields. Example payload: { "sectionId": 48, "quote": ${localizedStringExample}, "author": ${localizedStringExample}, "role": ${localizedStringExample} }`,
|
|
properties: {
|
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
|
quote: { ...localizedStringSchema, description: `Localized quote text, for example ${localizedStringExample}` },
|
|
author: { ...localizedStringSchema, description: `Localized author name, for example ${localizedStringExample}` },
|
|
role: { ...localizedStringSchema, description: `Localized author role or title, for example ${localizedStringExample}` }
|
|
},
|
|
required: ["sectionId"]
|
|
},
|
|
{
|
|
name: "update_product_carousel",
|
|
sectionType: "PRODUCT_CAROUSEL",
|
|
description: `Update a PRODUCT_CAROUSEL section. Title and description are localized. collectionId, limit, showPrice, showAddToCart, and products are plain values. If products is omitted, backend can auto-populate products on read-back using collectionId/type/productIds/limit. Example payload: { "sectionId": 52, "title": ${localizedStringExample}, "collectionId": "summer", "limit": 6, "showPrice": true }`,
|
|
properties: {
|
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
|
title: { ...localizedStringSchema, description: `Localized title, for example ${localizedStringExample}` },
|
|
description: { ...localizedStringSchema, description: `Localized supporting description, for example ${localizedStringExample}` },
|
|
collectionId: { type: "string", description: "Optional plain string collection identifier" },
|
|
limit: { type: "number", description: "Maximum number of products to show" },
|
|
showPrice: { type: "boolean", description: "Show product price" },
|
|
showAddToCart: { type: "boolean", description: "Show add to cart button" },
|
|
products: { type: "array", description: "Optional explicit product list. Omit this to let backend auto-populate on read-back.", items: { type: "object" } }
|
|
},
|
|
required: ["sectionId"]
|
|
},
|
|
{
|
|
name: "update_accordion",
|
|
sectionType: "ACCORDION",
|
|
description: `Update an ACCORDION section. title is optional and localized. categories is required. Each category needs a localized name, optional icon, and at least one question. Each question needs localized q and a fields. Example payload: { "sectionId": 60, "categories": [ { "name": ${localizedStringExample}, "questions": [ { "q": ${localizedStringExample}, "a": ${localizedStringExample} } ] } ] }`,
|
|
properties: {
|
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
|
title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` },
|
|
categories: {
|
|
type: "array",
|
|
description: "Accordion categories",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
name: { ...localizedStringSchema, description: `Localized category name, for example ${localizedStringExample}` },
|
|
icon: { type: "string", description: "Optional icon or emoji" },
|
|
questions: {
|
|
type: "array",
|
|
description: "Questions under the category",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
q: { ...localizedStringSchema, description: `Localized question, for example ${localizedStringExample}` },
|
|
a: { ...localizedStringSchema, description: `Localized answer, for example ${localizedStringExample}` }
|
|
},
|
|
required: ["q", "a"]
|
|
}
|
|
}
|
|
},
|
|
required: ["name", "questions"]
|
|
}
|
|
}
|
|
},
|
|
required: ["sectionId", "categories"]
|
|
},
|
|
{
|
|
name: "update_contact_info",
|
|
sectionType: "CONTACT_INFO",
|
|
description: `Update a CONTACT_INFO section. title is optional and localized. address and hours are localized text. email and line are plain strings. Example payload: { "sectionId": 72, "title": ${localizedStringExample}, "address": ${localizedStringExample}, "email": "hello@example.com", "line": "@homaroma", "hours": ${localizedStringExample} }`,
|
|
properties: {
|
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
|
title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` },
|
|
address: { ...localizedStringSchema, description: `Localized address, for example ${localizedStringExample}` },
|
|
email: { type: "string", description: "Plain string email address" },
|
|
line: { type: "string", description: "Plain string Line ID or contact" },
|
|
hours: { ...localizedStringSchema, description: `Localized business hours, for example ${localizedStringExample}` }
|
|
},
|
|
required: ["sectionId", "address", "email", "line", "hours"]
|
|
},
|
|
{
|
|
name: "update_contact_form",
|
|
sectionType: "CONTACT_FORM",
|
|
description: `Update a CONTACT_FORM section. title and subtitle are localized; this block has no extra fields. Example payload: { "sectionId": 73, "title": ${localizedStringExample}, "subtitle": ${localizedStringExample} }`,
|
|
properties: {
|
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
|
title: { ...localizedStringSchema, description: `Localized form title, for example ${localizedStringExample}` },
|
|
subtitle: { ...localizedStringSchema, description: `Localized form subtitle, for example ${localizedStringExample}` }
|
|
},
|
|
required: ["sectionId"]
|
|
}
|
|
] as const;
|
|
|
|
type SectionToolConfig = (typeof SECTION_TOOL_CONFIGS)[number];
|
|
|
|
const SECTION_TOOL_CONFIG_BY_NAME = new Map<string, SectionToolConfig>(
|
|
SECTION_TOOL_CONFIGS.map((config) => [config.name, config] as const)
|
|
);
|
|
|
|
function buildSectionPayload(params: any, fields: readonly string[]) {
|
|
const payload: Record<string, any> = {};
|
|
for (const field of fields) {
|
|
if (params[field] !== undefined) {
|
|
payload[field] = params[field];
|
|
}
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
function buildSectionUpdateResult(toolName: string, sectionType: string, updatedSection: any, payload: Record<string, any>) {
|
|
const notes: string[] = [];
|
|
if (sectionType === "SCENT_NOTES_BREAKDOWN") {
|
|
notes.push("heartNotes and middleNotes are mirrored automatically by the backend");
|
|
}
|
|
if (sectionType === "PRODUCT_CAROUSEL") {
|
|
notes.push("If products was omitted, the backend may populate products on read-back");
|
|
}
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify(
|
|
{
|
|
success: true,
|
|
tool: toolName,
|
|
sectionType,
|
|
sectionId: updatedSection?.id,
|
|
updatedFields: Object.keys(payload),
|
|
appliedPayload: payload,
|
|
notes,
|
|
section: updatedSection
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
export const SECTION_TOOL_DEFINITIONS = [
|
|
{
|
|
name: "add_page_section",
|
|
description: "Add a block section to a dynamic page layout",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
pageId: { type: "number", description: "The numeric ID of the page" },
|
|
type: { type: "string", enum: [...PAGE_SECTION_TYPES], description: "Block type (e.g. HERO, ANNOUNCEMENT_BAR, NEWSLETTER, EDITORIAL_TILE, EDITORIAL_CAROUSEL, PRODUCTS, CATEGORIES, TEXT, BANNER, HERO_ARTICLE, RICH_TEXT, SCENT_NOTES_BREAKDOWN, QUOTE_CALLOUT, PRODUCT_CAROUSEL)" },
|
|
order: { type: "number", description: "Display order sequence (starts at 1)" },
|
|
payload: { type: "object", description: "The JSON payload matching the section type rules" },
|
|
isActive: { type: "boolean", description: "Initial active state" }
|
|
},
|
|
required: ["pageId", "type", "payload"]
|
|
}
|
|
},
|
|
{
|
|
name: "update_page_section",
|
|
description: "Update an existing block section payload, order or active state",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
|
type: { type: "string", enum: [...PAGE_SECTION_TYPES], description: "Optional new block type" },
|
|
order: { type: "number", description: "Optional new display order sequence" },
|
|
payload: { type: "object", description: "Optional new JSON payload matching the section type rules" },
|
|
isActive: { type: "boolean", description: "Optional new active state" }
|
|
},
|
|
required: ["sectionId"]
|
|
}
|
|
},
|
|
...SECTION_TOOL_CONFIGS.map((config) => ({
|
|
name: config.name,
|
|
description: config.description,
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: config.properties,
|
|
required: config.required
|
|
}
|
|
})),
|
|
{
|
|
name: "delete_page_section",
|
|
description: "Delete a block section from a dynamic page layout",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
sectionId: { type: "number", description: "The numeric ID of the section to delete" }
|
|
},
|
|
required: ["sectionId"]
|
|
}
|
|
}
|
|
] as const;
|
|
|
|
export const SECTION_TOOL_NAMES = new Set<string>(SECTION_TOOL_DEFINITIONS.map((tool) => tool.name));
|
|
|
|
export async function handleSectionTool(api: AxiosInstance, name: string, params: any) {
|
|
if (!SECTION_TOOL_NAMES.has(name)) return null;
|
|
|
|
switch (name) {
|
|
case "add_page_section": {
|
|
const payload: any = {
|
|
type: params.type,
|
|
payload: params.payload,
|
|
isActive: params.isActive !== false
|
|
};
|
|
if (params.order !== undefined) payload.order = params.order;
|
|
const res = await api.post(`/pages/${params.pageId}/sections`, payload);
|
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
}
|
|
case "update_page_section": {
|
|
const payload: any = {};
|
|
if (params.type) payload.type = params.type;
|
|
if (params.order !== undefined) payload.order = params.order;
|
|
if (params.payload) payload.payload = params.payload;
|
|
if (params.isActive !== undefined) payload.isActive = params.isActive;
|
|
const res = await api.put(`/pages/sections/${params.sectionId}`, payload);
|
|
return buildSectionUpdateResult("update_page_section", res.data?.type ?? params.type ?? "UNKNOWN", res.data, payload);
|
|
}
|
|
case "update_hero_article":
|
|
case "update_rich_text":
|
|
case "update_scent_notes_breakdown":
|
|
case "update_quote_callout":
|
|
case "update_product_carousel":
|
|
case "update_accordion":
|
|
case "update_contact_info":
|
|
case "update_contact_form": {
|
|
const config = SECTION_TOOL_CONFIG_BY_NAME.get(name);
|
|
if (!config) {
|
|
throw new Error(`Unknown tool config for: ${name}`);
|
|
}
|
|
|
|
const payload = buildSectionPayload(params, Object.keys(config.properties).filter((key) => key !== "sectionId"));
|
|
if (config.sectionType === "SCENT_NOTES_BREAKDOWN") {
|
|
if (payload.heartNotes !== undefined && payload.middleNotes === undefined) {
|
|
payload.middleNotes = payload.heartNotes;
|
|
} else if (payload.middleNotes !== undefined && payload.heartNotes === undefined) {
|
|
payload.heartNotes = payload.middleNotes;
|
|
}
|
|
}
|
|
const res = await api.put(`/pages/sections/${params.sectionId}`, {
|
|
type: config.sectionType,
|
|
...payload
|
|
});
|
|
|
|
return buildSectionUpdateResult(name, config.sectionType, res.data, payload);
|
|
}
|
|
case "delete_page_section": {
|
|
const res = await api.delete(`/pages/sections/${params.sectionId}`);
|
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|