@saltcorn/copilot 0.7.5 → 0.8.1

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,108 @@ 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 {
10
+ GET_RELATION_PATHS_FUNCTION,
11
+ getRelationPaths,
12
+ getRelationPathsForPairs,
13
+ pickBestRelation,
14
+ } = require("./relation-paths");
7
15
 
8
16
  const ACTION_SIZES = ["btn-sm", "btn-lg"];
17
+
18
+ // ── Edit-mode guidance blocks ─────────────────────────────────────────────────
19
+
20
+ const EDIT_FIELD_INCLUSION = `\
21
+ Layout is a form for editing a single row. \
22
+ Include ALL non-primary-key, non-calculated fields from the table as inputs — \
23
+ not just fields mentioned in the task description. \
24
+ Never include calculated fields (aggregations, derived values) in an edit form — they are read-only. \
25
+ The only fields that may be omitted are FK fields to the users table that are explicitly \
26
+ designated as ownership fields (set automatically from the logged-in user). \
27
+ All other foreign key fields — including parent-context keys like trip_id — MUST appear as \
28
+ selector inputs; Saltcorn auto-fills them from the URL state when opened in context.`;
29
+
30
+ const EDIT_FIELDVIEW_SELECTION = `\
31
+ For date fields always prefer fieldview "flatpickr" when available — it provides the best user experience \
32
+ and works for both regular dates and day-only dates. \
33
+ Only use fieldview "edit_day" as a fallback when the field has day_only=true and flatpickr is not installed. \
34
+ For String fields that have an options attribute (a comma-separated list of fixed choices), \
35
+ use fieldview "select" — this renders a dropdown with those options. \
36
+ Do not use "select_by_code" for fields with fixed options.`;
37
+
38
+ const EDIT_LAYOUT_STRUCTURE = `\
39
+ Every field MUST be preceded by a label. \
40
+ Use a blank segment with the field's label text immediately above each field inside an above array, like: \
41
+ {"above": [{"type":"blank","contents":"Field Label","class":""}, {"type":"field","field_name":"...","fieldview":"edit"}]}. \
42
+ Alternatively wrap groups of fields in a card with a descriptive title.`;
43
+
44
+ const EDIT_ACTIONS = `\
45
+ Use edit fieldviews, group related inputs, and finish with a row of actions: \
46
+ a Save button (action_name "Save", style "btn btn-primary") and \
47
+ a Cancel button (action_name "GoBack", style "btn btn-outline-secondary"). \
48
+ To add a trigger button, add an extra action segment with action_name set to the trigger's name \
49
+ exactly as it appears in the available actions list (ctx.actions).`;
50
+
51
+ // ── Show-mode guidance blocks ─────────────────────────────────────────────────
52
+
53
+ const SHOW_PURPOSE = `\
54
+ Layout displays one record read-only. \
55
+ Use show fieldviews only (e.g. "show", "as_text", "showDay").`;
56
+
57
+ const SHOW_NO_EDIT_FIELDVIEWS = `\
58
+ A show layout is read-only and must never use edit fieldviews \
59
+ (textarea, edit, input, select) — those are for form inputs, not display.`;
60
+
61
+ const SHOW_LABELS = `\
62
+ Every field MUST be preceded by a label using a blank segment or \
63
+ grouped in a card with a descriptive title.`;
64
+
65
+ // ── List-columns guidance blocks ──────────────────────────────────────────────
66
+
67
+ const LISTCOLUMNS_STRUCTURE = `\
68
+ Layout defines list columns for a list view. \
69
+ Use a list_columns wrapper with besides entries containing list_column segments. \
70
+ Each list_column has a header_label for the column heading — \
71
+ do NOT add any blank or label segment inside a list_column. \
72
+ Typically each list_column contains a single field, action, or view_link.`;
73
+
74
+ const LISTCOLUMNS_FIELDVIEWS = `\
75
+ Include all meaningful fields as columns, including foreign key fields. \
76
+ For a foreign key field use fieldview "show" so the related record's label is displayed rather than a raw ID. \
77
+ For string/text fields use fieldview "as_text" or "show" by default; \
78
+ only use "show_with_html" when the task explicitly requires rendering HTML content.`;
79
+
80
+ const LISTCOLUMNS_VIEW_LINK = `\
81
+ A view_link that opens the detail of a row MUST point to a Show-type view (viewtemplate "Show"). \
82
+ Only include such a view_link if a Show view for this table is listed in the available views.`;
83
+
84
+ // ── Composed MODE_GUIDANCE ────────────────────────────────────────────────────
85
+
9
86
  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.",
87
+ edit: [
88
+ EDIT_FIELD_INCLUSION,
89
+ EDIT_ACTIONS,
90
+ EDIT_FIELDVIEW_SELECTION,
91
+ EDIT_LAYOUT_STRUCTURE,
92
+ ].join(" "),
93
+
94
+ show: [SHOW_PURPOSE, SHOW_NO_EDIT_FIELDVIEWS, SHOW_LABELS].join(" "),
95
+
12
96
  list: "Layout represents a single row in a list. Highlight key fields, keep actions compact, and support filtering if requested.",
97
+
98
+ listcolumns: [
99
+ LISTCOLUMNS_STRUCTURE,
100
+ LISTCOLUMNS_FIELDVIEWS,
101
+ LISTCOLUMNS_VIEW_LINK,
102
+ ].join(" "),
103
+
13
104
  filter:
14
105
  "Layout lets users define filters. Provide appropriate filter inputs plus an action to run or reset filters.",
106
+
15
107
  page: "Layout builds a general app page. Combine hero text, cards, containers, and call-to-action buttons.",
108
+
16
109
  default:
17
110
  "Use Saltcorn layout primitives (above, besides, container, card, tabs, blank, field, action, view_link, view). Do not return HTML snippets.",
18
111
  };
@@ -188,11 +281,200 @@ const derefSchema = (schema, maxDepth = 3) => {
188
281
  return expand(root, []);
189
282
  };
190
283
 
284
+ const simplifySchemaForLlm = (schema) => {
285
+ const deref = derefSchema(schema, 2); // Reduced maxDepth
286
+ const scrub = (node, keyName, depth = 0) => {
287
+ if (!node || typeof node !== "object" || depth > 4) {
288
+ // Added depth limit
289
+ if (
290
+ keyName === "contents" ||
291
+ keyName === "besides" ||
292
+ keyName === "above"
293
+ )
294
+ return {
295
+ type: "string",
296
+ description: "Simplified content: a string or a basic segment.",
297
+ };
298
+ return node;
299
+ }
300
+ if (Array.isArray(node))
301
+ return node.map((item) => scrub(item, undefined, depth + 1));
302
+ if (keyName === "style") {
303
+ return {
304
+ type: "string",
305
+ description: "CSS style string (e.g. background-color: red;).",
306
+ };
307
+ }
308
+ if (typeof node.$ref === "string") {
309
+ return {
310
+ type: "object",
311
+ description: "Simplified recursive segment.",
312
+ additionalProperties: false,
313
+ };
314
+ }
315
+ const out = {};
316
+ for (const [key, val] of Object.entries(node)) {
317
+ if (key === "$defs" || key === "definitions") continue;
318
+ out[key] = scrub(val, key, depth + 1);
319
+ }
320
+ const nodeType = out.type;
321
+ const isObjectType =
322
+ nodeType === "object" ||
323
+ (Array.isArray(nodeType) && nodeType.includes("object")) ||
324
+ Object.prototype.hasOwnProperty.call(out, "properties");
325
+ const isArrayType =
326
+ nodeType === "array" ||
327
+ (Array.isArray(nodeType) && nodeType.includes("array")) ||
328
+ Object.prototype.hasOwnProperty.call(out, "items");
329
+ const isNumberType =
330
+ nodeType === "number" ||
331
+ nodeType === "integer" ||
332
+ (Array.isArray(nodeType) &&
333
+ (nodeType.includes("number") || nodeType.includes("integer")));
334
+ if (isObjectType) {
335
+ out.additionalProperties = false;
336
+ }
337
+ if (isArrayType && typeof out.minItems !== "undefined") {
338
+ const minItems = Number(out.minItems);
339
+ if (Number.isFinite(minItems) && minItems > 1) out.minItems = 1;
340
+ }
341
+ if (isNumberType) {
342
+ delete out.minimum;
343
+ delete out.maximum;
344
+ delete out.exclusiveMinimum;
345
+ delete out.exclusiveMaximum;
346
+ delete out.multipleOf;
347
+ }
348
+ return out;
349
+ };
350
+ return scrub(deref);
351
+ };
352
+
353
+ const countOptionalParams = (schema) => {
354
+ let count = 0;
355
+ const walk = (node) => {
356
+ if (!node || typeof node !== "object") return;
357
+ if (Array.isArray(node)) {
358
+ node.forEach(walk);
359
+ return;
360
+ }
361
+ const props = node.properties;
362
+ if (props && typeof props === "object") {
363
+ const required = Array.isArray(node.required) ? node.required : [];
364
+ const optional = Object.keys(props).filter(
365
+ (key) => !required.includes(key)
366
+ );
367
+ count += optional.length;
368
+ Object.values(props).forEach(walk);
369
+ }
370
+ if (node.items) walk(node.items);
371
+ if (node.anyOf) node.anyOf.forEach(walk);
372
+ if (node.oneOf) node.oneOf.forEach(walk);
373
+ if (node.allOf) node.allOf.forEach(walk);
374
+ };
375
+ walk(schema);
376
+ return count;
377
+ };
378
+
379
+ const trimOptionalProperties = (schema, maxOptional) => {
380
+ if (!schema || typeof schema !== "object") return schema;
381
+
382
+ const clonedSchema = JSON.parse(JSON.stringify(schema));
383
+ let optionalCount = countOptionalParams(clonedSchema);
384
+ if (optionalCount <= maxOptional) {
385
+ return clonedSchema;
386
+ }
387
+
388
+ const optionalPropsByNode = new Map();
389
+
390
+ const findOptional = (node, path = []) => {
391
+ if (!node || typeof node !== "object") return;
392
+ if (Array.isArray(node)) {
393
+ node.forEach((item, i) => findOptional(item, [...path, i]));
394
+ return;
395
+ }
396
+
397
+ const props = node.properties;
398
+ if (props && typeof props === "object") {
399
+ const required = new Set(node.required || []);
400
+ const optionalKeys = Object.keys(props).filter((k) => !required.has(k));
401
+ if (optionalKeys.length > 0) {
402
+ optionalPropsByNode.set(node, optionalKeys);
403
+ }
404
+ Object.values(props).forEach((propNode) => findOptional(propNode));
405
+ }
406
+
407
+ if (node.items) findOptional(node.items, [...path, "items"]);
408
+ if (node.anyOf)
409
+ node.anyOf.forEach((item, i) =>
410
+ findOptional(item, [...path, "anyOf", i])
411
+ );
412
+ if (node.oneOf)
413
+ node.oneOf.forEach((item, i) =>
414
+ findOptional(item, [...path, "oneOf", i])
415
+ );
416
+ if (node.allOf)
417
+ node.allOf.forEach((item, i) =>
418
+ findOptional(item, [...path, "allOf", i])
419
+ );
420
+ };
421
+
422
+ findOptional(clonedSchema);
423
+
424
+ const nodesWithOptionals = Array.from(optionalPropsByNode.keys());
425
+ let nodeIndex = 0;
426
+
427
+ while (optionalCount > maxOptional && nodeIndex < nodesWithOptionals.length) {
428
+ const node = nodesWithOptionals[nodeIndex];
429
+ const optionalKeys = optionalPropsByNode.get(node);
430
+
431
+ if (optionalKeys && optionalKeys.length > 0) {
432
+ const keyToRemove = optionalKeys.pop();
433
+ delete node.properties[keyToRemove];
434
+ optionalCount--;
435
+ } else {
436
+ nodeIndex++;
437
+ }
438
+ }
439
+
440
+ return clonedSchema;
441
+ };
442
+
191
443
  const randomId = () =>
192
444
  Math.floor(Math.random() * 0xffffff)
193
445
  .toString(16)
194
446
  .padStart(6, "0");
195
447
 
448
+ const toCamelCaseStyle = (key) =>
449
+ key.replace(/-([a-z])/g, (_m, chr) => chr.toUpperCase());
450
+
451
+ const parseStyleString = (styleStr) => {
452
+ if (typeof styleStr !== "string") return styleStr;
453
+ const trimmed = styleStr.trim();
454
+ if (!trimmed) return {};
455
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
456
+ try {
457
+ const parsed = JSON.parse(trimmed);
458
+ return parsed && typeof parsed === "object" ? parsed : {};
459
+ } catch (err) {
460
+ // fall through to CSS parsing
461
+ }
462
+ }
463
+ const out = {};
464
+ trimmed.split(";").forEach((part) => {
465
+ const section = part.trim();
466
+ if (!section) return;
467
+ const idx = section.indexOf(":");
468
+ if (idx === -1) return;
469
+ const rawKey = section.slice(0, idx).trim();
470
+ const value = section.slice(idx + 1).trim();
471
+ if (!rawKey || !value) return;
472
+ if (rawKey.startsWith("--")) out[rawKey] = value;
473
+ else out[toCamelCaseStyle(rawKey)] = value;
474
+ });
475
+ return out;
476
+ };
477
+
196
478
  const ensureArray = (value) =>
197
479
  Array.isArray(value) ? value : value == null ? [] : [value];
198
480
 
@@ -206,7 +488,10 @@ const prettifyActionName = (name) =>
206
488
  // Picks a valid fieldview from the field's available fieldviews only.
207
489
  // Never returns a fieldview that doesn't exist in field.fieldviews
208
490
  const pickFieldview = (field, mode, requestedFieldview = null) => {
209
- const availableViews = field?.fieldviews || [];
491
+ const isEditMode = mode === "edit" || mode === "filter";
492
+ const availableViews = isEditMode
493
+ ? field?.editFieldviews || field?.fieldviews || []
494
+ : field?.fieldviews || [];
210
495
 
211
496
  // If no available fieldviews, return the first one or a safe default
212
497
  if (!availableViews.length) {
@@ -214,29 +499,60 @@ const pickFieldview = (field, mode, requestedFieldview = null) => {
214
499
  return mode === "edit" || mode === "filter" ? "edit" : "show";
215
500
  }
216
501
 
217
- // Helper to validate and return a fieldview only if it exists
502
+ // Helper to validate and return a fieldview only if it exists (exact match only)
218
503
  const validateAndReturn = (candidate) => {
219
504
  if (!candidate) return null;
220
505
  const lower = String(candidate).toLowerCase();
221
- // Exact match
222
- const exact = availableViews.find(
223
- (fv) => String(fv).toLowerCase() === lower,
506
+ return (
507
+ availableViews.find((fv) => String(fv).toLowerCase() === lower) || null
224
508
  );
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
509
  };
233
510
 
511
+ // Edit-only fieldviews must never be used in show/list modes
512
+ const EDIT_ONLY_FIELDVIEWS = new Set(["textarea", "edit", "input", "select"]);
513
+ const isShowMode =
514
+ mode === "show" || mode === "list" || mode === "listcolumns";
515
+
516
+ // For date fields in edit mode: flatpickr is always preferred (handles all date types).
517
+ // Only fall back to edit_day when day_only=true and flatpickr is not installed.
518
+ const fieldTypeLower = String(field?.type || "").toLowerCase();
519
+ const isDateField =
520
+ (mode === "edit" || mode === "filter") &&
521
+ (fieldTypeLower === "date" || fieldTypeLower.includes("date"));
522
+ if (isDateField) {
523
+ const flatpickrMatch = availableViews.find(
524
+ (fv) => String(fv).toLowerCase() === "flatpickr"
525
+ );
526
+ if (flatpickrMatch) return flatpickrMatch;
527
+ if (field?.attributes?.day_only) {
528
+ const dayMatch = availableViews.find(
529
+ (fv) => String(fv).toLowerCase() === "edit_day"
530
+ );
531
+ if (dayMatch) return dayMatch;
532
+ }
533
+ }
534
+
535
+ // Fields with fixed options (options attribute) always use plain select, never select_by_code
536
+ if ((mode === "edit" || mode === "filter") && field?.attributes?.options) {
537
+ const selectMatch = availableViews.find(
538
+ (fv) => String(fv).toLowerCase() === "select"
539
+ );
540
+ if (selectMatch) return selectMatch;
541
+ }
542
+
234
543
  // If a specific fieldview was requested by the user, try to honor it
235
- // but ONLY if it actually exists in available views
544
+ // but ONLY if it actually exists in available views and is appropriate for the mode
236
545
  if (requestedFieldview) {
237
- const validated = validateAndReturn(requestedFieldview);
238
- if (validated) return validated;
239
- // Requested fieldview not available for this field - fall through to defaults
546
+ if (
547
+ isShowMode &&
548
+ EDIT_ONLY_FIELDVIEWS.has(String(requestedFieldview).toLowerCase())
549
+ ) {
550
+ // Fall through to show-mode defaults — don't use edit fieldviews in show context
551
+ } else {
552
+ const validated = validateAndReturn(requestedFieldview);
553
+ if (validated) return validated;
554
+ // Requested fieldview not available for this field - fall through to defaults
555
+ }
240
556
  }
241
557
 
242
558
  // Get the field's configured default fieldview
@@ -249,21 +565,21 @@ const pickFieldview = (field, mode, requestedFieldview = null) => {
249
565
  }
250
566
 
251
567
  // Mode-based selection from available fieldviews
252
- if (mode === "show" || mode === "list") {
568
+ if (mode === "show" || mode === "list" || mode === "listcolumns") {
253
569
  // For show mode, prefer simple text-based views, but only from available views
254
570
  const showPreferences = ["as_text", "show", "as_string", "text", "showas"];
255
571
  for (const pref of showPreferences) {
256
- const match = availableViews.find((fv) =>
257
- String(fv).toLowerCase().includes(pref),
572
+ const match = availableViews.find(
573
+ (fv) => String(fv).toLowerCase() === pref
258
574
  );
259
575
  if (match) return match;
260
576
  }
261
577
  } else if (mode === "edit" || mode === "filter") {
262
- // For edit mode, prefer edit-capable fieldviews from available views
578
+ // For edit mode, prefer edit-capable fieldviews from available views (exact match)
263
579
  const editPreferences = ["edit", "input", "select", "textarea"];
264
580
  for (const pref of editPreferences) {
265
- const match = availableViews.find((fv) =>
266
- String(fv).toLowerCase().includes(pref),
581
+ const match = availableViews.find(
582
+ (fv) => String(fv).toLowerCase() === pref
267
583
  );
268
584
  if (match) return match;
269
585
  }
@@ -343,9 +659,12 @@ const normalizeSegment = (segment, ctx) => {
343
659
  if (typeof segment !== "object") return null;
344
660
 
345
661
  const clone = { ...segment };
662
+ if (typeof clone.style === "string") {
663
+ clone.style = parseStyleString(clone.style);
664
+ }
346
665
  if (clone.type === "prompt") return null;
347
666
 
348
- if (!clone.type && clone.above) {
667
+ if ((!clone.type || clone.type === "stack") && clone.above) {
349
668
  const above = ensureArray(clone.above)
350
669
  .map((child) => normalizeSegment(child, ctx))
351
670
  .filter(Boolean);
@@ -353,7 +672,7 @@ const normalizeSegment = (segment, ctx) => {
353
672
  }
354
673
  if (!clone.type && clone.besides) {
355
674
  const besides = ensureArray(clone.besides).map((child) =>
356
- child == null ? null : normalizeSegment(child, ctx),
675
+ child == null ? null : normalizeSegment(child, ctx)
357
676
  );
358
677
  if (!besides.some((child) => child)) return null;
359
678
  return {
@@ -362,8 +681,38 @@ const normalizeSegment = (segment, ctx) => {
362
681
  widths: normalizeWidths(clone.widths, besides.length),
363
682
  };
364
683
  }
684
+ // Handle {columns: {besides: [...], widths: [...]}} wrapper the LLM sometimes emits
685
+ if (!clone.type && clone.columns?.besides) {
686
+ const inner = clone.columns;
687
+ const besides = ensureArray(inner.besides).map((child) =>
688
+ child == null ? null : normalizeSegment(child, ctx)
689
+ );
690
+ if (!besides.some(Boolean)) return null;
691
+ return {
692
+ besides,
693
+ widths: normalizeWidths(inner.widths, besides.length),
694
+ };
695
+ }
365
696
 
366
697
  switch (clone.type) {
698
+ case "columns":
699
+ case "row":
700
+ case "grid": {
701
+ // LLM uses these as a besides wrapper with widths
702
+ if (clone.besides) {
703
+ const besides = ensureArray(clone.besides).map((child) =>
704
+ child == null ? null : normalizeSegment(child, ctx)
705
+ );
706
+ if (!besides.some(Boolean)) return null;
707
+ return {
708
+ besides,
709
+ widths: normalizeWidths(clone.widths, besides.length),
710
+ };
711
+ }
712
+ // fall through to container-like handling if no besides
713
+ const contents = normalizeChild(clone.contents, ctx);
714
+ return contents ? { ...clone, contents } : null;
715
+ }
367
716
  case "container": {
368
717
  const contents = normalizeChild(clone.contents, ctx);
369
718
  return contents
@@ -388,7 +737,10 @@ const normalizeSegment = (segment, ctx) => {
388
737
  }
389
738
  case "tabs": {
390
739
  const tabs = normalizeTabs(clone.tabs, ctx);
391
- return tabs.length ? { ...clone, tabs, class: clone.class || "" } : null;
740
+ if (!tabs.length) return null;
741
+ const titles = clone.titles ?? tabs.map((t) => t.title || "");
742
+ const contents = clone.contents ?? tabs.map((t) => t.contents);
743
+ return { ...clone, tabs, titles, contents, class: clone.class || "" };
392
744
  }
393
745
  case "blank":
394
746
  return {
@@ -428,17 +780,46 @@ const normalizeSegment = (segment, ctx) => {
428
780
  state: clone.state || {},
429
781
  class: clone.class || "",
430
782
  };
431
- case "view_link":
783
+ case "view_link": {
432
784
  if (!ctx.viewNames.length) return null;
785
+ const resolvedView = ctx.viewNames.includes(clone.view)
786
+ ? clone.view
787
+ : ctx.viewNames[0];
788
+ let relation = clone.relation;
789
+ if (!relation && ctx.table && ctx.schemaData) {
790
+ try {
791
+ const relations = getRelationPaths(
792
+ ctx.table.name,
793
+ resolvedView,
794
+ ctx.schemaData
795
+ );
796
+ if (relations.length > 0) {
797
+ const picked = pickBestRelation(relations);
798
+ if (picked) relation = picked.relationString;
799
+ }
800
+ } catch (e) {
801
+ console.error("view_link relation lookup failed:", e.message);
802
+ }
803
+ }
804
+ // Convert {{ expr }} template syntax to isFormula.label: true with JS expression
805
+ let viewLabel = clone.view_label || clone.view;
806
+ let isFormula = clone.isFormula || {};
807
+ const tmplMatch =
808
+ typeof viewLabel === "string" && viewLabel.match(/^\{\{\s*(.+?)\s*\}\}$/);
809
+ if (tmplMatch) {
810
+ viewLabel = tmplMatch[1];
811
+ isFormula = { ...isFormula, label: true };
812
+ }
433
813
  return {
434
814
  ...clone,
435
- view: ctx.viewNames.includes(clone.view)
436
- ? clone.view
437
- : ctx.viewNames[0],
438
- view_label: clone.view_label || clone.view,
815
+ view: resolvedView,
816
+ view_label: viewLabel,
439
817
  link_style: clone.link_style || "",
440
818
  class: clone.class || "",
819
+ isFormula,
820
+ ...(relation ? { relation } : {}),
441
821
  };
822
+ }
442
823
  case "field": {
443
824
  if (!ctx.fields.length) return null;
444
825
  const fieldMeta = ctx.fieldMap[clone.field_name] || ctx.fields[0];
@@ -447,7 +828,7 @@ const normalizeSegment = (segment, ctx) => {
447
828
  const validFieldview = pickFieldview(
448
829
  fieldMeta,
449
830
  ctx.mode,
450
- clone.fieldview,
831
+ clone.fieldview
451
832
  );
452
833
  return {
453
834
  ...clone,
@@ -457,6 +838,26 @@ const normalizeSegment = (segment, ctx) => {
457
838
  class: clone.class || "",
458
839
  };
459
840
  }
841
+ case "list_columns": {
842
+ const besides = ensureArray(clone.besides).map((child) =>
843
+ child == null ? null : normalizeSegment(child, ctx)
844
+ );
845
+ if (!besides.some(Boolean)) return null;
846
+ return { ...clone, besides, list_columns: true };
847
+ }
848
+ case "list_column": {
849
+ let raw = clone.contents;
850
+ // Saltcorn's list renderer handles `above` in a cell (wraps in Container)
851
+ // but silently drops a typeless `besides`. Convert it so both links render
852
+ // stacked in one column rather than disappearing.
853
+ if (raw && !raw.type && Array.isArray(raw.besides)) {
854
+ raw = { above: raw.besides };
855
+ }
856
+ const contents = normalizeChild(raw, ctx);
857
+ return contents
858
+ ? { ...clone, contents, header_label: clone.header_label || "" }
859
+ : null;
860
+ }
460
861
  case "action": {
461
862
  if (!ctx.actions.length) return null;
462
863
  const actionName = ctx.actions.includes(clone.action_name)
@@ -508,7 +909,7 @@ const collectSegments = (segment, out = []) => {
508
909
  if (segment.contents) collectSegments(segment.contents, out);
509
910
  if (segment.tabs) {
510
911
  ensureArray(segment.tabs).forEach((tab) =>
511
- collectSegments(tab.contents, out),
912
+ collectSegments(tab.contents, out)
512
913
  );
513
914
  }
514
915
  return out;
@@ -559,25 +960,48 @@ const normalizeLayoutCandidate = (candidate, ctx) => {
559
960
  if (!normalized) {
560
961
  normalized = convertForeignLayout(candidate, ctx);
561
962
  }
562
- if (!normalized) throw new Error("Empty layout after normalization");
963
+ if (!normalized) return null;
563
964
  const layout = Array.isArray(normalized) ? { above: normalized } : normalized;
564
965
  return sanitizeNoHtmlSegments(layout);
565
966
  };
566
967
 
968
+ const buildFieldMetadata = (fields) => {
969
+ if (!fields || !fields.length) return null;
970
+ const lines = fields
971
+ .filter((f) => !f.primary_key)
972
+ .map((f) => {
973
+ const attrs = Object.entries(f.attributes || {})
974
+ .filter(
975
+ ([, v]) => v !== null && v !== undefined && v !== "" && v !== false
976
+ )
977
+ .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
978
+ .join(", ");
979
+ return ` ${f.name} (${f.type}${f.calculated ? ", calculated" : ""}${
980
+ attrs ? ` — attrs: ${attrs}` : ""
981
+ })`;
982
+ });
983
+ return `Table fields available for this layout:\n${lines.join("\n")}`;
984
+ };
985
+
567
986
  const buildPromptText = (userPrompt, ctx, schema) => {
568
987
  const parts = [
569
988
  `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
989
  '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
990
  'The "layout" object MUST conform entirely to the provided JSON Schema. Do not invent properties, types, or structure not defined in the schema.',
572
991
  ];
992
+ parts.push(ctx.modeGuidance);
993
+ const fieldMeta = buildFieldMetadata(ctx.fields);
994
+ if (fieldMeta) parts.push(fieldMeta);
573
995
  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.",
996
+ "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
997
  );
576
998
  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)}`,
999
+ `Here is the strict Saltcorn layout JSON schema you MUST follow to construct the layout. Do not deviate from these definitions:\n${JSON.stringify(
1000
+ schema
1001
+ )}`
578
1002
  );
579
1003
  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}"`,
1004
+ `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
1005
  );
582
1006
  return parts.join("\n\n");
583
1007
  };
@@ -796,6 +1220,7 @@ const buildBracketObject = (node) => {
796
1220
  return obj;
797
1221
  };
798
1222
 
1223
+
799
1224
  const buildContext = async (mode, tableName) => {
800
1225
  const normalizedMode = (mode || "show").toLowerCase();
801
1226
  const ctx = {
@@ -806,15 +1231,25 @@ const buildContext = async (mode, tableName) => {
806
1231
  fieldMap: {},
807
1232
  actions: [],
808
1233
  viewNames: [],
1234
+ viewTableMap: {},
1235
+ schemaData: null,
809
1236
  };
810
1237
 
811
1238
  // Global actions and views are useful even when no table is specified (page builder)
812
1239
  const stateActions = Object.keys(getState().actions || {});
813
1240
  try {
814
- const allViews = await View.find();
815
- ctx.viewNames = allViews.map((v) => v.name).filter(Boolean);
1241
+ const schemaData = await build_schema_data();
1242
+ ctx.schemaData = schemaData;
1243
+ ctx.viewNames = schemaData.views.map((v) => v.name).filter(Boolean);
1244
+ for (const v of schemaData.views) {
1245
+ if (v.name && v.table_id) {
1246
+ const vt = Table.findOne({ id: v.table_id });
1247
+ if (vt) ctx.viewTableMap[v.name] = vt.name;
1248
+ }
1249
+ }
816
1250
  } catch (err) {
817
1251
  ctx.viewNames = [];
1252
+ ctx.viewTableMap = {};
818
1253
  }
819
1254
 
820
1255
  if (!tableName) {
@@ -823,7 +1258,7 @@ const buildContext = async (mode, tableName) => {
823
1258
  }).filter((tr) => tr.name && !tr.table_id);
824
1259
 
825
1260
  ctx.actions = Array.from(
826
- new Set([...stateActions, ...triggers.map((tr) => tr.name)]),
1261
+ new Set([...stateActions, ...triggers.map((tr) => tr.name)])
827
1262
  ).filter(Boolean);
828
1263
  return ctx;
829
1264
  }
@@ -843,7 +1278,19 @@ const buildContext = async (mode, tableName) => {
843
1278
  }
844
1279
  if (rawFields?.then) rawFields = await rawFields;
845
1280
  const fields = (rawFields || []).map((field) => {
846
- const fieldviews = Object.keys(field.type?.fieldviews || {});
1281
+ const fvDefs = field.type?.fieldviews || {};
1282
+ let fieldviews = Object.keys(fvDefs);
1283
+ // FK fields have type "Key" (a string) so field.type?.fieldviews is empty;
1284
+ // ensure select and show are always available for them
1285
+ const typeName = field.type?.name || field.type || "";
1286
+ if (!fieldviews.length && String(typeName).startsWith("Key")) {
1287
+ fieldviews = ["select", "show"];
1288
+ }
1289
+ // editFieldviews: only fieldviews where isEdit is not explicitly false
1290
+ const editFieldviews = fieldviews.filter(
1291
+ (fv) => fvDefs[fv]?.isEdit !== false
1292
+ );
1293
+
847
1294
  const isPkName =
848
1295
  table.pk_name &&
849
1296
  typeof field.name === "string" &&
@@ -868,6 +1315,8 @@ const buildContext = async (mode, tableName) => {
868
1315
  is_pk_name: !!isPkName,
869
1316
  default_fieldview: defaultFieldview,
870
1317
  fieldviews: fieldviews.length ? fieldviews : ["show"],
1318
+ editFieldviews: editFieldviews.length ? editFieldviews : fieldviews,
1319
+ attributes: field.attributes || {},
871
1320
  };
872
1321
  });
873
1322
 
@@ -888,7 +1337,7 @@ const buildContext = async (mode, tableName) => {
888
1337
  ? edit_build_in_actions || []
889
1338
  : ["Delete", "GoBack"];
890
1339
  const actions = Array.from(
891
- new Set([...builtIns, ...stateActions, ...triggers.map((tr) => tr.name)]),
1340
+ new Set([...builtIns, ...stateActions, ...triggers.map((tr) => tr.name)])
892
1341
  ).filter(Boolean);
893
1342
 
894
1343
  ctx.table = table;
@@ -951,9 +1400,60 @@ const buildErrorLayout = ({ message, mode, table }) => {
951
1400
  };
952
1401
  };
953
1402
 
1403
+ const GET_RELATION_PATHS_TOOL = { type: "function", function: GET_RELATION_PATHS_FUNCTION };
1404
+
1405
+ /**
1406
+ * Run the LLM giving it one opportunity to call get_relation_paths before
1407
+ * producing the final layout JSON. Returns the final text response.
1408
+ */
1409
+ const runWithRelationTools = async (llm, mainPrompt, opts) => {
1410
+ const llm_add_message = getState().functions.llm_add_message;
1411
+
1412
+ // Local chat copy with the main prompt pre-loaded so all iterations see it.
1413
+ const runChat = Array.isArray(opts.chat) ? [...opts.chat] : [];
1414
+ runChat.push({ role: "user", content: mainPrompt });
1415
+
1416
+ // Drop response_format during tool phase; repair chain handles JSON after.
1417
+ const toolOpts = { ...opts, tools: [GET_RELATION_PATHS_TOOL] };
1418
+ delete toolOpts.response_format;
1419
+ delete toolOpts.chat;
1420
+
1421
+ // Call 1: model either returns JSON directly or calls get_relation_paths.
1422
+ const raw = await llm.run(null, { ...toolOpts, chat: runChat });
1423
+ if (!raw?.hasToolCalls) {
1424
+ return typeof raw === "string" ? raw : raw?.content ?? "";
1425
+ }
1426
+
1427
+ // Append the assistant message so call 2 has full context.
1428
+ if (raw.ai_sdk && Array.isArray(raw.messages)) {
1429
+ raw.messages.filter((m) => m.role === "assistant").forEach((m) => runChat.push(m));
1430
+ } else if (raw.tool_calls) {
1431
+ runChat.push({ role: "assistant", content: raw.content || null, tool_calls: raw.tool_calls });
1432
+ }
1433
+
1434
+ // Resolve the tool call(s) and push results.
1435
+ const schemaData = await build_schema_data();
1436
+ for (const tc of raw.getToolCalls()) {
1437
+ let resultText;
1438
+ if (tc.tool_name === "get_relation_paths") {
1439
+ const sections = getRelationPathsForPairs(tc.input.pairs || [], schemaData);
1440
+ resultText =
1441
+ sections.join("\n\n") +
1442
+ `\n\nSet the "relation" property to one of the strings listed above for each view_link.`;
1443
+ } else {
1444
+ resultText = `Unknown tool: ${tc.tool_name}`;
1445
+ }
1446
+ await llm_add_message.run("tool_response", resultText, { chat: runChat, tool_call: tc });
1447
+ }
1448
+
1449
+ // Call 2: model has relation paths, now generates the layout JSON.
1450
+ const final = await llm.run(null, { ...opts, chat: runChat });
1451
+ return typeof final === "string" ? final : final?.content ?? "";
1452
+ };
1453
+
954
1454
  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.
1455
+ normalizeLayoutCandidate,
1456
+ run: async (prompt, mode, table, existing_layout, chat) => {
957
1457
  prompt = prompt.trim().replace(/^\[\w+\]:\s*/, "");
958
1458
 
959
1459
  const ctx = await buildContext(mode, table);
@@ -961,28 +1461,46 @@ module.exports = {
961
1461
  const llm = getState().functions.llm_generate;
962
1462
  if (!llm?.run) throw new Error("LLM generator not configured");
963
1463
 
964
- const llmPrompt = buildPromptText(prompt, ctx, schema);
965
- console.log({ schema });
966
- console.log({ llmPrompt: llmPrompt.slice(0, 1000) });
1464
+ const llmConfig = await getLlmConfigurationSafe();
1465
+ const allowResponseFormat = canUseResponseFormat(llmConfig);
1466
+
1467
+ let llmPrompt = buildPromptText(prompt, ctx, schema);
1468
+ if (existing_layout !== undefined && existing_layout !== null) {
1469
+ const layoutJson =
1470
+ typeof existing_layout === "string"
1471
+ ? existing_layout
1472
+ : JSON.stringify(existing_layout);
1473
+ llmPrompt = `${llmPrompt}\n\nExisting layout (reproduce this with the requested changes applied):\n${layoutJson}`;
1474
+ }
1475
+
967
1476
  let responseFormat;
968
1477
  try {
969
1478
  if (!schema || !schema.schema) {
970
1479
  throw new Error("Builder schema unavailable");
971
1480
  }
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
- );
1481
+ if (!allowResponseFormat) {
1482
+ console.warn("LLM backend does not support response_format; skipping");
978
1483
  } else {
979
- responseFormat = {
980
- type: "json_schema",
981
- json_schema: {
982
- name: "saltcorn_layout",
983
- schema: deref,
984
- },
985
- };
1484
+ validateSchemaRefs(schema.schema);
1485
+ let simplified = simplifySchemaForLlm(schema.schema);
1486
+ const optionalCount = countOptionalParams(simplified);
1487
+ console.log({ optionalCount });
1488
+ if (optionalCount > 24) {
1489
+ simplified = trimOptionalProperties(simplified, 24);
1490
+ }
1491
+ if (simplified && schemaHasRef(simplified)) {
1492
+ console.warn(
1493
+ "Builder response schema still contains $ref; skipping response_format"
1494
+ );
1495
+ } else if (simplified) {
1496
+ responseFormat = {
1497
+ type: "json_schema",
1498
+ json_schema: {
1499
+ name: "saltcorn_layout",
1500
+ schema: simplified,
1501
+ },
1502
+ };
1503
+ }
986
1504
  }
987
1505
  } catch (err) {
988
1506
  console.warn("Builder response schema validation failed", err);
@@ -1003,13 +1521,12 @@ module.exports = {
1003
1521
  if (!schema || !schema.schema) {
1004
1522
  throw new Error("Builder schema unavailable");
1005
1523
  }
1006
- console.log("¢¢¢¢", { options });
1007
- rawResponse = await llm.run(llmPrompt, options);
1008
- console.log(JSON.stringify(rawResponse, null, 2));
1524
+ rawResponse = await runWithRelationTools(llm, llmPrompt, options);
1009
1525
  payload = parseJsonPayload(rawResponse);
1010
- console.log(JSON.stringify({ payload }, null, 2));
1011
1526
  const candidate = payload.layout ?? payload;
1012
- return normalizeLayoutCandidate(candidate, ctx);
1527
+ const result = normalizeLayoutCandidate(candidate, ctx);
1528
+ if (!result) throw new Error("Empty layout after normalization");
1529
+ return result;
1013
1530
  } catch (err) {
1014
1531
  let lastError = err;
1015
1532
  try {
@@ -1017,7 +1534,9 @@ module.exports = {
1017
1534
  rawResponse = await llm.run(repairPrompt, options);
1018
1535
  payload = parseJsonPayload(rawResponse);
1019
1536
  const candidate = payload.layout ?? payload;
1020
- return normalizeLayoutCandidate(candidate, ctx);
1537
+ const result = normalizeLayoutCandidate(candidate, ctx);
1538
+ if (!result) throw new Error("Empty layout after normalization");
1539
+ return result;
1021
1540
  } catch (repairErr) {
1022
1541
  lastError = repairErr;
1023
1542
  }
@@ -1027,7 +1546,9 @@ module.exports = {
1027
1546
  rawResponse = await llm.run(reducedPrompt, options);
1028
1547
  payload = parseJsonPayload(rawResponse);
1029
1548
  const candidate = payload.layout ?? payload;
1030
- return normalizeLayoutCandidate(candidate, ctx);
1549
+ const result = normalizeLayoutCandidate(candidate, ctx);
1550
+ if (!result) throw new Error("Empty layout after normalization");
1551
+ return result;
1031
1552
  } catch (reducedErr) {
1032
1553
  lastError = reducedErr;
1033
1554
  }
@@ -1047,6 +1568,7 @@ module.exports = {
1047
1568
  { name: "prompt", type: "String" },
1048
1569
  { name: "mode", type: "String" },
1049
1570
  { name: "table", type: "String" },
1571
+ { name: "existing_layout", type: "JSON" },
1050
1572
  { name: "chat", type: "JSON" },
1051
1573
  ],
1052
1574
  };