@saltcorn/copilot 0.7.4 → 0.8.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/builder-gen.js CHANGED
@@ -1,33 +1,1512 @@
1
1
  const { getState } = require("@saltcorn/data/db/state");
2
- const WorkflowStep = require("@saltcorn/data/models/workflow_step");
3
- const Trigger = require("@saltcorn/data/models/trigger");
4
2
  const Table = require("@saltcorn/data/models/table");
5
- const { getActionConfigFields } = require("@saltcorn/data/plugin-helper");
3
+ const Trigger = require("@saltcorn/data/models/trigger");
4
+ const View = require("@saltcorn/data/models/view");
5
+ const { edit_build_in_actions } = require("@saltcorn/data/viewable_fields");
6
+ const { buildBuilderSchema } = require("./builder-schema");
7
+ const { getLlmConfigurationSafe, canUseResponseFormat } = require("./common");
8
+ const { build_schema_data } = require("@saltcorn/data/plugin-helper");
9
+ const { RelationsFinder } = require("@saltcorn/common-code");
10
+ const { RelationType } = require("@saltcorn/common-code");
6
11
 
7
- module.exports = {
8
- run: async (prompt, mode, table) => {
9
- const str = await getState().functions.llm_generate.run(
10
- `Generate an HTML snippet according to the requirement below. Your snippet will be
11
- placed inside a page which has loaded the Bootstrap 5 CSS framework, so you can use any
12
- Bootstrap 5 classes.
13
-
14
- If you need to run javascript in script tag that depends on external reosurces, you wrap this
15
- in a DOMContentLoaded event handler as external javascript resources may be loaded after your HTML snippet is included.
16
-
17
- Include only the HTML snippet with no explanation before or after the code snippet.
18
-
19
- Generate the HTML5 snippet for this request: ${prompt}
20
- `,
12
+ const ACTION_SIZES = ["btn-sm", "btn-lg"];
13
+
14
+ // ── Edit-mode guidance blocks ─────────────────────────────────────────────────
15
+
16
+ const EDIT_FIELD_INCLUSION = `\
17
+ Layout is a form for editing a single row. \
18
+ Include ALL non-primary-key, non-calculated fields from the table as inputs — \
19
+ not just fields mentioned in the task description. \
20
+ Never include calculated fields (aggregations, derived values) in an edit form they are read-only. \
21
+ The only fields that may be omitted are FK fields to the users table that are explicitly \
22
+ designated as ownership fields (set automatically from the logged-in user). \
23
+ All other foreign key fields — including parent-context keys like trip_id — MUST appear as \
24
+ selector inputs; Saltcorn auto-fills them from the URL state when opened in context.`;
25
+
26
+ const EDIT_FIELDVIEW_SELECTION = `\
27
+ For date fields always prefer fieldview "flatpickr" when available — it provides the best user experience \
28
+ and works for both regular dates and day-only dates. \
29
+ Only use fieldview "edit_day" as a fallback when the field has day_only=true and flatpickr is not installed. \
30
+ For String fields that have an options attribute (a comma-separated list of fixed choices), \
31
+ use fieldview "select" — this renders a dropdown with those options. \
32
+ Do not use "select_by_code" for fields with fixed options.`;
33
+
34
+ const EDIT_LAYOUT_STRUCTURE = `\
35
+ Every field MUST be preceded by a label. \
36
+ Use a blank segment with the field's label text immediately above each field inside an above array, like: \
37
+ {"above": [{"type":"blank","contents":"Field Label","class":""}, {"type":"field","field_name":"...","fieldview":"edit"}]}. \
38
+ Alternatively wrap groups of fields in a card with a descriptive title.`;
39
+
40
+ const EDIT_ACTIONS = `\
41
+ Use edit fieldviews, group related inputs, and finish with a row of actions: \
42
+ a Save button (action_name "Save", style "btn btn-primary") and \
43
+ a Cancel button (action_name "GoBack", style "btn btn-outline-secondary").`;
44
+
45
+ // ── Show-mode guidance blocks ─────────────────────────────────────────────────
46
+
47
+ const SHOW_PURPOSE = `\
48
+ Layout displays one record read-only. \
49
+ Use show fieldviews only (e.g. "show", "as_text", "showDay").`;
50
+
51
+ const SHOW_NO_EDIT_FIELDVIEWS = `\
52
+ A show layout is read-only and must never use edit fieldviews \
53
+ (textarea, edit, input, select) — those are for form inputs, not display.`;
54
+
55
+ const SHOW_LABELS = `\
56
+ Every field MUST be preceded by a label using a blank segment or \
57
+ grouped in a card with a descriptive title.`;
58
+
59
+ // ── List-columns guidance blocks ──────────────────────────────────────────────
60
+
61
+ const LISTCOLUMNS_STRUCTURE = `\
62
+ Layout defines list columns for a list view. \
63
+ Use a list_columns wrapper with besides entries containing list_column segments. \
64
+ Each list_column has a header_label for the column heading — \
65
+ do NOT add any blank or label segment inside a list_column. \
66
+ Typically each list_column contains a single field, action, or view_link.`;
67
+
68
+ const LISTCOLUMNS_FIELDVIEWS = `\
69
+ Include all meaningful fields as columns, including foreign key fields. \
70
+ For a foreign key field use fieldview "show" so the related record's label is displayed rather than a raw ID. \
71
+ For string/text fields use fieldview "as_text" or "show" by default; \
72
+ only use "show_with_html" when the task explicitly requires rendering HTML content.`;
73
+
74
+ const LISTCOLUMNS_VIEW_LINK = `\
75
+ A view_link that opens the detail of a row MUST point to a Show-type view (viewtemplate "Show"). \
76
+ Only include such a view_link if a Show view for this table is listed in the available views.`;
77
+
78
+ // ── Composed MODE_GUIDANCE ────────────────────────────────────────────────────
79
+
80
+ const MODE_GUIDANCE = {
81
+ edit: [
82
+ EDIT_FIELD_INCLUSION,
83
+ EDIT_ACTIONS,
84
+ EDIT_FIELDVIEW_SELECTION,
85
+ EDIT_LAYOUT_STRUCTURE,
86
+ ].join(" "),
87
+
88
+ show: [SHOW_PURPOSE, SHOW_NO_EDIT_FIELDVIEWS, SHOW_LABELS].join(" "),
89
+
90
+ list: "Layout represents a single row in a list. Highlight key fields, keep actions compact, and support filtering if requested.",
91
+
92
+ listcolumns: [
93
+ LISTCOLUMNS_STRUCTURE,
94
+ LISTCOLUMNS_FIELDVIEWS,
95
+ LISTCOLUMNS_VIEW_LINK,
96
+ ].join(" "),
97
+
98
+ filter:
99
+ "Layout lets users define filters. Provide appropriate filter inputs plus an action to run or reset filters.",
100
+
101
+ page: "Layout builds a general app page. Combine hero text, cards, containers, and call-to-action buttons.",
102
+
103
+ default:
104
+ "Use Saltcorn layout primitives (above, besides, container, card, tabs, blank, field, action, view_link, view). Do not return HTML snippets.",
105
+ };
106
+
107
+ const stripCodeFences = (text) => text.replace(/```(?:json)?/gi, "").trim();
108
+
109
+ const stripHtmlTags = (text) =>
110
+ String(text || "")
111
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
112
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
113
+ .replace(/<[^>]+>/g, " ")
114
+ .replace(/\s+/g, " ")
115
+ .trim();
116
+
117
+ const attrValue = (node, key) => {
118
+ if (!node) return undefined;
119
+ if (Object.prototype.hasOwnProperty.call(node, key)) return node[key];
120
+ if (
121
+ node.attributes &&
122
+ Object.prototype.hasOwnProperty.call(node.attributes, key)
123
+ )
124
+ return node.attributes[key];
125
+ return undefined;
126
+ };
127
+
128
+ const pickAttrValue = (node, keys) => {
129
+ for (const key of keys) {
130
+ const val = attrValue(node, key);
131
+ if (val !== undefined) return val;
132
+ }
133
+ return undefined;
134
+ };
135
+
136
+ const firstItem = (value) => (Array.isArray(value) ? value[0] : value);
137
+
138
+ const findBalancedBlock = (text, openChar, closeChar) => {
139
+ const start = text.indexOf(openChar);
140
+ if (start === -1) return null;
141
+ let depth = 0;
142
+ let inString = false;
143
+ let escape = false;
144
+ for (let i = start; i < text.length; i++) {
145
+ const ch = text[i];
146
+ if (inString) {
147
+ if (escape) escape = false;
148
+ else if (ch === "\\") escape = true;
149
+ else if (ch === '"') inString = false;
150
+ continue;
151
+ }
152
+ if (ch === '"') {
153
+ inString = true;
154
+ continue;
155
+ }
156
+ if (ch === openChar) depth++;
157
+ else if (ch === closeChar) {
158
+ depth--;
159
+ if (depth === 0) return text.slice(start, i + 1);
160
+ }
161
+ }
162
+ return null;
163
+ };
164
+
165
+ const extractJsonStructure = (text) => {
166
+ if (!text) return null;
167
+ const cleaned = stripCodeFences(String(text));
168
+ const attempt = (candidate) => {
169
+ if (!candidate) return null;
170
+ try {
171
+ return JSON.parse(candidate);
172
+ } catch (err) {
173
+ return null;
174
+ }
175
+ };
176
+
177
+ const trimmed = cleaned.trim();
178
+ let parsed = attempt(trimmed);
179
+ if (parsed) return parsed;
180
+
181
+ const eqIdx = trimmed.indexOf("=");
182
+ if (eqIdx !== -1) {
183
+ parsed = attempt(trimmed.slice(eqIdx + 1).trim());
184
+ if (parsed) return parsed;
185
+ }
186
+
187
+ const arrayBlock = findBalancedBlock(trimmed, "[", "]");
188
+ if (arrayBlock) {
189
+ parsed = attempt(arrayBlock);
190
+ if (parsed) return parsed;
191
+ }
192
+ const objectBlock = findBalancedBlock(trimmed, "{", "}");
193
+ if (objectBlock) {
194
+ parsed = attempt(objectBlock);
195
+ if (parsed) return parsed;
196
+ }
197
+ return null;
198
+ };
199
+
200
+ const getSchemaByRef = (root, ref) => {
201
+ if (!ref || typeof ref !== "string" || !ref.startsWith("#/")) return null;
202
+ const parts = ref
203
+ .slice(2)
204
+ .split("/")
205
+ .map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"));
206
+ let current = root;
207
+ for (const part of parts) {
208
+ if (!current || typeof current !== "object") return null;
209
+ current = current[part];
210
+ }
211
+ return current || null;
212
+ };
213
+
214
+ const validateSchemaRefs = (schema) => {
215
+ const missing = [];
216
+ const walk = (node) => {
217
+ if (!node || typeof node !== "object") return;
218
+ if (Array.isArray(node)) {
219
+ node.forEach((item) => walk(item));
220
+ return;
221
+ }
222
+ if (typeof node.$ref === "string") {
223
+ const target = getSchemaByRef(schema, node.$ref);
224
+ if (!target) missing.push(node.$ref);
225
+ }
226
+ for (const key of Object.keys(node)) walk(node[key]);
227
+ };
228
+ walk(schema);
229
+ if (missing.length) {
230
+ throw new Error(`Schema $ref targets not found: ${missing.join(", ")}`);
231
+ }
232
+ };
233
+
234
+ const schemaHasRef = (schema) => {
235
+ let found = false;
236
+ const walk = (node) => {
237
+ if (found || !node || typeof node !== "object") return;
238
+ if (Array.isArray(node)) {
239
+ node.forEach((item) => walk(item));
240
+ return;
241
+ }
242
+ if (typeof node.$ref === "string") {
243
+ found = true;
244
+ return;
245
+ }
246
+ Object.keys(node).forEach((key) => walk(node[key]));
247
+ };
248
+ walk(schema);
249
+ return found;
250
+ };
251
+
252
+ const derefSchema = (schema, maxDepth = 3) => {
253
+ const root = JSON.parse(JSON.stringify(schema));
254
+ const expand = (node, stack) => {
255
+ if (!node || typeof node !== "object") return node;
256
+ if (Array.isArray(node)) return node.map((item) => expand(item, stack));
257
+ if (typeof node.$ref === "string") {
258
+ if (stack.length >= maxDepth) {
259
+ return { $ref: node.$ref };
260
+ }
261
+ if (stack.includes(node.$ref)) {
262
+ return { $ref: node.$ref };
263
+ }
264
+ const target = getSchemaByRef(root, node.$ref);
265
+ if (!target) throw new Error(`Schema $ref not found: ${node.$ref}`);
266
+ return expand({ ...target }, [...stack, node.$ref]);
267
+ }
268
+ const out = {};
269
+ for (const key of Object.keys(node)) {
270
+ if (key === "$ref") continue;
271
+ out[key] = expand(node[key], stack);
272
+ }
273
+ return out;
274
+ };
275
+ return expand(root, []);
276
+ };
277
+
278
+ const simplifySchemaForLlm = (schema) => {
279
+ const deref = derefSchema(schema, 2); // Reduced maxDepth
280
+ const scrub = (node, keyName, depth = 0) => {
281
+ if (!node || typeof node !== "object" || depth > 4) {
282
+ // Added depth limit
283
+ if (
284
+ keyName === "contents" ||
285
+ keyName === "besides" ||
286
+ keyName === "above"
287
+ )
288
+ return {
289
+ type: "string",
290
+ description: "Simplified content: a string or a basic segment.",
291
+ };
292
+ return node;
293
+ }
294
+ if (Array.isArray(node))
295
+ return node.map((item) => scrub(item, undefined, depth + 1));
296
+ if (keyName === "style") {
297
+ return {
298
+ type: "string",
299
+ description: "CSS style string (e.g. background-color: red;).",
300
+ };
301
+ }
302
+ if (typeof node.$ref === "string") {
303
+ return {
304
+ type: "object",
305
+ description: "Simplified recursive segment.",
306
+ additionalProperties: false,
307
+ };
308
+ }
309
+ const out = {};
310
+ for (const [key, val] of Object.entries(node)) {
311
+ if (key === "$defs" || key === "definitions") continue;
312
+ out[key] = scrub(val, key, depth + 1);
313
+ }
314
+ const nodeType = out.type;
315
+ const isObjectType =
316
+ nodeType === "object" ||
317
+ (Array.isArray(nodeType) && nodeType.includes("object")) ||
318
+ Object.prototype.hasOwnProperty.call(out, "properties");
319
+ const isArrayType =
320
+ nodeType === "array" ||
321
+ (Array.isArray(nodeType) && nodeType.includes("array")) ||
322
+ Object.prototype.hasOwnProperty.call(out, "items");
323
+ const isNumberType =
324
+ nodeType === "number" ||
325
+ nodeType === "integer" ||
326
+ (Array.isArray(nodeType) &&
327
+ (nodeType.includes("number") || nodeType.includes("integer")));
328
+ if (isObjectType) {
329
+ out.additionalProperties = false;
330
+ }
331
+ if (isArrayType && typeof out.minItems !== "undefined") {
332
+ const minItems = Number(out.minItems);
333
+ if (Number.isFinite(minItems) && minItems > 1) out.minItems = 1;
334
+ }
335
+ if (isNumberType) {
336
+ delete out.minimum;
337
+ delete out.maximum;
338
+ delete out.exclusiveMinimum;
339
+ delete out.exclusiveMaximum;
340
+ delete out.multipleOf;
341
+ }
342
+ return out;
343
+ };
344
+ return scrub(deref);
345
+ };
346
+
347
+ const countOptionalParams = (schema) => {
348
+ let count = 0;
349
+ const walk = (node) => {
350
+ if (!node || typeof node !== "object") return;
351
+ if (Array.isArray(node)) {
352
+ node.forEach(walk);
353
+ return;
354
+ }
355
+ const props = node.properties;
356
+ if (props && typeof props === "object") {
357
+ const required = Array.isArray(node.required) ? node.required : [];
358
+ const optional = Object.keys(props).filter(
359
+ (key) => !required.includes(key)
360
+ );
361
+ count += optional.length;
362
+ Object.values(props).forEach(walk);
363
+ }
364
+ if (node.items) walk(node.items);
365
+ if (node.anyOf) node.anyOf.forEach(walk);
366
+ if (node.oneOf) node.oneOf.forEach(walk);
367
+ if (node.allOf) node.allOf.forEach(walk);
368
+ };
369
+ walk(schema);
370
+ return count;
371
+ };
372
+
373
+ const trimOptionalProperties = (schema, maxOptional) => {
374
+ if (!schema || typeof schema !== "object") return schema;
375
+
376
+ const clonedSchema = JSON.parse(JSON.stringify(schema));
377
+ let optionalCount = countOptionalParams(clonedSchema);
378
+ if (optionalCount <= maxOptional) {
379
+ return clonedSchema;
380
+ }
381
+
382
+ const optionalPropsByNode = new Map();
383
+
384
+ const findOptional = (node, path = []) => {
385
+ if (!node || typeof node !== "object") return;
386
+ if (Array.isArray(node)) {
387
+ node.forEach((item, i) => findOptional(item, [...path, i]));
388
+ return;
389
+ }
390
+
391
+ const props = node.properties;
392
+ if (props && typeof props === "object") {
393
+ const required = new Set(node.required || []);
394
+ const optionalKeys = Object.keys(props).filter((k) => !required.has(k));
395
+ if (optionalKeys.length > 0) {
396
+ optionalPropsByNode.set(node, optionalKeys);
397
+ }
398
+ Object.values(props).forEach((propNode) => findOptional(propNode));
399
+ }
400
+
401
+ if (node.items) findOptional(node.items, [...path, "items"]);
402
+ if (node.anyOf)
403
+ node.anyOf.forEach((item, i) =>
404
+ findOptional(item, [...path, "anyOf", i])
405
+ );
406
+ if (node.oneOf)
407
+ node.oneOf.forEach((item, i) =>
408
+ findOptional(item, [...path, "oneOf", i])
409
+ );
410
+ if (node.allOf)
411
+ node.allOf.forEach((item, i) =>
412
+ findOptional(item, [...path, "allOf", i])
413
+ );
414
+ };
415
+
416
+ findOptional(clonedSchema);
417
+
418
+ const nodesWithOptionals = Array.from(optionalPropsByNode.keys());
419
+ let nodeIndex = 0;
420
+
421
+ while (optionalCount > maxOptional && nodeIndex < nodesWithOptionals.length) {
422
+ const node = nodesWithOptionals[nodeIndex];
423
+ const optionalKeys = optionalPropsByNode.get(node);
424
+
425
+ if (optionalKeys && optionalKeys.length > 0) {
426
+ const keyToRemove = optionalKeys.pop();
427
+ delete node.properties[keyToRemove];
428
+ optionalCount--;
429
+ } else {
430
+ nodeIndex++;
431
+ }
432
+ }
433
+
434
+ return clonedSchema;
435
+ };
436
+
437
+ const randomId = () =>
438
+ Math.floor(Math.random() * 0xffffff)
439
+ .toString(16)
440
+ .padStart(6, "0");
441
+
442
+ const toCamelCaseStyle = (key) =>
443
+ key.replace(/-([a-z])/g, (_m, chr) => chr.toUpperCase());
444
+
445
+ const parseStyleString = (styleStr) => {
446
+ if (typeof styleStr !== "string") return styleStr;
447
+ const trimmed = styleStr.trim();
448
+ if (!trimmed) return {};
449
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
450
+ try {
451
+ const parsed = JSON.parse(trimmed);
452
+ return parsed && typeof parsed === "object" ? parsed : {};
453
+ } catch (err) {
454
+ // fall through to CSS parsing
455
+ }
456
+ }
457
+ const out = {};
458
+ trimmed.split(";").forEach((part) => {
459
+ const section = part.trim();
460
+ if (!section) return;
461
+ const idx = section.indexOf(":");
462
+ if (idx === -1) return;
463
+ const rawKey = section.slice(0, idx).trim();
464
+ const value = section.slice(idx + 1).trim();
465
+ if (!rawKey || !value) return;
466
+ if (rawKey.startsWith("--")) out[rawKey] = value;
467
+ else out[toCamelCaseStyle(rawKey)] = value;
468
+ });
469
+ return out;
470
+ };
471
+
472
+ const ensureArray = (value) =>
473
+ Array.isArray(value) ? value : value == null ? [] : [value];
474
+
475
+ const prettifyActionName = (name) =>
476
+ (name || "")
477
+ .replace(/_/g, " ")
478
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
479
+ .replace(/\s+/g, " ")
480
+ .trim();
481
+
482
+ // Picks a valid fieldview from the field's available fieldviews only.
483
+ // Never returns a fieldview that doesn't exist in field.fieldviews
484
+ const pickFieldview = (field, mode, requestedFieldview = null) => {
485
+ const availableViews = field?.fieldviews || [];
486
+
487
+ // If no available fieldviews, return the first one or a safe default
488
+ if (!availableViews.length) {
489
+ // Return the first available or fall back based on mode
490
+ return mode === "edit" || mode === "filter" ? "edit" : "show";
491
+ }
492
+
493
+ // Helper to validate and return a fieldview only if it exists (exact match only)
494
+ const validateAndReturn = (candidate) => {
495
+ if (!candidate) return null;
496
+ const lower = String(candidate).toLowerCase();
497
+ return (
498
+ availableViews.find((fv) => String(fv).toLowerCase() === lower) || null
499
+ );
500
+ };
501
+
502
+ // Edit-only fieldviews must never be used in show/list modes
503
+ const EDIT_ONLY_FIELDVIEWS = new Set(["textarea", "edit", "input", "select"]);
504
+ const isShowMode =
505
+ mode === "show" || mode === "list" || mode === "listcolumns";
506
+
507
+ // For date fields in edit mode: flatpickr is always preferred (handles all date types).
508
+ // Only fall back to edit_day when day_only=true and flatpickr is not installed.
509
+ const fieldTypeLower = String(field?.type || "").toLowerCase();
510
+ const isDateField =
511
+ (mode === "edit" || mode === "filter") &&
512
+ (fieldTypeLower === "date" || fieldTypeLower.includes("date"));
513
+ if (isDateField) {
514
+ const flatpickrMatch = availableViews.find(
515
+ (fv) => String(fv).toLowerCase() === "flatpickr"
516
+ );
517
+ if (flatpickrMatch) return flatpickrMatch;
518
+ if (field?.attributes?.day_only) {
519
+ const dayMatch = availableViews.find(
520
+ (fv) => String(fv).toLowerCase() === "edit_day"
521
+ );
522
+ if (dayMatch) return dayMatch;
523
+ }
524
+ }
525
+
526
+ // Fields with fixed options (options attribute) always use plain select, never select_by_code
527
+ if ((mode === "edit" || mode === "filter") && field?.attributes?.options) {
528
+ const selectMatch = availableViews.find(
529
+ (fv) => String(fv).toLowerCase() === "select"
530
+ );
531
+ if (selectMatch) return selectMatch;
532
+ }
533
+
534
+ // If a specific fieldview was requested by the user, try to honor it
535
+ // but ONLY if it actually exists in available views and is appropriate for the mode
536
+ if (requestedFieldview) {
537
+ if (
538
+ isShowMode &&
539
+ EDIT_ONLY_FIELDVIEWS.has(String(requestedFieldview).toLowerCase())
540
+ ) {
541
+ // Fall through to show-mode defaults — don't use edit fieldviews in show context
542
+ } else {
543
+ const validated = validateAndReturn(requestedFieldview);
544
+ if (validated) return validated;
545
+ // Requested fieldview not available for this field - fall through to defaults
546
+ }
547
+ }
548
+
549
+ // Get the field's configured default fieldview
550
+ const defaultFieldview =
551
+ field?.default_fieldview || field?.defaultFieldview || field?.fieldview;
552
+
553
+ if (defaultFieldview) {
554
+ const validated = validateAndReturn(defaultFieldview);
555
+ if (validated) return validated;
556
+ }
557
+
558
+ // Mode-based selection from available fieldviews
559
+ if (mode === "show" || mode === "list" || mode === "listcolumns") {
560
+ // For show mode, prefer simple text-based views, but only from available views
561
+ const showPreferences = ["as_text", "show", "as_string", "text", "showas"];
562
+ for (const pref of showPreferences) {
563
+ const match = availableViews.find(
564
+ (fv) => String(fv).toLowerCase() === pref
565
+ );
566
+ if (match) return match;
567
+ }
568
+ } else if (mode === "edit" || mode === "filter") {
569
+ // For edit mode, prefer edit-capable fieldviews from available views (exact match)
570
+ const editPreferences = ["edit", "input", "select", "textarea"];
571
+ for (const pref of editPreferences) {
572
+ const match = availableViews.find(
573
+ (fv) => String(fv).toLowerCase() === pref
574
+ );
575
+ if (match) return match;
576
+ }
577
+ }
578
+
579
+ // Fall back to first available fieldview - this is always valid
580
+ return availableViews[0];
581
+ };
582
+
583
+ const evenWidths = (count) => {
584
+ if (!count) return [];
585
+ const widths = Array(count).fill(Math.max(1, Math.floor(12 / count)));
586
+ let total = widths.reduce((sum, n) => sum + n, 0);
587
+ let idx = 0;
588
+ while (total < 12) {
589
+ widths[idx] += 1;
590
+ total += 1;
591
+ idx = (idx + 1) % count;
592
+ }
593
+ while (total > 12 && widths.some((w) => w > 1)) {
594
+ if (widths[idx] > 1) {
595
+ widths[idx] -= 1;
596
+ total -= 1;
597
+ }
598
+ idx = (idx + 1) % count;
599
+ }
600
+ return widths;
601
+ };
602
+
603
+ const normalizeWidths = (current, count) => {
604
+ if (!count) return [];
605
+ if (Array.isArray(current) && current.length === count) {
606
+ const sanitized = current.map((val) => {
607
+ const num = Number(val);
608
+ return Number.isFinite(num) && num > 0
609
+ ? Math.min(12, Math.round(num))
610
+ : null;
611
+ });
612
+ if (sanitized.every((n) => n && n > 0)) {
613
+ const total = sanitized.reduce((sum, n) => sum + n, 0);
614
+ if (total === 12) return sanitized;
615
+ }
616
+ }
617
+ return evenWidths(count);
618
+ };
619
+
620
+ const parseJsonPayload = (raw) => {
621
+ if (raw == null) throw new Error("Empty response from LLM");
622
+ if (typeof raw === "object") return raw;
623
+ const cleaned = stripCodeFences(String(raw));
624
+ const extracted = extractJsonStructure(cleaned);
625
+ if (extracted) return extracted;
626
+ throw new Error("Could not parse JSON payload from LLM response");
627
+ };
628
+
629
+ const normalizeChild = (value, ctx) => {
630
+ if (value == null) return null;
631
+ if (typeof value === "string") return { type: "blank", contents: value };
632
+ return normalizeSegment(value, ctx);
633
+ };
634
+
635
+ const normalizeTabs = (tabs, ctx) =>
636
+ ensureArray(tabs)
637
+ .map((tab) => ({ ...tab, contents: normalizeChild(tab?.contents, ctx) }))
638
+ .filter((tab) => tab?.title && tab.contents)
639
+ .map((tab) => ({ ...tab, class: tab.class || "" }));
640
+
641
+ const normalizeSegment = (segment, ctx) => {
642
+ if (segment == null) return null;
643
+ if (typeof segment === "string") return { type: "blank", contents: segment };
644
+ if (Array.isArray(segment)) {
645
+ const arr = segment
646
+ .map((child) => normalizeSegment(child, ctx))
647
+ .filter(Boolean);
648
+ return arr.length ? arr : null;
649
+ }
650
+ if (typeof segment !== "object") return null;
651
+
652
+ const clone = { ...segment };
653
+ if (typeof clone.style === "string") {
654
+ clone.style = parseStyleString(clone.style);
655
+ }
656
+ if (clone.type === "prompt") return null;
657
+
658
+ if ((!clone.type || clone.type === "stack") && clone.above) {
659
+ const above = ensureArray(clone.above)
660
+ .map((child) => normalizeSegment(child, ctx))
661
+ .filter(Boolean);
662
+ return above.length ? { ...clone, above } : null;
663
+ }
664
+ if (!clone.type && clone.besides) {
665
+ const besides = ensureArray(clone.besides).map((child) =>
666
+ child == null ? null : normalizeSegment(child, ctx)
667
+ );
668
+ if (!besides.some((child) => child)) return null;
669
+ return {
670
+ ...clone,
671
+ besides,
672
+ widths: normalizeWidths(clone.widths, besides.length),
673
+ };
674
+ }
675
+ // Handle {columns: {besides: [...], widths: [...]}} wrapper the LLM sometimes emits
676
+ if (!clone.type && clone.columns?.besides) {
677
+ const inner = clone.columns;
678
+ const besides = ensureArray(inner.besides).map((child) =>
679
+ child == null ? null : normalizeSegment(child, ctx)
21
680
  );
22
- const strHtml = str.includes("```html")
23
- ? str.split("```html")[1].split("```")[0]
24
- : str;
681
+ if (!besides.some(Boolean)) return null;
682
+ return {
683
+ besides,
684
+ widths: normalizeWidths(inner.widths, besides.length),
685
+ };
686
+ }
687
+
688
+ switch (clone.type) {
689
+ case "columns":
690
+ case "row":
691
+ case "grid": {
692
+ // LLM uses these as a besides wrapper with widths
693
+ if (clone.besides) {
694
+ const besides = ensureArray(clone.besides).map((child) =>
695
+ child == null ? null : normalizeSegment(child, ctx)
696
+ );
697
+ if (!besides.some(Boolean)) return null;
698
+ return {
699
+ besides,
700
+ widths: normalizeWidths(clone.widths, besides.length),
701
+ };
702
+ }
703
+ // fall through to container-like handling if no besides
704
+ const contents = normalizeChild(clone.contents, ctx);
705
+ return contents ? { ...clone, contents } : null;
706
+ }
707
+ case "container": {
708
+ const contents = normalizeChild(clone.contents, ctx);
709
+ return contents
710
+ ? {
711
+ ...clone,
712
+ contents,
713
+ class: clone.class || "",
714
+ customClass: clone.customClass || "",
715
+ }
716
+ : null;
717
+ }
718
+ case "card": {
719
+ const contents = normalizeChild(clone.contents, ctx);
720
+ return contents
721
+ ? {
722
+ ...clone,
723
+ contents,
724
+ title: clone.title || "",
725
+ class: clone.class || "",
726
+ }
727
+ : null;
728
+ }
729
+ case "tabs": {
730
+ const tabs = normalizeTabs(clone.tabs, ctx);
731
+ if (!tabs.length) return null;
732
+ const titles = clone.titles ?? tabs.map((t) => t.title || "");
733
+ const contents = clone.contents ?? tabs.map((t) => t.contents);
734
+ return { ...clone, tabs, titles, contents, class: clone.class || "" };
735
+ }
736
+ case "blank":
737
+ return {
738
+ ...clone,
739
+ contents: typeof clone.contents === "string" ? clone.contents : "",
740
+ class: clone.class || "",
741
+ };
742
+ case "line_break":
743
+ return { type: "line_break", class: clone.class || "" };
744
+ case "image":
745
+ return clone.url || clone.src
746
+ ? {
747
+ ...clone,
748
+ url: clone.url || clone.src || "",
749
+ alt: clone.alt || "",
750
+ class: clone.class || "",
751
+ }
752
+ : null;
753
+ case "link":
754
+ return clone.url
755
+ ? {
756
+ ...clone,
757
+ text: clone.text || clone.url,
758
+ link_style: clone.link_style || "",
759
+ class: clone.class || "",
760
+ }
761
+ : null;
762
+ case "search_bar":
763
+ return { ...clone, class: clone.class || "" };
764
+ case "view":
765
+ if (!ctx.viewNames.length) return null;
766
+ return {
767
+ ...clone,
768
+ view: ctx.viewNames.includes(clone.view)
769
+ ? clone.view
770
+ : ctx.viewNames[0],
771
+ state: clone.state || {},
772
+ class: clone.class || "",
773
+ };
774
+ case "view_link": {
775
+ if (!ctx.viewNames.length) return null;
776
+ const resolvedView = ctx.viewNames.includes(clone.view)
777
+ ? clone.view
778
+ : ctx.viewNames[0];
779
+ let relation = clone.relation;
780
+ if (!relation && ctx.table && ctx.schemaData) {
781
+ try {
782
+ const finder = new RelationsFinder(
783
+ ctx.schemaData.tables,
784
+ ctx.schemaData.views,
785
+ 6
786
+ );
787
+ const relations = finder.findRelations(
788
+ ctx.table.name,
789
+ resolvedView,
790
+ []
791
+ );
792
+ if (relations.length > 0) {
793
+ const picked = pickRelation(relations);
794
+ if (picked) relation = picked.relationString;
795
+ }
796
+ } catch (e) {
797
+ console.error("view_link relation lookup failed:", e.message);
798
+ }
799
+ }
800
+ // Convert {{ expr }} template syntax to isFormula.label: true with JS expression
801
+ let viewLabel = clone.view_label || clone.view;
802
+ let isFormula = clone.isFormula || {};
803
+ const tmplMatch =
804
+ typeof viewLabel === "string" && viewLabel.match(/^\{\{\s*(.+?)\s*\}\}$/);
805
+ if (tmplMatch) {
806
+ viewLabel = tmplMatch[1];
807
+ isFormula = { ...isFormula, label: true };
808
+ }
809
+ return {
810
+ ...clone,
811
+ view: resolvedView,
812
+ view_label: viewLabel,
813
+ link_style: clone.link_style || "",
814
+ class: clone.class || "",
815
+ isFormula,
816
+ ...(relation ? { relation } : {}),
817
+ };
818
+ }
819
+ case "field": {
820
+ if (!ctx.fields.length) return null;
821
+ const fieldMeta = ctx.fieldMap[clone.field_name] || ctx.fields[0];
822
+ // Use pickFieldview which validates that the fieldview exists in fieldMeta.fieldviews
823
+ // If clone.fieldview is invalid, pickFieldview will return a valid alternative
824
+ const validFieldview = pickFieldview(
825
+ fieldMeta,
826
+ ctx.mode,
827
+ clone.fieldview
828
+ );
829
+ return {
830
+ ...clone,
831
+ field_name: fieldMeta.name,
832
+ fieldview: validFieldview,
833
+ configuration: clone.configuration || {},
834
+ class: clone.class || "",
835
+ };
836
+ }
837
+ case "list_columns": {
838
+ const besides = ensureArray(clone.besides).map((child) =>
839
+ child == null ? null : normalizeSegment(child, ctx)
840
+ );
841
+ if (!besides.some(Boolean)) return null;
842
+ return { ...clone, besides, list_columns: true };
843
+ }
844
+ case "list_column": {
845
+ const contents = normalizeChild(clone.contents, ctx);
846
+ return contents
847
+ ? { ...clone, contents, header_label: clone.header_label || "" }
848
+ : null;
849
+ }
850
+ case "action": {
851
+ if (!ctx.actions.length) return null;
852
+ const actionName = ctx.actions.includes(clone.action_name)
853
+ ? clone.action_name
854
+ : ctx.actions[0];
855
+ return {
856
+ ...clone,
857
+ action_name: actionName,
858
+ action_label: clone.action_label || prettifyActionName(actionName),
859
+ action_style: clone.action_style || "btn-primary",
860
+ action_size: ACTION_SIZES.includes(clone.action_size)
861
+ ? clone.action_size
862
+ : undefined,
863
+ rndid: clone.rndid || randomId(),
864
+ minRole: clone.minRole || 100,
865
+ nsteps: clone.nsteps || 1,
866
+ isFormula: clone.isFormula || {},
867
+ configuration: clone.configuration || {},
868
+ class: clone.class || "",
869
+ };
870
+ }
871
+ default: {
872
+ if (clone.children) {
873
+ const childSegments = ensureArray(clone.children)
874
+ .map((child) => normalizeSegment(child, ctx))
875
+ .filter(Boolean);
876
+ if (childSegments.length === 1) return childSegments[0];
877
+ if (childSegments.length > 1) return { above: childSegments };
878
+ }
879
+ if (clone.contents) {
880
+ const contents = normalizeChild(clone.contents, ctx);
881
+ return contents ? { ...clone, contents } : null;
882
+ }
883
+ return null;
884
+ }
885
+ }
886
+ };
887
+
888
+ const collectSegments = (segment, out = []) => {
889
+ if (segment == null) return out;
890
+ if (Array.isArray(segment)) {
891
+ segment.forEach((s) => collectSegments(s, out));
892
+ return out;
893
+ }
894
+ if (typeof segment !== "object") return out;
895
+ out.push(segment);
896
+ if (segment.above) collectSegments(segment.above, out);
897
+ if (segment.besides) collectSegments(segment.besides, out);
898
+ if (segment.contents) collectSegments(segment.contents, out);
899
+ if (segment.tabs) {
900
+ ensureArray(segment.tabs).forEach((tab) =>
901
+ collectSegments(tab.contents, out)
902
+ );
903
+ }
904
+ return out;
905
+ };
906
+
907
+ const sanitizeNoHtmlSegments = (segment) => {
908
+ if (segment == null) return segment;
909
+ if (Array.isArray(segment))
910
+ return segment
911
+ .map((child) => sanitizeNoHtmlSegments(child))
912
+ .filter(Boolean);
913
+ if (typeof segment !== "object") return segment;
914
+
915
+ const clone = { ...segment };
916
+ if (clone.type === "blank") {
917
+ const usedHtml = !!clone.isHTML;
918
+ delete clone.isHTML;
919
+ delete clone.text_strings;
920
+ if (usedHtml && typeof clone.contents === "string") {
921
+ clone.contents = stripHtmlTags(clone.contents);
922
+ }
923
+ }
924
+
925
+ if (clone.contents !== undefined)
926
+ clone.contents = sanitizeNoHtmlSegments(clone.contents);
927
+ if (clone.above !== undefined)
928
+ clone.above = sanitizeNoHtmlSegments(clone.above);
929
+ if (clone.besides !== undefined)
930
+ clone.besides = sanitizeNoHtmlSegments(clone.besides);
931
+ if (clone.tabs !== undefined)
932
+ clone.tabs = ensureArray(clone.tabs)
933
+ .map((tab) => ({
934
+ ...tab,
935
+ contents: sanitizeNoHtmlSegments(tab?.contents),
936
+ }))
937
+ .filter((tab) => tab?.contents);
938
+ return clone;
939
+ };
940
+
941
+ const truncateText = (value, maxLen) => {
942
+ const str = String(value ?? "");
943
+ if (str.length <= maxLen) return str;
944
+ return `${str.slice(0, maxLen)}\n...truncated...`;
945
+ };
946
+
947
+ const normalizeLayoutCandidate = (candidate, ctx) => {
948
+ let normalized = normalizeSegment(candidate, ctx);
949
+ if (!normalized) {
950
+ normalized = convertForeignLayout(candidate, ctx);
951
+ }
952
+ if (!normalized) return null;
953
+ const layout = Array.isArray(normalized) ? { above: normalized } : normalized;
954
+ return sanitizeNoHtmlSegments(layout);
955
+ };
956
+
957
+ const buildFieldMetadata = (fields) => {
958
+ if (!fields || !fields.length) return null;
959
+ const lines = fields
960
+ .filter((f) => !f.primary_key)
961
+ .map((f) => {
962
+ const attrs = Object.entries(f.attributes || {})
963
+ .filter(
964
+ ([, v]) => v !== null && v !== undefined && v !== "" && v !== false
965
+ )
966
+ .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
967
+ .join(", ");
968
+ return ` ${f.name} (${f.type}${f.calculated ? ", calculated" : ""}${
969
+ attrs ? ` — attrs: ${attrs}` : ""
970
+ })`;
971
+ });
972
+ return `Table fields available for this layout:\n${lines.join("\n")}`;
973
+ };
974
+
975
+ const buildPromptText = (userPrompt, ctx, schema) => {
976
+ const parts = [
977
+ `You are an expert Saltcorn layout builder assistant. Your task is to generate a layout for mode "${ctx.mode}" that precisely fulfills the user's request.`,
978
+ 'CRITICAL: You must return ONLY a single valid JSON object. Do not include introductory text, explanations, markdown formatting (like ```json), or any pseudo-markup. The output must strictly follow this shape: {"layout": <layout-object>}.',
979
+ 'The "layout" object MUST conform entirely to the provided JSON Schema. Do not invent properties, types, or structure not defined in the schema.',
980
+ ];
981
+ parts.push(ctx.modeGuidance);
982
+ const fieldMeta = buildFieldMetadata(ctx.fields);
983
+ if (fieldMeta) parts.push(fieldMeta);
984
+ parts.push(
985
+ "When a card or container background is requested, set bgType explicitly (None, Color, Image, or Gradient). For Color use bgColor, for Image use bgFileId plus imageLocation (Top, Card, Body) and optionally imageSize (cover, contain, repeat using cover as default) when location is Card or Body, and for Gradient use gradStartColor, gradEndColor, and numeric gradDirection. Use hex color codes when specifying colors."
986
+ );
987
+ parts.push(
988
+ `Here is the strict Saltcorn layout JSON schema you MUST follow to construct the layout. Do not deviate from these definitions:\n${JSON.stringify(
989
+ schema
990
+ )}`
991
+ );
992
+ parts.push(
993
+ `Based on the schema above, process the following user request and generate the layout JSON. Reminder: ONLY output valid JSON starting with { and ending with }, no markdown fences.\nUser request:\n"${userPrompt}"`
994
+ );
995
+ return parts.join("\n\n");
996
+ };
997
+
998
+ const buildRepairPrompt = (rawOutput, schema) => {
999
+ const cleaned = truncateText(rawOutput, 6000);
1000
+ return [
1001
+ "You are repairing a JSON payload for Saltcorn. Return ONLY valid JSON.",
1002
+ "Do not add explanations, markdown, or code fences.",
1003
+ "If the JSON is incomplete, finish it. If it has extra text, remove it.",
1004
+ `Schema (must conform):\n${JSON.stringify(schema)}`,
1005
+ `Invalid output to fix:\n${cleaned}`,
1006
+ ].join("\n\n");
1007
+ };
1008
+
1009
+ const buildReducedPrompt = (userPrompt, ctx, schema) => {
1010
+ const limitedPrompt = `${userPrompt}\n\nLimit the layout to at most 4 containers/cards and no more than 2 levels of nesting. Keep the output compact.`;
1011
+ return buildPromptText(limitedPrompt, ctx, schema);
1012
+ };
1013
+
1014
+ const convertChildList = (children, ctx) => {
1015
+ const segments = ensureArray(children)
1016
+ .map((child) => convertForeignLayout(child, ctx))
1017
+ .filter(Boolean);
1018
+ if (!segments.length) return null;
1019
+ if (segments.length === 1) return segments[0];
1020
+ return { above: segments };
1021
+ };
1022
+
1023
+ const convertChildrenArray = (children, ctx) =>
1024
+ ensureArray(children)
1025
+ .map((child) => convertForeignLayout(child, ctx))
1026
+ .filter(Boolean);
1027
+
1028
+ const convertForeignField = (node, ctx) => {
1029
+ const fieldName = pickAttrValue(node, ["field", "field_name", "name"]);
1030
+ if (!fieldName && !ctx.fields.length) return null;
1031
+ const fieldMeta = ctx.fieldMap[fieldName] || ctx.fields[0];
1032
+ if (!fieldMeta) return null;
1033
+ let userView = pickAttrValue(node, ["fieldview", "view"]);
1034
+ const viewsAttr = attrValue(node, "views");
1035
+ if (!userView && viewsAttr !== undefined) userView = firstItem(viewsAttr);
1036
+ const typeHint = attrValue(node, "type");
1037
+ if (
1038
+ !userView &&
1039
+ typeof typeHint === "string" &&
1040
+ typeHint.toLowerCase() === "textarea"
1041
+ ) {
1042
+ userView = "textarea";
1043
+ }
1044
+ // Use pickFieldview to validate userView against field's available fieldviews
1045
+ const validFieldview = pickFieldview(fieldMeta, ctx.mode, userView);
1046
+ return {
1047
+ type: "field",
1048
+ field_name: fieldMeta.name,
1049
+ fieldview: validFieldview,
1050
+ configuration: node.configuration || {},
1051
+ };
1052
+ };
1053
+
1054
+ const convertForeignAction = (node, ctx) => {
1055
+ const actionName =
1056
+ pickAttrValue(node, ["action", "action_name", "name"]) || ctx.actions[0];
1057
+ if (!actionName) return null;
1058
+ const style = pickAttrValue(node, ["style", "action_style"]);
1059
+ const label = pickAttrValue(node, ["label", "action_label"]);
1060
+ const size = pickAttrValue(node, ["size", "action_size"]);
1061
+ const confirm = attrValue(node, "confirm");
1062
+ return {
1063
+ type: "action",
1064
+ action_name: actionName,
1065
+ action_label: label || prettifyActionName(actionName),
1066
+ action_style: style || "btn-primary",
1067
+ action_size: ACTION_SIZES.includes(size) ? size : undefined,
1068
+ confirm,
1069
+ rndid: randomId(),
1070
+ minRole: 100,
1071
+ nsteps: 1,
1072
+ isFormula: {},
1073
+ configuration: node.configuration || {},
1074
+ };
1075
+ };
1076
+
1077
+ const convertForeignLayout = (node, ctx) => {
1078
+ if (!node && node !== 0) return null;
1079
+ if (Array.isArray(node)) {
1080
+ const segments = node
1081
+ .map((child) => convertForeignLayout(child, ctx))
1082
+ .filter(Boolean);
1083
+ if (!segments.length) return null;
1084
+ if (segments.length === 1) return segments[0];
1085
+ return { above: segments };
1086
+ }
1087
+ if (typeof node === "string") return { type: "blank", contents: node };
1088
+ if (typeof node !== "object") return null;
1089
+ if (node.layout) return convertForeignLayout(node.layout, ctx);
1090
+
1091
+ const type = node.type || node.kind;
1092
+ switch (type) {
1093
+ case "container": {
1094
+ const contents =
1095
+ convertForeignLayout(node.contents, ctx) ||
1096
+ convertChildList(node.children, ctx);
1097
+ return contents ? { type: "container", contents } : null;
1098
+ }
1099
+ case "card": {
1100
+ const contents =
1101
+ convertForeignLayout(node.contents, ctx) ||
1102
+ convertChildList(node.children, ctx);
1103
+ return contents ? { type: "card", title: node.title, contents } : null;
1104
+ }
1105
+ case "columns": {
1106
+ const columns = convertChildrenArray(node.columns || node.children, ctx);
1107
+ return columns.length
1108
+ ? {
1109
+ besides: columns,
1110
+ widths: normalizeWidths(node.widths, columns.length),
1111
+ }
1112
+ : null;
1113
+ }
1114
+ case "column":
1115
+ return (
1116
+ convertForeignLayout(node.contents, ctx) ||
1117
+ convertChildList(node.children, ctx)
1118
+ );
1119
+ case "row":
1120
+ case "section":
1121
+ case "stack":
1122
+ case "group":
1123
+ case "form":
1124
+ case "form_group":
1125
+ case "formgroup":
1126
+ case "form-row":
1127
+ case "form-group":
1128
+ return convertChildList(node.children, ctx);
1129
+ case "tabs": {
1130
+ const tabs = ensureArray(node.tabs || node.children)
1131
+ .map((tab) => ({
1132
+ title: tab.title || tab.label || "Tab",
1133
+ contents: convertForeignLayout(tab.contents || tab.children, ctx),
1134
+ }))
1135
+ .filter((tab) => tab.contents);
1136
+ return tabs.length ? { type: "tabs", tabs } : null;
1137
+ }
1138
+ case "actions":
1139
+ return convertChildList(node.children, ctx);
1140
+ case "fieldview":
1141
+ case "field":
1142
+ case "input":
1143
+ case "textarea":
1144
+ case "select":
1145
+ return convertForeignField(node, ctx);
1146
+ case "action":
1147
+ case "button":
1148
+ return convertForeignAction(node, ctx);
1149
+ case "label":
1150
+ case "heading":
1151
+ case "title":
1152
+ return {
1153
+ type: "blank",
1154
+ contents: node.text || node.value || node.contents || "",
1155
+ };
1156
+ case "text":
1157
+ return {
1158
+ type: "blank",
1159
+ contents: node.text || node.value || node.contents || "",
1160
+ };
1161
+ case "html":
1162
+ return {
1163
+ type: "blank",
1164
+ contents: stripHtmlTags(node.html || node.contents || ""),
1165
+ };
1166
+ case "image":
1167
+ return {
1168
+ type: "image",
1169
+ url: node.url || node.src || "",
1170
+ alt: node.alt || "",
1171
+ };
1172
+ default:
1173
+ if (node.children) return convertChildList(node.children, ctx);
1174
+ if (node.contents) return convertForeignLayout(node.contents, ctx);
1175
+ if (node.text) return { type: "blank", contents: node.text };
1176
+ return null;
1177
+ }
1178
+ };
1179
+
1180
+ const splitNodeText = (text) => {
1181
+ const attrs = {};
1182
+ const body = [];
1183
+ (text || "")
1184
+ .split(/\r?\n/)
1185
+ .map((line) => line.trim())
1186
+ .filter(Boolean)
1187
+ .forEach((line) => {
1188
+ const eqIdx = line.indexOf("=");
1189
+ if (eqIdx > 0) {
1190
+ const key = line.slice(0, eqIdx).trim();
1191
+ const value = line.slice(eqIdx + 1).trim();
1192
+ if (key) attrs[key] = value;
1193
+ else body.push(line);
1194
+ } else body.push(line);
1195
+ });
1196
+ return { attrs, text: body.join(" ").trim() };
1197
+ };
1198
+
1199
+ const buildBracketObject = (node) => {
1200
+ if (!node || !node.tag) return null;
1201
+ const children = (node.children || [])
1202
+ .map((child) => buildBracketObject(child))
1203
+ .filter(Boolean);
1204
+ const { attrs, text } = splitNodeText(node.text || "");
1205
+ const obj = { type: node.tag };
1206
+ if (children.length) obj.children = children;
1207
+ if (Object.keys(attrs).length) obj.attributes = attrs;
1208
+ if (text) obj.text = text;
1209
+ return obj;
1210
+ };
1211
+
1212
+ const pickRelation = (relations) => {
1213
+ let own = null,
1214
+ parent = null,
1215
+ child = null;
1216
+ for (const r of relations) {
1217
+ if (r.type === RelationType.OWN) own = r;
1218
+ else if (r.type === RelationType.PARENT_SHOW) parent = r;
1219
+ else if (
1220
+ r.type === RelationType.CHILD_LIST ||
1221
+ r.type === RelationType.ONE_TO_ONE_SHOW
1222
+ )
1223
+ child = r;
1224
+ }
1225
+ return own || parent || child || relations[0];
1226
+ };
1227
+
1228
+ const buildContext = async (mode, tableName) => {
1229
+ const normalizedMode = (mode || "show").toLowerCase();
1230
+ const ctx = {
1231
+ mode: normalizedMode,
1232
+ modeGuidance: MODE_GUIDANCE[normalizedMode] || MODE_GUIDANCE.default,
1233
+ table: null,
1234
+ fields: [],
1235
+ fieldMap: {},
1236
+ actions: [],
1237
+ viewNames: [],
1238
+ viewTableMap: {},
1239
+ schemaData: null,
1240
+ };
1241
+
1242
+ // Global actions and views are useful even when no table is specified (page builder)
1243
+ const stateActions = Object.keys(getState().actions || {});
1244
+ try {
1245
+ const schemaData = await build_schema_data();
1246
+ ctx.schemaData = schemaData;
1247
+ ctx.viewNames = schemaData.views.map((v) => v.name).filter(Boolean);
1248
+ for (const v of schemaData.views) {
1249
+ if (v.name && v.table_id) {
1250
+ const vt = Table.findOne({ id: v.table_id });
1251
+ if (vt) ctx.viewTableMap[v.name] = vt.name;
1252
+ }
1253
+ }
1254
+ } catch (err) {
1255
+ ctx.viewNames = [];
1256
+ ctx.viewTableMap = {};
1257
+ }
1258
+
1259
+ if (!tableName) {
1260
+ const triggers = Trigger.find({
1261
+ when_trigger: { or: ["API call", "Never"] },
1262
+ }).filter((tr) => tr.name && !tr.table_id);
1263
+
1264
+ ctx.actions = Array.from(
1265
+ new Set([...stateActions, ...triggers.map((tr) => tr.name)])
1266
+ ).filter(Boolean);
1267
+ return ctx;
1268
+ }
1269
+
1270
+ const lookup =
1271
+ typeof tableName === "number" || /^[0-9]+$/.test(String(tableName))
1272
+ ? { id: Number(tableName) }
1273
+ : { name: tableName };
1274
+ const table = Table.findOne(lookup);
1275
+ if (!table) return ctx;
1276
+
1277
+ let rawFields = [];
1278
+ try {
1279
+ rawFields = table.getFields ? table.getFields() : table.fields || [];
1280
+ } catch (err) {
1281
+ rawFields = table.fields || [];
1282
+ }
1283
+ if (rawFields?.then) rawFields = await rawFields;
1284
+ const fields = (rawFields || []).map((field) => {
1285
+ let fieldviews = Object.keys(field.type?.fieldviews || {});
1286
+ // FK fields have type "Key" (a string) so field.type?.fieldviews is empty;
1287
+ // ensure select and show are always available for them
1288
+ const typeName = field.type?.name || field.type || "";
1289
+ if (!fieldviews.length && String(typeName).startsWith("Key")) {
1290
+ fieldviews = ["select", "show"];
1291
+ }
1292
+ const isPkName =
1293
+ table.pk_name &&
1294
+ typeof field.name === "string" &&
1295
+ field.name === table.pk_name;
1296
+
1297
+ // Capture the default fieldview from various possible sources
1298
+ // Priority: field-level configured > field's attributes > type default
1299
+ const defaultFieldview =
1300
+ field.fieldview ||
1301
+ field.default_fieldview ||
1302
+ (field.attributes && field.attributes.fieldview) ||
1303
+ field.type?.default_fieldview ||
1304
+ null;
1305
+
25
1306
  return {
26
- type: "blank",
27
- isHTML: true,
28
- contents: strHtml,
29
- text_strings: [],
1307
+ name: field.name,
1308
+ label: field.label || field.name,
1309
+ type: field.type?.name || field.type || field.input_type || "String",
1310
+ required: !!field.required,
1311
+ primary_key: !!field.primary_key,
1312
+ calculated: !!field.calculated,
1313
+ is_pk_name: !!isPkName,
1314
+ default_fieldview: defaultFieldview,
1315
+ fieldviews: fieldviews.length ? fieldviews : ["show"],
1316
+ attributes: field.attributes || {},
30
1317
  };
1318
+ });
1319
+
1320
+ const triggers = Trigger.find({
1321
+ when_trigger: { or: ["API call", "Never"] },
1322
+ }).filter((tr) => tr.name && (!tr.table_id || tr.table_id === table.id));
1323
+
1324
+ let viewNames = [];
1325
+ try {
1326
+ const views = await View.find_table_views_where(table.id, () => true);
1327
+ viewNames = views.map((v) => v.name);
1328
+ } catch (err) {
1329
+ viewNames = [];
1330
+ }
1331
+
1332
+ const builtIns =
1333
+ ctx.mode === "edit" || ctx.mode === "filter"
1334
+ ? edit_build_in_actions || []
1335
+ : ["Delete", "GoBack"];
1336
+ const actions = Array.from(
1337
+ new Set([...builtIns, ...stateActions, ...triggers.map((tr) => tr.name)])
1338
+ ).filter(Boolean);
1339
+
1340
+ ctx.table = table;
1341
+ ctx.fields = fields;
1342
+ ctx.fieldMap = Object.fromEntries(fields.map((f) => [f.name, f]));
1343
+ ctx.actions = actions;
1344
+ ctx.viewNames = viewNames;
1345
+ return ctx;
1346
+ };
1347
+
1348
+ const buildErrorLayout = ({ message, mode, table }) => {
1349
+ const trimmedMessage = String(message || "Unknown error").slice(0, 500);
1350
+ const contextLine = table
1351
+ ? `Mode: ${mode || "show"} | Table: ${table}`
1352
+ : `Mode: ${mode || "show"}`;
1353
+ return {
1354
+ above: [
1355
+ {
1356
+ type: "container",
1357
+ customClass: "p-3 border rounded",
1358
+ style: {
1359
+ backgroundColor: "#fff3cd",
1360
+ borderColor: "#ffecb5",
1361
+ color: "#000000",
1362
+ },
1363
+ contents: {
1364
+ above: [
1365
+ {
1366
+ type: "blank",
1367
+ contents: "Builder generation failed",
1368
+ textStyle: ["h4", "fw-bold"],
1369
+ block: true,
1370
+ inline: false,
1371
+ },
1372
+ {
1373
+ type: "blank",
1374
+ contents: contextLine,
1375
+ textStyle: ["small"],
1376
+ block: true,
1377
+ inline: false,
1378
+ },
1379
+ {
1380
+ type: "blank",
1381
+ contents:
1382
+ "We could not generate a layout from your request. Please try rephrasing or simplifying the prompt.",
1383
+ block: true,
1384
+ inline: false,
1385
+ },
1386
+ {
1387
+ type: "blank",
1388
+ contents: `Error: ${trimmedMessage}`,
1389
+ textStyle: ["font-monospace", "small"],
1390
+ block: true,
1391
+ inline: false,
1392
+ },
1393
+ ],
1394
+ },
1395
+ },
1396
+ ],
1397
+ };
1398
+ };
1399
+
1400
+ module.exports = {
1401
+ normalizeLayoutCandidate,
1402
+ run: async (prompt, mode, table, existing_layout, chat) => {
1403
+ prompt = prompt.trim().replace(/^\[\w+\]:\s*/, "");
1404
+
1405
+ const ctx = await buildContext(mode, table);
1406
+ const schema = buildBuilderSchema({ mode, ctx });
1407
+ const llm = getState().functions.llm_generate;
1408
+ if (!llm?.run) throw new Error("LLM generator not configured");
1409
+
1410
+ const llmConfig = await getLlmConfigurationSafe();
1411
+ const allowResponseFormat = canUseResponseFormat(llmConfig);
1412
+
1413
+ let llmPrompt = buildPromptText(prompt, ctx, schema);
1414
+ if (existing_layout !== undefined && existing_layout !== null) {
1415
+ const layoutJson =
1416
+ typeof existing_layout === "string"
1417
+ ? existing_layout
1418
+ : JSON.stringify(existing_layout);
1419
+ llmPrompt = `${llmPrompt}\n\nExisting layout (reproduce this with the requested changes applied):\n${layoutJson}`;
1420
+ }
1421
+
1422
+ let responseFormat;
1423
+ try {
1424
+ if (!schema || !schema.schema) {
1425
+ throw new Error("Builder schema unavailable");
1426
+ }
1427
+ if (!allowResponseFormat) {
1428
+ console.warn("LLM backend does not support response_format; skipping");
1429
+ } else {
1430
+ validateSchemaRefs(schema.schema);
1431
+ let simplified = simplifySchemaForLlm(schema.schema);
1432
+ const optionalCount = countOptionalParams(simplified);
1433
+ console.log({ optionalCount });
1434
+ if (optionalCount > 24) {
1435
+ simplified = trimOptionalProperties(simplified, 24);
1436
+ }
1437
+ if (simplified && schemaHasRef(simplified)) {
1438
+ console.warn(
1439
+ "Builder response schema still contains $ref; skipping response_format"
1440
+ );
1441
+ } else if (simplified) {
1442
+ responseFormat = {
1443
+ type: "json_schema",
1444
+ json_schema: {
1445
+ name: "saltcorn_layout",
1446
+ schema: simplified,
1447
+ },
1448
+ };
1449
+ }
1450
+ }
1451
+ } catch (err) {
1452
+ console.warn("Builder response schema validation failed", err);
1453
+ // responseFormat = null;
1454
+ }
1455
+
1456
+ const options = {};
1457
+ if (responseFormat) {
1458
+ options.response_format = responseFormat;
1459
+ }
1460
+ if (Array.isArray(chat) && chat.length) options.chat = chat;
1461
+
1462
+ // const deterministicLayout = buildDeterministicLayout(ctx, prompt);
1463
+
1464
+ let payload;
1465
+ let rawResponse;
1466
+ try {
1467
+ if (!schema || !schema.schema) {
1468
+ throw new Error("Builder schema unavailable");
1469
+ }
1470
+ rawResponse = await llm.run(llmPrompt, options);
1471
+ payload = parseJsonPayload(rawResponse);
1472
+ const candidate = payload.layout ?? payload;
1473
+ const result = normalizeLayoutCandidate(candidate, ctx);
1474
+ if (!result) throw new Error("Empty layout after normalization");
1475
+ return result;
1476
+ } catch (err) {
1477
+ let lastError = err;
1478
+ try {
1479
+ const repairPrompt = buildRepairPrompt(rawResponse, schema);
1480
+ rawResponse = await llm.run(repairPrompt, options);
1481
+ payload = parseJsonPayload(rawResponse);
1482
+ const candidate = payload.layout ?? payload;
1483
+ const result = normalizeLayoutCandidate(candidate, ctx);
1484
+ if (!result) throw new Error("Empty layout after normalization");
1485
+ return result;
1486
+ } catch (repairErr) {
1487
+ lastError = repairErr;
1488
+ }
1489
+
1490
+ try {
1491
+ const reducedPrompt = buildReducedPrompt(prompt, ctx, schema);
1492
+ rawResponse = await llm.run(reducedPrompt, options);
1493
+ payload = parseJsonPayload(rawResponse);
1494
+ const candidate = payload.layout ?? payload;
1495
+ const result = normalizeLayoutCandidate(candidate, ctx);
1496
+ if (!result) throw new Error("Empty layout after normalization");
1497
+ return result;
1498
+ } catch (reducedErr) {
1499
+ lastError = reducedErr;
1500
+ }
1501
+
1502
+ console.warn("Copilot layout generation failed", lastError);
1503
+ const errorLayout = buildErrorLayout({
1504
+ message: lastError?.message || String(lastError),
1505
+ mode,
1506
+ table,
1507
+ });
1508
+ return errorLayout;
1509
+ }
31
1510
  },
32
1511
  isAsync: true,
33
1512
  description: "Generate a builder layout",
@@ -35,5 +1514,7 @@ Generate the HTML5 snippet for this request: ${prompt}
35
1514
  { name: "prompt", type: "String" },
36
1515
  { name: "mode", type: "String" },
37
1516
  { name: "table", type: "String" },
1517
+ { name: "existing_layout", type: "JSON" },
1518
+ { name: "chat", type: "JSON" },
38
1519
  ],
39
1520
  };