x
This commit is contained in:
1 parent
c84f7bb3ae
commit
ce6fa69b09
8 files changed
+1307
-613
No files matched your search
@@ -0,0 +1,495 @@
|
||||
import type { AxiosInstance } from "axios";
|
||||
import {
|
||||
applyBulkMenuOperations,
|
||||
applyMenuAddNode,
|
||||
applyMenuMoveNode,
|
||||
applyMenuRemoveNode,
|
||||
applyMenuUpdateTranslation,
|
||||
collectMenuLinksFromTree,
|
||||
createMenuSnapshot,
|
||||
describeMenuNode,
|
||||
normalizeMenuPayloadForWrite,
|
||||
restoreMenuSnapshot,
|
||||
validateMenuItems,
|
||||
validateMenuNodeData,
|
||||
validateMenuTranslations,
|
||||
validateMenuTreeSnapshot,
|
||||
cloneMenuValue,
|
||||
isPlainObject,
|
||||
} from "./menu-tools";
|
||||
|
||||
export const MENU_TOOL_DEFINITIONS = [
|
||||
{
|
||||
name: "get_menu",
|
||||
description: "Fetch a specific menu and its items (including translations)",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Name of the menu (e.g. main, footer-explore)" }
|
||||
},
|
||||
required: ["name"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "upsert_menu",
|
||||
description: "Update or create a complete menu by replacing its items. Optional payload/isActive support covers tree-based menus and menu state. Uses proper diff & sync for translations.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Name of the menu (e.g. main, footer-explore)" },
|
||||
dryRun: { type: "boolean", description: "Preview the mutation without saving it" },
|
||||
payload: {
|
||||
oneOf: [{ type: "object" }, { type: "array" }],
|
||||
description: "Optional menu payload for tree-based menus such as main"
|
||||
},
|
||||
isActive: { type: "boolean", description: "Optional active state for the menu" },
|
||||
items: {
|
||||
type: "array",
|
||||
description: "Array of menu items",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number", description: "ID of existing item (optional)" },
|
||||
label: { type: "string", description: "Label of the menu item" },
|
||||
url: { type: "string", description: "URL of the menu item" },
|
||||
order: { type: "number", description: "Order sequence" },
|
||||
translations: {
|
||||
type: "array",
|
||||
description: "Optional array of translations",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
langCode: { type: "string" },
|
||||
label: { type: "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["label", "url", "order"]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["name"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "audit_missing_pages",
|
||||
description: "Audit all menus and find links that do not have a corresponding dynamic page or hardcoded route",
|
||||
inputSchema: { type: "object", properties: {} }
|
||||
},
|
||||
{
|
||||
name: "get_mega_menu_tree",
|
||||
description: "Read Mega Menu (main) payload, auto-generate nodeId for all nodes if missing, and return the tree.",
|
||||
inputSchema: { type: "object", properties: {} }
|
||||
},
|
||||
{
|
||||
name: "capture_menu_snapshot",
|
||||
description: "Capture the current state of a menu so it can be restored later.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Name of the menu to snapshot (e.g. main, footer-explore)" }
|
||||
},
|
||||
required: ["name"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "restore_menu_snapshot",
|
||||
description: "Restore a previously captured menu snapshot by snapshotId.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
snapshotId: { type: "string", description: "Snapshot identifier returned by capture_menu_snapshot" },
|
||||
dryRun: { type: "boolean", description: "Preview the mutation without saving it" }
|
||||
},
|
||||
required: ["snapshotId"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "bulk_menu_operations",
|
||||
description: "Apply multiple menu tree operations in a single transaction. Supports add_node, update_translation, move_node, and remove_node.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Name of the menu to modify (e.g. main, footer-explore)" },
|
||||
dryRun: { type: "boolean", description: "Preview the mutation without saving it" },
|
||||
operations: {
|
||||
type: "array",
|
||||
description: "Array of menu operations to apply in order",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: { type: "string", enum: ["add_node", "update_translation", "move_node", "remove_node"] },
|
||||
parentId: { type: "string", description: "Parent node ID for add_node" },
|
||||
nodeId: { type: "string", description: "Target node ID for update/move/remove" },
|
||||
newParentId: { type: "string", description: "New parent node ID for move_node" },
|
||||
orderIndex: { type: "number", description: "Insertion index for move_node" },
|
||||
translations: { type: "object", description: "Translation map for update_translation" },
|
||||
nodeData: { type: "object", description: "Node data for add_node" }
|
||||
},
|
||||
required: ["type"]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["name", "operations"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "add_menu_node",
|
||||
description: "Add a new node to the Mega Menu tree. CRITICAL RULE: Mega Menu structure is Root -> Column -> Link. Do NOT add a Link directly under Root. If you want to add a Link, you MUST check if a Column exists first. If no Column exists, create a Column node first, then add the Link under that Column.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
parentId: { type: "string", description: "UUID of parent node, or 'root'" },
|
||||
dryRun: { type: "boolean", description: "Preview the mutation without saving it" },
|
||||
nodeData: { type: "object", description: "Node data including label, url, translations, etc." }
|
||||
},
|
||||
required: ["nodeData"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "update_menu_translation",
|
||||
description: "Update 6-language translations for a specific node in the Mega Menu",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
nodeId: { type: "string", description: "UUID of the node" },
|
||||
dryRun: { type: "boolean", description: "Preview the mutation without saving it" },
|
||||
translations: {
|
||||
type: "object",
|
||||
description: "Object containing language codes as keys (th, en, etc.) and translated strings as values."
|
||||
}
|
||||
},
|
||||
required: ["nodeId", "translations"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "move_menu_node",
|
||||
description: "Move a node to a new parent or change its order",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
nodeId: { type: "string" },
|
||||
newParentId: { type: "string", description: "UUID of new parent, or 'root'" },
|
||||
dryRun: { type: "boolean", description: "Preview the mutation without saving it" },
|
||||
orderIndex: { type: "number", description: "New index in the parent's children array" }
|
||||
},
|
||||
required: ["nodeId"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "remove_menu_node",
|
||||
description: "Remove a node and all its children from the Mega Menu",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
nodeId: { type: "string" },
|
||||
dryRun: { type: "boolean", description: "Preview the mutation without saving it" }
|
||||
},
|
||||
required: ["nodeId"]
|
||||
}
|
||||
}
|
||||
] as const;
|
||||
|
||||
export const MENU_TOOL_NAMES = new Set(MENU_TOOL_DEFINITIONS.map((tool) => tool.name));
|
||||
|
||||
export async function handleMenuTool(api: AxiosInstance, name: string, params: any) {
|
||||
if (!MENU_TOOL_NAMES.has(name)) return null;
|
||||
|
||||
switch (name) {
|
||||
case "get_menu": {
|
||||
const res = await api.get(`/menus/${params.name}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "upsert_menu": {
|
||||
const dryRun = !!params.dryRun;
|
||||
const items = Array.isArray(params.items) ? params.items : [];
|
||||
const body: any = { items };
|
||||
const itemCheck = validateMenuItems(items);
|
||||
if (itemCheck.issues.length > 0) {
|
||||
throw new Error(`Invalid menu items: ${itemCheck.issues.join("; ")}`);
|
||||
}
|
||||
if (params.payload !== undefined) body.payload = params.payload;
|
||||
if (params.isActive !== undefined) body.isActive = params.isActive;
|
||||
if (params.payload !== undefined && !isPlainObject(params.payload) && !Array.isArray(params.payload)) {
|
||||
throw new Error("Invalid menu payload: payload must be an object or array");
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
dryRun: true,
|
||||
operation: "upsert_menu",
|
||||
menuName: params.name,
|
||||
preview: {
|
||||
name: params.name,
|
||||
items: cloneMenuValue(items),
|
||||
payload: params.payload !== undefined ? cloneMenuValue(params.payload) : undefined,
|
||||
isActive: params.isActive
|
||||
},
|
||||
warnings: itemCheck.warnings
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const res = await api.put(`/menus/${params.name}`, body);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "audit_missing_pages": {
|
||||
const menusRes = await api.get("/menus");
|
||||
const menus = menusRes.data || [];
|
||||
const pagesRes = await api.get("/pages");
|
||||
const pages = pagesRes.data || [];
|
||||
const createdSlugs = pages.map((p: any) => `/${p.slug}`);
|
||||
const hardcodedRoutes = ["/", "/shop", "/login", "/cart", "/checkout", "/account", "/search", "/contact"];
|
||||
|
||||
const missingLinks: any[] = [];
|
||||
const duplicates: any[] = [];
|
||||
const treeIssues: any[] = [];
|
||||
|
||||
menus.forEach((menu: any) => {
|
||||
const seenUrls = new Set<string>();
|
||||
const urls = [
|
||||
...(Array.isArray(menu.items)
|
||||
? menu.items.filter((item: any) => item?.url).map((item: any) => ({ url: item.url, label: item.label, source: "items" as const }))
|
||||
: []),
|
||||
...(Array.isArray(menu.payload) ? collectMenuLinksFromTree(menu.payload) : []),
|
||||
];
|
||||
const uniqueUrls = [...new Map(urls.map((entry) => [entry.url, entry])).values()];
|
||||
|
||||
uniqueUrls.forEach(({ url, label, source }) => {
|
||||
if (seenUrls.has(url)) {
|
||||
duplicates.push({ menuName: menu.name, url, label: label || url, source });
|
||||
return;
|
||||
}
|
||||
seenUrls.add(url);
|
||||
if (url.startsWith("http") || url.startsWith("mailto:") || url.startsWith("tel:")) return;
|
||||
if (hardcodedRoutes.includes(url)) return;
|
||||
if (url.startsWith("/collections/")) return;
|
||||
if (!createdSlugs.includes(url)) {
|
||||
missingLinks.push({
|
||||
menuName: menu.name,
|
||||
label: label || url,
|
||||
url,
|
||||
suggestedSlug: url.startsWith("/") ? url.substring(1) : url,
|
||||
source,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (Array.isArray(menu.payload)) {
|
||||
const treeCheck = validateMenuTreeSnapshot(menu.payload);
|
||||
treeIssues.push({ menuName: menu.name, issues: treeCheck.issues, warnings: treeCheck.warnings });
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ missingPages: missingLinks, duplicates, treeIssues }, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
case "get_mega_menu_tree": {
|
||||
const res = await api.get(`/menus/main`);
|
||||
const menu = res.data;
|
||||
const normalized = normalizeMenuPayloadForWrite(menu.payload);
|
||||
if (normalized.modified) {
|
||||
await api.put(`/menus/main`, { payload: normalized.payload, items: menu.items || [] });
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify(normalized.payload, null, 2) }] };
|
||||
}
|
||||
case "capture_menu_snapshot": {
|
||||
const res = await api.get(`/menus/${params.name}`);
|
||||
const snapshot = createMenuSnapshot(res.data);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
operation: "capture_menu_snapshot",
|
||||
snapshotId: snapshot.snapshotId,
|
||||
name: snapshot.name,
|
||||
createdAt: snapshot.createdAt,
|
||||
itemCount: snapshot.items.length,
|
||||
hasTreePayload: snapshot.payload.length > 0
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
case "restore_menu_snapshot": {
|
||||
const dryRun = !!params.dryRun;
|
||||
const snapshot = restoreMenuSnapshot(params.snapshotId);
|
||||
const preview = { name: snapshot.name, payload: snapshot.payload, items: snapshot.items, isActive: snapshot.isActive };
|
||||
if (dryRun) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ success: true, dryRun: true, operation: "restore_menu_snapshot", snapshotId: params.snapshotId, preview }, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const res = await api.put(`/menus/${snapshot.name}`, {
|
||||
payload: snapshot.payload,
|
||||
items: snapshot.items,
|
||||
isActive: snapshot.isActive
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify({ success: true, snapshotId: params.snapshotId, menu: res.data }, null, 2) }] };
|
||||
}
|
||||
case "bulk_menu_operations": {
|
||||
const dryRun = !!params.dryRun;
|
||||
if (!Array.isArray(params.operations) || params.operations.length === 0) {
|
||||
throw new Error("operations must be a non-empty array");
|
||||
}
|
||||
|
||||
const res = await api.get(`/menus/${params.name}`);
|
||||
const menu = res.data;
|
||||
const result = applyBulkMenuOperations(Array.isArray(menu.payload) ? menu.payload : [], params.operations);
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(
|
||||
{ success: true, dryRun: true, operation: "bulk_menu_operations", menuName: params.name, warnings: result.warnings, applied: result.applied, preview: { payload: result.payload } },
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = normalizeMenuPayloadForWrite(result.payload);
|
||||
const writeBody: any = { payload: normalized.payload, items: menu.items || [] };
|
||||
if (menu.isActive !== undefined) {
|
||||
writeBody.isActive = menu.isActive;
|
||||
}
|
||||
const writeRes = await api.put(`/menus/${params.name}`, writeBody);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ success: true, operation: "bulk_menu_operations", menuName: params.name, warnings: result.warnings, applied: result.applied, menu: writeRes.data }, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
case "add_menu_node": {
|
||||
const { parentId, nodeData } = params;
|
||||
const dryRun = !!params.dryRun;
|
||||
const res = await api.get(`/menus/main`);
|
||||
const menu = res.data;
|
||||
const validation = validateMenuNodeData(nodeData, parentId);
|
||||
if (validation.issues.length > 0) {
|
||||
throw new Error(`Invalid menu node: ${validation.issues.join("; ")}`);
|
||||
}
|
||||
const result = applyMenuAddNode(Array.isArray(menu.payload) ? menu.payload : [], parentId, nodeData);
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ success: true, dryRun: true, operation: "add_menu_node", nodeId: result.nodeId, warnings: validation.warnings, preview: result.payload }, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
||||
return { content: [{ type: "text", text: JSON.stringify({ success: true, nodeId: result.nodeId, payload: result.payload }, null, 2) }] };
|
||||
}
|
||||
case "update_menu_translation": {
|
||||
const { nodeId, translations } = params;
|
||||
const dryRun = !!params.dryRun;
|
||||
const validation = validateMenuTranslations(translations);
|
||||
if (validation.issues.length > 0) {
|
||||
throw new Error(`Invalid translations: ${validation.issues.join("; ")}`);
|
||||
}
|
||||
const res = await api.get(`/menus/main`);
|
||||
const menu = res.data;
|
||||
const result = applyMenuUpdateTranslation(Array.isArray(menu.payload) ? menu.payload : [], nodeId, translations);
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ success: true, dryRun: true, operation: "update_menu_translation", warnings: validation.warnings, preview: result.payload }, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
||||
return { content: [{ type: "text", text: JSON.stringify({ success: true, nodeId, payload: result.payload }, null, 2) }] };
|
||||
}
|
||||
case "move_menu_node": {
|
||||
const { nodeId, newParentId, orderIndex } = params;
|
||||
const dryRun = !!params.dryRun;
|
||||
const res = await api.get(`/menus/main`);
|
||||
const menu = res.data;
|
||||
const result = applyMenuMoveNode(Array.isArray(menu.payload) ? menu.payload : [], nodeId, newParentId, orderIndex);
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ success: true, dryRun: true, operation: "move_menu_node", preview: result.payload }, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
||||
return { content: [{ type: "text", text: JSON.stringify({ success: true, payload: result.payload }, null, 2) }] };
|
||||
}
|
||||
case "remove_menu_node": {
|
||||
const { nodeId } = params;
|
||||
const dryRun = !!params.dryRun;
|
||||
const res = await api.get(`/menus/main`);
|
||||
const menu = res.data;
|
||||
const result = applyMenuRemoveNode(Array.isArray(menu.payload) ? menu.payload : [], nodeId);
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ success: true, dryRun: true, operation: "remove_menu_node", removedNode: result.removedNode, preview: result.payload }, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
||||
return { content: [{ type: "text", text: JSON.stringify({ success: true, payload: result.payload }, null, 2) }] };
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user