@saltcorn/copilot 0.8.0 → 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.
@@ -4,6 +4,8 @@ const Form = require("@saltcorn/data/models/form");
4
4
  const MetaData = require("@saltcorn/data/models/metadata");
5
5
  const View = require("@saltcorn/data/models/view");
6
6
  const Trigger = require("@saltcorn/data/models/trigger");
7
+ const Page = require("@saltcorn/data/models/page");
8
+ const Plugin = require("@saltcorn/data/models/plugin");
7
9
  const { findType } = require("@saltcorn/data/models/discovery");
8
10
  const { save_menu_items } = require("@saltcorn/data/models/config");
9
11
  const db = require("@saltcorn/data/db");
@@ -35,10 +37,15 @@ const {
35
37
  } = require("@saltcorn/markup/tags");
36
38
  const { getState } = require("@saltcorn/data/db/state");
37
39
  const renderLayout = require("@saltcorn/markup/layout");
38
- const { viewname, tool_choice } = require("./common");
40
+ const { viewname } = require("./common");
39
41
  const { runTask, runNextTask } = require("./run_task");
40
42
  const { task_tool } = require("./tools");
41
- const { saltcorn_description, existing_tables_list } = require("./prompts");
43
+ const {
44
+ saltcorn_description,
45
+ existing_tables_list,
46
+ existing_entities_list,
47
+ available_plugins_list,
48
+ } = require("./prompts");
42
49
 
43
50
  const makeTaskList = async (req) => {
44
51
  const rs = await MetaData.find(
@@ -191,6 +198,34 @@ const makeTaskList = async (req) => {
191
198
  )
192
199
  );
193
200
  } else {
201
+ const planning = await MetaData.findOne({
202
+ type: "CopilotConstructMgr",
203
+ name: "planning",
204
+ });
205
+ if (planning) {
206
+ return div(
207
+ { class: "mt-2" },
208
+ p(
209
+ i({ class: "fas fa-spinner fa-spin me-2" }),
210
+ "Planning tasks, please wait..."
211
+ ),
212
+ script(
213
+ domReady(`
214
+ (function() {
215
+ function poll() {
216
+ view_post(${JSON.stringify(
217
+ viewname
218
+ )}, 'planning_status', {}, function(resp) {
219
+ if (resp && !resp.planning) location.reload();
220
+ else setTimeout(poll, 3000);
221
+ });
222
+ }
223
+ setTimeout(poll, 3000);
224
+ })();
225
+ `)
226
+ )
227
+ );
228
+ }
194
229
  return div(
195
230
  { class: "mt-2" },
196
231
  p("No tasks found"),
@@ -205,27 +240,59 @@ const makeTaskList = async (req) => {
205
240
  }
206
241
  };
207
242
 
208
- const gen_tasks = async (table_id, viewname, config, body, { req, res }) => {
209
- const spec = await MetaData.findOne({
210
- type: "CopilotConstructMgr",
211
- name: "spec",
212
- });
213
- if (!spec) throw new Error("Specification not found");
214
- const rs = await MetaData.find({
215
- type: "CopilotConstructMgr",
216
- name: "requirement",
217
- });
218
- if (!rs.length) throw new Error("No requirements found");
219
- const schema = await MetaData.findOne({
243
+ const get_view_config_tool = {
244
+ type: "function",
245
+ function: {
246
+ name: "get_view_config",
247
+ description:
248
+ "Fetch the full configuration of an existing view so you can decide whether to reuse it or create a new one. Call this before planning a task if you are unsure whether an existing view already meets the requirements.",
249
+ parameters: {
250
+ type: "object",
251
+ required: ["name"],
252
+ properties: {
253
+ name: {
254
+ type: "string",
255
+ description: "The exact name of the existing view to inspect.",
256
+ },
257
+ },
258
+ },
259
+ },
260
+ };
261
+
262
+ const doGenTasks = async (spec, rs, schema, userId) => {
263
+ const planningMd = await MetaData.create({
220
264
  type: "CopilotConstructMgr",
221
- name: "schema",
265
+ name: "planning",
266
+ body: {},
222
267
  });
223
- if (!schema) throw new Error("No schema found");
224
- if (!schema.body.implemented) throw new Error("Schema not implemented");
225
- const tables = await Table.find({});
268
+ try {
269
+ const tables = await Table.find({});
270
+ const tableById = Object.fromEntries(tables.map((t) => [t.id, t.name]));
271
+ const views = await View.find({});
272
+ const triggers = await Trigger.find({});
273
+ const pages = await Page.find({});
274
+ const entitiesSection = existing_entities_list({
275
+ views,
276
+ triggers,
277
+ pages,
278
+ tableById,
279
+ });
280
+ const installedPlugins = await Plugin.find({});
281
+ const installedNames = new Set(installedPlugins.map((p) => p.name));
282
+ let storePlugins = [];
283
+ try {
284
+ storePlugins = await Plugin.store_plugins_available();
285
+ } catch (_) {}
286
+ const pluginsSection = available_plugins_list(storePlugins, installedNames);
226
287
 
227
- const answer = await getState().functions.llm_generate.run(
228
- `Generate a plan for building this application:
288
+ const systemPrompt =
289
+ "You are a project manager. The user wants to build an application, and you must analyse their application description";
290
+
291
+ const tools = [get_view_config_tool, task_tool];
292
+ const chat = [];
293
+
294
+ let answer = await getState().functions.llm_generate.run(
295
+ `Generate a plan for building this application:
229
296
 
230
297
  Description: ${spec.body.description}
231
298
  Audience: ${spec.body.audience}
@@ -233,7 +300,7 @@ Core features: ${spec.body.core_features}
233
300
  Out of scope: ${spec.body.out_of_scope}
234
301
  Visual style: ${spec.body.visual_style}
235
302
 
236
- These are the requirements of the application:
303
+ These are the requirements of the application:
237
304
 
238
305
  ${rs.map((r) => `* ${r.body.requirement}`).join("\n")}
239
306
 
@@ -243,55 +310,159 @@ The database has already been built. The following tables are now present in the
243
310
 
244
311
  ${existing_tables_list(tables)}
245
312
 
246
- The plan should outline the continued development of the application on top of this database.
247
- Your plan can add additional tables if needed or adjust the table fields, but normally the tables
248
- should be designed optimally for this application.
313
+ The plan should outline the continued development of the application on top of this database.
314
+ Your plan can add additional tables if needed or adjust the table fields, but normally the tables
315
+ should be designed optimally for this application.
316
+
317
+ ${entitiesSection ? entitiesSection + "\n\n" : ""}${
318
+ pluginsSection ? pluginsSection + "\n\n" : ""
319
+ }The plan should focus on building views, triggers (including workflows) and pages.
249
320
 
250
- The plan should focus on building views, triggers (including workflows) and pages.
321
+ Important trigger planning rules:
322
+ * When a task involves a simple field update (e.g. marking an item complete or incomplete), plan it as a trigger using modify_row — NOT a workflow. Use a workflow only when multiple steps, branching, or looping are genuinely required.
323
+ * If multiple independent single-step actions are needed (e.g. "mark complete" and "mark incomplete"), describe them as separate triggers in the task description — do not describe them as one combined workflow.
324
+ * Do NOT mention "navigate back" or "return to context" in trigger task descriptions. Navigation is configured at the view level (GoBack button), not inside a trigger.
325
+ * If a trigger should be accessible as a button in a view, the task description must name the target view and say to add an action segment with action_name set to the trigger's name. If the view already exists, combine trigger creation and view update in the same task. If the view is created in a later task, that task's description must mention adding the trigger button, and it must depend on the trigger task.
326
+ * Do NOT plan any task that writes to a virtual (read-only) calculated field. Virtual fields are computed automatically and cannot be stored — any trigger or workflow that tries to update them will be refused. If you find yourself planning a trigger to keep a calculated field "current", delete that task — the field already updates itself.
251
327
 
252
328
  Important view planning rules:
253
- * Do NOT plan separate tasks for "create" and "edit" on the same table. In Saltcorn, a single Edit view handles both (no id = create, id present = edit). Plan one task covering both, and write task descriptions that say "create and edit" rather than treating them as two separate items.
329
+ * Each task must create exactly one view. Never put two or more views in the same task. Edit, Show, and List for the same table are always three separate tasks with three separate names, descriptions, and dependencies.
330
+ * Do NOT plan separate tasks for "create" and "edit" on the same table. In Saltcorn, a single Edit view handles both (no id = create, id present = edit). One task, one Edit view, description says "create and edit".
331
+ * Edit, Show, and List views for a table form a natural group and should normally each be planned as their own task. A List without a Show leaves users with no way to inspect details; omit or adjust only when the requirements explicitly say the data is read-only or not editable. When all three are planned, the ordering of tasks must be: Edit and Show first (in either order, they are independent of each other), then List last, because the List depends on both.
332
+ * A List view task must depend on the Edit view task and the Show view task for the same table (if both exist), since its rows link to them. Set depends_on accordingly.
333
+ * When a List view links to a Show view or Edit view, the task description must say: "Add a viewlink column to [view_name] for the current row" — not just "link each row". This wording makes it unambiguous that a viewlink column must be added to the list for each target view.
334
+ * In general, if a view embeds or links to another view, the linked view's task must be listed as a dependency.
254
335
  * When a table has foreign key fields referencing the users table, the task description must explicitly state for each one whether it is an ownership field (automatically set from the logged-in user, omit from the form) or a selector field (the user picks a value, include a selector in the form). Example: "user_id records the owner and is set automatically; shared_with_user_id must have a user selector."
255
336
  * For FK fields that represent a parent context (e.g. trip_id on packing_items), always include the field as a normal selector in the Edit view form. Do NOT say to omit it. Saltcorn automatically pre-fills the selector from the URL query parameter when the view is opened from a parent context, and the user can select it manually when the view is used standalone.
256
- * A List view that includes an edit link (viewlink column pointing to an Edit view) depends on that Edit view already existing. Always plan the Edit view task before the List view task, and set the List view task's depends_on to include the Edit view task name.
257
- * If a List view includes a viewlink to show record details (a Show view), the Show view must be created as a separate task before the List view task, and the List view task must depend on it. A Show view is a distinct viewtemplate — do NOT use an Edit view or a page as a substitute for it. The Show view and Edit view for the same table are independent — neither depends on the other; they can be planned in any order or in parallel.
258
- * In general, if a view embeds or links to another view, the linked view must be created first and listed as a dependency.
337
+ * For every task that creates a view, include the exact view name in the task description. View names must be lowercase, snake_case, unique across all tasks in the plan, and descriptive enough to identify the table and purpose for example 'packing_items_edit' rather than just 'edit'.
259
338
 
260
- * For every task that creates a view, include the exact view name in the task description. View names must be lowercase, snake_case, unique across all tasks in the plan (no two tasks may produce a view with the same name), and descriptive enough to identify the table and purpose — for example 'packing_items_edit' rather than just 'edit'.
339
+ Important user account rules:
340
+ * The platform (Saltcorn) provides a built-in user account system with login, registration, and session management. Do NOT plan any tasks for user registration, login pages, password management, or authentication flows — these are already handled by the platform.
341
+ * User identity is always available as the logged-in user. Ownership fields (FK to users) are set automatically from the session; no custom logic is needed.
342
+ * If a requirement mentions "user accounts", "secure login", "saving data per user", "user-specific data", or "sharing between users", treat it as already satisfied by the platform's built-in user system. Do not generate any task in response to such a requirement.
343
+
344
+ Important plugin rules:
345
+ * If multiple plugins need to be installed, combine them ALL into a single task named "Install plugins" that lists every required plugin name. Do NOT create a separate task per plugin.
261
346
 
262
347
  Important schema/table rules:
263
348
  * The database schema is already fully designed and implemented before task planning begins. ALL tables and fields needed by the application already exist. Do NOT plan any tasks that create tables, add fields, modify fields, or change the schema in any way. If you find yourself writing a task whose output is a table or a field, delete it — that work is already done.
264
349
  * Ownership behaviour (auto-setting a FK-to-users field from the logged-in user) is configured in the Edit view, not in the database. Do not create tasks for it at the schema level.
265
350
  * Do NOT plan tasks to add uniqueness constraints or validation to existing fields — those are already in the schema.
351
+ * Do NOT plan a standalone task for "access control", "row-level security", "permissions", or "roles". These are schema-level concerns already handled during schema design, or view-level concerns handled when building each view. The ownership field and sharing logic are already in the schema — there is nothing extra to configure as a separate task.
266
352
 
267
- Your plan should not include any clarification or questions to the product owner. The
268
- information you have been given so far is all that is available. Every step in the plan
353
+ Your plan should not include any clarification or questions to the product owner. The
354
+ information you have been given so far is all that is available. Every step in the plan
269
355
  should be immediately implementable in Saltcorn. You are writing the steps in the plan
270
356
  for a person who is competent in using saltcorn but has no other business knowledge.
271
357
 
272
358
  Do not include any steps that contain planning, design or review instructions. You are only writing a
273
359
  plan for the engineer building the application. Every step in the plan should have the construction or the modification
274
- of one or several application entity types.
360
+ of one or several application entity types.
275
361
 
276
- Now use the plan_tasks tool to make a plan of tasks for building software application
362
+ Before finalising the plan, you may call get_view_config for any existing view you are unsure about — to inspect its configuration and decide whether a task should reuse it (updating it) or create a new one. Once you have gathered all necessary information, call plan_tasks to submit the complete task list.
277
363
  `,
278
- {
279
- tools: [task_tool],
280
- ...tool_choice("plan_tasks"),
281
- systemPrompt:
282
- "You are a project manager. The user wants to build an application, and you must analyse their application description",
283
- }
284
- );
364
+ {
365
+ tools,
366
+ chat,
367
+ appendToChat: true,
368
+ systemPrompt,
369
+ }
370
+ );
285
371
 
286
- const tc = answer.getToolCalls()[0];
372
+ const MAX_ITERATIONS = 10;
373
+ let iterations = 0;
287
374
 
288
- for (const task of tc.input.tasks)
289
- await MetaData.create({
290
- type: "CopilotConstructMgr",
291
- name: "task",
292
- body: task,
293
- user_id: req.user?.id,
294
- });
375
+ while (iterations++ < MAX_ITERATIONS) {
376
+ if (typeof answer !== "object" || !answer.getToolCalls) break;
377
+ const toolCalls = answer.getToolCalls();
378
+ if (!toolCalls.length) break;
379
+
380
+ const planCall = toolCalls.find((tc) => tc.tool_name === "plan_tasks");
381
+ if (planCall) {
382
+ for (const task of planCall.input.tasks)
383
+ await MetaData.create({
384
+ type: "CopilotConstructMgr",
385
+ name: "task",
386
+ body: task,
387
+ user_id: userId,
388
+ });
389
+ break;
390
+ }
391
+
392
+ const getViewCalls = toolCalls.filter(
393
+ (tc) => tc.tool_name === "get_view_config"
394
+ );
395
+ if (!getViewCalls.length) break;
396
+
397
+ for (const tc of getViewCalls) {
398
+ const viewName = tc.input?.name;
399
+ const view = viewName ? await View.findOne({ name: viewName }) : null;
400
+ const result = view
401
+ ? JSON.stringify(
402
+ {
403
+ name: view.name,
404
+ viewtemplate: view.viewtemplate,
405
+ configuration: view.configuration,
406
+ },
407
+ null,
408
+ 2
409
+ )
410
+ : `No view named "${viewName}" found.`;
411
+
412
+ if (answer.ai_sdk) {
413
+ chat.push({
414
+ role: "tool",
415
+ content: [
416
+ {
417
+ type: "tool-result",
418
+ toolCallId: tc.tool_call_id,
419
+ toolName: "get_view_config",
420
+ result,
421
+ },
422
+ ],
423
+ });
424
+ } else {
425
+ chat.push({
426
+ role: "tool",
427
+ tool_call_id: tc.tool_call_id,
428
+ name: "get_view_config",
429
+ content: result,
430
+ });
431
+ }
432
+ }
433
+
434
+ answer = await getState().functions.llm_generate.run(null, {
435
+ tools,
436
+ chat,
437
+ appendToChat: true,
438
+ systemPrompt,
439
+ });
440
+ }
441
+ } finally {
442
+ await planningMd.delete();
443
+ }
444
+ };
445
+
446
+ const gen_tasks = async (table_id, viewname, config, body, { req, res }) => {
447
+ const spec = await MetaData.findOne({
448
+ type: "CopilotConstructMgr",
449
+ name: "spec",
450
+ });
451
+ if (!spec) throw new Error("Specification not found");
452
+ const rs = await MetaData.find({
453
+ type: "CopilotConstructMgr",
454
+ name: "requirement",
455
+ });
456
+ if (!rs.length) throw new Error("No requirements found");
457
+ const schema = await MetaData.findOne({
458
+ type: "CopilotConstructMgr",
459
+ name: "schema",
460
+ });
461
+ if (!schema) throw new Error("No schema found");
462
+ if (!schema.body.implemented) throw new Error("Schema not implemented");
463
+ doGenTasks(spec, rs, schema, req.user?.id).catch((e) =>
464
+ console.error("gen_tasks error", e)
465
+ );
295
466
  return { json: { reload_page: true } };
296
467
  };
297
468
 
@@ -306,17 +477,28 @@ const del_task = async (table_id, viewname, config, body, { req, res }) => {
306
477
  };
307
478
  const run_task = async (table_id, viewname, config, body, { req, res }) => {
308
479
  const reqUser = req?.user;
309
- setImmediate(async () => {
310
- try {
311
- if (body.id) await runTask(body.id, { user: reqUser, __: req.__ });
312
- else await runNextTask(true);
313
- } catch (e) {
314
- console.error("run_task background error", e);
315
- }
316
- });
480
+ if (body.id)
481
+ runTask(body.id, { user: reqUser, __: req.__ }).catch((e) =>
482
+ console.error("run_task error", e)
483
+ );
484
+ else runNextTask(true).catch((e) => console.error("run_task error", e));
317
485
  return { json: { reload_page: true } };
318
486
  };
319
487
 
488
+ const planning_status = async (
489
+ table_id,
490
+ viewname,
491
+ config,
492
+ body,
493
+ { req, res }
494
+ ) => {
495
+ const planning = await MetaData.findOne({
496
+ type: "CopilotConstructMgr",
497
+ name: "planning",
498
+ });
499
+ return { json: { planning: !!planning } };
500
+ };
501
+
320
502
  const task_status = async (table_id, viewname, config, body, { req, res }) => {
321
503
  const ids = body.ids || [];
322
504
  const tasks = await MetaData.find({
@@ -341,6 +523,7 @@ const start = async (table_id, viewname, config, body, { req, res }) => {
341
523
  name: "settings",
342
524
  body: { running: true },
343
525
  });
526
+ runNextTask().catch((e) => console.error("start error", e));
344
527
  return { json: { reload_page: true } };
345
528
  };
346
529
  const stop = async (table_id, viewname, config, body, { req, res }) => {
@@ -393,6 +576,7 @@ const task_routes = {
393
576
  del_all_tasks,
394
577
  mark_done_task,
395
578
  run_task,
579
+ planning_status,
396
580
  task_status,
397
581
  start,
398
582
  stop,
package/builder-gen.js CHANGED
@@ -6,8 +6,12 @@ const { edit_build_in_actions } = require("@saltcorn/data/viewable_fields");
6
6
  const { buildBuilderSchema } = require("./builder-schema");
7
7
  const { getLlmConfigurationSafe, canUseResponseFormat } = require("./common");
8
8
  const { build_schema_data } = require("@saltcorn/data/plugin-helper");
9
- const { RelationsFinder } = require("@saltcorn/common-code");
10
- const { RelationType } = require("@saltcorn/common-code");
9
+ const {
10
+ GET_RELATION_PATHS_FUNCTION,
11
+ getRelationPaths,
12
+ getRelationPathsForPairs,
13
+ pickBestRelation,
14
+ } = require("./relation-paths");
11
15
 
12
16
  const ACTION_SIZES = ["btn-sm", "btn-lg"];
13
17
 
@@ -40,7 +44,9 @@ Alternatively wrap groups of fields in a card with a descriptive title.`;
40
44
  const EDIT_ACTIONS = `\
41
45
  Use edit fieldviews, group related inputs, and finish with a row of actions: \
42
46
  a Save button (action_name "Save", style "btn btn-primary") and \
43
- a Cancel button (action_name "GoBack", style "btn btn-outline-secondary").`;
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).`;
44
50
 
45
51
  // ── Show-mode guidance blocks ─────────────────────────────────────────────────
46
52
 
@@ -482,7 +488,10 @@ const prettifyActionName = (name) =>
482
488
  // Picks a valid fieldview from the field's available fieldviews only.
483
489
  // Never returns a fieldview that doesn't exist in field.fieldviews
484
490
  const pickFieldview = (field, mode, requestedFieldview = null) => {
485
- const availableViews = field?.fieldviews || [];
491
+ const isEditMode = mode === "edit" || mode === "filter";
492
+ const availableViews = isEditMode
493
+ ? field?.editFieldviews || field?.fieldviews || []
494
+ : field?.fieldviews || [];
486
495
 
487
496
  // If no available fieldviews, return the first one or a safe default
488
497
  if (!availableViews.length) {
@@ -779,18 +788,13 @@ const normalizeSegment = (segment, ctx) => {
779
788
  let relation = clone.relation;
780
789
  if (!relation && ctx.table && ctx.schemaData) {
781
790
  try {
782
- const finder = new RelationsFinder(
783
- ctx.schemaData.tables,
784
- ctx.schemaData.views,
785
- 6
786
- );
787
- const relations = finder.findRelations(
791
+ const relations = getRelationPaths(
788
792
  ctx.table.name,
789
793
  resolvedView,
790
- []
794
+ ctx.schemaData
791
795
  );
792
796
  if (relations.length > 0) {
793
- const picked = pickRelation(relations);
797
+ const picked = pickBestRelation(relations);
794
798
  if (picked) relation = picked.relationString;
795
799
  }
796
800
  } catch (e) {
@@ -842,7 +846,14 @@ const normalizeSegment = (segment, ctx) => {
842
846
  return { ...clone, besides, list_columns: true };
843
847
  }
844
848
  case "list_column": {
845
- const contents = normalizeChild(clone.contents, ctx);
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);
846
857
  return contents
847
858
  ? { ...clone, contents, header_label: clone.header_label || "" }
848
859
  : null;
@@ -1209,21 +1220,6 @@ const buildBracketObject = (node) => {
1209
1220
  return obj;
1210
1221
  };
1211
1222
 
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
1223
 
1228
1224
  const buildContext = async (mode, tableName) => {
1229
1225
  const normalizedMode = (mode || "show").toLowerCase();
@@ -1282,13 +1278,19 @@ const buildContext = async (mode, tableName) => {
1282
1278
  }
1283
1279
  if (rawFields?.then) rawFields = await rawFields;
1284
1280
  const fields = (rawFields || []).map((field) => {
1285
- let fieldviews = Object.keys(field.type?.fieldviews || {});
1281
+ const fvDefs = field.type?.fieldviews || {};
1282
+ let fieldviews = Object.keys(fvDefs);
1286
1283
  // FK fields have type "Key" (a string) so field.type?.fieldviews is empty;
1287
1284
  // ensure select and show are always available for them
1288
1285
  const typeName = field.type?.name || field.type || "";
1289
1286
  if (!fieldviews.length && String(typeName).startsWith("Key")) {
1290
1287
  fieldviews = ["select", "show"];
1291
1288
  }
1289
+ // editFieldviews: only fieldviews where isEdit is not explicitly false
1290
+ const editFieldviews = fieldviews.filter(
1291
+ (fv) => fvDefs[fv]?.isEdit !== false
1292
+ );
1293
+
1292
1294
  const isPkName =
1293
1295
  table.pk_name &&
1294
1296
  typeof field.name === "string" &&
@@ -1313,6 +1315,7 @@ const buildContext = async (mode, tableName) => {
1313
1315
  is_pk_name: !!isPkName,
1314
1316
  default_fieldview: defaultFieldview,
1315
1317
  fieldviews: fieldviews.length ? fieldviews : ["show"],
1318
+ editFieldviews: editFieldviews.length ? editFieldviews : fieldviews,
1316
1319
  attributes: field.attributes || {},
1317
1320
  };
1318
1321
  });
@@ -1397,6 +1400,57 @@ const buildErrorLayout = ({ message, mode, table }) => {
1397
1400
  };
1398
1401
  };
1399
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
+
1400
1454
  module.exports = {
1401
1455
  normalizeLayoutCandidate,
1402
1456
  run: async (prompt, mode, table, existing_layout, chat) => {
@@ -1467,7 +1521,7 @@ module.exports = {
1467
1521
  if (!schema || !schema.schema) {
1468
1522
  throw new Error("Builder schema unavailable");
1469
1523
  }
1470
- rawResponse = await llm.run(llmPrompt, options);
1524
+ rawResponse = await runWithRelationTools(llm, llmPrompt, options);
1471
1525
  payload = parseJsonPayload(rawResponse);
1472
1526
  const candidate = payload.layout ?? payload;
1473
1527
  const result = normalizeLayoutCandidate(candidate, ctx);
package/chat-copilot.js CHANGED
@@ -382,6 +382,7 @@ const actionClasses = [
382
382
  require("./actions/generate-workflow"),
383
383
  require("./actions/generate-tables"),
384
384
  require("./actions/generate-js-action"),
385
+ require("./actions/generate-trigger"),
385
386
  require("./actions/generate-page"),
386
387
  require("./actions/generate-view"),
387
388
  ];
@@ -49,6 +49,7 @@ const get_agent_view = () => {
49
49
  { skill_type: "Generate View" },
50
50
  { skill_type: "Registry editor" },
51
51
  { skill_type: "Javascript Action" },
52
+ { skill_type: "Generate trigger" },
52
53
  ...(typeof Plugin.loadAndSaveNewPlugin === "function"
53
54
  ? [{ skill_type: "Install Plugin" }]
54
55
  : []),
package/index.js CHANGED
@@ -44,7 +44,7 @@ module.exports = {
44
44
  require("./agent-skills/viewgen.js"),
45
45
  require("./agent-skills/registry-editor.js"),
46
46
  require("./agent-skills/js-action.js"),
47
- require("./agent-skills/app-constructor-context.js"),
47
+ require("./agent-skills/triggergen.js"),
48
48
  ...(typeof Plugin.loadAndSaveNewPlugin === "function"
49
49
  ? [require("./agent-skills/install-plugin.js")]
50
50
  : []),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saltcorn/copilot",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "AI assistant for building Saltcorn applications",
5
5
  "main": "index.js",
6
6
  "dependencies": {