@solumflow-app/crm-mcp-server 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -0
- package/dist/index.js +1163 -211
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -5,23 +5,329 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
6
|
import { createClient } from "@solumflow-app/crm-client";
|
|
7
7
|
|
|
8
|
+
// package.json
|
|
9
|
+
var version = "0.4.0";
|
|
10
|
+
|
|
8
11
|
// src/catalog-tools.ts
|
|
9
|
-
import { z } from "zod";
|
|
12
|
+
import { z as z3 } from "zod";
|
|
13
|
+
|
|
14
|
+
// ../features/crm-invoicing/src/schema/catalog-api.schema.ts
|
|
15
|
+
import * as z from "zod";
|
|
16
|
+
|
|
17
|
+
// ../features/crm-invoicing/src/schema/amount-ceiling.ts
|
|
18
|
+
var MAX_AMOUNT_CENTS = 99999999;
|
|
19
|
+
|
|
20
|
+
// ../features/crm-invoicing/src/schema/catalog-api.schema.ts
|
|
21
|
+
var WEB_SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
22
|
+
var FIELD_SLUG = /^[a-z][a-z0-9_]*$/;
|
|
23
|
+
var CatalogSourceSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/, {
|
|
24
|
+
message: (
|
|
25
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
26
|
+
'A source is lower-case letters, digits and hyphens, at most 64 characters (for example "coolflames-payload").'
|
|
27
|
+
)
|
|
28
|
+
});
|
|
29
|
+
var CatalogExternalRefSchema = z.string().trim().min(1).max(255);
|
|
30
|
+
var CatalogWebSlugSchema = z.string().min(2).max(64).regex(WEB_SLUG, {
|
|
31
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
32
|
+
message: "An address is lower-case letters and digits joined by hyphens."
|
|
33
|
+
});
|
|
34
|
+
var CatalogFieldSlugSchema = z.string().max(63).regex(FIELD_SLUG, {
|
|
35
|
+
message: (
|
|
36
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
37
|
+
"A field slug starts with a letter and holds lower-case letters, digits and underscores."
|
|
38
|
+
)
|
|
39
|
+
});
|
|
40
|
+
var cents = z.number().int().min(0).max(MAX_AMOUNT_CENTS);
|
|
41
|
+
var text = (max) => z.string().trim().max(max).nullable().optional();
|
|
42
|
+
var CatalogPriceSchema = z.object({
|
|
43
|
+
/** The price's own id at the source, so a renamed price stays itself. */
|
|
44
|
+
externalRef: CatalogExternalRefSchema.optional(),
|
|
45
|
+
label: z.string().trim().min(1).max(120),
|
|
46
|
+
amountCents: cents,
|
|
47
|
+
compareAtCents: cents.nullable().optional(),
|
|
48
|
+
kind: z.enum(["one_off", "recurring"]).optional(),
|
|
49
|
+
recurringInterval: z.enum(["day", "week", "month", "year"]).optional(),
|
|
50
|
+
recurringIntervalCount: z.number().int().min(1).max(365).optional(),
|
|
51
|
+
isDefault: z.boolean().optional(),
|
|
52
|
+
active: z.boolean().optional()
|
|
53
|
+
}).strict().superRefine((price, ctx) => {
|
|
54
|
+
if ((price.kind ?? "one_off") === "recurring" && !price.recurringInterval) {
|
|
55
|
+
ctx.addIssue({
|
|
56
|
+
code: "custom",
|
|
57
|
+
path: ["recurringInterval"],
|
|
58
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
59
|
+
message: "A recurring price needs an interval."
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
var pricesSchema = z.array(CatalogPriceSchema).min(1).max(50).superRefine((prices, ctx) => {
|
|
64
|
+
if (prices.filter((price) => price.isDefault).length > 1) {
|
|
65
|
+
ctx.addIssue({
|
|
66
|
+
code: "custom",
|
|
67
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
68
|
+
message: "At most one price can be the default."
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const refs = prices.flatMap(
|
|
72
|
+
(price) => price.externalRef ? [price.externalRef] : []
|
|
73
|
+
);
|
|
74
|
+
if (new Set(refs).size !== refs.length) {
|
|
75
|
+
ctx.addIssue({
|
|
76
|
+
code: "custom",
|
|
77
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
78
|
+
message: "Two prices carry the same externalRef."
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const labels = prices.map((price) => price.label.toLowerCase());
|
|
82
|
+
if (new Set(labels).size !== labels.length) {
|
|
83
|
+
ctx.addIssue({
|
|
84
|
+
code: "custom",
|
|
85
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
86
|
+
message: "Two prices carry the same label."
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
var CatalogImageSchema = z.object({
|
|
91
|
+
url: z.string().max(2048).url().refine((url) => url.startsWith("https://"), {
|
|
92
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
93
|
+
message: "A picture is fetched over https only."
|
|
94
|
+
}),
|
|
95
|
+
alt: text(300)
|
|
96
|
+
}).strict();
|
|
97
|
+
var CatalogVariantAxisSchema = z.object({
|
|
98
|
+
slug: CatalogFieldSlugSchema,
|
|
99
|
+
label: z.string().trim().min(1).max(120).optional()
|
|
100
|
+
}).strict();
|
|
101
|
+
var CatalogVariantSchema = z.object({
|
|
102
|
+
externalRef: CatalogExternalRefSchema,
|
|
103
|
+
/** The value per axis, keyed by the axis slug. */
|
|
104
|
+
options: z.record(
|
|
105
|
+
CatalogFieldSlugSchema,
|
|
106
|
+
z.string().trim().min(1).max(120)
|
|
107
|
+
),
|
|
108
|
+
sku: text(100),
|
|
109
|
+
gtin: z.string().regex(/^[0-9]{8,14}$/).nullable().optional(),
|
|
110
|
+
prices: pricesSchema.optional(),
|
|
111
|
+
position: z.number().int().min(0).max(1e4).optional(),
|
|
112
|
+
active: z.boolean().optional()
|
|
113
|
+
}).strict();
|
|
114
|
+
var CATALOG_AVAILABILITIES = [
|
|
115
|
+
"draft",
|
|
116
|
+
"internal",
|
|
117
|
+
"scheduled",
|
|
118
|
+
"public"
|
|
119
|
+
];
|
|
120
|
+
var CatalogProductUpsertSchema = z.object({
|
|
121
|
+
source: CatalogSourceSchema,
|
|
122
|
+
/** Required to create a product; optional to update one. */
|
|
123
|
+
name: z.string().trim().min(1).max(500).optional(),
|
|
124
|
+
slug: CatalogWebSlugSchema.nullable().optional(),
|
|
125
|
+
description: text(5e3),
|
|
126
|
+
/** HTML; cleaned against the same allow-list the app uses. */
|
|
127
|
+
longDescription: text(1e5),
|
|
128
|
+
sku: text(100),
|
|
129
|
+
gtin: z.string().regex(/^[0-9]{8,14}$/).nullable().optional(),
|
|
130
|
+
productType: z.enum(["digital", "physical"]).optional(),
|
|
131
|
+
/**
|
|
132
|
+
* Where the product stands. A new product is a `draft` unless this says
|
|
133
|
+
* otherwise -- an import should not put a hundred products on somebody's
|
|
134
|
+
* website because a run finished.
|
|
135
|
+
*/
|
|
136
|
+
availability: z.enum(CATALOG_AVAILABILITIES).optional(),
|
|
137
|
+
/** The moment for `scheduled`; ignored for the other three. */
|
|
138
|
+
publishedAt: z.string().datetime({ offset: true }).nullable().optional(),
|
|
139
|
+
visibility: z.enum(["public", "unlisted"]).optional(),
|
|
140
|
+
metaTitle: text(300),
|
|
141
|
+
metaDescription: text(1e3),
|
|
142
|
+
/** Replaces the product's categories, by slug. */
|
|
143
|
+
categories: z.array(CatalogWebSlugSchema).max(50).optional(),
|
|
144
|
+
/**
|
|
145
|
+
* Make a category that does not exist yet instead of refusing. Off unless
|
|
146
|
+
* asked for, so a typo cannot become a category on a customer's website.
|
|
147
|
+
*/
|
|
148
|
+
createMissingCategories: z.boolean().optional(),
|
|
149
|
+
/** Field values by field slug; `null` empties one field. */
|
|
150
|
+
fields: z.record(CatalogFieldSlugSchema, z.unknown()).optional(),
|
|
151
|
+
/** Which fields the product's own page shows, in that order. */
|
|
152
|
+
publicFields: z.array(CatalogFieldSlugSchema).max(100).optional(),
|
|
153
|
+
/**
|
|
154
|
+
* Add a choice a select field does not offer yet instead of refusing.
|
|
155
|
+
* Off unless asked for, for the same reason as categories.
|
|
156
|
+
*/
|
|
157
|
+
addMissingOptions: z.boolean().optional(),
|
|
158
|
+
/** Replaces the product's prices as a whole. */
|
|
159
|
+
prices: pricesSchema.optional(),
|
|
160
|
+
/** Replaces the product's pictures, in this order. */
|
|
161
|
+
images: z.array(CatalogImageSchema).max(20).optional(),
|
|
162
|
+
variantAxes: z.array(CatalogVariantAxisSchema).min(1).max(3).optional(),
|
|
163
|
+
/** Replaces the product's variants; one missing here is archived. */
|
|
164
|
+
variants: z.array(CatalogVariantSchema).max(100).optional(),
|
|
165
|
+
/** `false` brings back a product the API archived earlier. */
|
|
166
|
+
archived: z.literal(false).optional()
|
|
167
|
+
}).strict().superRefine((body, ctx) => {
|
|
168
|
+
if (body.variants !== void 0 && body.variantAxes === void 0) {
|
|
169
|
+
ctx.addIssue({
|
|
170
|
+
code: "custom",
|
|
171
|
+
path: ["variantAxes"],
|
|
172
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
173
|
+
message: "Variants need the axes they differ along."
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (body.variants && body.variantAxes) {
|
|
177
|
+
const axes = body.variantAxes.map((axis) => axis.slug);
|
|
178
|
+
const seen = /* @__PURE__ */ new Set();
|
|
179
|
+
const refs = /* @__PURE__ */ new Set();
|
|
180
|
+
body.variants.forEach((variant, index) => {
|
|
181
|
+
const named = Object.keys(variant.options);
|
|
182
|
+
if (named.length !== axes.length || !axes.every((axis) => named.includes(axis))) {
|
|
183
|
+
ctx.addIssue({
|
|
184
|
+
code: "custom",
|
|
185
|
+
path: ["variants", index, "options"],
|
|
186
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
187
|
+
message: `A variant names a value for exactly these axes: ${axes.join(", ")}.`
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
const combination = axes.map((axis) => variant.options[axis] ?? "").join("\0");
|
|
191
|
+
if (seen.has(combination)) {
|
|
192
|
+
ctx.addIssue({
|
|
193
|
+
code: "custom",
|
|
194
|
+
path: ["variants", index, "options"],
|
|
195
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
196
|
+
message: "Two variants stand for the same combination."
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
if (refs.has(variant.externalRef)) {
|
|
200
|
+
ctx.addIssue({
|
|
201
|
+
code: "custom",
|
|
202
|
+
path: ["variants", index, "externalRef"],
|
|
203
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
204
|
+
message: "Two variants carry the same externalRef."
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
seen.add(combination);
|
|
208
|
+
refs.add(variant.externalRef);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (body.variantAxes) {
|
|
212
|
+
const slugs = body.variantAxes.map((axis) => axis.slug);
|
|
213
|
+
if (new Set(slugs).size !== slugs.length) {
|
|
214
|
+
ctx.addIssue({
|
|
215
|
+
code: "custom",
|
|
216
|
+
path: ["variantAxes"],
|
|
217
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
218
|
+
message: "An axis is named twice."
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (body.availability === "scheduled" && !body.publishedAt) {
|
|
223
|
+
ctx.addIssue({
|
|
224
|
+
code: "custom",
|
|
225
|
+
path: ["publishedAt"],
|
|
226
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
227
|
+
message: "A scheduled product needs the moment it appears."
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
var CatalogProductArchiveSchema = z.object({ source: CatalogSourceSchema }).strict();
|
|
232
|
+
var CatalogCategoryUpsertSchema = z.object({
|
|
233
|
+
name: z.string().trim().min(1).max(200).optional(),
|
|
234
|
+
description: text(5e3),
|
|
235
|
+
/** `null` puts the category at the top of the tree. */
|
|
236
|
+
parentSlug: CatalogWebSlugSchema.nullable().optional(),
|
|
237
|
+
availability: z.enum(CATALOG_AVAILABILITIES).optional(),
|
|
238
|
+
publishedAt: z.string().datetime({ offset: true }).nullable().optional(),
|
|
239
|
+
metaTitle: text(300),
|
|
240
|
+
metaDescription: text(1e3)
|
|
241
|
+
}).strict();
|
|
242
|
+
var CatalogObjectSlugSchema = z.string().max(64).regex(FIELD_SLUG, {
|
|
243
|
+
message: (
|
|
244
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
245
|
+
"A catalogue slug starts with a letter and holds lower-case letters, digits and underscores."
|
|
246
|
+
)
|
|
247
|
+
});
|
|
248
|
+
var CatalogCatalogueUpsertSchema = z.object({
|
|
249
|
+
nameSingular: z.string().trim().min(1).max(120).optional(),
|
|
250
|
+
namePlural: z.string().trim().min(1).max(120).optional(),
|
|
251
|
+
/** A lucide icon name, as the object settings offer them. */
|
|
252
|
+
icon: z.string().trim().max(64).nullable().optional()
|
|
253
|
+
}).strict();
|
|
254
|
+
var CatalogFieldTypeSchema = z.enum([
|
|
255
|
+
"text",
|
|
256
|
+
"number",
|
|
257
|
+
"select",
|
|
258
|
+
"checkbox",
|
|
259
|
+
"url",
|
|
260
|
+
"date",
|
|
261
|
+
"content_text",
|
|
262
|
+
"repeater",
|
|
263
|
+
"cta",
|
|
264
|
+
"configurator"
|
|
265
|
+
]);
|
|
266
|
+
var CATALOG_FIELD_TYPES = CatalogFieldTypeSchema.options;
|
|
267
|
+
var CatalogFieldOptionSchema = z.object({
|
|
268
|
+
value: z.string().trim().min(1).max(120),
|
|
269
|
+
label: z.string().trim().min(1).max(120).optional()
|
|
270
|
+
}).strict();
|
|
271
|
+
var CatalogAttributeUpsertSchema = z.object({
|
|
272
|
+
label: z.string().trim().min(1).max(120).optional(),
|
|
273
|
+
type: CatalogFieldTypeSchema.optional(),
|
|
274
|
+
/** For a select: whether a product may carry more than one choice. */
|
|
275
|
+
multiple: z.boolean().optional(),
|
|
276
|
+
/** Choices to make sure exist; never removes one that is not listed. */
|
|
277
|
+
options: z.array(CatalogFieldOptionSchema).max(500).optional(),
|
|
278
|
+
/** Choices to take off the list, by value. */
|
|
279
|
+
removeOptions: z.array(z.string().trim().min(1).max(120)).max(500).optional(),
|
|
280
|
+
/** Whether the public API may show this field at all. */
|
|
281
|
+
public: z.boolean().optional(),
|
|
282
|
+
/**
|
|
283
|
+
* The field's settings, replaced as a whole. Checked against the same
|
|
284
|
+
* schema the object builder saves with, per type: a repeater's `display`,
|
|
285
|
+
* `preset`, `itemFields` and `titleKey`; a call to action's `secondary`;
|
|
286
|
+
* a configurator's whole structure. An item field keeps its type once it
|
|
287
|
+
* exists.
|
|
288
|
+
*/
|
|
289
|
+
config: z.record(z.string(), z.unknown()).optional()
|
|
290
|
+
}).strict();
|
|
291
|
+
var CONFIGURATOR_FILE_KINDS = ["model", "texture"];
|
|
292
|
+
var CONFIGURATOR_TEXTURE_CONTENT_TYPES = [
|
|
293
|
+
"image/webp",
|
|
294
|
+
"image/jpeg"
|
|
295
|
+
];
|
|
296
|
+
var ConfiguratorFileUploadSchema = z.discriminatedUnion("step", [
|
|
297
|
+
z.object({
|
|
298
|
+
step: z.literal("start"),
|
|
299
|
+
/** The slug of the configurator field the file is for. */
|
|
300
|
+
field: CatalogFieldSlugSchema,
|
|
301
|
+
kind: z.enum(CONFIGURATOR_FILE_KINDS),
|
|
302
|
+
/** The bytes the caller is about to send, so a too-large file is refused before it travels. */
|
|
303
|
+
size: z.number().int().min(1),
|
|
304
|
+
/** For a texture: what kind of picture it is. A model is always glTF binary. */
|
|
305
|
+
contentType: z.enum(CONFIGURATOR_TEXTURE_CONTENT_TYPES).optional()
|
|
306
|
+
}).strict(),
|
|
307
|
+
z.object({
|
|
308
|
+
step: z.literal("finish"),
|
|
309
|
+
field: CatalogFieldSlugSchema,
|
|
310
|
+
kind: z.enum(CONFIGURATOR_FILE_KINDS),
|
|
311
|
+
/** The `uploadPath` `start` answered with. */
|
|
312
|
+
uploadPath: z.string().max(200)
|
|
313
|
+
}).strict()
|
|
314
|
+
]);
|
|
10
315
|
|
|
11
316
|
// src/tool-answers.ts
|
|
12
317
|
import { CrmApiError } from "@solumflow-app/crm-client";
|
|
13
|
-
|
|
318
|
+
import { z as z2 } from "zod";
|
|
319
|
+
function answer(text2, data) {
|
|
14
320
|
return {
|
|
15
|
-
content: [{ type: "text", text }],
|
|
321
|
+
content: [{ type: "text", text: text2 }],
|
|
16
322
|
structuredContent: { result: data }
|
|
17
323
|
};
|
|
18
324
|
}
|
|
19
|
-
function refusal(error) {
|
|
325
|
+
function refusal(error, scope) {
|
|
20
326
|
if (!(error instanceof CrmApiError)) {
|
|
21
327
|
throw error;
|
|
22
328
|
}
|
|
23
329
|
const lines = [`${error.code}: ${error.message}`];
|
|
24
|
-
const next = NEXT_STEP[error.code];
|
|
330
|
+
const next = error.code === "forbidden" ? `The key may not do this. It needs to be a secret key with the scope "${scope}".` : NEXT_STEP[error.code];
|
|
25
331
|
if (next) {
|
|
26
332
|
lines.push(next);
|
|
27
333
|
}
|
|
@@ -35,7 +341,6 @@ function refusal(error) {
|
|
|
35
341
|
}
|
|
36
342
|
var NEXT_STEP = {
|
|
37
343
|
unauthorized: "The key is not recognised or was revoked. Ask the account owner for a new secret key.",
|
|
38
|
-
forbidden: 'The key may not do this. It needs to be a secret key with the scope "website:manage".',
|
|
39
344
|
feature_unavailable: "The module this needs is switched off for the account. Ask somebody to switch it on in the app.",
|
|
40
345
|
rate_limited: "Too many requests. Wait a moment and try again.",
|
|
41
346
|
internal_error: "Something went wrong in the app. Trying again is safe."
|
|
@@ -45,28 +350,65 @@ function codeBlock(snippet) {
|
|
|
45
350
|
${snippet}
|
|
46
351
|
\`\`\``;
|
|
47
352
|
}
|
|
353
|
+
var page = {
|
|
354
|
+
cursor: z2.string().optional().describe("The nextCursor of the previous page; leave out for the first."),
|
|
355
|
+
limit: z2.number().int().min(1).max(100).optional()
|
|
356
|
+
};
|
|
357
|
+
function more(nextCursor) {
|
|
358
|
+
return nextCursor ? `More on the next page: cursor ${nextCursor}` : "";
|
|
359
|
+
}
|
|
360
|
+
function money(cents2, currency) {
|
|
361
|
+
return `${(cents2 / 100).toFixed(2)} ${currency.toUpperCase()}`;
|
|
362
|
+
}
|
|
48
363
|
|
|
49
364
|
// src/catalog-tools.ts
|
|
365
|
+
var objectInput = {
|
|
366
|
+
object: CatalogObjectSlugSchema.optional().describe(
|
|
367
|
+
"The slug of the catalogue (see list_catalogs). Leave out for the account\u2019s own catalogue."
|
|
368
|
+
)
|
|
369
|
+
};
|
|
50
370
|
var CONTENT_BLOCKS = 'Besides plain values (text, number, choice), a product can carry content blocks, three field types: "content_text" is { html, links? } with already-sanitised html; "cta" is { heading, body, primary: {label,url}, secondary? }, a call to action; "repeater" is { items: [{ id, ...one value per item field }] } and covers lists, accordions (FAQ), downloads and testimonials. A repeater\u2019s field says how to draw it: "display" is "list" or "accordion", "preset" names where it started (list, faq, downloads, testimonials), "itemFields" is the structure of one item (key, label, type: text, richtext, url {label,url}, icon {set,name}, image or file {url,name,size,contentType}) and "titleKey" is the item field that names an item, such as the question of a FAQ. A "configurator" is a 3D product configurator: a listing carries only { configurator: true }, a product\u2019s own page the whole document (see read_configurator). A block that is public but empty is null; a block that is not public is not there at all.';
|
|
51
|
-
var slugOrId =
|
|
371
|
+
var slugOrId = z3.string().min(1).max(200).describe("The address (slug) or the id of the product.");
|
|
52
372
|
function registerCatalogTools(server, client) {
|
|
53
373
|
server.registerTool(
|
|
54
374
|
"list_catalog_fields",
|
|
55
375
|
{
|
|
56
376
|
title: "List the public fields of the catalogue",
|
|
57
377
|
description: "The custom fields the catalogue publishes on its products, with their type, choices and, for a content block, how it is built. Read this once before building a product page: it says which blocks exist and what each one looks like. " + CONTENT_BLOCKS,
|
|
58
|
-
inputSchema: {},
|
|
378
|
+
inputSchema: { ...objectInput },
|
|
59
379
|
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
60
380
|
},
|
|
61
|
-
async () => {
|
|
381
|
+
async ({ object: object2 }) => {
|
|
62
382
|
try {
|
|
63
|
-
const fields = await client.getFields();
|
|
383
|
+
const fields = await client.getFields(object2 ? { object: object2 } : void 0);
|
|
64
384
|
return answer(
|
|
65
385
|
fields.length === 0 ? "The catalogue publishes no custom fields." : fields.map(describeField).join("\n"),
|
|
66
386
|
fields
|
|
67
387
|
);
|
|
68
388
|
} catch (error) {
|
|
69
|
-
return refusal(error);
|
|
389
|
+
return refusal(error, "catalog:read");
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
);
|
|
393
|
+
server.registerTool(
|
|
394
|
+
"list_catalogs",
|
|
395
|
+
{
|
|
396
|
+
title: "List the product catalogues",
|
|
397
|
+
description: "The product catalogues this key can write to, the account\u2019s own first. A catalogue other than the account\u2019s own is named by its slug in `object` on every catalogue tool; upsert_catalog makes one.",
|
|
398
|
+
inputSchema: {},
|
|
399
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
400
|
+
},
|
|
401
|
+
async () => {
|
|
402
|
+
try {
|
|
403
|
+
const catalogs = await client.getCatalogs();
|
|
404
|
+
return answer(
|
|
405
|
+
catalogs.map(
|
|
406
|
+
(catalog) => `${catalog.name} (${catalog.slug})${catalog.isDefault ? ", the account\u2019s own" : ""}`
|
|
407
|
+
).join("\n") || "This account has no product catalogue.",
|
|
408
|
+
catalogs
|
|
409
|
+
);
|
|
410
|
+
} catch (error) {
|
|
411
|
+
return refusal(error, "catalog:read");
|
|
70
412
|
}
|
|
71
413
|
}
|
|
72
414
|
);
|
|
@@ -90,11 +432,122 @@ function registerCatalogTools(server, client) {
|
|
|
90
432
|
product
|
|
91
433
|
);
|
|
92
434
|
} catch (error) {
|
|
93
|
-
return refusal(error);
|
|
435
|
+
return refusal(error, "catalog:read");
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
);
|
|
439
|
+
server.registerTool(
|
|
440
|
+
"list_products",
|
|
441
|
+
{
|
|
442
|
+
title: "List the published products",
|
|
443
|
+
description: "One page of the catalogue: name, address (slug), lowest price, whether it is in stock, its first picture and the public fields a listing shows. Narrow it to one category\u2019s whole branch by its id (from list_categories), or to one catalogue by the slug of its object. Read get_product for everything a product page needs. May be a minute old.",
|
|
444
|
+
inputSchema: {
|
|
445
|
+
...page,
|
|
446
|
+
category: z3.string().uuid().optional().describe("A category id; includes its subcategories."),
|
|
447
|
+
object: z3.string().min(1).max(63).optional().describe(
|
|
448
|
+
'One catalogue, by the slug of its object, such as "product".'
|
|
449
|
+
)
|
|
450
|
+
},
|
|
451
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
452
|
+
},
|
|
453
|
+
async (input) => {
|
|
454
|
+
try {
|
|
455
|
+
const list = await client.getProducts(input);
|
|
456
|
+
return answer(
|
|
457
|
+
list.data.length === 0 ? "No published products match." : [...list.data.map(describeProduct), more(list.nextCursor)].filter(Boolean).join("\n"),
|
|
458
|
+
list
|
|
459
|
+
);
|
|
460
|
+
} catch (error) {
|
|
461
|
+
return refusal(error, "catalog:read");
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
);
|
|
465
|
+
server.registerTool(
|
|
466
|
+
"list_categories",
|
|
467
|
+
{
|
|
468
|
+
title: "List the published categories",
|
|
469
|
+
description: 'The categories of the catalogue, each with its path from the top and how many products are in it. Left without `parent`, every category comes back, which is what building a menu in one go needs; "root" asks for the top level, a category id for its children.',
|
|
470
|
+
inputSchema: {
|
|
471
|
+
...page,
|
|
472
|
+
parent: z3.union([z3.literal("root"), z3.string().uuid()]).optional().describe('"root" or the id of a category.')
|
|
473
|
+
},
|
|
474
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
475
|
+
},
|
|
476
|
+
async (input) => {
|
|
477
|
+
try {
|
|
478
|
+
const list = await client.getCategories(input);
|
|
479
|
+
return answer(
|
|
480
|
+
list.data.length === 0 ? "No published categories." : [...list.data.map(describeCategory), more(list.nextCursor)].filter(Boolean).join("\n"),
|
|
481
|
+
list
|
|
482
|
+
);
|
|
483
|
+
} catch (error) {
|
|
484
|
+
return refusal(error, "catalog:read");
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
);
|
|
488
|
+
server.registerTool(
|
|
489
|
+
"get_category",
|
|
490
|
+
{
|
|
491
|
+
title: "Read one category page",
|
|
492
|
+
description: "One category as its page needs it: name, description, the texts for search engines and social media, and the first page of its products. Pass the nextCursor back to page through the products.",
|
|
493
|
+
inputSchema: {
|
|
494
|
+
slugOrId: z3.string().min(1).max(200).describe("The address (slug) or the id of the category."),
|
|
495
|
+
...page
|
|
496
|
+
},
|
|
497
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
498
|
+
},
|
|
499
|
+
async ({ slugOrId: wanted, ...listOptions }) => {
|
|
500
|
+
try {
|
|
501
|
+
const category = await client.getCategory(wanted, listOptions);
|
|
502
|
+
if (!category) {
|
|
503
|
+
return answer(`No category "${wanted}" on this account.`, null);
|
|
504
|
+
}
|
|
505
|
+
const { data, nextCursor } = category;
|
|
506
|
+
return answer(
|
|
507
|
+
[
|
|
508
|
+
`${data.name} (${data.slug}), ${data.productCount} products.`,
|
|
509
|
+
...data.products.map(describeProduct),
|
|
510
|
+
more(nextCursor)
|
|
511
|
+
].filter(Boolean).join("\n"),
|
|
512
|
+
category
|
|
513
|
+
);
|
|
514
|
+
} catch (error) {
|
|
515
|
+
return refusal(error, "catalog:read");
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
);
|
|
519
|
+
server.registerTool(
|
|
520
|
+
"get_product_availability",
|
|
521
|
+
{
|
|
522
|
+
title: "Whether a product can be bought now",
|
|
523
|
+
description: "Live, not cached: whether a visitor can buy the product right now. Never how many are left. For a stock badge that must be current; the inStock of a listing may be a minute old.",
|
|
524
|
+
inputSchema: { slugOrId },
|
|
525
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
526
|
+
},
|
|
527
|
+
async ({ slugOrId: wanted }) => {
|
|
528
|
+
try {
|
|
529
|
+
const status = await client.getAvailability(wanted);
|
|
530
|
+
if (!status) {
|
|
531
|
+
return answer(`No product "${wanted}" on this account.`, null);
|
|
532
|
+
}
|
|
533
|
+
return answer(
|
|
534
|
+
`${status.slug}: ${status.inStock ? "in stock" : "out of stock"}.`,
|
|
535
|
+
status
|
|
536
|
+
);
|
|
537
|
+
} catch (error) {
|
|
538
|
+
return refusal(error, "catalog:read");
|
|
94
539
|
}
|
|
95
540
|
}
|
|
96
541
|
);
|
|
97
542
|
}
|
|
543
|
+
function describeProduct(product) {
|
|
544
|
+
const stock = product.inStock ? "" : ", out of stock";
|
|
545
|
+
return `- ${product.name} (${product.slug}): from ${money(product.priceFromCents, product.currency)}${stock}`;
|
|
546
|
+
}
|
|
547
|
+
function describeCategory(category) {
|
|
548
|
+
const path = category.path.map((step) => step.name).join(" / ");
|
|
549
|
+
return `- ${path || category.name} (${category.slug}, id ${category.id}): ${category.productCount} products`;
|
|
550
|
+
}
|
|
98
551
|
function describeField(field) {
|
|
99
552
|
if (field.type !== "repeater") {
|
|
100
553
|
return `- ${field.slug}: ${field.label}, ${field.type}`;
|
|
@@ -105,8 +558,159 @@ function describeField(field) {
|
|
|
105
558
|
return `- ${field.slug}: ${field.label}, repeater (${field.display ?? "list"}${preset}; items: ${items}${title})`;
|
|
106
559
|
}
|
|
107
560
|
|
|
561
|
+
// src/catalog-write-tools.ts
|
|
562
|
+
function target(object2) {
|
|
563
|
+
return object2 ? { object: object2 } : void 0;
|
|
564
|
+
}
|
|
565
|
+
var FIELD_KINDS = 'Kinds: text, number, select, checkbox, url, date, and three content blocks for a product page: "content_text" (formatted text, no config), "cta" (a call to action; config { secondary: true } offers a second button) and "repeater" (a list, an accordion such as a FAQ, downloads or testimonials; config { display: "list" | "accordion", preset?: "list" | "faq" | "downloads" | "testimonials", itemFields: [{ key, label, type: text | richtext | url | icon | image | file }], titleKey?: the item field that names an item }). A number may take config { prefix?, suffix? }. A configurator is made with upsert_configurator. The kind is fixed once the field exists, and so is the type of a repeater\u2019s item field.';
|
|
566
|
+
var IDENTITY = 'A product is known by `externalRef`, its id in the system it comes from, together with `source`, the name of that system (for example "acme-webshop"). The same pair always means the same product: sending it again changes it instead of making a second one, so use references that stay the same, never a name that may change.';
|
|
567
|
+
function registerCatalogWriteTools(server, client) {
|
|
568
|
+
server.registerTool(
|
|
569
|
+
"upsert_product",
|
|
570
|
+
{
|
|
571
|
+
title: "Make or change a product",
|
|
572
|
+
description: IDENTITY + ' Every field but `source` may be left out, and left out means "leave it as it is"; null empties a field. A new product needs a `name` and is a draft unless `availability` says otherwise: do not publish without the user asking. `prices`, `categories`, `images` and `variants` each replace what the product had as a whole, and a variant left out is archived. Categories are named by slug and a missing one is refused unless `createMissingCategories` is true; a choice a select field does not offer is refused unless `addMissingOptions` is true. Pictures are https URLs, fetched after the answer (`images: "pending"`). Amounts are in cents. Read list_catalog_fields first for the slugs `fields` takes.',
|
|
573
|
+
inputSchema: {
|
|
574
|
+
externalRef: CatalogExternalRefSchema.describe(
|
|
575
|
+
"The id of the product in the system named by `source`."
|
|
576
|
+
),
|
|
577
|
+
...CatalogProductUpsertSchema.shape,
|
|
578
|
+
...objectInput
|
|
579
|
+
},
|
|
580
|
+
annotations: { destructiveHint: true, idempotentHint: true }
|
|
581
|
+
},
|
|
582
|
+
async ({ externalRef, object: object2, ...input }) => {
|
|
583
|
+
try {
|
|
584
|
+
const product = await client.upsertProduct(
|
|
585
|
+
externalRef,
|
|
586
|
+
input,
|
|
587
|
+
target(object2)
|
|
588
|
+
);
|
|
589
|
+
const verb = product.created ? "Made" : "Changed";
|
|
590
|
+
return answer(
|
|
591
|
+
`${verb} ${product.name} (${product.slug ?? "no address"}), ${product.availability}; ${product.variants.length} variants; pictures ${product.images}.`,
|
|
592
|
+
product
|
|
593
|
+
);
|
|
594
|
+
} catch (error) {
|
|
595
|
+
return refusal(error, "catalog:write");
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
);
|
|
599
|
+
server.registerTool(
|
|
600
|
+
"archive_product",
|
|
601
|
+
{
|
|
602
|
+
title: "Take a product out of the catalogue",
|
|
603
|
+
description: IDENTITY + " Archiving takes the product and its variants off the website and out of the shop at once. It stays in the database for every invoice that names it, and upsert_product with `archived: false` brings it back. Ask the user before archiving anything they did not name.",
|
|
604
|
+
inputSchema: {
|
|
605
|
+
externalRef: CatalogExternalRefSchema.describe(
|
|
606
|
+
"The id of the product in the system named by `source`."
|
|
607
|
+
),
|
|
608
|
+
...CatalogProductArchiveSchema.shape,
|
|
609
|
+
...objectInput
|
|
610
|
+
},
|
|
611
|
+
annotations: { destructiveHint: true, idempotentHint: true }
|
|
612
|
+
},
|
|
613
|
+
async ({ externalRef, source, object: object2 }) => {
|
|
614
|
+
try {
|
|
615
|
+
const result = await client.archiveProduct(
|
|
616
|
+
externalRef,
|
|
617
|
+
source,
|
|
618
|
+
target(object2)
|
|
619
|
+
);
|
|
620
|
+
return answer(
|
|
621
|
+
result.alreadyArchived ? "That product was already archived." : "Archived.",
|
|
622
|
+
result
|
|
623
|
+
);
|
|
624
|
+
} catch (error) {
|
|
625
|
+
return refusal(error, "catalog:write");
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
);
|
|
629
|
+
server.registerTool(
|
|
630
|
+
"upsert_category",
|
|
631
|
+
{
|
|
632
|
+
title: "Make or change a category",
|
|
633
|
+
description: "A category, known by its address (slug): sending the same slug again changes it. Left-out fields stay as they are; a new category needs a `name`. `parentSlug` places it under another category, null at the top. A new category is a draft unless `availability` says otherwise. Categories belong to one catalogue: a product names only categories of its own catalogue.",
|
|
634
|
+
inputSchema: {
|
|
635
|
+
slug: CatalogWebSlugSchema.describe(
|
|
636
|
+
"The address of the category: lower case, digits and dashes."
|
|
637
|
+
),
|
|
638
|
+
...CatalogCategoryUpsertSchema.shape,
|
|
639
|
+
...objectInput
|
|
640
|
+
},
|
|
641
|
+
annotations: { destructiveHint: false, idempotentHint: true }
|
|
642
|
+
},
|
|
643
|
+
async ({ slug, object: object2, ...input }) => {
|
|
644
|
+
try {
|
|
645
|
+
const category = await client.upsertCategory(
|
|
646
|
+
slug,
|
|
647
|
+
input,
|
|
648
|
+
target(object2)
|
|
649
|
+
);
|
|
650
|
+
return answer(
|
|
651
|
+
`${category.created ? "Made" : "Changed"} ${category.name} (${category.slug}).`,
|
|
652
|
+
category
|
|
653
|
+
);
|
|
654
|
+
} catch (error) {
|
|
655
|
+
return refusal(error, "catalog:write");
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
);
|
|
659
|
+
server.registerTool(
|
|
660
|
+
"upsert_field",
|
|
661
|
+
{
|
|
662
|
+
title: "Make or change a catalogue field",
|
|
663
|
+
description: "A custom field on products, known by its slug: sending the same slug again changes it. A new field needs a `label`. " + FIELD_KINDS + " `config` replaces the settings as a whole. `options` makes sure choices exist and never removes one; `removeOptions` takes choices off the list, which empties them on every product that had them. `public` decides whether the website may show the field at all.",
|
|
664
|
+
inputSchema: {
|
|
665
|
+
slug: CatalogFieldSlugSchema.describe(
|
|
666
|
+
"The slug of the field: a lower-case letter, then lower case, digits and underscores."
|
|
667
|
+
),
|
|
668
|
+
...CatalogAttributeUpsertSchema.shape,
|
|
669
|
+
...objectInput
|
|
670
|
+
},
|
|
671
|
+
annotations: { destructiveHint: true, idempotentHint: true }
|
|
672
|
+
},
|
|
673
|
+
async ({ slug, object: object2, ...input }) => {
|
|
674
|
+
try {
|
|
675
|
+
const field = await client.upsertField(slug, input, target(object2));
|
|
676
|
+
return answer(
|
|
677
|
+
`${field.created ? "Made" : "Changed"} ${field.label} (${field.slug}), ${field.type}, ${field.public ? "public" : "not public"}.`,
|
|
678
|
+
field
|
|
679
|
+
);
|
|
680
|
+
} catch (error) {
|
|
681
|
+
return refusal(error, "catalog:write");
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
);
|
|
685
|
+
server.registerTool(
|
|
686
|
+
"upsert_catalog",
|
|
687
|
+
{
|
|
688
|
+
title: "Make or rename a product catalogue",
|
|
689
|
+
description: 'A product catalogue of its own beside the account\u2019s products, such as "Fireplaces", known by its slug: sending the same slug again renames it. A new one needs both names, one product and many. Its fields, categories and products are then written with `object` set to this slug. Make one only when the user asks for a separate catalogue; most products belong in the account\u2019s own.',
|
|
690
|
+
inputSchema: {
|
|
691
|
+
slug: CatalogObjectSlugSchema.describe(
|
|
692
|
+
"The slug of the catalogue: a lower-case letter, then lower case, digits and underscores."
|
|
693
|
+
),
|
|
694
|
+
...CatalogCatalogueUpsertSchema.shape
|
|
695
|
+
},
|
|
696
|
+
annotations: { destructiveHint: false, idempotentHint: true }
|
|
697
|
+
},
|
|
698
|
+
async ({ slug, ...input }) => {
|
|
699
|
+
try {
|
|
700
|
+
const catalog = await client.upsertCatalog(slug, input);
|
|
701
|
+
return answer(
|
|
702
|
+
`${catalog.created ? "Made" : "Changed"} ${catalog.namePlural} (${catalog.slug}).`,
|
|
703
|
+
catalog
|
|
704
|
+
);
|
|
705
|
+
} catch (error) {
|
|
706
|
+
return refusal(error, "catalog:write");
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
108
712
|
// src/configurator-tools.ts
|
|
109
|
-
import { z as
|
|
713
|
+
import { z as z7 } from "zod";
|
|
110
714
|
|
|
111
715
|
// ../features/product-configurator/src/shared/configurator-limits.ts
|
|
112
716
|
var CONFIGURATOR_LIMITS = {
|
|
@@ -155,11 +759,11 @@ function issue(path, code, params) {
|
|
|
155
759
|
return params ? { path, code, params } : { path, code };
|
|
156
760
|
}
|
|
157
761
|
function formatIssuePath(path) {
|
|
158
|
-
return path.reduce((
|
|
762
|
+
return path.reduce((text2, part) => {
|
|
159
763
|
if (typeof part === "number") {
|
|
160
|
-
return `${
|
|
764
|
+
return `${text2}[${part}]`;
|
|
161
765
|
}
|
|
162
|
-
return
|
|
766
|
+
return text2 ? `${text2}.${part}` : part;
|
|
163
767
|
}, "");
|
|
164
768
|
}
|
|
165
769
|
|
|
@@ -181,11 +785,11 @@ function tokenize(source) {
|
|
|
181
785
|
while (end < source.length && /[0-9.]/.test(source.charAt(end))) {
|
|
182
786
|
end += 1;
|
|
183
787
|
}
|
|
184
|
-
const
|
|
185
|
-
if (!/^(\d+(\.\d+)?|\.\d+)$/.test(
|
|
788
|
+
const text2 = source.slice(index, end);
|
|
789
|
+
if (!/^(\d+(\.\d+)?|\.\d+)$/.test(text2)) {
|
|
186
790
|
return null;
|
|
187
791
|
}
|
|
188
|
-
tokens.push({ type: "number", value: Number(
|
|
792
|
+
tokens.push({ type: "number", value: Number(text2) });
|
|
189
793
|
index = end;
|
|
190
794
|
continue;
|
|
191
795
|
}
|
|
@@ -472,23 +1076,24 @@ function evaluateFormulaSource(source, scope) {
|
|
|
472
1076
|
}
|
|
473
1077
|
|
|
474
1078
|
// ../features/product-configurator/src/shared/geometry-document.schema.ts
|
|
475
|
-
import { z as
|
|
1079
|
+
import { z as z4 } from "zod";
|
|
476
1080
|
var CONFIGURATOR_NAME_PATTERN = /^[a-z][a-z0-9_]{0,39}$/;
|
|
477
1081
|
var CONFIGURATOR_CODE_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/;
|
|
478
|
-
var ConfiguratorNameSchema =
|
|
479
|
-
var ConfiguratorCodeSchema =
|
|
480
|
-
var FormulaSchema =
|
|
481
|
-
|
|
482
|
-
|
|
1082
|
+
var ConfiguratorNameSchema = z4.string().regex(CONFIGURATOR_NAME_PATTERN, "configurator.invalidName");
|
|
1083
|
+
var ConfiguratorCodeSchema = z4.string().regex(CONFIGURATOR_CODE_PATTERN, "configurator.invalidCode");
|
|
1084
|
+
var FormulaSchema = z4.union([
|
|
1085
|
+
z4.number().finite(),
|
|
1086
|
+
z4.string().trim().min(1).max(CONFIGURATOR_LIMITS.formulaChars, "configurator.formulaTooLong")
|
|
483
1087
|
]);
|
|
484
|
-
var Vector3Schema =
|
|
1088
|
+
var Vector3Schema = z4.tuple([FormulaSchema, FormulaSchema, FormulaSchema]);
|
|
485
1089
|
var PRIMITIVE_TYPES = [
|
|
486
1090
|
"box",
|
|
487
1091
|
"rounded_top_box",
|
|
488
1092
|
"stadium_prism",
|
|
489
1093
|
"arc_panel",
|
|
490
1094
|
"led_strip",
|
|
491
|
-
"niche_panel"
|
|
1095
|
+
"niche_panel",
|
|
1096
|
+
"model"
|
|
492
1097
|
];
|
|
493
1098
|
var PRIMITIVE_PARAMS = {
|
|
494
1099
|
box: { required: [], optional: [] },
|
|
@@ -506,69 +1111,127 @@ var PRIMITIVE_PARAMS = {
|
|
|
506
1111
|
niche_panel: {
|
|
507
1112
|
required: ["niche_x", "niche_y", "niche_width", "niche_height"],
|
|
508
1113
|
optional: []
|
|
509
|
-
}
|
|
1114
|
+
},
|
|
1115
|
+
// An uploaded `.glb` placed in the block's frame. `fit` 0 scales it evenly
|
|
1116
|
+
// and centres it (the default), 1 stretches it to the frame: a handle that
|
|
1117
|
+
// grows with the door; 2 keeps the model's own size, standing on the
|
|
1118
|
+
// frame's floor in the middle and against its front: a fire insert, which
|
|
1119
|
+
// is as large as it is. `paint` 1 gives every mesh the slot's material, so
|
|
1120
|
+
// it follows the visitor's colour; 0 (the default) keeps the model's own.
|
|
1121
|
+
model: { required: [], optional: ["fit", "paint"] }
|
|
510
1122
|
};
|
|
511
|
-
var
|
|
1123
|
+
var CONFIGURATOR_MODEL_PATH_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.glb$/;
|
|
1124
|
+
var CONFIGURATOR_FOLLOWS_PRESET = "preset";
|
|
1125
|
+
var PrimitiveNodeFields = {
|
|
512
1126
|
id: ConfiguratorNameSchema,
|
|
513
|
-
type: z2.enum(PRIMITIVE_TYPES),
|
|
514
1127
|
slot: ConfiguratorNameSchema,
|
|
515
1128
|
size: Vector3Schema,
|
|
516
1129
|
at: Vector3Schema,
|
|
517
1130
|
visible: FormulaSchema.optional(),
|
|
518
|
-
params:
|
|
1131
|
+
params: z4.record(ConfiguratorNameSchema, FormulaSchema).optional()
|
|
1132
|
+
};
|
|
1133
|
+
var PrimitiveNodeSchema = z4.strictObject({
|
|
1134
|
+
...PrimitiveNodeFields,
|
|
1135
|
+
type: z4.enum(PRIMITIVE_TYPES).exclude(["model"])
|
|
519
1136
|
});
|
|
520
|
-
var
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
)
|
|
533
|
-
|
|
1137
|
+
var ModelPathSchema = z4.string().regex(CONFIGURATOR_MODEL_PATH_PATTERN, "configurator.invalidModelPath");
|
|
1138
|
+
var ServedFileUrlSchema = z4.url({ protocol: /^https?$/ });
|
|
1139
|
+
function geometryNodeSchema(modelFile) {
|
|
1140
|
+
const ModelNodeSchema = z4.strictObject({
|
|
1141
|
+
...PrimitiveNodeFields,
|
|
1142
|
+
type: z4.literal("model"),
|
|
1143
|
+
model: modelFile
|
|
1144
|
+
});
|
|
1145
|
+
const ModelPerChoiceNodeSchema = z4.strictObject({
|
|
1146
|
+
...PrimitiveNodeFields,
|
|
1147
|
+
type: z4.literal("model"),
|
|
1148
|
+
follows: ConfiguratorNameSchema,
|
|
1149
|
+
models: z4.record(ConfiguratorCodeSchema, modelFile).refine(
|
|
1150
|
+
(models) => Object.keys(models).length <= CONFIGURATOR_LIMITS.presets,
|
|
1151
|
+
{ message: "configurator.tooMany" }
|
|
1152
|
+
)
|
|
1153
|
+
});
|
|
1154
|
+
const node = z4.lazy(
|
|
1155
|
+
() => z4.union([
|
|
1156
|
+
PrimitiveNodeSchema,
|
|
1157
|
+
ModelNodeSchema,
|
|
1158
|
+
ModelPerChoiceNodeSchema,
|
|
1159
|
+
z4.strictObject({
|
|
1160
|
+
id: ConfiguratorNameSchema,
|
|
1161
|
+
type: z4.literal("repeat"),
|
|
1162
|
+
count: FormulaSchema,
|
|
1163
|
+
index: z4.enum(["i", "j"]).optional(),
|
|
1164
|
+
visible: FormulaSchema.optional(),
|
|
1165
|
+
children: z4.array(node).min(1)
|
|
1166
|
+
})
|
|
1167
|
+
])
|
|
1168
|
+
);
|
|
1169
|
+
return node;
|
|
1170
|
+
}
|
|
1171
|
+
var GeometryNodeSchema = geometryNodeSchema(ModelPathSchema);
|
|
1172
|
+
var GeometryBindingSchema = z4.strictObject({
|
|
534
1173
|
param: ConfiguratorNameSchema,
|
|
535
|
-
map:
|
|
1174
|
+
map: z4.record(ConfiguratorCodeSchema, z4.number().finite()).optional()
|
|
536
1175
|
});
|
|
537
|
-
var GeometryInputSchema =
|
|
1176
|
+
var GeometryInputSchema = z4.strictObject({
|
|
538
1177
|
name: ConfiguratorNameSchema,
|
|
539
1178
|
binding: GeometryBindingSchema
|
|
540
1179
|
});
|
|
541
|
-
var GeometrySlotSchema =
|
|
1180
|
+
var GeometrySlotSchema = z4.strictObject({
|
|
542
1181
|
name: ConfiguratorNameSchema,
|
|
543
1182
|
param: ConfiguratorNameSchema.optional(),
|
|
544
1183
|
material: ConfiguratorCodeSchema.optional(),
|
|
545
|
-
color:
|
|
1184
|
+
color: z4.string().regex(/^#[0-9a-f]{6}$/i, "configurator.invalidColor").optional(),
|
|
1185
|
+
/**
|
|
1186
|
+
* Which way the grain of a picture runs on this part: `horizontal` (the
|
|
1187
|
+
* default) as the picture is drawn, `vertical` turned a quarter. It belongs
|
|
1188
|
+
* to the part, not the material: the same oak lies on a cabinet and stands
|
|
1189
|
+
* on a tall wall panel.
|
|
1190
|
+
*/
|
|
1191
|
+
grain: z4.enum(["horizontal", "vertical"]).optional()
|
|
546
1192
|
});
|
|
547
|
-
var GeometryAnnotationSchema =
|
|
1193
|
+
var GeometryAnnotationSchema = z4.strictObject({
|
|
548
1194
|
id: ConfiguratorNameSchema,
|
|
549
|
-
axis:
|
|
1195
|
+
axis: z4.enum(["x", "y"]),
|
|
550
1196
|
from: FormulaSchema,
|
|
551
1197
|
to: FormulaSchema,
|
|
552
1198
|
at: FormulaSchema,
|
|
553
|
-
label:
|
|
554
|
-
});
|
|
555
|
-
var GeometryDocumentSchema = z2.strictObject({
|
|
556
|
-
/** Rises with every new upload, so a product page can say which one it shows. */
|
|
557
|
-
version: z2.number().int().min(1),
|
|
558
|
-
/** The starter document this one was copied from, for information only. */
|
|
559
|
-
startedFrom: z2.string().trim().max(80).optional(),
|
|
560
|
-
inputs: z2.array(GeometryInputSchema).max(CONFIGURATOR_LIMITS.geometryInputs),
|
|
561
|
-
constants: z2.record(ConfiguratorNameSchema, z2.number().finite()),
|
|
562
|
-
slots: z2.array(GeometrySlotSchema).min(1).max(CONFIGURATOR_LIMITS.geometrySlots),
|
|
563
|
-
root: z2.array(GeometryNodeSchema).min(1),
|
|
564
|
-
camera: z2.strictObject({
|
|
565
|
-
/** Degrees around the vertical axis, 0 is straight on. */
|
|
566
|
-
azimuth: z2.number().min(-90).max(90),
|
|
567
|
-
/** Degrees above the horizon. */
|
|
568
|
-
elevation: z2.number().min(-10).max(80)
|
|
569
|
-
}).optional(),
|
|
570
|
-
annotations: z2.array(GeometryAnnotationSchema).max(CONFIGURATOR_LIMITS.annotations)
|
|
1199
|
+
label: z4.string().trim().min(1).max(80).optional()
|
|
571
1200
|
});
|
|
1201
|
+
function geometryDocumentSchema(modelFile) {
|
|
1202
|
+
return z4.strictObject({
|
|
1203
|
+
/** Rises with every new upload, so a product page can say which one it shows. */
|
|
1204
|
+
version: z4.number().int().min(1),
|
|
1205
|
+
/** The starter document this one was copied from, for information only. */
|
|
1206
|
+
startedFrom: z4.string().trim().max(80).optional(),
|
|
1207
|
+
inputs: z4.array(GeometryInputSchema).max(CONFIGURATOR_LIMITS.geometryInputs),
|
|
1208
|
+
constants: z4.record(ConfiguratorNameSchema, z4.number().finite()),
|
|
1209
|
+
slots: z4.array(GeometrySlotSchema).min(1).max(CONFIGURATOR_LIMITS.geometrySlots),
|
|
1210
|
+
root: z4.array(geometryNodeSchema(modelFile)).min(1),
|
|
1211
|
+
camera: z4.strictObject({
|
|
1212
|
+
/** Degrees around the vertical axis, 0 is straight on. */
|
|
1213
|
+
azimuth: z4.number().min(-90).max(90),
|
|
1214
|
+
/** Degrees above the horizon. */
|
|
1215
|
+
elevation: z4.number().min(-10).max(80)
|
|
1216
|
+
}).optional(),
|
|
1217
|
+
annotations: z4.array(GeometryAnnotationSchema).max(CONFIGURATOR_LIMITS.annotations)
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
var GeometryDocumentSchema = geometryDocumentSchema(ModelPathSchema);
|
|
1221
|
+
function choiceModelBlocks(nodes) {
|
|
1222
|
+
const found = [];
|
|
1223
|
+
const walk = (list) => {
|
|
1224
|
+
for (const node of list) {
|
|
1225
|
+
if (node.type === "repeat") {
|
|
1226
|
+
walk(node.children);
|
|
1227
|
+
} else if (node.type === "model" && node.follows !== void 0) {
|
|
1228
|
+
found.push({ name: `${node.id}_shown`, node });
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
walk(nodes);
|
|
1233
|
+
return found;
|
|
1234
|
+
}
|
|
572
1235
|
|
|
573
1236
|
// ../features/product-configurator/src/shared/geometry-document-problems.ts
|
|
574
1237
|
function geometryDocumentProblems(document, context = {}) {
|
|
@@ -685,7 +1348,17 @@ function geometryDocumentProblems(document, context = {}) {
|
|
|
685
1348
|
);
|
|
686
1349
|
}
|
|
687
1350
|
});
|
|
688
|
-
const
|
|
1351
|
+
const shownNames = choiceModelBlocks(document.root).map(
|
|
1352
|
+
(block) => block.name
|
|
1353
|
+
);
|
|
1354
|
+
for (const name of shownNames) {
|
|
1355
|
+
if (inputNames.has(name) || constantNames.includes(name)) {
|
|
1356
|
+
problems.push(
|
|
1357
|
+
issue([...base, "root"], "configurator.duplicateName", { name })
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
const shapeNames = /* @__PURE__ */ new Set([...inputNames, ...constantNames, ...shownNames]);
|
|
689
1362
|
const ids = /* @__PURE__ */ new Set();
|
|
690
1363
|
let declared = 0;
|
|
691
1364
|
const checkFormula2 = (formula, path, scope) => {
|
|
@@ -784,6 +1457,14 @@ function geometryDocumentProblems(document, context = {}) {
|
|
|
784
1457
|
}
|
|
785
1458
|
checkFormula2(formula, [...nodePath, "params", key], scope);
|
|
786
1459
|
}
|
|
1460
|
+
if (node.follows !== void 0 && context.parameters) {
|
|
1461
|
+
problems.push(
|
|
1462
|
+
...followProblems(node.follows, nodePath, {
|
|
1463
|
+
parameters: context.parameters,
|
|
1464
|
+
hasPresets: context.hasPresets ?? false
|
|
1465
|
+
})
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
787
1468
|
}
|
|
788
1469
|
};
|
|
789
1470
|
walk(document.root, [...base, "root"], shapeNames, 0);
|
|
@@ -811,92 +1492,110 @@ function geometryDocumentProblems(document, context = {}) {
|
|
|
811
1492
|
});
|
|
812
1493
|
return problems;
|
|
813
1494
|
}
|
|
1495
|
+
function followProblems(follows, path, context) {
|
|
1496
|
+
if (follows === CONFIGURATOR_FOLLOWS_PRESET) {
|
|
1497
|
+
return context.hasPresets ? [] : [issue([...path, "follows"], "configurator.followsNoPresets")];
|
|
1498
|
+
}
|
|
1499
|
+
return context.parameters.get(follows)?.kind === "choice" ? [] : [
|
|
1500
|
+
issue([...path, "follows"], "configurator.followsNotAChoice", {
|
|
1501
|
+
param: follows
|
|
1502
|
+
})
|
|
1503
|
+
];
|
|
1504
|
+
}
|
|
814
1505
|
|
|
815
1506
|
// ../features/product-configurator/src/shared/configurator-structure.schema.ts
|
|
816
|
-
import { z as
|
|
817
|
-
var LabelSchema =
|
|
1507
|
+
import { z as z5 } from "zod";
|
|
1508
|
+
var LabelSchema = z5.string().trim().min(1).max(80);
|
|
818
1509
|
var CONFIGURATOR_TEXTURE_PATH_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(webp|jpg|jpeg)$/;
|
|
819
|
-
var TexturePathSchema =
|
|
1510
|
+
var TexturePathSchema = z5.string().regex(CONFIGURATOR_TEXTURE_PATH_PATTERN, "configurator.invalidTexturePath");
|
|
820
1511
|
var ParameterBase = {
|
|
821
1512
|
key: ConfiguratorNameSchema,
|
|
822
1513
|
label: LabelSchema,
|
|
823
1514
|
tab: ConfiguratorNameSchema
|
|
824
1515
|
};
|
|
825
|
-
var ConfiguratorParameterSchema =
|
|
826
|
-
|
|
1516
|
+
var ConfiguratorParameterSchema = z5.discriminatedUnion("kind", [
|
|
1517
|
+
z5.strictObject({
|
|
827
1518
|
...ParameterBase,
|
|
828
|
-
kind:
|
|
1519
|
+
kind: z5.literal("number"),
|
|
829
1520
|
/** `mm` is a measure, `count` a number of things (compartments). */
|
|
830
|
-
unit:
|
|
831
|
-
step:
|
|
1521
|
+
unit: z5.enum(["mm", "count"]),
|
|
1522
|
+
step: z5.number().positive().finite(),
|
|
832
1523
|
/** The widest range any model may offer; a model narrows it in its value. */
|
|
833
|
-
min:
|
|
834
|
-
max:
|
|
1524
|
+
min: z5.number().finite(),
|
|
1525
|
+
max: z5.number().finite()
|
|
835
1526
|
}),
|
|
836
|
-
|
|
1527
|
+
z5.strictObject({
|
|
837
1528
|
...ParameterBase,
|
|
838
|
-
kind:
|
|
1529
|
+
kind: z5.literal("choice"),
|
|
839
1530
|
list: ConfiguratorNameSchema
|
|
840
1531
|
}),
|
|
841
|
-
|
|
842
|
-
|
|
1532
|
+
z5.strictObject({ ...ParameterBase, kind: z5.literal("material") }),
|
|
1533
|
+
z5.strictObject({ ...ParameterBase, kind: z5.literal("toggle") })
|
|
843
1534
|
]);
|
|
844
|
-
var
|
|
1535
|
+
var choiceListSchema = (picture) => z5.strictObject({
|
|
845
1536
|
key: ConfiguratorNameSchema,
|
|
846
1537
|
label: LabelSchema,
|
|
847
|
-
options:
|
|
848
|
-
|
|
1538
|
+
options: z5.array(
|
|
1539
|
+
z5.strictObject({
|
|
849
1540
|
code: ConfiguratorCodeSchema,
|
|
850
1541
|
label: LabelSchema,
|
|
851
|
-
image:
|
|
1542
|
+
image: picture.optional()
|
|
852
1543
|
})
|
|
853
1544
|
).min(1).max(CONFIGURATOR_LIMITS.optionsPerList)
|
|
854
1545
|
});
|
|
855
|
-
var
|
|
1546
|
+
var materialSchema = (picture) => z5.strictObject({
|
|
856
1547
|
code: ConfiguratorCodeSchema,
|
|
857
1548
|
label: LabelSchema,
|
|
858
|
-
color:
|
|
859
|
-
texture:
|
|
860
|
-
/** Millimetres one repeat of the texture covers. */
|
|
861
|
-
textureSize:
|
|
862
|
-
|
|
863
|
-
|
|
1549
|
+
color: z5.string().regex(/^#[0-9a-f]{6}$/i, "configurator.invalidColor"),
|
|
1550
|
+
texture: picture.optional(),
|
|
1551
|
+
/** Millimetres one repeat of the texture covers; the two maps below share it. */
|
|
1552
|
+
textureSize: z5.number().positive().max(1e4).optional(),
|
|
1553
|
+
/** Relief that moves with the light: a tangent-space normal map. */
|
|
1554
|
+
normalMap: picture.optional(),
|
|
1555
|
+
/** How deep the relief reads; 1 is the map as drawn. */
|
|
1556
|
+
normalScale: z5.number().min(0).max(3).optional(),
|
|
1557
|
+
/** Where the surface is matte and where it shines (the green channel), times `roughness`. */
|
|
1558
|
+
roughnessMap: picture.optional(),
|
|
1559
|
+
roughness: z5.number().min(0).max(1).optional(),
|
|
1560
|
+
metalness: z5.number().min(0).max(1).optional(),
|
|
864
1561
|
/** How much of what is behind it shows through: glass in front of a flame. Absent is solid. */
|
|
865
|
-
opacity:
|
|
1562
|
+
opacity: z5.number().min(0.05).max(1).optional(),
|
|
866
1563
|
/** Gives light rather than reflecting it: a flame, a LED. */
|
|
867
|
-
emissive:
|
|
1564
|
+
emissive: z5.boolean().optional(),
|
|
868
1565
|
priceGroup: ConfiguratorCodeSchema.optional()
|
|
869
1566
|
});
|
|
870
|
-
var
|
|
871
|
-
|
|
872
|
-
|
|
1567
|
+
var ConfiguratorChoiceListSchema = choiceListSchema(TexturePathSchema);
|
|
1568
|
+
var ConfiguratorMaterialSchema = materialSchema(TexturePathSchema);
|
|
1569
|
+
var ConditionValueSchema = z5.union([
|
|
1570
|
+
z5.number().finite(),
|
|
1571
|
+
z5.boolean(),
|
|
873
1572
|
ConfiguratorCodeSchema,
|
|
874
|
-
|
|
1573
|
+
z5.array(ConfiguratorCodeSchema).min(1).max(CONFIGURATOR_LIMITS.optionsPerList)
|
|
875
1574
|
]);
|
|
876
|
-
var ConfiguratorConditionSchema =
|
|
1575
|
+
var ConfiguratorConditionSchema = z5.strictObject({
|
|
877
1576
|
param: ConfiguratorNameSchema,
|
|
878
|
-
op:
|
|
1577
|
+
op: z5.enum(["eq", "neq", "in", "gte", "lte"]),
|
|
879
1578
|
value: ConditionValueSchema
|
|
880
1579
|
});
|
|
881
|
-
var ConfiguratorRuleSchema =
|
|
1580
|
+
var ConfiguratorRuleSchema = z5.discriminatedUnion("kind", [
|
|
882
1581
|
/** Show `param` only while `when` holds. */
|
|
883
|
-
|
|
884
|
-
kind:
|
|
1582
|
+
z5.strictObject({
|
|
1583
|
+
kind: z5.literal("visible"),
|
|
885
1584
|
param: ConfiguratorNameSchema,
|
|
886
1585
|
when: ConfiguratorConditionSchema
|
|
887
1586
|
}),
|
|
888
1587
|
/** Narrow a number parameter: `max cabinet_height = total_height - 300`. */
|
|
889
|
-
|
|
890
|
-
kind:
|
|
1588
|
+
z5.strictObject({
|
|
1589
|
+
kind: z5.literal("limit"),
|
|
891
1590
|
param: ConfiguratorNameSchema,
|
|
892
|
-
bound:
|
|
1591
|
+
bound: z5.enum(["min", "max"]),
|
|
893
1592
|
formula: FormulaSchema
|
|
894
1593
|
}),
|
|
895
1594
|
/** While `when` holds, these codes of a choice or material are not offered. */
|
|
896
|
-
|
|
897
|
-
kind:
|
|
1595
|
+
z5.strictObject({
|
|
1596
|
+
kind: z5.literal("exclude"),
|
|
898
1597
|
param: ConfiguratorNameSchema,
|
|
899
|
-
codes:
|
|
1598
|
+
codes: z5.array(ConfiguratorCodeSchema).min(1).max(CONFIGURATOR_LIMITS.optionsPerList),
|
|
900
1599
|
when: ConfiguratorConditionSchema
|
|
901
1600
|
})
|
|
902
1601
|
]);
|
|
@@ -906,23 +1605,23 @@ var PriceRuleBase = {
|
|
|
906
1605
|
/** How many times the amount counts, e.g. `compartments`. Absent is once. */
|
|
907
1606
|
multiplier: FormulaSchema.optional()
|
|
908
1607
|
};
|
|
909
|
-
var ConfiguratorPriceRuleSchema =
|
|
1608
|
+
var ConfiguratorPriceRuleSchema = z5.discriminatedUnion("kind", [
|
|
910
1609
|
/** The surcharge the model gives the chosen option. */
|
|
911
|
-
|
|
1610
|
+
z5.strictObject({
|
|
912
1611
|
...PriceRuleBase,
|
|
913
|
-
kind:
|
|
1612
|
+
kind: z5.literal("choice"),
|
|
914
1613
|
param: ConfiguratorNameSchema
|
|
915
1614
|
}),
|
|
916
1615
|
/** The surcharge of the chosen material's price group. */
|
|
917
|
-
|
|
1616
|
+
z5.strictObject({
|
|
918
1617
|
...PriceRuleBase,
|
|
919
|
-
kind:
|
|
1618
|
+
kind: z5.literal("material"),
|
|
920
1619
|
param: ConfiguratorNameSchema
|
|
921
1620
|
}),
|
|
922
1621
|
/** An amount, counted while a switch is on. */
|
|
923
|
-
|
|
1622
|
+
z5.strictObject({
|
|
924
1623
|
...PriceRuleBase,
|
|
925
|
-
kind:
|
|
1624
|
+
kind: z5.literal("toggle"),
|
|
926
1625
|
param: ConfiguratorNameSchema,
|
|
927
1626
|
amount: ConfiguratorNameSchema
|
|
928
1627
|
}),
|
|
@@ -930,35 +1629,64 @@ var ConfiguratorPriceRuleSchema = z3.discriminatedUnion("kind", [
|
|
|
930
1629
|
* A price on measure: `max(0, total_width - 1800) / 1000 * meter_price`.
|
|
931
1630
|
* Reads number parameters (in their unit) and amounts (in cents).
|
|
932
1631
|
*/
|
|
933
|
-
|
|
1632
|
+
z5.strictObject({
|
|
934
1633
|
key: ConfiguratorNameSchema,
|
|
935
1634
|
label: LabelSchema,
|
|
936
|
-
kind:
|
|
1635
|
+
kind: z5.literal("formula"),
|
|
937
1636
|
formula: FormulaSchema
|
|
938
1637
|
})
|
|
939
1638
|
]);
|
|
940
|
-
var
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1639
|
+
var CONFIGURATOR_SCENE_LIGHTINGS = [
|
|
1640
|
+
"studio",
|
|
1641
|
+
"warm",
|
|
1642
|
+
"daylight"
|
|
1643
|
+
];
|
|
1644
|
+
var CONFIGURATOR_SCENE_EDGES = ["soft", "sharp"];
|
|
1645
|
+
var ConfiguratorSceneSchema = z5.strictObject({
|
|
1646
|
+
lighting: z5.enum(CONFIGURATOR_SCENE_LIGHTINGS).optional(),
|
|
1647
|
+
edges: z5.enum(CONFIGURATOR_SCENE_EDGES).optional()
|
|
1648
|
+
});
|
|
1649
|
+
var structureObjectSchema = (files) => z5.strictObject({
|
|
1650
|
+
schemaVersion: z5.literal(1),
|
|
1651
|
+
geometry: geometryDocumentSchema(files.model),
|
|
1652
|
+
/**
|
|
1653
|
+
* How the scene is presented. Part of the structure and not of the shape
|
|
1654
|
+
* document, so the owner sets it under `crm.manage` like a material.
|
|
1655
|
+
*/
|
|
1656
|
+
scene: ConfiguratorSceneSchema.optional(),
|
|
1657
|
+
tabs: z5.array(
|
|
1658
|
+
z5.strictObject({ key: ConfiguratorNameSchema, label: LabelSchema })
|
|
1659
|
+
).min(1).max(CONFIGURATOR_LIMITS.tabs),
|
|
1660
|
+
parameters: z5.array(ConfiguratorParameterSchema).max(CONFIGURATOR_LIMITS.parameters),
|
|
1661
|
+
choiceLists: z5.array(choiceListSchema(files.picture)).max(CONFIGURATOR_LIMITS.choiceLists),
|
|
1662
|
+
materials: z5.array(materialSchema(files.picture)).max(CONFIGURATOR_LIMITS.materials),
|
|
1663
|
+
priceGroups: z5.array(
|
|
1664
|
+
z5.strictObject({ code: ConfiguratorCodeSchema, label: LabelSchema })
|
|
1665
|
+
).max(CONFIGURATOR_LIMITS.priceGroups),
|
|
948
1666
|
/** Named amounts a model fills in and price rules read (`meter_price`). */
|
|
949
|
-
amounts:
|
|
1667
|
+
amounts: z5.array(
|
|
1668
|
+
z5.strictObject({ key: ConfiguratorNameSchema, label: LabelSchema })
|
|
1669
|
+
).max(CONFIGURATOR_LIMITS.amounts),
|
|
950
1670
|
/**
|
|
951
1671
|
* A list of fixed combinations a model offers, each setting some number
|
|
952
1672
|
* parameters at once: a fireplace and the niche it needs.
|
|
953
1673
|
*/
|
|
954
|
-
presets:
|
|
1674
|
+
presets: z5.strictObject({
|
|
955
1675
|
label: LabelSchema,
|
|
956
1676
|
tab: ConfiguratorNameSchema,
|
|
957
|
-
sets:
|
|
958
|
-
required:
|
|
1677
|
+
sets: z5.array(ConfiguratorNameSchema).min(1).max(CONFIGURATOR_LIMITS.parameters),
|
|
1678
|
+
required: z5.boolean()
|
|
959
1679
|
}).optional(),
|
|
960
|
-
rules:
|
|
961
|
-
priceRules:
|
|
1680
|
+
rules: z5.array(ConfiguratorRuleSchema).max(CONFIGURATOR_LIMITS.rules),
|
|
1681
|
+
priceRules: z5.array(ConfiguratorPriceRuleSchema).max(CONFIGURATOR_LIMITS.priceRules)
|
|
1682
|
+
});
|
|
1683
|
+
var ConfiguratorStructureObjectSchema = structureObjectSchema({
|
|
1684
|
+
picture: TexturePathSchema,
|
|
1685
|
+
model: ModelPathSchema
|
|
1686
|
+
});
|
|
1687
|
+
var ServedConfiguratorStructureObjectSchema = structureObjectSchema({
|
|
1688
|
+
picture: ServedFileUrlSchema,
|
|
1689
|
+
model: ServedFileUrlSchema
|
|
962
1690
|
});
|
|
963
1691
|
var ConfiguratorStructureSchema = ConfiguratorStructureObjectSchema.superRefine((structure, ctx) => {
|
|
964
1692
|
for (const problem of configuratorStructureProblems(structure)) {
|
|
@@ -1109,8 +1837,8 @@ function configuratorStructureProblems(structure) {
|
|
|
1109
1837
|
};
|
|
1110
1838
|
structure.rules.forEach((rule, index) => {
|
|
1111
1839
|
const path = ["rules", index];
|
|
1112
|
-
const
|
|
1113
|
-
if (!
|
|
1840
|
+
const target2 = parameters.get(rule.param);
|
|
1841
|
+
if (!target2) {
|
|
1114
1842
|
problems.push(
|
|
1115
1843
|
issue([...path, "param"], "configurator.unknownParam", {
|
|
1116
1844
|
key: rule.param
|
|
@@ -1119,7 +1847,7 @@ function configuratorStructureProblems(structure) {
|
|
|
1119
1847
|
return;
|
|
1120
1848
|
}
|
|
1121
1849
|
if (rule.kind === "limit") {
|
|
1122
|
-
if (
|
|
1850
|
+
if (target2.kind !== "number") {
|
|
1123
1851
|
problems.push(
|
|
1124
1852
|
issue([...path, "param"], "configurator.notANumberParam", {
|
|
1125
1853
|
key: rule.param
|
|
@@ -1137,7 +1865,7 @@ function configuratorStructureProblems(structure) {
|
|
|
1137
1865
|
return;
|
|
1138
1866
|
}
|
|
1139
1867
|
if (rule.kind === "exclude") {
|
|
1140
|
-
const codes = codesOf(
|
|
1868
|
+
const codes = codesOf(target2, lists, materials);
|
|
1141
1869
|
if (!codes) {
|
|
1142
1870
|
problems.push(
|
|
1143
1871
|
issue([...path, "param"], "configurator.notAChoiceParam", {
|
|
@@ -1216,8 +1944,8 @@ function configuratorStructureProblems(structure) {
|
|
|
1216
1944
|
problems
|
|
1217
1945
|
);
|
|
1218
1946
|
}
|
|
1219
|
-
const
|
|
1220
|
-
if (!
|
|
1947
|
+
const target2 = parameters.get(rule.param);
|
|
1948
|
+
if (!target2 || target2.kind !== rule.kind) {
|
|
1221
1949
|
problems.push(
|
|
1222
1950
|
issue([...path, "param"], "configurator.priceRuleParamKind", {
|
|
1223
1951
|
key: rule.param,
|
|
@@ -1243,6 +1971,7 @@ function configuratorStructureProblems(structure) {
|
|
|
1243
1971
|
...geometryDocumentProblems(structure.geometry, {
|
|
1244
1972
|
parameters: geometryParameters,
|
|
1245
1973
|
materialCodes: materials,
|
|
1974
|
+
hasPresets: structure.presets !== void 0,
|
|
1246
1975
|
path: ["geometry"]
|
|
1247
1976
|
})
|
|
1248
1977
|
);
|
|
@@ -1365,55 +2094,55 @@ function parameterResolutionOrder(structure) {
|
|
|
1365
2094
|
}
|
|
1366
2095
|
|
|
1367
2096
|
// ../features/product-configurator/src/shared/configurator-value.schema.ts
|
|
1368
|
-
import { z as
|
|
1369
|
-
var CentsSchema =
|
|
2097
|
+
import { z as z6 } from "zod";
|
|
2098
|
+
var CentsSchema = z6.number().int().min(0).max(1e8);
|
|
1370
2099
|
var PriceSchema = CentsSchema.nullable();
|
|
1371
|
-
var ConfiguratorValueObjectSchema =
|
|
2100
|
+
var ConfiguratorValueObjectSchema = z6.strictObject({
|
|
1372
2101
|
/** Null: the model has no price of its own, every quote is "open". */
|
|
1373
2102
|
basePriceCents: PriceSchema,
|
|
1374
|
-
numbers:
|
|
2103
|
+
numbers: z6.record(
|
|
1375
2104
|
ConfiguratorNameSchema,
|
|
1376
|
-
|
|
1377
|
-
min:
|
|
1378
|
-
max:
|
|
1379
|
-
default:
|
|
2105
|
+
z6.strictObject({
|
|
2106
|
+
min: z6.number().finite(),
|
|
2107
|
+
max: z6.number().finite(),
|
|
2108
|
+
default: z6.number().finite()
|
|
1380
2109
|
})
|
|
1381
2110
|
).default({}),
|
|
1382
2111
|
/** Per choice list: the options this model offers, with their surcharge. */
|
|
1383
|
-
choices:
|
|
2112
|
+
choices: z6.record(
|
|
1384
2113
|
ConfiguratorNameSchema,
|
|
1385
|
-
|
|
1386
|
-
options:
|
|
2114
|
+
z6.strictObject({
|
|
2115
|
+
options: z6.record(
|
|
1387
2116
|
ConfiguratorCodeSchema,
|
|
1388
|
-
|
|
2117
|
+
z6.strictObject({ priceCents: PriceSchema })
|
|
1389
2118
|
),
|
|
1390
2119
|
default: ConfiguratorCodeSchema.optional()
|
|
1391
2120
|
})
|
|
1392
2121
|
).default({}),
|
|
1393
2122
|
/** Per material parameter: the materials this model offers. */
|
|
1394
|
-
materials:
|
|
2123
|
+
materials: z6.record(
|
|
1395
2124
|
ConfiguratorNameSchema,
|
|
1396
|
-
|
|
1397
|
-
offered:
|
|
2125
|
+
z6.strictObject({
|
|
2126
|
+
offered: z6.array(ConfiguratorCodeSchema).max(CONFIGURATOR_LIMITS.materials),
|
|
1398
2127
|
default: ConfiguratorCodeSchema.optional()
|
|
1399
2128
|
})
|
|
1400
2129
|
).default({}),
|
|
1401
2130
|
/** Per switch: whether this model offers it, and how it starts. Absent is offered, off. */
|
|
1402
|
-
toggles:
|
|
2131
|
+
toggles: z6.record(
|
|
1403
2132
|
ConfiguratorNameSchema,
|
|
1404
|
-
|
|
2133
|
+
z6.strictObject({ offered: z6.boolean(), default: z6.boolean() })
|
|
1405
2134
|
).default({}),
|
|
1406
2135
|
/** The surcharge of each material price group. */
|
|
1407
|
-
priceGroups:
|
|
2136
|
+
priceGroups: z6.record(ConfiguratorCodeSchema, PriceSchema).default({}),
|
|
1408
2137
|
/** The amounts the structure names, such as a price per metre. */
|
|
1409
|
-
amounts:
|
|
1410
|
-
presets:
|
|
1411
|
-
|
|
2138
|
+
amounts: z6.record(ConfiguratorNameSchema, PriceSchema).default({}),
|
|
2139
|
+
presets: z6.array(
|
|
2140
|
+
z6.strictObject({
|
|
1412
2141
|
code: ConfiguratorCodeSchema,
|
|
1413
|
-
label:
|
|
2142
|
+
label: z6.string().trim().min(1).max(80),
|
|
1414
2143
|
/** A product of the same account this preset stands for, e.g. the fireplace. */
|
|
1415
|
-
productId:
|
|
1416
|
-
values:
|
|
2144
|
+
productId: z6.string().uuid().nullable(),
|
|
2145
|
+
values: z6.record(ConfiguratorNameSchema, z6.number().finite())
|
|
1417
2146
|
})
|
|
1418
2147
|
).max(CONFIGURATOR_LIMITS.presets).default([])
|
|
1419
2148
|
});
|
|
@@ -1553,7 +2282,8 @@ function resolveConfiguration(params) {
|
|
|
1553
2282
|
max,
|
|
1554
2283
|
step: parameter.step,
|
|
1555
2284
|
unit: parameter.unit,
|
|
1556
|
-
fixed: fixedValue !== void 0
|
|
2285
|
+
fixed: fixedValue !== void 0,
|
|
2286
|
+
settled: fixedValue === void 0 && min === max
|
|
1557
2287
|
});
|
|
1558
2288
|
continue;
|
|
1559
2289
|
}
|
|
@@ -1621,7 +2351,8 @@ function resolveConfiguration(params) {
|
|
|
1621
2351
|
...base,
|
|
1622
2352
|
visible,
|
|
1623
2353
|
value: visible ? current : null,
|
|
1624
|
-
options
|
|
2354
|
+
options,
|
|
2355
|
+
settled: options.length === 1
|
|
1625
2356
|
});
|
|
1626
2357
|
}
|
|
1627
2358
|
const parameters = structure.parameters.map((parameter) => resolved.get(parameter.key)).filter(
|
|
@@ -1652,7 +2383,8 @@ function resolveConfiguration(params) {
|
|
|
1652
2383
|
}
|
|
1653
2384
|
slots[slot.name] = {
|
|
1654
2385
|
color: material?.color ?? slot.color ?? NEUTRAL_SLOT_COLOR,
|
|
1655
|
-
material
|
|
2386
|
+
material,
|
|
2387
|
+
grain: slot.grain === "vertical" ? "vertical" : "horizontal"
|
|
1656
2388
|
};
|
|
1657
2389
|
}
|
|
1658
2390
|
const normalised = { values: {} };
|
|
@@ -1686,13 +2418,13 @@ function onStep(value, step) {
|
|
|
1686
2418
|
}
|
|
1687
2419
|
|
|
1688
2420
|
// ../features/product-configurator/src/shared/expand-geometry.ts
|
|
1689
|
-
function expandGeometry(document, inputs) {
|
|
2421
|
+
function expandGeometry(document, inputs, chosen = { choices: {} }) {
|
|
1690
2422
|
const issues = [];
|
|
1691
2423
|
const parts = [];
|
|
1692
2424
|
const baseScope = { ...document.constants };
|
|
1693
2425
|
for (const input of document.inputs) {
|
|
1694
|
-
const
|
|
1695
|
-
if (
|
|
2426
|
+
const number2 = inputs[input.name];
|
|
2427
|
+
if (number2 === void 0 || !Number.isFinite(number2)) {
|
|
1696
2428
|
issues.push(
|
|
1697
2429
|
issue(["inputs", input.name], "configurator.inputMissing", {
|
|
1698
2430
|
name: input.name
|
|
@@ -1700,11 +2432,14 @@ function expandGeometry(document, inputs) {
|
|
|
1700
2432
|
);
|
|
1701
2433
|
continue;
|
|
1702
2434
|
}
|
|
1703
|
-
baseScope[input.name] =
|
|
2435
|
+
baseScope[input.name] = number2;
|
|
1704
2436
|
}
|
|
1705
2437
|
if (issues.length > 0) {
|
|
1706
2438
|
return { ok: false, issues };
|
|
1707
2439
|
}
|
|
2440
|
+
for (const block of choiceModelBlocks(document.root)) {
|
|
2441
|
+
baseScope[block.name] = modelOf(block.node, chosen) === void 0 ? 0 : 1;
|
|
2442
|
+
}
|
|
1708
2443
|
const evaluate = (formula, scope, path) => {
|
|
1709
2444
|
const result = evaluateFormulaSource(formula, scope);
|
|
1710
2445
|
if (!result.ok) {
|
|
@@ -1783,13 +2518,17 @@ function expandGeometry(document, inputs) {
|
|
|
1783
2518
|
);
|
|
1784
2519
|
return;
|
|
1785
2520
|
}
|
|
2521
|
+
const model = modelOf(node, chosen);
|
|
2522
|
+
if (node.type === "model" && model === void 0) {
|
|
2523
|
+
continue;
|
|
2524
|
+
}
|
|
1786
2525
|
const params = {};
|
|
1787
2526
|
for (const [key, formula] of Object.entries(node.params ?? {})) {
|
|
1788
|
-
const
|
|
1789
|
-
if (
|
|
2527
|
+
const number2 = evaluate(formula, scope, [...nodePath, "params", key]);
|
|
2528
|
+
if (number2 === null) {
|
|
1790
2529
|
return;
|
|
1791
2530
|
}
|
|
1792
|
-
params[key] =
|
|
2531
|
+
params[key] = number2;
|
|
1793
2532
|
}
|
|
1794
2533
|
parts.push({
|
|
1795
2534
|
key: `${node.id}${keySuffix}`,
|
|
@@ -1798,7 +2537,8 @@ function expandGeometry(document, inputs) {
|
|
|
1798
2537
|
slot: node.slot,
|
|
1799
2538
|
size,
|
|
1800
2539
|
at,
|
|
1801
|
-
params
|
|
2540
|
+
params,
|
|
2541
|
+
...model === void 0 ? {} : { model }
|
|
1802
2542
|
});
|
|
1803
2543
|
if (parts.length > CONFIGURATOR_LIMITS.expandedParts) {
|
|
1804
2544
|
issues.push(
|
|
@@ -1852,10 +2592,26 @@ function boundsOf(parts) {
|
|
|
1852
2592
|
}
|
|
1853
2593
|
return { min, max };
|
|
1854
2594
|
}
|
|
2595
|
+
function modelOf(node, chosen) {
|
|
2596
|
+
if (node.follows === void 0) {
|
|
2597
|
+
return node.model;
|
|
2598
|
+
}
|
|
2599
|
+
const code = node.follows === CONFIGURATOR_FOLLOWS_PRESET ? chosen.preset : chosen.choices[node.follows];
|
|
2600
|
+
return code === void 0 ? void 0 : node.models?.[code];
|
|
2601
|
+
}
|
|
1855
2602
|
|
|
1856
2603
|
// src/configurator-tools.ts
|
|
1857
|
-
|
|
1858
|
-
|
|
2604
|
+
import { readFile, stat } from "fs/promises";
|
|
2605
|
+
import { extname } from "path";
|
|
2606
|
+
var FILE_RULES = {
|
|
2607
|
+
model: { extensions: [".glb"], maxBytes: 10 * 1024 * 1024 },
|
|
2608
|
+
texture: {
|
|
2609
|
+
extensions: [".webp", ".jpg", ".jpeg"],
|
|
2610
|
+
maxBytes: 2 * 1024 * 1024
|
|
2611
|
+
}
|
|
2612
|
+
};
|
|
2613
|
+
var CONFIGURATOR = 'A "configurator" field holds a 3D product configurator. Its structure has "geometry" (the shape document: "inputs" bound to parameters, "constants", material "slots", a "root" tree of building blocks and "annotations" for dimension lines), "parameters", "choiceLists", "materials", "priceGroups", "amounts", optional "presets", optional "scene" ({ "lighting": "studio" | "warm" | "daylight" }), "rules" and "priceRules". Building blocks: box, rounded_top_box (params.radius), stadium_prism, arc_panel (params.thickness, params.mirror), led_strip, niche_panel (params.niche_x, niche_y, niche_width, niche_height), model (a "model" path to a .glb uploaded with upload_configurator_file, params.fit 0 contain or 1 stretch, params.paint 1 to use the slot material) and repeat (count, children, index i or j). Every measure is millimetres; "at" is the minimum corner, x left to right, y bottom to top, z from the wall into the room; "size" is width, height, depth. A measure is a number or a formula with + - * / ( ) and min, max, floor, ceil, round, abs over the shape\u2019s inputs, constants and the repeat index. Texture paths in materials come from upload_configurator_file too. Making a configurator, or changing its "geometry", needs a key with the configurator:shape scope; everything around the shape needs only catalog:write.';
|
|
2614
|
+
var documentInput = z7.string().min(2).max(2e5).describe(
|
|
1859
2615
|
'The JSON text of a whole configurator structure (it has "schemaVersion"), or of a shape document alone (it has "root").'
|
|
1860
2616
|
);
|
|
1861
2617
|
function registerConfiguratorTools(server, client) {
|
|
@@ -1863,14 +2619,14 @@ function registerConfiguratorTools(server, client) {
|
|
|
1863
2619
|
"check_configurator_shape",
|
|
1864
2620
|
{
|
|
1865
2621
|
title: "Check a configurator structure or shape document",
|
|
1866
|
-
description: "Checks a configurator structure or a shape document exactly as the
|
|
2622
|
+
description: "Checks a configurator structure or a shape document exactly as the API will when it is saved: the outline, every name a formula reads, bindings, cycles in the rules and the limits. A whole structure is also expanded at its smallest measures, so a part that would come out negative is named. Answers with every problem and where it is, or that the document is sound. " + CONFIGURATOR,
|
|
1867
2623
|
inputSchema: { document: documentInput },
|
|
1868
2624
|
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
1869
2625
|
},
|
|
1870
2626
|
({ document }) => {
|
|
1871
2627
|
const problems = checkDocument(document);
|
|
1872
2628
|
return answer(
|
|
1873
|
-
problems.length === 0 ? "The document is sound.
|
|
2629
|
+
problems.length === 0 ? "The document is sound. upsert_configurator saves it." : [
|
|
1874
2630
|
`${problems.length} problem(s):`,
|
|
1875
2631
|
...problems.map(describeIssue)
|
|
1876
2632
|
].join("\n"),
|
|
@@ -1878,14 +2634,125 @@ function registerConfiguratorTools(server, client) {
|
|
|
1878
2634
|
);
|
|
1879
2635
|
}
|
|
1880
2636
|
);
|
|
2637
|
+
server.registerTool(
|
|
2638
|
+
"upsert_configurator",
|
|
2639
|
+
{
|
|
2640
|
+
title: "Save a configurator",
|
|
2641
|
+
description: 'Makes a configurator field, or changes one, with its whole structure. The structure is checked here first, exactly as check_configurator_shape does, and nothing is sent while it has a problem. Making the field, or changing its "geometry", needs a key with configurator:shape; changing parameters, lists, materials, prices or rules of an existing one needs only catalog:write. A new field needs a `label`. Upload models and textures first with upload_configurator_file and put the paths it answers in the structure. ' + CONFIGURATOR,
|
|
2642
|
+
inputSchema: {
|
|
2643
|
+
slug: CatalogFieldSlugSchema.describe(
|
|
2644
|
+
"The slug of the configurator field."
|
|
2645
|
+
),
|
|
2646
|
+
label: z7.string().trim().min(1).max(120).optional(),
|
|
2647
|
+
structure: z7.string().min(2).max(2e5).describe(
|
|
2648
|
+
'The JSON text of the whole configurator structure (it has "schemaVersion").'
|
|
2649
|
+
),
|
|
2650
|
+
public: z7.boolean().optional().describe("Whether product pages may show the configurator."),
|
|
2651
|
+
...objectInput
|
|
2652
|
+
},
|
|
2653
|
+
annotations: { destructiveHint: true, idempotentHint: true }
|
|
2654
|
+
},
|
|
2655
|
+
async ({ slug, label, structure, public: isPublic, object: object2 }) => {
|
|
2656
|
+
const problems = checkDocument(structure);
|
|
2657
|
+
if (problems.length > 0 || !structure.includes('"schemaVersion"')) {
|
|
2658
|
+
return {
|
|
2659
|
+
isError: true,
|
|
2660
|
+
content: [
|
|
2661
|
+
{
|
|
2662
|
+
type: "text",
|
|
2663
|
+
text: [
|
|
2664
|
+
problems.length > 0 ? `Not saved. ${problems.length} problem(s):` : 'Not saved: this is a shape document alone; save a whole structure (it has "schemaVersion").',
|
|
2665
|
+
...problems.map(describeIssue)
|
|
2666
|
+
].join("\n")
|
|
2667
|
+
}
|
|
2668
|
+
]
|
|
2669
|
+
};
|
|
2670
|
+
}
|
|
2671
|
+
try {
|
|
2672
|
+
const field = await client.upsertField(
|
|
2673
|
+
slug,
|
|
2674
|
+
{
|
|
2675
|
+
...label ? { label } : {},
|
|
2676
|
+
type: "configurator",
|
|
2677
|
+
config: JSON.parse(structure),
|
|
2678
|
+
...isPublic === void 0 ? {} : { public: isPublic }
|
|
2679
|
+
},
|
|
2680
|
+
object2 ? { object: object2 } : void 0
|
|
2681
|
+
);
|
|
2682
|
+
return answer(
|
|
2683
|
+
`${field.created ? "Made" : "Changed"} the configurator ${field.label} (${field.slug}).`,
|
|
2684
|
+
field
|
|
2685
|
+
);
|
|
2686
|
+
} catch (error) {
|
|
2687
|
+
return refusal(error, "configurator:shape");
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
);
|
|
2691
|
+
server.registerTool(
|
|
2692
|
+
"upload_configurator_file",
|
|
2693
|
+
{
|
|
2694
|
+
title: "Upload a model or a texture for a configurator",
|
|
2695
|
+
description: 'Reads one file from the user\u2019s disk and uploads it for a configurator field: a "model" is a binary glTF (.glb, at most 10 MB, everything inside the file, no Draco, no lights of its own, at most 200,000 triangles) and part of the shape, so it needs configurator:shape; a "texture" is a .webp or .jpg picture of at most 2 MB. Answers with the path to put in the structure. The field must exist first (upsert_configurator).',
|
|
2696
|
+
inputSchema: {
|
|
2697
|
+
fieldSlug: CatalogFieldSlugSchema.describe(
|
|
2698
|
+
"The slug of the configurator field."
|
|
2699
|
+
),
|
|
2700
|
+
kind: z7.enum(CONFIGURATOR_FILE_KINDS),
|
|
2701
|
+
filePath: z7.string().min(1).max(1e3).describe("The absolute path of the file on this machine."),
|
|
2702
|
+
...objectInput
|
|
2703
|
+
},
|
|
2704
|
+
annotations: { destructiveHint: false, idempotentHint: false }
|
|
2705
|
+
},
|
|
2706
|
+
async ({ fieldSlug, kind, filePath, object: object2 }) => {
|
|
2707
|
+
const rules = FILE_RULES[kind];
|
|
2708
|
+
const extension = extname(filePath).toLowerCase();
|
|
2709
|
+
if (!rules.extensions.includes(extension)) {
|
|
2710
|
+
return notUploaded(
|
|
2711
|
+
`A ${kind} is a ${rules.extensions.join(" or ")} file; this is "${extension || "no extension"}".`
|
|
2712
|
+
);
|
|
2713
|
+
}
|
|
2714
|
+
const info = await stat(filePath).catch(() => null);
|
|
2715
|
+
if (!info?.isFile()) {
|
|
2716
|
+
return notUploaded(`"${filePath}" is not a file on this machine.`);
|
|
2717
|
+
}
|
|
2718
|
+
if (info.size > rules.maxBytes) {
|
|
2719
|
+
return notUploaded(
|
|
2720
|
+
`The file is ${info.size} bytes; a ${kind} may be at most ${rules.maxBytes}.`
|
|
2721
|
+
);
|
|
2722
|
+
}
|
|
2723
|
+
try {
|
|
2724
|
+
const bytes = new Uint8Array(await readFile(filePath));
|
|
2725
|
+
const result = await client.uploadConfiguratorFile(
|
|
2726
|
+
fieldSlug,
|
|
2727
|
+
{
|
|
2728
|
+
kind,
|
|
2729
|
+
bytes,
|
|
2730
|
+
...kind === "texture" ? {
|
|
2731
|
+
contentType: extension === ".webp" ? "image/webp" : "image/jpeg"
|
|
2732
|
+
} : {}
|
|
2733
|
+
},
|
|
2734
|
+
object2 ? { object: object2 } : void 0
|
|
2735
|
+
);
|
|
2736
|
+
return answer(
|
|
2737
|
+
`Uploaded. Put this path in the structure: ${result.path}`,
|
|
2738
|
+
result
|
|
2739
|
+
);
|
|
2740
|
+
} catch (error) {
|
|
2741
|
+
return refusal(
|
|
2742
|
+
error,
|
|
2743
|
+
kind === "model" ? "configurator:shape" : "catalog:write"
|
|
2744
|
+
);
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
);
|
|
1881
2748
|
server.registerTool(
|
|
1882
2749
|
"read_configurator",
|
|
1883
2750
|
{
|
|
1884
2751
|
title: "Read the configurator of a product",
|
|
1885
2752
|
description: "The configurator field of one product as its page receives it: the structure (texture paths already URLs), the model\u2019s value (ranges, offered options, amounts, presets), the tax a quote uses and the two hashes a request must send back. Useful to draft a new shape from an existing one. " + CONFIGURATOR,
|
|
1886
2753
|
inputSchema: {
|
|
1887
|
-
slugOrId:
|
|
1888
|
-
field:
|
|
2754
|
+
slugOrId: z7.string().min(1).max(200).describe("The address (slug) or the id of the product."),
|
|
2755
|
+
field: z7.string().min(1).max(64).describe("The slug of the configurator field.")
|
|
1889
2756
|
},
|
|
1890
2757
|
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
1891
2758
|
},
|
|
@@ -1904,15 +2771,15 @@ function registerConfiguratorTools(server, client) {
|
|
|
1904
2771
|
}
|
|
1905
2772
|
return answer(`The configurator "${field}" of ${product.name}.`, value);
|
|
1906
2773
|
} catch (error) {
|
|
1907
|
-
return refusal(error);
|
|
2774
|
+
return refusal(error, "catalog:read");
|
|
1908
2775
|
}
|
|
1909
2776
|
}
|
|
1910
2777
|
);
|
|
1911
2778
|
}
|
|
1912
|
-
function checkDocument(
|
|
2779
|
+
function checkDocument(text2) {
|
|
1913
2780
|
let parsed;
|
|
1914
2781
|
try {
|
|
1915
|
-
parsed = JSON.parse(
|
|
2782
|
+
parsed = JSON.parse(text2);
|
|
1916
2783
|
} catch {
|
|
1917
2784
|
return [{ path: [], code: "configurator.notJson" }];
|
|
1918
2785
|
}
|
|
@@ -1956,18 +2823,102 @@ function describeIssue(problem) {
|
|
|
1956
2823
|
const params = problem.params ? ` (${Object.entries(problem.params).map(([key, value]) => `${key}: ${value}`).join(", ")})` : "";
|
|
1957
2824
|
return `- ${where}: ${problem.code}${params}`;
|
|
1958
2825
|
}
|
|
2826
|
+
function notUploaded(text2) {
|
|
2827
|
+
return {
|
|
2828
|
+
isError: true,
|
|
2829
|
+
content: [{ type: "text", text: `Not uploaded. ${text2}` }]
|
|
2830
|
+
};
|
|
2831
|
+
}
|
|
1959
2832
|
function isRecord(value) {
|
|
1960
2833
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1961
2834
|
}
|
|
1962
2835
|
|
|
2836
|
+
// src/event-tools.ts
|
|
2837
|
+
import { z as z8 } from "zod";
|
|
2838
|
+
var moment = z8.string().datetime({ offset: true }).optional().describe("An ISO timestamp with its offset, such as 2026-11-01T00:00:00Z.");
|
|
2839
|
+
function registerEventTools(server, client) {
|
|
2840
|
+
server.registerTool(
|
|
2841
|
+
"list_events",
|
|
2842
|
+
{
|
|
2843
|
+
title: "List upcoming events",
|
|
2844
|
+
description: "The published events of the account, soonest first: title, when and where, the lowest ticket price, whether it is sold out and the link to its ticket page. Both ends of the window are optional and inclusive. Events are made in the app; this cannot create one. May be a minute old.",
|
|
2845
|
+
inputSchema: { ...page, from: moment, to: moment },
|
|
2846
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
2847
|
+
},
|
|
2848
|
+
async (input) => {
|
|
2849
|
+
try {
|
|
2850
|
+
const list = await client.getEvents(input);
|
|
2851
|
+
return answer(
|
|
2852
|
+
list.events.length === 0 ? "No published events in this window." : [...list.events.map(describeEvent), more(list.nextCursor)].filter(Boolean).join("\n"),
|
|
2853
|
+
list
|
|
2854
|
+
);
|
|
2855
|
+
} catch (error) {
|
|
2856
|
+
return refusal(error, "events:read");
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
);
|
|
2860
|
+
server.registerTool(
|
|
2861
|
+
"get_event",
|
|
2862
|
+
{
|
|
2863
|
+
title: "Read one event",
|
|
2864
|
+
description: "One event as its page needs it: description, doors and sales times, address, ticket types with their prices, and the texts for search engines. May be a minute old; ask get_event_availability for seats that must be current.",
|
|
2865
|
+
inputSchema: {
|
|
2866
|
+
idOrSlug: z8.string().min(1).max(200).describe("The id or the address (slug) of the event.")
|
|
2867
|
+
},
|
|
2868
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
2869
|
+
},
|
|
2870
|
+
async ({ idOrSlug }) => {
|
|
2871
|
+
try {
|
|
2872
|
+
const event = await client.getEvent(idOrSlug);
|
|
2873
|
+
if (!event) {
|
|
2874
|
+
return answer(`No event "${idOrSlug}" on this account.`, null);
|
|
2875
|
+
}
|
|
2876
|
+
return answer(
|
|
2877
|
+
`${event.title} (${event.slug}), ${event.status}, ${event.ticketTypes.length} ticket types.`,
|
|
2878
|
+
event
|
|
2879
|
+
);
|
|
2880
|
+
} catch (error) {
|
|
2881
|
+
return refusal(error, "events:read");
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
);
|
|
2885
|
+
server.registerTool(
|
|
2886
|
+
"get_event_availability",
|
|
2887
|
+
{
|
|
2888
|
+
title: "Seats left for an event",
|
|
2889
|
+
description: "Live, not cached: how many seats are left for the event and for each ticket type, and whether it is sold out. A remaining of null means there is no ceiling.",
|
|
2890
|
+
inputSchema: {
|
|
2891
|
+
eventId: z8.string().uuid().describe("The id of the event.")
|
|
2892
|
+
},
|
|
2893
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
2894
|
+
},
|
|
2895
|
+
async ({ eventId }) => {
|
|
2896
|
+
try {
|
|
2897
|
+
const seats = await client.getEventAvailability(eventId);
|
|
2898
|
+
if (!seats) {
|
|
2899
|
+
return answer(`No event "${eventId}" on this account.`, null);
|
|
2900
|
+
}
|
|
2901
|
+
return answer(
|
|
2902
|
+
seats.soldOut ? "Sold out." : `${seats.remaining ?? "No limit on"} seats left.`,
|
|
2903
|
+
seats
|
|
2904
|
+
);
|
|
2905
|
+
} catch (error) {
|
|
2906
|
+
return refusal(error, "events:read");
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
);
|
|
2910
|
+
}
|
|
2911
|
+
function describeEvent(event) {
|
|
2912
|
+
const where = event.locationName ? `, ${event.locationName}` : "";
|
|
2913
|
+
const price = event.priceFromCents !== null && event.currency ? `, from ${money(event.priceFromCents, event.currency)}` : "";
|
|
2914
|
+
const sold = event.soldOut ? ", sold out" : "";
|
|
2915
|
+
return `- ${event.title} (id ${event.id}): ${event.startsAt}${where}${price}${sold} \u2014 ${event.url}`;
|
|
2916
|
+
}
|
|
2917
|
+
|
|
1963
2918
|
// src/placement-tools.ts
|
|
1964
|
-
import { z as
|
|
2919
|
+
import { z as z9 } from "zod";
|
|
1965
2920
|
var REACT_HINT = "On a React or Next.js site, prefer the components in `@solumflow-app/crm-client/react` (CrmForm, CrmBooking, CrmChatWidget, CrmSiteTracking) over pasting HTML.";
|
|
1966
|
-
var
|
|
1967
|
-
cursor: z6.string().optional().describe("The nextCursor of the previous page; leave out for the first."),
|
|
1968
|
-
limit: z6.number().int().min(1).max(100).optional()
|
|
1969
|
-
};
|
|
1970
|
-
var origin = z6.string().regex(/^https?:\/\/[a-zA-Z0-9.-]+(:[0-9]{1,5})?$/).describe("Scheme, host and optional port; no path: https://www.example.com");
|
|
2921
|
+
var origin = z9.string().regex(/^https?:\/\/[a-zA-Z0-9.-]+(:[0-9]{1,5})?$/).describe("Scheme, host and optional port; no path: https://www.example.com");
|
|
1971
2922
|
function registerPlacementTools(server, client) {
|
|
1972
2923
|
server.registerTool(
|
|
1973
2924
|
"list_forms",
|
|
@@ -1987,7 +2938,7 @@ function registerPlacementTools(server, client) {
|
|
|
1987
2938
|
list
|
|
1988
2939
|
);
|
|
1989
2940
|
} catch (error) {
|
|
1990
|
-
return refusal(error);
|
|
2941
|
+
return refusal(error, "website:manage");
|
|
1991
2942
|
}
|
|
1992
2943
|
}
|
|
1993
2944
|
);
|
|
@@ -2007,7 +2958,7 @@ function registerPlacementTools(server, client) {
|
|
|
2007
2958
|
pages
|
|
2008
2959
|
);
|
|
2009
2960
|
} catch (error) {
|
|
2010
|
-
return refusal(error);
|
|
2961
|
+
return refusal(error, "website:manage");
|
|
2011
2962
|
}
|
|
2012
2963
|
}
|
|
2013
2964
|
);
|
|
@@ -2030,7 +2981,7 @@ function registerPlacementTools(server, client) {
|
|
|
2030
2981
|
list
|
|
2031
2982
|
);
|
|
2032
2983
|
} catch (error) {
|
|
2033
|
-
return refusal(error);
|
|
2984
|
+
return refusal(error, "website:manage");
|
|
2034
2985
|
}
|
|
2035
2986
|
}
|
|
2036
2987
|
);
|
|
@@ -2040,13 +2991,13 @@ function registerPlacementTools(server, client) {
|
|
|
2040
2991
|
title: "Make or change a chat widget",
|
|
2041
2992
|
description: "Makes the chat widget at this address when there is none, or changes it. Sets its name, whether it is live, and which sites it may run on. How it looks and what it says is done in the app\u2019s builder. Sites are added and removed, never replaced. Careful: an empty list means ANY site, so adding the first origin narrows the widget to that site only, and removing an origin takes the chat off a site that may be live.",
|
|
2042
2993
|
inputSchema: {
|
|
2043
|
-
slug:
|
|
2994
|
+
slug: z9.string().regex(/^[a-z][a-z0-9-]*$/).max(100).describe(
|
|
2044
2995
|
'The widget\u2019s address on this account, such as "website-chat".'
|
|
2045
2996
|
),
|
|
2046
|
-
name:
|
|
2047
|
-
status:
|
|
2048
|
-
addAllowedOrigins:
|
|
2049
|
-
removeAllowedOrigins:
|
|
2997
|
+
name: z9.string().min(1).max(200).optional().describe("Required when the widget does not exist yet."),
|
|
2998
|
+
status: z9.enum(["draft", "published"]).optional().describe("Only a published widget answers on a site."),
|
|
2999
|
+
addAllowedOrigins: z9.array(origin).max(20).optional(),
|
|
3000
|
+
removeAllowedOrigins: z9.array(origin).max(20).optional()
|
|
2050
3001
|
},
|
|
2051
3002
|
annotations: {
|
|
2052
3003
|
readOnlyHint: false,
|
|
@@ -2064,7 +3015,7 @@ ${describeChatWidget(widget)}`,
|
|
|
2064
3015
|
widget
|
|
2065
3016
|
);
|
|
2066
3017
|
} catch (error) {
|
|
2067
|
-
return refusal(error);
|
|
3018
|
+
return refusal(error, "website:manage");
|
|
2068
3019
|
}
|
|
2069
3020
|
}
|
|
2070
3021
|
);
|
|
@@ -2084,7 +3035,7 @@ ${describeChatWidget(widget)}`,
|
|
|
2084
3035
|
site
|
|
2085
3036
|
);
|
|
2086
3037
|
} catch (error) {
|
|
2087
|
-
return refusal(error);
|
|
3038
|
+
return refusal(error, "website:manage");
|
|
2088
3039
|
}
|
|
2089
3040
|
}
|
|
2090
3041
|
);
|
|
@@ -2094,9 +3045,9 @@ ${describeChatWidget(widget)}`,
|
|
|
2094
3045
|
title: "Set up or change the tracking script",
|
|
2095
3046
|
description: "Sets website tracking up when it is not there, adds or removes the hosts it counts visits from, and changes the consent it waits for. An empty list of hosts counts nothing, so add the host of every site the script is placed on. Hosts are added and removed, never replaced.",
|
|
2096
3047
|
inputSchema: {
|
|
2097
|
-
addAllowedHosts:
|
|
2098
|
-
removeAllowedHosts:
|
|
2099
|
-
consentSignal:
|
|
3048
|
+
addAllowedHosts: z9.array(z9.string()).max(20).optional().describe('Hosts such as "www.example.com" or "*.example.com".'),
|
|
3049
|
+
removeAllowedHosts: z9.array(z9.string()).max(20).optional(),
|
|
3050
|
+
consentSignal: z9.enum([
|
|
2100
3051
|
"ad_storage",
|
|
2101
3052
|
"analytics_storage",
|
|
2102
3053
|
"personalization_storage",
|
|
@@ -2121,7 +3072,7 @@ ${describeSiteTracking(site)}`,
|
|
|
2121
3072
|
site
|
|
2122
3073
|
);
|
|
2123
3074
|
} catch (error) {
|
|
2124
|
-
return refusal(error);
|
|
3075
|
+
return refusal(error, "website:manage");
|
|
2125
3076
|
}
|
|
2126
3077
|
}
|
|
2127
3078
|
);
|
|
@@ -2155,9 +3106,6 @@ function describeSiteTracking(site) {
|
|
|
2155
3106
|
codeBlock(site.embed)
|
|
2156
3107
|
].join("\n");
|
|
2157
3108
|
}
|
|
2158
|
-
function more(nextCursor) {
|
|
2159
|
-
return nextCursor ? `More on the next page: cursor ${nextCursor}` : "";
|
|
2160
|
-
}
|
|
2161
3109
|
|
|
2162
3110
|
// src/server-config.ts
|
|
2163
3111
|
function readServerConfig(env) {
|
|
@@ -2166,13 +3114,13 @@ function readServerConfig(env) {
|
|
|
2166
3114
|
if (!apiKey) {
|
|
2167
3115
|
return {
|
|
2168
3116
|
ok: false,
|
|
2169
|
-
error: 'SOLUMFLOW_API_KEY is not set. Make a secret key with the
|
|
3117
|
+
error: 'SOLUMFLOW_API_KEY is not set. Make a secret key in the app (Settings \u2192 API keys) with the scopes the agent needs: "website:manage" to place things on a site, "catalog:read" and "events:read" to read, "catalog:write" to fill the catalogue, and "configurator:shape" -- which only a reseller can give -- to make a 3D configurator and upload its models. Settings \u2192 Integrations \u2192 MCP server makes such a key and the command to add this server in one go. Pass it in the environment.'
|
|
2170
3118
|
};
|
|
2171
3119
|
}
|
|
2172
3120
|
if (!apiKey.startsWith("crms_")) {
|
|
2173
3121
|
return {
|
|
2174
3122
|
ok: false,
|
|
2175
|
-
error:
|
|
3123
|
+
error: "SOLUMFLOW_API_KEY is not a secret key. This server needs a `crms_` key: a `crmp_` key is meant for a browser and can never place things on a website or write the catalogue."
|
|
2176
3124
|
};
|
|
2177
3125
|
}
|
|
2178
3126
|
if (!/^https?:\/\/[^/]+$/.test(baseUrl)) {
|
|
@@ -2193,7 +3141,9 @@ async function main() {
|
|
|
2193
3141
|
}
|
|
2194
3142
|
const server = new McpServer({
|
|
2195
3143
|
name: "crm-mcp-server",
|
|
2196
|
-
version
|
|
3144
|
+
// Read from the package, so the version an agent is told is the one
|
|
3145
|
+
// that was published.
|
|
3146
|
+
version
|
|
2197
3147
|
});
|
|
2198
3148
|
const client = createClient({
|
|
2199
3149
|
apiKey: config.apiKey,
|
|
@@ -2201,6 +3151,8 @@ async function main() {
|
|
|
2201
3151
|
});
|
|
2202
3152
|
registerPlacementTools(server, client);
|
|
2203
3153
|
registerCatalogTools(server, client);
|
|
3154
|
+
registerCatalogWriteTools(server, client);
|
|
3155
|
+
registerEventTools(server, client);
|
|
2204
3156
|
registerConfiguratorTools(server, client);
|
|
2205
3157
|
await server.connect(new StdioServerTransport());
|
|
2206
3158
|
}
|