@saltcorn/copilot 0.7.5 → 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
@@ -4,15 +4,102 @@ const Trigger = require("@saltcorn/data/models/trigger");
4
4
  const View = require("@saltcorn/data/models/view");
5
5
  const { edit_build_in_actions } = require("@saltcorn/data/viewable_fields");
6
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");
7
11
 
8
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
+
9
80
  const MODE_GUIDANCE = {
10
- edit: "Layout is a form for editing a single row. Include required inputs with edit fieldviews, group related inputs, and finish with a Save action.",
11
- show: "Layout displays one record read-only. Use show fieldviews, blank headings, and optional follow-up actions.",
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
+
12
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
+
13
98
  filter:
14
99
  "Layout lets users define filters. Provide appropriate filter inputs plus an action to run or reset filters.",
100
+
15
101
  page: "Layout builds a general app page. Combine hero text, cards, containers, and call-to-action buttons.",
102
+
16
103
  default:
17
104
  "Use Saltcorn layout primitives (above, besides, container, card, tabs, blank, field, action, view_link, view). Do not return HTML snippets.",
18
105
  };
@@ -188,11 +275,200 @@ const derefSchema = (schema, maxDepth = 3) => {
188
275
  return expand(root, []);
189
276
  };
190
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
+
191
437
  const randomId = () =>
192
438
  Math.floor(Math.random() * 0xffffff)
193
439
  .toString(16)
194
440
  .padStart(6, "0");
195
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
+
196
472
  const ensureArray = (value) =>
197
473
  Array.isArray(value) ? value : value == null ? [] : [value];
198
474
 
@@ -214,29 +490,60 @@ const pickFieldview = (field, mode, requestedFieldview = null) => {
214
490
  return mode === "edit" || mode === "filter" ? "edit" : "show";
215
491
  }
216
492
 
217
- // Helper to validate and return a fieldview only if it exists
493
+ // Helper to validate and return a fieldview only if it exists (exact match only)
218
494
  const validateAndReturn = (candidate) => {
219
495
  if (!candidate) return null;
220
496
  const lower = String(candidate).toLowerCase();
221
- // Exact match
222
- const exact = availableViews.find(
223
- (fv) => String(fv).toLowerCase() === lower,
497
+ return (
498
+ availableViews.find((fv) => String(fv).toLowerCase() === lower) || null
224
499
  );
225
- if (exact) return exact;
226
- // Fuzzy match (contains)
227
- const fuzzy = availableViews.find((fv) =>
228
- String(fv).toLowerCase().includes(lower),
229
- );
230
- if (fuzzy) return fuzzy;
231
- return null;
232
500
  };
233
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
+
234
534
  // If a specific fieldview was requested by the user, try to honor it
235
- // but ONLY if it actually exists in available views
535
+ // but ONLY if it actually exists in available views and is appropriate for the mode
236
536
  if (requestedFieldview) {
237
- const validated = validateAndReturn(requestedFieldview);
238
- if (validated) return validated;
239
- // Requested fieldview not available for this field - fall through to defaults
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
+ }
240
547
  }
241
548
 
242
549
  // Get the field's configured default fieldview
@@ -249,21 +556,21 @@ const pickFieldview = (field, mode, requestedFieldview = null) => {
249
556
  }
250
557
 
251
558
  // Mode-based selection from available fieldviews
252
- if (mode === "show" || mode === "list") {
559
+ if (mode === "show" || mode === "list" || mode === "listcolumns") {
253
560
  // For show mode, prefer simple text-based views, but only from available views
254
561
  const showPreferences = ["as_text", "show", "as_string", "text", "showas"];
255
562
  for (const pref of showPreferences) {
256
- const match = availableViews.find((fv) =>
257
- String(fv).toLowerCase().includes(pref),
563
+ const match = availableViews.find(
564
+ (fv) => String(fv).toLowerCase() === pref
258
565
  );
259
566
  if (match) return match;
260
567
  }
261
568
  } else if (mode === "edit" || mode === "filter") {
262
- // For edit mode, prefer edit-capable fieldviews from available views
569
+ // For edit mode, prefer edit-capable fieldviews from available views (exact match)
263
570
  const editPreferences = ["edit", "input", "select", "textarea"];
264
571
  for (const pref of editPreferences) {
265
- const match = availableViews.find((fv) =>
266
- String(fv).toLowerCase().includes(pref),
572
+ const match = availableViews.find(
573
+ (fv) => String(fv).toLowerCase() === pref
267
574
  );
268
575
  if (match) return match;
269
576
  }
@@ -343,9 +650,12 @@ const normalizeSegment = (segment, ctx) => {
343
650
  if (typeof segment !== "object") return null;
344
651
 
345
652
  const clone = { ...segment };
653
+ if (typeof clone.style === "string") {
654
+ clone.style = parseStyleString(clone.style);
655
+ }
346
656
  if (clone.type === "prompt") return null;
347
657
 
348
- if (!clone.type && clone.above) {
658
+ if ((!clone.type || clone.type === "stack") && clone.above) {
349
659
  const above = ensureArray(clone.above)
350
660
  .map((child) => normalizeSegment(child, ctx))
351
661
  .filter(Boolean);
@@ -353,7 +663,7 @@ const normalizeSegment = (segment, ctx) => {
353
663
  }
354
664
  if (!clone.type && clone.besides) {
355
665
  const besides = ensureArray(clone.besides).map((child) =>
356
- child == null ? null : normalizeSegment(child, ctx),
666
+ child == null ? null : normalizeSegment(child, ctx)
357
667
  );
358
668
  if (!besides.some((child) => child)) return null;
359
669
  return {
@@ -362,8 +672,38 @@ const normalizeSegment = (segment, ctx) => {
362
672
  widths: normalizeWidths(clone.widths, besides.length),
363
673
  };
364
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)
680
+ );
681
+ if (!besides.some(Boolean)) return null;
682
+ return {
683
+ besides,
684
+ widths: normalizeWidths(inner.widths, besides.length),
685
+ };
686
+ }
365
687
 
366
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
+ }
367
707
  case "container": {
368
708
  const contents = normalizeChild(clone.contents, ctx);
369
709
  return contents
@@ -388,7 +728,10 @@ const normalizeSegment = (segment, ctx) => {
388
728
  }
389
729
  case "tabs": {
390
730
  const tabs = normalizeTabs(clone.tabs, ctx);
391
- return tabs.length ? { ...clone, tabs, class: clone.class || "" } : null;
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 || "" };
392
735
  }
393
736
  case "blank":
394
737
  return {
@@ -428,17 +771,51 @@ const normalizeSegment = (segment, ctx) => {
428
771
  state: clone.state || {},
429
772
  class: clone.class || "",
430
773
  };
431
- case "view_link":
774
+ case "view_link": {
432
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
+ }
433
809
  return {
434
810
  ...clone,
435
- view: ctx.viewNames.includes(clone.view)
436
- ? clone.view
437
- : ctx.viewNames[0],
438
- view_label: clone.view_label || clone.view,
811
+ view: resolvedView,
812
+ view_label: viewLabel,
439
813
  link_style: clone.link_style || "",
440
814
  class: clone.class || "",
815
+ isFormula,
816
+ ...(relation ? { relation } : {}),
441
817
  };
818
+ }
442
819
  case "field": {
443
820
  if (!ctx.fields.length) return null;
444
821
  const fieldMeta = ctx.fieldMap[clone.field_name] || ctx.fields[0];
@@ -447,7 +824,7 @@ const normalizeSegment = (segment, ctx) => {
447
824
  const validFieldview = pickFieldview(
448
825
  fieldMeta,
449
826
  ctx.mode,
450
- clone.fieldview,
827
+ clone.fieldview
451
828
  );
452
829
  return {
453
830
  ...clone,
@@ -457,6 +834,19 @@ const normalizeSegment = (segment, ctx) => {
457
834
  class: clone.class || "",
458
835
  };
459
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
+ }
460
850
  case "action": {
461
851
  if (!ctx.actions.length) return null;
462
852
  const actionName = ctx.actions.includes(clone.action_name)
@@ -508,7 +898,7 @@ const collectSegments = (segment, out = []) => {
508
898
  if (segment.contents) collectSegments(segment.contents, out);
509
899
  if (segment.tabs) {
510
900
  ensureArray(segment.tabs).forEach((tab) =>
511
- collectSegments(tab.contents, out),
901
+ collectSegments(tab.contents, out)
512
902
  );
513
903
  }
514
904
  return out;
@@ -559,25 +949,48 @@ const normalizeLayoutCandidate = (candidate, ctx) => {
559
949
  if (!normalized) {
560
950
  normalized = convertForeignLayout(candidate, ctx);
561
951
  }
562
- if (!normalized) throw new Error("Empty layout after normalization");
952
+ if (!normalized) return null;
563
953
  const layout = Array.isArray(normalized) ? { above: normalized } : normalized;
564
954
  return sanitizeNoHtmlSegments(layout);
565
955
  };
566
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
+
567
975
  const buildPromptText = (userPrompt, ctx, schema) => {
568
976
  const parts = [
569
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.`,
570
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>}.',
571
979
  'The "layout" object MUST conform entirely to the provided JSON Schema. Do not invent properties, types, or structure not defined in the schema.',
572
980
  ];
981
+ parts.push(ctx.modeGuidance);
982
+ const fieldMeta = buildFieldMetadata(ctx.fields);
983
+ if (fieldMeta) parts.push(fieldMeta);
573
984
  parts.push(
574
- "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.",
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."
575
986
  );
576
987
  parts.push(
577
- `Here is the strict Saltcorn layout JSON schema you MUST follow to construct the layout. Do not deviate from these definitions:\n${JSON.stringify(schema)}`,
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
+ )}`
578
991
  );
579
992
  parts.push(
580
- `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}"`,
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}"`
581
994
  );
582
995
  return parts.join("\n\n");
583
996
  };
@@ -796,6 +1209,22 @@ const buildBracketObject = (node) => {
796
1209
  return obj;
797
1210
  };
798
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
+
799
1228
  const buildContext = async (mode, tableName) => {
800
1229
  const normalizedMode = (mode || "show").toLowerCase();
801
1230
  const ctx = {
@@ -806,15 +1235,25 @@ const buildContext = async (mode, tableName) => {
806
1235
  fieldMap: {},
807
1236
  actions: [],
808
1237
  viewNames: [],
1238
+ viewTableMap: {},
1239
+ schemaData: null,
809
1240
  };
810
1241
 
811
1242
  // Global actions and views are useful even when no table is specified (page builder)
812
1243
  const stateActions = Object.keys(getState().actions || {});
813
1244
  try {
814
- const allViews = await View.find();
815
- ctx.viewNames = allViews.map((v) => v.name).filter(Boolean);
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
+ }
816
1254
  } catch (err) {
817
1255
  ctx.viewNames = [];
1256
+ ctx.viewTableMap = {};
818
1257
  }
819
1258
 
820
1259
  if (!tableName) {
@@ -823,7 +1262,7 @@ const buildContext = async (mode, tableName) => {
823
1262
  }).filter((tr) => tr.name && !tr.table_id);
824
1263
 
825
1264
  ctx.actions = Array.from(
826
- new Set([...stateActions, ...triggers.map((tr) => tr.name)]),
1265
+ new Set([...stateActions, ...triggers.map((tr) => tr.name)])
827
1266
  ).filter(Boolean);
828
1267
  return ctx;
829
1268
  }
@@ -843,7 +1282,13 @@ const buildContext = async (mode, tableName) => {
843
1282
  }
844
1283
  if (rawFields?.then) rawFields = await rawFields;
845
1284
  const fields = (rawFields || []).map((field) => {
846
- const fieldviews = Object.keys(field.type?.fieldviews || {});
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
+ }
847
1292
  const isPkName =
848
1293
  table.pk_name &&
849
1294
  typeof field.name === "string" &&
@@ -868,6 +1313,7 @@ const buildContext = async (mode, tableName) => {
868
1313
  is_pk_name: !!isPkName,
869
1314
  default_fieldview: defaultFieldview,
870
1315
  fieldviews: fieldviews.length ? fieldviews : ["show"],
1316
+ attributes: field.attributes || {},
871
1317
  };
872
1318
  });
873
1319
 
@@ -888,7 +1334,7 @@ const buildContext = async (mode, tableName) => {
888
1334
  ? edit_build_in_actions || []
889
1335
  : ["Delete", "GoBack"];
890
1336
  const actions = Array.from(
891
- new Set([...builtIns, ...stateActions, ...triggers.map((tr) => tr.name)]),
1337
+ new Set([...builtIns, ...stateActions, ...triggers.map((tr) => tr.name)])
892
1338
  ).filter(Boolean);
893
1339
 
894
1340
  ctx.table = table;
@@ -952,8 +1398,8 @@ const buildErrorLayout = ({ message, mode, table }) => {
952
1398
  };
953
1399
 
954
1400
  module.exports = {
955
- run: async (prompt, mode, table, chat) => {
956
- // Remove any leading "container:" or similar so as to remain with only the user prompt.
1401
+ normalizeLayoutCandidate,
1402
+ run: async (prompt, mode, table, existing_layout, chat) => {
957
1403
  prompt = prompt.trim().replace(/^\[\w+\]:\s*/, "");
958
1404
 
959
1405
  const ctx = await buildContext(mode, table);
@@ -961,28 +1407,46 @@ module.exports = {
961
1407
  const llm = getState().functions.llm_generate;
962
1408
  if (!llm?.run) throw new Error("LLM generator not configured");
963
1409
 
964
- const llmPrompt = buildPromptText(prompt, ctx, schema);
965
- console.log({ schema });
966
- console.log({ llmPrompt: llmPrompt.slice(0, 1000) });
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
+
967
1422
  let responseFormat;
968
1423
  try {
969
1424
  if (!schema || !schema.schema) {
970
1425
  throw new Error("Builder schema unavailable");
971
1426
  }
972
- validateSchemaRefs(schema.schema);
973
- const deref = derefSchema(schema.schema);
974
- if (schemaHasRef(deref)) {
975
- console.warn(
976
- "Builder response schema still contains $ref; skipping response_format",
977
- );
1427
+ if (!allowResponseFormat) {
1428
+ console.warn("LLM backend does not support response_format; skipping");
978
1429
  } else {
979
- responseFormat = {
980
- type: "json_schema",
981
- json_schema: {
982
- name: "saltcorn_layout",
983
- schema: deref,
984
- },
985
- };
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
+ }
986
1450
  }
987
1451
  } catch (err) {
988
1452
  console.warn("Builder response schema validation failed", err);
@@ -1003,13 +1467,12 @@ module.exports = {
1003
1467
  if (!schema || !schema.schema) {
1004
1468
  throw new Error("Builder schema unavailable");
1005
1469
  }
1006
- console.log("¢¢¢¢", { options });
1007
1470
  rawResponse = await llm.run(llmPrompt, options);
1008
- console.log(JSON.stringify(rawResponse, null, 2));
1009
1471
  payload = parseJsonPayload(rawResponse);
1010
- console.log(JSON.stringify({ payload }, null, 2));
1011
1472
  const candidate = payload.layout ?? payload;
1012
- return normalizeLayoutCandidate(candidate, ctx);
1473
+ const result = normalizeLayoutCandidate(candidate, ctx);
1474
+ if (!result) throw new Error("Empty layout after normalization");
1475
+ return result;
1013
1476
  } catch (err) {
1014
1477
  let lastError = err;
1015
1478
  try {
@@ -1017,7 +1480,9 @@ module.exports = {
1017
1480
  rawResponse = await llm.run(repairPrompt, options);
1018
1481
  payload = parseJsonPayload(rawResponse);
1019
1482
  const candidate = payload.layout ?? payload;
1020
- return normalizeLayoutCandidate(candidate, ctx);
1483
+ const result = normalizeLayoutCandidate(candidate, ctx);
1484
+ if (!result) throw new Error("Empty layout after normalization");
1485
+ return result;
1021
1486
  } catch (repairErr) {
1022
1487
  lastError = repairErr;
1023
1488
  }
@@ -1027,7 +1492,9 @@ module.exports = {
1027
1492
  rawResponse = await llm.run(reducedPrompt, options);
1028
1493
  payload = parseJsonPayload(rawResponse);
1029
1494
  const candidate = payload.layout ?? payload;
1030
- return normalizeLayoutCandidate(candidate, ctx);
1495
+ const result = normalizeLayoutCandidate(candidate, ctx);
1496
+ if (!result) throw new Error("Empty layout after normalization");
1497
+ return result;
1031
1498
  } catch (reducedErr) {
1032
1499
  lastError = reducedErr;
1033
1500
  }
@@ -1047,6 +1514,7 @@ module.exports = {
1047
1514
  { name: "prompt", type: "String" },
1048
1515
  { name: "mode", type: "String" },
1049
1516
  { name: "table", type: "String" },
1517
+ { name: "existing_layout", type: "JSON" },
1050
1518
  { name: "chat", type: "JSON" },
1051
1519
  ],
1052
1520
  };