@saltcorn/copilot 0.8.1 → 0.8.3

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.
@@ -65,13 +65,17 @@ class GeneratePage {
65
65
  };
66
66
  }
67
67
  static async system_prompt() {
68
- return `If the user asks to generate a page, a web page or a landing page, use the generate_page to generate a page.`;
68
+ return `If the user asks to generate a page, a web page or a landing page, use the generate_page to generate a page.
69
+
70
+ Critical: When generating HTML, your response must be the complete, final HTML document and nothing else. Do not include reasoning, planning notes, explanatory text, TODO comments, markdown code fences, or placeholder content. Everything you write will be stored verbatim and rendered directly to users — there is no review step between your output and the live page.`;
69
71
  }
70
72
  static async follow_on_generate({ name, page_type }) {
71
73
  if (page_type === "Marketing page") {
72
74
  return {
73
75
  prompt:
74
- "Generate the HTML for the web page using the Bootstrap 5 CSS framework.",
76
+ "Generate the HTML for the web page using the Bootstrap 5 CSS framework. " +
77
+ 'Include a toast notification area just before the closing </body> tag: ' +
78
+ '<div id="toasts-area" class="toast-container position-fixed top-0 start-50 p-0" style="z-index:999;" aria-live="polite" aria-atomic="true"></div>',
75
79
  };
76
80
  }
77
81
  const prompt = `Now generate the contents of the ${name} page`;
@@ -156,6 +156,11 @@ class GenerateTables {
156
156
  type: "string",
157
157
  description: "The name of the table",
158
158
  },
159
+ description: {
160
+ type: "string",
161
+ description:
162
+ "A short human-readable description of what this table stores and its role in the application",
163
+ },
159
164
  fields: {
160
165
  type: "array",
161
166
  items: this.field_item_schema(),
@@ -163,6 +168,13 @@ class GenerateTables {
163
168
  },
164
169
  },
165
170
  },
171
+ reused_table_names: {
172
+ type: "array",
173
+ items: { type: "string" },
174
+ description:
175
+ "Names of existing tables that are already complete and require no changes. " +
176
+ "List them here so the caller knows which tables were reused as-is. Do NOT repeat their field definitions in the tables array.",
177
+ },
166
178
  },
167
179
  };
168
180
  }
@@ -202,6 +214,12 @@ class GenerateTables {
202
214
  want to add or update. The system will automatically add new fields and update the settings of
203
215
  existing fields — it will not recreate or drop the table.
204
216
 
217
+ ## Reused tables
218
+
219
+ If an existing table is used by the application as-is (no new fields needed), do NOT repeat it
220
+ in the tables array. Instead, add its name to the reused_table_names array. This tells the
221
+ system to include it in the schema diagram without attempting to modify it.
222
+
205
223
  If a user requests creating a table with certain fields and the table already exists, automatically add any missing fields to that table. Do not ask the user for confirmation or prompt them again—just proceed with the table update.
206
224
 
207
225
  If a table has a ForeignKey field that references another table which does not yet exist in the
@@ -250,6 +268,28 @@ class GenerateTables {
250
268
  The type_and_configuration.data_type for a calculated field should reflect the return type of
251
269
  the expression (e.g. Integer, Float, String, Bool).
252
270
 
271
+ ## Table descriptions
272
+
273
+ Every table you define MUST include a description — a short sentence explaining what
274
+ the table stores and its role in the application. This description is shown in
275
+ subsequent prompts to give the planner context about the schema, so make it
276
+ informative (e.g. "Stores billable time entries logged by lawyers against a project").
277
+
278
+ ## Bulk data import and export
279
+
280
+ Do NOT create tables whose purpose is to trigger a bulk import or export (e.g. a
281
+ table with a File field that a user fills in via an Edit view to start an import).
282
+ Bulk import and export is a UI concern — there are plugins that provide dedicated
283
+ viewtemplates operating directly on the target table, which is a much better
284
+ solution than a workaround table with a file field and an Edit view.
285
+
286
+ A tracking table that records the status and outcome of an automated import or
287
+ export process (e.g. import_jobs) is acceptable, but only if the table is populated
288
+ automatically by the process — not filled in manually by a user. Such tables must
289
+ have a description that clearly says they are auto-populated and must not be edited
290
+ by hand (e.g. "Auto-populated by the import process. Records status and errors for
291
+ each import run. Not editable by users.").
292
+
253
293
  ## Existing tables
254
294
 
255
295
  The database already contains the following tables:
@@ -279,8 +319,10 @@ class GenerateTables {
279
319
  }
280
320
 
281
321
  static async execute({ tables }, req) {
282
- const sctables = this.process_tables(tables);
283
- for (const table of sctables) await Table.create(table.name);
322
+ const existingDbTables = await Table.find({});
323
+ const sctables = this.process_tables(tables, existingDbTables);
324
+ for (const table of sctables)
325
+ await Table.create(table.name, { description: table.description || "" });
284
326
  for (const table of sctables) {
285
327
  for (const field of table.fields) {
286
328
  field.table = Table.findOne({ name: table.name });
@@ -309,10 +351,11 @@ class GenerateTables {
309
351
  const added = [];
310
352
  const updated = [];
311
353
 
354
+ const existingDbTables = await Table.find({});
312
355
  for (const f of sanitized) {
313
356
  const fname = (f?.name || "").toLowerCase();
314
357
  if (!fname) continue;
315
- const processed = this.process_field(f, []);
358
+ const processed = this.process_field(f, [], existingDbTables);
316
359
  const existing = existingFieldMap.get(fname);
317
360
  if (!existing) {
318
361
  processed.table = table;
@@ -346,7 +389,7 @@ class GenerateTables {
346
389
  return { added, updated };
347
390
  }
348
391
 
349
- static process_field(f, allTablesList = []) {
392
+ static process_field(f, allTablesList = [], dbTables = []) {
350
393
  if (f.aggregation) {
351
394
  const { data_type } = f.type_and_configuration || {
352
395
  data_type: "Integer",
@@ -374,6 +417,8 @@ class GenerateTables {
374
417
  f.type_and_configuration || { data_type: "String" };
375
418
  let type = data_type;
376
419
  const scattributes = { ...attributes, importance: f.importance };
420
+ if (!scattributes.min_length) delete scattributes.min_length;
421
+ if (!scattributes.max_length) delete scattributes.max_length;
377
422
  if (data_type === "ForeignKey") {
378
423
  type = `Key to ${reference_table}`;
379
424
  const refTableHere = allTablesList.find(
@@ -391,6 +436,21 @@ class GenerateTables {
391
436
  }
392
437
  } else if (reference_table === "users") {
393
438
  scattributes.summary_field = "email";
439
+ } else {
440
+ const dbTable = dbTables.find((t) => t.name === reference_table);
441
+ if (dbTable) {
442
+ const strFields = (dbTable.fields || []).filter(
443
+ (rf) => rf.type?.name === "String" || rf.type === "String"
444
+ );
445
+ if (strFields.length) {
446
+ const maxImp = strFields.reduce((prev, curr) => {
447
+ const pi = prev?.attributes?.importance ?? 0;
448
+ const ci = curr?.attributes?.importance ?? 0;
449
+ return pi >= ci ? prev : curr;
450
+ });
451
+ scattributes.summary_field = maxImp.name;
452
+ }
453
+ }
394
454
  }
395
455
  }
396
456
  return {
@@ -404,14 +464,17 @@ class GenerateTables {
404
464
  };
405
465
  }
406
466
 
407
- static process_tables(tables) {
467
+ static process_tables(tables, dbTables = []) {
408
468
  return tables.map((table) => {
409
469
  const sanitizedFields = Array.isArray(table.fields)
410
470
  ? table.fields.filter((f) => (f?.name || "").toLowerCase() !== "id")
411
471
  : [];
412
472
  return new Table({
413
473
  name: table.table_name,
414
- fields: sanitizedFields.map((f) => this.process_field(f, tables)),
474
+ description: table.description || "",
475
+ fields: sanitizedFields.map((f) =>
476
+ this.process_field(f, tables, dbTables)
477
+ ),
415
478
  });
416
479
  });
417
480
  }
@@ -451,6 +514,7 @@ const buildMermaidMarkup = (tables) => {
451
514
  };
452
515
 
453
516
  module.exports = GenerateTables;
517
+ module.exports.buildMermaidMarkup = buildMermaidMarkup;
454
518
 
455
519
  /* todo
456
520
 
@@ -272,6 +272,16 @@ class GenerateWorkflow {
272
272
  IMPORTANT — keep row expressions simple; use dedicated steps for data fetching:
273
273
  Row expressions (e.g. in modify_row) should be simple references to values already in the context — not inline queries or complex logic. If the values you need are not already in context, add a dedicated step before the row expression step to fetch or compute them and write the results into the context. Choose the step type that fits the job: TableQuery to query a table, run_js_code for custom computation, or any other appropriate step. Each step should do one clear thing; the row expression then just picks the relevant context values.
274
274
 
275
+ CRITICAL — modify_row without a triggering row requires where="Database" + select_table + query (all three):
276
+ A basic modify_row step updates the row that triggered the workflow. If the workflow is NOT triggered by a specific table row (e.g. triggered by a button with no table, or running inside a ForLoop), there is no triggering row and table is undefined — this crashes with "Cannot read properties of undefined (reading 'tryUpdateRow')".
277
+ To update arbitrary rows in a workflow, the step configuration MUST include all three of:
278
+ - where: "Database"
279
+ - select_table: the name of the table to update (string)
280
+ - query: a JS expression (evaluated against the workflow context) returning a where-clause object that identifies the row(s) to update — e.g. {id: bh_id}
281
+ If any of these three is missing, the action falls through to the triggering-row path and crashes when table is undefined.
282
+ Example modify_row step configuration for updating a billable_hours row inside a ForLoop where bh_id is in context:
283
+ { "step_type": "modify_row", "where": "Database", "select_table": "billable_hours", "query": "{id: bh_id}", "row_expr": "{invoiced: true}" }
284
+
275
285
  CRITICAL — every workflow must form a single connected chain from the first step to the last:
276
286
  - Every step except the very last one MUST have a next_step that names another step in the workflow.
277
287
  - The very last step must have next_step omitted or set to an empty string to terminate the workflow.
@@ -282,6 +292,20 @@ class GenerateWorkflow {
282
292
  steps can read values from and write values to. This context is a state that is persisted on disk for each workflow
283
293
  run.
284
294
 
295
+ Important: every {{}} interpolation in any workflow step (email body, email subject, filename, prompt template,
296
+ etc.) must reference a variable that already exists in the workflow context at the point the step runs.
297
+ Do NOT use fallback expressions such as {{invoice_date || new Date().toISOString()}} — if the variable is not
298
+ defined, the interpolation engine throws before the || fallback can execute. For example, a send_email step
299
+ using {{invoice_date}} will fail with "invoice_date is not defined" if no prior step put invoice_date in context.
300
+ If a value might not be in context at that point, retrieve or compute it in an earlier step and store it under
301
+ a known key.
302
+
303
+ Important: when a workflow is triggered from a Show view action button, the trigger must have its table set to
304
+ the view's table. Saltcorn then automatically passes the full row as the initial workflow context — all field
305
+ values are available by their field names (e.g. id, name, contact_email). Do NOT attempt to pass row data
306
+ through a state property on the actions array — it is silently ignored. If the trigger has no table set, the
307
+ workflow starts with an empty context and all field references will throw "is not defined".
308
+
285
309
  Each step can have a next_step key which is the name of the next step, or a JavaScript expression which evaluates
286
310
  to the name of the next step based on the context. In the evaluation of the next step, each value in the context is
287
311
  in scope and can be addressed directly. Identifiers for the step names are also in scope, the name of the next step
@@ -307,16 +331,43 @@ class GenerateWorkflow {
307
331
  Most of them are are explained by their parameter descriptions. Here are some additional information for some
308
332
  step types:
309
333
 
334
+ TableQuery: stores the query results in the context under the name given in the query_variable configuration field.
335
+ This name is chosen by you — pick a short descriptive name (e.g. "billable_hours", "lawyer_rows").
336
+ Every subsequent step that reads those results — whether a run_js_code step or another TableQuery's query_object
337
+ expression — must use this exact variable name. Mismatched names cause "X is not defined" errors at runtime.
338
+
339
+ Important: an insert step writes ONLY the new row's id into the context (e.g. new_invoice_id). It does NOT
340
+ write any other field values from the inserted row. If a later step needs fields from that row (e.g. invoice_date,
341
+ total_amount, contact_email), add a TableQuery step immediately after the insert to fetch the row by id and make
342
+ its fields available in context. For example, query {id: new_invoice_id} on the invoices table with
343
+ query_variable "invoice_row", then reference invoice_row.invoice_date, invoice_row.total_amount etc. in
344
+ subsequent steps. Never assume that insert fields are in context — only the id is guaranteed.
345
+
310
346
  run_js_code: if the step_type is "run_js_code" then the step object should include the JavaScript code to be executed in the "code"
311
347
  key. You can use await in the code if you need to run asynchronous code. The values in the context are directly in scope and can be accessed using their name. In addition, the variable
312
348
  "context" is also in scope and can be used to address the context as a whole. To write values to the context, return an
313
349
  object. The following Saltcorn models are already available in scope without any require: Table, Row, Field, User, View, Trigger, Page, File — use them directly.
314
350
  When you need to require other modules, always use a plain require call, e.g. const moment = require('moment');
315
- NEVER use the pattern const X = X || require(...) — this causes a ReferenceError because const variables cannot be referenced before initialization. The values in this object will be written into the current context. If a value already exists in the context
351
+ NEVER use the pattern const X = X || require(...) — this causes a ReferenceError because const variables cannot be referenced before initialization.
352
+ The values in this object will be written into the current context. If a value already exists in the context
316
353
  it will be overwritten. For example, If the context contains values x and y which are numbers and you would like to push
317
- the value "sum" which is the sum of x and y, then use this as the code: return {sum: x+y}. You cannot set the next step in the
354
+ the value "sum" which is the sum of x and y, then use this as the code: return {sum: x+y}. You cannot set the next step in the
318
355
  return object or by returning a string from a run_js_code step, this will not work. To set the next step from a code action, always use the next_step property of the step object.
319
- This expression for the next step can depend on value pushed to the context (by the return object in the code) as these values are in scope.
356
+ This expression for the next step can depend on value pushed to the context (by the return object in the code) as these values are in scope.
357
+
358
+ SetContext: sets one or more variables in the workflow context. The configuration must include a ctx_values key
359
+ whose value is a JavaScript object expression — it MUST be wrapped in curly braces.
360
+ Example: {running_total: 0} initialises a counter, {invoice_date: new Date().toISOString().slice(0,10)} sets a date.
361
+ Do NOT write bare key-value pairs such as running_total: 0 — without the braces the expression is invalid and
362
+ throws "running_total is not defined" when a later step tries to read it.
363
+ The object expression can reference existing context variables: {total: subtotal + tax}.
364
+
365
+ Early termination pattern: to stop a workflow when a condition is not met (e.g. no rows found),
366
+ use a run_js_code step that writes a boolean flag to the context (e.g. return {has_rows: billable_hours.length > 0}),
367
+ then set that step's next_step to a conditional expression (e.g. has_rows ? "next_real_step" : "abort_step"),
368
+ and add a TerminateWorkflow step named "abort_step".
369
+ Do NOT try to terminate from inside run_js_code by returning null, throwing, or omitting a return — those do not stop the workflow.
370
+ The conditional must be in the step's next_step field, not inside the code.
320
371
 
321
372
  ForLoop: ForLoop steps loop over an array which is specified by the array_expression JavaScript expression. Execution of the workflow steps is temporarily diverted to another set
322
373
  of steps, starting from the step specified by the loop_body_inital_step value, and runs until it encounters a
@@ -52,22 +52,59 @@ class GeneratePageSkill {
52
52
  }
53
53
  get userActions() {
54
54
  return {
55
- async build_copilot_page_gen({ user, name, title, description, html }) {
56
- const file = await File.from_contents(
57
- `${name}.html`,
58
- "text/html",
59
- html,
60
- user?.id,
61
- 100,
62
- );
63
-
64
- await Page.create({
65
- name,
66
- title,
67
- description,
68
- min_role: 100,
69
- layout: { html_file: file.path_to_serve },
70
- });
55
+ async build_copilot_page_gen({
56
+ user,
57
+ name,
58
+ title,
59
+ description,
60
+ html,
61
+ min_role = 100,
62
+ }) {
63
+ const existingPage = Page.findOne({ name });
64
+ if (existingPage) {
65
+ // Overwrite existing file if the page already has one, otherwise create new
66
+ let filePath;
67
+ if (existingPage.layout?.html_file) {
68
+ const existingFile = await File.findOne(
69
+ existingPage.layout.html_file
70
+ );
71
+ if (existingFile) {
72
+ await existingFile.overwrite_contents(html);
73
+ filePath = existingFile.path_to_serve;
74
+ }
75
+ }
76
+ if (!filePath) {
77
+ const file = await File.from_contents(
78
+ `${name}.html`,
79
+ "text/html",
80
+ html,
81
+ user?.id,
82
+ min_role
83
+ );
84
+ filePath = file.path_to_serve;
85
+ }
86
+ await Page.update(existingPage.id, {
87
+ title,
88
+ description,
89
+ min_role,
90
+ layout: { html_file: filePath },
91
+ });
92
+ } else {
93
+ const file = await File.from_contents(
94
+ `${name}.html`,
95
+ "text/html",
96
+ html,
97
+ user?.id,
98
+ min_role
99
+ );
100
+ await Page.create({
101
+ name,
102
+ title,
103
+ description,
104
+ min_role,
105
+ layout: { html_file: file.path_to_serve },
106
+ });
107
+ }
71
108
  setTimeout(() => getState().refresh_pages(), 200);
72
109
  return {
73
110
  notify: `Page saved: <a target="_blank" href="/page/${name}">${name}</a>`,
@@ -97,61 +134,130 @@ class GeneratePageSkill {
97
134
  } else return "Metadata recieved";
98
135
  },
99
136
  postProcess: async ({ tool_call, generate, req }) => {
100
- const str = await generate(
101
- `Now generate the contents of the ${tool_call.input.name} HTML page. If I asked you to embed a view,
102
- use the <embed-view> self-closing tag to do so, setting the view name in the viewname attribute. For example,
137
+ const version_tag = db.connectObj.version_tag;
138
+ const asset = (name) => `/static_assets/${version_tag}/${name}`;
139
+ const { name, title, description, min_role, page_type } =
140
+ tool_call.input;
141
+
142
+ if (page_type === "Layout page") {
143
+ const str = await generate(
144
+ `Generate the Saltcorn layout JSON for the ${name} page. ` +
145
+ `Your response must be a single valid JSON object — no explanatory text, no markdown fences, no reasoning. ` +
146
+ `\n\nThe layout is a tree of segments. The top-level object is:\n` +
147
+ ` {"above": [...segments...]} — stack items vertically\n` +
148
+ `or:\n` +
149
+ ` {"besides": [...segments...], "widths": [6,6]} — place side by side (widths must sum to 12)\n` +
150
+ `\nAvailable segment types:\n` +
151
+ `- Embed a view (receives page URL state, e.g. id): {"type":"view","view":"viewname","state":"shared"}\n` +
152
+ ` Use state "shared" whenever the embedded view needs variables from the page URL (such as id for a Show view).\n` +
153
+ ` To pass only specific variables, add extra_state_fml: {"type":"view","view":"viewname","state":"shared","extra_state_fml":"{id: id}"}\n` +
154
+ `- HTML block: {"type":"blank","isHTML":true,"contents":"<p>html</p>"}\n` +
155
+ `- Text block: {"type":"blank","contents":"plain text"}\n` +
156
+ `- Container/wrapper: {"type":"container","customClass":"p-3","htmlElement":"div","contents":{segment}}\n` +
157
+ `- Vertical stack: {"above":[{segment},{segment}]}\n` +
158
+ `- Horizontal split: {"besides":[{segment},{segment}],"widths":[8,4]}\n`
159
+ );
160
+ const jsonStr = str.includes("```json")
161
+ ? str.split("```json")[1].split("```")[0]
162
+ : str.includes("```")
163
+ ? str.split("```")[1].split("```")[0]
164
+ : str;
165
+ let layout;
166
+ try {
167
+ layout = JSON.parse(jsonStr.trim());
168
+ } catch (e) {
169
+ return {
170
+ stop: true,
171
+ add_response: `Error parsing layout JSON for ${name}: ${e.message}`,
172
+ };
173
+ }
174
+ if (this.yoloMode) {
175
+ const existingPage = await Page.findOne({ name });
176
+ if (existingPage) {
177
+ await Page.update(existingPage.id, {
178
+ title,
179
+ description,
180
+ min_role: min_role ?? 100,
181
+ layout,
182
+ });
183
+ } else {
184
+ await Page.create({
185
+ name,
186
+ title,
187
+ description,
188
+ min_role: min_role ?? 100,
189
+ layout,
190
+ });
191
+ }
192
+ setTimeout(() => getState().refresh_pages(), 200);
193
+ return {
194
+ stop: true,
195
+ add_response: `Page ${name} created.`,
196
+ };
197
+ }
198
+ return {
199
+ stop: true,
200
+ add_response: `Page ${name} layout generated (preview not available for layout pages).`,
201
+ };
202
+ } else {
203
+ const str = await generate(
204
+ `Generate the complete HTML for the ${name} page. Your response must be the raw HTML document only — no explanatory text before or after, no markdown code fences, no reasoning, no placeholder comments. The output is saved and rendered directly to users without any review step, so anything you write outside valid HTML will be visible on the page.
205
+
206
+ If I asked you to embed a view, use the <embed-view> self-closing tag to do so, setting the view name in the viewname attribute. For example,
103
207
  to embed the view LeadForm inside a div, write: <div><embed-view viewname="LeadForm"></div>
104
-
208
+
105
209
  If you need to include the standard bootstrap CSS and javascript files, they are available as:
106
210
 
107
211
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
108
212
 
109
- and
110
- <script src="/static_assets/js/jquery-3.6.0.min.js"></script>
213
+ and
214
+ <script src="${asset("jquery-3.6.0.min.js")}"></script>
111
215
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
112
216
 
113
217
  If you are embedding views with <embed-view>, you should also embed the following script sources at the end of the <body> tag to make sure the content inside those views works:
114
218
 
115
- <script src="/static_assets/js/saltcorn-common.js"></script>
116
- <script src="/static_assets/js/saltcorn.js">
117
- `,
118
- );
119
- const html = str.includes("```html")
120
- ? str.split("```html")[1].split("```")[0]
121
- : str;
122
-
123
- if (this.yoloMode) {
124
- await this.userActions.build_copilot_page_gen({
125
- user: req?.user,
126
- name: tool_call.input.name,
127
- title: tool_call.input.title,
128
- description: tool_call.input.description,
129
- html,
130
- });
219
+ <script src="${asset("saltcorn-common.js")}"></script>
220
+ <script src="${asset("saltcorn.js")}"></script>
221
+ `
222
+ );
223
+ const html = str.includes("```html")
224
+ ? str.split("```html")[1].split("```")[0]
225
+ : str;
226
+
227
+ if (this.yoloMode) {
228
+ await this.userActions.build_copilot_page_gen({
229
+ user: req?.user,
230
+ name,
231
+ title,
232
+ description,
233
+ min_role: min_role ?? 100,
234
+ html,
235
+ });
236
+ return {
237
+ stop: true,
238
+ add_response: `Page ${name} created.`,
239
+ };
240
+ }
131
241
  return {
132
242
  stop: true,
133
- add_response: `Page ${tool_call.input.name} created.`,
134
- };
135
- }
136
- return {
137
- stop: true,
138
- add_response: iframe({
139
- srcdoc: text_attr(html),
140
- width: 500,
141
- height: 800,
142
- }),
143
- add_system_prompt: `If the user asks you to regenerate the page,
243
+ add_response: iframe({
244
+ srcdoc: text_attr(html),
245
+ width: 500,
246
+ height: 800,
247
+ }),
248
+ add_system_prompt: `If the user asks you to regenerate the page,
144
249
  you must run the generate_page tool again. After running this tool
145
250
  you will be prompted to generate the html again. You should repeat
146
251
  the html from the previous answer except for the changes the user
147
252
  is requesting.`,
148
- add_user_action: {
149
- name: "build_copilot_page_gen",
150
- type: "button",
151
- label: "Save page "+tool_call.input.name,
152
- input: { html },
153
- },
154
- };
253
+ add_user_action: {
254
+ name: "build_copilot_page_gen",
255
+ type: "button",
256
+ label: "Save page " + name,
257
+ input: { html },
258
+ },
259
+ };
260
+ }
155
261
  },
156
262
 
157
263
  /*renderToolCall({ phrase }, { req }) {
@@ -163,7 +269,7 @@ class GeneratePageSkill {
163
269
  response.includes("Unable to provide HTML for this page")
164
270
  )
165
271
  return response;
166
- if (
272
+ if (
167
273
  typeof response === "string" &&
168
274
  response.includes("The HTML code for the ")
169
275
  )
@@ -194,11 +300,17 @@ class GeneratePageSkill {
194
300
  "A longer description that is not visible but appears in the page header and is indexed by search engines",
195
301
  type: "string",
196
302
  },
303
+ min_role: {
304
+ description:
305
+ "Minimum role required to access this page. Use 1 for admin-only, 40 for staff and above, 80 for logged-in users and above, 100 for public. Set this based on the intended audience described in the task.",
306
+ type: "integer",
307
+ enum: [1, 40, 80, 100],
308
+ },
197
309
  page_type: {
198
310
  description:
199
- "The type of page to generate: a Marketing page if for promotional purposes, such as a landing page or a brouchure, with an appealing design. An Application page is simpler and an integrated part of the application",
311
+ "The type of page to generate. Use 'Layout page' for any page that is part of the application (dashboards, print pages, data pages, pages that embed views) — this creates a proper Saltcorn layout page. Use 'Marketing page' for public-facing promotional pages such as landing pages or brochures. Use 'Application page' only for standalone HTML pages that are part of the app but do not embed Saltcorn views.",
200
312
  type: "string",
201
- enum: ["Marketing page", "Application page"],
313
+ enum: ["Layout page", "Marketing page", "Application page"],
202
314
  },
203
315
  },
204
316
  },
@@ -6,6 +6,7 @@ const Field = require("@saltcorn/data/models/field");
6
6
  const User = require("@saltcorn/data/models/user");
7
7
  const Plugin = require("@saltcorn/data/models/plugin");
8
8
  const Role = require("@saltcorn/data/models/role");
9
+ const File = require("@saltcorn/data/models/file");
9
10
  const WorkflowStep = require("@saltcorn/data/models/workflow_step");
10
11
  const { getState } = require("@saltcorn/data/db/state");
11
12
 
@@ -167,6 +168,7 @@ with both the entity type and name, and the new JSON definition as a string as a
167
168
  "trigger",
168
169
  "plugin",
169
170
  "system-configuration-value",
171
+ "file",
170
172
  "type",
171
173
  ],
172
174
  },
@@ -317,6 +319,11 @@ with both the entity type and name, and the new JSON definition as a string as a
317
319
  }
318
320
  return v;
319
321
  }
322
+ case "file": {
323
+ const file = await File.findOne({ filename: input.entity_name });
324
+ if (!file) return `file not found`;
325
+ return { filename: file.filename, min_role_read: file.min_role_read };
326
+ }
320
327
  case "plugin": {
321
328
  const plugin = await Plugin.findOne({ name: input.entity_name });
322
329
  if (!plugin) return `plugin not found`;
@@ -369,6 +376,18 @@ with both the entity type and name, and the new JSON definition as a string as a
369
376
  const trigger = Trigger.findOne({ name: input.entity_name });
370
377
  if (!trigger) return `trigger not found`;
371
378
  const value = trigger.toJson;
379
+ // For workflow triggers, attach all steps so the agent can see their configurations
380
+ if (trigger.action === "Workflow") {
381
+ const steps = await WorkflowStep.find({ trigger_id: trigger.id });
382
+ value.steps = steps.map((s) => ({
383
+ id: s.id,
384
+ name: s.name,
385
+ action_name: s.action_name,
386
+ initial_step: s.initial_step,
387
+ next_step: s.next_step,
388
+ configuration: s.configuration,
389
+ }));
390
+ }
372
391
  const schema = {
373
392
  name: { type: "string", description: "trigger name" },
374
393
  action: {
@@ -473,6 +492,7 @@ with both the entity type and name, and the new JSON definition as a string as a
473
492
  "system-configuration-value",
474
493
  "module-configuration",
475
494
  "role",
495
+ "file",
476
496
  ],
477
497
  },
478
498
  entity_name: {
@@ -718,8 +738,9 @@ with both the entity type and name, and the new JSON definition as a string as a
718
738
  table || table_name,
719
739
  )?.id;
720
740
 
721
- // Pre-check action before saving
722
- if (tsNoTableName.action) {
741
+ // Pre-check action before saving (skip built-ins handled outside actions registry)
742
+ const builtinActions = new Set(["Workflow", "Multi-step action"]);
743
+ if (tsNoTableName.action && !builtinActions.has(tsNoTableName.action)) {
723
744
  const action = getState().actions[tsNoTableName.action];
724
745
  if (!action)
725
746
  return `Action '${tsNoTableName.action}' not found`;
@@ -897,6 +918,13 @@ with both the entity type and name, and the new JSON definition as a string as a
897
918
  await getState().refresh_roles();
898
919
  return "Role created";
899
920
  }
921
+
922
+ case "file": {
923
+ const file = await File.findOne({ filename: input.entity_name });
924
+ if (!file) return `file not found: ${input.entity_name}`;
925
+ await file.set_role(entityValue.min_role_read);
926
+ return "Done";
927
+ }
900
928
  }
901
929
  return "Done";
902
930
  } catch (e) {
@@ -33,11 +33,7 @@ class AnyActionSkill {
33
33
  `If the task requires several independent single-step actions (e.g. "mark complete" and "mark incomplete"), call this tool once per action — do NOT bundle them into one workflow.\n\n` +
34
34
  `**Navigation is a view concern:** If a task description says "return the user to X" or "navigate back", ` +
35
35
  `do NOT add a navigation step inside the trigger. Triggers only handle data operations. ` +
36
- `Navigation (GoBack) is configured on the button in the list view, not inside the trigger itself.\n\n` +
37
- `**Verification/confirmation emails after user registration:** Create a trigger with event "Insert" on the "users" table, ` +
38
- `action_type "run_js_code". Do NOT use the send_email action — it will not work for verification. ` +
39
- `Set the action_config "code" field to exactly:\n` +
40
- `const { send_verification_email } = require("@saltcorn/data/models/email");\nawait send_verification_email(row, req);`
36
+ `Navigation (GoBack) is configured on the button in the list view, not inside the trigger itself.`
41
37
  );
42
38
  }
43
39